diff --git a/.gitignore b/.gitignore index 82c5087..84188f3 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,7 @@ tmall-board-speed-check.json .remote-access.backup-*/ .workbuddy/ **/node_modules + +# Local verification artifacts +/outputs/ +.git-status.txt diff --git a/README.md b/README.md index e9b1da4..c51db3c 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 1acda76..a649155 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,8 @@ "syntax": "node scripts/check-syntax.js", "test": "node --test test/*.test.js", "check": "npm run syntax && npm test", - "alert:check": "node src/services/alertChecker.js" + "alert:check": "node src/services/alertChecker.js", + "product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js" }, "dependencies": { "@aws-sdk/client-s3": "3.975.0", diff --git a/backend/scripts/seed_product_tag_config_from_xlsx.js b/backend/scripts/seed_product_tag_config_from_xlsx.js new file mode 100644 index 0000000..40e9e80 --- /dev/null +++ b/backend/scripts/seed_product_tag_config_from_xlsx.js @@ -0,0 +1,404 @@ +#!/usr/bin/env node +'use strict'; + +/** + * 从《公司SABC标签配置.xlsx》导入商品标签配置到系统库。 + * + * 用法: + * node scripts/seed_product_tag_config_from_xlsx.js --file=/path/to/公司SABC标签配置.xlsx + * node scripts/seed_product_tag_config_from_xlsx.js --file=... --print-sql # 只打印 SQL,不写库 + * node scripts/seed_product_tag_config_from_xlsx.js --file=... --dry-run # 只解析并打印摘要 + * + * 解析口径(2026-09-11 评审确认): + * - 区块① A3:F150 → 规模标签阈值(148 行,37 品类 × 4 档) + * - 区块② H4:L28 → SABC 映射(25 行,5 规模标签 × 5 利润标签) + * - 阈值统一四舍五入保留 2 位小数(决策 5,2026-09-11 修订);品类为 '-' 或空的行忽略 + * + * 前置:三张表需已存在(DBA 执行 sql/20260911_create_product_tag_config_system_mysql.sql, + * 或由 ensureProductTagConfigSchema() 自动建表)。 + */ + +const path = require('path'); +const fs = require('fs'); +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const XLSX = require('xlsx'); +const mysql = require('mysql2/promise'); +const database = require('../src/config/database'); + +const SCALE_TAG_ORDER = ['爆款', '热卖款', '动销款', '低动销款']; +const IGNORED_CATEGORIES = ['-', '']; +const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级']; + +// 配置表聚合类目 → DB 类目 的兜底映射(按用户 2026-09-11 推测确认)。 +// xlsx 里「其他沙发类」「办公家具」本身是聚合类目,DB 中没有同名完整类目; +// 其子品类在 DB 中独立存在,且未被别的配置名占用(懒人沙发/文具边柜已单独配置,故排除)。 +// 用「家族规则」而非逐个枚举叶子名,可自动覆盖 DB 中实际存在的全部子类目,避免名字写错产生幽灵行。 +// family: 'sofa' → 所有短名含「沙发」的类目,排除 exclude 列表 +// family: 'office'→ 所有「商业/办公家具 -」前缀的类目,排除 exclude 列表 +const MANUAL_CATEGORY_MAP = { + '其他沙发类': { family: 'sofa', exclude: ['懒人沙发'] }, + '办公家具': { family: 'office', exclude: ['文具边柜'] }, +}; + +// 区块① / 区块② 在 sheet 中的位置(1-based,与 Excel 界面一致) +const BLOCK1 = { startRow: 3, endRow: 150, cols: { category: 0, pMin: 1, pMax: 2, sMin: 3, sMax: 4, scaleTag: 5 } }; +const BLOCK2 = { startRow: 4, endRow: 28, cols: { scaleTag: 7, profitLevel: 8, profitText: 9, sabc: 11 } }; + +// 去掉「住宅家具 - 」这类一级类目前缀,只留叶子品类名 +function stripCategoryPrefix(value) { + return String(value).replace(/^.*?\s+-\s+/, '').trim(); +} + +function parseArgs(argv) { + const args = { file: '', printSql: false, dryRun: false }; + argv.slice(2).forEach((item) => { + if (item.startsWith('--file=')) args.file = item.slice('--file='.length); + else if (item === '--print-sql') args.printSql = true; + else if (item === '--dry-run') args.dryRun = true; + }); + return args; +} + +function text(value) { + if (value === null || value === undefined) return ''; + return String(value).trim(); +} + +// 决策 5(2026-09-11 11:12 修订):所有阈值统一「四舍五入保留 2 位小数」。 +// - 0.5 → 0.5(合法业务阈值,不变;不会变成 1) +// - 溢出值 7916.34311148132 → 7916.34;2063.13142776357 → 2063.13 +// - 整数 12000 → 12000 +function normalizeThreshold(value) { + if (value === null || value === undefined || value === '') return null; + const num = Number(value); + if (!Number.isFinite(num)) return null; + return Math.round(num * 100) / 100; +} + +function sqlText(value) { + if (value === null || value === undefined) return 'NULL'; + return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +function sqlNumber(value) { + return value === null || value === undefined ? 'NULL' : String(value); +} + +function readWorkbook(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`文件不存在:${filePath}`); + } + const workbook = XLSX.readFile(filePath); + const sheetName = workbook.SheetNames[0]; + const sheet = workbook.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true, defval: null }); + return { sheetName, rows }; +} + +function parseScaleThresholds(rows) { + const list = []; + const skipped = []; + for (let rowNo = BLOCK1.startRow; rowNo <= BLOCK1.endRow; rowNo += 1) { + const row = rows[rowNo - 1] || []; + const category = text(row[BLOCK1.cols.category]); + if (IGNORED_CATEGORIES.includes(category)) { + if (category) skipped.push({ rowNo, reason: `品类为 "${category}",按决策 6 忽略` }); + continue; + } + const scaleTag = text(row[BLOCK1.cols.scaleTag]); + if (!SCALE_TAG_ORDER.includes(scaleTag)) { + skipped.push({ rowNo, reason: `规模标签 "${scaleTag}" 不在枚举内` }); + continue; + } + const raw = { + performanceMin: row[BLOCK1.cols.pMin], + performanceMax: row[BLOCK1.cols.pMax], + salesMin: row[BLOCK1.cols.sMin], + salesMax: row[BLOCK1.cols.sMax], + }; + const rounded = { + performanceMin: normalizeThreshold(raw.performanceMin), + performanceMax: normalizeThreshold(raw.performanceMax), + salesMin: normalizeThreshold(raw.salesMin), + salesMax: normalizeThreshold(raw.salesMax), + }; + // 只记录「确实被取整」的溢出值,便于核对(决策 5) + Object.entries(raw).forEach(([key, value]) => { + const before = Number(value); + if (!Number.isFinite(before)) return; + if (rounded[key] !== before) { + skipped.push({ rowNo, reason: `字段 ${key} 原值 ${value} 疑似计算溢出,已取整为 ${rounded[key]}(决策 5)`, rounding: true }); + } + }); + list.push({ + // 配置表里是短名(如「电脑桌/书桌」),稍后会展开成 jst_sku.category 的完整值 + displayName: category, + scaleTag, + ...rounded, + priority: SCALE_TAG_ORDER.indexOf(scaleTag) + 1, + sort: SCALE_TAG_ORDER.indexOf(scaleTag) + 1, + }); + } + return { list, skipped }; +} + +// 读取 jst_sku.category 的全部真实取值(企划库与业务库同表,优先用可达的企划库) +async function loadDbCategories() { + const connection = await mysql.createConnection({ + host: process.env.PLANNING_PUBLIC_DB_HOST, + port: Number(process.env.PLANNING_PUBLIC_DB_PORT), + user: process.env.PLANNING_PUBLIC_DB_USER, + password: process.env.PLANNING_PUBLIC_DB_PASSWORD, + database: process.env.PLANNING_PUBLIC_DB_NAME, + connectTimeout: 20000, + }); + const [rows] = await connection.query( + `SELECT TRIM(category) AS category, COUNT(*) AS skuCount + FROM jst_sku + WHERE category IS NOT NULL AND TRIM(category) <> '' + GROUP BY TRIM(category)` + ); + await connection.end(); + return rows.map((row) => ({ + full: row.category, + short: stripCategoryPrefix(row.category), + skuCount: Number(row.skuCount), + })); +} + +/** + * 把「配置短名」展开为「数据库完整品类名」。 + * 一个短名可能对应多个 DB 品类(如「电脑桌/书桌」→ 住宅家具-电脑桌 + 住宅家具-书桌), + * 展开后每个 DB 品类生成一行,displayName 保留原短名用于展示分组。 + */ +function expandCategories(configRows, dbCategories) { + const matched = []; + const ambiguous = []; + const missing = []; + + configRows.forEach((row) => { + const shortName = row.displayName; + + // 1) 手工映射优先:聚合类目 → DB 类目(家族规则,自动展开子类目) + if (MANUAL_CATEGORY_MAP[shortName]) { + const rule = MANUAL_CATEGORY_MAP[shortName]; + let resolved = []; + if (Array.isArray(rule)) { + // 兼容:显式 DB 短名列表 + rule.forEach((targetShort) => { + const hits = dbCategories.filter((item) => item.short === targetShort); + if (hits.length) hits.forEach((hit) => resolved.push(hit)); + else console.warn(` ⚠️ 手工映射目标「${targetShort}」在 jst_sku 中找不到对应类目,已跳过(请核对 MANUAL_CATEGORY_MAP)`); + }); + } else if (rule && rule.family === 'sofa') { + resolved = dbCategories.filter((item) => item.short.includes('沙发') && !rule.exclude.includes(item.short)); + } else if (rule && rule.family === 'office') { + resolved = dbCategories.filter((item) => /^商业\/办公家具\s*-\s*/.test(item.full) && !rule.exclude.includes(item.short)); + } + if (!resolved.length) { + missing.push(row); + } else { + resolved.forEach((hit) => matched.push({ ...row, category: hit.full })); + } + return; + } + + // 2) 剥离前缀后同名 + let hits = dbCategories.filter((item) => item.short === shortName); + // 3) 包含匹配兜底 + if (!hits.length) { + hits = dbCategories.filter((item) => ( + item.short.includes(shortName) || shortName.includes(item.short) + )); + } + + if (!hits.length) { + missing.push(row); + } else { + hits.forEach((hit) => matched.push({ ...row, category: hit.full })); + if (hits.length > 1) ambiguous.push({ shortName, targets: hits }); + } + }); + + return { matched, ambiguous, missing }; +} + +function parseSabcMappings(rows) { + const list = []; + // H 列(规模标签)是纵向合并单元格(H4:H8、H9:H13 …), + // XLSX.utils.sheet_to_json 只在合并区左上角单元格保留值,其余为 null, + // 因此需要向下填充,否则只能解析出 5 条而不是 25 条。 + let carriedScaleTag = ''; + for (let rowNo = BLOCK2.startRow; rowNo <= BLOCK2.endRow; rowNo += 1) { + const row = rows[rowNo - 1] || []; + const ownScaleTag = text(row[BLOCK2.cols.scaleTag]); + if (ownScaleTag) carriedScaleTag = ownScaleTag; + + const scaleTag = carriedScaleTag; + const profitLevel = text(row[BLOCK2.cols.profitLevel]); + const sabcTag = text(row[BLOCK2.cols.sabc]); + if (!scaleTag || !profitLevel || !sabcTag) continue; + list.push({ scaleTag, profitLevel, sabcTag, sort: list.length + 1 }); + } + return list; +} + +function buildScaleInsertSql(list) { + if (!list.length) return '-- (无规模阈值数据)'; + const values = list.map((row) => ` (${sqlText(row.category)}, ${sqlText(row.displayName)}, ${sqlText(row.scaleTag)}, ` + + `${sqlNumber(row.performanceMin)}, ${sqlNumber(row.performanceMax)}, ` + + `${sqlNumber(row.salesMin)}, ${sqlNumber(row.salesMax)}, ${row.priority}, ${row.sort}, 1)`); + return `INSERT INTO product_scale_thresholds\n` + + ' (category, display_name, scale_tag, performance_min, performance_max, sales_min, sales_max, priority, sort, enabled)\nVALUES\n' + + `${values.join(',\n')}\nON DUPLICATE KEY UPDATE\n` + + ' display_name = VALUES(display_name),\n' + + ' performance_min = VALUES(performance_min), performance_max = VALUES(performance_max),\n' + + ' sales_min = VALUES(sales_min), sales_max = VALUES(sales_max),\n' + + ' priority = VALUES(priority), sort = VALUES(sort), enabled = VALUES(enabled);'; +} + +const PROFIT_LEVEL_RATE = { + 一级: 0.18, + 二级: 0.16, + 三级: 0.12, + 四级: 0.08, + 五级: 0.08, +}; + +async function writeScaleThresholds(list) { + await database.local.query('DELETE FROM product_scale_thresholds'); + if (!list.length) return 0; + const values = []; + const placeholders = list.map((row) => { + values.push( + row.category, row.displayName, row.scaleTag, + row.performanceMin, row.performanceMax, row.salesMin, row.salesMax, + row.priority, row.sort, 1 + ); + return '(?,?,?,?,?,?,?,?,?,?)'; + }); + await database.local.query( + `INSERT INTO product_scale_thresholds + (category, display_name, scale_tag, performance_min, performance_max, + sales_min, sales_max, priority, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return list.length; +} + +async function writeProfitLevels() { + await database.local.query('DELETE FROM product_profit_levels'); + const values = []; + const placeholders = PROFIT_LEVELS.map((levelCode, index) => { + const rate = PROFIT_LEVEL_RATE[levelCode]; + values.push(levelCode, rate, `营业利润率≤${rate.toFixed(2)}`, index + 1, 1); + return '(?,?,?,?,?)'; + }); + await database.local.query( + `INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return PROFIT_LEVELS.length; +} + +async function writeSabcMappings(list) { + await database.local.query('DELETE FROM product_sabc_mappings'); + if (!list.length) return 0; + const values = []; + const placeholders = list.map((row) => { + values.push(row.scaleTag, row.profitLevel, row.sabcTag, row.sort, 1); + return '(?,?,?,?,?)'; + }); + await database.local.query( + `INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return list.length; +} + +async function main() { + const args = parseArgs(process.argv); + if (!args.file) { + console.error('缺少 --file= 参数'); + 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 new file mode 100644 index 0000000..38a6bbd --- /dev/null +++ b/backend/scripts/sync_jian_sun_product_analysis_tables.js @@ -0,0 +1,218 @@ +require('dotenv').config(); + +const sql = require('mssql'); + +const batchSize = Math.max(100, Number.parseInt(process.env.PRODUCT_ANALYSIS_SYNC_BATCH_SIZE || '5000', 10)); +const targetSchema = /^[A-Za-z_][A-Za-z0-9_]*$/.test(process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA || '') + ? process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA + : 'app'; + +const sourceConfig = { + server: process.env.JIAN_SUN_DB_HOST, + port: Number(process.env.JIAN_SUN_DB_PORT || 1433), + database: process.env.JIAN_SUN_DB_NAME || 'JianSun', + user: process.env.JIAN_SUN_DB_USER, + password: process.env.JIAN_SUN_DB_PASSWORD, + options: { encrypt: false, trustServerCertificate: true }, + connectionTimeout: 120000, + requestTimeout: 120000, + pool: { max: 4, min: 0, idleTimeoutMillis: 120000 }, +}; + +const targetConfig = { + server: process.env.CLOUD_DB_PUBLIC_HOST, + port: Number(process.env.CLOUD_DB_PUBLIC_PORT || 1433), + database: process.env.CLOUD_DB_PUBLIC_NAME || 'JSReportReplica', + user: process.env.CLOUD_DB_PUBLIC_USER, + password: process.env.CLOUD_DB_PUBLIC_PASSWORD, + options: { + encrypt: String(process.env.CLOUD_DB_PUBLIC_SSL || '').toLowerCase() === 'true', + trustServerCertificate: true, + }, + connectionTimeout: 120000, + requestTimeout: 120000, + pool: { max: 4, min: 0, idleTimeoutMillis: 120000 }, +}; + +const tables = [ + { + name: 'pdd_DataActivityRecords', + createColumns: ` + [id] int NOT NULL, + [shopName] varchar(40) NULL, + [createTime] varchar(20) NULL, + [business_type_str] varchar(100) NULL, + [activity_name] varchar(100) NULL, + [activity_id] int NULL, + [goods_name] varchar(100) NULL, + [goods_id] bigint NULL, + [hd_thumb_url] varchar(100) NULL, + [enroll_time ] bigint NULL, + [min_activity_sku_price] decimal(38, 2) NULL, + [max_activity_sku_price] decimal(38, 2) NULL, + [activity_goods_start_time] bigint NULL, + [activity_goods_end_time] bigint NULL, + [status] int NULL`, + columns: [ + ['id', sql.Int, false], + ['shopName', sql.VarChar(40), true], + ['createTime', sql.VarChar(20), true], + ['business_type_str', sql.VarChar(100), true], + ['activity_name', sql.VarChar(100), true], + ['activity_id', sql.Int, true], + ['goods_name', sql.VarChar(100), true], + ['goods_id', sql.BigInt, true], + ['hd_thumb_url', sql.VarChar(100), true], + ['enroll_time ', sql.BigInt, true], + ['min_activity_sku_price', sql.Decimal(38, 2), true], + ['max_activity_sku_price', sql.Decimal(38, 2), true], + ['activity_goods_start_time', sql.BigInt, true], + ['activity_goods_end_time', sql.BigInt, true], + ['status', sql.Int, true], + ], + indexColumn: 'goods_id', + }, + { + name: 'pdd_GoodsQualities', + createColumns: ` + [id] int NOT NULL, + [avgDescRevScr1m] float NULL, + [qltyRfndOrdrCntRto1m] float NULL, + [ptHelpPzRate30d] float NULL, + [midRto30d] float NULL, + [goodsId] bigint NULL, + [displayMallScoreRk] int NULL, + [rankAvgDescRevScr1mStage] int NULL, + [rankAvgDescRevScr1mStplStage] int NULL, + [rankQltyRfndOrdrCntRto1mStage] int NULL, + [rankQltyRfndOrdrCntRto1mStplStage] int NULL, + [rankPtHelpPzRate30dStage] int NULL, + [rankPtHelpPzRate30dStplStage] int NULL, + [rankComscoreStage ] int NULL, + [rankComscoreStplStage] int NULL, + [goodsScoreRkStage] int NULL, + [goodsScoreRkStplStage] int NULL, + [createTime] varchar(20) NULL, + [shopName] varchar(40) NULL`, + columns: [ + ['id', sql.Int, false], + ['avgDescRevScr1m', sql.Float, true], + ['qltyRfndOrdrCntRto1m', sql.Float, true], + ['ptHelpPzRate30d', sql.Float, true], + ['midRto30d', sql.Float, true], + ['goodsId', sql.BigInt, true], + ['displayMallScoreRk', sql.Int, true], + ['rankAvgDescRevScr1mStage', sql.Int, true], + ['rankAvgDescRevScr1mStplStage', sql.Int, true], + ['rankQltyRfndOrdrCntRto1mStage', sql.Int, true], + ['rankQltyRfndOrdrCntRto1mStplStage', sql.Int, true], + ['rankPtHelpPzRate30dStage', sql.Int, true], + ['rankPtHelpPzRate30dStplStage', sql.Int, true], + ['rankComscoreStage ', sql.Int, true], + ['rankComscoreStplStage', sql.Int, true], + ['goodsScoreRkStage', sql.Int, true], + ['goodsScoreRkStplStage', sql.Int, true], + ['createTime', sql.VarChar(20), true], + ['shopName', sql.VarChar(40), true], + ], + indexColumn: 'goodsId', + }, +]; + +function qualified(schema, tableName) { + return `[${schema}].[${tableName}]`; +} + +function assertConfig(config, label) { + const missing = ['server', 'database', 'user', 'password'].filter((key) => !config[key]); + if (missing.length) throw new Error(`${label} missing configuration: ${missing.join(', ')}`); +} + +async function ensureTargetTable(pool, definition) { + const tableName = qualified(targetSchema, definition.name); + const pkName = `PK_${definition.name}`; + const lookupIndex = `IX_${definition.name}_${definition.indexColumn}_createTime`; + await pool.request().batch(` + IF OBJECT_ID(N'${tableName}', N'U') IS NULL + BEGIN + CREATE TABLE ${tableName} ( + ${definition.createColumns}, + CONSTRAINT [${pkName}] PRIMARY KEY CLUSTERED ([id]) + ); + END; + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'${tableName}') AND name = N'${lookupIndex}') + BEGIN + CREATE INDEX [${lookupIndex}] ON ${tableName} ([${definition.indexColumn}], [createTime]); + END; + `); +} + +function createBulkTable(definition) { + const table = new sql.Table(definition.name); + table.schema = targetSchema; + table.create = false; + definition.columns.forEach(([name, type, nullable]) => table.columns.add(name, type, { nullable })); + return table; +} + +async function syncTable(sourcePool, targetPool, definition) { + await ensureTargetTable(targetPool, definition); + const sourceName = qualified('dbo', definition.name); + const targetName = qualified(targetSchema, definition.name); + const columnSql = definition.columns.map(([name]) => `[${name}]`).join(', '); + const targetState = await targetPool.request().query(`SELECT COALESCE(MAX([id]), 0) AS maxId FROM ${targetName}`); + let lastId = Number(targetState.recordset[0].maxId || 0); + let copied = 0; + + while (true) { + const result = await sourcePool.request() + .input('lastId', sql.Int, lastId) + .query(`SELECT TOP (${batchSize}) ${columnSql} FROM ${sourceName} WHERE [id] > @lastId ORDER BY [id]`); + const rows = result.recordset || []; + if (!rows.length) break; + + const bulkTable = createBulkTable(definition); + rows.forEach((row) => bulkTable.rows.add(...definition.columns.map(([name]) => row[name]))); + await targetPool.request().bulk(bulkTable); + lastId = Number(rows[rows.length - 1].id); + copied += rows.length; + console.log(`[product-analysis-sync] ${definition.name}: copied ${copied}, target max id ${lastId}`); + } + + const [sourceStats, targetStats] = await Promise.all([ + sourcePool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${sourceName}`), + targetPool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${targetName}`), + ]); + return { copied, source: sourceStats.recordset[0], target: targetStats.recordset[0] }; +} + +async function syncProductAnalysisReplica() { + assertConfig(sourceConfig, 'JianSun source'); + assertConfig(targetConfig, 'JSReportReplica target'); + const [sourcePool, targetPool] = await Promise.all([ + new sql.ConnectionPool(sourceConfig).connect(), + new sql.ConnectionPool(targetConfig).connect(), + ]); + + try { + const summary = {}; + for (const definition of tables) { + summary[definition.name] = await syncTable(sourcePool, targetPool, definition); + } + console.log(JSON.stringify(summary, null, 2)); + return summary; + } finally { + await Promise.all([sourcePool.close(), targetPool.close()]); + } +} + +if (require.main === module) { + syncProductAnalysisReplica().catch((error) => { + console.error('[product-analysis-sync] failed:', error.stack || error.message); + process.exitCode = 1; + }); +} + +module.exports = { + syncProductAnalysisReplica, +}; 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 new file mode 100644 index 0000000..ec24a51 --- /dev/null +++ b/backend/sql/20260911_create_product_tag_rules_and_dept_owners.sql @@ -0,0 +1,55 @@ +-- 商品监控配套:部门商品标签规则 + 部门SKU负责人配置 +-- 目标库:系统库 shujuku3_system(MySQL / MariaDB) +-- 创建日期:2026-09-11 +-- +-- 说明: +-- 1) product_tag_rules 部门标签 G-S/G-A/G-B/G-C 的阈值规则(tag_scope='department') +-- 与公司 SABC(tag_scope='company')共用一张表,靠 tag_scope 区分。 +-- ⚠️ 公司 SABC 目前由「商品标签配置」页的三张表承载,本表仅启用 department 分支。 +-- 2) department_sku_owners 部门 × SKU/SPU → 负责人 的手工维护映射 +-- +-- 本文件与 services/localSchema.js 的 ensureProductMonitoringSchema() 等价, +-- 服务启动时会自动 CREATE TABLE IF NOT EXISTS,本文件供 DBA 手工建表使用。 + +CREATE TABLE IF NOT EXISTS product_tag_rules ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department', + tag_level VARCHAR(16) NOT NULL COMMENT '部门标签:G-S/G-A/G-B/G-C(company 分支预留 S/A/B/C/D)', + metric_key VARCHAR(64) NOT NULL COMMENT '阈值指标:sales_qty_7d(近7天销售件数)/ sales_amount_7d(近7天销售额)', + threshold_min DECIMAL(18,4) NULL COMMENT '区间起点(含)', + threshold_max DECIMAL(18,4) NULL COMMENT '区间终点(不含)', + priority INT NOT NULL DEFAULT 0 COMMENT '同 scope 内升序命中即停', + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key), + KEY idx_scope_enabled (tag_scope, enabled), + KEY idx_metric (metric_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'; + +-- 初始数据:部门标签四档,默认口径为近 7 天销售件数(sales_qty_7d) +-- ⚠️ 阈值为占位默认值,需在「系统设置 → 部门商品标签配置」中按业务口径调整后生效。 +INSERT INTO product_tag_rules (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled) +VALUES + ('department', 'G-S', 'sales_qty_7d', 500, NULL, 1, 1), + ('department', 'G-A', 'sales_qty_7d', 200, 500, 2, 1), + ('department', 'G-B', 'sales_qty_7d', 50, 200, 3, 1), + ('department', 'G-C', 'sales_qty_7d', 0, 50, 4, 1) +ON DUPLICATE KEY UPDATE tag_scope = tag_scope; + + +CREATE TABLE IF NOT EXISTS department_sku_owners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + department_id BIGINT UNSIGNED NOT NULL COMMENT '关联 departments.id', + owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser', + user_id BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id', + spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人;填值则为该 SPU 的负责人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id), + KEY idx_dept (department_id), + KEY idx_spu (spu), + KEY idx_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'; diff --git a/backend/src/api/dashboard.js b/backend/src/api/dashboard.js deleted file mode 100644 index 1b4b215..0000000 --- a/backend/src/api/dashboard.js +++ /dev/null @@ -1,364 +0,0 @@ -'use strict'; - -const express = require('express'); -const jianSunDb = require('../config/jianSunDatabase'); - -const router = express.Router(); -const DASHBOARD_PERIOD_DAYS = Object.freeze({ day: 1, week: 7, month: 30 }); -const DASHBOARD_CACHE_TTL_MS = 60 * 1000; -const DASHBOARD_QUERY_TIMEOUT_MS = Math.max(Number(process.env.DASHBOARD_QUERY_TIMEOUT_MS) || 180000, 30000); -const dashboardCache = new Map(); -const dashboardInFlight = new Map(); - -function toNumber(value) { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; -} - -function normalizeDate(value) { - const text = String(value || '').trim(); - return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''; -} - -function toDateString(value) { - if (!value) return ''; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10); -} - -function addDays(value, offset) { - const date = new Date(`${value}T00:00:00Z`); - date.setUTCDate(date.getUTCDate() + offset); - return date.toISOString().slice(0, 10); -} - -function inclusiveDayCount(startDate, endDate) { - const start = new Date(`${startDate}T00:00:00Z`); - const end = new Date(`${endDate}T00:00:00Z`); - return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1; -} - -function percentChange(current, previous) { - if (!Number.isFinite(current) || !Number.isFinite(previous) || previous === 0) return null; - return ((current - previous) * 100) / Math.abs(previous); -} - -function sumRows(rows) { - return rows.reduce((result, row) => { - result.payAmount += toNumber(row.payAmount); - result.buyerCount += toNumber(row.buyerCount); - result.visitorCount += toNumber(row.visitorCount); - result.goodsPv += toNumber(row.goodsPv); - result.payGoodsQty += toNumber(row.payGoodsQty); - result.payOrderCount += toNumber(row.payOrderCount); - result.favoriteCount += toNumber(row.favoriteCount); - result.impressionCount += toNumber(row.impressionCount); - return result; - }, { - payAmount: 0, - buyerCount: 0, - visitorCount: 0, - goodsPv: 0, - payGoodsQty: 0, - payOrderCount: 0, - favoriteCount: 0, - impressionCount: 0, - }); -} - -async function runDashboardQuery(name, sqlText, params = {}) { - try { - return { - name, - rows: await jianSunDb.query(sqlText, params, { requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }), - error: null, - }; - } catch (error) { - console.warn(`[dashboard] ${name} query failed:`, error.message); - return { name, rows: [], error }; - } -} - -function buildDashboardMetrics(current, previous) { - const conversionRate = current.visitorCount > 0 - ? (current.buyerCount * 100) / current.visitorCount - : null; - const previousConversionRate = previous.visitorCount > 0 - ? (previous.buyerCount * 100) / previous.visitorCount - : null; - return [ - { key: 'payAmount', label: '支付金额', value: current.payAmount, previous: previous.payAmount, change: percentChange(current.payAmount, previous.payAmount), format: 'money' }, - { key: 'payOrderCount', label: '支付订单数', value: Math.round(current.payOrderCount), previous: Math.round(previous.payOrderCount), change: percentChange(current.payOrderCount, previous.payOrderCount), format: 'integer' }, - { key: 'visitorCount', label: '访客数', value: Math.round(current.visitorCount), previous: Math.round(previous.visitorCount), change: percentChange(current.visitorCount, previous.visitorCount), format: 'integer' }, - { key: 'buyerCount', label: '支付买家数', value: Math.round(current.buyerCount), previous: Math.round(previous.buyerCount), change: percentChange(current.buyerCount, previous.buyerCount), format: 'integer' }, - { key: 'conversionRate', label: '支付转化率', value: conversionRate, previous: previousConversionRate, change: percentChange(conversionRate, previousConversionRate), format: 'percent' }, - { key: 'payGoodsQty', label: '支付件数', value: Math.round(current.payGoodsQty), previous: Math.round(previous.payGoodsQty), change: percentChange(current.payGoodsQty, previous.payGoodsQty), format: 'integer' }, - { key: 'favoriteCount', label: '收藏人数', value: Math.round(current.favoriteCount), previous: Math.round(previous.favoriteCount), change: percentChange(current.favoriteCount, previous.favoriteCount), format: 'integer' }, - { key: 'goodsPv', label: '商品浏览量', value: Math.round(current.goodsPv), previous: Math.round(previous.goodsPv), change: percentChange(current.goodsPv, previous.goodsPv), format: 'integer' }, - ]; -} - -function buildEmptyDashboardMetrics() { - return [ - { key: 'payAmount', label: '支付金额', value: null, previous: null, change: null, format: 'money' }, - { key: 'payOrderCount', label: '支付订单数', value: null, previous: null, change: null, format: 'integer' }, - { key: 'visitorCount', label: '访客数', value: null, previous: null, change: null, format: 'integer' }, - { key: 'buyerCount', label: '支付买家数', value: null, previous: null, change: null, format: 'integer' }, - { key: 'conversionRate', label: '支付转化率', value: null, previous: null, change: null, format: 'percent' }, - { key: 'payGoodsQty', label: '支付件数', value: null, previous: null, change: null, format: 'integer' }, - { key: 'favoriteCount', label: '收藏人数', value: null, previous: null, change: null, format: 'integer' }, - { key: 'goodsPv', label: '商品浏览量', value: null, previous: null, change: null, format: 'integer' }, - ]; -} - -router.get('/meta', async (_req, res) => { - try { - const rows = await jianSunDb.query( - `SELECT DISTINCT shopName - FROM dbo.pdd_DataGoods - WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' - ORDER BY shopName`, - {}, - { requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS } - ); - res.json({ success: true, data: { platform: 'pdd', shops: rows.map((row) => row.shopName) } }); - } catch (error) { - res.status(error.statusCode || 500).json({ success: false, error: error.message }); - } -}); - -router.get('/summary', async (req, res) => { - let settleDashboard = null; - let activeCacheKey = ''; - try { - const requestedStartDate = normalizeDate(req.query.startDate); - const requestedEndDate = normalizeDate(req.query.endDate); - const hasCustomDate = Boolean(requestedStartDate || requestedEndDate); - if (hasCustomDate && (!requestedStartDate || !requestedEndDate)) { - return res.status(400).json({ success: false, error: '自定义日期需要同时选择开始日期和结束日期' }); - } - const customDays = hasCustomDate ? inclusiveDayCount(requestedStartDate, requestedEndDate) : 0; - if (hasCustomDate && customDays < 1) { - return res.status(400).json({ success: false, error: '开始日期不能晚于结束日期' }); - } - if (hasCustomDate && customDays > 90) { - return res.status(400).json({ success: false, error: '自定义日期范围不能超过90天' }); - } - const requestedPeriod = hasCustomDate - ? 'custom' - : DASHBOARD_PERIOD_DAYS[req.query.period] ? req.query.period : 'week'; - const shopName = String(req.query.shopName || '').trim(); - const cacheKey = JSON.stringify({ - period: requestedPeriod, - startDate: requestedStartDate, - endDate: requestedEndDate, - shopName, - }); - activeCacheKey = cacheKey; - const cached = dashboardCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return res.json({ success: true, data: cached.data }); - } - const pending = dashboardInFlight.get(cacheKey); - if (pending) { - const result = await pending; - if (result.error) throw result.error; - return res.json({ success: true, data: result.data }); - } - - dashboardInFlight.set(cacheKey, new Promise((resolve) => { - settleDashboard = resolve; - })); - - const period = requestedPeriod; - const days = hasCustomDate ? customDays : DASHBOARD_PERIOD_DAYS[period]; - const [salesResult, serviceResult, starResult] = await Promise.all([ - runDashboardQuery( - 'sales', - `WITH source_bounds AS ( - SELECT TOP (1) LEFT(createTime, 10) AS maxDate - FROM dbo.pdd_DataGoods - WHERE createTime >= '2000-01-01' - AND createTime < '2100-01-01' - AND (@shopName = '' OR shopName = @shopName) - ORDER BY createTime DESC - ), - daily_coverage AS ( - SELECT LEFT(g.createTime, 10) AS statDate, COUNT(DISTINCT g.shopName) AS shopCount - FROM dbo.pdd_DataGoods g - CROSS JOIN source_bounds b - WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, -45, TRY_CONVERT(date, b.maxDate)), 23) - AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, b.maxDate)), 23) - AND g.shopName IS NOT NULL - AND LTRIM(RTRIM(g.shopName)) <> '' - AND (@shopName = '' OR g.shopName = @shopName) - GROUP BY LEFT(g.createTime, 10) - ), - coverage_target AS ( - SELECT MAX(shopCount) AS maxShopCount FROM daily_coverage - ), - available_end AS ( - SELECT MAX(c.statDate) AS latestDate - FROM daily_coverage c - CROSS JOIN coverage_target t - WHERE c.shopCount >= t.maxShopCount * 0.8 - ), - selected_end AS ( - SELECT CASE - WHEN @requestedEndDate <> '' AND @requestedEndDate < latestDate THEN @requestedEndDate - ELSE latestDate - END AS latestDate - FROM available_end - ) - SELECT - LEFT(g.createTime, 10) AS statDate, - e.latestDate AS latestDate, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrAmt), 0)) AS payAmount, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrUsrCnt), 0)) AS buyerCount, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsUv), 0)) AS visitorCount, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsPv), 0)) AS goodsPv, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrGoodsQty), 0)) AS payGoodsQty, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrCnt), 0)) AS payOrderCount, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsFavCnt), 0)) AS favoriteCount, - SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.imprUsrCnt), 0)) AS impressionCount - FROM dbo.pdd_DataGoods g - CROSS JOIN selected_end e - WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, 1 - @historyDays, TRY_CONVERT(date, e.latestDate)), 23) - AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, e.latestDate)), 23) - AND (@shopName = '' OR g.shopName = @shopName) - GROUP BY LEFT(g.createTime, 10), e.latestDate - ORDER BY LEFT(g.createTime, 10)`, - { historyDays: days * 2, requestedEndDate: requestedEndDate || '', shopName } - ), - runDashboardQuery( - 'service-score', - `WITH ranked AS ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber - FROM dbo.pdd_MallServiceScore - WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' - AND createTime LIKE '[12][0-9][0-9][0-9]-%' - AND (@shopName = '' OR shopName = @shopName) - ) - SELECT - MAX(LEFT(createTime, 10)) AS createTime, - COUNT(*) AS shopCount, - AVG(TRY_CONVERT(decimal(18, 6), cstmrServScore)) AS cstmrServScore, - AVG(TRY_CONVERT(decimal(18, 6), spLmScore)) AS spLmScore, - AVG(TRY_CONVERT(decimal(18, 6), fhLmScore)) AS fhLmScore, - AVG(TRY_CONVERT(decimal(18, 6), wlLmScore)) AS wlLmScore - FROM ranked - WHERE rowNumber = 1`, - { shopName } - ), - runDashboardQuery( - 'shop-stars', - `WITH ranked AS ( - SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber - FROM dbo.pdd_ShopStars - WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' - AND createTime LIKE '[12][0-9][0-9][0-9]-%' - AND (@shopName = '' OR shopName = @shopName) - ) - SELECT - MAX(LEFT(createTime, 10)) AS createTime, - COUNT(*) AS shopCount, - AVG(TRY_CONVERT(decimal(18, 6), mallStarTomms)) AS mallStarTomms, - AVG(TRY_CONVERT(decimal(18, 6), gmvRk)) AS gmvRk, - AVG(TRY_CONVERT(decimal(18, 6), scoreRk)) AS scoreRk - FROM ranked - WHERE rowNumber = 1`, - { shopName } - ), - ]); - const salesRows = salesResult.rows; - const serviceRows = serviceResult.rows; - const starRows = starResult.rows; - const warnings = [salesResult, serviceResult, starResult] - .filter((result) => result.error) - .map((result) => result.name); - const normalizedSales = salesRows - .map((row) => ({ - date: String(row.statDate || ''), - payAmount: toNumber(row.payAmount), - buyerCount: toNumber(row.buyerCount), - visitorCount: toNumber(row.visitorCount), - goodsPv: toNumber(row.goodsPv), - payGoodsQty: toNumber(row.payGoodsQty), - payOrderCount: toNumber(row.payOrderCount), - favoriteCount: toNumber(row.favoriteCount), - impressionCount: toNumber(row.impressionCount), - })) - .filter((row) => normalizeDate(row.date)); - const latestDate = String(salesRows[0]?.latestDate || normalizedSales.at(-1)?.date || ''); - const currentStart = hasCustomDate ? requestedStartDate : latestDate ? addDays(latestDate, 1 - days) : ''; - const previousEnd = currentStart ? addDays(currentStart, -1) : ''; - const previousStart = previousEnd ? addDays(previousEnd, 1 - days) : ''; - const currentRows = normalizedSales.filter((row) => row.date >= currentStart && row.date <= latestDate); - const previousRows = normalizedSales.filter((row) => row.date >= previousStart && row.date <= previousEnd); - const current = sumRows(currentRows); - const previous = sumRows(previousRows); - const metrics = latestDate && !salesResult.error - ? buildDashboardMetrics(current, previous) - : buildEmptyDashboardMetrics(); - - const spend = null; - const gmv = null; - const roi = null; - - const responseData = { - platform: 'pdd', - source: 'jiansun_database', - scope: shopName ? 'single-shop' : 'all-shops', - shopName: shopName || '全部店铺', - warnings, - period, - days, - latestDate, - startDate: currentStart, - previousStart, - previousEnd, - metrics, - trend: currentRows, - promotion: { - spend, - gmv, - roi, - clickCount: null, - orderCount: null, - }, - refunds: { - amount: null, - rate: null, - products: [], - }, - store: { - serviceDate: toDateString(serviceRows[0]?.createTime), - starDate: toDateString(starRows[0]?.createTime), - shopCount: Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount)), - star: starRows[0]?.mallStarTomms == null ? null : toNumber(starRows[0].mallStarTomms), - peerDescription: shopName - ? shopName - : `已汇总 ${Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount))} 家店铺`, - activityRank: starRows[0]?.gmvRk == null ? null : toNumber(starRows[0].gmvRk), - scoreRank: starRows[0]?.scoreRk == null ? null : toNumber(starRows[0].scoreRk), - scores: [ - { key: 'experience', label: '服务体验', value: serviceRows[0]?.cstmrServScore == null ? null : toNumber(serviceRows[0].cstmrServScore) }, - { key: 'product', label: '商品服务', value: serviceRows[0]?.spLmScore == null ? null : toNumber(serviceRows[0].spLmScore) }, - { key: 'logistics', label: '物流服务', value: serviceRows[0]?.wlLmScore == null ? null : toNumber(serviceRows[0].wlLmScore) }, - { key: 'shipping', label: '发货服务', value: serviceRows[0]?.fhLmScore == null ? null : toNumber(serviceRows[0].fhLmScore) }, - ], - }, - }; - dashboardCache.set(cacheKey, { data: responseData, expiresAt: Date.now() + DASHBOARD_CACHE_TTL_MS }); - settleDashboard({ data: responseData }); - dashboardInFlight.delete(cacheKey); - res.json({ success: true, data: responseData }); - } catch (error) { - settleDashboard?.({ error }); - if (activeCacheKey) dashboardInFlight.delete(activeCacheKey); - res.status(error.statusCode || 500).json({ success: false, error: error.message }); - } -}); - - -module.exports = router; diff --git a/backend/src/api/marketingEvents.js b/backend/src/api/marketingEvents.js new file mode 100644 index 0000000..f7b2355 --- /dev/null +++ b/backend/src/api/marketingEvents.js @@ -0,0 +1,268 @@ +const express = require('express'); +const jianSunDb = require('../config/jianSunDatabase'); + +const router = express.Router(); + +const MAX_PAGE_SIZE = 100; + +function safeInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function normalizeSortField(field) { + const allowed = new Set([ + 'id', 'shopName', 'createTime', 'activity_name', 'activity_id', + 'goods_name', 'goods_id', 'min_activity_sku_price', 'max_activity_sku_price', + 'activity_goods_start_time', 'activity_goods_end_time', 'status', + ]); + return allowed.has(field) ? field : 'activity_goods_start_time'; +} + +function normalizeSortOrder(order) { + return String(order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; +} + +function escapeColumn(field) { + const allowed = new Set([ + 'id', 'shopName', 'createTime', 'business_type_str', 'activity_name', 'activity_id', + 'goods_name', 'goods_id', 'hd_thumb_url', 'enroll_time', 'min_activity_sku_price', + 'max_activity_sku_price', 'activity_goods_start_time', 'activity_goods_end_time', 'status', + ]); + if (!allowed.has(field)) return '[id]'; + return field === 'enroll_time' ? '[enroll_time]' : `[${field}]`; +} + +async function getSecondLatestDate() { + const rows = await jianSunDb.query( + `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate + FROM dbo.pdd_DataActivityRecords + WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' + ORDER BY createDate DESC` + ); + if (rows.length >= 2) return rows[1].createDate; + if (rows.length === 1) return rows[0].createDate; + return ''; +} + +router.get('/latest-date', async (req, res) => { + try { + const date = await getSecondLatestDate(); + res.json({ success: true, data: { date } }); + } catch (error) { + console.error('[MarketingEvents] 查询最新数据日期失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/list', async (req, res) => { + try { + const { + page = 1, + pageSize = 20, + sortBy = 'activity_goods_start_time', + sortOrder = 'DESC', + shopName, + activityType, + activityName, + activityId, + goodsName, + goodsId, + status, + date, + } = req.query; + + const currentPage = safeInt(page, 1); + const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE); + const offset = (currentPage - 1) * limit; + const sortColumn = normalizeSortField(sortBy); + const sortDir = normalizeSortOrder(sortOrder); + + const whereParts = []; + const params = {}; + + const filterDate = (date && String(date).trim()) || await getSecondLatestDate(); + if (filterDate) { + whereParts.push('LEFT([createTime], 10) = @filterDate'); + params.filterDate = filterDate; + } + + if (shopName && shopName.trim()) { + whereParts.push('[shopName] LIKE @shopName'); + params.shopName = `%${shopName.trim()}%`; + } + if (activityType && activityType.trim()) { + whereParts.push('[business_type_str] LIKE @activityType'); + params.activityType = `%${activityType.trim()}%`; + } + if (activityName && activityName.trim()) { + whereParts.push('[activity_name] LIKE @activityName'); + params.activityName = `%${activityName.trim()}%`; + } + if (activityId && String(activityId).trim()) { + whereParts.push('[activity_id] = @activityId'); + params.activityId = Number(activityId); + } + if (goodsName && goodsName.trim()) { + whereParts.push('[goods_name] LIKE @goodsName'); + params.goodsName = `%${goodsName.trim()}%`; + } + if (goodsId && String(goodsId).trim()) { + whereParts.push('[goods_id] = @goodsId'); + params.goodsId = String(goodsId).trim(); + } + if (status !== undefined && status !== '' && status !== 'all') { + whereParts.push('[status] = @status'); + params.status = Number(status); + } + + const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''; + + const countResult = await jianSunDb.query( + `SELECT COUNT(*) AS total FROM dbo.pdd_DataActivityRecords ${whereSql}`, + params + ); + const total = countResult[0]?.total || 0; + + const dataResult = await jianSunDb.query( + `SELECT + [id], + [shopName], + [createTime], + [business_type_str] AS activityType, + [activity_name] AS activityName, + [activity_id] AS activityId, + [goods_name] AS goodsName, + [goods_id] AS goodsId, + [hd_thumb_url] AS thumbUrl, + [enroll_time] AS enrollTime, + TRY_CONVERT(decimal(18,2), [min_activity_sku_price]) / 100.0 AS minPrice, + TRY_CONVERT(decimal(18,2), [max_activity_sku_price]) / 100.0 AS maxPrice, + [activity_goods_start_time] AS activityStartTime, + [activity_goods_end_time] AS activityEndTime, + [status], + CASE WHEN [status] = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', [status]) END AS statusLabel + FROM dbo.pdd_DataActivityRecords + ${whereSql} + ORDER BY ${escapeColumn(sortColumn)} ${sortDir} + OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`, + { ...params, offset, limit } + ); + + const statusCountsResult = await jianSunDb.query( + `SELECT + [status], + COUNT(*) AS count + FROM dbo.pdd_DataActivityRecords + ${whereSql} + GROUP BY [status] + ORDER BY [status]`, + params + ); + + const statusCounts = {}; + let totalAll = 0; + statusCountsResult.forEach((row) => { + statusCounts[row.status] = row.count; + totalAll += row.count; + }); + + res.json({ + success: true, + data: { + list: dataResult, + total, + page: currentPage, + pageSize: limit, + filterDate, + statusCounts: { all: totalAll, ...statusCounts }, + }, + }); + } catch (error) { + console.error('[MarketingEvents] 查询活动记录失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/summary', async (req, res) => { + try { + const { date } = req.query; + const filterDate = (date && String(date).trim()) || await getSecondLatestDate(); + + const whereSql = filterDate ? 'WHERE LEFT([createTime], 10) = @filterDate' : ''; + const params = filterDate ? { filterDate } : {}; + + const [row] = await jianSunDb.query( + `SELECT + COUNT(*) AS totalRecords, + SUM(CASE WHEN [status] = 501 THEN 1 ELSE 0 END) AS successCount, + COUNT(DISTINCT TRY_CONVERT(varchar(50), [goods_id])) AS goodsCount, + COUNT(DISTINCT [shopName]) AS shopCount, + COUNT(DISTINCT [activity_id]) AS activityIdCount, + COUNT(DISTINCT [business_type_str]) AS activityTypeCount, + AVG(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS avgMinPrice, + AVG(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS avgMaxPrice, + MIN(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS globalMinPrice, + MAX(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS globalMaxPrice + FROM dbo.pdd_DataActivityRecords + ${whereSql}`, + params + ); + + const stats = row || {}; + const totalRecords = Number(stats.totalRecords) || 0; + const successCount = Number(stats.successCount) || 0; + const successRate = totalRecords > 0 ? ((successCount / totalRecords) * 100).toFixed(1) : '0.0'; + + res.json({ + success: true, + data: { + filterDate, + totalRecords, + successCount, + successRate, + goodsCount: Number(stats.goodsCount) || 0, + shopCount: Number(stats.shopCount) || 0, + activityIdCount: Number(stats.activityIdCount) || 0, + activityTypeCount: Number(stats.activityTypeCount) || 0, + avgMinPrice: stats.avgMinPrice != null ? Number(stats.avgMinPrice).toFixed(2) : '-', + avgMaxPrice: stats.avgMaxPrice != null ? Number(stats.avgMaxPrice).toFixed(2) : '-', + globalMinPrice: stats.globalMinPrice != null ? Number(stats.globalMinPrice).toFixed(2) : '-', + globalMaxPrice: stats.globalMaxPrice != null ? Number(stats.globalMaxPrice).toFixed(2) : '-', + }, + }); + } catch (error) { + console.error('[MarketingEvents] 查询汇总数据失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/status-counts', async (req, res) => { + try { + const result = await jianSunDb.query( + `SELECT + [status], + COUNT(*) AS count + FROM dbo.pdd_DataActivityRecords + GROUP BY [status] + ORDER BY [status]` + ); + + const statusCounts = {}; + let total = 0; + result.forEach((row) => { + statusCounts[row.status] = row.count; + total += row.count; + }); + + res.json({ + success: true, + data: { all: total, ...statusCounts }, + }); + } catch (error) { + console.error('[MarketingEvents] 查询状态统计失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js new file mode 100644 index 0000000..ff1978f --- /dev/null +++ b/backend/src/api/mkhBom.js @@ -0,0 +1,40 @@ +'use strict'; + +const express = require('express'); +const { + getMkhBomList, + getMkhBomDetail, +} = require('../services/mkhBomService'); + +const router = express.Router(); + +function sendError(res, error, fallback) { + const status = Number(error?.statusCode) || 500; + console.error('[MkhBom]', { + message: error?.message || fallback, + code: error?.code || null, + status, + }); + res.status(status).json({ success: false, error: error.message || fallback }); +} + +// MKH-BOM 资料列表(模块化组合商品 + 配件汇总) +router.get('/list', async (req, res) => { + try { + res.json({ success: true, data: await getMkhBomList(req.query || {}) }); + } catch (error) { + sendError(res, error, '加载 MKH-BOM 资料列表失败'); + } +}); + +// 单个 MKH 组合商品的 BOM 配件明细(打印用) +router.get('/detail', async (req, res) => { + try { + const skuId = req.query?.skuId || req.query?.comboSkuId; + res.json({ success: true, data: await getMkhBomDetail(skuId) }); + } catch (error) { + sendError(res, error, '加载 MKH-BOM 明细失败'); + } +}); + +module.exports = router; diff --git a/backend/src/api/modularStockMonitor.js b/backend/src/api/modularStockMonitor.js new file mode 100644 index 0000000..ce34dd1 --- /dev/null +++ b/backend/src/api/modularStockMonitor.js @@ -0,0 +1,39 @@ +'use strict'; + +const express = require('express'); +const { + getModularStockList, + getModularStockFilters, +} = require('../services/modularStockMonitorService'); + +const router = express.Router(); + +function sendError(res, error, fallback) { + const status = Number(error?.statusCode) || 500; + console.error('[ModularStockMonitor]', { + message: error?.message || fallback, + code: error?.code || null, + status, + }); + res.status(status).json({ success: false, error: error.message || fallback }); +} + +// 配件库存监控明细列表(默认仅「简尚-崔桥工厂半成品包装项目仓」) +router.get('/list', async (req, res) => { + try { + res.json({ success: true, data: await getModularStockList(req.query || {}) }); + } catch (error) { + sendError(res, error, '加载配件库存监控列表失败'); + } +}); + +// 筛选下拉选项(品类 / 预警等级) +router.get('/filters', async (_req, res) => { + try { + res.json({ success: true, data: await getModularStockFilters() }); + } catch (error) { + sendError(res, error, '加载配件库存监控筛选选项失败'); + } +}); + +module.exports = router; diff --git a/backend/src/api/planningUpload.js b/backend/src/api/planningUpload.js index 14ea686..b87e400 100644 --- a/backend/src/api/planningUpload.js +++ b/backend/src/api/planningUpload.js @@ -1,9 +1,20 @@ const express = require('express'); const { getResolvedUserWithPageAccess } = require('../services/pageAccess'); +const { getOpsBIncentiveBoard, getOpsBIncentiveBatches, getOpsBIncentiveDaily } = require('../services/opsBIncentiveBoardService'); const { getAlibaba1688Product } = require('../services/alibaba1688ProductService'); const { attachEncodingDetail } = require('../services/alibaba1688ProductEncodingRegistry'); const router = express.Router(); +async function ensurePlanningUploadAccess(req) { + const user = await getResolvedUserWithPageAccess(req.user.sub); + if (!user || (user.role !== 'admin' && !user.allowedPageKeys?.includes('ops_b_incentive'))) { + const error = new Error('当前账号没有激励看板权限'); + error.status = 403; + throw error; + } + return user; +} + async function ensureSelectionFissionAccess(req) { // Selection workflows are available to every authenticated user. They share // this router with template uploads, but must not inherit upload permissions. @@ -29,4 +40,43 @@ router.get('/selection-fission/1688-products/:productId', async (req, res) => { } }); +router.get('/analysis/ops-b-incentive/batches', async (req, res) => { + try { + await ensurePlanningUploadAccess(req); + const data = await getOpsBIncentiveBatches(); + res.json({ success: true, data }); + } catch (error) { + console.error('获取运营中心B激励看板批次列表失败:', error); + res.status(error.status || 500).json({ + error: error.message || '获取运营中心B激励看板批次列表失败', + }); + } +}); + +router.get('/analysis/ops-b-incentive', async (req, res) => { + try { + await ensurePlanningUploadAccess(req); + const result = await getOpsBIncentiveBoard(req.query || {}); + res.json({ success: true, data: result }); + } catch (error) { + console.error('获取运营中心B拼多多激励看板失败:', error); + res.status(error.status || 500).json({ + error: error.message || '获取运营中心B拼多多激励看板失败', + }); + } +}); + +router.get('/analysis/ops-b-incentive/daily', async (req, res) => { + try { + await ensurePlanningUploadAccess(req); + const result = await getOpsBIncentiveDaily(req.query || {}); + res.json({ success: true, data: result }); + } catch (error) { + console.error('获取运营中心B拼多多激励每日明细失败:', error); + res.status(error.status || 500).json({ + error: error.message || '获取运营中心B拼多多激励每日明细失败', + }); + } +}); + module.exports = router; diff --git a/backend/src/api/productAnalysis.js b/backend/src/api/productAnalysis.js new file mode 100644 index 0000000..464abf8 --- /dev/null +++ b/backend/src/api/productAnalysis.js @@ -0,0 +1,1739 @@ +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 new file mode 100644 index 0000000..ba703d1 --- /dev/null +++ b/backend/src/api/productMonitoring.js @@ -0,0 +1,73 @@ +'use strict'; + +const express = require('express'); +const { + getMonitoringList, + getMonitoringFilters, + getDepartmentTagConfig, + saveDepartmentTagConfig, + getProductOperators, +} = require('../services/productMonitoringService'); + +const router = express.Router(); + +function sendError(res, error, fallback = '商品监控请求失败') { + const status = Number(error?.statusCode) || 500; + console.error('[ProductMonitoring]', { + message: error?.message || fallback, + code: error?.code || null, + status, + stack: error?.stack || null, + }); + res.status(status).json({ success: false, error: error.message || fallback }); +} + +// 明细列表(含公司标签 / 部门标签 / 负责人 / 汇总) +router.get('/list', async (req, res) => { + try { + res.json({ success: true, data: await getMonitoringList(req.query || {}) }); + } catch (error) { + sendError(res, error, '加载商品监控列表失败'); + } +}); + +// 筛选下拉选项 +router.get('/filters', async (_req, res) => { + try { + res.json({ success: true, data: await getMonitoringFilters() }); + } catch (error) { + sendError(res, error, '加载筛选选项失败'); + } +}); + +// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道) +router.get('/operators', async (req, res) => { + try { + const sku = req.query?.sku || req.query?.skuoid; + if (!sku) { + return res.status(400).json({ success: false, error: '缺少 sku 参数' }); + } + return res.json({ success: true, data: { operators: await getProductOperators(sku) } }); + } catch (error) { + return sendError(res, error, '加载运营明细失败'); + } +}); + +// 部门标签 + 负责人配置(系统设置用) +router.get('/department-config', async (_req, res) => { + try { + res.json({ success: true, data: await getDepartmentTagConfig() }); + } catch (error) { + sendError(res, error, '加载部门标签配置失败'); + } +}); + +router.put('/department-config', async (req, res) => { + try { + res.json({ success: true, data: await saveDepartmentTagConfig(req.body || {}) }); + } catch (error) { + sendError(res, error, '保存部门标签配置失败'); + } +}); + +module.exports = router; diff --git a/backend/src/api/promotionModule.js b/backend/src/api/promotionModule.js new file mode 100644 index 0000000..2a1e62d --- /dev/null +++ b/backend/src/api/promotionModule.js @@ -0,0 +1,417 @@ +const express = require('express'); +const jianSunDb = require('../config/jianSunDatabase'); +const middleDb = require('../config/middlePlatformDatabase'); + +const router = express.Router(); + +const MAX_PAGE_SIZE = 100; + +function safeInt(value, fallback) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function toNum(value) { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +function fmtMoney(value) { + const n = Number(value); + return Number.isFinite(n) ? n.toFixed(2) : '-'; +} + +function fmtRate(value) { + const n = Number(value); + return Number.isFinite(n) ? (n * 100).toFixed(2) : '-'; +} + +function fmtNum(value) { + const n = Number(value); + return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '-'; +} + +function getDateRange(period, customStart, customEnd) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + switch (period) { + case 'realtime': { + const d = new Date(today); + return { start: formatDate(d), end: formatDate(d) }; + } + case 'yesterday': { + const d = new Date(today); + d.setDate(d.getDate() - 1); + return { start: formatDate(d), end: formatDate(d) }; + } + case 'week': { + const d = new Date(today); + d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1)); + return { start: formatDate(d), end: formatDate(today) }; + } + case 'month': { + const d = new Date(today.getFullYear(), today.getMonth(), 1); + return { start: formatDate(d), end: formatDate(today) }; + } + case 'last7d': { + const d = new Date(today); + d.setDate(d.getDate() - 6); + return { start: formatDate(d), end: formatDate(today) }; + } + case 'last30d': { + const d = new Date(today); + d.setDate(d.getDate() - 29); + return { start: formatDate(d), end: formatDate(today) }; + } + case 'custom': { + return { + start: customStart || formatDate(today), + end: customEnd || formatDate(today), + }; + } + default: { + const d = new Date(today); + d.setDate(d.getDate() - 6); + return { start: formatDate(d), end: formatDate(today) }; + } + } +} + +function formatDate(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +// 环比:取紧邻上一段等长周期(如近7天 → 再往前7天;自定义区间 → 等长前移) +function getPreviousRange(range) { + const start = new Date(`${range.start}T00:00:00`); + const end = new Date(`${range.end}T00:00:00`); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return null; + } + const lengthDays = Math.max(1, Math.round((end - start) / 86400000) + 1); + const prevEnd = new Date(start); + prevEnd.setDate(prevEnd.getDate() - 1); + const prevStart = new Date(prevEnd); + prevStart.setDate(prevStart.getDate() - (lengthDays - 1)); + return { start: formatDate(prevStart), end: formatDate(prevEnd) }; +} + +const PROMOTION_SUMMARY_SELECT = `SELECT + COUNT(*) AS totalRecords, + SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 AS totalOrderSpend, + SUM(TRY_CONVERT(decimal(18,2), [gmv])) / 1000.0 AS totalGmv, + CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0 + THEN SUM(TRY_CONVERT(decimal(18,2), [gmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) + ELSE 0 END AS avgRoi, + SUM(TRY_CONVERT(decimal(18,2), [spend])) / 1000.0 AS totalSpend, + SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / 1000.0 AS totalNetGmv, + CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0 + THEN SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) + ELSE 0 END AS avgNetRoi, + SUM(TRY_CONVERT(int, [netOrderNum])) AS totalNetOrderNum, + CASE WHEN SUM(TRY_CONVERT(int, [netOrderNum])) > 0 + THEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 / SUM(TRY_CONVERT(int, [netOrderNum])) + ELSE 0 END AS avgCostPerNetOrder + FROM dbo.pdd_ReportGoodsSpread + WHERE LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate`; + +async function queryPromotionSummary(range) { + const [row] = await jianSunDb.query(PROMOTION_SUMMARY_SELECT, { + startDate: range.start, + endDate: range.end, + }); + const stats = row || {}; + return { + totalRecords: toNum(stats.totalRecords), + orderSpend: toNum(stats.totalOrderSpend), + gmv: toNum(stats.totalGmv), + roi: toNum(stats.avgRoi), + spend: toNum(stats.totalSpend), + netGmv: toNum(stats.totalNetGmv), + netRoi: toNum(stats.avgNetRoi), + netOrderNum: toNum(stats.totalNetOrderNum), + costPerNetOrder: toNum(stats.avgCostPerNetOrder), + }; +} + +// 环比变化率:(当前 - 上一周期) / |上一周期|,上一周期为 0 时返回 null +function buildPeriodChanges(current, previous) { + const changes = {}; + Object.keys(current).forEach((key) => { + const base = Math.abs(previous[key]); + changes[key] = base > 0 ? (current[key] - previous[key]) / base : null; + }); + return changes; +} + +async function getImageMap(goodsIds) { + if (!goodsIds.length) return new Map(); + try { + const placeholders = goodsIds.map((_, i) => `@gid${i}`).join(','); + const params = {}; + goodsIds.forEach((id, i) => { params[`gid${i}`] = String(id); }); + const rows = await middleDb.query( + `SELECT DISTINCT CONVERT(varchar(50), goodsId) AS goodsId, hdThumbUrl + FROM dbo.pdd_DataGoods + WHERE CONVERT(varchar(50), goodsId) IN (${placeholders})`, + params + ); + const map = new Map(); + rows.forEach((row) => { + if (row.hdThumbUrl) map.set(String(row.goodsId), row.hdThumbUrl); + }); + return map; + } catch { + return new Map(); + } +} + +router.get('/latest-date', async (req, res) => { + try { + const rows = await jianSunDb.query( + `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate + FROM dbo.pdd_ReportGoodsSpread + WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' + ORDER BY createDate DESC` + ); + const date = rows.length >= 2 ? rows[1].createDate : (rows[0]?.createDate || ''); + res.json({ success: true, data: { date: String(date || '').trim() } }); + } catch (error) { + console.error('[PromotionModule] 查询最新数据日期失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/shops', async (req, res) => { + try { + const rows = await jianSunDb.query( + `SELECT DISTINCT [shopName] + FROM dbo.pdd_ReportGoodsSpread + WHERE [shopName] IS NOT NULL AND LTRIM(RTRIM([shopName])) != '' + ORDER BY [shopName]` + ); + res.json({ success: true, data: rows.map((r) => r.shopName) }); + } catch (error) { + console.error('[PromotionModule] 查询店铺列表失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/summary', async (req, res) => { + try { + const { date, period, start: customStart, end: customEnd } = req.query; + let filterDate = (date && String(date).trim()) || ''; + let range; + + if (period === 'custom') { + range = getDateRange('custom', customStart, customEnd); + } else if (period) { + range = getDateRange(period); + filterDate = ''; + } + + if (!range) { + // 单日口径:未带 period 时按最新数据日(默认取倒数第二个有数据的日期) + if (!filterDate) { + const dateRows = await jianSunDb.query( + `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate + FROM dbo.pdd_ReportGoodsSpread + WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' + ORDER BY createDate DESC` + ); + filterDate = dateRows.length >= 2 + ? String(dateRows[1].createDate || '').trim() + : String(dateRows[0]?.createDate || '').trim(); + } + range = { start: filterDate, end: filterDate }; + } + + const previousRange = getPreviousRange(range); + const [current, previous] = await Promise.all([ + queryPromotionSummary(range), + previousRange ? queryPromotionSummary(previousRange) : Promise.resolve(null), + ]); + + res.json({ + success: true, + data: { + filterDate: `${range.start} ~ ${range.end}`, + previousRange: previousRange ? `${previousRange.start} ~ ${previousRange.end}` : '', + totalRecords: current.totalRecords, + orderSpend: fmtMoney(current.orderSpend), + gmv: fmtMoney(current.gmv), + roi: fmtNum(current.roi), + spend: fmtMoney(current.spend), + netGmv: fmtMoney(current.netGmv), + netRoi: fmtNum(current.netRoi), + netOrderNum: fmtNum(current.netOrderNum), + costPerNetOrder: fmtMoney(current.costPerNetOrder), + changes: previous ? buildPeriodChanges(current, previous) : null, + }, + }); + } catch (error) { + console.error('[PromotionModule] 查询汇总数据失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +router.get('/list', async (req, res) => { + try { + const { + page = 1, + pageSize = 20, + sortBy = 'orderSpend', + sortOrder = 'DESC', + keyword, + shopName, + date, + period, + start: customStart, + end: customEnd, + } = req.query; + + const currentPage = safeInt(page, 1); + const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE); + const offset = (currentPage - 1) * limit; + + const whereParts = []; + const params = {}; + + if (period && period !== 'custom') { + const range = getDateRange(period); + whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate'); + params.startDate = range.start; + params.endDate = range.end; + } else if (period === 'custom' && customStart && customEnd) { + whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate'); + params.startDate = customStart; + params.endDate = customEnd; + } else { + let filterDate = (date && String(date).trim()) || ''; + if (!filterDate) { + const dateRows = await jianSunDb.query( + `SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate + FROM dbo.pdd_ReportGoodsSpread + WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != '' + ORDER BY createDate DESC` + ); + filterDate = dateRows.length >= 2 + ? String(dateRows[1].createDate || '').trim() + : String(dateRows[0]?.createDate || '').trim(); + } + if (filterDate) { + whereParts.push('LEFT([createTime], 10) = @filterDate'); + params.filterDate = filterDate; + } + } + + if (keyword && keyword.trim()) { + whereParts.push('([goodsName] LIKE @kw OR [goodsId] LIKE @kw)'); + params.kw = `%${keyword.trim()}%`; + } + if (shopName && shopName.trim()) { + whereParts.push('[shopName] = @shopName'); + params.shopName = shopName.trim(); + } + + const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''; + + const allowedSort = new Set([ + 'goodsName', 'goodsId', 'shopName', 'createTime', 'orderSpend', 'gmv', 'roi', + 'spend', 'orderNum', 'netGmv', 'netRoi', 'netOrderNum', 'netAvgPayAmount', + 'settlementGmv', 'settlementRoi', 'settlementOrder', 'impression', 'click', 'ctr', 'cvr', + ]); + const sortCol = allowedSort.has(sortBy) ? `[${sortBy}]` : '[orderSpend]'; + const sortDir = String(sortOrder || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; + + const countResult = await jianSunDb.query( + `SELECT COUNT(*) AS total FROM dbo.pdd_ReportGoodsSpread ${whereSql}`, + params + ); + const total = countResult[0]?.total || 0; + + const dataResult = await jianSunDb.query( + `SELECT + [id], + [goodsName], + [goodsId], + [shopName], + [createTime], + [orderSpend], + [gmv], + [roi], + [spend], + [orderNum], + [transactionCost], + [avgPayAmount], + [directGmv], + [indirectGmv], + [directOrderNum], + [indirectOrderNum], + [netGmv], + [netRoi], + [netOrderNum], + [netAvgPayAmount], + [netRate], + [settlementGmv], + [settlementRoi], + [settlementOrder], + [refundGmvRate30d], + [refundOrderRate30d], + [exemptRefundGmvRate30d], + [exemptRefundOrderRate30d], + [settlementGmvRate], + [settlementOrderRate], + [costPerSettlementOrderForDeal], + [impression], + [click], + [ctr], + [cvr], + [targetRoi], + [targetBid] + FROM dbo.pdd_ReportGoodsSpread + ${whereSql} + ORDER BY ${sortCol} ${sortDir} + OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`, + { ...params, offset, limit } + ); + + const goodsIds = dataResult.map((r) => String(r.goodsId)).filter(Boolean); + const imageMap = await getImageMap(goodsIds); + + const list = dataResult.map((row) => ({ + ...row, + thumbUrl: imageMap.get(String(row.goodsId)) || '', + orderSpend: row.orderSpend != null ? toNum(row.orderSpend) / 1000.0 : null, + gmv: row.gmv != null ? toNum(row.gmv) / 1000.0 : null, + spend: row.spend != null ? toNum(row.spend) / 1000.0 : null, + transactionCost: row.transactionCost != null ? toNum(row.transactionCost) / 1000.0 : null, + avgPayAmount: row.avgPayAmount != null ? toNum(row.avgPayAmount) / 1000.0 : null, + directGmv: row.directGmv != null ? toNum(row.directGmv) / 1000.0 : null, + indirectGmv: row.indirectGmv != null ? toNum(row.indirectGmv) / 1000.0 : null, + netGmv: row.netGmv != null ? toNum(row.netGmv) / 1000.0 : null, + netAvgPayAmount: row.netAvgPayAmount != null ? toNum(row.netAvgPayAmount) / 1000.0 : null, + settlementGmv: row.settlementGmv != null ? toNum(row.settlementGmv) / 1000.0 : null, + costPerSettlementOrderForDeal: row.costPerSettlementOrderForDeal != null ? toNum(row.costPerSettlementOrderForDeal) / 1000.0 : null, + })); + + res.json({ + success: true, + data: { + list, + total, + page: currentPage, + pageSize: limit, + }, + }); + } catch (error) { + console.error('[PromotionModule] 查询推广数据失败:', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/src/config/productInformationDatabase.js b/backend/src/config/productInformationDatabase.js new file mode 100644 index 0000000..3bc5606 --- /dev/null +++ b/backend/src/config/productInformationDatabase.js @@ -0,0 +1,169 @@ +/** + * 产品资料系统数据库连接模块(PostgreSQL) + * + * 数据来源:系统库 database_configs 表中 name = 'product_information_database' 的配置。 + * 典型配置:postgres / 218.93.9.38:25432 / furniture3d_prod / external_full_reader / ssl=1 + * + * 该库用于 MKH-BOM 资料页面的商品资料匹配: + * external.intake_sku_items_ready_v1 + * —— 真实可用的 SKU 明细视图(约 4.6 万行) + * —— source_sku_id 为聚水潭原始 SKU 编码(即业务库 BOM 的配件编码) + * —— sku_code 为商品编码,barcode 为条码 + * external.warehouse_locations_v1 —— 库位主数据(当前仅 1 行:A-01 成品区) + * + * 说明:external.product_bom_v1 / product_skus_v1 当前为空视图,不可用。 + */ + +const { Pool } = require('pg'); +const database = require('./database'); + +const CONFIG_NAME = 'product_information_database'; +const READY_VIEW = 'external.intake_sku_items_ready_v1'; +const LOCATION_VIEW = 'external.warehouse_locations_v1'; + +const parsePositiveInt = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +const POOL_MAX = parsePositiveInt(process.env.PRODUCT_DB_POOL_MAX, 4); + +let pool = null; +let cachedSignature = ''; + +const truthy = (value) => ( + value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1' +); + +async function loadStoredConfig() { + const rows = await database.local.query( + `SELECT id, host, port, database_name, username, password, \`ssl\`, driver, status + FROM database_configs WHERE name = ? LIMIT 1`, + [CONFIG_NAME] + ); + + const config = rows[0]; + if (!config) { + const error = new Error('产品资料系统数据库尚未配置,请在「系统设置 - 数据库配置」中完成配置'); + error.statusCode = 503; + error.code = 'PRODUCT_DB_NOT_CONFIGURED'; + throw error; + } + + if (String(config.driver || '').toLowerCase() !== 'postgres') { + const error = new Error('产品资料系统数据库需要选择 PostgreSQL 类型'); + error.statusCode = 503; + error.code = 'PRODUCT_DB_DRIVER_MISMATCH'; + throw error; + } + + return { + id: config.id, + host: String(config.host || '').trim(), + port: Number(config.port) || 5432, + database: String(config.database_name || '').trim(), + user: String(config.username || '').trim(), + password: config.password || '', + ssl: truthy(config.ssl), + }; +} + +const buildSignature = (config) => [ + config.id, + config.host, + config.port, + config.database, + config.user, + config.ssl ? 'ssl' : 'nossl', +].join('::'); + +async function getPool() { + const config = await loadStoredConfig(); + const signature = buildSignature(config); + + if (pool && signature === cachedSignature) { + return pool; + } + + if (pool) { + await pool.end().catch(() => {}); + pool = null; + } + + pool = new Pool({ + host: config.host, + port: config.port, + database: config.database, + user: config.user, + password: config.password, + ssl: config.ssl ? { rejectUnauthorized: false } : false, + max: POOL_MAX, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 20000, + query_timeout: 180000, + statement_timeout: 180000, + }); + + pool.on('error', (error) => { + console.warn('[ProductDB] 连接池异常:', error.message); + }); + + cachedSignature = signature; + return pool; +} + +/** + * 执行查询,返回行数组。 + * @param {string} sqlText PostgreSQL 语句(使用 $1/$2 占位) + * @param {Array} params 参数数组 + */ +async function query(sqlText, params = []) { + const maxAttempts = 2; + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const activePool = await getPool(); + const result = await activePool.query(sqlText, params); + return result.rows; + } catch (error) { + lastError = error; + if (error.code === 'PRODUCT_DB_NOT_CONFIGURED' || error.code === 'PRODUCT_DB_DRIVER_MISMATCH') { + throw error; + } + if (attempt < maxAttempts) { + console.warn(`[ProductDB] 查询失败,准备重连后重试 (${attempt}/${maxAttempts}): ${error.message}`); + await reset(); + } + } + } + + throw lastError; +} + +async function reset() { + if (pool) { + await pool.end().catch(() => {}); + } + pool = null; + cachedSignature = ''; +} + +async function test() { + try { + const rows = await query('SELECT 1 AS test'); + return { success: true, message: '产品资料系统数据库连接成功', data: rows }; + } catch (error) { + return { success: false, message: `连接失败: ${error.message}` }; + } +} + +module.exports = { + query, + test, + reset, + getPool, + CONFIG_NAME, + READY_VIEW, + LOCATION_VIEW, +}; diff --git a/backend/src/constants/sidebarPages.js b/backend/src/constants/sidebarPages.js index ed5eee4..b9294ef 100644 --- a/backend/src/constants/sidebarPages.js +++ b/backend/src/constants/sidebarPages.js @@ -14,12 +14,31 @@ const buildPlanningPage = ({ pageKey, pageName, path, allowSubPaths = false }) = }); const SIDEBAR_PAGES = [ + buildPlanningPage({ pageKey: 'ops_b_incentive', pageName: '激励看板', path: '/planning/operation/ops-b-incentive' }), buildPlanningPage({ pageKey: 'dashboard_preview', pageName: '仪表盘(开发预览)', path: '/planning/dev-style-preview', }), + buildPlanningPage({ + pageKey: 'marketing_events', + pageName: '营销活动', + path: '/planning/operation/marketing-events', + allowSubPaths: true, + }), + buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }), buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }), + buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }), + buildPlanningPage({ + pageKey: 'modular_stock_monitor', + pageName: '模块化库存监控 · 配件库存监控', + path: '/planning/operation/modular-stock-monitor', + }), + buildPlanningPage({ + pageKey: 'mkh_bom', + pageName: '模块化库存监控 · MKH-BOM资料', + path: '/planning/operation/mkh-bom-data', + }), buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }), { pageKey: 'settings', diff --git a/backend/src/index.js b/backend/src/index.js index a285953..96a2fe2 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -29,7 +29,12 @@ const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin'); const channelRoutes = require('./api/channel'); const analysisRoutes = require('./api/analysis'); const productCodeBoardRoutes = require('./api/productCodeBoard'); -const dashboardRoutes = require('./api/dashboard'); +const productAnalysisRoutes = require('./api/productAnalysis'); +const marketingEventsRoutes = require('./api/marketingEvents'); +const promotionModuleRoutes = require('./api/promotionModule'); +const productMonitoringRoutes = require('./api/productMonitoring'); +const modularStockMonitorRoutes = require('./api/modularStockMonitor'); +const mkhBomRoutes = require('./api/mkhBom'); const database = require('./config/database'); const cloudDb = require('./config/cloudDatabase'); const jianSunDb = require('./config/jianSunDatabase'); @@ -40,6 +45,8 @@ const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductE const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob'); const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob'); const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob'); +const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob'); +const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); const { startAlibaba1688ImageSearchExpiryScheduler, } = require('./jobs/alibaba1688ImageSearchExpiryJob'); @@ -123,6 +130,7 @@ function shouldHandlePlanningUploadPageCache(req) { req.path === '/' || req.path.startsWith('/table/may_product_data') || req.path.startsWith('/analysis/may-channel-insight') + || req.path.startsWith('/analysis/ops-b-incentive') || req.path.startsWith('/selection-fission') ) { return false; @@ -640,9 +648,14 @@ async function startServer(options = {}) { cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes); cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes); cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes); - cubejsApp.use('/api/dashboard', authMiddleware, dashboardRoutes); + cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes); + cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes); + cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes); cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); + cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); + cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes); + cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); // ===================== // 数据分析 API(公开访问,可根据需要添加认证) @@ -680,9 +693,9 @@ async function startServer(options = {}) { console.log('='.repeat(50)); console.log('🚀 后端服务已启动'); - console.log(`📡 服务地址: http://localhost:4000`); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); console.log(cubeMode === 'cube' - ? '📊 Cube.js API: http://localhost:4000/cubejs-api' + ? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api` : '📊 Cube.js API: 已降级不可用,业务 API 继续运行'); // 测试数据库连接 @@ -708,6 +721,8 @@ async function startServer(options = {}) { startPddSkuDimensionSnapshotSchedulers(); startPlanningMayProductSnapshotSchedulers(); startPlanningDingWorkflowSyncSchedulers(); + startOpsBIncentiveSnapshotSchedulers(); + startGoodsFirstSeenScheduler(); startAlibaba1688ImageSearchExpiryScheduler(); // Keep the first selection-pool page request from paying the remote schema diff --git a/backend/src/jobs/goodsFirstSeenJob.js b/backend/src/jobs/goodsFirstSeenJob.js new file mode 100644 index 0000000..3daf32d --- /dev/null +++ b/backend/src/jobs/goodsFirstSeenJob.js @@ -0,0 +1,164 @@ +const cron = require('node-cron'); +const jianSunDb = require('../config/jianSunDatabase'); +const planningDb = require('../services/planningDatabase'); +const { pickSkuCaptureTime } = require('../services/opsBIncentiveBoardService'); + +const JOB_CRON = String(process.env.GOODS_FIRST_SEEN_CRON || '20 5 * * *').trim(); +const TIMEZONE = 'Asia/Shanghai'; +const SYNC_CHUNK_SIZE = 500; +const BACKFILL_CHUNK_SIZE = 800; +const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily'; + +let schemaEnsured = false; + +async function ensureTable() { + if (schemaEnsured) return; + await planningDb.query(` + CREATE TABLE IF NOT EXISTS pdd_goods_first_seen ( + goods_id VARCHAR(50) NOT NULL PRIMARY KEY, + first_created_at DATETIME NOT NULL COMMENT '首次采集到的SKU创建时间(约等于上架时间)', + last_capture_date DATE NOT NULL COMMENT '最近一次出现在采集批次的日期', + is_onsale TINYINT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + schemaEnsured = true; +} + +function epochToDatetime(value) { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) return null; + const ms = num >= 1e12 ? num : num * 1000; + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? null : date; +} + +// 同步单个采集批次:新商品写入首见时间,已存在商品只刷新最近采集日/在售状态(保留最初 first_created_at) +async function syncCaptureDate(captureDate) { + await ensureTable(); + const startedAt = Date.now(); + const rows = await jianSunDb.query( + `SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goodsId, + MIN(s.createdAt) AS minCreatedAt, + MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale + FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) + WHERE s.createTime = @captureDate + AND s.goodsId IS NOT NULL AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) <> '' + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId)))`, + { captureDate }, + { requestTimeout: 300000 } + ); + + const records = []; + rows.forEach((row) => { + const createdAt = epochToDatetime(row.minCreatedAt); + if (!createdAt) return; + records.push([String(row.goodsId), createdAt, captureDate, Number(row.onsale) === 1 ? 1 : 0]); + }); + + const countBefore = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'); + for (let offset = 0; offset < records.length; offset += SYNC_CHUNK_SIZE) { + const chunk = records.slice(offset, offset + SYNC_CHUNK_SIZE); + await planningDb.query( + `INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale) + VALUES ? + ON DUPLICATE KEY UPDATE + last_capture_date = VALUES(last_capture_date), + is_onsale = VALUES(is_onsale)`, + [chunk] + ); + } + const countAfter = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'); + + return { + captureDate, + goodsInBatch: records.length, + upserted: records.length, + newGoods: Math.max(0, Number(countAfter[0]?.n || 0) - Number(countBefore[0]?.n || 0)), + total: Number(countAfter[0]?.n || 0), + elapsedMs: Date.now() - startedAt, + }; +} + +// 每日增量:取最近一个完整采集批次(复用激励看板的 7 天完整性校验) +async function refreshLatestCapture() { + const captureDate = await pickSkuCaptureTime(new Date().toISOString().slice(0, 10)); + if (!captureDate) { + console.warn('[GoodsFirstSeen] 近7天无可用完整采集批次,跳过本次同步'); + return { skipped: true }; + } + return syncCaptureDate(captureDate); +} + +// 历史回填:对当前快照表里的全部商品,走 goodsId 索引精确点查首次创建时间(不受采集历史跨度限制) +async function backfillAllHistory() { + await ensureTable(); + const startedAt = Date.now(); + const goodsRows = await planningDb.query( + `SELECT DISTINCT goodsId FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})` + ); + const goodsIds = [...new Set(goodsRows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))]; + console.log(`[GoodsFirstSeen] 历史回填开始,快照商品 ${goodsIds.length} 个`); + + const countBefore = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0); + let synced = 0; + for (let offset = 0; offset < goodsIds.length; offset += BACKFILL_CHUNK_SIZE) { + const chunk = goodsIds.slice(offset, offset + BACKFILL_CHUNK_SIZE); + const params = {}; + const placeholders = chunk.map((goodsId, index) => { + params[`g${index}`] = Number(goodsId); + return `@g${index}`; + }).join(', '); + const rows = await jianSunDb.query( + `SELECT s.goodsId AS goodsId, + MIN(TRY_CONVERT(BIGINT, s.createdAt)) AS minCreatedAt, + MAX(s.createTime) AS lastCaptureRaw, + MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale + FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(INDEXOF_goodsId_pdd_Skus)) + WHERE s.goodsId IN (${placeholders}) + GROUP BY s.goodsId`, + params, + { requestTimeout: 300000 } + ); + + const records = []; + rows.forEach((row) => { + const createdAt = epochToDatetime(row.minCreatedAt); + const lastCapture = epochToDatetime(row.lastCaptureRaw); + if (!createdAt) return; + records.push([String(row.goodsId), createdAt, lastCapture || createdAt, Number(row.onsale) === 1 ? 1 : 0]); + }); + for (let i = 0; i < records.length; i += SYNC_CHUNK_SIZE) { + const chunk = records.slice(i, i + SYNC_CHUNK_SIZE); + await planningDb.query( + `INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale) + VALUES ? + ON DUPLICATE KEY UPDATE + last_capture_date = VALUES(last_capture_date), + is_onsale = VALUES(is_onsale)`, + [chunk] + ); + } + synced += records.length; + console.log(`[GoodsFirstSeen] 回填进度 ${Math.min(offset + BACKFILL_CHUNK_SIZE, goodsIds.length)}/${goodsIds.length},命中 ${records.length}`); + } + const countAfter = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0); + const summary = { goodsInSnapshot: goodsIds.length, synced, newGoods: Math.max(0, countAfter - countBefore), total: countAfter, elapsedMs: Date.now() - startedAt }; + console.log(`[GoodsFirstSeen] 历史回填结束: ${JSON.stringify(summary)}`); + return summary; +} + +function startGoodsFirstSeenScheduler() { + const disabled = String(process.env.DISABLE_GOODS_FIRST_SEEN_JOB || '').trim().toLowerCase() === 'true'; + if (disabled) { + console.log('[GoodsFirstSeen] 首见时间同步任务已被禁用'); + return; + } + cron.schedule(JOB_CRON, () => { + refreshLatestCapture().catch((error) => { + console.error('[GoodsFirstSeen] 定时同步失败:', error.message); + }); + }, { timezone: TIMEZONE }); + console.log(`[Scheduler] 商品首见时间同步任务已注册: ${JOB_CRON} (${TIMEZONE})`); +} + +module.exports = { refreshLatestCapture, backfillAllHistory, syncCaptureDate, startGoodsFirstSeenScheduler }; diff --git a/backend/src/jobs/opsBIncentiveSnapshotJob.js b/backend/src/jobs/opsBIncentiveSnapshotJob.js new file mode 100644 index 0000000..ece8b09 --- /dev/null +++ b/backend/src/jobs/opsBIncentiveSnapshotJob.js @@ -0,0 +1,31 @@ +const cron = require('node-cron'); +const { refreshOpsBIncentiveSnapshots } = require('../services/opsBIncentiveBoardService'); + +const JOB_CRON = String(process.env.OPS_B_INCENTIVE_SNAPSHOT_CRON || '0 * * * *').trim(); +const TIMEZONE = 'Asia/Shanghai'; + +async function runOpsBIncentiveSnapshotJob(source = 'scheduled') { + try { + const result = await refreshOpsBIncentiveSnapshots(source); + console.log('[OpsBIncentiveSnapshot] refresh completed:', JSON.stringify(result)); + return result; + } catch (error) { + console.error('[OpsBIncentiveSnapshot] refresh failed:', error.message || error); + return []; + } +} + +function startOpsBIncentiveSnapshotSchedulers() { + // 默认开启;仅当显式设置 DISABLE_OPS_B_INCENTIVE_SNAPSHOT=true 时关闭。 + const disabled = String(process.env.DISABLE_OPS_B_INCENTIVE_SNAPSHOT || '').trim().toLowerCase() === 'true'; + if (disabled) { + console.log('[Scheduler] 运营中心B激励看板快照任务已被禁用'); + return; + } + cron.schedule(JOB_CRON, () => { + runOpsBIncentiveSnapshotJob('daily_schedule').catch(() => {}); + }, { timezone: TIMEZONE }); + console.log(`[Scheduler] 运营中心B激励看板快照任务已注册: ${JOB_CRON} (${TIMEZONE})`); +} + +module.exports = { startOpsBIncentiveSnapshotSchedulers, runOpsBIncentiveSnapshotJob }; diff --git a/backend/src/services/desktopSessionService.js b/backend/src/services/desktopSessionService.js index a6eec71..6ce964c 100644 --- a/backend/src/services/desktopSessionService.js +++ b/backend/src/services/desktopSessionService.js @@ -77,6 +77,8 @@ function buildDesktopCapabilities(user) { shopManagement: canPlan, execution: canPlan, logs: true, + marketingEvents: isAdmin || allowed.has('marketing_events'), + promotion: isAdmin || allowed.has('promotion_module'), productTest: isAdmin || allowed.has('new_product_test'), }; } diff --git a/backend/src/services/localSchema.js b/backend/src/services/localSchema.js index c813ddc..3e3d5fa 100644 --- a/backend/src/services/localSchema.js +++ b/backend/src/services/localSchema.js @@ -7,6 +7,11 @@ const { ensureDesktopPresenceSchema } = require('./desktopPresenceService'); const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService'); const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService'); const { ensureThirdPartySsoDirectSchema } = require('./thirdPartySsoDirectService'); +const { ensureProductTagConfigSchema } = require('./productTagConfigService'); +const { + ensureProductMonitoringSchema, + warmProductMonitoringDataset, +} = require('./productMonitoringService'); const USER_COLUMNS = [ ['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"], @@ -113,6 +118,18 @@ async function ensureTable(tableName, createSql) { } } +async function tableExists(tableName) { + const rows = await database.local.query( + `SELECT 1 + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = ? + LIMIT 1`, + [tableName] + ); + return rows.length > 0; +} + async function indexExists(tableName, indexName) { const rows = await database.local.query( `SELECT 1 @@ -238,10 +255,67 @@ async function seedRedisConfigFromEnv() { } async function ensureLocalSchema() { + await ensureTable( + 'ops_b_incentive_snapshots', + `CREATE TABLE IF NOT EXISTS ops_b_incentive_snapshots ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + snapshot_key VARCHAR(80) NOT NULL, + scope_start DATE NOT NULL, + scope_end DATE NOT NULL, + metric_end DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'completed', + finalized TINYINT(1) NOT NULL DEFAULT 0, + rows_json MEDIUMTEXT NOT NULL, + summary_json TEXT NULL, + generated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME NULL, + error_message TEXT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_ops_b_snapshot_key (snapshot_key), + KEY idx_ops_b_snapshot_period (scope_start, scope_end, metric_end, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4` + ); + await ensureTable( + 'ops_b_incentive_periods', + `CREATE TABLE IF NOT EXISTS ops_b_incentive_periods ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + scope_start DATE NOT NULL COMMENT '上架日期区间起', + scope_end DATE NOT NULL COMMENT '上架日期区间止', + pull_date DATE NOT NULL COMMENT '拉取(冻结)日期', + fee_cap DECIMAL(5,4) NOT NULL COMMENT '费比达标上限', + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_ops_b_period_scope (scope_start, scope_end) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4` + ); + await ensureTable( + 'product_analysis_user_prefs', + `CREATE TABLE IF NOT EXISTS product_analysis_user_prefs ( + user_id BIGINT PRIMARY KEY COMMENT '用户ID', + prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化配置 JSON', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分析用户个性化配置'` + ); + if (await tableExists('pricing_calculator_user_prefs')) { + await database.local.query( + `INSERT INTO product_analysis_user_prefs (user_id, prefs_json, created_at, updated_at) + SELECT user_id, prefs_json, created_at, updated_at + FROM pricing_calculator_user_prefs + WHERE JSON_VALID(prefs_json) + AND JSON_EXTRACT(prefs_json, '$.productAnalysisSignalThresholds') IS NOT NULL + ON DUPLICATE KEY UPDATE + prefs_json = VALUES(prefs_json), + updated_at = VALUES(updated_at)` + ); + } await ensureDesktopPresenceSchema(); await ensureDesktopExecutionTaskSchema(); await ensureThirdPartySsoSchema(); await ensureThirdPartySsoDirectSchema(); + await ensureProductTagConfigSchema(); await ensureTable( 'auth_configs', @@ -877,7 +951,11 @@ async function ensureLocalSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫控价快照读库'` ); + await ensureProductMonitoringSchema(); + await ensureSidebarRolePermissions(); + // 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动 + void warmProductMonitoringDataset(); await ensureDepartmentPagePermissions(); await cleanupRetiredFeatureSettings(database.local); await repairDuplicateExternalDepartments(); diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js new file mode 100644 index 0000000..4b383f6 --- /dev/null +++ b/backend/src/services/mkhBomService.js @@ -0,0 +1,417 @@ +'use strict'; + +// 模块化库存监控 —— MKH-BOM 资料 +// +// 页面结构(与业务确认的模板):MKH商品编码 | 配件明细 | 数量 | 配件仓位 +// +// ── 数据口径 ────────────────────────────────────────────────────── +// +// ① MKH 商品编码(列表主数据)—— 业务库 JSReportReplica(MSSQL / schema=rpt) +// rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品' (约 4436 条) +// · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198 +// +// ② 配件明细(BOM)—— 产品资料系统数据库(PostgreSQL / furniture3d_prod) +// public."IntakeNonIntegratedProduct".payload -> 'skuItems' -> 'componentDetails' +// · 每一项形如 { code, name, role/assetType, barcode, quantity } +// · 例:MKH-DNZ0384 → [{ barcode: DNZ038501N1437V01, quantity: 1, role: 板材 }, +// { barcode: DNZ038401N1440V01, quantity: 1, role: 组件 }] +// · 匹配:编码去横线/空格转大写后比对;精确 SKU 匹配不到时退化到 SPU 前缀匹配 +// (业务库 MKHDNZ0384260600002 ↔ 资料库 MKH-DNZ0384260600001) +// · 资料库里已维护 BOM、但业务库 jst_sku 里没有对应成品的模块化商品, +// 会以 source='product_db' 追加到列表最前,保证已维护的数据一定可见。 +// +// ⚠️ 该库另有 external.product_bom_v1 标准视图,但它依赖 Product / ProductSKU / +// Panel 三张整合表,这三张表当前均为 0 行,因此视图恒为空、不要用。 +// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同 +// (无 SKU 条码),也不能直接当配件 BOM。 +// +// ③ 配件仓位 —— 资料库 external.warehouse_locations_v1(库位主数据,当前仅 A-01); +// 业务库 jst_sku.bin / other_bin 全库为空,故多数配件显示「未维护」。 +// +// ④ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 + +const cloudDatabase = require('../config/cloudDatabase'); +const productDb = require('../config/productInformationDatabase'); + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; +const MAX_EXTRA_LOOKUP = 300; + +// MKH 商品编码 = 模块化成品 +const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品'`; + +const toText = (value) => { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text === '' ? null : text; +}; + +const toNumber = (value) => { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +// 编码归一化:去横线/空格 + 大写,用于跨库匹配 +const normalizeCode = (value) => { + const text = toText(value); + return text ? text.toUpperCase().replace(/[-\s]/g, '') : null; +}; + +const buildFilters = (query = {}) => { + const conditions = []; + const params = {}; + + const keyword = toText(query.keyword); + if (keyword) { + params.keyword = `%${keyword}%`; + conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)'); + } + + return { conditions, params }; +}; + +/** + * 把 componentDetails 原始 JSON 解析成统一结构。 + */ +function parseComponents(raw) { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => { + if (!item || typeof item !== 'object') return null; + const barcode = toText(item.barcode); + const code = toText(item.code); + const name = toText(item.name) || barcode || code; + return { + barcode: barcode || code, + code, + name, + role: toText(item.role) || toText(item.assetType), + qty: toNumber(item.quantity) ?? 1, + }; + }) + .filter(Boolean); +} + +/** + * 从产品资料系统数据库读取全部「有配件明细」的模块化商品。 + * 返回 { bySku: Map, bySpuPrefix: Array, entries: Array } + * 失败时返回空结构并降级(页面照常展示列表,只是配件列为 0)。 + */ +async function loadPgBom() { + const empty = { bySku: new Map(), bySpuPrefix: [], entries: [] }; + const sql = ` + SELECT + p.id AS product_id, + p.spu AS spu, + COALESCE(s->>'skuCode', s->>'barcode') AS sku_code, + s->'componentDetails' AS components + FROM public."IntakeNonIntegratedProduct" p + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.payload->'skuItems', '[]'::jsonb)) s + WHERE jsonb_array_length(COALESCE(s->'componentDetails', '[]'::jsonb)) > 0`; + + let rows = []; + try { + rows = await productDb.query(sql); + } catch (error) { + console.warn('[MkhBom] 读取产品资料系统数据库失败,配件 BOM 降级:', error.message); + return empty; + } + + // 商品名称(资料库档案表,取不到不影响主流程) + const nameMap = new Map(); + try { + const nameRows = await productDb.query( + `SELECT id, "productName" FROM public."IntakeNonIntegratedProductProfile" WHERE "productName" IS NOT NULL`); + nameRows.forEach((row) => nameMap.set(toText(row.id), toText(row.productName))); + } catch (error) { + console.warn('[MkhBom] 读取资料库商品名称失败(忽略):', error.message); + } + + const bySku = new Map(); + const bySpuPrefix = []; + const entries = []; + + rows.forEach((row) => { + const components = parseComponents(row.components); + if (components.length === 0) return; + + const skuCode = toText(row.sku_code); + const spu = toText(row.spu); + const skuKey = normalizeCode(skuCode); + if (skuKey) bySku.set(skuKey, components); + + const spuKey = normalizeCode(spu); + if (spuKey) bySpuPrefix.push({ prefix: spuKey, components, spu }); + + entries.push({ + skuCode, + spu, + productName: nameMap.get(toText(row.product_id)) || spu || skuCode, + components, + }); + }); + + // 前缀长优先,避免短前缀抢先命中 + bySpuPrefix.sort((a, b) => b.prefix.length - a.prefix.length); + + return { bySku, bySpuPrefix, entries }; +} + +/** + * 按编码匹配配件:先精确 SKU,再退化到 SPU 前缀。 + */ +function matchComponents(bom, skuId) { + const key = normalizeCode(skuId); + if (!key) return null; + + const exact = bom.bySku.get(key); + if (exact) return { components: exact, matchType: 'sku' }; + + const hit = bom.bySpuPrefix.find((entry) => key.startsWith(entry.prefix)); + if (hit) return { components: hit.components, matchType: 'spu' }; + + return null; +} + +const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0); + +/** + * 找出「资料库里已维护 BOM、但业务库列表里没有」的模块化商品, + * 作为追加行放在列表最前面,保证已维护的数据一定能在页面上看到。 + */ +async function buildExtraRows(bom) { + if (!bom.entries.length) return []; + if (bom.entries.length > MAX_EXTRA_LOOKUP) return []; + + const candidates = []; + bom.entries.forEach((entry) => { + [entry.skuCode, normalizeCode(entry.skuCode)].forEach((code) => { + if (code && !candidates.includes(code)) candidates.push(code); + }); + }); + if (!candidates.length) return []; + + const params = {}; + const placeholders = candidates.map((code, index) => { + params[`c${index}`] = code; + return `@c${index}`; + }); + + let existing = new Set(); + try { + const rows = await cloudDatabase.query( + `SELECT s.sku_id AS skuId FROM dbo.jst_sku s WHERE s.sku_id IN (${placeholders.join(', ')})`, + params, + { requestTimeout: 120000 } + ); + existing = new Set(rows.map((row) => normalizeCode(row.skuId)).filter(Boolean)); + } catch (error) { + console.warn('[MkhBom] 追加行去重查询失败,按全部追加处理:', error.message); + } + + return bom.entries + .filter((entry) => { + const key = normalizeCode(entry.skuCode); + return key && !existing.has(key); + }) + .map((entry) => ({ + skuId: entry.skuCode || entry.spu, + productName: entry.productName, + category: null, + material: null, + accessoryCount: entry.components.length, + accessoryQtySum: sumQty(entry.components), + bomConfigured: '已配置', + bomMatchType: 'sku', + source: 'product_db', + })); +} + +/** + * 列表:MKH 模块化成品(业务库)+ 配件种类/数量(资料库 BOM) + */ +async function getMkhBomList(query = {}) { + const page = Math.max(1, Number.parseInt(query.page, 10) || 1); + const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE; + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize)); + const { conditions, params } = buildFilters(query); + + const whereSql = conditions.length + ? `FROM dbo.jst_sku s WHERE ${MKH_WHERE} AND ${conditions.join(' AND ')}` + : `FROM dbo.jst_sku s WHERE ${MKH_WHERE}`; + + const sortField = String(query.sortField || 'skuId'); + const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const orderSql = sortField === 'skuId' ? `s.sku_id ${sortOrder}` : `s.sku_id ASC`; + + const bom = await loadPgBom(); + const extraRows = await buildExtraRows(bom); + const extraCount = extraRows.length; + + // 追加行占据全局前 extraCount 个位置,业务库行顺延 + const offset = (page - 1) * pageSize; + const extraStart = Math.min(offset, extraCount); + const extraTake = Math.max(0, Math.min(pageSize, extraCount - extraStart)); + const businessOffset = Math.max(0, offset - extraCount); + const businessTake = pageSize - extraTake; + + const pageExtras = extraRows.slice(extraStart, extraStart + extraTake); + + let businessRows = []; + if (businessTake > 0) { + businessRows = await cloudDatabase.query( + `SELECT + s.sku_id AS skuId, + s.name AS productName, + s.category AS category, + s.other_10 AS material + ${whereSql} + ORDER BY ${orderSql} + OFFSET ${businessOffset} ROWS FETCH NEXT ${businessTake} ROWS ONLY`, + params, + { requestTimeout: 180000 } + ); + } + + const [totalRow] = await cloudDatabase.query( + `SELECT COUNT_BIG(*) AS total ${whereSql}`, + params, + { requestTimeout: 180000 } + ); + + const mapped = businessRows.map((row) => { + const skuId = toText(row.skuId); + const matched = matchComponents(bom, skuId); + const components = matched?.components || []; + + return { + skuId, + productName: toText(row.productName), + category: toText(row.category), + material: toText(row.material), + accessoryCount: components.length, + accessoryQtySum: sumQty(components), + bomConfigured: components.length > 0 ? '已配置' : '未配置', + bomMatchType: matched?.matchType || null, + source: 'business_db', + }; + }); + + const rows = [...pageExtras, ...mapped]; + + return { + rows, + total: Number(totalRow?.total || 0) + extraCount, + bomRowCount: bom.entries.length, + bomExtraCount: extraCount, + bomReady: bom.entries.length > 0, + page, + pageSize, + configuredInPage: rows.filter((row) => row.accessoryCount > 0).length, + }; +} + +/** + * 明细:单个 MKH 成品的配件清单 + * 结构按业务模板:配件明细 | 数量 | 配件仓位 + */ +async function getMkhBomDetail(mkhSkuId) { + const skuId = toText(mkhSkuId); + if (!skuId) { + const error = new Error('缺少 MKH 商品编码'); + error.statusCode = 400; + throw error; + } + + const comboRows = await cloudDatabase.query( + `SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category + FROM dbo.jst_sku s + WHERE s.sku_id = @mkhSkuId`, + { mkhSkuId: skuId }, + { requestTimeout: 120000 } + ); + + let combo = comboRows[0] ? { + skuId: toText(comboRows[0].skuId), + productName: toText(comboRows[0].productName), + category: toText(comboRows[0].category), + } : null; + + const bom = await loadPgBom(); + const matched = matchComponents(bom, skuId); + const components = matched?.components || []; + + // 业务库里没有该成品(资料库专有编码)时,用资料库条目补齐基础信息 + if (!combo) { + const entry = bom.entries.find((item) => normalizeCode(item.skuCode) === normalizeCode(skuId)); + if (!entry) { + const error = new Error('未找到该 MKH 商品'); + error.statusCode = 404; + throw error; + } + combo = { skuId, productName: entry.productName, category: null }; + } + + const codes = components.map((item) => item.barcode).filter(Boolean); + const stockMap = new Map(); + const locationMap = new Map(); + + if (codes.length > 0) { + const placeholders = codes.map((_, index) => `@c${index}`).join(', '); + const codeParams = Object.fromEntries(codes.map((code, index) => [`c${index}`, code])); + + try { + const stockRows = await cloudDatabase.query( + `SELECT i.sku_id AS skuId, SUM(i.qty) AS qty + FROM dbo.jst_inventoryInfo i + WHERE i.sku_id IN (${placeholders}) + GROUP BY i.sku_id`, + codeParams, + { requestTimeout: 180000 } + ); + stockRows.forEach((row) => stockMap.set(toText(row.skuId), toNumber(row.qty) ?? 0)); + } catch (error) { + console.warn('[MkhBom] 配件库存查询降级:', error.message); + } + + try { + const locRows = await cloudDatabase.query( + `SELECT sku_id AS skuId, + COALESCE(NULLIF(LTRIM(RTRIM(bin)), N''), NULLIF(LTRIM(RTRIM(other_bin)), N'')) AS location + FROM dbo.jst_sku + WHERE sku_id IN (${placeholders})`, + codeParams, + { requestTimeout: 120000 } + ); + locRows.forEach((row) => locationMap.set(toText(row.skuId), toText(row.location))); + } catch (error) { + console.warn('[MkhBom] 配件仓位查询降级:', error.message); + } + } + + const items = components.map((component, index) => ({ + index: index + 1, + skuId: component.barcode, + barcode: component.barcode, + skuName: component.name, + role: component.role, + qty: toNumber(component.qty) ?? 0, + location: locationMap.get(component.barcode) || null, + stock: stockMap.get(component.barcode) ?? 0, + })); + + return { + skuId: toText(combo.skuId), + productName: toText(combo.productName), + category: toText(combo.category), + items, + bomReady: items.length > 0, + bomMatchType: matched?.matchType || null, + }; +} + +module.exports = { + getMkhBomList, + getMkhBomDetail, +}; diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js new file mode 100644 index 0000000..1d219cd --- /dev/null +++ b/backend/src/services/modularStockMonitorService.js @@ -0,0 +1,513 @@ +'use strict'; + +// 模块化库存监控 —— 配件库存监控 +// +// 需求来源:《模块化库存监控.xlsx》 +// 一级功能:模块化库存监控 二级功能:配件库存监控 +// 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 | +// 仓位 | 近7日日均发货量 | 库存可用天数 | 所在仓库 +// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码 +// +// 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认): +// 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库 +// 目标仓库共 794 个 SKU +// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库 +// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得) +// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU: +// sevenAvgSale 近7日日均发货量 +// jsUseDays 库存可用天数 +// safetyDays 安全天数(默认 15) +// suggestStock 建议备货量 +// jsStock 监控表口径库存(作为 inventoryInfo 的兜底) +// nearSecondSale 近一日发货量 → 当日出库 +// +// ⚠️ 关联键坑(2026-09-12 修正): +// pm_stockMonitor 里同时有 sku 与 sku_id 两列: +// sku = 短款号,如 DNZ040967 —— 用它关联只能命中 34 / 794 +// sku_id = 完整 SKU 编码 DNZ040967N1440V01 —— 必须用它关联,命中 794 / 794 +// 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty +// (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794) +// +// ⚠️ 业务库确实无数据源的字段: +// - 仓位 location:rpt.jst_sku.bin / other_bin 全库 557,475 行填充数为 0 + +const cloudDatabase = require('../config/cloudDatabase'); +const productDb = require('../config/productInformationDatabase'); + +const WAREHOUSE_NAME = '简尚-崔桥工厂半成品包装项目仓'; +// rpt.jst_warehouseInfo 中「简尚-崔桥工厂半成品包装项目仓」对应的 wms_co_id +const WAREHOUSE_WMS_CO_ID = 15718196; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; +const DEFAULT_SAFETY_DAYS = 15; +// 预警阈值:库存可用天数 <= 7 天即预警 +const LOW_STOCK_DAYS_THRESHOLD = 7; + +const WARNING_LEVEL = Object.freeze({ + OUT_OF_STOCK: '缺货', + WARNING: '预警', + NORMAL: '正常', +}); + +// 模块化配置状态: +// 「已配置MKH」= 该 SKU 已在「产品资料系统数据库」中建立商品档案 +// (external.product_catalog_wide_base_v1 按 barcode 匹配) +// 「未配置MKH」= 产品资料系统数据库中查不到该 SKU 档案 +// +// 口径变更说明(2026-09-15,按业务确认): +// 原先用业务库 jst_comboProductItem.src_sku_id 判定「是否被 MKH 组合 BOM 引用」, +// 实测目标仓 794 个 SKU 中只有 32 个命中——因为目标仓(半成品包装仓)与 +// MKH 组合 BOM 的配件(159 个 SKU / 14 个款号)是两批基本不重叠的编码集合, +// 按款号(SPU)放宽也只有 3 个款号重叠。 +// 业务确认改用「产品资料系统数据库是否有商品档案」作为判定口径, +// 实测目标仓 661/794 命中。 +const MKH_STATUS = Object.freeze({ + CONFIGURED: '已配置MKH', + UNCONFIGURED: '未配置MKH', +}); + +// 产品资料覆盖度缓存:避免每次列表请求都回查产品资料系统数据库 +const PRODUCT_COVERAGE_TTL_MS = 10 * 60 * 1000; +let productCoverageCache = null; + +const SORTABLE_FIELDS = Object.freeze({ + skuId: 's.sku_id', + productName: 's.name', + category: 's.category', + realtimeStock: 'realtimeStock', + avgShip7d: 'm.sevenAvgSale', + stockDays: 'm.jsUseDays', + warningLevel: 'warningOrder', + mkhStatus: 'mkhStatus', + location: 'location', + warehouse: 's.other_3', +}); + +/** + * 「已配置MKH」判定表达式。 + * available = true —— 用产品资料库覆盖清单(@productCoveredCsv,逗号分隔的 SKU 编码) + * available = false —— 产品资料库不可用时回退到业务库 MKH BOM 判定,保证页面可用 + */ +const buildConfiguredExistsSql = (coverage = {}) => ( + coverage.available + ? `EXISTS ( + SELECT 1 FROM STRING_SPLIT(@productCoveredCsv, N',') pc WHERE pc.value = s.sku_id + )` + : `EXISTS ( + SELECT 1 FROM JSDataMiddlePlatform.dbo.jst_comboProductItem x WHERE x.src_sku_id = s.sku_id + )` +); + +const buildBaseSql = (coverage = {}) => ` +FROM dbo.jst_sku s +OUTER APPLY ( + SELECT TOP 1 i.qty + FROM dbo.jst_inventoryInfo i + WHERE i.sku_id = s.sku_id AND i.wms_co_id = @wmsCoId + ORDER BY i.updateTime DESC +) inv +OUTER APPLY ( + SELECT TOP 1 m2.sevenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock, + m2.date, m2.jsStock, m2.nearSecondSale + FROM dbo.pm_stockMonitor m2 + WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName + ORDER BY m2.date DESC +) m +OUTER APPLY ( + SELECT CAST(SUM(ISNULL(p.inQty, 0)) AS DECIMAL(18, 2)) AS inQty + FROM dbo.jst_purchaseItem p + WHERE p.sku_id = s.sku_id + AND p.delivery_date >= @monitorDate + AND p.delivery_date < DATEADD(DAY, 1, @monitorDate) +) pin +OUTER APPLY ( + SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode + FROM dbo.fys_skuCostPriceUpdate c + WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id)))) + ORDER BY c.createTime DESC +) cost +-- 模块化配置状态:见文件头部「口径变更说明」 +OUTER APPLY ( + SELECT CAST(CASE WHEN ${buildConfiguredExistsSql(coverage)} THEN 1 ELSE 0 END AS BIT) AS configured +) cfg +WHERE s.other_3 = @warehouseName`; + +const toNumber = (value) => { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const toText = (value) => { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text === '' ? null : text; +}; + +const normalizeWarningLevel = (realtimeStock, stockDays) => { + const stock = Number(realtimeStock || 0); + if (stock <= 0) return WARNING_LEVEL.OUT_OF_STOCK; + + const days = toNumber(stockDays); + if (days !== null && days <= LOW_STOCK_DAYS_THRESHOLD) return WARNING_LEVEL.WARNING; + + return WARNING_LEVEL.NORMAL; +}; + +const WARNING_ORDER = { + [WARNING_LEVEL.OUT_OF_STOCK]: 0, + [WARNING_LEVEL.WARNING]: 1, + [WARNING_LEVEL.NORMAL]: 2, +}; + +/** + * 取目标仓最新监控日(pm_stockMonitor 快照日),作为「当日入库 / 当日出库」的统计基准日。 + * 业务库没有出入库流水表,只能以监控日为基准做按日汇总。 + */ +async function resolveMonitorDate() { + const rows = await cloudDatabase.query( + `SELECT CAST(MAX(date) AS DATE) AS monitorDate + FROM dbo.pm_stockMonitor + WHERE warehouse = @warehouseName`, + { warehouseName: WAREHOUSE_NAME }, + { requestTimeout: 120000 } + ); + + const raw = rows?.[0]?.monitorDate; + if (!raw) return null; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); +} + +const buildFilters = (query = {}, monitorDate = null, coverage = {}) => { + const conditions = []; + const params = { + wmsCoId: WAREHOUSE_WMS_CO_ID, + warehouseName: WAREHOUSE_NAME, + monitorDate, + }; + + // 「已配置MKH」判定依赖产品资料库覆盖清单(逗号分隔的 SKU 编码) + if (coverage.available) { + params.productCoveredCsv = coverage.csv || ''; + } + + const keyword = toText(query.keyword); + if (keyword) { + params.keyword = `%${keyword}%`; + conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)'); + } + + const category = toText(query.category); + if (category) { + params.category = category; + conditions.push('s.category = @category'); + } + + const location = toText(query.location); + if (location) { + params.location = location; + conditions.push(`COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) = @location`); + } + + const mkhStatus = toText(query.mkhStatus); + if (mkhStatus === MKH_STATUS.CONFIGURED || mkhStatus === MKH_STATUS.UNCONFIGURED) { + params.mkhStatus = mkhStatus; + conditions.push(`CASE WHEN cfg.configured = 1 THEN N'${MKH_STATUS.CONFIGURED}' ELSE N'${MKH_STATUS.UNCONFIGURED}' END = @mkhStatus`); + } + + const warningLevel = toText(query.warningLevel); + if (warningLevel && Object.prototype.hasOwnProperty.call(WARNING_ORDER, warningLevel)) { + params.warningOrder = WARNING_ORDER[warningLevel]; + conditions.push(` + CASE + WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0 + WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 + ELSE 2 + END = @warningOrder`); + } + + const onlyWarning = String(query.onlyWarning || '').toLowerCase(); + if (['1', 'true', 'yes'].includes(onlyWarning)) { + conditions.push(`( + COALESCE(inv.qty, m.jsStock, 0) <= 0 + OR (m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD}) + )`); + } + + return { conditions, params }; +}; + +/** + * 用「产品资料系统数据库」补齐商品图片。 + * + * ⚠️ 2026-09-15 实测:目标仓 794 个 SKU 在业务库里图片字段全部为空—— + * rpt.jst_sku.pic / pic_big 794/794 为 NULL + * rpt.fys_skuCostPriceUpdate.pictureSkucode 794/794 为 NULL + * 且 rpt.jst_sku 中只有 pic / pic_big 两个图片列,无其他图片源。 + * 因此图片统一改从产品资料系统数据库(PostgreSQL)取: + * external.product_catalog_wide_base_v1 + * 按 barcode(完整 SKU 编码)匹配,image_url 优先、product_thumbnail_url 兜底 + * 实测目标仓 661 条可匹配,其中 481 条带图。 + * 查询失败时静默降级,不影响列表主流程。 + */ +async function attachProductImages(rows = []) { + const codes = Array.from(new Set(rows.map((row) => toText(row.skuId)).filter(Boolean))); + if (codes.length === 0) { + return rows; + } + + const imageMap = new Map(); + try { + const imageRows = await productDb.query( + `SELECT barcode, image_url, product_thumbnail_url + FROM external.product_catalog_wide_base_v1 + WHERE barcode = ANY($1)`, + [codes] + ); + + imageRows.forEach((row) => { + const code = toText(row.barcode); + if (!code || imageMap.has(code)) return; + const url = toText(row.image_url) || toText(row.product_thumbnail_url); + if (url) imageMap.set(code, url); + }); + } catch (error) { + console.warn('[ModularStock] 读取产品资料库图片失败,降级为业务库图片:', error.message); + } + + return rows.map((row) => ({ + ...row, + productImageUrl: imageMap.get(toText(row.skuId)) || null, + })); +} + +/** + * 解析目标仓 SKU 的「产品资料库覆盖清单」,用于「已配置MKH」判定。 + * 返回 { available, csv, set, total, coveredCount },结果按 TTL 缓存。 + * 产品资料库不可用时 available = false,SQL 侧自动回退到业务库 MKH BOM 判定。 + */ +async function resolveProductCoverage() { + const now = Date.now(); + if ( + productCoverageCache + && productCoverageCache.warehouse === WAREHOUSE_NAME + && now - productCoverageCache.at < PRODUCT_COVERAGE_TTL_MS + ) { + return productCoverageCache; + } + + const skuRows = await cloudDatabase.query( + `SELECT s.sku_id AS skuId FROM dbo.jst_sku s WHERE s.other_3 = @warehouseName`, + { warehouseName: WAREHOUSE_NAME }, + { requestTimeout: 180000 } + ); + const codes = skuRows.map((row) => toText(row.skuId)).filter(Boolean); + + let covered = []; + let available = true; + try { + const rows = await productDb.query( + `SELECT DISTINCT barcode + FROM external.product_catalog_wide_base_v1 + WHERE barcode = ANY($1)`, + [codes] + ); + covered = rows.map((row) => toText(row.barcode)).filter(Boolean); + } catch (error) { + available = false; + console.warn('[ModularStock] 产品资料库覆盖清单查询失败,回退业务库 MKH BOM 判定:', error.message); + } + + productCoverageCache = { + warehouse: WAREHOUSE_NAME, + available, + csv: covered.join(','), + set: new Set(covered), + total: codes.length, + coveredCount: covered.length, + at: now, + }; + return productCoverageCache; +} + +const mapRow = (row = {}) => { + const realtimeStock = Number(row.realtimeStock || 0); + const stockDays = toNumber(row.stockDays); + const safetyDays = toNumber(row.safetyDays) ?? DEFAULT_SAFETY_DAYS; + const warningLevel = normalizeWarningLevel(realtimeStock, stockDays); + + return { + skuId: toText(row.skuId), + productName: toText(row.productName), + category: toText(row.category), + // 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空) + imageUrl: toText(row.productImageUrl) || toText(row.imageUrl), + warningLevel, + warningOrder: WARNING_ORDER[warningLevel], + mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED, + // 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量 + inQtyToday: toNumber(row.inQtyToday) ?? 0, + outQtyToday: toNumber(row.outQtyToday) ?? 0, + realtimeStock, + location: toText(row.location), + avgShip7d: toNumber(row.avgShip7d), + stockDays, + safetyDays, + suggestStock: toNumber(row.suggestStock), + warehouse: toText(row.warehouse) || WAREHOUSE_NAME, + }; +}; + +const buildProjection = () => ` + s.sku_id AS skuId, + s.name AS productName, + s.category AS category, + COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock, + ISNULL(pin.inQty, 0) AS inQtyToday, + ISNULL(m.nearSecondSale, 0) AS outQtyToday, + COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) AS location, + m.sevenAvgSale AS avgShip7d, + m.jsUseDays AS stockDays, + m.safetyDays AS safetyDays, + m.suggestStock AS suggestStock, + s.other_3 AS warehouse, + COALESCE( + NULLIF(LTRIM(RTRIM(s.pic_big)), N''), + NULLIF(LTRIM(RTRIM(s.pic)), N''), + cost.pictureSkucode + ) AS imageUrl, + CASE WHEN cfg.configured = 1 THEN N'已配置MKH' ELSE N'未配置MKH' END AS mkhStatus, + CASE + WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0 + WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 + ELSE 2 + END AS warningOrder, + m.date AS monitorDate`; + +async function getModularStockList(query = {}) { + const page = Math.max(1, Number.parseInt(query.page, 10) || 1); + const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE; + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize)); + const [monitorDate, coverage] = await Promise.all([ + resolveMonitorDate(), + resolveProductCoverage(), + ]); + const { conditions, params } = buildFilters(query, monitorDate, coverage); + const baseSql = buildBaseSql(coverage); + const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql; + + const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock'; + const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + const orderSql = `${SORTABLE_FIELDS[sortField]} ${sortOrder}`; + + const offset = (page - 1) * pageSize; + + // 已按 s.sku_id 排序时不再追加 tie-breaker,否则 ORDER BY 出现重复列报错 169 + const orderClause = SORTABLE_FIELDS[sortField] === 's.sku_id' + ? orderSql + : `${orderSql}, s.sku_id ASC`; + + const rows = await cloudDatabase.query( + `SELECT ${buildProjection()} + ${whereSql} + ORDER BY ${orderClause} + OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`, + params, + { requestTimeout: 180000 } + ); + + const [totalRow] = await cloudDatabase.query( + `SELECT COUNT_BIG(*) AS total ${whereSql}`, + params, + { requestTimeout: 180000 } + ); + + const [summaryRow] = await buildSummary(whereSql, params); + + // 用产品资料系统数据库补齐图片(业务库目标仓图片字段全为空) + const enrichedRows = await attachProductImages(rows); + + return { + rows: enrichedRows.map(mapRow), + total: Number(totalRow?.total || 0), + page, + pageSize, + warehouse: WAREHOUSE_NAME, + monitorDate: summaryRow?.monitorDate || monitorDate, + summary: { + skuCount: summaryRow?.skuCount || 0, + outOfStockCount: summaryRow?.outOfStockCount || 0, + lowStockDaysCount: summaryRow?.lowStockDaysCount || 0, + configuredCount: summaryRow?.configuredCount || 0, + unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0), + totalStock: summaryRow?.totalStock || 0, + }, + }; +} + +async function buildSummary(whereSql, params) { + const rows = await cloudDatabase.query( + `SELECT + COUNT_BIG(*) AS skuCount, + SUM(CASE WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 1 ELSE 0 END) AS outOfStockCount, + CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock, + SUM(CASE WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 ELSE 0 END) AS lowStockDaysCount, + SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount, + MAX(m.date) AS monitorDate + ${whereSql}`, + params, + { requestTimeout: 180000 } + ); + + const row = rows[0] || {}; + + return [{ + skuCount: Number(row.skuCount || 0), + outOfStockCount: Number(row.outOfStockCount || 0), + totalStock: Number(row.totalStock || 0), + lowStockDaysCount: Number(row.lowStockDaysCount || 0), + configuredCount: Number(row.configuredCount || 0), + monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null, + }]; +} + +async function getModularStockFilters() { + const [monitorDate, coverage] = await Promise.all([ + resolveMonitorDate(), + resolveProductCoverage(), + ]); + const params = { + wmsCoId: WAREHOUSE_WMS_CO_ID, + warehouseName: WAREHOUSE_NAME, + monitorDate, + }; + if (coverage.available) { + params.productCoveredCsv = coverage.csv || ''; + } + + const rows = await cloudDatabase.query( + `SELECT DISTINCT s.category + ${buildBaseSql(coverage)} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N'' + ORDER BY s.category`, + params, + { requestTimeout: 180000 } + ); + + return { + categories: rows.map((row) => toText(row.category)).filter(Boolean), + warningLevels: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING, WARNING_LEVEL.NORMAL], + mkhStatuses: [MKH_STATUS.CONFIGURED, MKH_STATUS.UNCONFIGURED], + warehouse: WAREHOUSE_NAME, + }; +} + +module.exports = { + WAREHOUSE_NAME, + WAREHOUSE_WMS_CO_ID, + WARNING_LEVEL, + DEFAULT_SAFETY_DAYS, + getModularStockList, + getModularStockFilters, +}; diff --git a/backend/src/services/opsBIncentiveBoardService.js b/backend/src/services/opsBIncentiveBoardService.js new file mode 100644 index 0000000..5374412 --- /dev/null +++ b/backend/src/services/opsBIncentiveBoardService.js @@ -0,0 +1,430 @@ +const dayjs = require('dayjs'); +const database = require('../config/database'); +const jianSunDb = require('../config/jianSunDatabase'); +const cloudDatabase = require('../config/cloudDatabase'); + +const DEFAULT_SCOPE_START = '2026-07-15'; +const DEFAULT_SCOPE_END = '2026-07-21'; +const DEFAULT_END_DATE = '2026-09-02'; +const AWARD_PER_ID = 400; +const JULY_START = '2026-07-01'; +const JULY_END = '2026-07-31'; + +function text(value, fallback = '') { + const valueText = String(value ?? '').trim(); + return valueText || fallback; +} + +function validDate(value, fallback) { + const valueText = text(value, fallback); + return /^\d{4}-\d{2}-\d{2}$/u.test(valueText) && dayjs(valueText).isValid() ? valueText : fallback; +} + +function addDays(value, offset) { + return dayjs(value).add(offset, 'day').format('YYYY-MM-DD'); +} + +function resolveQuery(query = {}) { + const scopeStart = validDate(query.scopeStart || query.startDate, DEFAULT_SCOPE_START); + const scopeEnd = validDate(query.scopeEnd || query.endDate, DEFAULT_SCOPE_END); + const endDate = validDate(query.metricEnd || query.asOfDate, DEFAULT_END_DATE); + const metricStart = addDays(endDate, -29); + const feeCap = scopeStart >= '2026-07-22' ? 0.88 : 0.9; + return { + scopeStart, + scopeEnd: scopeEnd < scopeStart ? scopeStart : scopeEnd, + metricStart, + metricEnd: endDate, + feeCap, + ider: text(query.ider), + shopName: text(query.shopName), + spu: text(query.spu), + goodsId: text(query.goodsId), + }; +} + +function normalizeNumber(value) { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeRow(row = {}, query) { + const shippedDays30d = Number(row.shippedDays30d || 0); + const performance30d = normalizeNumber(row.performance30d); + const fee30d = normalizeNumber(row.fee30d); + const feeRatio30d = performance30d > 0 && fee30d !== null ? fee30d / performance30d : null; + const firstCreatedAt = row.firstCreatedAt ? new Date(row.firstCreatedAt) : null; + const listingStart = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime()) + ? dayjs(firstCreatedAt).startOf('day') + : null; + const metricEndDay = dayjs(query.metricEnd).startOf('day'); + const listedCalendarDays = listingStart && metricEndDay.isValid() + ? Math.max(1, metricEndDay.diff(listingStart, 'day') + 1) + : 30; + const metricDenominatorDays = Math.min(30, listedCalendarDays); + const sellThroughRate30d = shippedDays30d / metricDenominatorDays; + const feeQualified = performance30d > 0 && feeRatio30d !== null && feeRatio30d <= query.feeCap; + const bonusQualified = feeQualified && sellThroughRate30d > 0.3; + const listingDays = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime()) + ? Math.max(0, dayjs(query.metricEnd).diff(dayjs(firstCreatedAt), 'day')) + : null; + return { + ...row, + goodsId: text(row.goodsId), + ider: text(row.ider), + shopName: text(row.shopName), + spu: text(row.spu), + goodsName: text(row.goodsName), + linkType: text(row.linkType), + status: Number(row.onsale || 0) === 1 ? '在售' : '已下架', + skuCount: Number(row.skuCount || 0), + listingDays, + julyPerformance: normalizeNumber(row.julyPerformance), + julyFee: normalizeNumber(row.julyFee), + julyFeeRatio: normalizeNumber(row.julyPerformance) > 0 ? normalizeNumber(row.julyFee) / normalizeNumber(row.julyPerformance) : null, + julyGoodsCount: normalizeNumber(row.julyGoodsCount) || 0, + julyShippedDays: Number(row.julyShippedDays || 0), + julySellThroughRate: Number(row.julyShippedDays || 0) / 31, + performance30d, + fee30d, + feeRatio30d, + goodsCount30d: normalizeNumber(row.goodsCount30d) || 0, + shippedDays30d, + metricDenominatorDays, + sellThroughRate30d, + feeQualified, + sellThroughQualified: sellThroughRate30d > 0.3, + bonusQualified, + bonus: bonusQualified ? AWARD_PER_ID : 0, + }; +} + +function buildParams(query) { + const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000); + return { + scopeStart: query.scopeStart, + scopeEnd: query.scopeEnd, + metricStart: query.metricStart, + metricEnd: query.metricEnd, + julyStart: JULY_START, + julyEnd: JULY_END, + ider: query.ider, + shopName: query.shopName, + spu: query.spu, + goodsId: query.goodsId, + scopeStartEpochSec: epoch(query.scopeStart), + scopeEndEpochSec: epoch(addDays(query.scopeEnd, 1)), + scopeStartEpochMs: epoch(query.scopeStart) * 1000, + scopeEndEpochMs: epoch(addDays(query.scopeEnd, 1)) * 1000, + }; +} + +function emptyMetrics() { + return { julyPerformance: 0, julyFee: 0, julyGoodsCount: 0, julyShippedDays: 0, performance30d: 0, fee30d: 0, goodsCount30d: 0, shippedDays30d: 0 }; +} + +async function loadSalesMetrics(goodsIds, query) { + const metrics = new Map(); + const ids = [...new Set((goodsIds || []).map((value) => String(value || '').trim()).filter((value) => /^\d+$/u.test(value)))]; + for (let offset = 0; offset < ids.length; offset += 1000) { + const chunk = ids.slice(offset, offset + 1000); + const inList = chunk.map((id) => `'${id}'`).join(','); + const rows = await cloudDatabase.query(` + SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) AS goodsId, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS julyPerformance, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS julyFee, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS julyGoodsCount, + COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS julyShippedDays, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS performance30d, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS fee30d, + SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS goodsCount30d, + COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS shippedDays30d + FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK) + WHERE d.item_id IN (${inList}) + AND d.stat_date BETWEEN @julyStart AND @metricEnd + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多') + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0 + GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) + `, { julyStart: JULY_START, julyEnd: JULY_END, metricStart: query.metricStart, metricEnd: query.metricEnd }, { requestTimeout: 180000 }); + rows.forEach((row) => metrics.set(String(row.goodsId), row)); + } + return metrics; +} + +// pdd_Skus 由外部采集系统写入,存在残缺批次(如 2026-09-02 仅 113 个商品)。 +// 从 upperBound 往回 7 天内挑最新一个商品数不低于窗口最大值 70% 的批次,避免用到不完整采集。 +async function pickSkuCaptureTime(upperBound) { + const end = dayjs(upperBound).isAfter(dayjs(), 'day') ? dayjs().format('YYYY-MM-DD') : dayjs(upperBound).format('YYYY-MM-DD'); + const dates = Array.from({ length: 7 }, (_, index) => dayjs(end).subtract(index, 'day').format('YYYY-MM-DD')); + const counts = await Promise.all(dates.map((captureTime) => jianSunDb.query( + 'SELECT COUNT(DISTINCT goodsId) AS goodsCount FROM dbo.pdd_Skus WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE createTime = @captureTime', + { captureTime }, { requestTimeout: 60000 } + ))); + const mapped = dates.map((dateValue, index) => ({ date: dateValue, goodsCount: Number(counts[index]?.[0]?.goodsCount || 0) })); + const maxCount = Math.max(...mapped.map((item) => item.goodsCount)); + if (maxCount <= 0) return null; + return (mapped.find((item) => item.goodsCount >= maxCount * 0.7) || mapped.find((item) => item.goodsCount === maxCount)).date; +} + +async function getOpsBIncentiveBoardLive(input = {}) { + const query = resolveQuery(input); + const livePeriod = findPeriod(await loadPeriods(), query); + if (livePeriod) query.feeCap = livePeriod.feeCap; + const targetRows = await jianSunDb.query(`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid))) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.ider)))) AS ider, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), tm.shopname)))) AS shopName, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.spu)))) AS spu, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.linkType)))) AS linkType FROM dbo.op_TargetManagement tm WITH (NOLOCK) WHERE tm.dept = N'运营中心B' GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid)))`, {}, { requestTimeout: 60000 }); + const latestCreateTime = await pickSkuCaptureTime(query.metricEnd); + if (!latestCreateTime) throw new Error('pdd_Skus 近7天无可用完整采集批次'); + const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000); + const startSec = epoch(query.scopeStart); + const endSec = epoch(addDays(query.scopeEnd, 1)); + const skuRows = await jianSunDb.query(`SELECT CONVERT(NVARCHAR(100), s.goodsId) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.goodsName)))) AS goodsName, MIN(DATEADD(SECOND, CONVERT(INT, CASE WHEN s.createdAt >= 1000000000000 THEN s.createdAt / 1000 ELSE s.createdAt END), CONVERT(DATETIME2, '1970-01-01'))) AS firstCreatedAt, MAX(CASE WHEN TRY_CONVERT(INT, s.isOnsale) = 1 THEN 1 ELSE 0 END) AS onsale, COUNT_BIG(1) AS skuCount FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE s.createTime = @latestCreateTime AND ((s.createdAt >= @startSec AND s.createdAt < @endSec) OR (s.createdAt >= @startMs AND s.createdAt < @endMs)) GROUP BY s.goodsId`, { latestCreateTime, startSec, endSec, startMs: startSec * 1000, endMs: endSec * 1000 }, { requestTimeout: 180000 }); + const targetMap = new Map(targetRows.map((row) => [String(row.goodsId), row])); + const scopeRows = skuRows.flatMap((row) => { + const target = targetMap.get(String(row.goodsId)); + if (!target) return []; + if ((query.ider && target.ider !== query.ider) || (query.shopName && target.shopName !== query.shopName) || (query.spu && !String(target.spu || '').includes(query.spu)) || (query.goodsId && !String(row.goodsId).includes(query.goodsId))) return []; + return [{ ...row, ...target }]; + }); + const salesMetrics = await loadSalesMetrics(scopeRows.map((row) => row.goodsId), query); + const rows = scopeRows.map((row) => ({ ...emptyMetrics(), ...row, ...(salesMetrics.get(String(row.goodsId)) || {}) })); + let dwsLatestRows = []; + try { + dwsLatestRows = await cloudDatabase.query(`SELECT MAX(TRY_CONVERT(DATE, stat_date)) AS latestDate FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] WITH (NOLOCK) WHERE ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), platform))), N'') IN (N'Pinduoduo', N'拼多多')`, {}, { requestTimeout: 60000 }); + } catch (error) { + console.warn('[OpsBIncentiveBoard] latest DWS date unavailable:', error.message); + } + const normalizedRows = (rows || []).map((row) => normalizeRow(row, query)); + const qualifiedRows = normalizedRows.filter((row) => row.bonusQualified); + const ownerMap = new Map(); + normalizedRows.forEach((row) => { + const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 }; + current.goodsIdCount += 1; + current.goodsCount30d += row.goodsCount30d; + current.shippedDays30d += row.shippedDays30d; + current.feeQualifiedCount += row.feeQualified ? 1 : 0; + current.bonusQualifiedCount += row.bonusQualified ? 1 : 0; + current.bonus += row.bonus; + ownerMap.set(row.ider, current); + }); + return { + rows: normalizedRows, + bonusRows: qualifiedRows, + ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount), + summary: { + goodsIdCount: normalizedRows.length, + feeQualifiedCount: normalizedRows.filter((row) => row.feeQualified).length, + sellThroughQualifiedCount: normalizedRows.filter((row) => row.sellThroughQualified).length, + bonusQualifiedCount: qualifiedRows.length, + bonus: qualifiedRows.length * AWARD_PER_ID, + awardPerId: AWARD_PER_ID, + }, + query: { ...query, julyStart: JULY_START, julyEnd: JULY_END, latestDwsDate: dwsLatestRows?.[0]?.latestDate || null }, + }; +} + +// 口径来源:运营中心B激励方案批次表。pullDate = 拉取(冻结)日期,统计窗口固定为 pullDate 往前 30 天。 +// 批次配置存 MySQL ops_b_incentive_periods 表(可动态新增批次,无需发版),此列表仅作首次种子与兜底。 +const DEFAULT_SNAPSHOT_PERIODS = [ + { scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 }, + { scopeStart: '2026-07-22', scopeEnd: '2026-07-28', pullDate: '2026-09-05', feeCap: 0.88 }, + { scopeStart: '2026-07-29', scopeEnd: '2026-08-04', pullDate: '2026-09-12', feeCap: 0.875 }, + { scopeStart: '2026-08-05', scopeEnd: '2026-08-11', pullDate: '2026-09-19', feeCap: 0.87 }, + { scopeStart: '2026-08-12', scopeEnd: '2026-08-18', pullDate: '2026-09-26', feeCap: 0.865 }, + { scopeStart: '2026-08-19', scopeEnd: '2026-08-25', pullDate: '2026-10-03', feeCap: 0.86 }, + { scopeStart: '2026-08-26', scopeEnd: '2026-09-01', pullDate: '2026-10-10', feeCap: 0.855 }, + { scopeStart: '2026-09-02', scopeEnd: '2026-09-08', pullDate: '2026-10-17', feeCap: 0.85 }, + { scopeStart: '2026-09-09', scopeEnd: '2026-09-15', pullDate: '2026-10-24', feeCap: 0.845 }, + { scopeStart: '2026-09-16', scopeEnd: '2026-09-22', pullDate: '2026-10-31', feeCap: 0.84 }, +]; + +function makePeriodKey(scopeStart, scopeEnd) { + return `ops-b-${scopeStart.replace(/-/g, '')}-${scopeEnd.replace(/-/g, '')}`; +} + +let periodsCache = null; +let periodsCacheExpiresAt = 0; + +async function loadPeriods() { + if (periodsCache && Date.now() < periodsCacheExpiresAt) return periodsCache; + let rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end"); + if (!rows.length) { + for (const period of DEFAULT_SNAPSHOT_PERIODS) { + await database.local.query( + 'INSERT IGNORE INTO ops_b_incentive_periods (scope_start, scope_end, pull_date, fee_cap, status) VALUES (?, ?, ?, ?, ?)', + [period.scopeStart, period.scopeEnd, period.pullDate, period.feeCap, 'active'] + ); + } + rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end"); + } + periodsCache = rows + .filter((row) => String(row.status || 'active') === 'active') + .map((row) => ({ + key: makePeriodKey(dayjs(row.scope_start).format('YYYY-MM-DD'), dayjs(row.scope_end).format('YYYY-MM-DD')), + scopeStart: dayjs(row.scope_start).format('YYYY-MM-DD'), + scopeEnd: dayjs(row.scope_end).format('YYYY-MM-DD'), + pullDate: dayjs(row.pull_date).format('YYYY-MM-DD'), + feeCap: Number(row.fee_cap), + })); + periodsCacheExpiresAt = Date.now() + 60000; + return periodsCache; +} + +function findPeriod(periods, query) { + return periods.find((period) => period.scopeStart === query.scopeStart && period.scopeEnd === query.scopeEnd) || null; +} + +async function readSnapshot(period) { + const rows = await database.local.query( + 'SELECT rows_json, summary_json, metric_end, finalized, generated_at FROM ops_b_incentive_snapshots WHERE snapshot_key = ? AND status = ? LIMIT 1', + [period.key, 'completed'] + ); + const row = rows[0]; + if (!row) return null; + try { + const meta = JSON.parse(row.summary_json || '{}'); + return { rows: JSON.parse(row.rows_json || '[]'), summary: meta.summary || {}, ownerSummary: meta.ownerSummary || [], metricEnd: row.metric_end, finalized: Boolean(row.finalized), generatedAt: row.generated_at }; + } catch (error) { + console.warn('[OpsBIncentiveSnapshot] invalid snapshot:', error.message); + return null; + } +} + +async function writeSnapshot(period, query, result, finalized) { + await database.local.query(` + INSERT INTO ops_b_incentive_snapshots + (snapshot_key, scope_start, scope_end, metric_end, status, finalized, rows_json, summary_json, generated_at, completed_at, error_message) + VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, NOW(), NOW(), NULL) + ON DUPLICATE KEY UPDATE + metric_end = VALUES(metric_end), finalized = VALUES(finalized), rows_json = VALUES(rows_json), summary_json = VALUES(summary_json), + generated_at = VALUES(generated_at), completed_at = VALUES(completed_at), status = 'completed', error_message = NULL + `, [period.key, query.scopeStart, query.scopeEnd, query.metricEnd, finalized ? 1 : 0, JSON.stringify(result.rows || []), JSON.stringify({ summary: result.summary || {}, ownerSummary: result.ownerSummary || [] })]); +} + +async function getOpsBIncentiveBoard(input = {}) { + const query = resolveQuery(input); + const period = findPeriod(await loadPeriods(), query); + if (period) query.feeCap = period.feeCap; + if (!period) return getOpsBIncentiveBoardLive(input); + const existing = await readSnapshot(period); + if (!existing) { + return { + pending: true, + rows: [], + bonusRows: [], + ownerSummary: [], + summary: {}, + query: { ...query, latestDwsDate: null, snapshot: { cached: false, finalized: false, pending: true } }, + }; + } + const filteredRows = existing.rows.filter((row) => ( + (!query.ider || row.ider === query.ider) + && (!query.shopName || row.shopName === query.shopName) + && (!query.spu || String(row.spu || '').includes(query.spu)) + && (!query.goodsId || String(row.goodsId || '').includes(query.goodsId)) + )); + const ownerMap = new Map(); + filteredRows.forEach((row) => { + const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 }; + current.goodsIdCount += 1; + current.goodsCount30d += Number(row.goodsCount30d || 0); + current.shippedDays30d += Number(row.shippedDays30d || 0); + current.feeQualifiedCount += row.feeQualified ? 1 : 0; + current.bonusQualifiedCount += row.bonusQualified ? 1 : 0; + current.bonus += Number(row.bonus || 0); + ownerMap.set(row.ider, current); + }); + const bonusRows = filteredRows.filter((row) => row.bonusQualified); + return { + ...existing, + rows: filteredRows, + bonusRows, + ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount), + summary: { + goodsIdCount: filteredRows.length, + feeQualifiedCount: filteredRows.filter((row) => row.feeQualified).length, + sellThroughQualifiedCount: filteredRows.filter((row) => row.sellThroughQualified).length, + bonusQualifiedCount: bonusRows.length, + bonus: bonusRows.reduce((sum, row) => sum + Number(row.bonus || 0), 0), + awardPerId: AWARD_PER_ID, + }, + query: { + ...query, + metricEnd: dayjs(existing.metricEnd).format('YYYY-MM-DD'), + latestDwsDate: null, + snapshot: { cached: true, finalized: existing.finalized, generatedAt: existing.generatedAt }, + }, + }; +} + +async function refreshOpsBIncentiveSnapshots(source = 'scheduled') { + const today = dayjs().format('YYYY-MM-DD'); + const results = []; + for (const period of await loadPeriods()) { + const { scopeStart, scopeEnd } = period; + // 拉取日次日首刷才定档,确保统计窗口包含拉取日全天数据(数仓滞后约一天)。 + const finalized = today > period.pullDate; + const existing = await readSnapshot(period); + if (existing?.finalized) { + results.push({ key: period.key, rows: existing.rows.length, finalized: true, skipped: true, source }); + continue; + } + const result = await getOpsBIncentiveBoardLive({ scopeStart, scopeEnd, metricEnd: period.pullDate }); + await writeSnapshot(period, { scopeStart, scopeEnd, metricEnd: period.pullDate }, result, finalized); + results.push({ key: period.key, rows: result.rows.length, finalized, source }); + } + return results; +} + +async function getOpsBIncentiveBatches() { + const periods = await loadPeriods(); + const snapshots = await database.local.query( + "SELECT snapshot_key, finalized, generated_at, JSON_EXTRACT(summary_json, '$.summary.goodsIdCount') goodsIdCount, JSON_EXTRACT(summary_json, '$.summary.bonusQualifiedCount') bonusQualifiedCount, JSON_EXTRACT(summary_json, '$.summary.bonus') bonus FROM ops_b_incentive_snapshots WHERE status = 'completed'" + ); + const snapshotMap = new Map(snapshots.map((row) => [row.snapshot_key, row])); + return periods.map((period, index) => { + const snapshot = snapshotMap.get(period.key) || {}; + return { + ...period, + batchNo: index + 1, + finalized: Boolean(snapshot.finalized), + generatedAt: snapshot.generated_at || null, + goodsIdCount: Number(snapshot.goodsIdCount || 0), + bonusQualifiedCount: Number(snapshot.bonusQualifiedCount || 0), + bonus: Number(snapshot.bonus || 0), + }; + }); +} + +async function getOpsBIncentiveDaily(input = {}) { + const query = resolveQuery(input); + const params = buildParams(query); + if (!query.goodsId) return { rows: [], query }; + const rows = await cloudDatabase.query(` + WITH daily AS ( + SELECT TRY_CONVERT(DATE, d.stat_date) AS statDate, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.goods_count), 0)) AS goodsCount, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_performance), 0)) AS realPerformance, + SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_conclude_fee), 0)) AS realFee + FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK) + WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) = @goodsId + AND TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd + AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多') + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0 + AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0 + GROUP BY TRY_CONVERT(DATE, d.stat_date) + ) SELECT statDate, goodsCount, CASE WHEN goodsCount > 0 THEN N'是' ELSE N'否' END AS isActive, realPerformance, realFee + FROM daily ORDER BY statDate ASC + `, params, { requestTimeout: 120000 }); + return { rows: rows || [], query }; +} + +module.exports = { getOpsBIncentiveBoard, getOpsBIncentiveBoardLive, getOpsBIncentiveDaily, getOpsBIncentiveBatches, refreshOpsBIncentiveSnapshots, resolveQuery, pickSkuCaptureTime, AWARD_PER_ID, DEFAULT_SNAPSHOT_PERIODS }; diff --git a/backend/src/services/productMonitoringService.js b/backend/src/services/productMonitoringService.js new file mode 100644 index 0000000..8f08983 --- /dev/null +++ b/backend/src/services/productMonitoringService.js @@ -0,0 +1,943 @@ +'use strict'; + +// 商品监控:全公司在售商品的基础档案 + 库存 + 标签分级 + 部门运营指标 +// +// 数据来源(2026-09-11 实测确认): +// 主表 业务库 rpt.pm_stockMonitor —— 脑图「商品监控」的原始数据源 +// (5.6 万行 / 日更 / 含 sevenAvgSale 日销、jsStock 库存、jsUseDays 可用天数、 +// suggestStock 建议备货、purchase 采购负责人、supplier、warehouse、sku_idStatus) +// 补充 业务库 rpt.jst_sku —— 图片、条码(sku_code)、材质、颜色、尺寸 +// 配置 系统库 product_scale_thresholds / product_profit_levels / product_sabc_mappings +// (公司 SABC,由「系统设置 → 商品标签配置」维护) +// 配置 系统库 product_tag_rules (部门标签 G-S/G-A/G-B/G-C) +// 配置 系统库 department_sku_owners (部门 SKU 负责人) +// +// 判定口径: +// - 公司标签 S/A/B/C/D ← productTagConfigService.resolveProductTags()(业绩 + 日销双命中) +// - 部门标签 G-S/G-A/G-B/G-C ← product_tag_rules(tag_scope='department'),左闭右开,升序命中即停 +// +// ⚠️ 已知留空字段(等业务给源表后再填): +// - 在途库存 inTransitStock → null,前端显示 `--` +// - 部门未发货数量 departmentUnshipped → null,前端显示 `--` +// (pm_stockMonitor.outOfStock 语义未确认,本次不用) + +const database = require('../config/database'); +const cloudDatabase = require('../config/cloudDatabase'); +const productTagConfigService = require('./productTagConfigService'); + +const REPORT_SOURCE_TABLE = 'dbo.pm_stockMonitor'; +// 销售明细(最新,2026-09-09):按 skuoid 聚合可得业绩/销售额、部门、主渠道、主运营 +const SALES_DETAIL_TABLE = 'dbo.dws_sn_sales_full_info_di'; +const SALES_WINDOW_DAYS = 7; +// 公司标签 S/A/B/C 的「业绩」阈值(系统设置 → 商品标签配置)是 30 天量级 +// (如「住宅家具 - 书架」爆款线 48000),因此标签判定用 30 天销售额,日销仍用宽表 sevenAvgSale +const SALES_PERFORMANCE_WINDOW_DAYS = 30; + +const SALE_STATUS_OPTIONS = Object.freeze(['销售', '停产', '退市']); +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; + +const DEPARTMENT_TAG_SCOPE = 'department'; +const DEPARTMENT_TAG_LEVELS = Object.freeze(['G-S', 'G-A', 'G-B', 'G-C']); +const DEPARTMENT_METRIC_KEYS = Object.freeze(['sales_qty_7d', 'sales_amount_7d']); + +// ==================== 表结构 ==================== + +async function ensureTable(tableName, createSql) { + await database.local.query(createSql); + const rows = await database.local.query( + `SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ? + LIMIT 1`, + [tableName] + ); + if (rows.length === 0) { + throw new Error(`Table ${tableName} create failed`); + } +} + +async function ensureProductMonitoringSchema() { + await ensureTable( + 'product_tag_rules', + `CREATE TABLE IF NOT EXISTS product_tag_rules ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department', + tag_level VARCHAR(16) NOT NULL COMMENT '部门标签 G-S/G-A/G-B/G-C', + metric_key VARCHAR(64) NOT NULL COMMENT 'sales_qty_7d | sales_amount_7d', + threshold_min DECIMAL(18,4) NULL, + threshold_max DECIMAL(18,4) NULL, + priority INT NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key), + KEY idx_scope_enabled (tag_scope, enabled), + KEY idx_metric (metric_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'` + ); + + await ensureTable( + 'department_sku_owners', + `CREATE TABLE IF NOT EXISTS department_sku_owners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + department_id BIGINT UNSIGNED NOT NULL, + owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser', + user_id BIGINT UNSIGNED NOT NULL, + spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id), + KEY idx_dept (department_id), + KEY idx_spu (spu), + KEY idx_user (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'` + ); + + // 部门标签默认四档(不存在时播种,口径:近 7 天销售件数) + const existing = await database.local.query( + 'SELECT COUNT(*) AS c FROM product_tag_rules WHERE tag_scope = ?', + [DEPARTMENT_TAG_SCOPE] + ); + if (Number(existing[0]?.c || 0) === 0) { + const seeds = [ + ['G-S', 500, null, 1], + ['G-A', 200, 500, 2], + ['G-B', 50, 200, 3], + ['G-C', 0, 50, 4], + ]; + for (const [level, min, max, priority] of seeds) { + await database.local.query( + `INSERT INTO product_tag_rules + (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled) + VALUES (?, ?, 'sales_qty_7d', ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE tag_scope = tag_scope`, + [DEPARTMENT_TAG_SCOPE, level, min, max, priority] + ); + } + } +} + +// ==================== 配置读取 ==================== + +async function loadDepartmentTagRules() { + const rows = await database.local.query( + `SELECT id, tag_level, metric_key, threshold_min, threshold_max, priority, enabled + FROM product_tag_rules + WHERE tag_scope = ? AND enabled = 1 + ORDER BY priority ASC, id ASC`, + [DEPARTMENT_TAG_SCOPE] + ); + return rows.map((row) => ({ + id: row.id, + tagLevel: row.tag_level, + metricKey: row.metric_key, + thresholdMin: row.threshold_min === null ? null : Number(row.threshold_min), + thresholdMax: row.threshold_max === null ? null : Number(row.threshold_max), + priority: Number(row.priority || 0), + })); +} + +// 公司 SKU 负责人:取自 rpt.V_caterAndOper.oper,**SPU 粒度**(方案第 12 条已确认接受该粒度) +async function loadCompanyOwnerIndex() { + try { + const rows = await cloudDatabase.query( + `SELECT spu, oper FROM dbo.V_caterAndOper WHERE oper IS NOT NULL AND oper <> N''` + ); + const index = new Map(); + rows.forEach((row) => { + const spu = String(row.spu || '').trim(); + const oper = String(row.oper || '').trim(); + if (spu && oper && !index.has(spu)) index.set(spu, oper); + }); + return index; + } catch (error) { + console.warn('[ProductMonitoring] 加载公司SKU负责人失败,该列将留空:', error.message); + return new Map(); + } +} + +async function loadDepartmentSkuOwners() { + const rows = await database.local.query( + `SELECT o.department_id, o.owner_type, o.user_id, o.spu, u.name AS user_name, d.name AS department_name + FROM department_sku_owners o + LEFT JOIN users u ON u.id = o.user_id + LEFT JOIN departments d ON d.id = o.department_id` + ); + return rows.map((row) => ({ + departmentId: row.department_id, + departmentName: row.department_name, + ownerType: row.owner_type, + userId: row.user_id, + ownerName: row.user_name, + spu: row.spu, + })); +} + +// 部门标签判定:左闭右开 [min, max),按 priority 升序命中即停;未命中返回 null +function matchDepartmentTag(rules, salesQty7d) { + if (!rules.length) return null; + // ⚠️ 无销售记录(null)不等于「销量 0」:Number(null) 是 0,会把无数据行误判成最低档 + if (salesQty7d === null || salesQty7d === undefined || salesQty7d === '') return null; + const value = Number(salesQty7d); + if (!Number.isFinite(value)) return null; + for (const rule of rules) { + const geMin = rule.thresholdMin === null || value >= rule.thresholdMin; + const ltMax = rule.thresholdMax === null || value < rule.thresholdMax; + if (geMin && ltMax) return rule.tagLevel; + } + return null; +} + +// ==================== 业务库查询 ==================== + +// 可排序字段(排序在内存完成,直接用行字段名) +const DERIVED_SORT_FIELDS = new Set([ + 'skuId', 'spu', 'category', 'productName', 'supplier', 'warehouse', 'purchaser', 'saleStatus', + 'companyOwner', 'companySales7d', 'availableStock', 'stockDays', 'suggestedReplenish', + 'companyTag', 'department', 'departmentTag', 'departmentOwner', + 'performance7d', 'performance30d', 'departmentSales7d', 'mainSalesChannel', 'mainSalesOperator', +]); + +async function getLatestReportDate() { + const rows = await cloudDatabase.query( + `SELECT MAX(date) AS latestDate FROM ${REPORT_SOURCE_TABLE}` + ); + const latest = rows[0]?.latestDate; + if (!latest) { + throw new Error('业务库 pm_stockMonitor 无数据,无法确定最新统计日期'); + } + return latest; +} + +// 全量基础行:pm_stockMonitor 主表 + jst_sku 补图片/条码/材质/颜色/尺寸 +// +// ⚠️ 只按统计日过滤、不带任何业务条件——结果会被 buildDataset 缓存, +// 业务条件(状态/品类/SPU/SKU/供应商…)与标签过滤都在内存里做,见 applyBaseFilters。 +async function queryAllReportRows(latestDate) { + const params = { latestDate }; + + const dataSql = ` + SELECT + m.sku_id AS skuId, + m.sku AS skuCode, + m.spu AS spu, + m.skuName AS productName, + m.category AS category, + m.supplier AS supplier, + m.warehouse AS warehouse, + m.purchase AS purchaser, + m.area AS area, + m.cooperation AS cooperation, + m.brand AS brand, + m.sku_idStatus AS saleStatus, + m.jsStock AS availableStock, + m.jsUseDays AS stockDays, + m.sevenAvgSale AS companySales7d, + m.suggestStock AS suggestedReplenish, + m.produceCycle AS produceCycle, + m.safetyDays AS safetyDays, + s.sku_code AS barcode, + s.color AS colorRaw, + s.properties_value AS sizeRaw, + COALESCE(NULLIF(s.other_10, N''), NULLIF(s.other_2, N'')) AS material, + COALESCE(s.pic_big, s.pic) AS imageUrl + FROM ${REPORT_SOURCE_TABLE} m + LEFT JOIN dbo.jst_sku s ON s.sku_id = m.sku_id + WHERE m.date = @latestDate + `; + + const rows = await cloudDatabase.query(dataSql, params, { requestTimeout: 180000 }); + + return { + rows: rows.map((row) => ({ + skuId: row.skuId, + skuCode: row.skuCode || row.skuId, + spu: row.spu, + barcode: row.barcode || row.skuId, + imageUrl: row.imageUrl || null, + category: row.category, + productName: row.productName, + material: row.material || null, + color: row.colorRaw || null, + size: row.sizeRaw || null, + saleStatus: row.saleStatus, + supplier: row.supplier || null, + warehouse: row.warehouse || null, + purchaser: row.purchaser || null, + area: row.area || null, + cooperation: row.cooperation || null, + brand: row.brand || null, + companySales7d: toNumberOrNull(row.companySales7d), + availableStock: toNumberOrNull(row.availableStock), + inTransitStock: null, // 待业务提供源表 + stockDays: toNumberOrNull(row.stockDays), + suggestedReplenish: toNumberOrNull(row.suggestedReplenish), + produceCycle: toNumberOrNull(row.produceCycle), + safetyDays: toNumberOrNull(row.safetyDays), + departmentUnshipped: null, // 待业务确认 outOfStock 语义 + })), + latestDate, + }; +} + +function toNumberOrNull(value) { + if (value === null || value === undefined || value === '') return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +// ==================== 汇总统计 ==================== + +// 汇总基于「过滤后的最终结果」统计,保证与列表口径一致 +function buildSummary(rows = []) { + let outOfStockCount = 0; + let lowStockDaysCount = 0; + let totalSuggestedReplenish = 0; + let needReplenishCount = 0; + + rows.forEach((row) => { + const stock = row.availableStock === null ? 0 : Number(row.availableStock); + const days = row.stockDays === null ? 0 : Number(row.stockDays); + if (stock <= 0) outOfStockCount += 1; + else if (days < 7) lowStockDaysCount += 1; + + // ⚠️ suggestStock 是有符号的净需求(库存过剩为负数),合计只累计正值,避免负负相加失真 + const replenish = row.suggestedReplenish === null ? 0 : Number(row.suggestedReplenish); + if (replenish > 0) { + totalSuggestedReplenish += replenish; + needReplenishCount += 1; + } + }); + + return { + skuCount: rows.length, + outOfStockCount, + lowStockDaysCount, + totalSuggestedReplenish: Math.round(totalSuggestedReplenish), + needReplenishCount, + }; +} + +// ==================== 销售明细聚合(业绩 / 部门 / 主渠道 / 主运营) ==================== + +// 取销售明细表的最新统计日 +async function getLatestSalesDate() { + const rows = await cloudDatabase.query( + `SELECT MAX(stat_date) AS latestDate FROM ${SALES_DETAIL_TABLE}` + ); + return rows[0]?.latestDate || null; +} + +// 按 skuoid 聚合近 7 天:销售额(业绩口径)、部门、主销售渠道、主销售运营 +async function loadSalesAggregatesBySku() { + try { + const latestDate = await getLatestSalesDate(); + if (!latestDate) return new Map(); + + const rows = await cloudDatabase.query( + `SELECT + d.skuoid AS skuoid, + SUM(d.sales_amount) AS salesAmount30d, + SUM(d.goods_count) AS salesQty30d, + SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.sales_amount ELSE 0 END) AS salesAmount7d, + SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.goods_count ELSE 0 END) AS salesQty7d, + MAX(d.profit_center) AS department, + MAX(d.shop_dept) AS shopDept + FROM ${SALES_DETAIL_TABLE} d + WHERE d.stat_date >= DATEADD(day, -@perfDays, @latestDate) + AND d.skuoid IS NOT NULL AND d.skuoid <> N'' + GROUP BY d.skuoid`, + { days: SALES_WINDOW_DAYS, perfDays: SALES_PERFORMANCE_WINDOW_DAYS, latestDate }, + { requestTimeout: 180000 } + ); + + const index = new Map(); + rows.forEach((row) => { + const key = String(row.skuoid || '').trim(); + if (!key) return; + index.set(key, { + salesAmount7d: toNumberOrNull(row.salesAmount7d), + salesQty7d: toNumberOrNull(row.salesQty7d), + salesAmount30d: toNumberOrNull(row.salesAmount30d), + salesQty30d: toNumberOrNull(row.salesQty30d), + department: row.department || row.shopDept || null, + }); + }); + return index; + } catch (error) { + console.warn('[ProductMonitoring] 加载销售明细聚合失败,业绩/部门列将留空:', error.message); + return new Map(); + } +} + +// 主销售渠道 / 主销售运营:近 7 天销售额最高的渠道与运营(脑图口径) +async function loadMainChannelAndOperator(skuoids) { + const result = new Map(); + if (!Array.isArray(skuoids) || !skuoids.length) return result; + + try { + const latestDate = await getLatestSalesDate(); + if (!latestDate) return result; + + // 仅对本页 SKU 查询,避免全表聚合 + const list = [...new Set(skuoids.filter(Boolean))].slice(0, MAX_PAGE_SIZE); + const params = { days: SALES_WINDOW_DAYS, latestDate }; + const placeholders = list.map((sku, i) => { + params[`sku${i}`] = sku; + return `@sku${i}`; + }); + + const rows = await cloudDatabase.query( + `SELECT d.skuoid AS skuoid, d.shop_name AS shopName, d.ider AS operator, + SUM(d.sales_amount) AS amt + FROM ${SALES_DETAIL_TABLE} d + WHERE d.stat_date >= DATEADD(day, -@days, @latestDate) + AND d.skuoid IN (${placeholders.join(',')}) + GROUP BY d.skuoid, d.shop_name, d.ider`, + params, + { requestTimeout: 120000 } + ); + + const byChannel = new Map(); + const byOperator = new Map(); + rows.forEach((row) => { + const sku = String(row.skuoid || '').trim(); + const amt = toNumberOrNull(row.amt) || 0; + if (!sku) return; + if (row.shopName) { + const key = `${sku}||${row.shopName}`; + const prev = byChannel.get(key) || 0; + byChannel.set(key, prev + amt); + } + if (row.operator) { + const key = `${sku}||${row.operator}`; + const prev = byOperator.get(key) || 0; + byOperator.set(key, prev + amt); + } + }); + + const pickTop = (map, targetMap) => { + map.forEach((amt, key) => { + const [sku, name] = key.split('||'); + const prev = targetMap.get(sku); + if (!prev || amt > prev.amt) targetMap.set(sku, { name, amt }); + }); + }; + const topChannel = new Map(); + const topOperator = new Map(); + pickTop(byChannel, topChannel); + pickTop(byOperator, topOperator); + + list.forEach((sku) => { + result.set(sku, { + mainSalesChannel: topChannel.get(sku)?.name || null, + mainSalesOperator: topOperator.get(sku)?.name || null, + }); + }); + return result; + } catch (error) { + console.warn('[ProductMonitoring] 加载主渠道/主运营失败,相关列将留空:', error.message); + return result; + } +} + +// 行下钻:该 SKU 归属的运营人员列表 + 各自日销(近 7 天日均发货件数) +async function getProductOperators(skuoid) { + const sku = String(skuoid || '').trim(); + if (!sku) return []; + + const latestDate = await getLatestSalesDate(); + if (!latestDate) return []; + + const rows = await cloudDatabase.query( + `SELECT d.ider AS operator, + SUM(d.goods_count) AS qty7d, + SUM(d.sales_amount) AS amt7d, + MAX(d.shop_name) AS sampleShop + FROM ${SALES_DETAIL_TABLE} d + WHERE d.stat_date >= DATEADD(day, -@days, @latestDate) + AND d.skuoid = @sku + AND d.ider IS NOT NULL AND d.ider <> N'' + GROUP BY d.ider + ORDER BY SUM(d.goods_count) DESC`, + { days: SALES_WINDOW_DAYS, latestDate, sku }, + { requestTimeout: 120000 } + ); + + // 每位运营的主渠道 + const channelRows = await cloudDatabase.query( + `SELECT d.ider AS operator, d.shop_name AS shopName, SUM(d.sales_amount) AS amt + FROM ${SALES_DETAIL_TABLE} d + WHERE d.stat_date >= DATEADD(day, -@days, @latestDate) + AND d.skuoid = @sku AND d.ider IS NOT NULL AND d.ider <> N'' + GROUP BY d.ider, d.shop_name`, + { days: SALES_WINDOW_DAYS, latestDate, sku }, + { requestTimeout: 120000 } + ); + + const topChannel = new Map(); + channelRows.forEach((row) => { + const amt = toNumberOrNull(row.amt) || 0; + const prev = topChannel.get(row.operator); + if (!prev || amt > prev.amt) topChannel.set(row.operator, { name: row.shopName, amt }); + }); + + return rows.map((row) => { + const qty7d = toNumberOrNull(row.qty7d) || 0; + return { + operator: row.operator, + dailySales: Number((qty7d / SALES_WINDOW_DAYS).toFixed(2)), + salesQty7d: qty7d, + salesAmount7d: toNumberOrNull(row.amt7d), + mainChannel: topChannel.get(row.operator)?.name || null, + }; + }); +} + +// ==================== 筛选选项 ==================== + +async function queryFilterOptions(latestDate) { + const [dimensionRows, statusRows] = await Promise.all([ + cloudDatabase.query( + `SELECT DISTINCT TOP 300 m.category AS category, m.supplier AS supplier, m.warehouse AS warehouse, + m.purchase AS purchaser + FROM ${REPORT_SOURCE_TABLE} m + WHERE m.date = @latestDate`, + { latestDate } + ), + cloudDatabase.query( + `SELECT m.sku_idStatus AS status, COUNT(*) AS c + FROM ${REPORT_SOURCE_TABLE} m + WHERE m.date = @latestDate + GROUP BY m.sku_idStatus`, + { latestDate } + ), + ]); + + const uniq = (key) => [...new Set(dimensionRows.map((r) => r[key]).filter(Boolean))].sort((a, b) => String(a).localeCompare(String(b), 'zh-CN')); + + return { + categories: uniq('category'), + suppliers: uniq('supplier'), + warehouses: uniq('warehouse'), + purchasers: uniq('purchaser'), + saleStatuses: SALE_STATUS_OPTIONS.map((status) => ({ + status, + count: Number(statusRows.find((r) => r.status === status)?.c || 0), + })), + latestDate, + }; +} + +// ==================== 数据集缓存 ==================== +// +// 实测:mssql 驱动逐行开销约 0.4ms/行,取全量 5.6 万行要 20~30 秒,不能每次请求都跑。 +// 而「公司标签 / 部门标签 / 部门负责人」依赖系统库配置,无法下推到 SQL,必须拿到全量行再算。 +// 因此:按统计日缓存一份「已完成派生」的全量数据集,后续请求只在内存里过滤 + 排序 + 分页。 +// 缓存失效时机:① 统计日变化(日更)② 保存部门标签/负责人配置 ③ 前端带 refresh=1。 + +const DATASET_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +let datasetCache = null; +let datasetBuilding = null; + +const dateKeyOf = (value) => (value ? new Date(value).toISOString().slice(0, 10) : ''); + +async function buildDataset(latestDate) { + const key = dateKeyOf(latestDate); + const [baseResult, departmentRules, departmentOwners, companyTagConfig, companyOwnerIndex, salesAggIndex] = await Promise.all([ + queryAllReportRows(latestDate), + loadDepartmentTagRules(), + loadDepartmentSkuOwners(), + loadCompanyTagResolver(), + loadCompanyOwnerIndex(), + loadSalesAggregatesBySku(), + ]); + + const rows = baseResult.rows.map((row) => { + const salesAgg = salesAggIndex.get(String(row.skuCode || row.skuId).trim()) || null; + + // 业绩口径 = 近 30 天销售额(对齐 SABC 阈值量级),近 7 天销售额单独留一列展示 + const performance30d = salesAgg?.salesAmount30d ?? null; + const performance7d = salesAgg?.salesAmount7d ?? null; + const departmentSalesQty7d = salesAgg?.salesQty7d ?? null; + + const companyTag = resolveCompanyTag(companyTagConfig, row.category, performance30d, row.companySales7d); + const departmentTag = matchDepartmentTag(departmentRules, departmentSalesQty7d); + const department = salesAgg?.department || null; + const owner = resolveDepartmentOwner(departmentOwners, { ...row, department }); + + return { + ...row, + companyOwner: companyOwnerIndex.get(String(row.spu || '').trim()) || null, + department, + // 业绩(销售额)——30 天用于 SABC 判定,7 天用于展示 + performance7d, + performance30d, + // 部门近7日日销(件数 / 7) + departmentSales7d: departmentSalesQty7d === null + ? null + : Number((departmentSalesQty7d / SALES_WINDOW_DAYS).toFixed(2)), + departmentSalesQty7d, + // 建议备货:宽表 suggestStock 为有符号净需求,负数代表库存过剩,展示时取 0 + suggestedReplenish: row.suggestedReplenish === null ? null : Math.max(0, row.suggestedReplenish), + companyTag, + departmentTag, + departmentOwner: owner?.ownerName || null, + mainSalesChannel: null, + mainSalesOperator: null, + }; + }); + + return { key, builtAt: Date.now(), rows }; +} + +async function getDataset(force = false) { + const latestDate = await getLatestReportDate(); + const key = dateKeyOf(latestDate); + + const fresh = datasetCache && datasetCache.key === key + && (Date.now() - datasetCache.builtAt) < DATASET_CACHE_TTL_MS; + if (!force && fresh) return { dataset: datasetCache, latestDate }; + + // 并发去重:同一统计日的构建只跑一次 + if (datasetBuilding && datasetBuilding.key === key) { + return { dataset: await datasetBuilding.promise, latestDate }; + } + + const promise = buildDataset(latestDate) + .then((dataset) => { + datasetCache = dataset; + console.log(`[ProductMonitoring] 数据集构建完成:${key},共 ${dataset.rows.length} 行`); + return dataset; + }) + .finally(() => { datasetBuilding = null; }); + + datasetBuilding = { key, promise }; + return { dataset: await promise, latestDate }; +} + +function invalidateDataset() { + datasetCache = null; +} + +// 启动预热:不阻塞应用启动,构建失败只记日志(首次请求会重试) +async function warmProductMonitoringDataset() { + try { + await getDataset(); + } catch (error) { + console.warn('[ProductMonitoring] 数据集预热失败,将在首次请求时重试:', error.message); + } +} + +// 基础字段过滤(原 SQL WHERE 的等价物,改为内存匹配) +function applyBaseFilters(rows = [], query = {}) { + const like = (value, keyword) => String(value ?? '').toLowerCase() + .includes(String(keyword ?? '').trim().toLowerCase()); + + const keywordFields = [ + ['category', 'category'], + ['spu', 'spu'], + ['sku', 'skuId'], + ['productName', 'productName'], + ['supplier', 'supplier'], + ['warehouse', 'warehouse'], + ['purchaser', 'purchaser'], + ].map(([param, field]) => [field, String(query[param] ?? '').trim()]) + .filter(([, keyword]) => keyword); + + const saleStatus = String(query.saleStatus ?? '').trim(); + const includeAll = String(query.includeAll ?? '') === '1'; + const status = saleStatus || (includeAll ? '' : '销售'); + + if (!keywordFields.length && !status) return rows; + + return rows.filter((row) => { + if (status && String(row.saleStatus || '') !== status) return false; + return keywordFields.every(([field, keyword]) => like(row[field], keyword)); + }); +} + +// ==================== 主入口 ==================== + +async function getMonitoringList(query = {}) { + const forceRefresh = String(query.refresh ?? '') === '1'; + const { dataset, latestDate } = await getDataset(forceRefresh); + + // 1) 基础字段过滤 → 2) 派生字段过滤 → 3) 排序 → 4) 分页 + const filtered = applyDerivedFilters(applyBaseFilters(dataset.rows, query), query); + const sorted = sortRows(filtered, query); + + const total = sorted.length; + const page = Math.max(1, Number(query.page) || 1); + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(query.pageSize) || DEFAULT_PAGE_SIZE)); + const pageRows = sorted.slice((page - 1) * pageSize, page * pageSize); + + // 主渠道 / 主运营只对本页 SKU 查询,控制代价 + const mainChannelIndex = await loadMainChannelAndOperator(pageRows.map((row) => row.skuCode || row.skuId)); + const rows = pageRows.map((row) => { + const main = mainChannelIndex.get(String(row.skuCode || row.skuId).trim()) || {}; + return { + ...row, + mainSalesChannel: main.mainSalesChannel || null, + mainSalesOperator: main.mainSalesOperator || null, + }; + }); + + return { + rows, + total, + page, + pageSize, + latestDate, + cachedAt: dataset.builtAt, + summary: buildSummary(sorted), + }; +} + +// 派生字段过滤:多选用逗号分隔(companyTag=S,A),文本用包含匹配 +function applyDerivedFilters(rows = [], query = {}) { + const parseList = (value) => String(value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + const companyTags = parseList(query.companyTag); + const departmentTags = parseList(query.departmentTag); + const departmentKeyword = String(query.department ?? '').trim(); + const departmentOwnerKeyword = String(query.departmentOwner ?? '').trim().toLowerCase(); + const companyOwnerKeyword = String(query.companyOwner ?? '').trim().toLowerCase(); + + if (!companyTags.length && !departmentTags.length && !departmentKeyword + && !departmentOwnerKeyword && !companyOwnerKeyword) { + return rows; + } + + return rows.filter((row) => { + if (companyTags.length && !companyTags.includes(String(row.companyTag || ''))) return false; + if (departmentTags.length && !departmentTags.includes(String(row.departmentTag || ''))) return false; + if (departmentKeyword && String(row.department || '') !== departmentKeyword) return false; + if (departmentOwnerKeyword && !String(row.departmentOwner || '').toLowerCase().includes(departmentOwnerKeyword)) return false; + if (companyOwnerKeyword && !String(row.companyOwner || '').toLowerCase().includes(companyOwnerKeyword)) return false; + return true; + }); +} + +function sortRows(rows = [], query = {}) { + const field = DERIVED_SORT_FIELDS.has(query.sortField) ? query.sortField : 'companySales7d'; + const desc = String(query.sortOrder || '').toUpperCase() !== 'ASC'; + const direction = desc ? -1 : 1; + + return [...rows].sort((a, b) => { + const av = a[field]; + const bv = b[field]; + // 空值恒排末尾,避免 null 参与数值/字符串比较造成的抖动 + if (av === null || av === undefined || av === '') { + return bv === null || bv === undefined || bv === '' ? String(a.skuId).localeCompare(String(b.skuId)) : 1; + } + if (bv === null || bv === undefined || bv === '') return -1; + + const an = Number(av); + const bn = Number(bv); + const bothNumeric = Number.isFinite(an) && Number.isFinite(bn); + const delta = bothNumeric + ? an - bn + : String(av).localeCompare(String(bv), 'zh-CN'); + + return (delta || String(a.skuId).localeCompare(String(b.skuId))) * direction; + }); +} + +// 公司 SABC:复用「商品标签配置」的阈值,按 (品类, 业绩, 日销) 判定规模标签 +async function loadCompanyTagResolver() { + try { + const config = await productTagConfigService.getProductTagConfig(); + const thresholds = config?.scaleThresholds || []; + const byCategory = new Map(); + thresholds.forEach((t) => { + if (t.enabled === false) return; + const key = String(t.category || ''); + if (!byCategory.has(key)) byCategory.set(key, []); + byCategory.get(key).push(t); + }); + return { byCategory, mappings: config?.sabcMappings || [] }; + } catch (error) { + console.warn('[ProductMonitoring] 加载公司标签配置失败,标签列将留空:', error.message); + return null; + } +} + +// 判定:业绩与日销同时命中(左闭右开),按 priority 升序命中即停 +function resolveCompanyTag(config, category, performance, sales) { + if (!config) return null; + const ofCategory = config.byCategory.get(String(category || '')); + if (!ofCategory || !ofCategory.length) return null; + // 业绩缺失时按 0 处理(区间在业务上必然从 0 起),避免整列留空 + const perf = performance === null || performance === undefined ? 0 : Number(performance); + const sale = sales === null || sales === undefined ? 0 : Number(sales); + const scaleTag = productTagConfigService.matchScaleTagOfCategory(ofCategory, perf, sale); + if (!scaleTag || scaleTag === '滞销款') return 'D'; + // 利润等级当前无上游数据源,同规模标签下取最优档作为展示值 + const hit = config.mappings.find((m) => m.scaleTag === scaleTag && m.enabled !== false); + return hit?.sabcTag || null; +} + +// 部门负责人:优先「部门 + SPU」精确配置 → 「部门 + SPU 为空」的部门级默认配置 +function resolveDepartmentOwner(owners, row) { + if (!owners.length) return null; + const deptName = String(row.department || '').trim(); + const spu = String(row.spu || '').trim(); + if (!deptName) return null; + + const ofDept = owners.filter((o) => o.ownerType === 'sku_owner' && o.departmentName === deptName); + if (!ofDept.length) return null; + + const bySpu = spu ? ofDept.find((o) => String(o.spu || '').trim() === spu) : null; + if (bySpu) return bySpu; + return ofDept.find((o) => !o.spu) || null; +} + +async function getMonitoringFilters() { + const latestDate = await getLatestReportDate(); + const [options, departmentRules] = await Promise.all([ + queryFilterOptions(latestDate), + loadDepartmentTagRules(), + ]); + return { + ...options, + companyTags: ['S', 'A', 'B', 'C', 'D'], + departmentTags: DEPARTMENT_TAG_LEVELS, + departmentTagRules: departmentRules, + }; +} + +// ==================== 部门标签配置(系统设置用) ==================== + +async function getDepartmentTagConfig() { + const [rules, owners, departments, users] = await Promise.all([ + loadDepartmentTagRules(), + loadDepartmentSkuOwners(), + database.local.query('SELECT id, name, code, status FROM departments WHERE status = ? ORDER BY sort ASC, id ASC', ['active']), + database.local.query('SELECT id, name, username, role FROM users ORDER BY id ASC LIMIT 1000'), + ]); + + return { + rules: DEPARTMENT_TAG_LEVELS.map((level) => { + const hit = rules.find((r) => r.tagLevel === level); + return { + tagLevel: level, + metricKey: hit?.metricKey || 'sales_qty_7d', + thresholdMin: hit?.thresholdMin ?? null, + thresholdMax: hit?.thresholdMax ?? null, + priority: hit?.priority ?? (DEPARTMENT_TAG_LEVELS.indexOf(level) + 1), + enabled: Boolean(hit), + }; + }), + metricKeys: [...DEPARTMENT_METRIC_KEYS], + owners, + departments: departments.map((d) => ({ id: d.id, name: d.name, code: d.code })), + users: users.map((u) => ({ id: u.id, name: u.name, username: u.username, role: u.role })), + }; +} + +async function saveDepartmentTagConfig(payload = {}) { + // 标签/负责人配置变了,已缓存的数据集里的派生字段立即失效 + invalidateDataset(); + + const rules = Array.isArray(payload.rules) ? payload.rules : null; + const owners = Array.isArray(payload.owners) ? payload.owners : null; + + if (rules) { + validateDepartmentRules(rules); + await database.local.query('DELETE FROM product_tag_rules WHERE tag_scope = ?', [DEPARTMENT_TAG_SCOPE]); + for (const rule of rules) { + if (rule.enabled === false) continue; + await database.local.query( + `INSERT INTO product_tag_rules + (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled) + VALUES (?, ?, ?, ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE + metric_key = VALUES(metric_key), + threshold_min = VALUES(threshold_min), + threshold_max = VALUES(threshold_max), + priority = VALUES(priority), + enabled = 1`, + [ + DEPARTMENT_TAG_SCOPE, + String(rule.tagLevel), + DEPARTMENT_METRIC_KEYS.includes(rule.metricKey) ? rule.metricKey : 'sales_qty_7d', + toNumberOrNull(rule.thresholdMin), + toNumberOrNull(rule.thresholdMax), + Number(rule.priority) || 0, + ] + ); + } + } + + if (owners) { + validateDepartmentOwners(owners); + await database.local.query('DELETE FROM department_sku_owners'); + for (const owner of owners) { + await database.local.query( + `INSERT INTO department_sku_owners (department_id, owner_type, user_id, spu) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE department_id = VALUES(department_id)`, + [ + Number(owner.departmentId), + owner.ownerType === 'purchaser' ? 'purchaser' : 'sku_owner', + Number(owner.userId), + owner.spu ? String(owner.spu).trim() : null, + ] + ); + } + } + + return getDepartmentTagConfig(); +} + +function validateDepartmentRules(rules) { + const seen = new Set(); + rules.forEach((rule, index) => { + const line = index + 1; + if (!DEPARTMENT_TAG_LEVELS.includes(String(rule.tagLevel))) { + throw new Error(`第 ${line} 行:部门标签必须是 ${DEPARTMENT_TAG_LEVELS.join(' / ')}`); + } + const min = toNumberOrNull(rule.thresholdMin); + const max = toNumberOrNull(rule.thresholdMax); + if (min !== null && max !== null && min >= max) { + throw new Error(`第 ${line} 行:${rule.tagLevel} 的起点(${min}) 必须小于终点(${max})`); + } + if (seen.has(rule.tagLevel)) { + throw new Error(`第 ${line} 行:部门标签「${rule.tagLevel}」重复`); + } + seen.add(rule.tagLevel); + }); +} + +function validateDepartmentOwners(owners) { + const seen = new Set(); + owners.forEach((owner, index) => { + const line = index + 1; + if (!owner.departmentId) throw new Error(`第 ${line} 行:必须选择部门`); + if (!owner.userId) throw new Error(`第 ${line} 行:必须选择负责人`); + const key = `${owner.departmentId}||${owner.ownerType}||${owner.spu || ''}||${owner.userId}`; + if (seen.has(key)) throw new Error(`第 ${line} 行:部门/负责人/SPU 组合重复`); + seen.add(key); + }); +} + +module.exports = { + ensureProductMonitoringSchema, + getMonitoringList, + getMonitoringFilters, + getDepartmentTagConfig, + saveDepartmentTagConfig, + warmProductMonitoringDataset, + invalidateDataset, + getProductOperators, + matchDepartmentTag, + DEPARTMENT_TAG_LEVELS, + DEPARTMENT_METRIC_KEYS, + SALE_STATUS_OPTIONS, +}; diff --git a/backend/src/services/productTagConfigService.js b/backend/src/services/productTagConfigService.js new file mode 100644 index 0000000..910e025 --- /dev/null +++ b/backend/src/services/productTagConfigService.js @@ -0,0 +1,521 @@ +'use strict'; + +// 商品标签配置:规模标签阈值、利润标签阈值、SABC 映射 +// +// 数据来源:《公司SABC标签配置.xlsx》 +// - 区块① 分类 × 业绩/日销区间 → 规模标签(148 行) +// - 区块② 规模标签 × 利润标签 → SABC(25 行) +// +// 判定口径(2026-09-11 评审确认): +// 1. SABC 保留 5 档,含 D +// 2. 利润标签是上游传入的输入值,直接查表;营业利润率仅作展示说明,不参与计算 +// 3. 区间一律「左闭右开」[起点, 终点);规模标签按 priority 升序命中即停 +// 4. 业绩与日销必须同时命中 +// 5. 阈值导入时统一取整 +// 6. 品类为 '-' 的行跳过 +// 7. 暂不实现标签自动重算 + +const database = require('../config/database'); + +const SCALE_TAGS = Object.freeze(['爆款', '热卖款', '动销款', '低动销款']); +const FALLBACK_SCALE_TAG = '滞销款'; +const SCALE_TAG_ORDER = Object.freeze([...SCALE_TAGS, FALLBACK_SCALE_TAG]); +const PROFIT_LEVELS = Object.freeze(['一级', '二级', '三级', '四级', '五级']); +const SABC_TAGS = Object.freeze(['S', 'A', 'B', 'C', 'D']); + +// 区块① 中作为脏数据占位、导入时跳过的品类值 +const IGNORED_CATEGORIES = Object.freeze(['-', '']); + +// 有意不参与评级的品类(2026-09-11 决策 B)。 +// 与「未配置」语义不同:这里是明确决定不评级,不是遗漏,前端要能区分展示。 +// 存在 system_config 里,无需为它单独建表。 +const EXCLUDED_CATEGORIES_CONFIG_KEY = 'product_tag_excluded_categories'; +const DEFAULT_EXCLUDED_CATEGORIES = Object.freeze(['其他 - 配件']); + +// 未评级原因 +const RATING_SKIP_EXCLUDED = 'excluded'; +const RATING_SKIP_UNCONFIGURED = 'unconfigured'; + +async function ensureTable(tableName, createSql) { + await database.local.query(createSql); + const rows = await database.local.query( + `SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ? + LIMIT 1`, + [tableName] + ); + if (rows.length === 0) { + throw new Error(`Table ${tableName} create failed`); + } +} + +async function ensureProductTagConfigSchema() { + await ensureTable( + 'product_scale_thresholds', + `CREATE TABLE IF NOT EXISTS product_scale_thresholds ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + category VARCHAR(128) NOT NULL COMMENT 'jst_sku.category 的完整值,如 住宅家具 - 电脑桌', + display_name VARCHAR(128) NULL COMMENT '配置表里的短名,如 电脑桌/书桌(仅用于展示与分组)', + scale_tag VARCHAR(32) NOT NULL COMMENT '爆款/热卖款/动销款/低动销款', + performance_min DECIMAL(18,4) NULL COMMENT '业绩起点(含)', + performance_max DECIMAL(18,4) NULL COMMENT '业绩终点(不含)', + sales_min DECIMAL(18,4) NULL COMMENT '日销起点(含)', + sales_max DECIMAL(18,4) NULL COMMENT '日销终点(不含)', + priority INT NOT NULL DEFAULT 0 COMMENT '同品类判定优先级,升序命中即停', + sort INT NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_category_tag (category, scale_tag), + KEY idx_category_priority (category, enabled, priority), + KEY idx_display_name (display_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模标签阈值配置'` + ); + + await ensureTable( + 'product_profit_levels', + `CREATE TABLE IF NOT EXISTS product_profit_levels ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + level_code VARCHAR(16) NOT NULL COMMENT '一级~五级', + profit_rate_max DECIMAL(8,4) NULL COMMENT '营业利润率上限(说明用)', + description VARCHAR(128) NULL COMMENT '展示文案,如 营业利润率≤0.18', + sort INT NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_level_code (level_code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='利润标签阈值配置'` + ); + + await ensureTable( + 'product_sabc_mappings', + `CREATE TABLE IF NOT EXISTS product_sabc_mappings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + scale_tag VARCHAR(32) NOT NULL, + profit_level VARCHAR(16) NOT NULL, + sabc_tag VARCHAR(8) NOT NULL COMMENT 'S/A/B/C/D', + sort INT NOT NULL DEFAULT 0, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_scale_profit (scale_tag, profit_level) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模×利润→SABC 映射'` + ); +} + +function toNullableNumber(value) { + if (value === null || value === undefined || value === '') return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +// 决策 5:只对「计算溢出型」脏数据取整,合法的小数(如日销起点 0.5)必须保留。 +// 判据:小数位数 > 2 位(如 7916.34311148132、2063.13142776357)→ 取整; +// 0.5 / 1.25 这类 ≤2 位小数的正常阈值原样保留。 +function normalizeThreshold(value) { + const num = toNullableNumber(value); + if (num === null) return null; + const decimals = String(num).split('.')[1]; + if (decimals && decimals.length > 2) return Math.round(num); + return num; +} + +function toNumber(value, fallback = 0) { + const num = Number(value); + return Number.isFinite(num) ? num : fallback; +} + +function normalizeText(value) { + return String(value === null || value === undefined ? '' : value).trim(); +} + +function assertOneOf(value, allowed, label) { + if (!allowed.includes(value)) { + throw new Error(`${label} 取值非法:${value},允许值 ${allowed.join(' / ')}`); + } +} + +// ==================== 读取 ==================== + +async function loadScaleThresholds() { + const rows = await database.local.query( + `SELECT id, category, display_name, scale_tag, performance_min, performance_max, + sales_min, sales_max, priority, sort, enabled + FROM product_scale_thresholds + ORDER BY category ASC, priority ASC, id ASC` + ); + return rows.map((row) => ({ + id: row.id, + category: row.category, + displayName: row.display_name || row.category, + scaleTag: row.scale_tag, + performanceMin: toNullableNumber(row.performance_min), + performanceMax: toNullableNumber(row.performance_max), + salesMin: toNullableNumber(row.sales_min), + salesMax: toNullableNumber(row.sales_max), + priority: toNumber(row.priority), + sort: toNumber(row.sort), + enabled: Boolean(row.enabled), + })); +} + +async function loadProfitLevels() { + const rows = await database.local.query( + `SELECT id, level_code, profit_rate_max, description, sort, enabled + FROM product_profit_levels + ORDER BY sort ASC, id ASC` + ); + return rows.map((row) => ({ + id: row.id, + levelCode: row.level_code, + profitRateMax: toNullableNumber(row.profit_rate_max), + description: row.description || '', + sort: toNumber(row.sort), + enabled: Boolean(row.enabled), + })); +} + +async function loadSabcMappings() { + const rows = await database.local.query( + `SELECT id, scale_tag, profit_level, sabc_tag, sort, enabled + FROM product_sabc_mappings + ORDER BY sort ASC, id ASC` + ); + return rows.map((row) => ({ + id: row.id, + scaleTag: row.scale_tag, + profitLevel: row.profit_level, + sabcTag: row.sabc_tag, + sort: toNumber(row.sort), + enabled: Boolean(row.enabled), + })); +} + +async function getProductTagConfig() { + const [scaleThresholds, profitLevels, sabcMappings, excludedCategories] = await Promise.all([ + loadScaleThresholds(), + loadProfitLevels(), + loadSabcMappings(), + loadExcludedCategories(), + ]); + return { + scaleThresholds, + profitLevels, + sabcMappings, + excludedCategories, + meta: { + scaleTags: SCALE_TAG_ORDER, + profitLevels: PROFIT_LEVELS, + sabcTags: SABC_TAGS, + }, + }; +} + +// ==================== 排除品类(不参与评级) ==================== + +async function loadExcludedCategories() { + try { + const rows = await database.local.query( + 'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1', + [EXCLUDED_CATEGORIES_CONFIG_KEY] + ); + // 没有配置记录时用默认值(配件不参与评级,决策 B) + if (!rows.length || !rows[0].config_value) return [...DEFAULT_EXCLUDED_CATEGORIES]; + const parsed = JSON.parse(rows[0].config_value); + if (!Array.isArray(parsed)) return [...DEFAULT_EXCLUDED_CATEGORIES]; + return parsed.map((item) => normalizeText(item)).filter(Boolean); + } catch (_error) { + return [...DEFAULT_EXCLUDED_CATEGORIES]; + } +} + +async function saveExcludedCategories(list) { + const normalized = [...new Set((list || []).map((item) => normalizeText(item)).filter(Boolean))]; + const payload = JSON.stringify(normalized); + const existing = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [EXCLUDED_CATEGORIES_CONFIG_KEY] + ); + if (existing.length) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [payload, 'json', EXCLUDED_CATEGORIES_CONFIG_KEY] + ); + } else { + await database.local.query( + 'INSERT INTO system_config (config_key, config_value, config_type) VALUES (?, ?, ?)', + [EXCLUDED_CATEGORIES_CONFIG_KEY, payload, 'json'] + ); + } + return normalized; +} + +// ==================== 校验 ==================== + +function validateScaleThresholds(rows) { + const seen = new Set(); + rows.forEach((row, index) => { + const line = index + 1; + const category = normalizeText(row.category); + if (!category) throw new Error(`第 ${line} 行:品类(jst_sku.category 完整值)不能为空`); + if (IGNORED_CATEGORIES.includes(category)) throw new Error(`第 ${line} 行:品类 "${category}" 已标记为忽略,不应提交`); + assertOneOf(normalizeText(row.scaleTag), SCALE_TAGS, `第 ${line} 行 规模标签`); + + const key = `${category}||${row.scaleTag}`; + if (seen.has(key)) throw new Error(`第 ${line} 行:品类「${category}」的「${row.scaleTag}」重复`); + seen.add(key); + + const pMin = toNullableNumber(row.performanceMin); + const pMax = toNullableNumber(row.performanceMax); + const sMin = toNullableNumber(row.salesMin); + const sMax = toNullableNumber(row.salesMax); + + if (pMin !== null && pMax !== null && pMin >= pMax) { + throw new Error(`第 ${line} 行:业绩起点(${pMin}) 必须小于 业绩终点(${pMax})`); + } + if (sMin !== null && sMax !== null && sMin >= sMax) { + throw new Error(`第 ${line} 行:日销起点(${sMin}) 必须小于 日销终点(${sMax})`); + } + }); +} + +function validateSabcMappings(rows) { + const seen = new Set(); + rows.forEach((row, index) => { + const line = index + 1; + assertOneOf(normalizeText(row.scaleTag), SCALE_TAG_ORDER, `第 ${line} 行 规模标签`); + assertOneOf(normalizeText(row.profitLevel), PROFIT_LEVELS, `第 ${line} 行 利润标签`); + assertOneOf(normalizeText(row.sabcTag), SABC_TAGS, `第 ${line} 行 SABC`); + + const key = `${row.scaleTag}||${row.profitLevel}`; + if (seen.has(key)) throw new Error(`第 ${line} 行:规模标签「${row.scaleTag}」× 利润标签「${row.profitLevel}」重复`); + seen.add(key); + }); +} + +// ==================== 保存(整表替换) ==================== + +async function replaceScaleThresholds(rows) { + validateScaleThresholds(rows); + await database.local.query('DELETE FROM product_scale_thresholds'); + if (!rows.length) return 0; + + const values = []; + const placeholders = rows.map((row) => { + // priority 由文档顺序推导:爆款=1 热卖款=2 动销款=3 低动销款=4 + const priority = SCALE_TAGS.indexOf(normalizeText(row.scaleTag)) + 1; + values.push( + normalizeText(row.category), + normalizeText(row.displayName) || normalizeText(row.category), + normalizeText(row.scaleTag), + normalizeThreshold(row.performanceMin), + normalizeThreshold(row.performanceMax), + normalizeThreshold(row.salesMin), + normalizeThreshold(row.salesMax), + priority, + toNumber(row.sort, priority), + row.enabled === false ? 0 : 1 + ); + return '(?,?,?,?,?,?,?,?,?,?)'; + }); + + await database.local.query( + `INSERT INTO product_scale_thresholds + (category, display_name, scale_tag, performance_min, performance_max, + sales_min, sales_max, priority, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return rows.length; +} + +async function replaceProfitLevels(rows) { + const list = rows && rows.length ? rows : PROFIT_LEVELS.map((levelCode) => ({ levelCode })); + list.forEach((row, index) => { + assertOneOf(normalizeText(row.levelCode), PROFIT_LEVELS, `第 ${index + 1} 行 利润标签`); + }); + + const seen = new Set(); + list.forEach((row, index) => { + const code = normalizeText(row.levelCode); + if (seen.has(code)) throw new Error(`第 ${index + 1} 行:利润标签「${code}」重复`); + seen.add(code); + }); + + await database.local.query('DELETE FROM product_profit_levels'); + const values = []; + const placeholders = list.map((row, index) => { + values.push( + normalizeText(row.levelCode), + toNullableNumber(row.profitRateMax), + normalizeText(row.description) || null, + toNumber(row.sort, index + 1), + row.enabled === false ? 0 : 1 + ); + return '(?,?,?,?,?)'; + }); + + await database.local.query( + `INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return list.length; +} + +async function replaceSabcMappings(rows) { + validateSabcMappings(rows); + await database.local.query('DELETE FROM product_sabc_mappings'); + if (!rows.length) return 0; + + const values = []; + const placeholders = rows.map((row, index) => { + values.push( + normalizeText(row.scaleTag), + normalizeText(row.profitLevel), + normalizeText(row.sabcTag), + toNumber(row.sort, index + 1), + row.enabled === false ? 0 : 1 + ); + return '(?,?,?,?,?)'; + }); + + await database.local.query( + `INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled) + VALUES ${placeholders.join(',')}`, + values + ); + return rows.length; +} + +// 一次性保存三个区块(前端「保存配置」按钮) +async function saveProductTagConfig(payload = {}) { + const result = {}; + if (Array.isArray(payload.scaleThresholds)) { + result.scaleThresholdCount = await replaceScaleThresholds(payload.scaleThresholds); + } + if (Array.isArray(payload.profitLevels)) { + result.profitLevelCount = await replaceProfitLevels(payload.profitLevels); + } + if (Array.isArray(payload.sabcMappings)) { + result.sabcMappingCount = await replaceSabcMappings(payload.sabcMappings); + } + if (Array.isArray(payload.excludedCategories)) { + result.excludedCategoryCount = (await saveExcludedCategories(payload.excludedCategories)).length; + } + return result; +} + +// ==================== 运行时判定 ==================== + +// 决策 3 + 4:左闭右开,业绩与日销同时命中;决策 2:按 priority 升序命中即停 +// +// 重要:必须区分两种「没命中」—— +// ① 该品类有配置阈值,但数值全部落在区间外 → 真·滞销款(兜底档) +// ② 该品类**根本没有配置任何阈值** → 返回 null(未评级),不能算滞销款 +// 否则未配置的品类(如「其他 - 配件」47 万 SKU)会被整批误判成滞销款。 +function matchScaleTagOfCategory(thresholdsOfCategory, performance, sales) { + const ordered = [...(thresholdsOfCategory || [])] + .filter((row) => row.enabled !== false) + .sort((a, b) => (a.priority - b.priority) || (a.id - b.id)); + + if (!ordered.length) return null; + + for (const row of ordered) { + const pIn = (row.performanceMin === null || performance >= row.performanceMin) + && (row.performanceMax === null || performance < row.performanceMax); + const sIn = (row.salesMin === null || sales >= row.salesMin) + && (row.salesMax === null || sales < row.salesMax); + if (pIn && sIn) return row.scaleTag; + } + return FALLBACK_SCALE_TAG; +} + +function buildScaleThresholdIndex(thresholds) { + const index = new Map(); + (thresholds || []).forEach((row) => { + const key = normalizeText(row.category); + if (!index.has(key)) index.set(key, []); + index.get(key).push(row); + }); + return index; +} + +function buildSabcIndex(mappings) { + const index = new Map(); + (mappings || []).forEach((row) => { + if (row.enabled === false) return; + index.set(`${normalizeText(row.scaleTag)}||${normalizeText(row.profitLevel)}`, row.sabcTag); + }); + return index; +} + +// 批量解析:items = [{ category, performance, sales, profitLevel }] +async function resolveProductTags(items) { + const [thresholds, mappings, excluded] = await Promise.all([ + loadScaleThresholds(), + loadSabcMappings(), + loadExcludedCategories(), + ]); + const thresholdIndex = buildScaleThresholdIndex(thresholds); + const sabcIndex = buildSabcIndex(mappings); + const excludedSet = new Set(excluded); + + return (items || []).map((item) => { + const category = normalizeText(item.category); + + // 有意排除的品类:直接返回未评级,且标明原因,不与「未配置」混淆 + if (excludedSet.has(category)) { + return { + ...item, + scaleTag: null, + sabcTag: null, + rated: false, + skippedReason: RATING_SKIP_EXCLUDED, + }; + } + + const rows = thresholdIndex.get(category); + const scaleTag = matchScaleTagOfCategory(rows, toNullableNumber(item.performance), toNullableNumber(item.sales)); + const profitLevel = normalizeText(item.profitLevel); + const sabcTag = (scaleTag && profitLevel) + ? (sabcIndex.get(`${scaleTag}||${profitLevel}`) || null) + : null; + + return { + ...item, + scaleTag, + sabcTag, + rated: scaleTag !== null, + // 该品类没有任何阈值配置 → 未评级(不能算滞销款) + skippedReason: scaleTag === null ? RATING_SKIP_UNCONFIGURED : null, + }; + }); +} + +module.exports = { + SCALE_TAGS, + SCALE_TAG_ORDER, + FALLBACK_SCALE_TAG, + PROFIT_LEVELS, + SABC_TAGS, + IGNORED_CATEGORIES, + DEFAULT_EXCLUDED_CATEGORIES, + EXCLUDED_CATEGORIES_CONFIG_KEY, + RATING_SKIP_EXCLUDED, + RATING_SKIP_UNCONFIGURED, + ensureProductTagConfigSchema, + getProductTagConfig, + saveProductTagConfig, + loadExcludedCategories, + saveExcludedCategories, + resolveProductTags, + matchScaleTagOfCategory, + // 供单元测试或导入脚本复用 + normalizeThreshold, + validateScaleThresholds, + validateSabcMappings, +}; diff --git a/backend/src/services/retiredFeatures.js b/backend/src/services/retiredFeatures.js index c3bba89..c9b130d 100644 --- a/backend/src/services/retiredFeatures.js +++ b/backend/src/services/retiredFeatures.js @@ -2,16 +2,12 @@ const RETIRED_PAGE_KEYS = [ 'planning_upload', 'market_insight', 'agent', 'ai_skill_tags', 'store_checkup', 'review_analysis', 'customer_service_checkup', 'pricing_calculator', 'desktop_client_download', 'ai_buyer_show', 'ai_seeding_video', - 'product_monitoring', 'ops_b_incentive', 'marketing_events', 'promotion_module', 'product_analysis', ]; const RETIRED_ENTRY_KEYS = new Set([ 'market-insight', 'agent', 'ai-skill-tags', 'platform-switch', 'store-management', 'store-checkup', 'review-analysis', 'customer-service-checkup', 'operation-tools', 'pricing-calculator', 'desktop-client-download', 'ai-buyer-show', 'ai-seeding-video', 'desktop-client-releases', - 'product-monitoring', 'ops-b-incentive', - 'marketing-management', 'marketing-events', 'promotion-module', - 'product-management', 'product-analysis', ]); const RETIRED_CONFIG_KEYS = [ 'openclaw_chat_config', 'openclaw_chat_skill_tags', @@ -19,7 +15,6 @@ const RETIRED_CONFIG_KEYS = [ 'cs_quality_ai_config', 'desktop_client_releases', 'ai_buyer_show_ark_api_key', 'ai_buyer_show_ark_base_url', 'ai_buyer_show_vision_model', 'ai_buyer_show_review_model', - 'product_tag_excluded_categories', ]; const RETIRED_DATABASE_CONFIG_NAMES = ['ai_buyer_show_database']; @@ -53,15 +48,6 @@ async function cleanupRetiredFeatureSettings(db) { await db.query(`DELETE FROM database_configs WHERE name IN (${RETIRED_DATABASE_CONFIG_NAMES.map(() => '?').join(',')})`, RETIRED_DATABASE_CONFIG_NAMES); await db.query('DROP TABLE IF EXISTS pricing_calculator_history'); await db.query('DROP TABLE IF EXISTS pricing_calculator_user_prefs'); - await db.query('DROP TABLE IF EXISTS ops_b_incentive_snapshots'); - await db.query('DROP TABLE IF EXISTS ops_b_incentive_periods'); - await db.query('DROP TABLE IF EXISTS product_analysis_user_prefs'); - await db.query('DROP TABLE IF EXISTS pdd_goods_first_seen'); - await db.query('DROP TABLE IF EXISTS department_sku_owners'); - await db.query('DROP TABLE IF EXISTS product_tag_rules'); - await db.query('DROP TABLE IF EXISTS product_sabc_mappings'); - await db.query('DROP TABLE IF EXISTS product_profit_levels'); - await db.query('DROP TABLE IF EXISTS product_scale_thresholds'); const rows = await db.query("SELECT config_value FROM system_config WHERE config_key = ?", ['sidebar_navigation']); if (rows.length) { const current = rows[0].config_value; diff --git a/backend/test/dashboardRoutes.test.js b/backend/test/dashboardRoutes.test.js deleted file mode 100644 index eb4f99a..0000000 --- a/backend/test/dashboardRoutes.test.js +++ /dev/null @@ -1,54 +0,0 @@ -const assert = require('node:assert/strict'); -const test = require('node:test'); -const Module = require('node:module'); -const express = require('express'); - -function loadDashboardRouter() { - const originalLoad = Module._load; - Module._load = function load(id, parent, ...rest) { - if (parent?.filename.endsWith('/api/dashboard.js') && id.endsWith('/jianSunDatabase')) { - return { - query: async (sql) => { - if (sql.includes('SELECT DISTINCT shopName')) return [{ shopName: '测试店铺' }]; - if (sql.includes('FROM dbo.pdd_DataGoods g')) { - return [{ - statDate: '2026-09-12', latestDate: '2026-09-12', payAmount: 100, - buyerCount: 2, visitorCount: 10, goodsPv: 20, payGoodsQty: 3, - payOrderCount: 2, favoriteCount: 1, impressionCount: 30, - }]; - } - if (sql.includes('FROM dbo.pdd_MallServiceScore')) return [{ shopCount: 1, cstmrServScore: 4.8 }]; - if (sql.includes('FROM dbo.pdd_ShopStars')) return [{ shopCount: 1, mallStarTomms: 5 }]; - return []; - }, - }; - } - return originalLoad.call(this, id, parent, ...rest); - }; - try { - return require('../src/api/dashboard'); - } finally { - Module._load = originalLoad; - } -} - -test('dashboard keeps its read-only data endpoints after product analysis is retired', async () => { - const app = express(); - app.use('/api/dashboard', loadDashboardRouter()); - const server = await new Promise((resolve) => { - const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); - }); - try { - const base = `http://127.0.0.1:${server.address().port}`; - const meta = await (await fetch(`${base}/api/dashboard/meta`)).json(); - assert.deepEqual(meta.data.shops, ['测试店铺']); - - const summary = await (await fetch(`${base}/api/dashboard/summary?period=day`)).json(); - assert.equal(summary.success, true); - assert.equal(summary.data.latestDate, '2026-09-12'); - assert.equal(summary.data.metrics.find((item) => item.key === 'payAmount').value, 100); - assert.equal((await fetch(`${base}/api/product-analysis/products`)).status, 404); - } finally { - await new Promise((resolve) => server.close(resolve)); - } -}); diff --git a/backend/test/desktopSessionService.test.js b/backend/test/desktopSessionService.test.js index 4c6dbe6..b48e80c 100644 --- a/backend/test/desktopSessionService.test.js +++ b/backend/test/desktopSessionService.test.js @@ -92,9 +92,11 @@ test('desktop capabilities follow resolved page permissions', () => { shopManagement: false, execution: false, logs: true, + marketingEvents: false, + promotion: false, productTest: false, }); - assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).productTest, true); + assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).promotion, true); }); test('SSO ticket is consumed exactly once and produces a normal web token', async () => { diff --git a/backend/test/retiredFeatures.test.js b/backend/test/retiredFeatures.test.js index bacfc4e..7619ad1 100644 --- a/backend/test/retiredFeatures.test.js +++ b/backend/test/retiredFeatures.test.js @@ -12,10 +12,6 @@ test('retired sidebar cleanup is recursive and idempotent', () => { { key: 'agent' }, { key: 'store-management', children: [{ key: 'store-checkup' }] }, { key: 'operation-tools', children: [{ key: 'pricing-calculator' }] }, - { key: 'marketing-management', children: [{ key: 'marketing-events' }] }, - { key: 'product-management', children: [{ key: 'product-analysis' }] }, - { key: 'product-monitoring' }, - { key: 'ops-b-incentive' }, { key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'desktop-client-releases' }, { key: 'settings', label: '管理设置' }] }, ] }); const clean = pruneRetiredSidebarConfig(saved); @@ -24,7 +20,6 @@ test('retired sidebar cleanup is recursive and idempotent', () => { assert.equal(pruneRetiredSidebarConfig('{invalid'), '{invalid'); assert.equal(isRetiredSystemConfigKey('openclaw_chat_skill_tags'), true); assert.equal(isRetiredSystemConfigKey('cs_quality_ai_config'), true); - assert.equal(isRetiredSystemConfigKey('product_tag_excluded_categories'), true); assert.equal(isRetiredDatabaseConfigName('ai_buyer_show_database'), true); assert.equal(isRetiredSystemConfigKey('site_name'), false); }); @@ -33,12 +28,12 @@ test('upgrade removes only retired settings and permission rows', async () => { const calls = []; await cleanupRetiredFeatureSettings({ query: async (sql, params) => { calls.push({ sql, params }); - return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"},{"key":"settings"}]}' }] : []; + return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"}]}' }] : []; } }); const deletes = calls.filter((call) => call.sql.startsWith('DELETE')); assert.equal(deletes.length, 5); assert.ok(deletes.every((call) => call.sql.includes(' WHERE ') && call.params.length > 0)); - assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 11); + assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 2); const update = calls.find((call) => call.sql.startsWith('UPDATE')); - assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'settings' }]); + assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'product-analysis' }]); }); diff --git a/backend/test/retiredRoutes.test.js b/backend/test/retiredRoutes.test.js index ad9025f..f3fad52 100644 --- a/backend/test/retiredRoutes.test.js +++ b/backend/test/retiredRoutes.test.js @@ -3,11 +3,14 @@ const assert = require('node:assert/strict'); const Module = require('node:module'); const express = require('express'); -test('retired analysis endpoints return 404 while retained selection endpoint works', async () => { +test('retired market endpoints return 404 while retained incentive and calculator endpoints work', async () => { const originalLoad = Module._load; Module._load = function (id, parent, ...rest) { - if (parent?.filename.endsWith('/api/planningUpload.js')) { + if (/[\\/]api[\\/]planningUpload\.js$/.test(parent?.filename || '')) { if (id.endsWith('/pageAccess')) return { getResolvedUserWithPageAccess: async () => ({ role: 'admin' }) }; + if (id.endsWith('/opsBIncentiveBoardService')) return { + getOpsBIncentiveBoard: async () => ({ rows: [] }), getOpsBIncentiveBatches: async () => [], getOpsBIncentiveDaily: async () => [], + }; if (id.endsWith('/alibaba1688ProductService')) return { getAlibaba1688Product: async (id) => ({ id }) }; if (id.endsWith('/alibaba1688ProductEncodingRegistry')) return { attachEncodingDetail: async (value) => value }; } @@ -21,10 +24,10 @@ test('retired analysis endpoints return 404 while retained selection endpoint wo const server = await new Promise((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); }); try { const base = `http://127.0.0.1:${server.address().port}/api/planning-upload`; - for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans', '/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily']) { + for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans']) { assert.equal((await fetch(base + path)).status, 404, path); } - for (const path of ['/selection-fission/1688-products/123']) { + for (const path of ['/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily', '/selection-fission/1688-products/123']) { const response = await fetch(base + path); assert.equal(response.status, 200, path); assert.equal((await response.json()).success, true); diff --git a/frontend/src/api/admin.js b/frontend/src/api/admin.js index 220f839..77ebc86 100644 --- a/frontend/src/api/admin.js +++ b/frontend/src/api/admin.js @@ -143,6 +143,18 @@ export const testSelectionImageGenerationSettings = () => { +// 商品标签配置(规模标签 / 利润标签 / SABC 映射) +export const getProductTagConfig = () => { + return request.get('/api/admin/product-tag-config'); +}; + +export const updateProductTagConfig = (data) => { + return request.put('/api/admin/product-tag-config', data); +}; + + + + export const getAuthConfig = () => { return request.get('/api/admin/auth-config'); }; diff --git a/frontend/src/api/dashboard.js b/frontend/src/api/dashboard.js deleted file mode 100644 index aade18d..0000000 --- a/frontend/src/api/dashboard.js +++ /dev/null @@ -1,16 +0,0 @@ -import request from './request'; - -let dashboardMetaInFlight = null; - -export const getDashboardMeta = () => { - if (!dashboardMetaInFlight) { - dashboardMetaInFlight = request.get('/api/dashboard/meta').finally(() => { - dashboardMetaInFlight = null; - }); - } - return dashboardMetaInFlight; -}; - -export const getDashboardSummary = (params, config = {}) => ( - request.get('/api/dashboard/summary', { ...config, params }) -); diff --git a/frontend/src/api/marketingEvents.js b/frontend/src/api/marketingEvents.js new file mode 100644 index 0000000..36d5fdf --- /dev/null +++ b/frontend/src/api/marketingEvents.js @@ -0,0 +1,17 @@ +import request from './request'; + +export const getMarketingEventsList = (params) => ( + request.get('/api/marketing-events/list', { params }) +); + +export const getMarketingEventsLatestDate = () => ( + request.get('/api/marketing-events/latest-date') +); + +export const getMarketingEventsSummary = (params) => ( + request.get('/api/marketing-events/summary', { params }) +); + +export const getMarketingEventsStatusCounts = () => ( + request.get('/api/marketing-events/status-counts') +); diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js new file mode 100644 index 0000000..26cb884 --- /dev/null +++ b/frontend/src/api/mkhBom.js @@ -0,0 +1,16 @@ +import request from './request'; + +const LIST_TIMEOUT_MS = 3 * 60 * 1000; +const DETAIL_TIMEOUT_MS = 3 * 60 * 1000; + +// MKH-BOM 资料列表(模块化组合商品 + 配件汇总) +export const getMkhBomList = (params = {}) => request.get( + '/api/mkh-bom/list', + { params, timeout: LIST_TIMEOUT_MS } +); + +// 单个 MKH 组合商品的 BOM 配件明细(列表展开 / 打印预览共用) +export const getMkhBomDetail = (skuId) => request.get( + '/api/mkh-bom/detail', + { params: { skuId }, timeout: DETAIL_TIMEOUT_MS } +); diff --git a/frontend/src/api/modularStockMonitor.js b/frontend/src/api/modularStockMonitor.js new file mode 100644 index 0000000..19257e7 --- /dev/null +++ b/frontend/src/api/modularStockMonitor.js @@ -0,0 +1,16 @@ +import request from './request'; + +const LIST_TIMEOUT_MS = 3 * 60 * 1000; +const META_TIMEOUT_MS = 60 * 1000; + +// 配件库存监控明细(默认仅「简尚-崔桥工厂半成品包装项目仓」) +export const getModularStockList = (params = {}) => request.get( + '/api/modular-stock-monitor/list', + { params, timeout: LIST_TIMEOUT_MS } +); + +// 筛选下拉选项(品类 / 预警等级) +export const getModularStockFilters = () => request.get( + '/api/modular-stock-monitor/filters', + { timeout: META_TIMEOUT_MS } +); diff --git a/frontend/src/api/planningUpload.js b/frontend/src/api/planningUpload.js index 0f46465..051fffb 100644 --- a/frontend/src/api/planningUpload.js +++ b/frontend/src/api/planningUpload.js @@ -2,6 +2,21 @@ import request from './request'; const PLANNING_TABLE_TIMEOUT_MS = 3 * 60 * 1000; +export const getOpsBIncentiveBatches = () => request.get( + '/api/planning-upload/analysis/ops-b-incentive/batches', + { timeout: 60 * 1000 } +); + +export const getOpsBIncentiveBoard = (params = {}) => request.get( + '/api/planning-upload/analysis/ops-b-incentive', + { params, timeout: PLANNING_TABLE_TIMEOUT_MS } +); + +export const getOpsBIncentiveDaily = (params = {}) => request.get( + '/api/planning-upload/analysis/ops-b-incentive/daily', + { params, timeout: PLANNING_TABLE_TIMEOUT_MS } +); + export const getPlanningAlibaba1688ProductDetail = (productId) => { return request.get(`/api/planning-upload/selection-fission/1688-products/${productId}`, { headers: { diff --git a/frontend/src/api/productAnalysis.js b/frontend/src/api/productAnalysis.js new file mode 100644 index 0000000..a7baef2 --- /dev/null +++ b/frontend/src/api/productAnalysis.js @@ -0,0 +1,42 @@ +import request from './request'; + +let productAnalysisMetaInFlight = null; +const productAnalysisProductsInFlight = new Map(); + +export const getProductAnalysisMeta = () => { + if (!productAnalysisMetaInFlight) { + productAnalysisMetaInFlight = request.get('/api/product-analysis/meta') + .finally(() => { + productAnalysisMetaInFlight = null; + }); + } + return productAnalysisMetaInFlight; +}; + +export const getProductAnalysisSignalThresholds = () => ( + request.get('/api/product-analysis/signal-thresholds', { silentError: true }) +); + +export const saveProductAnalysisSignalThresholds = (thresholds) => ( + request.put('/api/product-analysis/signal-thresholds', { thresholds }, { silentError: true }) +); + +export const getDevelopmentDashboard = (params, config = {}) => ( + request.get('/api/product-analysis/dashboard', { ...config, params }) +); + +export const getProductAnalysisProducts = (params) => { + const requestKey = JSON.stringify(params || {}); + if (!productAnalysisProductsInFlight.has(requestKey)) { + const inFlight = request.get('/api/product-analysis/products', { params }) + .finally(() => { + productAnalysisProductsInFlight.delete(requestKey); + }); + productAnalysisProductsInFlight.set(requestKey, inFlight); + } + return productAnalysisProductsInFlight.get(requestKey); +}; + +export const getProductAnalysisTab = (goodsId, tabKey, params) => ( + request.get(`/api/product-analysis/products/${encodeURIComponent(goodsId)}/tabs/${tabKey}`, { params }) +); diff --git a/frontend/src/api/productMonitoring.js b/frontend/src/api/productMonitoring.js new file mode 100644 index 0000000..1f2a523 --- /dev/null +++ b/frontend/src/api/productMonitoring.js @@ -0,0 +1,34 @@ +import request from './request'; + +const LIST_TIMEOUT_MS = 3 * 60 * 1000; +const META_TIMEOUT_MS = 60 * 1000; + +// 明细列表(公司标签 / 部门标签 / 负责人 / 汇总) +export const getProductMonitoringList = (params = {}) => request.get( + '/api/product-monitoring/list', + { params, timeout: LIST_TIMEOUT_MS } +); + +// 筛选下拉选项(品类 / 供应商 / 仓库 / 采购负责人 / 销售状态) +export const getProductMonitoringFilters = () => request.get( + '/api/product-monitoring/filters', + { timeout: META_TIMEOUT_MS } +); + +// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道) +export const getProductMonitoringOperators = (sku) => request.get( + '/api/product-monitoring/operators', + { params: { sku }, timeout: META_TIMEOUT_MS } +); + +// 部门标签(G-S/G-A/G-B/G-C)+ 部门 SKU 负责人配置 +export const getDepartmentTagConfig = () => request.get( + '/api/product-monitoring/department-config', + { timeout: META_TIMEOUT_MS } +); + +export const saveDepartmentTagConfig = (payload = {}) => request.put( + '/api/product-monitoring/department-config', + payload, + { timeout: META_TIMEOUT_MS } +); diff --git a/frontend/src/api/promotionModule.js b/frontend/src/api/promotionModule.js new file mode 100644 index 0000000..93e2d52 --- /dev/null +++ b/frontend/src/api/promotionModule.js @@ -0,0 +1,17 @@ +import request from './request'; + +export const getPromotionModuleList = (params) => ( + request.get('/api/promotion-module/list', { params }) +); + +export const getPromotionModuleLatestDate = () => ( + request.get('/api/promotion-module/latest-date') +); + +export const getPromotionModuleSummary = (params) => ( + request.get('/api/promotion-module/summary', { params }) +); + +export const getPromotionModuleShops = () => ( + request.get('/api/promotion-module/shops') +); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 2e77693..63a6520 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -2,8 +2,30 @@ export const DEFAULT_SIDEBAR_CONFIG = { collapsedByDefault: false, darkModeByDefault: false, entries: [ + { key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 }, + { key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 }, { key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 }, + { + key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true, + children: [ + { key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 }, + { key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 }, + ], + }, + { + key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true, + children: [ + { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 }, + ], + }, { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, + { + key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true, + children: [ + { key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 }, + { key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 }, + ], + }, { key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true, children: [ @@ -30,6 +52,28 @@ const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [ const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries)); const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries)); +// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置 +const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板'; +const OPS_B_INCENTIVE_LEGACY_ORDER = 6; +const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']); +// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并 +const extractPromotedTopLevelEntries = (entries = []) => { + const promoted = []; + const nextEntries = entries.map((entry) => { + if (!Array.isArray(entry?.children)) return entry; + const children = []; + entry.children.forEach((child) => { + if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) { + promoted.push(child); + } else { + children.push(child); + } + }); + return children.length === entry.children.length ? entry : { ...entry, children }; + }); + return { nextEntries, promoted }; +}; + const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key)); const configuredKeys = new Set(); @@ -45,6 +89,18 @@ const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { if (typeof defaultEntry.requiresAdmin !== 'undefined') { merged.requiresAdmin = defaultEntry.requiresAdmin; } + if ( + defaultEntry.key === 'ops-b-incentive' + && (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL) + ) { + merged.label = defaultEntry.label; + } + if ( + defaultEntry.key === 'ops-b-incentive' + && (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER) + ) { + merged.order = defaultEntry.order; + } const hasDefaultChildren = Array.isArray(defaultEntry.children); const hasConfiguredChildren = Array.isArray(configuredEntry.children); @@ -81,10 +137,15 @@ const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { const mergeSidebarConfigWithDefaults = (config) => { const defaults = cloneDefaultConfig(); + const nestedEntries = (config?.entries || []); + const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries); + const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean)); + const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted; + return { ...defaults, ...config, - entries: mergeSidebarEntries(defaults.entries, config?.entries || []), + entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]), }; }; diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index a806b85..9347efe 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -72,7 +72,26 @@ const SIDEBAR_ICON_MAP = { const SIDEBAR_ROUTE_MAP = { 'dashboard-preview': { path: '/planning/dev-style-preview' }, + 'marketing-events': { + path: '/planning/operation/marketing-events', + activePath: '/planning/operation/marketing-events', + matchMode: 'prefix', + }, + 'promotion-module': { path: '/planning/operation/promotion-module' }, + 'product-monitoring': { path: '/planning/operation/product-monitoring' }, + 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, + 'product-analysis': { path: '/planning/operation/product-analysis' }, 'work-orders': { path: '/planning/operation/work-orders' }, + 'modular-stock-monitor-accessory': { + path: '/planning/operation/modular-stock-monitor', + activePath: '/planning/operation/modular-stock-monitor', + matchMode: 'prefix', + }, + 'mkh-bom-data': { + path: '/planning/operation/mkh-bom-data', + activePath: '/planning/operation/mkh-bom-data', + matchMode: 'prefix', + }, settings: { path: '/settings' }, 'online-desktop-users': { path: '/settings/online-desktop-users' }, 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, diff --git a/frontend/src/pages/DevelopmentStylePreview/index.jsx b/frontend/src/pages/DevelopmentStylePreview/index.jsx index d88e520..f4559c5 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 { getDashboardMeta, getDashboardSummary } from '../../api/dashboard'; +import { getDevelopmentDashboard, getProductAnalysisMeta } from '../../api/productAnalysis'; import './stylePreview.css'; const overviewMetrics = [ @@ -180,7 +180,7 @@ const DevelopmentStylePreview = () => { useEffect(() => { let active = true; - getDashboardMeta().then((response) => { + getProductAnalysisMeta().then((response) => { if (!active) return; const shops = Array.isArray(response?.data?.shops) ? response.data.shops : []; setShopOptions([ @@ -202,7 +202,7 @@ const DevelopmentStylePreview = () => { const params = customStartDate && customEndDate ? { startDate: customStartDate, endDate: customEndDate, shopName: shopName || undefined } : { period: PERIOD_MAP[activePeriod] || 'week', shopName: shopName || undefined }; - getDashboardSummary(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => { + getDevelopmentDashboard(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => { const payload = response?.data || {}; setDashboard(payload); }).catch((error) => { diff --git a/frontend/src/pages/MarketingEvents/index.jsx b/frontend/src/pages/MarketingEvents/index.jsx new file mode 100644 index 0000000..a497666 --- /dev/null +++ b/frontend/src/pages/MarketingEvents/index.jsx @@ -0,0 +1,492 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Button, + DatePicker, + Form, + Input, + Segmented, + Select, + Spin, + Table, + Tag, +} from 'antd'; +import { + ReloadOutlined, + SearchOutlined, +} from '@ant-design/icons'; +import { SycmBusinessPanel, SycmDashboardMetric, SycmPage } from '../../components/Common'; +import { + getMarketingEventsList, + getMarketingEventsLatestDate, + getMarketingEventsSummary, +} from '../../api/marketingEvents'; +import './style.css'; + +const { RangePicker } = DatePicker; + +function formatTimestamp(ts) { + if (!ts) return '-'; + const num = Number(ts); + if (!Number.isFinite(num)) return '-'; + const date = new Date(num > 1e12 ? num : num * 1000); + if (Number.isNaN(date.getTime())) return '-'; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mm = String(date.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${d} ${hh}:${mm}`; +} + +function formatPrice(value) { + if (value === null || value === undefined || value === '') return '-'; + const num = Number(value); + if (!Number.isFinite(num)) return '-'; + return `¥${num.toFixed(2)}`; +} + +function formatNumber(value) { + if (value === null || value === undefined) return '-'; + const num = Number(value); + if (!Number.isFinite(num)) return '-'; + return num.toLocaleString('zh-CN'); +} + +const STATUS_MAP = { + 501: { label: '报名成功', color: 'success' }, + 502: { label: '审核中', color: 'processing' }, + 503: { label: '已拒绝', color: 'error' }, + 504: { label: '已取消', color: 'default' }, + 505: { label: '已结束', color: 'warning' }, +}; + +function getStatusInfo(status) { + if (STATUS_MAP[status]) return STATUS_MAP[status]; + return { label: `状态 ${status}`, color: 'default' }; +} + +const MarketingEvents = () => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [dataSource, setDataSource] = useState([]); + const [total, setTotal] = useState(0); + const [statusCounts, setStatusCounts] = useState({ all: 0 }); + const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); + const [sorter, setSorter] = useState({ field: 'activity_goods_start_time', order: 'descend' }); + const [statusFilter, setStatusFilter] = useState('all'); + const [groupByGoods, setGroupByGoods] = useState(true); + const [queryFilters, setQueryFilters] = useState({}); + const [latestDate, setLatestDate] = useState(''); + const [summary, setSummary] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(false); + + const fetchLatestDate = useCallback(async () => { + try { + const res = await getMarketingEventsLatestDate(); + if (res?.data?.date) { + setLatestDate(res.data.date); + return res.data.date; + } + } catch { + // 忽略错误 + } + return ''; + }, []); + + const fetchSummary = useCallback(async (date) => { + setSummaryLoading(true); + try { + const res = await getMarketingEventsSummary({ date }); + if (res?.data) { + setSummary(res.data); + } + } catch { + setSummary(null); + } finally { + setSummaryLoading(false); + } + }, []); + + useEffect(() => { + fetchLatestDate().then((date) => { + if (date) fetchSummary(date); + }); + }, [fetchLatestDate, fetchSummary]); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const params = { + page: pagination.current, + pageSize: pagination.pageSize, + sortBy: sorter.field, + sortOrder: sorter.order === 'ascend' ? 'ASC' : 'DESC', + ...queryFilters, + }; + if (statusFilter !== 'all') { + params.status = statusFilter; + } + const result = await getMarketingEventsList(params); + const data = result?.data; + if (data) { + setDataSource(data.list || []); + setTotal(data.total || 0); + if (data.statusCounts) { + setStatusCounts(data.statusCounts); + } + if (data.filterDate) { + setLatestDate(data.filterDate); + fetchSummary(data.filterDate); + } + } + } catch { + setDataSource([]); + setTotal(0); + } finally { + setLoading(false); + } + }, [pagination, sorter, statusFilter, queryFilters, fetchSummary]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleTableChange = (pag, _filters, sort) => { + setPagination({ current: pag.current, pageSize: pag.pageSize }); + if (sort.field) { + setSorter({ field: sort.field, order: sort.order || 'descend' }); + } + }; + + const handleReset = () => { + form.resetFields(); + setQueryFilters({}); + setStatusFilter('all'); + setPagination({ current: 1, pageSize: 20 }); + setSorter({ field: 'activity_goods_start_time', order: 'descend' }); + }; + + const handleFinish = (values) => { + const filters = {}; + if (values.shopName) filters.shopName = values.shopName; + if (values.activityType) filters.activityType = values.activityType; + if (values.activityName) filters.activityName = values.activityName; + if (values.activityId) filters.activityId = values.activityId; + if (values.goodsName) filters.goodsName = values.goodsName; + if (values.goodsId) filters.goodsId = values.goodsId; + if (values.dateRange?.length === 2) { + filters.dateStart = values.dateRange[0].format('YYYY-MM-DD'); + filters.dateEnd = values.dateRange[1].format('YYYY-MM-DD'); + } + setQueryFilters(filters); + setPagination((prev) => ({ ...prev, current: 1 })); + }; + + const statusOptions = useMemo(() => [ + { label: `全部 ${statusCounts.all || 0}`, value: 'all' }, + { label: `报名成功 ${statusCounts[501] || 0}`, value: 501 }, + { label: `审核中 ${statusCounts[502] || 0}`, value: 502 }, + { label: `已拒绝 ${statusCounts[503] || 0}`, value: 503 }, + { label: `已取消 ${statusCounts[504] || 0}`, value: 504 }, + { label: `已结束 ${statusCounts[505] || 0}`, value: 505 }, + ], [statusCounts]); + + // 按商品聚合:同一商品多个活动时,最低价活动作为主行优先展示,其余折叠进展开行 + const displayData = useMemo(() => { + const priceOf = (row) => { + const num = Number(row?.minPrice); + return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY; + }; + if (!groupByGoods) { + return dataSource.map((row) => ({ ...row, activityCount: 1, childActivities: null })); + } + const groups = new Map(); + dataSource.forEach((row) => { + const key = String(row.goodsId || row.id); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(row); + }); + const list = []; + groups.forEach((rows) => { + const sorted = [...rows].sort((left, right) => priceOf(left) - priceOf(right)); + const [primary, ...rest] = sorted; + list.push({ + ...primary, + activityCount: sorted.length, + childActivities: rest.length ? rest : null, + }); + }); + return list; + }, [dataSource, groupByGoods]); + + const summaryMetrics = useMemo(() => { + if (!summary) return []; + return [ + { key: 'totalRecords', label: '活动记录数', value: formatNumber(summary.totalRecords) }, + { key: 'successCount', label: '报名成功', value: formatNumber(summary.successCount) }, + { key: 'successRate', label: '报名成功率', value: `${summary.successRate}%` }, + { key: 'goodsCount', label: '参与商品数', value: formatNumber(summary.goodsCount) }, + { key: 'shopCount', label: '参与店铺数', value: formatNumber(summary.shopCount) }, + { key: 'activityIdCount', label: '活动数', value: formatNumber(summary.activityIdCount) }, + { key: 'activityTypeCount', label: '活动类型', value: formatNumber(summary.activityTypeCount) }, + { key: 'priceRange', label: '价格区间', value: summary.globalMinPrice !== '-' ? `${formatPrice(summary.globalMinPrice)} ~ ${formatPrice(summary.globalMaxPrice)}` : '-' }, + ]; + }, [summary]); + + const columns = useMemo(() => [ + { + title: '商品图片', + dataIndex: 'thumbUrl', + key: 'thumbUrl', + width: 80, + fixed: 'left', + render: (value, record) => ( + value ? ( + {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 new file mode 100644 index 0000000..510366d --- /dev/null +++ b/frontend/src/pages/MarketingEvents/style.css @@ -0,0 +1,224 @@ +.marketing-events-panel { + min-height: calc(100vh - 84px); + overflow: hidden; +} + +.marketing-events-panel__heading { + display: flex; + align-items: baseline; + gap: var(--sycm-space-sm); + padding: var(--sycm-module-padding) var(--sycm-module-padding) 0; +} + +.marketing-events-panel__heading h2 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} + +.marketing-events-panel__heading span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + line-height: var(--sycm-line-height-normal); +} + +.marketing-events-data-date { + margin-left: auto; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + white-space: nowrap; +} + +.marketing-events-summary { + display: grid; + grid-template-columns: repeat(4, minmax(110px, 1fr)); + gap: var(--sycm-space-sm); + margin-top: var(--sycm-space-sm); + padding: 0 var(--sycm-module-padding); +} + +.marketing-events-summary .sycm-dashboard-metric { + min-height: 88px; + padding: var(--sycm-space-sm); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-subtle); +} + +.marketing-events-summary .sycm-dashboard-metric__label { + font-size: var(--sycm-font-size-caption); +} + +.marketing-events-summary .sycm-dashboard-metric__value { + font-size: var(--sycm-font-size-metric); +} + +.marketing-events-summary__empty { + grid-column: 1 / -1; + padding: var(--sycm-space-md); + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + text-align: center; +} + +.marketing-events-filter { + display: grid; + grid-template-columns: repeat(3, minmax(180px, 1fr)); + gap: var(--sycm-space-sm) var(--sycm-space-xl); + margin-top: var(--sycm-space-sm); + padding: var(--sycm-module-padding); + background: var(--sycm-bg-subtle); +} + +.marketing-events-filter .ant-form-item { + min-width: 0; + margin-bottom: 0; +} + +.marketing-events-filter .ant-form-item-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + align-items: center; +} + +.marketing-events-filter .ant-form-item-label { + padding: 0 var(--sycm-space-sm) 0 0; + overflow: visible; + text-align: right; +} + +.marketing-events-filter .ant-form-item-label > label { + height: var(--sycm-control-height); + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + line-height: var(--sycm-control-height); +} + +.marketing-events-filter .ant-form-item-control, +.marketing-events-filter .ant-form-item-control-input, +.marketing-events-filter .ant-form-item-control-input-content { + min-height: var(--sycm-control-height); +} + +.marketing-events-filter__actions { + display: flex; + min-height: var(--sycm-control-height); + align-items: center; + gap: var(--sycm-space-sm); + padding-left: 72px; +} + +.marketing-events-table-toolbar { + display: flex; + min-height: 56px; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-md); + padding: var(--sycm-space-sm) var(--sycm-module-padding); + border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); + overflow-x: auto; +} + +.marketing-events-status-switch { + flex: 0 0 auto; +} + +.marketing-events-child-table { + margin: 0; +} + +.marketing-events-child-table .ant-table { + background: var(--sycm-bg-page-soft, #fafafa); +} + +.marketing-events-table .ant-table-wrapper, +.marketing-events-table .ant-spin-nested-loading, +.marketing-events-table .ant-spin-container { + min-width: 0; +} + +.marketing-events-table .ant-table { + min-width: 0; +} + +.marketing-events-table .ant-table-cell { + vertical-align: middle; +} + +.marketing-events-table .ant-table-thead > tr > th { + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-semibold); + white-space: nowrap; +} + +.marketing-events-table .ant-table-tbody > tr > td { + color: var(--sycm-text-primary); + font-size: var(--sycm-font-size-caption); +} + +.marketing-events-table .ant-table-placeholder .ant-table-cell { + height: 220px; +} + +.marketing-events-thumb { + display: block; + width: 52px; + height: 52px; + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-radius: var(--sycm-radius-sm); + object-fit: cover; +} + +.marketing-events-thumb.is-empty { + display: inline-flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + background: var(--sycm-bg-subtle); + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); +} + +.marketing-events-goods-cell { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--sycm-space-xxs); + line-height: var(--sycm-line-height-normal); +} + +.marketing-events-goods-name { + overflow: hidden; + color: var(--sycm-text-strong); + font-weight: var(--sycm-font-weight-medium); + text-overflow: ellipsis; + white-space: nowrap; +} + +.marketing-events-goods-id { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); +} + +@media (max-width: 767px) { + .marketing-events-panel__heading { + align-items: flex-start; + flex-direction: column; + gap: var(--sycm-space-xxs); + } + + .marketing-events-filter { + grid-template-columns: 1fr; + gap: var(--sycm-space-sm); + } + + .marketing-events-filter__actions { + padding-left: 72px; + } +} diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx new file mode 100644 index 0000000..192d65a --- /dev/null +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -0,0 +1,440 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, Button, Card, Input, Space, Spin, Table, Tooltip, Typography, message, +} from 'antd'; +import { PrinterOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { getMkhBomList, getMkhBomDetail } from '../../api/mkhBom'; +import './style.css'; + +const { Title, Text } = Typography; + +const number = (value, digits = 0) => (value === null || value === undefined || value === '' + ? '0' + : Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + })); + +const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); + +const EMPTY_FILTERS = { keyword: '' }; + +// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 +const PrintPreview = ({ data, onClose }) => { + const now = useMemo(() => new Date().toLocaleString('zh-CN'), []); + const items = data.items || []; + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
+
+ + + + 按 Esc 关闭预览 + +
+ +
+
+
MKH-BOM 资料
+
模块化商品配件明细 · 打印模板
+
+ +
+
打印时间:{now}
+
配件种类:{items.length}
+
+ +
+ + + + + + + + + + {items.map((item, idx) => ( + + {idx === 0 ? ( + + ) : null} + + + + + ))} + {items.length === 0 ? ( + + + + + ) : null} + +
MKH商品编码配件明细数量配件仓位
{text(data.skuId)}{text(item.skuName)}{item.skuId ? {item.skuId} : null}{number(item.qty)}{item.location ? text(item.location) : 未维护}
{text(data.skuId)}该 MKH 商品的配件明细尚未录入「产品资料系统数据库」
+ +
+ + MKH 商品编码取自业务库;配件明细与数量取自「产品资料系统数据库」(BOM); + 配件仓位取库位主数据,未维护时显示「未维护」。 + +
+ + + ); +}; + +const MkhBomData = () => { + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [sorter, setSorter] = useState({ field: 'skuId', order: 'ASC' }); + + const [expandedKeys, setExpandedKeys] = useState({}); + const [detailCache, setDetailCache] = useState({}); + const [detailLoading, setDetailLoading] = useState({}); + + const [printData, setPrintData] = useState(null); + const [printLoading, setPrintLoading] = useState(false); + + const tableWrapRef = useRef(null); + const [bodyHeight, setBodyHeight] = useState(420); + + useEffect(() => { + const el = tableWrapRef.current; + if (!el) return undefined; + const RESERVE_BELOW = 104; + const measure = () => { + const scroller = el.closest('.planning-subsystem-content'); + const elRect = el.getBoundingClientRect(); + let available; + if (scroller) { + const scrollerRect = scroller.getBoundingClientRect(); + available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop; + } else { + available = window.innerHeight - elRect.top - RESERVE_BELOW; + } + const next = Math.max(240, Math.round(available)); + setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev)); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + if (el.parentElement) ro.observe(el.parentElement); + window.addEventListener('resize', measure); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); + + const query = useMemo(() => { + const params = { page, pageSize, sortField: sorter.field, sortOrder: sorter.order }; + Object.entries(filters).forEach(([key, value]) => { if (value) params[key] = value; }); + return params; + }, [page, pageSize, sorter, filters]); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const result = await getMkhBomList(query); + if (!result || result.success === false) { + setError(result?.error || '加载 MKH-BOM 资料失败'); + return; + } + setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 }); + } catch (requestError) { + setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败'); + } finally { + setLoading(false); + } + }, [query]); + + useEffect(() => { load(); }, [load]); + + const changeFilter = (key) => (value) => { + setFilters((old) => ({ ...old, [key]: value || '' })); + setPage(1); + }; + const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); }; + + const loadDetail = useCallback(async (skuId) => { + if (detailCache[skuId] || detailLoading[skuId]) return; + setDetailLoading((old) => ({ ...old, [skuId]: true })); + try { + const result = await getMkhBomDetail(skuId); + if (!result || result.success === false) { + message.error(result?.error || '加载配件明细失败'); + return; + } + setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } })); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '加载配件明细失败'); + } finally { + setDetailLoading((old) => ({ ...old, [skuId]: false })); + } + }, [detailCache, detailLoading]); + + const handlePrint = async (skuId) => { + setPrintLoading(true); + try { + const result = await getMkhBomDetail(skuId); + if (!result || result.success === false) { + message.error(result?.error || '加载打印数据失败'); + return; + } + setPrintData(result?.data || { skuId, items: [] }); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '加载打印数据失败'); + } finally { + setPrintLoading(false); + } + }; + + const columns = useMemo(() => [ + { + title: 'MKH商品编码', + dataIndex: 'skuId', + width: 240, + fixed: 'left', + sorter: true, + render: (value, row) => ( + + {text(value)} + {row.source === 'product_db' ? ( + + 资料库 + + ) : null} + + ), + }, + { + title: '商品名称', + dataIndex: 'productName', + ellipsis: true, + sorter: true, + render: (value) => {text(value)}, + }, + { + title: 'BOM 状态', + dataIndex: 'bomConfigured', + width: 100, + align: 'center', + render: (value, row) => (row.bomConfigured === '已配置' + ? 已配置 + : 未配置), + }, + { + title: '配件种类', + dataIndex: 'accessoryCount', + width: 110, + align: 'right', + sorter: true, + render: (value) => number(value), + }, + { + title: '配件总数量', + dataIndex: 'accessoryQtySum', + width: 120, + align: 'right', + sorter: true, + render: (value) => number(value), + }, + { + title: '操作', + key: 'action', + width: 190, + fixed: 'right', + render: (_value, row) => ( + + + + + ), + }, + ], [expandedKeys, printLoading, loadDetail]); + + const expandedRowRender = (row) => { + const detail = detailCache[row.skuId]; + if (detailLoading[row.skuId] && !detail) { + return
加载配件明细…
; + } + const items = detail?.items || []; + return ( +
+ {items.length === 0 ? ( +
+ + 该 MKH 商品在「产品资料系统数据库」中暂无配件 BOM 录入。 + 在产品资料系统里维护好该商品的配件明细后,此处会自动显示配件明细、数量与仓位。 + +
+ ) : null} + ( + + {text(v)} + {r.skuId ? {r.skuId} : null} + + ), + }, + { title: '数量', dataIndex: 'qty', width: 90, align: 'right', render: (v) => number(v) }, + { + title: '配件仓位', + dataIndex: 'location', + width: 140, + render: (v) => (v ? text(v) : ( + + 未维护 + + )), + }, + ]} + /> + + ); + }; + + return ( + +
+
+ MKH-BOM 资料 + + 模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位 + + + 数据口径:MKH 商品编码取自业务库 jst_sku(sku_id LIKE 'MKH%' 且 item_type = 成品,共 4436 条); + 配件明细与数量取自「产品资料系统数据库」(PostgreSQL / furniture3d_prod)中模块化商品维护的配件明细(配件条码 + 数量); + 资料库中已维护 BOM 但业务库无对应成品的编码会置顶并标注「资料库」;配件仓位取库位主数据,未维护时显示「未维护」。 + +
+
+ + + + } + placeholder="MKH商品编码 / 名称" + value={filters.keyword} + onChange={(e) => changeFilter('keyword')(e.target.value)} + style={{ width: 260 }} + /> + + + + + + {error ? 重试} /> : null} + + {!loading && (data.total || 0) > 0 ? ( + 0 ? 'info' : 'warning'} + showIcon + message={data.bomRowCount > 0 + ? `产品资料系统数据库已接入 ${data.bomRowCount} 个模块化商品的配件 BOM` + : '产品资料系统数据库中尚未录入模块化商品的配件 BOM'} + description={( + + MKH 商品编码({data.total - (data.bomExtraCount || 0)} 条)取自业务库 jst_sku; + 配件明细与数量取自「产品资料系统数据库」中模块化商品维护的 BOM。 + 资料库中已维护 BOM、但业务库无对应成品的编码({data.bomExtraCount || 0} 条)会置顶并标注「资料库」。 + 在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 + + )} + /> + ) : null} + + MKH 模块化成品({data.total || 0} 条)} + > + +
+
expandedKeys[k]), + expandedRowRender, + onExpand: (expanded, record) => { + setExpandedKeys((old) => ({ ...old, [record.skuId]: expanded })); + if (expanded) loadDetail(record.skuId); + }, + }} + onChange={(pagination, _filter, nextSorter) => { + setPage(pagination.current || 1); + setPageSize(pagination.pageSize || 50); + if (nextSorter?.field) { + setSorter({ + field: nextSorter.field, + order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC', + }); + } + }} + pagination={{ + current: data.page || page, + pageSize: data.pageSize || pageSize, + total: data.total || 0, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200], + showTotal: (total) => `共 ${total} 条`, + }} + scroll={{ x: 1000, y: bodyHeight }} + /> + + + + + {printData ? setPrintData(null)} /> : null} + + ); +}; + +export default MkhBomData; diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css new file mode 100644 index 0000000..e25d910 --- /dev/null +++ b/frontend/src/pages/MkhBomData/style.css @@ -0,0 +1,239 @@ +.mkh-page { + padding: 16px 20px 24px; +} + +.mkh-header { + margin-bottom: 14px; +} + +.mkh-header__heading .ant-typography { + margin-bottom: 4px; +} + +.mkh-note { + display: block; + max-width: 1180px; + margin-top: 6px; + font-size: 12px; + line-height: 1.7; +} + +.mkh-filter-card { + margin-bottom: 12px; +} + +.mkh-panel { + margin-top: 4px; +} + +.mkh-sku { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + color: var(--sycm-text-primary); +} + +.mkh-muted { + color: var(--sycm-text-tertiary); +} + +.mkh-detail-loading { + padding: 12px 8px; + color: var(--sycm-text-secondary); +} + +.mkh-detail { + padding: 4px 8px 8px; +} + +.mkh-detail-tip { + padding: 4px 0 8px; + font-size: 12px; +} + +.mkh-detail-code { + display: block; + margin-top: 2px; + font-family: 'SFMono-Regular', Consolas, Menlo, monospace; + font-size: 11px; + color: var(--sycm-text-tertiary); +} + +.mkh-tag { + display: inline-block; + padding: 0 6px; + border-radius: 3px; + font-size: 12px; + line-height: 18px; + white-space: nowrap; +} + +.mkh-tag--ok { + color: #0f6e56; + background: rgba(29, 158, 117, 0.14); +} + +.mkh-tag--muted { + color: var(--sycm-text-tertiary); + background: rgba(136, 135, 128, 0.14); +} + +.mkh-tag--src { + color: #0958d9; + background: rgba(22, 119, 255, 0.12); +} + +/* ============ A4 打印预览 ============ */ +.mkh-print-mask { + position: fixed; + inset: 0; + z-index: 2000; + overflow: auto; + background: rgba(0, 0, 0, 0.55); + padding: 24px 0 40px; +} + +.mkh-print-toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + justify-content: center; + padding: 12px; + margin-bottom: 16px; +} + +.mkh-print-sheet { + width: 210mm; + min-height: 297mm; + margin: 0 auto; + padding: 14mm 12mm; + background: #fff; + color: #000; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35); + box-sizing: border-box; + font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; + font-size: 12px; + line-height: 1.5; +} + +.mkh-print-head { + text-align: center; + border-bottom: 2px solid #000; + padding-bottom: 10px; + margin-bottom: 12px; +} + +.mkh-print-title { + font-size: 22px; + font-weight: 700; + letter-spacing: 2px; +} + +.mkh-print-sub { + margin-top: 4px; + font-size: 12px; + color: #444; +} + +.mkh-print-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 24px; + margin-bottom: 12px; + font-size: 13px; +} + +.mkh-print-meta > div { + display: flex; +} + +.mkh-print-meta span { + color: #555; + min-width: 78px; +} + +.mkh-print-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.mkh-print-table th, +.mkh-print-table td { + border: 1px solid #000; + padding: 6px 8px; + text-align: left; + word-break: break-all; +} + +.mkh-print-table thead th { + background: #f2f2f2; + font-weight: 700; +} + +.mkh-print-table .mkh-center { + text-align: center; +} + +.mkh-print-table .mkh-muted { + color: #999; +} + +.mkh-print-table .mkh-print-sku { + font-family: 'SFMono-Regular', Consolas, Menlo, monospace; + font-size: 11px; + vertical-align: middle; +} + +.mkh-print-code { + display: block; + margin-top: 2px; + font-family: 'SFMono-Regular', Consolas, Menlo, monospace; + font-size: 10px; + color: #666; +} + +.mkh-print-foot { + margin-top: 16px; + padding-top: 8px; + border-top: 1px dashed #999; + font-size: 11px; + color: #666; +} + +/* 打印时只输出 A4 纸张,隐藏其余界面 */ +@media print { + body * { + visibility: hidden !important; + } + + .mkh-print-sheet, + .mkh-print-sheet * { + visibility: visible !important; + } + + .mkh-print-toolbar { + display: none !important; + } + + .mkh-print-mask { + position: absolute !important; + inset: 0 !important; + background: #fff !important; + overflow: visible !important; + padding: 0 !important; + } + + .mkh-print-sheet { + width: 210mm; + min-height: auto; + margin: 0 !important; + padding: 0 !important; + box-shadow: none !important; + } + + @page { + size: A4; + margin: 12mm; + } +} diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx new file mode 100644 index 0000000..f9d6cc6 --- /dev/null +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -0,0 +1,428 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, Button, Card, Col, Dropdown, Image, Input, Row, Select, + Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message, +} from 'antd'; +import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { getModularStockList, getModularStockFilters } from '../../api/modularStockMonitor'; +import { exportTableFile } from '../../utils/tableExport'; +import './style.css'; + +const { Title, Text } = Typography; + +const WARNING_COLOR = { 缺货: 'red', 预警: 'orange', 正常: 'green' }; + +// 表头带口径说明,避免把「真实值 0」误读成「没数据」 +const TipTitle = ({ title, tip }) => ( + + {title} + +); + +const number = (value, digits = 2) => value === null || value === undefined || value === '' + ? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); +const int = (value) => number(value, 0); +const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); + +const toOptions = (list = []) => (Array.isArray(list) ? list : []) + .filter(Boolean) + .map((value) => ({ value, label: value })); + +const EMPTY_FILTERS = { keyword: '', category: '', warningLevel: '', mkhStatus: '' }; + +const AccessoryStockPanel = () => { + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [onlyWarning, setOnlyWarning] = useState(false); + const [filterOptions, setFilterOptions] = useState({ categories: [], warningLevels: [] }); + const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: null }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [sorter, setSorter] = useState({ field: 'realtimeStock', order: 'DESC' }); + const tableWrapRef = useRef(null); + const [bodyHeight, setBodyHeight] = useState(420); + + // 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc: + // 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行 + useEffect(() => { + const el = tableWrapRef.current; + if (!el) return undefined; + + // 分页器 + 卡片内边距 + 横向滚动条余量 + const RESERVE_BELOW = 104; + + const measure = () => { + const scroller = el.closest('.planning-subsystem-content'); + const elRect = el.getBoundingClientRect(); + let available; + if (scroller) { + const scrollerRect = scroller.getBoundingClientRect(); + // 加回 scrollTop,抵消滚动对 getBoundingClientRect 的影响 + available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop; + } else { + available = window.innerHeight - elRect.top - RESERVE_BELOW; + } + const next = Math.max(240, Math.round(available)); + setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev)); + }; + + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + if (el.parentElement) ro.observe(el.parentElement); + window.addEventListener('resize', measure); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); + + useEffect(() => { + let cancelled = false; + getModularStockFilters() + .then((result) => { + const payload = result?.data || {}; + if (cancelled) return; + setFilterOptions({ + categories: payload.categories || [], + warningLevels: payload.warningLevels || ['缺货', '预警', '正常'], + mkhStatuses: payload.mkhStatuses || ['已配置MKH', '未配置MKH'], + }); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const query = useMemo(() => { + const params = { + page, + pageSize, + sortField: sorter.field, + sortOrder: sorter.order, + }; + Object.entries(filters).forEach(([key, value]) => { + if (value) params[key] = value; + }); + if (onlyWarning) params.onlyWarning = '1'; + return params; + }, [page, pageSize, sorter, filters, onlyWarning]); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const result = await getModularStockList(query); + setData(result?.data || { rows: [], total: 0, summary: {} }); + } catch (requestError) { + setError(requestError?.response?.data?.error || requestError?.message || '加载配件库存监控数据失败'); + } finally { + setLoading(false); + } + }, [query]); + + useEffect(() => { load(); }, [load]); + + const changeFilter = (key) => (value) => { + setFilters((old) => ({ ...old, [key]: value || '' })); + setPage(1); + }; + const resetFilters = () => { + setFilters(EMPTY_FILTERS); + setOnlyWarning(false); + setPage(1); + }; + + const columns = useMemo(() => [ + { + title: '图片', + dataIndex: 'imageUrl', + width: 56, + fixed: 'left', + exportable: false, + render: (value) => (value + ? + : --), + }, + { + title: '预警', + dataIndex: 'warningLevel', + width: 76, + sorter: true, + render: (value) => (value + ? {value} + : --), + }, + { + title: , + dataIndex: 'mkhStatus', + width: 110, + sorter: true, + render: (value) => { + const v = value || '未配置MKH'; + return v === '已配置MKH' + ? {v} + : {v}; + }, + }, + { + title: 'SKU编码', + dataIndex: 'skuId', + width: 176, + fixed: 'left', + sorter: true, + render: (value) => {text(value)}, + }, + { + // 必须显式给 width:antd 在 scroll.x 场景下会把无宽度列压成 0 宽(此前该列不可见的原因) + title: '商品名称', + dataIndex: 'productName', + width: 220, + ellipsis: true, + sorter: true, + render: (value) => {text(value)}, + }, + { + title: , + dataIndex: 'inQtyToday', + width: 116, + align: 'right', + render: (value) => (value === null || value === undefined + ? -- + : int(value)), + }, + { + title: , + dataIndex: 'outQtyToday', + width: 116, + align: 'right', + render: (value) => (value === null || value === undefined + ? -- + : int(value)), + }, + { + title: '实时库存', + dataIndex: 'realtimeStock', + width: 96, + align: 'right', + sorter: true, + render: int, + }, + { + title: , + dataIndex: 'location', + width: 88, + sorter: true, + render: (value) => (value + ? {value} + : + 未维护 + ), + }, + { + title: '近7日日均发货量', + dataIndex: 'avgShip7d', + width: 138, + align: 'right', + sorter: true, + render: (value) => number(value), + }, + { + title: '库存可用天数', + dataIndex: 'stockDays', + width: 116, + align: 'right', + sorter: true, + render: (value) => number(value, 1), + }, + { + title: '所在仓库', + dataIndex: 'warehouse', + width: 200, + ellipsis: true, + sorter: true, + render: (value) => {text(value)}, + }, + ], []); + + const exportColumns = useMemo( + () => columns.filter((column) => column.exportable !== false), + [columns] + ); + + const handleExport = async (format) => { + if (!data.rows.length) { + message.warning('暂无数据可导出'); + return; + } + try { + await exportTableFile( + format, + `配件库存监控_${data.monitorDate || '最新'}`, + exportColumns, + data.rows, + '配件库存监控' + ); + message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`); + } catch (exportError) { + message.error(exportError?.message || '导出失败'); + } + }; + + const summary = data.summary || {}; + + return ( + <> + + + } + placeholder="SKU 编码 / 名称" + value={filters.keyword} + onChange={(e) => changeFilter('keyword')(e.target.value)} + style={{ width: 220 }} + /> + +
{ + setPage(pagination.current || 1); + setPageSize(pagination.pageSize || 50); + if (nextSorter?.field) { + setSorter({ + field: nextSorter.field, + order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC', + }); + } + }} + pagination={{ + current: data.page || page, + pageSize: data.pageSize || pageSize, + total: data.total || 0, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200], + showTotal: (total) => `共 ${total} 条`, + }} + scroll={{ x: 1508, y: bodyHeight }} + /> + + + + + ); +}; + +const ModularStockMonitor = () => ( + +
+
+ 模块化库存监控 + + 配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 7 日日均发货量与可用天数 + + + 数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 7 日日均发货量、 + 库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖); + 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」; + 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 + 状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。 + +
+
+ , + }]} + /> +
+); + +export default ModularStockMonitor; diff --git a/frontend/src/pages/ModularStockMonitor/style.css b/frontend/src/pages/ModularStockMonitor/style.css new file mode 100644 index 0000000..80ea127 --- /dev/null +++ b/frontend/src/pages/ModularStockMonitor/style.css @@ -0,0 +1,156 @@ +.msm-page { + container-name: modular-stock-monitor; + container-type: inline-size; + display: flex; + min-height: calc(100% - 46px); + flex-direction: column; + gap: var(--sycm-page-section-gap); + background: var(--sycm-bg-page-soft); +} + +.msm-header { + display: flex; + min-height: 84px; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-lg); + margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0; + padding: 14px calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} +.msm-header__heading { min-width: 0; } +.msm-header h3 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-page); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} +.msm-header__heading .ant-typography { + display: block; + margin-top: 6px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.msm-tabs .ant-tabs-nav { + margin: 0; + padding: 0 calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} +.msm-tabs .ant-tabs-tab { + margin: 0; + padding: 12px 4px; + font-size: var(--sycm-font-size-title); +} +.msm-tabs .ant-tabs-content-holder, +.msm-tabs .ant-tabs-content, +.msm-tabs .ant-tabs-tabpane { min-height: 100%; } +.msm-tabs .ant-tabs-tabpane { + display: flex; + flex-direction: column; + gap: var(--sycm-page-section-gap); + padding-top: var(--sycm-page-section-gap); +} + +.msm-filter-card, +.msm-panel, +.msm-summary-card { + border: 1px solid var(--sycm-border-card) !important; + border-radius: var(--sycm-radius-lg) !important; + box-shadow: var(--sycm-shadow-card) !important; +} +.msm-filter-card { margin-bottom: 0; } +.msm-filter-card .ant-card-body { padding: 14px 16px; } +.msm-filter-card .ant-space { width: 100%; } +.msm-filter-card .ant-space-item { max-width: 100%; } +.msm-filter-card .ant-select, +.msm-filter-card .ant-input-affix-wrapper { max-width: 100%; } + +.msm-summary-row { margin-top: -4px; } +.msm-summary-row .ant-col { display: flex; } +.msm-summary-card { + position: relative; + width: 100%; + min-height: 98px; + overflow: hidden; +} +.msm-summary-card::before { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--sycm-color-primary); + content: ''; +} +.msm-summary-card.is-red::before { background: var(--sycm-color-danger); } +.msm-summary-card.is-orange::before { background: var(--sycm-color-orange); } +.msm-summary-card.is-green::before { background: var(--sycm-color-success); } +.msm-summary-card .ant-card-body { + display: flex; + min-height: 98px; + align-items: center; + padding: 16px 18px; +} +.msm-summary-card .ant-statistic { width: 100%; } +.msm-summary-card .ant-statistic-title { + margin-bottom: 8px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} +.msm-summary-card .ant-statistic-content { + color: var(--sycm-text-strong); + font-family: var(--sycm-font-family-number); + font-size: var(--sycm-font-size-metric); + line-height: 1.1; +} +.msm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); } + +.msm-panel { margin-bottom: 0; overflow: hidden; } +.msm-panel .ant-card-head { + min-height: 48px; + padding: 0 16px; + border-bottom-color: var(--sycm-border-light); +} +.msm-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); } +.msm-panel .ant-card-body { padding: 0 16px 16px; } +.msm-panel .ant-table { font-size: var(--sycm-font-size-caption); } +.msm-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); } +.msm-panel .ant-table-placeholder .ant-table-cell { height: 180px; } +.msm-panel__date { font-size: var(--sycm-font-size-caption); } + +.msm-muted { color: var(--sycm-text-tertiary); } +/* 表头口径提示:虚线下划线 + help 光标 */ +.msm-th-tip { + border-bottom: 1px dashed var(--sycm-border-strong, var(--sycm-text-tertiary)); + cursor: help; +} +.msm-note { + display: block; + max-width: 1180px; + margin-top: 4px; + font-size: 12px; + line-height: 1.6; +} +.msm-sku { + font-family: var(--sycm-font-family-number); + font-weight: var(--sycm-font-weight-semibold); +} + +@container modular-stock-monitor (max-width: 900px) { + .msm-filter-card .ant-space-item { flex: 1 1 180px; } +} + +@media (max-width: 767px) { + .msm-header { + min-height: 72px; + align-items: flex-start; + flex-direction: column; + gap: var(--sycm-space-sm); + padding-block: 12px; + } + .msm-filter-card .ant-select, + .msm-filter-card .ant-input-affix-wrapper { width: 100%; } + .msm-panel .ant-card-body { padding-inline: 10px; } +} diff --git a/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx b/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx new file mode 100644 index 0000000..78f580b --- /dev/null +++ b/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx @@ -0,0 +1,233 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, Button, Card, Col, DatePicker, Descriptions, Drawer, Dropdown, Input, Row, Select, + Space, Spin, Statistic, Table, Tabs, Tag, Typography, message, +} from 'antd'; +import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { SycmPage } from '../../components/Common'; +import { getOpsBIncentiveBatches, getOpsBIncentiveBoard, getOpsBIncentiveDaily } from '../../api/planningUpload'; +import { exportTableFile } from '../../utils/tableExport'; +import './opsBIncentiveBoard.css'; + +const { Title, Text } = Typography; +const FALLBACK_BATCH = { key: 'ops-b-20260715-20260721', batchNo: 1, scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 }; +const batchLabel = (item) => { + const mmdd = (value) => dayjs(value).format('MM-DD'); + const cap = `${parseFloat((Number(item.feeCap) * 100).toFixed(1))}%`; + return `第${item.batchNo}批(${mmdd(item.scopeStart)} 至 ${mmdd(item.scopeEnd)} · 拉取 ${mmdd(item.pullDate)} · 费比≤${cap})${item.finalized ? ' · 已定档' : ''}`; +}; + +const number = (value, digits = 2) => value === null || value === undefined || value === '' + ? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); +const money = (value) => value === null || value === undefined ? '--' : `¥${number(value)}`; +const percent = (value) => value === null || value === undefined ? '--' : `${(Number(value) * 100).toFixed(2)}%`; +const dateText = (value) => value ? dayjs(value).format('YYYY-MM-DD') : '--'; + +const OpsBIncentiveBoardPanel = () => { + const [batches, setBatches] = useState([]); + const [batch, setBatch] = useState(FALLBACK_BATCH.key); + const [metricEnd, setMetricEnd] = useState(dayjs(FALLBACK_BATCH.pullDate)); + const [filters, setFilters] = useState({ ider: '', shopName: '', spu: '', goodsId: '' }); + const [data, setData] = useState({ rows: [], ownerSummary: [], summary: {} }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [selected, setSelected] = useState(null); + const [dailyRows, setDailyRows] = useState([]); + const [dailyLoading, setDailyLoading] = useState(false); + + const selectedBatch = useMemo( + () => batches.find((item) => item.key === batch) || FALLBACK_BATCH, + [batches, batch] + ); + + useEffect(() => { + let cancelled = false; + getOpsBIncentiveBatches() + .then((result) => { + const list = result?.data?.data || result?.data || []; + if (cancelled || !Array.isArray(list) || !list.length) return; + setBatches(list); + if (!list.some((item) => item.key === FALLBACK_BATCH.key)) { + setBatch(list[0].key); + setMetricEnd(dayjs(list[0].pullDate)); + } + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const query = useMemo(() => ({ + scopeStart: selectedBatch.scopeStart, + scopeEnd: selectedBatch.scopeEnd, + metricEnd: metricEnd?.format('YYYY-MM-DD') || selectedBatch.pullDate, + ...filters, + }), [selectedBatch, metricEnd, filters]); + + const changeBatch = (value) => { + setBatch(value); + const next = batches.find((item) => item.key === value); + if (next) setMetricEnd(dayjs(next.pullDate)); + }; + + const load = useCallback(async () => { + setLoading(true); setError(''); + try { + const result = await getOpsBIncentiveBoard(query); + setData(result?.data || result || { rows: [], ownerSummary: [], summary: {} }); + } catch (requestError) { + setError(requestError?.message || '加载运营中心B看板失败'); + } finally { setLoading(false); } + }, [query]); + + useEffect(() => { load(); }, [load]); + + const openDetail = async (record) => { + setSelected(record); setDailyRows([]); setDailyLoading(true); + try { + const result = await getOpsBIncentiveDaily({ ...query, goodsId: record.goodsId }); + setDailyRows(result?.data?.rows || result?.rows || []); + } catch (requestError) { + message.error(requestError?.message || '加载每日明细失败'); + } finally { setDailyLoading(false); } + }; + + const iderOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.ider).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]); + const shopOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.shopName).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]); + // 分组:0=动销率与费比均达标,1=仅一项达标,2=均不达标;组内按动销率降序。 + const sortedRows = useMemo(() => [...(data.rows || [])].sort((a, b) => { + const group = (row) => (row.bonusQualified ? 0 : (row.feeQualified || row.sellThroughQualified ? 1 : 2)); + const rate = (row) => (row.sellThroughRate30d === null || row.sellThroughRate30d === undefined ? -1 : row.sellThroughRate30d); + return group(a) - group(b) || rate(b) - rate(a) || String(a.goodsId).localeCompare(String(b.goodsId)); + }), [data.rows]); + const num = (value) => (value === null || value === undefined || value === '' ? -Infinity : Number(value)); + const qualifyFilters = [ + { text: '达标', value: 'yes' }, + { text: '未达标', value: 'no' }, + ]; + const qualifyFilter = (field) => ({ + filters: qualifyFilters, + onFilter: (value, row) => (value === 'yes' ? Boolean(row[field]) : !row[field]), + }); + const columns = [ + { title: 'ID负责人', dataIndex: 'ider', fixed: 'left', width: 100 }, + { title: '商品ID', dataIndex: 'goodsId', fixed: 'left', width: 130, render: (value, row) => }, + { 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 new file mode 100644 index 0000000..68fa2c8 --- /dev/null +++ b/frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css @@ -0,0 +1,160 @@ +.ops-b-incentive-page { + container-name: ops-b-incentive; + container-type: inline-size; + display: flex; + min-height: calc(100% - 46px); + flex-direction: column; + gap: var(--sycm-page-section-gap); + background: var(--sycm-bg-page-soft); +} + +.ops-b-header { + display: flex; + min-height: 84px; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-lg); + margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0; + padding: 14px calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} + +.ops-b-header__heading { min-width: 0; } +.ops-b-header h3 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-page); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} +.ops-b-header__heading .ant-typography { + display: block; + margin-top: 6px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.ops-b-tabs .ant-tabs-nav { + margin: 0; + padding: 0 calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} +.ops-b-tabs .ant-tabs-tab { + margin: 0; + padding: 12px 4px; + font-size: var(--sycm-font-size-title); +} +.ops-b-tabs .ant-tabs-tab + .ant-tabs-tab { margin-left: 32px; } +.ops-b-tabs .ant-tabs-content-holder, +.ops-b-tabs .ant-tabs-content, +.ops-b-tabs .ant-tabs-tabpane { min-height: 100%; } +.ops-b-tabs .ant-tabs-tabpane { + display: flex; + flex-direction: column; + gap: var(--sycm-page-section-gap); + padding-top: var(--sycm-page-section-gap); +} + +.ops-b-filter-card, +.ops-b-panel, +.ops-b-summary-card { + border: 1px solid var(--sycm-border-card) !important; + border-radius: var(--sycm-radius-lg) !important; + box-shadow: var(--sycm-shadow-card) !important; +} +.ops-b-filter-card { margin-bottom: 0; } +.ops-b-filter-card .ant-card-body { padding: 14px 16px; } +.ops-b-filter-card .ant-space { width: 100%; } +.ops-b-filter-card .ant-space-item { max-width: 100%; } +.ops-b-filter-card .ant-select, +.ops-b-filter-card .ant-picker, +.ops-b-filter-card .ant-input-affix-wrapper { max-width: 100%; } + +.ops-b-summary-row { margin-top: -4px; } +.ops-b-summary-row .ant-col { display: flex; } +.ops-b-summary-card { + position: relative; + width: 100%; + min-height: 98px; + overflow: hidden; +} +.ops-b-summary-card::before { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--sycm-color-primary); + content: ''; +} +.ops-b-summary-card.is-green::before { background: var(--sycm-color-success); } +.ops-b-summary-card.is-orange::before { background: var(--sycm-color-orange); } +.ops-b-summary-card.is-gold::before { background: var(--sycm-color-warning); } +.ops-b-summary-card .ant-card-body { + display: flex; + min-height: 98px; + align-items: center; + padding: 16px 18px; +} +.ops-b-summary-card .ant-statistic { width: 100%; } +.ops-b-summary-card .ant-statistic-title { + margin-bottom: 8px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} +.ops-b-summary-card .ant-statistic-content { + color: var(--sycm-text-strong); + font-family: var(--sycm-font-family-number); + font-size: var(--sycm-font-size-metric); + line-height: 1.1; +} + +.ops-b-panel { margin-bottom: 0; overflow: hidden; } +.ops-b-panel .ant-card-head { + min-height: 48px; + padding: 0 16px; + border-bottom-color: var(--sycm-border-light); +} +.ops-b-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); } +.ops-b-panel .ant-card-body { padding: 0 16px 16px; } +.ops-b-panel .ant-table { font-size: var(--sycm-font-size-caption); } +.ops-b-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); } +.ops-b-panel .ant-table-placeholder .ant-table-cell { height: 180px; } +.ops-b-id-link { + height: auto; + padding: 0; + font-family: var(--sycm-font-family-number); + font-weight: var(--sycm-font-weight-semibold); +} +.ops-b-daily-table { margin-top: var(--sycm-space-md); } + +.ops-b-detail-drawer .ant-drawer-header { + padding: 16px 20px; + border-bottom-color: var(--sycm-border-light); +} +.ops-b-detail-drawer .ant-drawer-title { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); +} +.ops-b-detail-drawer .ant-drawer-body { padding: 20px; } +.ops-b-detail-drawer .ant-descriptions { margin-bottom: var(--sycm-space-md); } + +@container ops-b-incentive (max-width: 900px) { + .ops-b-filter-card .ant-space-item { flex: 1 1 180px; } +} + +@media (max-width: 767px) { + .ops-b-header { + min-height: 72px; + align-items: flex-start; + flex-direction: column; + gap: var(--sycm-space-sm); + padding-block: 12px; + } + .ops-b-filter-card .ant-select, + .ops-b-filter-card .ant-picker, + .ops-b-filter-card .ant-input-affix-wrapper { width: 100%; } + .ops-b-panel .ant-card-body { padding-inline: 10px; } + .ops-b-detail-drawer .ant-drawer-body { padding: 14px; } +} diff --git a/frontend/src/pages/ProductAnalysis/index.jsx b/frontend/src/pages/ProductAnalysis/index.jsx new file mode 100644 index 0000000..b785632 --- /dev/null +++ b/frontend/src/pages/ProductAnalysis/index.jsx @@ -0,0 +1,990 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Button, + Checkbox, + DatePicker, + Drawer, + Empty, + Image, + Input, + InputNumber, + Popover, + Segmented, + Select, + Skeleton, + Space, + Table, + Tooltip, + Typography, + message, +} from 'antd'; +import { + ExclamationCircleOutlined, + ReloadOutlined, + RightOutlined, + SearchOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; +import dayjs from 'dayjs'; +import { useSearchParams } from 'react-router-dom'; +import { + getProductAnalysisMeta, + getProductAnalysisProducts, + getProductAnalysisSignalThresholds, + getProductAnalysisTab, + saveProductAnalysisSignalThresholds, +} from '../../api/productAnalysis'; +import { + SycmBusinessPanel, + SycmDashboardMetric, + SycmModuleTabs, + SycmPage, + SycmStatusTag, + SycmToolbar, +} from '../../components/Common'; +import { + SYCM_CHART_PEER_AVG_COLOR, + SYCM_CHART_STORE_COLOR, + sycmEchartsBase, + sycmLineSeriesDefaults, +} from '../../design-system'; +import './style.css'; + +const { Text } = Typography; +const { RangePicker } = DatePicker; + +const DEFAULT_TAB = 'refunds'; +const TAB_ITEMS = [ + { key: 'refunds', label: '退款售后' }, + { key: 'promotion', label: '推广投放' }, + { key: 'activities', label: '活动信息' }, + { key: 'navigator', label: '商品领航员' }, + { key: 'service-issues', label: '体验问题' }, + { key: 'bad-reviews', label: '差评分析' }, +]; +const TAB_KEY_SET = new Set(TAB_ITEMS.map((item) => item.key)); +const RANGE_SHORTCUTS = [ + { key: 'realtime', label: '实时' }, + { key: 'yesterday', label: '昨日' }, + { key: '7d', label: '近7天' }, + { key: '30d', label: '近30天' }, + { key: 'week', label: '本周' }, + { key: 'month', label: '本月' }, +]; +const DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS = Object.freeze({ + ctr: { low: 3, high: 5 }, + conversion: { low: 0.3, high: 0.5 }, +}); +const TRAFFIC_SIGNAL_FILTER_OPTIONS = [ + { value: 'all', label: '全部' }, + { value: 'red', label: 红灯 }, + { 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 new file mode 100644 index 0000000..15f7ceb --- /dev/null +++ b/frontend/src/pages/ProductAnalysis/style.css @@ -0,0 +1,776 @@ +.product-analysis-page { + container-name: product-analysis; + container-type: inline-size; + display: flex; + flex-direction: column; + gap: var(--sycm-page-section-gap); +} + +.product-analysis-filter-panel .sycm-business-panel__body { + padding: 12px 16px; +} + +.product-analysis-filter-panel .sycm-toolbar { + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center !important; + gap: 12px; +} + +.product-analysis-filter-panel .sycm-toolbar > .ant-space { + margin-left: 0; +} + +.product-analysis-filters { + display: grid; + width: 100%; + min-width: 0; + flex: 1 1 100%; + grid-template-columns: 96px minmax(170px, 210px) max-content 236px minmax(220px, 1fr) auto; + align-items: center; + gap: 8px; +} + +.product-analysis-range-shortcuts { + display: inline-flex; + height: var(--sycm-control-height); + align-items: stretch; + overflow: hidden; + border: 1px solid var(--sycm-border-base); + border-radius: var(--sycm-radius-md); + background: var(--sycm-bg-container); +} + +.product-analysis-range-shortcuts button { + min-width: 46px; + padding: 0 10px; + border: 0; + border-right: 1px solid var(--sycm-border-light); + background: transparent; + color: var(--sycm-text-secondary); + font-family: inherit; + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + line-height: 30px; + white-space: nowrap; + cursor: pointer; + transition: color var(--sycm-interaction-duration-fast), background var(--sycm-interaction-duration-fast); +} + +.product-analysis-range-shortcuts button:last-child { + border-right: 0; +} + +.product-analysis-range-shortcuts button:hover { + color: var(--sycm-color-primary); + background: var(--sycm-color-primary-bg); +} + +.product-analysis-range-shortcuts button.is-active { + background: var(--sycm-bg-container); + color: var(--sycm-color-primary); + box-shadow: inset 0 -2px 0 var(--sycm-color-primary); +} + +.product-analysis-filters .ant-select, +.product-analysis-filters .ant-input-affix-wrapper { + width: 100%; +} + +.product-analysis-filters > .ant-input-affix-wrapper { + height: var(--sycm-control-height); + min-height: var(--sycm-control-height); + align-items: center; + padding-top: 6px; + padding-bottom: 6px; +} + +.product-analysis-filters > .ant-input-affix-wrapper .ant-input { + height: 18px; + min-height: 0; + padding-top: 0; + padding-bottom: 0; + line-height: 18px; +} + +.product-analysis-filters .ant-checkbox-wrapper { + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + white-space: nowrap; +} + +.product-analysis-filters .product-analysis-active-filter { + display: inline-flex; + height: var(--sycm-control-height); + align-items: center; + margin: 0; + padding: 0 9px; + border: 1px solid var(--sycm-border-light); + border-radius: var(--sycm-radius-md); + background: var(--sycm-bg-subtle); + line-height: var(--sycm-line-height-normal); +} + +.product-analysis-filters .product-analysis-active-filter .ant-checkbox { + top: 2px; +} + +.product-analysis-filters .product-analysis-active-filter > span:last-child { + display: inline-flex; + height: 100%; + align-items: center; + padding-inline-start: 6px; + line-height: var(--sycm-line-height-normal); +} + +.product-analysis-filters .product-analysis-active-filter:hover { + border-color: var(--sycm-color-primary-border); + background: var(--sycm-color-primary-bg); +} + +.product-analysis-metrics, +.product-analysis-tab-metrics { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} + +.product-analysis-overview-panel > .sycm-business-panel__body { + padding: 0 4px 4px; +} + +.product-analysis-overview-panel .product-analysis-metrics { + gap: 0; + overflow: hidden; + border-radius: 0 0 var(--sycm-radius-xl) var(--sycm-radius-xl); +} + +.product-analysis-overview-panel .sycm-dashboard-metric { + min-height: 98px; + border: 0; + border-right: 1px solid var(--sycm-border-light); + border-radius: 0; + padding: 14px 16px 13px; + background: var(--sycm-bg-container); +} + +.product-analysis-overview-panel .sycm-dashboard-metric:last-child { + border-right: 0; +} + +.product-analysis-overview-panel .sycm-dashboard-metric__label { + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-regular); +} + +.product-analysis-metrics .sycm-dashboard-metric, +.product-analysis-tab-metrics .sycm-dashboard-metric { + min-height: 96px; +} + +.product-analysis-table-panel > .sycm-business-panel__body { + padding: 0; +} + +.product-analysis-table-panel .ant-table-wrapper { + border-top: 1px solid var(--sycm-border-light); +} + +.product-analysis-table-panel .ant-table-row { + cursor: pointer; +} + +.product-analysis-table-panel .ant-table-row.is-selected > td { + background: var(--sycm-interaction-selected-bg) !important; +} + +.product-analysis-table-panel .ant-table-row.is-selected > td:first-child { + box-shadow: inset 3px 0 0 var(--sycm-color-primary); +} + +.product-analysis-product-cell { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.product-analysis-product-cell .ant-image, +.product-analysis-product-cell img { + width: 46px; + height: 46px; + flex: 0 0 46px; + border-radius: var(--sycm-radius-sm); + background: var(--sycm-bg-subtle); + object-fit: cover; +} + +.product-analysis-product-cell > div { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 5px; +} + +.product-analysis-product-cell strong { + overflow: hidden; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.product-analysis-product-cell span { + overflow: hidden; + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.product-analysis-signal-cell { + display: flex; + min-width: 0; + flex-direction: column; + gap: 5px; +} + +.product-analysis-signal-list { + display: flex; + align-items: center; + gap: 6px; +} + +.product-analysis-signal { + display: inline-grid; + height: 28px; + min-width: 118px; + grid-template-columns: 9px auto 1fr; + align-items: center; + gap: 6px; + padding: 0 8px; + border: 1px solid transparent; + border-radius: var(--sycm-radius-md); + font-size: var(--sycm-font-size-caption); + line-height: 1; + white-space: nowrap; +} + +.product-analysis-signal strong { + justify-self: end; + color: currentColor; + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-semibold); +} + +.product-analysis-signal__lamp { + width: 8px; + height: 8px; + border: 2px solid color-mix(in srgb, currentColor 18%, transparent); + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 10%, transparent); +} + +.product-analysis-signal.is-red { + border-color: var(--sycm-color-danger-border); + background: var(--sycm-color-danger-bg); + color: var(--sycm-color-danger); +} + +.product-analysis-signal.is-yellow { + border-color: var(--sycm-color-warning-border); + background: var(--sycm-color-warning-bg); + color: var(--sycm-color-warning); +} + +.product-analysis-signal.is-green { + border-color: var(--sycm-color-success-border); + background: var(--sycm-color-success-bg); + color: var(--sycm-color-success); +} + +.product-analysis-signal-reason { + display: flex; + width: 100%; + min-width: 0; + align-items: flex-start; + gap: 5px; + color: var(--sycm-color-danger); + font-size: 11px; + line-height: 15px; +} + +.product-analysis-signal-reason .anticon { + flex: 0 0 auto; + margin-top: 1px; +} + +.product-analysis-signal-reason > span:last-child { + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} + +.product-analysis-signal-observing { + display: flex; + width: max-content; + min-width: 190px; + align-items: center; + gap: 9px; + padding: 6px 10px; + border: 1px solid var(--sycm-color-primary-border); + border-radius: var(--sycm-radius-md); + background: var(--sycm-color-primary-bg); + color: var(--sycm-color-primary); +} + +.product-analysis-signal-observing__dot { + width: 9px; + height: 9px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--sycm-color-primary); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--sycm-color-primary) 12%, transparent); +} + +.product-analysis-signal-observing > span:last-child { + display: flex; + flex-direction: column; + gap: 2px; +} + +.product-analysis-signal-observing strong { + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-semibold); + line-height: 15px; +} + +.product-analysis-signal-observing small { + color: var(--sycm-text-secondary); + font-size: 11px; + line-height: 14px; +} + +.product-analysis-signal-unavailable { + color: var(--sycm-text-tertiary); +} + +.product-analysis-signal-filter { + flex: 0 0 auto; +} + +.product-analysis-signal-filter .ant-segmented-item-label { + min-width: 48px; + padding-inline: 8px; +} + +.product-analysis-signal-filter__label { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + white-space: nowrap; +} + +.product-analysis-signal-filter__label i { + width: 7px; + height: 7px; + flex: 0 0 7px; + border-radius: 50%; +} + +.product-analysis-signal-filter__label.is-red i { + background: var(--sycm-color-danger); + box-shadow: 0 0 0 3px var(--sycm-color-danger-bg); +} + +.product-analysis-signal-filter__label.is-yellow i { + background: var(--sycm-color-warning); + box-shadow: 0 0 0 3px var(--sycm-color-warning-bg); +} + +.product-analysis-signal-filter__label.is-green i { + background: var(--sycm-color-success); + box-shadow: 0 0 0 3px var(--sycm-color-success-bg); +} + +.product-analysis-signal-settings { + width: 390px; +} + +.product-analysis-signal-settings__header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 2px 2px 12px; + border-bottom: 1px solid var(--sycm-border-light); +} + +.product-analysis-signal-settings__header strong { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-body); + font-weight: var(--sycm-font-weight-semibold); +} + +.product-analysis-signal-settings__header span { + color: var(--sycm-text-tertiary); + font-size: 11px; +} + +.product-analysis-signal-setting { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) minmax(0, 1fr); + align-items: end; + gap: 10px; + padding: 13px 2px; + border-bottom: 1px solid var(--sycm-border-light); +} + +.product-analysis-signal-setting > strong { + align-self: center; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-semibold); +} + +.product-analysis-signal-setting label { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; +} + +.product-analysis-signal-setting label > span { + font-size: 11px; + font-weight: var(--sycm-font-weight-medium); +} + +.product-analysis-signal-setting label > span.is-red { + color: var(--sycm-color-danger); +} + +.product-analysis-signal-setting label > span.is-green { + color: var(--sycm-color-success); +} + +.product-analysis-signal-setting .ant-input-number { + width: 100%; +} + +.product-analysis-threshold-input { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) 28px; +} + +.product-analysis-threshold-input .ant-input-number { + border-start-end-radius: 0; + border-end-end-radius: 0; +} + +.product-analysis-threshold-input > span { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--sycm-border-base); + border-left: 0; + border-start-end-radius: var(--sycm-radius-sm); + border-end-end-radius: var(--sycm-radius-sm); + background: var(--sycm-bg-subtle); + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.product-analysis-quality-rule { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 8px 10px; + align-items: center; + padding: 13px 2px; + border-bottom: 1px solid var(--sycm-border-light); +} + +.product-analysis-quality-rule > strong { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-semibold); +} + +.product-analysis-quality-rule > div { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.product-analysis-quality-rule span { + padding: 3px 6px; + border: 1px solid currentColor; + border-radius: var(--sycm-radius-sm); + font-size: 11px; + line-height: 15px; +} + +.product-analysis-quality-rule .is-green { + color: var(--sycm-color-success); +} + +.product-analysis-quality-rule .is-yellow { + color: var(--sycm-color-warning); +} + +.product-analysis-quality-rule .is-red { + color: var(--sycm-color-danger); +} + +.product-analysis-quality-rule small { + grid-column: 2; + color: var(--sycm-text-tertiary); + font-size: 11px; +} + +.product-analysis-signal-settings__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 12px; +} + +.product-analysis-detail-panel > .sycm-business-panel__body { + padding: 0; +} + +.product-analysis-detail-panel > .sycm-business-panel__header { + min-height: 64px; + padding-top: 8px; + padding-bottom: 8px; +} + +.product-analysis-detail-panel .sycm-business-panel__heading, +.product-analysis-detail-panel .sycm-business-panel__copy, +.product-analysis-detail-panel .sycm-business-panel__title { + min-width: 0; + width: 100%; +} + +.product-analysis-selected-product { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.product-analysis-selected-product .ant-image, +.product-analysis-selected-product img { + width: 40px; + height: 40px; + flex: 0 0 40px; + border-radius: var(--sycm-radius-sm); + background: var(--sycm-bg-subtle); + object-fit: cover; +} + +.product-analysis-selected-product__copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 3px; +} + +.product-analysis-selected-product__copy strong, +.product-analysis-selected-product__copy > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.product-analysis-selected-product__copy strong { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-body); + font-weight: var(--sycm-font-weight-semibold); + line-height: 20px; +} + +.product-analysis-selected-product__copy > span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-regular); + line-height: 16px; +} + +.product-analysis-detail-panel .sycm-module-tabs { + border-top: 1px solid var(--sycm-border-light); + border-bottom: 1px solid var(--sycm-border-light); + border-radius: 0; + box-shadow: none; +} + +.product-analysis-tab-content { + min-height: 280px; + padding: 16px; +} + +.product-analysis-tab-metrics { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 16px; +} + +.product-analysis-tab-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; +} + +.product-analysis-tab-grid.is-table-only { + grid-template-columns: 1fr; +} + +.product-analysis-trend-block, +.product-analysis-history-block { + min-width: 0; + overflow: hidden; + border: 1px solid var(--sycm-border-base); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-container); +} + +.product-analysis-trend-block { + padding: 14px 16px 8px; +} + +.product-analysis-history-block h3 { + padding: 14px 16px 12px; + border-bottom: 1px solid var(--sycm-border-light); +} + +.product-analysis-trend-block h3, +.product-analysis-history-block h3 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); + line-height: 22px; +} + +.product-analysis-history-block .ant-table-wrapper { + width: 100%; +} + +.product-analysis-history-block .product-analysis-issue-row { + cursor: pointer; +} + +.product-analysis-history-block .product-analysis-issue-row:focus-visible > td { + background: var(--sycm-color-primary-soft); + outline: 1px solid var(--sycm-color-primary-border); + outline-offset: -1px; +} + +.product-analysis-view-orders { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--sycm-color-primary); + font-weight: var(--sycm-font-weight-medium); +} + +.product-analysis-order-detail { + display: block; + white-space: normal; + word-break: break-word; + line-height: 20px; +} + +@container product-analysis (max-width: 1180px) { + .product-analysis-filters { + grid-template-columns: 100px minmax(180px, 1fr) minmax(220px, 1fr); + } + + .product-analysis-range-shortcuts, + .product-analysis-filters .ant-picker, + .product-analysis-filters .ant-checkbox-wrapper { + grid-column: 1 / -1; + } + + .product-analysis-range-shortcuts { + width: max-content; + max-width: 100%; + overflow-x: auto; + } + + .product-analysis-metrics { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .product-analysis-overview-panel .sycm-dashboard-metric:nth-child(3n) { + border-right: 0; + } + + .product-analysis-tab-grid { + grid-template-columns: 1fr; + } +} + +@container product-analysis (max-width: 1500px) { + .product-analysis-filter-panel .sycm-toolbar { + grid-template-columns: minmax(0, 1fr); + } + + .product-analysis-filter-panel .sycm-toolbar > .ant-space { + justify-self: end; + } +} + +@container product-analysis (max-width: 760px) { + .product-analysis-filters { + grid-template-columns: 1fr 1fr; + } + + .product-analysis-range-shortcuts, + .product-analysis-filters .ant-picker, + .product-analysis-filters .ant-input-affix-wrapper, + .product-analysis-filters .ant-checkbox-wrapper { + grid-column: 1 / -1; + } + + .product-analysis-metrics, + .product-analysis-tab-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .product-analysis-overview-panel .sycm-dashboard-metric:nth-child(3n) { + border-right: 1px solid var(--sycm-border-light); + } + + .product-analysis-overview-panel .sycm-dashboard-metric:nth-child(2n) { + border-right: 0; + } +} + +@container product-analysis (max-width: 480px) { + .product-analysis-filters, + .product-analysis-metrics, + .product-analysis-tab-metrics { + grid-template-columns: 1fr; + } + + .product-analysis-filters > * { + grid-column: 1; + } + + .product-analysis-overview-panel .sycm-dashboard-metric { + border-right: 0; + border-bottom: 1px solid var(--sycm-border-light); + } + + .product-analysis-overview-panel .sycm-dashboard-metric:last-child { + border-bottom: 0; + } +} diff --git a/frontend/src/pages/ProductMonitoring/index.jsx b/frontend/src/pages/ProductMonitoring/index.jsx new file mode 100644 index 0000000..c99d953 --- /dev/null +++ b/frontend/src/pages/ProductMonitoring/index.jsx @@ -0,0 +1,340 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, Button, Card, Col, Drawer, Dropdown, Image, Input, Row, Select, + Segmented, Space, Spin, Statistic, Table, Tabs, Tag, Tooltip, Typography, message, +} from 'antd'; +import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { + getProductMonitoringList, + getProductMonitoringFilters, + getProductMonitoringOperators, +} from '../../api/productMonitoring'; +import { exportTableFile } from '../../utils/tableExport'; +import './style.css'; + +const { Title, Text } = Typography; + +const number = (value, digits = 2) => value === null || value === undefined || value === '' + ? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); +const int = (value) => number(value, 0); +const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); + +const COMPANY_TAG_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' }; +const DEPARTMENT_TAG_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' }; +const STATUS_TABS = [ + { key: '销售', label: '销售' }, + { key: '停产', label: '停产' }, + { key: '退市', label: '退市' }, + { key: '__all__', label: '全部' }, +]; + +const toOptions = (list = []) => (Array.isArray(list) ? list : []) + .filter(Boolean) + .map((value) => ({ value, label: value })); + +const ProductMonitoringPanel = () => { + const [status, setStatus] = useState('销售'); + const [filters, setFilters] = useState({ + category: '', spu: '', sku: '', productName: '', supplier: '', warehouse: '', + purchaser: '', companyTag: [], departmentTag: [], departmentOwner: '', + }); + const [filterOptions, setFilterOptions] = useState({ categories: [], suppliers: [], warehouses: [], purchasers: [] }); + const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, latestDate: null }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [sorter, setSorter] = useState({ field: 'companySales7d', order: 'DESC' }); + const [selected, setSelected] = useState(null); + const [operators, setOperators] = useState([]); + const [operatorsLoading, setOperatorsLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + getProductMonitoringFilters() + .then((result) => { + const payload = result?.data || {}; + if (cancelled) return; + setFilterOptions({ + categories: payload.categories || [], + suppliers: payload.suppliers || [], + warehouses: payload.warehouses || [], + purchasers: payload.purchasers || [], + }); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const query = useMemo(() => { + const params = { + page, + pageSize, + sortField: sorter.field, + sortOrder: sorter.order, + }; + if (status === '__all__') params.includeAll = '1'; + else params.saleStatus = status; + + Object.entries(filters).forEach(([key, value]) => { + if (Array.isArray(value)) { + if (value.length) params[key] = value.join(','); + } else if (value) { + params[key] = value; + } + }); + return params; + }, [page, pageSize, sorter, status, filters]); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const result = await getProductMonitoringList(query); + setData(result?.data || { rows: [], total: 0, summary: {} }); + } catch (requestError) { + setError(requestError?.response?.data?.error || requestError?.message || '加载商品监控数据失败'); + } finally { + setLoading(false); + } + }, [query]); + + useEffect(() => { load(); }, [load]); + + const changeStatus = (key) => { + setStatus(key); + setPage(1); + }; + const changeFilter = (key) => (value) => { + setFilters((old) => ({ ...old, [key]: value || '' })); + setPage(1); + }; + const changeMultiFilter = (key) => (value) => { + setFilters((old) => ({ ...old, [key]: value || [] })); + setPage(1); + }; + const resetFilters = () => { + setFilters({ + category: '', spu: '', sku: '', productName: '', supplier: '', warehouse: '', + purchaser: '', companyTag: [], departmentTag: [], departmentOwner: '', + }); + setPage(1); + }; + + const openDetail = async (record) => { + setSelected(record); + setOperators([]); + setOperatorsLoading(true); + try { + const result = await getProductMonitoringOperators(record.skuCode || record.skuId); + setOperators(result?.data?.operators || []); + } catch (requestError) { + message.error(requestError?.response?.data?.error || requestError?.message || '加载运营明细失败'); + } finally { + setOperatorsLoading(false); + } + }; + + const columns = useMemo(() => [ + { title: '图片', dataIndex: 'imageUrl', width: 64, fixed: 'left', exportable: false, render: (value) => (value ? : --) }, + { 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 new file mode 100644 index 0000000..30fc2f9 --- /dev/null +++ b/frontend/src/pages/ProductMonitoring/style.css @@ -0,0 +1,167 @@ +.pm-page { + container-name: product-monitoring; + container-type: inline-size; + display: flex; + min-height: calc(100% - 46px); + flex-direction: column; + gap: var(--sycm-page-section-gap); + background: var(--sycm-bg-page-soft); +} + +.pm-header { + display: flex; + min-height: 84px; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-lg); + margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0; + padding: 14px calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} +.pm-header__heading { min-width: 0; } +.pm-header h3 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-page); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} +.pm-header__heading .ant-typography { + display: block; + margin-top: 6px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.pm-tabs .ant-tabs-nav { + margin: 0; + padding: 0 calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} +.pm-tabs .ant-tabs-tab { + margin: 0; + padding: 12px 4px; + font-size: var(--sycm-font-size-title); +} +.pm-tabs .ant-tabs-content-holder, +.pm-tabs .ant-tabs-content, +.pm-tabs .ant-tabs-tabpane { min-height: 100%; } +.pm-tabs .ant-tabs-tabpane { + display: flex; + flex-direction: column; + gap: var(--sycm-page-section-gap); + padding-top: var(--sycm-page-section-gap); +} + +.pm-filter-card, +.pm-panel, +.pm-summary-card { + border: 1px solid var(--sycm-border-card) !important; + border-radius: var(--sycm-radius-lg) !important; + box-shadow: var(--sycm-shadow-card) !important; +} +.pm-filter-card { margin-bottom: 0; } +.pm-filter-card .ant-card-body { padding: 14px 16px; } +.pm-filter-card .ant-space { width: 100%; } +.pm-filter-card .ant-space-item { max-width: 100%; } +.pm-filter-card .ant-select, +.pm-filter-card .ant-input-affix-wrapper { max-width: 100%; } + +.pm-status-bar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.pm-summary-row { margin-top: -4px; } +.pm-summary-row .ant-col { display: flex; } +.pm-summary-card { + position: relative; + width: 100%; + min-height: 98px; + overflow: hidden; +} +.pm-summary-card::before { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--sycm-color-primary); + content: ''; +} +.pm-summary-card.is-red::before { background: var(--sycm-color-danger); } +.pm-summary-card.is-orange::before { background: var(--sycm-color-orange); } +.pm-summary-card.is-green::before { background: var(--sycm-color-success); } +.pm-summary-card .ant-card-body { + display: flex; + min-height: 98px; + align-items: center; + padding: 16px 18px; +} +.pm-summary-card .ant-statistic { width: 100%; } +.pm-summary-card .ant-statistic-title { + margin-bottom: 8px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} +.pm-summary-card .ant-statistic-content { + color: var(--sycm-text-strong); + font-family: var(--sycm-font-family-number); + font-size: var(--sycm-font-size-metric); + line-height: 1.1; +} +.pm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); } + +.pm-panel { margin-bottom: 0; overflow: hidden; } +.pm-panel .ant-card-head { + min-height: 48px; + padding: 0 16px; + border-bottom-color: var(--sycm-border-light); +} +.pm-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); } +.pm-panel .ant-card-body { padding: 0 16px 16px; } +.pm-panel .ant-table { font-size: var(--sycm-font-size-caption); } +.pm-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); } +.pm-panel .ant-table-placeholder .ant-table-cell { height: 180px; } +.pm-panel__date { font-size: var(--sycm-font-size-caption); } +.pm-id-link { + height: auto; + padding: 0; + font-family: var(--sycm-font-family-number); + font-weight: var(--sycm-font-weight-semibold); +} +.pm-muted { color: var(--sycm-text-tertiary); } +.pm-daily-table { margin-top: var(--sycm-space-md); } +.pm-detail-meta { margin-bottom: var(--sycm-space-sm); } + +.pm-detail-drawer .ant-drawer-header { + padding: 16px 20px; + border-bottom-color: var(--sycm-border-light); +} +.pm-detail-drawer .ant-drawer-title { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); +} +.pm-detail-drawer .ant-drawer-body { padding: 20px; } + +@container product-monitoring (max-width: 900px) { + .pm-filter-card .ant-space-item { flex: 1 1 180px; } +} + +@media (max-width: 767px) { + .pm-header { + min-height: 72px; + align-items: flex-start; + flex-direction: column; + gap: var(--sycm-space-sm); + padding-block: 12px; + } + .pm-filter-card .ant-select, + .pm-filter-card .ant-input-affix-wrapper { width: 100%; } + .pm-panel .ant-card-body { padding-inline: 10px; } + .pm-detail-drawer .ant-drawer-body { padding: 14px; } +} diff --git a/frontend/src/pages/PromotionModule/index.jsx b/frontend/src/pages/PromotionModule/index.jsx new file mode 100644 index 0000000..c2f74a8 --- /dev/null +++ b/frontend/src/pages/PromotionModule/index.jsx @@ -0,0 +1,610 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Button, + Checkbox, + DatePicker, + Drawer, + Input, + Select, + Space, + Spin, + Table, + Tabs, + Tooltip, +} from 'antd'; +import { + PlusOutlined, + ReloadOutlined, + SearchOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import { + SycmBusinessPanel, + SycmDashboardMetric, + SycmPage, +} from '../../components/Common'; +import { + getPromotionModuleList, + getPromotionModuleSummary, + getPromotionModuleShops, +} from '../../api/promotionModule'; +import './style.css'; + +const { RangePicker } = DatePicker; + +const PROMOTION_TYPES = [ + { key: 'stable-cost', label: '稳定成本推广' }, + { key: 'full-store', label: '全店托管' }, +]; + +const DASHBOARD_METRICS = [ + { key: 'orderSpend', label: '成交花费(元)' }, + { key: 'gmv', label: '交易额(元)' }, + { key: 'roi', label: '实际投产比' }, + { key: 'spend', label: '总花费(元)' }, + { key: 'netGmv', label: '净交易额(元)' }, + { key: 'netRoi', label: '净实际投产比' }, + { key: 'netOrderNum', label: '净成交笔数' }, + { key: 'costPerNetOrder', label: '每笔净成交花费(元)' }, +]; + +const PERIOD_OPTIONS = [ + { label: '实时', value: 'realtime' }, + { label: '昨日', value: 'yesterday' }, + { label: '近7天', value: 'last7d' }, + { label: '近30天', value: 'last30d' }, + { label: '本周', value: 'week' }, + { label: '本月', value: 'month' }, +]; + +function fmtMoney(value) { + const n = Number(value); + if (!Number.isFinite(n)) return '-'; + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function fmtRate(value) { + const n = Number(value); + if (!Number.isFinite(n)) return '-'; + return `${(n * 100).toFixed(2)}%`; +} + +function fmtNum(value) { + const n = Number(value); + if (!Number.isFinite(n)) return '-'; + return n.toLocaleString('zh-CN'); +} + +function fmtPlain(value) { + const n = Number(value); + if (!Number.isFinite(n)) return '-'; + return n.toFixed(2); +} + +const PromotionModule = () => { + const [promotionType, setPromotionType] = useState('stable-cost'); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [keyword, setKeyword] = useState(''); + const [settingsOpen, setSettingsOpen] = useState(false); + const [addProductOpen, setAddProductOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [dataSource, setDataSource] = useState([]); + const [total, setTotal] = useState(0); + const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); + const [sorter, setSorter] = useState({ field: 'orderSpend', order: 'descend' }); + const [summary, setSummary] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(false); + const [shops, setShops] = useState([]); + const [selectedShop, setSelectedShop] = useState(''); + const [period, setPeriod] = useState('last7d'); + const [customDateRange, setCustomDateRange] = useState(null); + const [filterDateLabel, setFilterDateLabel] = useState(''); + + const fetchShops = useCallback(async () => { + try { + const res = await getPromotionModuleShops(); + if (res?.data) setShops(res.data); + } catch { /* 忽略 */ } + }, []); + + useEffect(() => { + fetchShops(); + }, [fetchShops]); + + const buildParams = useCallback(() => { + const p = {}; + if (period === 'custom' && customDateRange?.length === 2) { + p.period = 'custom'; + p.start = customDateRange[0].format('YYYY-MM-DD'); + p.end = customDateRange[1].format('YYYY-MM-DD'); + } else { + p.period = period; + } + if (selectedShop) p.shopName = selectedShop; + if (keyword.trim()) p.keyword = keyword.trim(); + return p; + }, [period, customDateRange, selectedShop, keyword]); + + const fetchSummary = useCallback(async () => { + setSummaryLoading(true); + try { + const params = buildParams(); + const res = await getPromotionModuleSummary(params); + if (res?.data) { + setSummary(res.data); + setFilterDateLabel(res.data.filterDate || ''); + } + } catch { + setSummary(null); + } finally { + setSummaryLoading(false); + } + }, [buildParams]); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const params = { + ...buildParams(), + page: pagination.current, + pageSize: pagination.pageSize, + sortBy: sorter.field, + sortOrder: sorter.order === 'ascend' ? 'ASC' : 'DESC', + }; + const result = await getPromotionModuleList(params); + const data = result?.data; + if (data) { + setDataSource(data.list || []); + setTotal(data.total || 0); + } + } catch { + setDataSource([]); + setTotal(0); + } finally { + setLoading(false); + } + }, [buildParams, pagination, sorter]); + + useEffect(() => { + fetchSummary(); + fetchData(); + }, [fetchSummary, fetchData]); + + const handleTableChange = (pag, _filters, sort) => { + setPagination({ current: pag.current, pageSize: pag.pageSize }); + if (sort.field) { + setSorter({ field: sort.field, order: sort.order || 'descend' }); + } + }; + + const handleSearch = () => { + setPagination((prev) => ({ ...prev, current: 1 })); + }; + + const handlePeriodChange = (value) => { + setPeriod(value); + setCustomDateRange(null); + setPagination((prev) => ({ ...prev, current: 1 })); + }; + + const handleShopChange = (value) => { + setSelectedShop(value); + setPagination((prev) => ({ ...prev, current: 1 })); + }; + + const summaryMetrics = useMemo(() => { + if (!summary) return DASHBOARD_METRICS.map((m) => ({ ...m, value: '--' })); + const changes = summary.changes || {}; + return DASHBOARD_METRICS.map((m) => { + const raw = summary[m.key]; + let formatted = '--'; + if (raw != null && raw !== '-') { + if (['orderSpend', 'gmv', 'spend', 'netGmv', 'costPerNetOrder'].includes(m.key)) { + formatted = fmtMoney(raw); + } else if (m.key === 'netOrderNum') { + formatted = fmtNum(raw); + } else { + formatted = fmtPlain(raw); + } + } + // 环比:与上一等长周期对比 + const change = changes[m.key]; + let trend; + let trendTone; + if (Number.isFinite(change)) { + const percent = Math.abs(change * 100).toFixed(1); + if (change > 0) { + trend = `↑ ${percent}%`; + trendTone = 'up'; + } else if (change < 0) { + trend = `↓ ${percent}%`; + trendTone = 'down'; + } else { + trend = '持平'; + } + } + return { ...m, value: formatted, trend, trendTone }; + }); + }, [summary]); + + const baseColumns = [ + { + title: '推广商品', + key: 'product', + width: 280, + fixed: 'left', + render: (_, record) => ( +
+ {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 new file mode 100644 index 0000000..bfa4e74 --- /dev/null +++ b/frontend/src/pages/PromotionModule/style.css @@ -0,0 +1,214 @@ +.promotion-module-page { + display: flex; + flex-direction: column; + gap: var(--sycm-page-section-gap); +} + +.promotion-dashboard, +.promotion-workspace { + overflow: hidden; +} + +.promotion-dashboard { + padding: var(--sycm-module-padding); +} + +.promotion-dashboard__header, +.promotion-actionbar, +.promotion-filterbar, +.promotion-selectionbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-md); +} + +.promotion-dashboard__header > div:first-child { + display: flex; + min-width: 0; + align-items: baseline; + gap: var(--sycm-space-sm); +} + +.promotion-dashboard__header h2 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); +} + +.promotion-dashboard__header span, +.promotion-dashboard__tools, +.promotion-filterbar > span, +.promotion-selectionbar { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); +} + +.promotion-dashboard__tools { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--sycm-space-sm); +} + +.promotion-dashboard__metrics { + display: grid; + grid-template-columns: repeat(8, minmax(110px, 1fr)); + gap: var(--sycm-space-sm); + margin-top: var(--sycm-space-sm); +} + +.promotion-dashboard__metrics .sycm-dashboard-metric { + min-height: 96px; + padding: var(--sycm-space-sm); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-subtle); +} + +.promotion-dashboard__metrics .sycm-dashboard-metric__label { + font-size: var(--sycm-font-size-caption); +} + +.promotion-dashboard__metrics .sycm-dashboard-metric__value { + font-size: var(--sycm-font-size-metric); +} + +.promotion-type-tabs .ant-tabs-nav { + min-height: var(--sycm-module-header-min-height); + margin: 0 !important; + padding: 0 var(--sycm-module-padding); + background: var(--sycm-bg-subtle); +} + +.promotion-type-tabs .ant-tabs-content-holder { + display: none; +} + +.promotion-actionbar { + min-height: calc(var(--sycm-control-height) + var(--sycm-space-lg)); + padding: var(--sycm-space-sm) var(--sycm-module-padding); +} + +.promotion-actionbar > div { + display: flex; + gap: var(--sycm-space-sm); +} + +.promotion-filterbar { + justify-content: flex-start; + padding: var(--sycm-space-sm) var(--sycm-module-padding); + border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); + background: var(--sycm-bg-subtle); +} + +.promotion-filterbar .ant-space { + flex-wrap: nowrap; +} + +.promotion-filterbar__shop { + width: 160px; +} + +.promotion-filterbar__search { + width: 240px; +} + +.promotion-selectionbar { + min-height: var(--sycm-module-header-min-height); + justify-content: flex-start; + padding: 0 var(--sycm-module-padding); +} + +.promotion-table .ant-table-placeholder .ant-table-cell { + height: 320px; +} + +.promotion-product-cell { + display: flex; + min-width: 0; + align-items: flex-start; + gap: var(--sycm-space-sm); +} + +.promotion-product-cell > img, +.promotion-product-cell__image { + display: inline-flex; + width: 52px; + height: 52px; + flex: 0 0 52px; + align-items: center; + justify-content: center; + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-radius: var(--sycm-radius-sm); + background: var(--sycm-bg-subtle); + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); + object-fit: cover; +} + +.promotion-product-cell > div { + min-width: 0; +} + +.promotion-product-cell strong, +.promotion-product-cell span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.promotion-product-cell strong { + max-width: 180px; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); +} + +.promotion-product-cell span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); +} + +.promotion-budget-cell { + display: flex; + flex-direction: column; + gap: var(--sycm-space-xxs); + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.promotion-metric-settings { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sycm-space-sm); +} + +.promotion-metric-settings .ant-checkbox-wrapper { + margin-inline-start: 0; + font-size: var(--sycm-font-size-caption); +} + +.promotion-add-product-empty { + margin-top: 96px; +} + +@media (max-width: 1180px) { + .promotion-dashboard__metrics { + grid-template-columns: repeat(4, minmax(110px, 1fr)); + } + + .promotion-filterbar { + flex-wrap: wrap; + } + + .promotion-filterbar .ant-input-search { + width: 100%; + } + + .promotion-filterbar > span { + margin-left: 0; + } +} diff --git a/frontend/src/pages/Settings/DepartmentTagSettings.jsx b/frontend/src/pages/Settings/DepartmentTagSettings.jsx new file mode 100644 index 0000000..505525a --- /dev/null +++ b/frontend/src/pages/Settings/DepartmentTagSettings.jsx @@ -0,0 +1,340 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, Button, Card, Input, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message, +} from 'antd'; +import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, TagsOutlined, TeamOutlined } from '@ant-design/icons'; +import { + getDepartmentTagConfig, + saveDepartmentTagConfig, +} from '../../api/productMonitoring'; + +const { Text } = Typography; + +const METRIC_OPTIONS = [ + { value: 'sales_qty_7d', label: '近7天销售件数' }, + { value: 'sales_amount_7d', label: '近7天销售额' }, +]; +const OWNER_TYPE_OPTIONS = [ + { value: 'sku_owner', label: 'SKU负责人' }, + { value: 'purchaser', label: '采购负责人' }, +]; +const LEVEL_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' }; + +const readError = (error) => error?.response?.data?.error || error?.message || '操作失败'; + +const useDepartmentConfig = () => { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const result = await getDepartmentTagConfig(); + setConfig(result?.data || null); + } catch (requestError) { + setError(readError(requestError)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + return { config, setConfig, loading, setLoading, error, setError, load }; +}; + +// ==================== 部门商品标签配置(G-S / G-A / G-B / G-C) ==================== + +export const DepartmentTagSettings = () => { + const { config, setConfig, loading, error, load } = useDepartmentConfig(); + const [rules, setRules] = useState([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (config?.rules) setRules(config.rules.map((item) => ({ ...item }))); + }, [config]); + + const patch = (tagLevel, key, value) => { + setRules((old) => old.map((item) => (item.tagLevel === tagLevel ? { ...item, [key]: value } : item))); + }; + + const handleSave = async () => { + setSaving(true); + try { + const result = await saveDepartmentTagConfig({ rules }); + setConfig(result?.data || null); + message.success('部门商品标签已保存,商品监控列表下次加载即生效'); + } catch (requestError) { + message.error(readError(requestError)); + } finally { + setSaving(false); + } + }; + + const columns = [ + { + title: '部门标签', + dataIndex: 'tagLevel', + width: 120, + render: (value) => {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 new file mode 100644 index 0000000..cdb3b26 --- /dev/null +++ b/frontend/src/pages/Settings/ProductTagSettings.jsx @@ -0,0 +1,372 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Input, InputNumber, Select, Space, Spin, Table, Tag, Typography, message } from 'antd'; +import { ReloadOutlined, SaveOutlined, SearchOutlined } from '@ant-design/icons'; +import { getProductTagConfig, updateProductTagConfig } from '../../api/admin'; +import { SycmCard } from '../../components/Common'; +import './productTagSettings.css'; + +const SCALE_TAGS = ['爆款', '热卖款', '动销款', '低动销款']; +const SCALE_TAG_ORDER = [...SCALE_TAGS, '滞销款']; +const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级']; +const SABC_TAGS = ['S', 'A', 'B', 'C', 'D']; + +// 规模标签配色:爆款最优 → 滞销款最差 +const SCALE_TAG_COLOR = { + 爆款: 'red', + 热卖款: 'orange', + 动销款: 'blue', + 低动销款: 'default', + 滞销款: 'default', +}; + +const SABC_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' }; + +const numText = (value) => ( + value === null || value === undefined || value === '' + ? '--' + : Number(value).toLocaleString('zh-CN') +); + +const scaleRowKey = (row) => `${row.category}||${row.scaleTag}`; + +const ProductTagSettings = () => { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [scaleThresholds, setScaleThresholds] = useState([]); + const [profitLevels, setProfitLevels] = useState([]); + const [sabcMappings, setSabcMappings] = useState([]); + const [excludedCategories, setExcludedCategories] = useState([]); + const [categoryFilter, setCategoryFilter] = useState(''); + const [scaleTagFilter, setScaleTagFilter] = useState(); + + const applyConfig = useCallback((config = {}) => { + setScaleThresholds(Array.isArray(config.scaleThresholds) ? config.scaleThresholds : []); + setProfitLevels(Array.isArray(config.profitLevels) ? config.profitLevels : []); + setSabcMappings(Array.isArray(config.sabcMappings) ? config.sabcMappings : []); + setExcludedCategories(Array.isArray(config.excludedCategories) ? config.excludedCategories : []); + }, []); + + const loadConfig = useCallback(async () => { + setLoading(true); + try { + const response = await getProductTagConfig(); + applyConfig(response?.data?.data || response?.data || {}); + } catch (error) { + message.error(error?.response?.data?.error || error?.message || '加载商品标签配置失败'); + } finally { + setLoading(false); + } + }, [applyConfig]); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const handleSave = async () => { + setSaving(true); + try { + const response = await updateProductTagConfig({ + scaleThresholds, + profitLevels, + sabcMappings, + excludedCategories, + }); + applyConfig(response?.data?.data || response?.data || {}); + message.success( + `已保存:规模阈值 ${scaleThresholds.length} 行 / 利润标签 ${profitLevels.length} 行 / ` + + `SABC 映射 ${sabcMappings.length} 条 / 排除品类 ${excludedCategories.length} 个` + ); + } catch (error) { + message.error(error?.response?.data?.error || error?.message || '保存商品标签配置失败'); + } finally { + setSaving(false); + } + }; + + // 规模阈值:原地更新某一行 + const patchScaleRow = useCallback((rowKey, field, value) => { + setScaleThresholds((rows) => rows.map((row) => ( + scaleRowKey(row) === rowKey ? { ...row, [field]: value } : row + ))); + }, []); + + // SABC 映射:矩阵单元格更新(不存在则新增) + const patchSabcCell = useCallback((scaleTag, profitLevel, sabcTag) => { + setSabcMappings((rows) => { + const index = rows.findIndex((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel); + if (index >= 0) { + const next = [...rows]; + next[index] = { ...next[index], sabcTag }; + return next; + } + return [...rows, { scaleTag, profitLevel, sabcTag, enabled: true }]; + }); + }, []); + + const sabcValueOf = useCallback((scaleTag, profitLevel) => { + const hit = sabcMappings.find((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel); + return hit?.sabcTag; + }, [sabcMappings]); + + // 候选品类:已配置的品类(含 jst_sku.category 全名)作为联想项, + // 未配置的(如「其他 - 配件」)直接手动输入完整值。 + const dbCategoryOptions = useMemo(() => { + const set = new Set(); + scaleThresholds.forEach((row) => { + if (row.category) set.add(row.category); + }); + excludedCategories.forEach((item) => set.add(item)); + return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN')); + }, [scaleThresholds, excludedCategories]); + + const filteredScaleRows = useMemo(() => { + const keyword = categoryFilter.trim(); + return scaleThresholds.filter((row) => ( + (!keyword || String(row.displayName || '').includes(keyword) || String(row.category || '').includes(keyword)) + && (!scaleTagFilter || row.scaleTag === scaleTagFilter) + )); + }, [scaleThresholds, categoryFilter, scaleTagFilter]); + + const scaleColumns = [ + { + title: '品类', + dataIndex: 'displayName', + width: 200, + fixed: 'left', + filters: SCALE_TAGS.map((tag) => ({ text: tag, value: tag })), + onFilter: (value, record) => record.scaleTag === value, + render: (value, record) => ( +
+
{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} 个`}} + > + +