feat: 启用运营分析模块,新增模块化库存监控与 MKH-BOM 资料
本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。
主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
- retiredFeatures 移出对应退役名单与清表语句,改为正常保留
- 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
- 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
- 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
- MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)
验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
--ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致
未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
This commit is contained in:
parent
8448f7a0e8
commit
445347e664
4
.gitignore
vendored
4
.gitignore
vendored
@ -58,3 +58,7 @@ tmall-board-speed-check.json
|
|||||||
.remote-access.backup-*/
|
.remote-access.backup-*/
|
||||||
.workbuddy/
|
.workbuddy/
|
||||||
**/node_modules
|
**/node_modules
|
||||||
|
|
||||||
|
# Local verification artifacts
|
||||||
|
/outputs/
|
||||||
|
.git-status.txt
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。
|
公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。
|
||||||
|
|
||||||
保留仪表盘、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
|
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
|
||||||
|
|
||||||
已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
|
已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
|
||||||
|
|
||||||
@ -27,7 +27,7 @@ npm --prefix frontend run check
|
|||||||
|
|
||||||
前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。
|
前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。
|
||||||
|
|
||||||
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。迁移仅作用于实际配置给此实例的系统数据库。
|
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。上架测算旧偏好表中的商品分析阈值会先迁移到商品分析专用表;其他保留模块的数据不受影响。迁移仅作用于实际配置给此实例的系统数据库。
|
||||||
|
|
||||||
## 参与修改
|
## 参与修改
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
"syntax": "node scripts/check-syntax.js",
|
"syntax": "node scripts/check-syntax.js",
|
||||||
"test": "node --test test/*.test.js",
|
"test": "node --test test/*.test.js",
|
||||||
"check": "npm run syntax && npm test",
|
"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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.975.0",
|
"@aws-sdk/client-s3": "3.975.0",
|
||||||
|
|||||||
404
backend/scripts/seed_product_tag_config_from_xlsx.js
Normal file
404
backend/scripts/seed_product_tag_config_from_xlsx.js
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从《公司SABC标签配置.xlsx》导入商品标签配置到系统库。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/seed_product_tag_config_from_xlsx.js --file=/path/to/公司SABC标签配置.xlsx
|
||||||
|
* node scripts/seed_product_tag_config_from_xlsx.js --file=... --print-sql # 只打印 SQL,不写库
|
||||||
|
* node scripts/seed_product_tag_config_from_xlsx.js --file=... --dry-run # 只解析并打印摘要
|
||||||
|
*
|
||||||
|
* 解析口径(2026-09-11 评审确认):
|
||||||
|
* - 区块① A3:F150 → 规模标签阈值(148 行,37 品类 × 4 档)
|
||||||
|
* - 区块② H4:L28 → SABC 映射(25 行,5 规模标签 × 5 利润标签)
|
||||||
|
* - 阈值统一四舍五入保留 2 位小数(决策 5,2026-09-11 修订);品类为 '-' 或空的行忽略
|
||||||
|
*
|
||||||
|
* 前置:三张表需已存在(DBA 执行 sql/20260911_create_product_tag_config_system_mysql.sql,
|
||||||
|
* 或由 ensureProductTagConfigSchema() 自动建表)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||||
|
|
||||||
|
const XLSX = require('xlsx');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const database = require('../src/config/database');
|
||||||
|
|
||||||
|
const SCALE_TAG_ORDER = ['爆款', '热卖款', '动销款', '低动销款'];
|
||||||
|
const IGNORED_CATEGORIES = ['-', ''];
|
||||||
|
const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级'];
|
||||||
|
|
||||||
|
// 配置表聚合类目 → DB 类目 的兜底映射(按用户 2026-09-11 推测确认)。
|
||||||
|
// xlsx 里「其他沙发类」「办公家具」本身是聚合类目,DB 中没有同名完整类目;
|
||||||
|
// 其子品类在 DB 中独立存在,且未被别的配置名占用(懒人沙发/文具边柜已单独配置,故排除)。
|
||||||
|
// 用「家族规则」而非逐个枚举叶子名,可自动覆盖 DB 中实际存在的全部子类目,避免名字写错产生幽灵行。
|
||||||
|
// family: 'sofa' → 所有短名含「沙发」的类目,排除 exclude 列表
|
||||||
|
// family: 'office'→ 所有「商业/办公家具 -」前缀的类目,排除 exclude 列表
|
||||||
|
const MANUAL_CATEGORY_MAP = {
|
||||||
|
'其他沙发类': { family: 'sofa', exclude: ['懒人沙发'] },
|
||||||
|
'办公家具': { family: 'office', exclude: ['文具边柜'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 区块① / 区块② 在 sheet 中的位置(1-based,与 Excel 界面一致)
|
||||||
|
const BLOCK1 = { startRow: 3, endRow: 150, cols: { category: 0, pMin: 1, pMax: 2, sMin: 3, sMax: 4, scaleTag: 5 } };
|
||||||
|
const BLOCK2 = { startRow: 4, endRow: 28, cols: { scaleTag: 7, profitLevel: 8, profitText: 9, sabc: 11 } };
|
||||||
|
|
||||||
|
// 去掉「住宅家具 - 」这类一级类目前缀,只留叶子品类名
|
||||||
|
function stripCategoryPrefix(value) {
|
||||||
|
return String(value).replace(/^.*?\s+-\s+/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { file: '', printSql: false, dryRun: false };
|
||||||
|
argv.slice(2).forEach((item) => {
|
||||||
|
if (item.startsWith('--file=')) args.file = item.slice('--file='.length);
|
||||||
|
else if (item === '--print-sql') args.printSql = true;
|
||||||
|
else if (item === '--dry-run') args.dryRun = true;
|
||||||
|
});
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value) {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 决策 5(2026-09-11 11:12 修订):所有阈值统一「四舍五入保留 2 位小数」。
|
||||||
|
// - 0.5 → 0.5(合法业务阈值,不变;不会变成 1)
|
||||||
|
// - 溢出值 7916.34311148132 → 7916.34;2063.13142776357 → 2063.13
|
||||||
|
// - 整数 12000 → 12000
|
||||||
|
function normalizeThreshold(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return null;
|
||||||
|
return Math.round(num * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqlText(value) {
|
||||||
|
if (value === null || value === undefined) return 'NULL';
|
||||||
|
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqlNumber(value) {
|
||||||
|
return value === null || value === undefined ? 'NULL' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWorkbook(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`文件不存在:${filePath}`);
|
||||||
|
}
|
||||||
|
const workbook = XLSX.readFile(filePath);
|
||||||
|
const sheetName = workbook.SheetNames[0];
|
||||||
|
const sheet = workbook.Sheets[sheetName];
|
||||||
|
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true, defval: null });
|
||||||
|
return { sheetName, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScaleThresholds(rows) {
|
||||||
|
const list = [];
|
||||||
|
const skipped = [];
|
||||||
|
for (let rowNo = BLOCK1.startRow; rowNo <= BLOCK1.endRow; rowNo += 1) {
|
||||||
|
const row = rows[rowNo - 1] || [];
|
||||||
|
const category = text(row[BLOCK1.cols.category]);
|
||||||
|
if (IGNORED_CATEGORIES.includes(category)) {
|
||||||
|
if (category) skipped.push({ rowNo, reason: `品类为 "${category}",按决策 6 忽略` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const scaleTag = text(row[BLOCK1.cols.scaleTag]);
|
||||||
|
if (!SCALE_TAG_ORDER.includes(scaleTag)) {
|
||||||
|
skipped.push({ rowNo, reason: `规模标签 "${scaleTag}" 不在枚举内` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const raw = {
|
||||||
|
performanceMin: row[BLOCK1.cols.pMin],
|
||||||
|
performanceMax: row[BLOCK1.cols.pMax],
|
||||||
|
salesMin: row[BLOCK1.cols.sMin],
|
||||||
|
salesMax: row[BLOCK1.cols.sMax],
|
||||||
|
};
|
||||||
|
const rounded = {
|
||||||
|
performanceMin: normalizeThreshold(raw.performanceMin),
|
||||||
|
performanceMax: normalizeThreshold(raw.performanceMax),
|
||||||
|
salesMin: normalizeThreshold(raw.salesMin),
|
||||||
|
salesMax: normalizeThreshold(raw.salesMax),
|
||||||
|
};
|
||||||
|
// 只记录「确实被取整」的溢出值,便于核对(决策 5)
|
||||||
|
Object.entries(raw).forEach(([key, value]) => {
|
||||||
|
const before = Number(value);
|
||||||
|
if (!Number.isFinite(before)) return;
|
||||||
|
if (rounded[key] !== before) {
|
||||||
|
skipped.push({ rowNo, reason: `字段 ${key} 原值 ${value} 疑似计算溢出,已取整为 ${rounded[key]}(决策 5)`, rounding: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
list.push({
|
||||||
|
// 配置表里是短名(如「电脑桌/书桌」),稍后会展开成 jst_sku.category 的完整值
|
||||||
|
displayName: category,
|
||||||
|
scaleTag,
|
||||||
|
...rounded,
|
||||||
|
priority: SCALE_TAG_ORDER.indexOf(scaleTag) + 1,
|
||||||
|
sort: SCALE_TAG_ORDER.indexOf(scaleTag) + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { list, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取 jst_sku.category 的全部真实取值(企划库与业务库同表,优先用可达的企划库)
|
||||||
|
async function loadDbCategories() {
|
||||||
|
const connection = await mysql.createConnection({
|
||||||
|
host: process.env.PLANNING_PUBLIC_DB_HOST,
|
||||||
|
port: Number(process.env.PLANNING_PUBLIC_DB_PORT),
|
||||||
|
user: process.env.PLANNING_PUBLIC_DB_USER,
|
||||||
|
password: process.env.PLANNING_PUBLIC_DB_PASSWORD,
|
||||||
|
database: process.env.PLANNING_PUBLIC_DB_NAME,
|
||||||
|
connectTimeout: 20000,
|
||||||
|
});
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT TRIM(category) AS category, COUNT(*) AS skuCount
|
||||||
|
FROM jst_sku
|
||||||
|
WHERE category IS NOT NULL AND TRIM(category) <> ''
|
||||||
|
GROUP BY TRIM(category)`
|
||||||
|
);
|
||||||
|
await connection.end();
|
||||||
|
return rows.map((row) => ({
|
||||||
|
full: row.category,
|
||||||
|
short: stripCategoryPrefix(row.category),
|
||||||
|
skuCount: Number(row.skuCount),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把「配置短名」展开为「数据库完整品类名」。
|
||||||
|
* 一个短名可能对应多个 DB 品类(如「电脑桌/书桌」→ 住宅家具-电脑桌 + 住宅家具-书桌),
|
||||||
|
* 展开后每个 DB 品类生成一行,displayName 保留原短名用于展示分组。
|
||||||
|
*/
|
||||||
|
function expandCategories(configRows, dbCategories) {
|
||||||
|
const matched = [];
|
||||||
|
const ambiguous = [];
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
configRows.forEach((row) => {
|
||||||
|
const shortName = row.displayName;
|
||||||
|
|
||||||
|
// 1) 手工映射优先:聚合类目 → DB 类目(家族规则,自动展开子类目)
|
||||||
|
if (MANUAL_CATEGORY_MAP[shortName]) {
|
||||||
|
const rule = MANUAL_CATEGORY_MAP[shortName];
|
||||||
|
let resolved = [];
|
||||||
|
if (Array.isArray(rule)) {
|
||||||
|
// 兼容:显式 DB 短名列表
|
||||||
|
rule.forEach((targetShort) => {
|
||||||
|
const hits = dbCategories.filter((item) => item.short === targetShort);
|
||||||
|
if (hits.length) hits.forEach((hit) => resolved.push(hit));
|
||||||
|
else console.warn(` ⚠️ 手工映射目标「${targetShort}」在 jst_sku 中找不到对应类目,已跳过(请核对 MANUAL_CATEGORY_MAP)`);
|
||||||
|
});
|
||||||
|
} else if (rule && rule.family === 'sofa') {
|
||||||
|
resolved = dbCategories.filter((item) => item.short.includes('沙发') && !rule.exclude.includes(item.short));
|
||||||
|
} else if (rule && rule.family === 'office') {
|
||||||
|
resolved = dbCategories.filter((item) => /^商业\/办公家具\s*-\s*/.test(item.full) && !rule.exclude.includes(item.short));
|
||||||
|
}
|
||||||
|
if (!resolved.length) {
|
||||||
|
missing.push(row);
|
||||||
|
} else {
|
||||||
|
resolved.forEach((hit) => matched.push({ ...row, category: hit.full }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 剥离前缀后同名
|
||||||
|
let hits = dbCategories.filter((item) => item.short === shortName);
|
||||||
|
// 3) 包含匹配兜底
|
||||||
|
if (!hits.length) {
|
||||||
|
hits = dbCategories.filter((item) => (
|
||||||
|
item.short.includes(shortName) || shortName.includes(item.short)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hits.length) {
|
||||||
|
missing.push(row);
|
||||||
|
} else {
|
||||||
|
hits.forEach((hit) => matched.push({ ...row, category: hit.full }));
|
||||||
|
if (hits.length > 1) ambiguous.push({ shortName, targets: hits });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { matched, ambiguous, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSabcMappings(rows) {
|
||||||
|
const list = [];
|
||||||
|
// H 列(规模标签)是纵向合并单元格(H4:H8、H9:H13 …),
|
||||||
|
// XLSX.utils.sheet_to_json 只在合并区左上角单元格保留值,其余为 null,
|
||||||
|
// 因此需要向下填充,否则只能解析出 5 条而不是 25 条。
|
||||||
|
let carriedScaleTag = '';
|
||||||
|
for (let rowNo = BLOCK2.startRow; rowNo <= BLOCK2.endRow; rowNo += 1) {
|
||||||
|
const row = rows[rowNo - 1] || [];
|
||||||
|
const ownScaleTag = text(row[BLOCK2.cols.scaleTag]);
|
||||||
|
if (ownScaleTag) carriedScaleTag = ownScaleTag;
|
||||||
|
|
||||||
|
const scaleTag = carriedScaleTag;
|
||||||
|
const profitLevel = text(row[BLOCK2.cols.profitLevel]);
|
||||||
|
const sabcTag = text(row[BLOCK2.cols.sabc]);
|
||||||
|
if (!scaleTag || !profitLevel || !sabcTag) continue;
|
||||||
|
list.push({ scaleTag, profitLevel, sabcTag, sort: list.length + 1 });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScaleInsertSql(list) {
|
||||||
|
if (!list.length) return '-- (无规模阈值数据)';
|
||||||
|
const values = list.map((row) => ` (${sqlText(row.category)}, ${sqlText(row.displayName)}, ${sqlText(row.scaleTag)}, `
|
||||||
|
+ `${sqlNumber(row.performanceMin)}, ${sqlNumber(row.performanceMax)}, `
|
||||||
|
+ `${sqlNumber(row.salesMin)}, ${sqlNumber(row.salesMax)}, ${row.priority}, ${row.sort}, 1)`);
|
||||||
|
return `INSERT INTO product_scale_thresholds\n`
|
||||||
|
+ ' (category, display_name, scale_tag, performance_min, performance_max, sales_min, sales_max, priority, sort, enabled)\nVALUES\n'
|
||||||
|
+ `${values.join(',\n')}\nON DUPLICATE KEY UPDATE\n`
|
||||||
|
+ ' display_name = VALUES(display_name),\n'
|
||||||
|
+ ' performance_min = VALUES(performance_min), performance_max = VALUES(performance_max),\n'
|
||||||
|
+ ' sales_min = VALUES(sales_min), sales_max = VALUES(sales_max),\n'
|
||||||
|
+ ' priority = VALUES(priority), sort = VALUES(sort), enabled = VALUES(enabled);';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFIT_LEVEL_RATE = {
|
||||||
|
一级: 0.18,
|
||||||
|
二级: 0.16,
|
||||||
|
三级: 0.12,
|
||||||
|
四级: 0.08,
|
||||||
|
五级: 0.08,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function writeScaleThresholds(list) {
|
||||||
|
await database.local.query('DELETE FROM product_scale_thresholds');
|
||||||
|
if (!list.length) return 0;
|
||||||
|
const values = [];
|
||||||
|
const placeholders = list.map((row) => {
|
||||||
|
values.push(
|
||||||
|
row.category, row.displayName, row.scaleTag,
|
||||||
|
row.performanceMin, row.performanceMax, row.salesMin, row.salesMax,
|
||||||
|
row.priority, row.sort, 1
|
||||||
|
);
|
||||||
|
return '(?,?,?,?,?,?,?,?,?,?)';
|
||||||
|
});
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO product_scale_thresholds
|
||||||
|
(category, display_name, scale_tag, performance_min, performance_max,
|
||||||
|
sales_min, sales_max, priority, sort, enabled)
|
||||||
|
VALUES ${placeholders.join(',')}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return list.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeProfitLevels() {
|
||||||
|
await database.local.query('DELETE FROM product_profit_levels');
|
||||||
|
const values = [];
|
||||||
|
const placeholders = PROFIT_LEVELS.map((levelCode, index) => {
|
||||||
|
const rate = PROFIT_LEVEL_RATE[levelCode];
|
||||||
|
values.push(levelCode, rate, `营业利润率≤${rate.toFixed(2)}`, index + 1, 1);
|
||||||
|
return '(?,?,?,?,?)';
|
||||||
|
});
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled)
|
||||||
|
VALUES ${placeholders.join(',')}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return PROFIT_LEVELS.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeSabcMappings(list) {
|
||||||
|
await database.local.query('DELETE FROM product_sabc_mappings');
|
||||||
|
if (!list.length) return 0;
|
||||||
|
const values = [];
|
||||||
|
const placeholders = list.map((row) => {
|
||||||
|
values.push(row.scaleTag, row.profitLevel, row.sabcTag, row.sort, 1);
|
||||||
|
return '(?,?,?,?,?)';
|
||||||
|
});
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled)
|
||||||
|
VALUES ${placeholders.join(',')}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return list.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv);
|
||||||
|
if (!args.file) {
|
||||||
|
console.error('缺少 --file=<xlsx 路径> 参数');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sheetName, rows } = readWorkbook(args.file);
|
||||||
|
console.log(`读取工作簿:${args.file}`);
|
||||||
|
console.log(`Sheet:${sheetName},共 ${rows.length} 行`);
|
||||||
|
|
||||||
|
const { list: configRows, skipped } = parseScaleThresholds(rows);
|
||||||
|
const mappings = parseSabcMappings(rows);
|
||||||
|
|
||||||
|
console.log(`\n解析配置表:`);
|
||||||
|
console.log(` 规模阈值配置行:${configRows.length}(按「配置短名 × 规模标签」计)`);
|
||||||
|
console.log(` SABC 映射 :${mappings.length} 条`);
|
||||||
|
|
||||||
|
if (skipped.length) {
|
||||||
|
console.log(`\n跳过 / 修正明细(${skipped.length} 条):`);
|
||||||
|
skipped.forEach((item) => console.log(` 第 ${item.rowNo} 行:${item.reason}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 品类名展开:配置短名 → jst_sku.category 完整名 ----
|
||||||
|
const dbCategories = await loadDbCategories();
|
||||||
|
const { matched: thresholds, ambiguous, missing } = expandCategories(configRows, dbCategories);
|
||||||
|
|
||||||
|
console.log(`\n品类名映射:`);
|
||||||
|
console.log(` 数据库品类数:${dbCategories.length}`);
|
||||||
|
console.log(` 展开后阈值行:${thresholds.length}(配置行 ${configRows.length} → DB 品类行)`);
|
||||||
|
if (ambiguous.length) {
|
||||||
|
// 同一个短名会出现在多个规模标签下,按短名去重后再报告
|
||||||
|
const byShortName = new Map();
|
||||||
|
ambiguous.forEach((item) => {
|
||||||
|
if (!byShortName.has(item.shortName)) byShortName.set(item.shortName, item.targets);
|
||||||
|
});
|
||||||
|
console.log(`\n ⚠️ 一对多(${byShortName.size} 个配置名对应多个 DB 品类,已全部归入同一档阈值):`);
|
||||||
|
byShortName.forEach((targets, shortName) => {
|
||||||
|
console.log(` ${shortName}`);
|
||||||
|
targets.forEach((t) => console.log(` → ${t.full} (${t.skuCount} SKU)`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (missing.length) {
|
||||||
|
console.log(`\n ❌ 匹配不到(${missing.length} 个,这些配置行不会生效,需在 MANUAL_CATEGORY_MAP 中补映射):`);
|
||||||
|
[...new Set(missing.map((m) => m.displayName))].forEach((name) => console.log(` - ${name}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const byTag = SCALE_TAG_ORDER.map((tag) => ({
|
||||||
|
tag,
|
||||||
|
count: thresholds.filter((row) => row.scaleTag === tag).length,
|
||||||
|
}));
|
||||||
|
console.log(`\n 按规模标签分布:${byTag.map((item) => `${item.tag} ${item.count}`).join(' / ')}`);
|
||||||
|
console.log(` 展开后品类数:${new Set(thresholds.map((row) => row.category)).size}`);
|
||||||
|
|
||||||
|
if (args.printSql) {
|
||||||
|
console.log('\n---------- 规模标签阈值 SQL ----------');
|
||||||
|
console.log(buildScaleInsertSql(thresholds));
|
||||||
|
console.log('\n(利润标签与 SABC 映射的 SQL 见 sql/20260911_create_product_tag_config_system_mysql.sql)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.dryRun) {
|
||||||
|
console.log('\n--dry-run:未写入数据库。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const writtenThresholds = await writeScaleThresholds(thresholds);
|
||||||
|
const writtenLevels = await writeProfitLevels();
|
||||||
|
const writtenMappings = await writeSabcMappings(mappings);
|
||||||
|
console.log('\n✅ 导入完成:');
|
||||||
|
console.log(` product_scale_thresholds : ${writtenThresholds} 行`);
|
||||||
|
console.log(` product_profit_levels : ${writtenLevels} 行`);
|
||||||
|
console.log(` product_sabc_mappings : ${writtenMappings} 条`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => database.close && database.close())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('导入失败:', error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
218
backend/scripts/sync_jian_sun_product_analysis_tables.js
Normal file
218
backend/scripts/sync_jian_sun_product_analysis_tables.js
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const sql = require('mssql');
|
||||||
|
|
||||||
|
const batchSize = Math.max(100, Number.parseInt(process.env.PRODUCT_ANALYSIS_SYNC_BATCH_SIZE || '5000', 10));
|
||||||
|
const targetSchema = /^[A-Za-z_][A-Za-z0-9_]*$/.test(process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA || '')
|
||||||
|
? process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA
|
||||||
|
: 'app';
|
||||||
|
|
||||||
|
const sourceConfig = {
|
||||||
|
server: process.env.JIAN_SUN_DB_HOST,
|
||||||
|
port: Number(process.env.JIAN_SUN_DB_PORT || 1433),
|
||||||
|
database: process.env.JIAN_SUN_DB_NAME || 'JianSun',
|
||||||
|
user: process.env.JIAN_SUN_DB_USER,
|
||||||
|
password: process.env.JIAN_SUN_DB_PASSWORD,
|
||||||
|
options: { encrypt: false, trustServerCertificate: true },
|
||||||
|
connectionTimeout: 120000,
|
||||||
|
requestTimeout: 120000,
|
||||||
|
pool: { max: 4, min: 0, idleTimeoutMillis: 120000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetConfig = {
|
||||||
|
server: process.env.CLOUD_DB_PUBLIC_HOST,
|
||||||
|
port: Number(process.env.CLOUD_DB_PUBLIC_PORT || 1433),
|
||||||
|
database: process.env.CLOUD_DB_PUBLIC_NAME || 'JSReportReplica',
|
||||||
|
user: process.env.CLOUD_DB_PUBLIC_USER,
|
||||||
|
password: process.env.CLOUD_DB_PUBLIC_PASSWORD,
|
||||||
|
options: {
|
||||||
|
encrypt: String(process.env.CLOUD_DB_PUBLIC_SSL || '').toLowerCase() === 'true',
|
||||||
|
trustServerCertificate: true,
|
||||||
|
},
|
||||||
|
connectionTimeout: 120000,
|
||||||
|
requestTimeout: 120000,
|
||||||
|
pool: { max: 4, min: 0, idleTimeoutMillis: 120000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tables = [
|
||||||
|
{
|
||||||
|
name: 'pdd_DataActivityRecords',
|
||||||
|
createColumns: `
|
||||||
|
[id] int NOT NULL,
|
||||||
|
[shopName] varchar(40) NULL,
|
||||||
|
[createTime] varchar(20) NULL,
|
||||||
|
[business_type_str] varchar(100) NULL,
|
||||||
|
[activity_name] varchar(100) NULL,
|
||||||
|
[activity_id] int NULL,
|
||||||
|
[goods_name] varchar(100) NULL,
|
||||||
|
[goods_id] bigint NULL,
|
||||||
|
[hd_thumb_url] varchar(100) NULL,
|
||||||
|
[enroll_time ] bigint NULL,
|
||||||
|
[min_activity_sku_price] decimal(38, 2) NULL,
|
||||||
|
[max_activity_sku_price] decimal(38, 2) NULL,
|
||||||
|
[activity_goods_start_time] bigint NULL,
|
||||||
|
[activity_goods_end_time] bigint NULL,
|
||||||
|
[status] int NULL`,
|
||||||
|
columns: [
|
||||||
|
['id', sql.Int, false],
|
||||||
|
['shopName', sql.VarChar(40), true],
|
||||||
|
['createTime', sql.VarChar(20), true],
|
||||||
|
['business_type_str', sql.VarChar(100), true],
|
||||||
|
['activity_name', sql.VarChar(100), true],
|
||||||
|
['activity_id', sql.Int, true],
|
||||||
|
['goods_name', sql.VarChar(100), true],
|
||||||
|
['goods_id', sql.BigInt, true],
|
||||||
|
['hd_thumb_url', sql.VarChar(100), true],
|
||||||
|
['enroll_time ', sql.BigInt, true],
|
||||||
|
['min_activity_sku_price', sql.Decimal(38, 2), true],
|
||||||
|
['max_activity_sku_price', sql.Decimal(38, 2), true],
|
||||||
|
['activity_goods_start_time', sql.BigInt, true],
|
||||||
|
['activity_goods_end_time', sql.BigInt, true],
|
||||||
|
['status', sql.Int, true],
|
||||||
|
],
|
||||||
|
indexColumn: 'goods_id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pdd_GoodsQualities',
|
||||||
|
createColumns: `
|
||||||
|
[id] int NOT NULL,
|
||||||
|
[avgDescRevScr1m] float NULL,
|
||||||
|
[qltyRfndOrdrCntRto1m] float NULL,
|
||||||
|
[ptHelpPzRate30d] float NULL,
|
||||||
|
[midRto30d] float NULL,
|
||||||
|
[goodsId] bigint NULL,
|
||||||
|
[displayMallScoreRk] int NULL,
|
||||||
|
[rankAvgDescRevScr1mStage] int NULL,
|
||||||
|
[rankAvgDescRevScr1mStplStage] int NULL,
|
||||||
|
[rankQltyRfndOrdrCntRto1mStage] int NULL,
|
||||||
|
[rankQltyRfndOrdrCntRto1mStplStage] int NULL,
|
||||||
|
[rankPtHelpPzRate30dStage] int NULL,
|
||||||
|
[rankPtHelpPzRate30dStplStage] int NULL,
|
||||||
|
[rankComscoreStage ] int NULL,
|
||||||
|
[rankComscoreStplStage] int NULL,
|
||||||
|
[goodsScoreRkStage] int NULL,
|
||||||
|
[goodsScoreRkStplStage] int NULL,
|
||||||
|
[createTime] varchar(20) NULL,
|
||||||
|
[shopName] varchar(40) NULL`,
|
||||||
|
columns: [
|
||||||
|
['id', sql.Int, false],
|
||||||
|
['avgDescRevScr1m', sql.Float, true],
|
||||||
|
['qltyRfndOrdrCntRto1m', sql.Float, true],
|
||||||
|
['ptHelpPzRate30d', sql.Float, true],
|
||||||
|
['midRto30d', sql.Float, true],
|
||||||
|
['goodsId', sql.BigInt, true],
|
||||||
|
['displayMallScoreRk', sql.Int, true],
|
||||||
|
['rankAvgDescRevScr1mStage', sql.Int, true],
|
||||||
|
['rankAvgDescRevScr1mStplStage', sql.Int, true],
|
||||||
|
['rankQltyRfndOrdrCntRto1mStage', sql.Int, true],
|
||||||
|
['rankQltyRfndOrdrCntRto1mStplStage', sql.Int, true],
|
||||||
|
['rankPtHelpPzRate30dStage', sql.Int, true],
|
||||||
|
['rankPtHelpPzRate30dStplStage', sql.Int, true],
|
||||||
|
['rankComscoreStage ', sql.Int, true],
|
||||||
|
['rankComscoreStplStage', sql.Int, true],
|
||||||
|
['goodsScoreRkStage', sql.Int, true],
|
||||||
|
['goodsScoreRkStplStage', sql.Int, true],
|
||||||
|
['createTime', sql.VarChar(20), true],
|
||||||
|
['shopName', sql.VarChar(40), true],
|
||||||
|
],
|
||||||
|
indexColumn: 'goodsId',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function qualified(schema, tableName) {
|
||||||
|
return `[${schema}].[${tableName}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertConfig(config, label) {
|
||||||
|
const missing = ['server', 'database', 'user', 'password'].filter((key) => !config[key]);
|
||||||
|
if (missing.length) throw new Error(`${label} missing configuration: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTargetTable(pool, definition) {
|
||||||
|
const tableName = qualified(targetSchema, definition.name);
|
||||||
|
const pkName = `PK_${definition.name}`;
|
||||||
|
const lookupIndex = `IX_${definition.name}_${definition.indexColumn}_createTime`;
|
||||||
|
await pool.request().batch(`
|
||||||
|
IF OBJECT_ID(N'${tableName}', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE ${tableName} (
|
||||||
|
${definition.createColumns},
|
||||||
|
CONSTRAINT [${pkName}] PRIMARY KEY CLUSTERED ([id])
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'${tableName}') AND name = N'${lookupIndex}')
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX [${lookupIndex}] ON ${tableName} ([${definition.indexColumn}], [createTime]);
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBulkTable(definition) {
|
||||||
|
const table = new sql.Table(definition.name);
|
||||||
|
table.schema = targetSchema;
|
||||||
|
table.create = false;
|
||||||
|
definition.columns.forEach(([name, type, nullable]) => table.columns.add(name, type, { nullable }));
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncTable(sourcePool, targetPool, definition) {
|
||||||
|
await ensureTargetTable(targetPool, definition);
|
||||||
|
const sourceName = qualified('dbo', definition.name);
|
||||||
|
const targetName = qualified(targetSchema, definition.name);
|
||||||
|
const columnSql = definition.columns.map(([name]) => `[${name}]`).join(', ');
|
||||||
|
const targetState = await targetPool.request().query(`SELECT COALESCE(MAX([id]), 0) AS maxId FROM ${targetName}`);
|
||||||
|
let lastId = Number(targetState.recordset[0].maxId || 0);
|
||||||
|
let copied = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const result = await sourcePool.request()
|
||||||
|
.input('lastId', sql.Int, lastId)
|
||||||
|
.query(`SELECT TOP (${batchSize}) ${columnSql} FROM ${sourceName} WHERE [id] > @lastId ORDER BY [id]`);
|
||||||
|
const rows = result.recordset || [];
|
||||||
|
if (!rows.length) break;
|
||||||
|
|
||||||
|
const bulkTable = createBulkTable(definition);
|
||||||
|
rows.forEach((row) => bulkTable.rows.add(...definition.columns.map(([name]) => row[name])));
|
||||||
|
await targetPool.request().bulk(bulkTable);
|
||||||
|
lastId = Number(rows[rows.length - 1].id);
|
||||||
|
copied += rows.length;
|
||||||
|
console.log(`[product-analysis-sync] ${definition.name}: copied ${copied}, target max id ${lastId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sourceStats, targetStats] = await Promise.all([
|
||||||
|
sourcePool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${sourceName}`),
|
||||||
|
targetPool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${targetName}`),
|
||||||
|
]);
|
||||||
|
return { copied, source: sourceStats.recordset[0], target: targetStats.recordset[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncProductAnalysisReplica() {
|
||||||
|
assertConfig(sourceConfig, 'JianSun source');
|
||||||
|
assertConfig(targetConfig, 'JSReportReplica target');
|
||||||
|
const [sourcePool, targetPool] = await Promise.all([
|
||||||
|
new sql.ConnectionPool(sourceConfig).connect(),
|
||||||
|
new sql.ConnectionPool(targetConfig).connect(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = {};
|
||||||
|
for (const definition of tables) {
|
||||||
|
summary[definition.name] = await syncTable(sourcePool, targetPool, definition);
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
|
return summary;
|
||||||
|
} finally {
|
||||||
|
await Promise.all([sourcePool.close(), targetPool.close()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
syncProductAnalysisReplica().catch((error) => {
|
||||||
|
console.error('[product-analysis-sync] failed:', error.stack || error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
syncProductAnalysisReplica,
|
||||||
|
};
|
||||||
@ -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负责人配置';
|
||||||
@ -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;
|
|
||||||
268
backend/src/api/marketingEvents.js
Normal file
268
backend/src/api/marketingEvents.js
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const jianSunDb = require('../config/jianSunDatabase');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
function safeInt(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSortField(field) {
|
||||||
|
const allowed = new Set([
|
||||||
|
'id', 'shopName', 'createTime', 'activity_name', 'activity_id',
|
||||||
|
'goods_name', 'goods_id', 'min_activity_sku_price', 'max_activity_sku_price',
|
||||||
|
'activity_goods_start_time', 'activity_goods_end_time', 'status',
|
||||||
|
]);
|
||||||
|
return allowed.has(field) ? field : 'activity_goods_start_time';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSortOrder(order) {
|
||||||
|
return String(order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeColumn(field) {
|
||||||
|
const allowed = new Set([
|
||||||
|
'id', 'shopName', 'createTime', 'business_type_str', 'activity_name', 'activity_id',
|
||||||
|
'goods_name', 'goods_id', 'hd_thumb_url', 'enroll_time', 'min_activity_sku_price',
|
||||||
|
'max_activity_sku_price', 'activity_goods_start_time', 'activity_goods_end_time', 'status',
|
||||||
|
]);
|
||||||
|
if (!allowed.has(field)) return '[id]';
|
||||||
|
return field === 'enroll_time' ? '[enroll_time]' : `[${field}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSecondLatestDate() {
|
||||||
|
const rows = await jianSunDb.query(
|
||||||
|
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||||
|
FROM dbo.pdd_DataActivityRecords
|
||||||
|
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||||
|
ORDER BY createDate DESC`
|
||||||
|
);
|
||||||
|
if (rows.length >= 2) return rows[1].createDate;
|
||||||
|
if (rows.length === 1) return rows[0].createDate;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/latest-date', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const date = await getSecondLatestDate();
|
||||||
|
res.json({ success: true, data: { date } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MarketingEvents] 查询最新数据日期失败:', error.message);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
sortBy = 'activity_goods_start_time',
|
||||||
|
sortOrder = 'DESC',
|
||||||
|
shopName,
|
||||||
|
activityType,
|
||||||
|
activityName,
|
||||||
|
activityId,
|
||||||
|
goodsName,
|
||||||
|
goodsId,
|
||||||
|
status,
|
||||||
|
date,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const currentPage = safeInt(page, 1);
|
||||||
|
const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE);
|
||||||
|
const offset = (currentPage - 1) * limit;
|
||||||
|
const sortColumn = normalizeSortField(sortBy);
|
||||||
|
const sortDir = normalizeSortOrder(sortOrder);
|
||||||
|
|
||||||
|
const whereParts = [];
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
const filterDate = (date && String(date).trim()) || await getSecondLatestDate();
|
||||||
|
if (filterDate) {
|
||||||
|
whereParts.push('LEFT([createTime], 10) = @filterDate');
|
||||||
|
params.filterDate = filterDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shopName && shopName.trim()) {
|
||||||
|
whereParts.push('[shopName] LIKE @shopName');
|
||||||
|
params.shopName = `%${shopName.trim()}%`;
|
||||||
|
}
|
||||||
|
if (activityType && activityType.trim()) {
|
||||||
|
whereParts.push('[business_type_str] LIKE @activityType');
|
||||||
|
params.activityType = `%${activityType.trim()}%`;
|
||||||
|
}
|
||||||
|
if (activityName && activityName.trim()) {
|
||||||
|
whereParts.push('[activity_name] LIKE @activityName');
|
||||||
|
params.activityName = `%${activityName.trim()}%`;
|
||||||
|
}
|
||||||
|
if (activityId && String(activityId).trim()) {
|
||||||
|
whereParts.push('[activity_id] = @activityId');
|
||||||
|
params.activityId = Number(activityId);
|
||||||
|
}
|
||||||
|
if (goodsName && goodsName.trim()) {
|
||||||
|
whereParts.push('[goods_name] LIKE @goodsName');
|
||||||
|
params.goodsName = `%${goodsName.trim()}%`;
|
||||||
|
}
|
||||||
|
if (goodsId && String(goodsId).trim()) {
|
||||||
|
whereParts.push('[goods_id] = @goodsId');
|
||||||
|
params.goodsId = String(goodsId).trim();
|
||||||
|
}
|
||||||
|
if (status !== undefined && status !== '' && status !== 'all') {
|
||||||
|
whereParts.push('[status] = @status');
|
||||||
|
params.status = Number(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const countResult = await jianSunDb.query(
|
||||||
|
`SELECT COUNT(*) AS total FROM dbo.pdd_DataActivityRecords ${whereSql}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = countResult[0]?.total || 0;
|
||||||
|
|
||||||
|
const dataResult = await jianSunDb.query(
|
||||||
|
`SELECT
|
||||||
|
[id],
|
||||||
|
[shopName],
|
||||||
|
[createTime],
|
||||||
|
[business_type_str] AS activityType,
|
||||||
|
[activity_name] AS activityName,
|
||||||
|
[activity_id] AS activityId,
|
||||||
|
[goods_name] AS goodsName,
|
||||||
|
[goods_id] AS goodsId,
|
||||||
|
[hd_thumb_url] AS thumbUrl,
|
||||||
|
[enroll_time] AS enrollTime,
|
||||||
|
TRY_CONVERT(decimal(18,2), [min_activity_sku_price]) / 100.0 AS minPrice,
|
||||||
|
TRY_CONVERT(decimal(18,2), [max_activity_sku_price]) / 100.0 AS maxPrice,
|
||||||
|
[activity_goods_start_time] AS activityStartTime,
|
||||||
|
[activity_goods_end_time] AS activityEndTime,
|
||||||
|
[status],
|
||||||
|
CASE WHEN [status] = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', [status]) END AS statusLabel
|
||||||
|
FROM dbo.pdd_DataActivityRecords
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY ${escapeColumn(sortColumn)} ${sortDir}
|
||||||
|
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`,
|
||||||
|
{ ...params, offset, limit }
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusCountsResult = await jianSunDb.query(
|
||||||
|
`SELECT
|
||||||
|
[status],
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM dbo.pdd_DataActivityRecords
|
||||||
|
${whereSql}
|
||||||
|
GROUP BY [status]
|
||||||
|
ORDER BY [status]`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusCounts = {};
|
||||||
|
let totalAll = 0;
|
||||||
|
statusCountsResult.forEach((row) => {
|
||||||
|
statusCounts[row.status] = row.count;
|
||||||
|
totalAll += row.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list: dataResult,
|
||||||
|
total,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: limit,
|
||||||
|
filterDate,
|
||||||
|
statusCounts: { all: totalAll, ...statusCounts },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MarketingEvents] 查询活动记录失败:', error.message);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/summary', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date } = req.query;
|
||||||
|
const filterDate = (date && String(date).trim()) || await getSecondLatestDate();
|
||||||
|
|
||||||
|
const whereSql = filterDate ? 'WHERE LEFT([createTime], 10) = @filterDate' : '';
|
||||||
|
const params = filterDate ? { filterDate } : {};
|
||||||
|
|
||||||
|
const [row] = await jianSunDb.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) AS totalRecords,
|
||||||
|
SUM(CASE WHEN [status] = 501 THEN 1 ELSE 0 END) AS successCount,
|
||||||
|
COUNT(DISTINCT TRY_CONVERT(varchar(50), [goods_id])) AS goodsCount,
|
||||||
|
COUNT(DISTINCT [shopName]) AS shopCount,
|
||||||
|
COUNT(DISTINCT [activity_id]) AS activityIdCount,
|
||||||
|
COUNT(DISTINCT [business_type_str]) AS activityTypeCount,
|
||||||
|
AVG(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS avgMinPrice,
|
||||||
|
AVG(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS avgMaxPrice,
|
||||||
|
MIN(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS globalMinPrice,
|
||||||
|
MAX(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS globalMaxPrice
|
||||||
|
FROM dbo.pdd_DataActivityRecords
|
||||||
|
${whereSql}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = row || {};
|
||||||
|
const totalRecords = Number(stats.totalRecords) || 0;
|
||||||
|
const successCount = Number(stats.successCount) || 0;
|
||||||
|
const successRate = totalRecords > 0 ? ((successCount / totalRecords) * 100).toFixed(1) : '0.0';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
filterDate,
|
||||||
|
totalRecords,
|
||||||
|
successCount,
|
||||||
|
successRate,
|
||||||
|
goodsCount: Number(stats.goodsCount) || 0,
|
||||||
|
shopCount: Number(stats.shopCount) || 0,
|
||||||
|
activityIdCount: Number(stats.activityIdCount) || 0,
|
||||||
|
activityTypeCount: Number(stats.activityTypeCount) || 0,
|
||||||
|
avgMinPrice: stats.avgMinPrice != null ? Number(stats.avgMinPrice).toFixed(2) : '-',
|
||||||
|
avgMaxPrice: stats.avgMaxPrice != null ? Number(stats.avgMaxPrice).toFixed(2) : '-',
|
||||||
|
globalMinPrice: stats.globalMinPrice != null ? Number(stats.globalMinPrice).toFixed(2) : '-',
|
||||||
|
globalMaxPrice: stats.globalMaxPrice != null ? Number(stats.globalMaxPrice).toFixed(2) : '-',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MarketingEvents] 查询汇总数据失败:', error.message);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/status-counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await jianSunDb.query(
|
||||||
|
`SELECT
|
||||||
|
[status],
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM dbo.pdd_DataActivityRecords
|
||||||
|
GROUP BY [status]
|
||||||
|
ORDER BY [status]`
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusCounts = {};
|
||||||
|
let total = 0;
|
||||||
|
result.forEach((row) => {
|
||||||
|
statusCounts[row.status] = row.count;
|
||||||
|
total += row.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { all: total, ...statusCounts },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MarketingEvents] 查询状态统计失败:', error.message);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
40
backend/src/api/mkhBom.js
Normal file
40
backend/src/api/mkhBom.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const {
|
||||||
|
getMkhBomList,
|
||||||
|
getMkhBomDetail,
|
||||||
|
} = require('../services/mkhBomService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function sendError(res, error, fallback) {
|
||||||
|
const status = Number(error?.statusCode) || 500;
|
||||||
|
console.error('[MkhBom]', {
|
||||||
|
message: error?.message || fallback,
|
||||||
|
code: error?.code || null,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
res.status(status).json({ success: false, error: error.message || fallback });
|
||||||
|
}
|
||||||
|
|
||||||
|
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
|
||||||
|
router.get('/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: await getMkhBomList(req.query || {}) });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '加载 MKH-BOM 资料列表失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 单个 MKH 组合商品的 BOM 配件明细(打印用)
|
||||||
|
router.get('/detail', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const skuId = req.query?.skuId || req.query?.comboSkuId;
|
||||||
|
res.json({ success: true, data: await getMkhBomDetail(skuId) });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '加载 MKH-BOM 明细失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
39
backend/src/api/modularStockMonitor.js
Normal file
39
backend/src/api/modularStockMonitor.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const {
|
||||||
|
getModularStockList,
|
||||||
|
getModularStockFilters,
|
||||||
|
} = require('../services/modularStockMonitorService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function sendError(res, error, fallback) {
|
||||||
|
const status = Number(error?.statusCode) || 500;
|
||||||
|
console.error('[ModularStockMonitor]', {
|
||||||
|
message: error?.message || fallback,
|
||||||
|
code: error?.code || null,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
res.status(status).json({ success: false, error: error.message || fallback });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配件库存监控明细列表(默认仅「简尚-崔桥工厂半成品包装项目仓」)
|
||||||
|
router.get('/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: await getModularStockList(req.query || {}) });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '加载配件库存监控列表失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 筛选下拉选项(品类 / 预警等级)
|
||||||
|
router.get('/filters', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: await getModularStockFilters() });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '加载配件库存监控筛选选项失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -1,9 +1,20 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { getResolvedUserWithPageAccess } = require('../services/pageAccess');
|
const { getResolvedUserWithPageAccess } = require('../services/pageAccess');
|
||||||
|
const { getOpsBIncentiveBoard, getOpsBIncentiveBatches, getOpsBIncentiveDaily } = require('../services/opsBIncentiveBoardService');
|
||||||
const { getAlibaba1688Product } = require('../services/alibaba1688ProductService');
|
const { getAlibaba1688Product } = require('../services/alibaba1688ProductService');
|
||||||
const { attachEncodingDetail } = require('../services/alibaba1688ProductEncodingRegistry');
|
const { attachEncodingDetail } = require('../services/alibaba1688ProductEncodingRegistry');
|
||||||
const router = express.Router();
|
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) {
|
async function ensureSelectionFissionAccess(req) {
|
||||||
// Selection workflows are available to every authenticated user. They share
|
// Selection workflows are available to every authenticated user. They share
|
||||||
// this router with template uploads, but must not inherit upload permissions.
|
// 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;
|
module.exports = router;
|
||||||
|
|||||||
1739
backend/src/api/productAnalysis.js
Normal file
1739
backend/src/api/productAnalysis.js
Normal file
File diff suppressed because it is too large
Load Diff
73
backend/src/api/productMonitoring.js
Normal file
73
backend/src/api/productMonitoring.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const {
|
||||||
|
getMonitoringList,
|
||||||
|
getMonitoringFilters,
|
||||||
|
getDepartmentTagConfig,
|
||||||
|
saveDepartmentTagConfig,
|
||||||
|
getProductOperators,
|
||||||
|
} = require('../services/productMonitoringService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function sendError(res, error, fallback = '商品监控请求失败') {
|
||||||
|
const status = Number(error?.statusCode) || 500;
|
||||||
|
console.error('[ProductMonitoring]', {
|
||||||
|
message: error?.message || fallback,
|
||||||
|
code: error?.code || null,
|
||||||
|
status,
|
||||||
|
stack: error?.stack || null,
|
||||||
|
});
|
||||||
|
res.status(status).json({ success: false, error: error.message || fallback });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 明细列表(含公司标签 / 部门标签 / 负责人 / 汇总)
|
||||||
|
router.get('/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: await getMonitoringList(req.query || {}) });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '加载商品监控列表失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 筛选下拉选项
|
||||||
|
router.get('/filters', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: await getMonitoringFilters() });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '加载筛选选项失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道)
|
||||||
|
router.get('/operators', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sku = req.query?.sku || req.query?.skuoid;
|
||||||
|
if (!sku) {
|
||||||
|
return res.status(400).json({ success: false, error: '缺少 sku 参数' });
|
||||||
|
}
|
||||||
|
return res.json({ success: true, data: { operators: await getProductOperators(sku) } });
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, error, '加载运营明细失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 部门标签 + 负责人配置(系统设置用)
|
||||||
|
router.get('/department-config', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: await getDepartmentTagConfig() });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '加载部门标签配置失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/department-config', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, data: await saveDepartmentTagConfig(req.body || {}) });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, '保存部门标签配置失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
417
backend/src/api/promotionModule.js
Normal file
417
backend/src/api/promotionModule.js
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const jianSunDb = require('../config/jianSunDatabase');
|
||||||
|
const middleDb = require('../config/middlePlatformDatabase');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
function safeInt(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNum(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMoney(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n.toFixed(2) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRate(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? (n * 100).toFixed(2) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateRange(period, customStart, customEnd) {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'realtime': {
|
||||||
|
const d = new Date(today);
|
||||||
|
return { start: formatDate(d), end: formatDate(d) };
|
||||||
|
}
|
||||||
|
case 'yesterday': {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
return { start: formatDate(d), end: formatDate(d) };
|
||||||
|
}
|
||||||
|
case 'week': {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1));
|
||||||
|
return { start: formatDate(d), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
return { start: formatDate(d), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
case 'last7d': {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - 6);
|
||||||
|
return { start: formatDate(d), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
case 'last30d': {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - 29);
|
||||||
|
return { start: formatDate(d), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
case 'custom': {
|
||||||
|
return {
|
||||||
|
start: customStart || formatDate(today),
|
||||||
|
end: customEnd || formatDate(today),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - 6);
|
||||||
|
return { start: formatDate(d), end: formatDate(today) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 环比:取紧邻上一段等长周期(如近7天 → 再往前7天;自定义区间 → 等长前移)
|
||||||
|
function getPreviousRange(range) {
|
||||||
|
const start = new Date(`${range.start}T00:00:00`);
|
||||||
|
const end = new Date(`${range.end}T00:00:00`);
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lengthDays = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||||
|
const prevEnd = new Date(start);
|
||||||
|
prevEnd.setDate(prevEnd.getDate() - 1);
|
||||||
|
const prevStart = new Date(prevEnd);
|
||||||
|
prevStart.setDate(prevStart.getDate() - (lengthDays - 1));
|
||||||
|
return { start: formatDate(prevStart), end: formatDate(prevEnd) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMOTION_SUMMARY_SELECT = `SELECT
|
||||||
|
COUNT(*) AS totalRecords,
|
||||||
|
SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 AS totalOrderSpend,
|
||||||
|
SUM(TRY_CONVERT(decimal(18,2), [gmv])) / 1000.0 AS totalGmv,
|
||||||
|
CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0
|
||||||
|
THEN SUM(TRY_CONVERT(decimal(18,2), [gmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend]))
|
||||||
|
ELSE 0 END AS avgRoi,
|
||||||
|
SUM(TRY_CONVERT(decimal(18,2), [spend])) / 1000.0 AS totalSpend,
|
||||||
|
SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / 1000.0 AS totalNetGmv,
|
||||||
|
CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0
|
||||||
|
THEN SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend]))
|
||||||
|
ELSE 0 END AS avgNetRoi,
|
||||||
|
SUM(TRY_CONVERT(int, [netOrderNum])) AS totalNetOrderNum,
|
||||||
|
CASE WHEN SUM(TRY_CONVERT(int, [netOrderNum])) > 0
|
||||||
|
THEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 / SUM(TRY_CONVERT(int, [netOrderNum]))
|
||||||
|
ELSE 0 END AS avgCostPerNetOrder
|
||||||
|
FROM dbo.pdd_ReportGoodsSpread
|
||||||
|
WHERE LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate`;
|
||||||
|
|
||||||
|
async function queryPromotionSummary(range) {
|
||||||
|
const [row] = await jianSunDb.query(PROMOTION_SUMMARY_SELECT, {
|
||||||
|
startDate: range.start,
|
||||||
|
endDate: range.end,
|
||||||
|
});
|
||||||
|
const stats = row || {};
|
||||||
|
return {
|
||||||
|
totalRecords: toNum(stats.totalRecords),
|
||||||
|
orderSpend: toNum(stats.totalOrderSpend),
|
||||||
|
gmv: toNum(stats.totalGmv),
|
||||||
|
roi: toNum(stats.avgRoi),
|
||||||
|
spend: toNum(stats.totalSpend),
|
||||||
|
netGmv: toNum(stats.totalNetGmv),
|
||||||
|
netRoi: toNum(stats.avgNetRoi),
|
||||||
|
netOrderNum: toNum(stats.totalNetOrderNum),
|
||||||
|
costPerNetOrder: toNum(stats.avgCostPerNetOrder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 环比变化率:(当前 - 上一周期) / |上一周期|,上一周期为 0 时返回 null
|
||||||
|
function buildPeriodChanges(current, previous) {
|
||||||
|
const changes = {};
|
||||||
|
Object.keys(current).forEach((key) => {
|
||||||
|
const base = Math.abs(previous[key]);
|
||||||
|
changes[key] = base > 0 ? (current[key] - previous[key]) / base : null;
|
||||||
|
});
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getImageMap(goodsIds) {
|
||||||
|
if (!goodsIds.length) return new Map();
|
||||||
|
try {
|
||||||
|
const placeholders = goodsIds.map((_, i) => `@gid${i}`).join(',');
|
||||||
|
const params = {};
|
||||||
|
goodsIds.forEach((id, i) => { params[`gid${i}`] = String(id); });
|
||||||
|
const rows = await middleDb.query(
|
||||||
|
`SELECT DISTINCT CONVERT(varchar(50), goodsId) AS goodsId, hdThumbUrl
|
||||||
|
FROM dbo.pdd_DataGoods
|
||||||
|
WHERE CONVERT(varchar(50), goodsId) IN (${placeholders})`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const map = new Map();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row.hdThumbUrl) map.set(String(row.goodsId), row.hdThumbUrl);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/latest-date', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await jianSunDb.query(
|
||||||
|
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||||
|
FROM dbo.pdd_ReportGoodsSpread
|
||||||
|
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||||
|
ORDER BY createDate DESC`
|
||||||
|
);
|
||||||
|
const date = rows.length >= 2 ? rows[1].createDate : (rows[0]?.createDate || '');
|
||||||
|
res.json({ success: true, data: { date: String(date || '').trim() } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PromotionModule] 查询最新数据日期失败:', error.message);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/shops', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await jianSunDb.query(
|
||||||
|
`SELECT DISTINCT [shopName]
|
||||||
|
FROM dbo.pdd_ReportGoodsSpread
|
||||||
|
WHERE [shopName] IS NOT NULL AND LTRIM(RTRIM([shopName])) != ''
|
||||||
|
ORDER BY [shopName]`
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: rows.map((r) => r.shopName) });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PromotionModule] 查询店铺列表失败:', error.message);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/summary', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date, period, start: customStart, end: customEnd } = req.query;
|
||||||
|
let filterDate = (date && String(date).trim()) || '';
|
||||||
|
let range;
|
||||||
|
|
||||||
|
if (period === 'custom') {
|
||||||
|
range = getDateRange('custom', customStart, customEnd);
|
||||||
|
} else if (period) {
|
||||||
|
range = getDateRange(period);
|
||||||
|
filterDate = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
// 单日口径:未带 period 时按最新数据日(默认取倒数第二个有数据的日期)
|
||||||
|
if (!filterDate) {
|
||||||
|
const dateRows = await jianSunDb.query(
|
||||||
|
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||||
|
FROM dbo.pdd_ReportGoodsSpread
|
||||||
|
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||||
|
ORDER BY createDate DESC`
|
||||||
|
);
|
||||||
|
filterDate = dateRows.length >= 2
|
||||||
|
? String(dateRows[1].createDate || '').trim()
|
||||||
|
: String(dateRows[0]?.createDate || '').trim();
|
||||||
|
}
|
||||||
|
range = { start: filterDate, end: filterDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousRange = getPreviousRange(range);
|
||||||
|
const [current, previous] = await Promise.all([
|
||||||
|
queryPromotionSummary(range),
|
||||||
|
previousRange ? queryPromotionSummary(previousRange) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
filterDate: `${range.start} ~ ${range.end}`,
|
||||||
|
previousRange: previousRange ? `${previousRange.start} ~ ${previousRange.end}` : '',
|
||||||
|
totalRecords: current.totalRecords,
|
||||||
|
orderSpend: fmtMoney(current.orderSpend),
|
||||||
|
gmv: fmtMoney(current.gmv),
|
||||||
|
roi: fmtNum(current.roi),
|
||||||
|
spend: fmtMoney(current.spend),
|
||||||
|
netGmv: fmtMoney(current.netGmv),
|
||||||
|
netRoi: fmtNum(current.netRoi),
|
||||||
|
netOrderNum: fmtNum(current.netOrderNum),
|
||||||
|
costPerNetOrder: fmtMoney(current.costPerNetOrder),
|
||||||
|
changes: previous ? buildPeriodChanges(current, previous) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PromotionModule] 查询汇总数据失败:', error.message);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
sortBy = 'orderSpend',
|
||||||
|
sortOrder = 'DESC',
|
||||||
|
keyword,
|
||||||
|
shopName,
|
||||||
|
date,
|
||||||
|
period,
|
||||||
|
start: customStart,
|
||||||
|
end: customEnd,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const currentPage = safeInt(page, 1);
|
||||||
|
const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE);
|
||||||
|
const offset = (currentPage - 1) * limit;
|
||||||
|
|
||||||
|
const whereParts = [];
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (period && period !== 'custom') {
|
||||||
|
const range = getDateRange(period);
|
||||||
|
whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate');
|
||||||
|
params.startDate = range.start;
|
||||||
|
params.endDate = range.end;
|
||||||
|
} else if (period === 'custom' && customStart && customEnd) {
|
||||||
|
whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate');
|
||||||
|
params.startDate = customStart;
|
||||||
|
params.endDate = customEnd;
|
||||||
|
} else {
|
||||||
|
let filterDate = (date && String(date).trim()) || '';
|
||||||
|
if (!filterDate) {
|
||||||
|
const dateRows = await jianSunDb.query(
|
||||||
|
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||||
|
FROM dbo.pdd_ReportGoodsSpread
|
||||||
|
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||||
|
ORDER BY createDate DESC`
|
||||||
|
);
|
||||||
|
filterDate = dateRows.length >= 2
|
||||||
|
? String(dateRows[1].createDate || '').trim()
|
||||||
|
: String(dateRows[0]?.createDate || '').trim();
|
||||||
|
}
|
||||||
|
if (filterDate) {
|
||||||
|
whereParts.push('LEFT([createTime], 10) = @filterDate');
|
||||||
|
params.filterDate = filterDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyword && keyword.trim()) {
|
||||||
|
whereParts.push('([goodsName] LIKE @kw OR [goodsId] LIKE @kw)');
|
||||||
|
params.kw = `%${keyword.trim()}%`;
|
||||||
|
}
|
||||||
|
if (shopName && shopName.trim()) {
|
||||||
|
whereParts.push('[shopName] = @shopName');
|
||||||
|
params.shopName = shopName.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const allowedSort = new Set([
|
||||||
|
'goodsName', 'goodsId', 'shopName', 'createTime', 'orderSpend', 'gmv', 'roi',
|
||||||
|
'spend', 'orderNum', 'netGmv', 'netRoi', 'netOrderNum', 'netAvgPayAmount',
|
||||||
|
'settlementGmv', 'settlementRoi', 'settlementOrder', 'impression', 'click', 'ctr', 'cvr',
|
||||||
|
]);
|
||||||
|
const sortCol = allowedSort.has(sortBy) ? `[${sortBy}]` : '[orderSpend]';
|
||||||
|
const sortDir = String(sortOrder || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
const countResult = await jianSunDb.query(
|
||||||
|
`SELECT COUNT(*) AS total FROM dbo.pdd_ReportGoodsSpread ${whereSql}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = countResult[0]?.total || 0;
|
||||||
|
|
||||||
|
const dataResult = await jianSunDb.query(
|
||||||
|
`SELECT
|
||||||
|
[id],
|
||||||
|
[goodsName],
|
||||||
|
[goodsId],
|
||||||
|
[shopName],
|
||||||
|
[createTime],
|
||||||
|
[orderSpend],
|
||||||
|
[gmv],
|
||||||
|
[roi],
|
||||||
|
[spend],
|
||||||
|
[orderNum],
|
||||||
|
[transactionCost],
|
||||||
|
[avgPayAmount],
|
||||||
|
[directGmv],
|
||||||
|
[indirectGmv],
|
||||||
|
[directOrderNum],
|
||||||
|
[indirectOrderNum],
|
||||||
|
[netGmv],
|
||||||
|
[netRoi],
|
||||||
|
[netOrderNum],
|
||||||
|
[netAvgPayAmount],
|
||||||
|
[netRate],
|
||||||
|
[settlementGmv],
|
||||||
|
[settlementRoi],
|
||||||
|
[settlementOrder],
|
||||||
|
[refundGmvRate30d],
|
||||||
|
[refundOrderRate30d],
|
||||||
|
[exemptRefundGmvRate30d],
|
||||||
|
[exemptRefundOrderRate30d],
|
||||||
|
[settlementGmvRate],
|
||||||
|
[settlementOrderRate],
|
||||||
|
[costPerSettlementOrderForDeal],
|
||||||
|
[impression],
|
||||||
|
[click],
|
||||||
|
[ctr],
|
||||||
|
[cvr],
|
||||||
|
[targetRoi],
|
||||||
|
[targetBid]
|
||||||
|
FROM dbo.pdd_ReportGoodsSpread
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY ${sortCol} ${sortDir}
|
||||||
|
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`,
|
||||||
|
{ ...params, offset, limit }
|
||||||
|
);
|
||||||
|
|
||||||
|
const goodsIds = dataResult.map((r) => String(r.goodsId)).filter(Boolean);
|
||||||
|
const imageMap = await getImageMap(goodsIds);
|
||||||
|
|
||||||
|
const list = dataResult.map((row) => ({
|
||||||
|
...row,
|
||||||
|
thumbUrl: imageMap.get(String(row.goodsId)) || '',
|
||||||
|
orderSpend: row.orderSpend != null ? toNum(row.orderSpend) / 1000.0 : null,
|
||||||
|
gmv: row.gmv != null ? toNum(row.gmv) / 1000.0 : null,
|
||||||
|
spend: row.spend != null ? toNum(row.spend) / 1000.0 : null,
|
||||||
|
transactionCost: row.transactionCost != null ? toNum(row.transactionCost) / 1000.0 : null,
|
||||||
|
avgPayAmount: row.avgPayAmount != null ? toNum(row.avgPayAmount) / 1000.0 : null,
|
||||||
|
directGmv: row.directGmv != null ? toNum(row.directGmv) / 1000.0 : null,
|
||||||
|
indirectGmv: row.indirectGmv != null ? toNum(row.indirectGmv) / 1000.0 : null,
|
||||||
|
netGmv: row.netGmv != null ? toNum(row.netGmv) / 1000.0 : null,
|
||||||
|
netAvgPayAmount: row.netAvgPayAmount != null ? toNum(row.netAvgPayAmount) / 1000.0 : null,
|
||||||
|
settlementGmv: row.settlementGmv != null ? toNum(row.settlementGmv) / 1000.0 : null,
|
||||||
|
costPerSettlementOrderForDeal: row.costPerSettlementOrderForDeal != null ? toNum(row.costPerSettlementOrderForDeal) / 1000.0 : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PromotionModule] 查询推广数据失败:', error.message);
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
169
backend/src/config/productInformationDatabase.js
Normal file
169
backend/src/config/productInformationDatabase.js
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* 产品资料系统数据库连接模块(PostgreSQL)
|
||||||
|
*
|
||||||
|
* 数据来源:系统库 database_configs 表中 name = 'product_information_database' 的配置。
|
||||||
|
* 典型配置:postgres / 218.93.9.38:25432 / furniture3d_prod / external_full_reader / ssl=1
|
||||||
|
*
|
||||||
|
* 该库用于 MKH-BOM 资料页面的商品资料匹配:
|
||||||
|
* external.intake_sku_items_ready_v1
|
||||||
|
* —— 真实可用的 SKU 明细视图(约 4.6 万行)
|
||||||
|
* —— source_sku_id 为聚水潭原始 SKU 编码(即业务库 BOM 的配件编码)
|
||||||
|
* —— sku_code 为商品编码,barcode 为条码
|
||||||
|
* external.warehouse_locations_v1 —— 库位主数据(当前仅 1 行:A-01 成品区)
|
||||||
|
*
|
||||||
|
* 说明:external.product_bom_v1 / product_skus_v1 当前为空视图,不可用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const database = require('./database');
|
||||||
|
|
||||||
|
const CONFIG_NAME = 'product_information_database';
|
||||||
|
const READY_VIEW = 'external.intake_sku_items_ready_v1';
|
||||||
|
const LOCATION_VIEW = 'external.warehouse_locations_v1';
|
||||||
|
|
||||||
|
const parsePositiveInt = (value, fallback) => {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const POOL_MAX = parsePositiveInt(process.env.PRODUCT_DB_POOL_MAX, 4);
|
||||||
|
|
||||||
|
let pool = null;
|
||||||
|
let cachedSignature = '';
|
||||||
|
|
||||||
|
const truthy = (value) => (
|
||||||
|
value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1'
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadStoredConfig() {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
`SELECT id, host, port, database_name, username, password, \`ssl\`, driver, status
|
||||||
|
FROM database_configs WHERE name = ? LIMIT 1`,
|
||||||
|
[CONFIG_NAME]
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = rows[0];
|
||||||
|
if (!config) {
|
||||||
|
const error = new Error('产品资料系统数据库尚未配置,请在「系统设置 - 数据库配置」中完成配置');
|
||||||
|
error.statusCode = 503;
|
||||||
|
error.code = 'PRODUCT_DB_NOT_CONFIGURED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(config.driver || '').toLowerCase() !== 'postgres') {
|
||||||
|
const error = new Error('产品资料系统数据库需要选择 PostgreSQL 类型');
|
||||||
|
error.statusCode = 503;
|
||||||
|
error.code = 'PRODUCT_DB_DRIVER_MISMATCH';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: config.id,
|
||||||
|
host: String(config.host || '').trim(),
|
||||||
|
port: Number(config.port) || 5432,
|
||||||
|
database: String(config.database_name || '').trim(),
|
||||||
|
user: String(config.username || '').trim(),
|
||||||
|
password: config.password || '',
|
||||||
|
ssl: truthy(config.ssl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSignature = (config) => [
|
||||||
|
config.id,
|
||||||
|
config.host,
|
||||||
|
config.port,
|
||||||
|
config.database,
|
||||||
|
config.user,
|
||||||
|
config.ssl ? 'ssl' : 'nossl',
|
||||||
|
].join('::');
|
||||||
|
|
||||||
|
async function getPool() {
|
||||||
|
const config = await loadStoredConfig();
|
||||||
|
const signature = buildSignature(config);
|
||||||
|
|
||||||
|
if (pool && signature === cachedSignature) {
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pool) {
|
||||||
|
await pool.end().catch(() => {});
|
||||||
|
pool = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pool = new Pool({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
database: config.database,
|
||||||
|
user: config.user,
|
||||||
|
password: config.password,
|
||||||
|
ssl: config.ssl ? { rejectUnauthorized: false } : false,
|
||||||
|
max: POOL_MAX,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 20000,
|
||||||
|
query_timeout: 180000,
|
||||||
|
statement_timeout: 180000,
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (error) => {
|
||||||
|
console.warn('[ProductDB] 连接池异常:', error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
cachedSignature = signature;
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行查询,返回行数组。
|
||||||
|
* @param {string} sqlText PostgreSQL 语句(使用 $1/$2 占位)
|
||||||
|
* @param {Array} params 参数数组
|
||||||
|
*/
|
||||||
|
async function query(sqlText, params = []) {
|
||||||
|
const maxAttempts = 2;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const activePool = await getPool();
|
||||||
|
const result = await activePool.query(sqlText, params);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (error.code === 'PRODUCT_DB_NOT_CONFIGURED' || error.code === 'PRODUCT_DB_DRIVER_MISMATCH') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
console.warn(`[ProductDB] 查询失败,准备重连后重试 (${attempt}/${maxAttempts}): ${error.message}`);
|
||||||
|
await reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
if (pool) {
|
||||||
|
await pool.end().catch(() => {});
|
||||||
|
}
|
||||||
|
pool = null;
|
||||||
|
cachedSignature = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
try {
|
||||||
|
const rows = await query('SELECT 1 AS test');
|
||||||
|
return { success: true, message: '产品资料系统数据库连接成功', data: rows };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: `连接失败: ${error.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
query,
|
||||||
|
test,
|
||||||
|
reset,
|
||||||
|
getPool,
|
||||||
|
CONFIG_NAME,
|
||||||
|
READY_VIEW,
|
||||||
|
LOCATION_VIEW,
|
||||||
|
};
|
||||||
@ -14,12 +14,31 @@ const buildPlanningPage = ({ pageKey, pageName, path, allowSubPaths = false }) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
const SIDEBAR_PAGES = [
|
const SIDEBAR_PAGES = [
|
||||||
|
buildPlanningPage({ pageKey: 'ops_b_incentive', pageName: '激励看板', path: '/planning/operation/ops-b-incentive' }),
|
||||||
buildPlanningPage({
|
buildPlanningPage({
|
||||||
pageKey: 'dashboard_preview',
|
pageKey: 'dashboard_preview',
|
||||||
pageName: '仪表盘(开发预览)',
|
pageName: '仪表盘(开发预览)',
|
||||||
path: '/planning/dev-style-preview',
|
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: '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' }),
|
buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }),
|
||||||
{
|
{
|
||||||
pageKey: 'settings',
|
pageKey: 'settings',
|
||||||
|
|||||||
@ -29,7 +29,12 @@ const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin');
|
|||||||
const channelRoutes = require('./api/channel');
|
const channelRoutes = require('./api/channel');
|
||||||
const analysisRoutes = require('./api/analysis');
|
const analysisRoutes = require('./api/analysis');
|
||||||
const productCodeBoardRoutes = require('./api/productCodeBoard');
|
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 database = require('./config/database');
|
||||||
const cloudDb = require('./config/cloudDatabase');
|
const cloudDb = require('./config/cloudDatabase');
|
||||||
const jianSunDb = require('./config/jianSunDatabase');
|
const jianSunDb = require('./config/jianSunDatabase');
|
||||||
@ -40,6 +45,8 @@ const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductE
|
|||||||
const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob');
|
const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob');
|
||||||
const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob');
|
const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob');
|
||||||
const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
|
const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
|
||||||
|
const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob');
|
||||||
|
const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
|
||||||
const {
|
const {
|
||||||
startAlibaba1688ImageSearchExpiryScheduler,
|
startAlibaba1688ImageSearchExpiryScheduler,
|
||||||
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
|
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
|
||||||
@ -123,6 +130,7 @@ function shouldHandlePlanningUploadPageCache(req) {
|
|||||||
req.path === '/'
|
req.path === '/'
|
||||||
|| req.path.startsWith('/table/may_product_data')
|
|| req.path.startsWith('/table/may_product_data')
|
||||||
|| req.path.startsWith('/analysis/may-channel-insight')
|
|| req.path.startsWith('/analysis/may-channel-insight')
|
||||||
|
|| req.path.startsWith('/analysis/ops-b-incentive')
|
||||||
|| req.path.startsWith('/selection-fission')
|
|| req.path.startsWith('/selection-fission')
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@ -640,9 +648,14 @@ async function startServer(options = {}) {
|
|||||||
cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes);
|
cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes);
|
||||||
cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes);
|
cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes);
|
||||||
cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes);
|
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/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
|
||||||
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
|
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(公开访问,可根据需要添加认证)
|
// 数据分析 API(公开访问,可根据需要添加认证)
|
||||||
@ -680,9 +693,9 @@ async function startServer(options = {}) {
|
|||||||
|
|
||||||
console.log('='.repeat(50));
|
console.log('='.repeat(50));
|
||||||
console.log('🚀 后端服务已启动');
|
console.log('🚀 后端服务已启动');
|
||||||
console.log(`📡 服务地址: http://localhost:4000`);
|
console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
|
||||||
console.log(cubeMode === 'cube'
|
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 继续运行');
|
: '📊 Cube.js API: 已降级不可用,业务 API 继续运行');
|
||||||
|
|
||||||
// 测试数据库连接
|
// 测试数据库连接
|
||||||
@ -708,6 +721,8 @@ async function startServer(options = {}) {
|
|||||||
startPddSkuDimensionSnapshotSchedulers();
|
startPddSkuDimensionSnapshotSchedulers();
|
||||||
startPlanningMayProductSnapshotSchedulers();
|
startPlanningMayProductSnapshotSchedulers();
|
||||||
startPlanningDingWorkflowSyncSchedulers();
|
startPlanningDingWorkflowSyncSchedulers();
|
||||||
|
startOpsBIncentiveSnapshotSchedulers();
|
||||||
|
startGoodsFirstSeenScheduler();
|
||||||
startAlibaba1688ImageSearchExpiryScheduler();
|
startAlibaba1688ImageSearchExpiryScheduler();
|
||||||
|
|
||||||
// Keep the first selection-pool page request from paying the remote schema
|
// Keep the first selection-pool page request from paying the remote schema
|
||||||
|
|||||||
164
backend/src/jobs/goodsFirstSeenJob.js
Normal file
164
backend/src/jobs/goodsFirstSeenJob.js
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
const cron = require('node-cron');
|
||||||
|
const jianSunDb = require('../config/jianSunDatabase');
|
||||||
|
const planningDb = require('../services/planningDatabase');
|
||||||
|
const { pickSkuCaptureTime } = require('../services/opsBIncentiveBoardService');
|
||||||
|
|
||||||
|
const JOB_CRON = String(process.env.GOODS_FIRST_SEEN_CRON || '20 5 * * *').trim();
|
||||||
|
const TIMEZONE = 'Asia/Shanghai';
|
||||||
|
const SYNC_CHUNK_SIZE = 500;
|
||||||
|
const BACKFILL_CHUNK_SIZE = 800;
|
||||||
|
const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily';
|
||||||
|
|
||||||
|
let schemaEnsured = false;
|
||||||
|
|
||||||
|
async function ensureTable() {
|
||||||
|
if (schemaEnsured) return;
|
||||||
|
await planningDb.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS pdd_goods_first_seen (
|
||||||
|
goods_id VARCHAR(50) NOT NULL PRIMARY KEY,
|
||||||
|
first_created_at DATETIME NOT NULL COMMENT '首次采集到的SKU创建时间(约等于上架时间)',
|
||||||
|
last_capture_date DATE NOT NULL COMMENT '最近一次出现在采集批次的日期',
|
||||||
|
is_onsale TINYINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
schemaEnsured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function epochToDatetime(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num) || num <= 0) return null;
|
||||||
|
const ms = num >= 1e12 ? num : num * 1000;
|
||||||
|
const date = new Date(ms);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步单个采集批次:新商品写入首见时间,已存在商品只刷新最近采集日/在售状态(保留最初 first_created_at)
|
||||||
|
async function syncCaptureDate(captureDate) {
|
||||||
|
await ensureTable();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const rows = await jianSunDb.query(
|
||||||
|
`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goodsId,
|
||||||
|
MIN(s.createdAt) AS minCreatedAt,
|
||||||
|
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
|
||||||
|
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index))
|
||||||
|
WHERE s.createTime = @captureDate
|
||||||
|
AND s.goodsId IS NOT NULL AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) <> ''
|
||||||
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId)))`,
|
||||||
|
{ captureDate },
|
||||||
|
{ requestTimeout: 300000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = [];
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const createdAt = epochToDatetime(row.minCreatedAt);
|
||||||
|
if (!createdAt) return;
|
||||||
|
records.push([String(row.goodsId), createdAt, captureDate, Number(row.onsale) === 1 ? 1 : 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const countBefore = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
|
||||||
|
for (let offset = 0; offset < records.length; offset += SYNC_CHUNK_SIZE) {
|
||||||
|
const chunk = records.slice(offset, offset + SYNC_CHUNK_SIZE);
|
||||||
|
await planningDb.query(
|
||||||
|
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
|
||||||
|
VALUES ?
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_capture_date = VALUES(last_capture_date),
|
||||||
|
is_onsale = VALUES(is_onsale)`,
|
||||||
|
[chunk]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const countAfter = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
|
||||||
|
|
||||||
|
return {
|
||||||
|
captureDate,
|
||||||
|
goodsInBatch: records.length,
|
||||||
|
upserted: records.length,
|
||||||
|
newGoods: Math.max(0, Number(countAfter[0]?.n || 0) - Number(countBefore[0]?.n || 0)),
|
||||||
|
total: Number(countAfter[0]?.n || 0),
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每日增量:取最近一个完整采集批次(复用激励看板的 7 天完整性校验)
|
||||||
|
async function refreshLatestCapture() {
|
||||||
|
const captureDate = await pickSkuCaptureTime(new Date().toISOString().slice(0, 10));
|
||||||
|
if (!captureDate) {
|
||||||
|
console.warn('[GoodsFirstSeen] 近7天无可用完整采集批次,跳过本次同步');
|
||||||
|
return { skipped: true };
|
||||||
|
}
|
||||||
|
return syncCaptureDate(captureDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史回填:对当前快照表里的全部商品,走 goodsId 索引精确点查首次创建时间(不受采集历史跨度限制)
|
||||||
|
async function backfillAllHistory() {
|
||||||
|
await ensureTable();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const goodsRows = await planningDb.query(
|
||||||
|
`SELECT DISTINCT goodsId FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})`
|
||||||
|
);
|
||||||
|
const goodsIds = [...new Set(goodsRows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))];
|
||||||
|
console.log(`[GoodsFirstSeen] 历史回填开始,快照商品 ${goodsIds.length} 个`);
|
||||||
|
|
||||||
|
const countBefore = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
|
||||||
|
let synced = 0;
|
||||||
|
for (let offset = 0; offset < goodsIds.length; offset += BACKFILL_CHUNK_SIZE) {
|
||||||
|
const chunk = goodsIds.slice(offset, offset + BACKFILL_CHUNK_SIZE);
|
||||||
|
const params = {};
|
||||||
|
const placeholders = chunk.map((goodsId, index) => {
|
||||||
|
params[`g${index}`] = Number(goodsId);
|
||||||
|
return `@g${index}`;
|
||||||
|
}).join(', ');
|
||||||
|
const rows = await jianSunDb.query(
|
||||||
|
`SELECT s.goodsId AS goodsId,
|
||||||
|
MIN(TRY_CONVERT(BIGINT, s.createdAt)) AS minCreatedAt,
|
||||||
|
MAX(s.createTime) AS lastCaptureRaw,
|
||||||
|
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
|
||||||
|
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(INDEXOF_goodsId_pdd_Skus))
|
||||||
|
WHERE s.goodsId IN (${placeholders})
|
||||||
|
GROUP BY s.goodsId`,
|
||||||
|
params,
|
||||||
|
{ requestTimeout: 300000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = [];
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const createdAt = epochToDatetime(row.minCreatedAt);
|
||||||
|
const lastCapture = epochToDatetime(row.lastCaptureRaw);
|
||||||
|
if (!createdAt) return;
|
||||||
|
records.push([String(row.goodsId), createdAt, lastCapture || createdAt, Number(row.onsale) === 1 ? 1 : 0]);
|
||||||
|
});
|
||||||
|
for (let i = 0; i < records.length; i += SYNC_CHUNK_SIZE) {
|
||||||
|
const chunk = records.slice(i, i + SYNC_CHUNK_SIZE);
|
||||||
|
await planningDb.query(
|
||||||
|
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
|
||||||
|
VALUES ?
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
last_capture_date = VALUES(last_capture_date),
|
||||||
|
is_onsale = VALUES(is_onsale)`,
|
||||||
|
[chunk]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
synced += records.length;
|
||||||
|
console.log(`[GoodsFirstSeen] 回填进度 ${Math.min(offset + BACKFILL_CHUNK_SIZE, goodsIds.length)}/${goodsIds.length},命中 ${records.length}`);
|
||||||
|
}
|
||||||
|
const countAfter = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
|
||||||
|
const summary = { goodsInSnapshot: goodsIds.length, synced, newGoods: Math.max(0, countAfter - countBefore), total: countAfter, elapsedMs: Date.now() - startedAt };
|
||||||
|
console.log(`[GoodsFirstSeen] 历史回填结束: ${JSON.stringify(summary)}`);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGoodsFirstSeenScheduler() {
|
||||||
|
const disabled = String(process.env.DISABLE_GOODS_FIRST_SEEN_JOB || '').trim().toLowerCase() === 'true';
|
||||||
|
if (disabled) {
|
||||||
|
console.log('[GoodsFirstSeen] 首见时间同步任务已被禁用');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cron.schedule(JOB_CRON, () => {
|
||||||
|
refreshLatestCapture().catch((error) => {
|
||||||
|
console.error('[GoodsFirstSeen] 定时同步失败:', error.message);
|
||||||
|
});
|
||||||
|
}, { timezone: TIMEZONE });
|
||||||
|
console.log(`[Scheduler] 商品首见时间同步任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { refreshLatestCapture, backfillAllHistory, syncCaptureDate, startGoodsFirstSeenScheduler };
|
||||||
31
backend/src/jobs/opsBIncentiveSnapshotJob.js
Normal file
31
backend/src/jobs/opsBIncentiveSnapshotJob.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
const cron = require('node-cron');
|
||||||
|
const { refreshOpsBIncentiveSnapshots } = require('../services/opsBIncentiveBoardService');
|
||||||
|
|
||||||
|
const JOB_CRON = String(process.env.OPS_B_INCENTIVE_SNAPSHOT_CRON || '0 * * * *').trim();
|
||||||
|
const TIMEZONE = 'Asia/Shanghai';
|
||||||
|
|
||||||
|
async function runOpsBIncentiveSnapshotJob(source = 'scheduled') {
|
||||||
|
try {
|
||||||
|
const result = await refreshOpsBIncentiveSnapshots(source);
|
||||||
|
console.log('[OpsBIncentiveSnapshot] refresh completed:', JSON.stringify(result));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OpsBIncentiveSnapshot] refresh failed:', error.message || error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOpsBIncentiveSnapshotSchedulers() {
|
||||||
|
// 默认开启;仅当显式设置 DISABLE_OPS_B_INCENTIVE_SNAPSHOT=true 时关闭。
|
||||||
|
const disabled = String(process.env.DISABLE_OPS_B_INCENTIVE_SNAPSHOT || '').trim().toLowerCase() === 'true';
|
||||||
|
if (disabled) {
|
||||||
|
console.log('[Scheduler] 运营中心B激励看板快照任务已被禁用');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cron.schedule(JOB_CRON, () => {
|
||||||
|
runOpsBIncentiveSnapshotJob('daily_schedule').catch(() => {});
|
||||||
|
}, { timezone: TIMEZONE });
|
||||||
|
console.log(`[Scheduler] 运营中心B激励看板快照任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startOpsBIncentiveSnapshotSchedulers, runOpsBIncentiveSnapshotJob };
|
||||||
@ -77,6 +77,8 @@ function buildDesktopCapabilities(user) {
|
|||||||
shopManagement: canPlan,
|
shopManagement: canPlan,
|
||||||
execution: canPlan,
|
execution: canPlan,
|
||||||
logs: true,
|
logs: true,
|
||||||
|
marketingEvents: isAdmin || allowed.has('marketing_events'),
|
||||||
|
promotion: isAdmin || allowed.has('promotion_module'),
|
||||||
productTest: isAdmin || allowed.has('new_product_test'),
|
productTest: isAdmin || allowed.has('new_product_test'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,11 @@ const { ensureDesktopPresenceSchema } = require('./desktopPresenceService');
|
|||||||
const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService');
|
const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService');
|
||||||
const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService');
|
const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService');
|
||||||
const { ensureThirdPartySsoDirectSchema } = require('./thirdPartySsoDirectService');
|
const { ensureThirdPartySsoDirectSchema } = require('./thirdPartySsoDirectService');
|
||||||
|
const { ensureProductTagConfigSchema } = require('./productTagConfigService');
|
||||||
|
const {
|
||||||
|
ensureProductMonitoringSchema,
|
||||||
|
warmProductMonitoringDataset,
|
||||||
|
} = require('./productMonitoringService');
|
||||||
|
|
||||||
const USER_COLUMNS = [
|
const USER_COLUMNS = [
|
||||||
['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"],
|
['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) {
|
async function indexExists(tableName, indexName) {
|
||||||
const rows = await database.local.query(
|
const rows = await database.local.query(
|
||||||
`SELECT 1
|
`SELECT 1
|
||||||
@ -238,10 +255,67 @@ async function seedRedisConfigFromEnv() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureLocalSchema() {
|
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 ensureDesktopPresenceSchema();
|
||||||
await ensureDesktopExecutionTaskSchema();
|
await ensureDesktopExecutionTaskSchema();
|
||||||
await ensureThirdPartySsoSchema();
|
await ensureThirdPartySsoSchema();
|
||||||
await ensureThirdPartySsoDirectSchema();
|
await ensureThirdPartySsoDirectSchema();
|
||||||
|
await ensureProductTagConfigSchema();
|
||||||
|
|
||||||
await ensureTable(
|
await ensureTable(
|
||||||
'auth_configs',
|
'auth_configs',
|
||||||
@ -877,7 +951,11 @@ async function ensureLocalSchema() {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫控价快照读库'`
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫控价快照读库'`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await ensureProductMonitoringSchema();
|
||||||
|
|
||||||
await ensureSidebarRolePermissions();
|
await ensureSidebarRolePermissions();
|
||||||
|
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动
|
||||||
|
void warmProductMonitoringDataset();
|
||||||
await ensureDepartmentPagePermissions();
|
await ensureDepartmentPagePermissions();
|
||||||
await cleanupRetiredFeatureSettings(database.local);
|
await cleanupRetiredFeatureSettings(database.local);
|
||||||
await repairDuplicateExternalDepartments();
|
await repairDuplicateExternalDepartments();
|
||||||
|
|||||||
417
backend/src/services/mkhBomService.js
Normal file
417
backend/src/services/mkhBomService.js
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 模块化库存监控 —— MKH-BOM 资料
|
||||||
|
//
|
||||||
|
// 页面结构(与业务确认的模板):MKH商品编码 | 配件明细 | 数量 | 配件仓位
|
||||||
|
//
|
||||||
|
// ── 数据口径 ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// ① MKH 商品编码(列表主数据)—— 业务库 JSReportReplica(MSSQL / schema=rpt)
|
||||||
|
// rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品' (约 4436 条)
|
||||||
|
// · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198
|
||||||
|
//
|
||||||
|
// ② 配件明细(BOM)—— 产品资料系统数据库(PostgreSQL / furniture3d_prod)
|
||||||
|
// public."IntakeNonIntegratedProduct".payload -> 'skuItems' -> 'componentDetails'
|
||||||
|
// · 每一项形如 { code, name, role/assetType, barcode, quantity }
|
||||||
|
// · 例:MKH-DNZ0384 → [{ barcode: DNZ038501N1437V01, quantity: 1, role: 板材 },
|
||||||
|
// { barcode: DNZ038401N1440V01, quantity: 1, role: 组件 }]
|
||||||
|
// · 匹配:编码去横线/空格转大写后比对;精确 SKU 匹配不到时退化到 SPU 前缀匹配
|
||||||
|
// (业务库 MKHDNZ0384260600002 ↔ 资料库 MKH-DNZ0384260600001)
|
||||||
|
// · 资料库里已维护 BOM、但业务库 jst_sku 里没有对应成品的模块化商品,
|
||||||
|
// 会以 source='product_db' 追加到列表最前,保证已维护的数据一定可见。
|
||||||
|
//
|
||||||
|
// ⚠️ 该库另有 external.product_bom_v1 标准视图,但它依赖 Product / ProductSKU /
|
||||||
|
// Panel 三张整合表,这三张表当前均为 0 行,因此视图恒为空、不要用。
|
||||||
|
// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同
|
||||||
|
// (无 SKU 条码),也不能直接当配件 BOM。
|
||||||
|
//
|
||||||
|
// ③ 配件仓位 —— 资料库 external.warehouse_locations_v1(库位主数据,当前仅 A-01);
|
||||||
|
// 业务库 jst_sku.bin / other_bin 全库为空,故多数配件显示「未维护」。
|
||||||
|
//
|
||||||
|
// ④ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。
|
||||||
|
|
||||||
|
const cloudDatabase = require('../config/cloudDatabase');
|
||||||
|
const productDb = require('../config/productInformationDatabase');
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 50;
|
||||||
|
const MAX_PAGE_SIZE = 200;
|
||||||
|
const MAX_EXTRA_LOOKUP = 300;
|
||||||
|
|
||||||
|
// MKH 商品编码 = 模块化成品
|
||||||
|
const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品'`;
|
||||||
|
|
||||||
|
const toText = (value) => {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text === '' ? null : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编码归一化:去横线/空格 + 大写,用于跨库匹配
|
||||||
|
const normalizeCode = (value) => {
|
||||||
|
const text = toText(value);
|
||||||
|
return text ? text.toUpperCase().replace(/[-\s]/g, '') : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFilters = (query = {}) => {
|
||||||
|
const conditions = [];
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
const keyword = toText(query.keyword);
|
||||||
|
if (keyword) {
|
||||||
|
params.keyword = `%${keyword}%`;
|
||||||
|
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conditions, params };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 componentDetails 原始 JSON 解析成统一结构。
|
||||||
|
*/
|
||||||
|
function parseComponents(raw) {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return null;
|
||||||
|
const barcode = toText(item.barcode);
|
||||||
|
const code = toText(item.code);
|
||||||
|
const name = toText(item.name) || barcode || code;
|
||||||
|
return {
|
||||||
|
barcode: barcode || code,
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
role: toText(item.role) || toText(item.assetType),
|
||||||
|
qty: toNumber(item.quantity) ?? 1,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从产品资料系统数据库读取全部「有配件明细」的模块化商品。
|
||||||
|
* 返回 { bySku: Map, bySpuPrefix: Array, entries: Array }
|
||||||
|
* 失败时返回空结构并降级(页面照常展示列表,只是配件列为 0)。
|
||||||
|
*/
|
||||||
|
async function loadPgBom() {
|
||||||
|
const empty = { bySku: new Map(), bySpuPrefix: [], entries: [] };
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
p.id AS product_id,
|
||||||
|
p.spu AS spu,
|
||||||
|
COALESCE(s->>'skuCode', s->>'barcode') AS sku_code,
|
||||||
|
s->'componentDetails' AS components
|
||||||
|
FROM public."IntakeNonIntegratedProduct" p
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.payload->'skuItems', '[]'::jsonb)) s
|
||||||
|
WHERE jsonb_array_length(COALESCE(s->'componentDetails', '[]'::jsonb)) > 0`;
|
||||||
|
|
||||||
|
let rows = [];
|
||||||
|
try {
|
||||||
|
rows = await productDb.query(sql);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MkhBom] 读取产品资料系统数据库失败,配件 BOM 降级:', error.message);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 商品名称(资料库档案表,取不到不影响主流程)
|
||||||
|
const nameMap = new Map();
|
||||||
|
try {
|
||||||
|
const nameRows = await productDb.query(
|
||||||
|
`SELECT id, "productName" FROM public."IntakeNonIntegratedProductProfile" WHERE "productName" IS NOT NULL`);
|
||||||
|
nameRows.forEach((row) => nameMap.set(toText(row.id), toText(row.productName)));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MkhBom] 读取资料库商品名称失败(忽略):', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySku = new Map();
|
||||||
|
const bySpuPrefix = [];
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const components = parseComponents(row.components);
|
||||||
|
if (components.length === 0) return;
|
||||||
|
|
||||||
|
const skuCode = toText(row.sku_code);
|
||||||
|
const spu = toText(row.spu);
|
||||||
|
const skuKey = normalizeCode(skuCode);
|
||||||
|
if (skuKey) bySku.set(skuKey, components);
|
||||||
|
|
||||||
|
const spuKey = normalizeCode(spu);
|
||||||
|
if (spuKey) bySpuPrefix.push({ prefix: spuKey, components, spu });
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
skuCode,
|
||||||
|
spu,
|
||||||
|
productName: nameMap.get(toText(row.product_id)) || spu || skuCode,
|
||||||
|
components,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 前缀长优先,避免短前缀抢先命中
|
||||||
|
bySpuPrefix.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||||
|
|
||||||
|
return { bySku, bySpuPrefix, entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按编码匹配配件:先精确 SKU,再退化到 SPU 前缀。
|
||||||
|
*/
|
||||||
|
function matchComponents(bom, skuId) {
|
||||||
|
const key = normalizeCode(skuId);
|
||||||
|
if (!key) return null;
|
||||||
|
|
||||||
|
const exact = bom.bySku.get(key);
|
||||||
|
if (exact) return { components: exact, matchType: 'sku' };
|
||||||
|
|
||||||
|
const hit = bom.bySpuPrefix.find((entry) => key.startsWith(entry.prefix));
|
||||||
|
if (hit) return { components: hit.components, matchType: 'spu' };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 找出「资料库里已维护 BOM、但业务库列表里没有」的模块化商品,
|
||||||
|
* 作为追加行放在列表最前面,保证已维护的数据一定能在页面上看到。
|
||||||
|
*/
|
||||||
|
async function buildExtraRows(bom) {
|
||||||
|
if (!bom.entries.length) return [];
|
||||||
|
if (bom.entries.length > MAX_EXTRA_LOOKUP) return [];
|
||||||
|
|
||||||
|
const candidates = [];
|
||||||
|
bom.entries.forEach((entry) => {
|
||||||
|
[entry.skuCode, normalizeCode(entry.skuCode)].forEach((code) => {
|
||||||
|
if (code && !candidates.includes(code)) candidates.push(code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!candidates.length) return [];
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
const placeholders = candidates.map((code, index) => {
|
||||||
|
params[`c${index}`] = code;
|
||||||
|
return `@c${index}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let existing = new Set();
|
||||||
|
try {
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT s.sku_id AS skuId FROM dbo.jst_sku s WHERE s.sku_id IN (${placeholders.join(', ')})`,
|
||||||
|
params,
|
||||||
|
{ requestTimeout: 120000 }
|
||||||
|
);
|
||||||
|
existing = new Set(rows.map((row) => normalizeCode(row.skuId)).filter(Boolean));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MkhBom] 追加行去重查询失败,按全部追加处理:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bom.entries
|
||||||
|
.filter((entry) => {
|
||||||
|
const key = normalizeCode(entry.skuCode);
|
||||||
|
return key && !existing.has(key);
|
||||||
|
})
|
||||||
|
.map((entry) => ({
|
||||||
|
skuId: entry.skuCode || entry.spu,
|
||||||
|
productName: entry.productName,
|
||||||
|
category: null,
|
||||||
|
material: null,
|
||||||
|
accessoryCount: entry.components.length,
|
||||||
|
accessoryQtySum: sumQty(entry.components),
|
||||||
|
bomConfigured: '已配置',
|
||||||
|
bomMatchType: 'sku',
|
||||||
|
source: 'product_db',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表:MKH 模块化成品(业务库)+ 配件种类/数量(资料库 BOM)
|
||||||
|
*/
|
||||||
|
async function getMkhBomList(query = {}) {
|
||||||
|
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
|
||||||
|
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
|
||||||
|
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
|
||||||
|
const { conditions, params } = buildFilters(query);
|
||||||
|
|
||||||
|
const whereSql = conditions.length
|
||||||
|
? `FROM dbo.jst_sku s WHERE ${MKH_WHERE} AND ${conditions.join(' AND ')}`
|
||||||
|
: `FROM dbo.jst_sku s WHERE ${MKH_WHERE}`;
|
||||||
|
|
||||||
|
const sortField = String(query.sortField || 'skuId');
|
||||||
|
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
const orderSql = sortField === 'skuId' ? `s.sku_id ${sortOrder}` : `s.sku_id ASC`;
|
||||||
|
|
||||||
|
const bom = await loadPgBom();
|
||||||
|
const extraRows = await buildExtraRows(bom);
|
||||||
|
const extraCount = extraRows.length;
|
||||||
|
|
||||||
|
// 追加行占据全局前 extraCount 个位置,业务库行顺延
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const extraStart = Math.min(offset, extraCount);
|
||||||
|
const extraTake = Math.max(0, Math.min(pageSize, extraCount - extraStart));
|
||||||
|
const businessOffset = Math.max(0, offset - extraCount);
|
||||||
|
const businessTake = pageSize - extraTake;
|
||||||
|
|
||||||
|
const pageExtras = extraRows.slice(extraStart, extraStart + extraTake);
|
||||||
|
|
||||||
|
let businessRows = [];
|
||||||
|
if (businessTake > 0) {
|
||||||
|
businessRows = await cloudDatabase.query(
|
||||||
|
`SELECT
|
||||||
|
s.sku_id AS skuId,
|
||||||
|
s.name AS productName,
|
||||||
|
s.category AS category,
|
||||||
|
s.other_10 AS material
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY ${orderSql}
|
||||||
|
OFFSET ${businessOffset} ROWS FETCH NEXT ${businessTake} ROWS ONLY`,
|
||||||
|
params,
|
||||||
|
{ requestTimeout: 180000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalRow] = await cloudDatabase.query(
|
||||||
|
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
|
||||||
|
params,
|
||||||
|
{ requestTimeout: 180000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapped = businessRows.map((row) => {
|
||||||
|
const skuId = toText(row.skuId);
|
||||||
|
const matched = matchComponents(bom, skuId);
|
||||||
|
const components = matched?.components || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
skuId,
|
||||||
|
productName: toText(row.productName),
|
||||||
|
category: toText(row.category),
|
||||||
|
material: toText(row.material),
|
||||||
|
accessoryCount: components.length,
|
||||||
|
accessoryQtySum: sumQty(components),
|
||||||
|
bomConfigured: components.length > 0 ? '已配置' : '未配置',
|
||||||
|
bomMatchType: matched?.matchType || null,
|
||||||
|
source: 'business_db',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = [...pageExtras, ...mapped];
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
total: Number(totalRow?.total || 0) + extraCount,
|
||||||
|
bomRowCount: bom.entries.length,
|
||||||
|
bomExtraCount: extraCount,
|
||||||
|
bomReady: bom.entries.length > 0,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
configuredInPage: rows.filter((row) => row.accessoryCount > 0).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明细:单个 MKH 成品的配件清单
|
||||||
|
* 结构按业务模板:配件明细 | 数量 | 配件仓位
|
||||||
|
*/
|
||||||
|
async function getMkhBomDetail(mkhSkuId) {
|
||||||
|
const skuId = toText(mkhSkuId);
|
||||||
|
if (!skuId) {
|
||||||
|
const error = new Error('缺少 MKH 商品编码');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comboRows = await cloudDatabase.query(
|
||||||
|
`SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category
|
||||||
|
FROM dbo.jst_sku s
|
||||||
|
WHERE s.sku_id = @mkhSkuId`,
|
||||||
|
{ mkhSkuId: skuId },
|
||||||
|
{ requestTimeout: 120000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
let combo = comboRows[0] ? {
|
||||||
|
skuId: toText(comboRows[0].skuId),
|
||||||
|
productName: toText(comboRows[0].productName),
|
||||||
|
category: toText(comboRows[0].category),
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
const bom = await loadPgBom();
|
||||||
|
const matched = matchComponents(bom, skuId);
|
||||||
|
const components = matched?.components || [];
|
||||||
|
|
||||||
|
// 业务库里没有该成品(资料库专有编码)时,用资料库条目补齐基础信息
|
||||||
|
if (!combo) {
|
||||||
|
const entry = bom.entries.find((item) => normalizeCode(item.skuCode) === normalizeCode(skuId));
|
||||||
|
if (!entry) {
|
||||||
|
const error = new Error('未找到该 MKH 商品');
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
combo = { skuId, productName: entry.productName, category: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = components.map((item) => item.barcode).filter(Boolean);
|
||||||
|
const stockMap = new Map();
|
||||||
|
const locationMap = new Map();
|
||||||
|
|
||||||
|
if (codes.length > 0) {
|
||||||
|
const placeholders = codes.map((_, index) => `@c${index}`).join(', ');
|
||||||
|
const codeParams = Object.fromEntries(codes.map((code, index) => [`c${index}`, code]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stockRows = await cloudDatabase.query(
|
||||||
|
`SELECT i.sku_id AS skuId, SUM(i.qty) AS qty
|
||||||
|
FROM dbo.jst_inventoryInfo i
|
||||||
|
WHERE i.sku_id IN (${placeholders})
|
||||||
|
GROUP BY i.sku_id`,
|
||||||
|
codeParams,
|
||||||
|
{ requestTimeout: 180000 }
|
||||||
|
);
|
||||||
|
stockRows.forEach((row) => stockMap.set(toText(row.skuId), toNumber(row.qty) ?? 0));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MkhBom] 配件库存查询降级:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const locRows = await cloudDatabase.query(
|
||||||
|
`SELECT sku_id AS skuId,
|
||||||
|
COALESCE(NULLIF(LTRIM(RTRIM(bin)), N''), NULLIF(LTRIM(RTRIM(other_bin)), N'')) AS location
|
||||||
|
FROM dbo.jst_sku
|
||||||
|
WHERE sku_id IN (${placeholders})`,
|
||||||
|
codeParams,
|
||||||
|
{ requestTimeout: 120000 }
|
||||||
|
);
|
||||||
|
locRows.forEach((row) => locationMap.set(toText(row.skuId), toText(row.location)));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MkhBom] 配件仓位查询降级:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = components.map((component, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
skuId: component.barcode,
|
||||||
|
barcode: component.barcode,
|
||||||
|
skuName: component.name,
|
||||||
|
role: component.role,
|
||||||
|
qty: toNumber(component.qty) ?? 0,
|
||||||
|
location: locationMap.get(component.barcode) || null,
|
||||||
|
stock: stockMap.get(component.barcode) ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
skuId: toText(combo.skuId),
|
||||||
|
productName: toText(combo.productName),
|
||||||
|
category: toText(combo.category),
|
||||||
|
items,
|
||||||
|
bomReady: items.length > 0,
|
||||||
|
bomMatchType: matched?.matchType || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getMkhBomList,
|
||||||
|
getMkhBomDetail,
|
||||||
|
};
|
||||||
513
backend/src/services/modularStockMonitorService.js
Normal file
513
backend/src/services/modularStockMonitorService.js
Normal file
@ -0,0 +1,513 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 模块化库存监控 —— 配件库存监控
|
||||||
|
//
|
||||||
|
// 需求来源:《模块化库存监控.xlsx》
|
||||||
|
// 一级功能:模块化库存监控 二级功能:配件库存监控
|
||||||
|
// 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 |
|
||||||
|
// 仓位 | 近7日日均发货量 | 库存可用天数 | 所在仓库
|
||||||
|
// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码
|
||||||
|
//
|
||||||
|
// 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认):
|
||||||
|
// 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库
|
||||||
|
// 目标仓库共 794 个 SKU
|
||||||
|
// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库
|
||||||
|
// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得)
|
||||||
|
// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU:
|
||||||
|
// sevenAvgSale 近7日日均发货量
|
||||||
|
// jsUseDays 库存可用天数
|
||||||
|
// safetyDays 安全天数(默认 15)
|
||||||
|
// suggestStock 建议备货量
|
||||||
|
// jsStock 监控表口径库存(作为 inventoryInfo 的兜底)
|
||||||
|
// nearSecondSale 近一日发货量 → 当日出库
|
||||||
|
//
|
||||||
|
// ⚠️ 关联键坑(2026-09-12 修正):
|
||||||
|
// pm_stockMonitor 里同时有 sku 与 sku_id 两列:
|
||||||
|
// sku = 短款号,如 DNZ040967 —— 用它关联只能命中 34 / 794
|
||||||
|
// sku_id = 完整 SKU 编码 DNZ040967N1440V01 —— 必须用它关联,命中 794 / 794
|
||||||
|
// 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty
|
||||||
|
// (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794)
|
||||||
|
//
|
||||||
|
// ⚠️ 业务库确实无数据源的字段:
|
||||||
|
// - 仓位 location:rpt.jst_sku.bin / other_bin 全库 557,475 行填充数为 0
|
||||||
|
|
||||||
|
const cloudDatabase = require('../config/cloudDatabase');
|
||||||
|
const productDb = require('../config/productInformationDatabase');
|
||||||
|
|
||||||
|
const WAREHOUSE_NAME = '简尚-崔桥工厂半成品包装项目仓';
|
||||||
|
// rpt.jst_warehouseInfo 中「简尚-崔桥工厂半成品包装项目仓」对应的 wms_co_id
|
||||||
|
const WAREHOUSE_WMS_CO_ID = 15718196;
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 50;
|
||||||
|
const MAX_PAGE_SIZE = 200;
|
||||||
|
const DEFAULT_SAFETY_DAYS = 15;
|
||||||
|
// 预警阈值:库存可用天数 <= 7 天即预警
|
||||||
|
const LOW_STOCK_DAYS_THRESHOLD = 7;
|
||||||
|
|
||||||
|
const WARNING_LEVEL = Object.freeze({
|
||||||
|
OUT_OF_STOCK: '缺货',
|
||||||
|
WARNING: '预警',
|
||||||
|
NORMAL: '正常',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模块化配置状态:
|
||||||
|
// 「已配置MKH」= 该 SKU 已在「产品资料系统数据库」中建立商品档案
|
||||||
|
// (external.product_catalog_wide_base_v1 按 barcode 匹配)
|
||||||
|
// 「未配置MKH」= 产品资料系统数据库中查不到该 SKU 档案
|
||||||
|
//
|
||||||
|
// 口径变更说明(2026-09-15,按业务确认):
|
||||||
|
// 原先用业务库 jst_comboProductItem.src_sku_id 判定「是否被 MKH 组合 BOM 引用」,
|
||||||
|
// 实测目标仓 794 个 SKU 中只有 32 个命中——因为目标仓(半成品包装仓)与
|
||||||
|
// MKH 组合 BOM 的配件(159 个 SKU / 14 个款号)是两批基本不重叠的编码集合,
|
||||||
|
// 按款号(SPU)放宽也只有 3 个款号重叠。
|
||||||
|
// 业务确认改用「产品资料系统数据库是否有商品档案」作为判定口径,
|
||||||
|
// 实测目标仓 661/794 命中。
|
||||||
|
const MKH_STATUS = Object.freeze({
|
||||||
|
CONFIGURED: '已配置MKH',
|
||||||
|
UNCONFIGURED: '未配置MKH',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 产品资料覆盖度缓存:避免每次列表请求都回查产品资料系统数据库
|
||||||
|
const PRODUCT_COVERAGE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
let productCoverageCache = null;
|
||||||
|
|
||||||
|
const SORTABLE_FIELDS = Object.freeze({
|
||||||
|
skuId: 's.sku_id',
|
||||||
|
productName: 's.name',
|
||||||
|
category: 's.category',
|
||||||
|
realtimeStock: 'realtimeStock',
|
||||||
|
avgShip7d: 'm.sevenAvgSale',
|
||||||
|
stockDays: 'm.jsUseDays',
|
||||||
|
warningLevel: 'warningOrder',
|
||||||
|
mkhStatus: 'mkhStatus',
|
||||||
|
location: 'location',
|
||||||
|
warehouse: 's.other_3',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「已配置MKH」判定表达式。
|
||||||
|
* available = true —— 用产品资料库覆盖清单(@productCoveredCsv,逗号分隔的 SKU 编码)
|
||||||
|
* available = false —— 产品资料库不可用时回退到业务库 MKH BOM 判定,保证页面可用
|
||||||
|
*/
|
||||||
|
const buildConfiguredExistsSql = (coverage = {}) => (
|
||||||
|
coverage.available
|
||||||
|
? `EXISTS (
|
||||||
|
SELECT 1 FROM STRING_SPLIT(@productCoveredCsv, N',') pc WHERE pc.value = s.sku_id
|
||||||
|
)`
|
||||||
|
: `EXISTS (
|
||||||
|
SELECT 1 FROM JSDataMiddlePlatform.dbo.jst_comboProductItem x WHERE x.src_sku_id = s.sku_id
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildBaseSql = (coverage = {}) => `
|
||||||
|
FROM dbo.jst_sku s
|
||||||
|
OUTER APPLY (
|
||||||
|
SELECT TOP 1 i.qty
|
||||||
|
FROM dbo.jst_inventoryInfo i
|
||||||
|
WHERE i.sku_id = s.sku_id AND i.wms_co_id = @wmsCoId
|
||||||
|
ORDER BY i.updateTime DESC
|
||||||
|
) inv
|
||||||
|
OUTER APPLY (
|
||||||
|
SELECT TOP 1 m2.sevenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
|
||||||
|
m2.date, m2.jsStock, m2.nearSecondSale
|
||||||
|
FROM dbo.pm_stockMonitor m2
|
||||||
|
WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName
|
||||||
|
ORDER BY m2.date DESC
|
||||||
|
) m
|
||||||
|
OUTER APPLY (
|
||||||
|
SELECT CAST(SUM(ISNULL(p.inQty, 0)) AS DECIMAL(18, 2)) AS inQty
|
||||||
|
FROM dbo.jst_purchaseItem p
|
||||||
|
WHERE p.sku_id = s.sku_id
|
||||||
|
AND p.delivery_date >= @monitorDate
|
||||||
|
AND p.delivery_date < DATEADD(DAY, 1, @monitorDate)
|
||||||
|
) pin
|
||||||
|
OUTER APPLY (
|
||||||
|
SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode
|
||||||
|
FROM dbo.fys_skuCostPriceUpdate c
|
||||||
|
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id))))
|
||||||
|
ORDER BY c.createTime DESC
|
||||||
|
) cost
|
||||||
|
-- 模块化配置状态:见文件头部「口径变更说明」
|
||||||
|
OUTER APPLY (
|
||||||
|
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql(coverage)} THEN 1 ELSE 0 END AS BIT) AS configured
|
||||||
|
) cfg
|
||||||
|
WHERE s.other_3 = @warehouseName`;
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toText = (value) => {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text === '' ? null : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeWarningLevel = (realtimeStock, stockDays) => {
|
||||||
|
const stock = Number(realtimeStock || 0);
|
||||||
|
if (stock <= 0) return WARNING_LEVEL.OUT_OF_STOCK;
|
||||||
|
|
||||||
|
const days = toNumber(stockDays);
|
||||||
|
if (days !== null && days <= LOW_STOCK_DAYS_THRESHOLD) return WARNING_LEVEL.WARNING;
|
||||||
|
|
||||||
|
return WARNING_LEVEL.NORMAL;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WARNING_ORDER = {
|
||||||
|
[WARNING_LEVEL.OUT_OF_STOCK]: 0,
|
||||||
|
[WARNING_LEVEL.WARNING]: 1,
|
||||||
|
[WARNING_LEVEL.NORMAL]: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取目标仓最新监控日(pm_stockMonitor 快照日),作为「当日入库 / 当日出库」的统计基准日。
|
||||||
|
* 业务库没有出入库流水表,只能以监控日为基准做按日汇总。
|
||||||
|
*/
|
||||||
|
async function resolveMonitorDate() {
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT CAST(MAX(date) AS DATE) AS monitorDate
|
||||||
|
FROM dbo.pm_stockMonitor
|
||||||
|
WHERE warehouse = @warehouseName`,
|
||||||
|
{ warehouseName: WAREHOUSE_NAME },
|
||||||
|
{ requestTimeout: 120000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const raw = rows?.[0]?.monitorDate;
|
||||||
|
if (!raw) return null;
|
||||||
|
const d = new Date(raw);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFilters = (query = {}, monitorDate = null, coverage = {}) => {
|
||||||
|
const conditions = [];
|
||||||
|
const params = {
|
||||||
|
wmsCoId: WAREHOUSE_WMS_CO_ID,
|
||||||
|
warehouseName: WAREHOUSE_NAME,
|
||||||
|
monitorDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 「已配置MKH」判定依赖产品资料库覆盖清单(逗号分隔的 SKU 编码)
|
||||||
|
if (coverage.available) {
|
||||||
|
params.productCoveredCsv = coverage.csv || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = toText(query.keyword);
|
||||||
|
if (keyword) {
|
||||||
|
params.keyword = `%${keyword}%`;
|
||||||
|
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = toText(query.category);
|
||||||
|
if (category) {
|
||||||
|
params.category = category;
|
||||||
|
conditions.push('s.category = @category');
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = toText(query.location);
|
||||||
|
if (location) {
|
||||||
|
params.location = location;
|
||||||
|
conditions.push(`COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) = @location`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mkhStatus = toText(query.mkhStatus);
|
||||||
|
if (mkhStatus === MKH_STATUS.CONFIGURED || mkhStatus === MKH_STATUS.UNCONFIGURED) {
|
||||||
|
params.mkhStatus = mkhStatus;
|
||||||
|
conditions.push(`CASE WHEN cfg.configured = 1 THEN N'${MKH_STATUS.CONFIGURED}' ELSE N'${MKH_STATUS.UNCONFIGURED}' END = @mkhStatus`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const warningLevel = toText(query.warningLevel);
|
||||||
|
if (warningLevel && Object.prototype.hasOwnProperty.call(WARNING_ORDER, warningLevel)) {
|
||||||
|
params.warningOrder = WARNING_ORDER[warningLevel];
|
||||||
|
conditions.push(`
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
|
||||||
|
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END = @warningOrder`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlyWarning = String(query.onlyWarning || '').toLowerCase();
|
||||||
|
if (['1', 'true', 'yes'].includes(onlyWarning)) {
|
||||||
|
conditions.push(`(
|
||||||
|
COALESCE(inv.qty, m.jsStock, 0) <= 0
|
||||||
|
OR (m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD})
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conditions, params };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用「产品资料系统数据库」补齐商品图片。
|
||||||
|
*
|
||||||
|
* ⚠️ 2026-09-15 实测:目标仓 794 个 SKU 在业务库里图片字段全部为空——
|
||||||
|
* rpt.jst_sku.pic / pic_big 794/794 为 NULL
|
||||||
|
* rpt.fys_skuCostPriceUpdate.pictureSkucode 794/794 为 NULL
|
||||||
|
* 且 rpt.jst_sku 中只有 pic / pic_big 两个图片列,无其他图片源。
|
||||||
|
* 因此图片统一改从产品资料系统数据库(PostgreSQL)取:
|
||||||
|
* external.product_catalog_wide_base_v1
|
||||||
|
* 按 barcode(完整 SKU 编码)匹配,image_url 优先、product_thumbnail_url 兜底
|
||||||
|
* 实测目标仓 661 条可匹配,其中 481 条带图。
|
||||||
|
* 查询失败时静默降级,不影响列表主流程。
|
||||||
|
*/
|
||||||
|
async function attachProductImages(rows = []) {
|
||||||
|
const codes = Array.from(new Set(rows.map((row) => toText(row.skuId)).filter(Boolean)));
|
||||||
|
if (codes.length === 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageMap = new Map();
|
||||||
|
try {
|
||||||
|
const imageRows = await productDb.query(
|
||||||
|
`SELECT barcode, image_url, product_thumbnail_url
|
||||||
|
FROM external.product_catalog_wide_base_v1
|
||||||
|
WHERE barcode = ANY($1)`,
|
||||||
|
[codes]
|
||||||
|
);
|
||||||
|
|
||||||
|
imageRows.forEach((row) => {
|
||||||
|
const code = toText(row.barcode);
|
||||||
|
if (!code || imageMap.has(code)) return;
|
||||||
|
const url = toText(row.image_url) || toText(row.product_thumbnail_url);
|
||||||
|
if (url) imageMap.set(code, url);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ModularStock] 读取产品资料库图片失败,降级为业务库图片:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
productImageUrl: imageMap.get(toText(row.skuId)) || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析目标仓 SKU 的「产品资料库覆盖清单」,用于「已配置MKH」判定。
|
||||||
|
* 返回 { available, csv, set, total, coveredCount },结果按 TTL 缓存。
|
||||||
|
* 产品资料库不可用时 available = false,SQL 侧自动回退到业务库 MKH BOM 判定。
|
||||||
|
*/
|
||||||
|
async function resolveProductCoverage() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
productCoverageCache
|
||||||
|
&& productCoverageCache.warehouse === WAREHOUSE_NAME
|
||||||
|
&& now - productCoverageCache.at < PRODUCT_COVERAGE_TTL_MS
|
||||||
|
) {
|
||||||
|
return productCoverageCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skuRows = await cloudDatabase.query(
|
||||||
|
`SELECT s.sku_id AS skuId FROM dbo.jst_sku s WHERE s.other_3 = @warehouseName`,
|
||||||
|
{ warehouseName: WAREHOUSE_NAME },
|
||||||
|
{ requestTimeout: 180000 }
|
||||||
|
);
|
||||||
|
const codes = skuRows.map((row) => toText(row.skuId)).filter(Boolean);
|
||||||
|
|
||||||
|
let covered = [];
|
||||||
|
let available = true;
|
||||||
|
try {
|
||||||
|
const rows = await productDb.query(
|
||||||
|
`SELECT DISTINCT barcode
|
||||||
|
FROM external.product_catalog_wide_base_v1
|
||||||
|
WHERE barcode = ANY($1)`,
|
||||||
|
[codes]
|
||||||
|
);
|
||||||
|
covered = rows.map((row) => toText(row.barcode)).filter(Boolean);
|
||||||
|
} catch (error) {
|
||||||
|
available = false;
|
||||||
|
console.warn('[ModularStock] 产品资料库覆盖清单查询失败,回退业务库 MKH BOM 判定:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
productCoverageCache = {
|
||||||
|
warehouse: WAREHOUSE_NAME,
|
||||||
|
available,
|
||||||
|
csv: covered.join(','),
|
||||||
|
set: new Set(covered),
|
||||||
|
total: codes.length,
|
||||||
|
coveredCount: covered.length,
|
||||||
|
at: now,
|
||||||
|
};
|
||||||
|
return productCoverageCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapRow = (row = {}) => {
|
||||||
|
const realtimeStock = Number(row.realtimeStock || 0);
|
||||||
|
const stockDays = toNumber(row.stockDays);
|
||||||
|
const safetyDays = toNumber(row.safetyDays) ?? DEFAULT_SAFETY_DAYS;
|
||||||
|
const warningLevel = normalizeWarningLevel(realtimeStock, stockDays);
|
||||||
|
|
||||||
|
return {
|
||||||
|
skuId: toText(row.skuId),
|
||||||
|
productName: toText(row.productName),
|
||||||
|
category: toText(row.category),
|
||||||
|
// 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空)
|
||||||
|
imageUrl: toText(row.productImageUrl) || toText(row.imageUrl),
|
||||||
|
warningLevel,
|
||||||
|
warningOrder: WARNING_ORDER[warningLevel],
|
||||||
|
mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED,
|
||||||
|
// 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量
|
||||||
|
inQtyToday: toNumber(row.inQtyToday) ?? 0,
|
||||||
|
outQtyToday: toNumber(row.outQtyToday) ?? 0,
|
||||||
|
realtimeStock,
|
||||||
|
location: toText(row.location),
|
||||||
|
avgShip7d: toNumber(row.avgShip7d),
|
||||||
|
stockDays,
|
||||||
|
safetyDays,
|
||||||
|
suggestStock: toNumber(row.suggestStock),
|
||||||
|
warehouse: toText(row.warehouse) || WAREHOUSE_NAME,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildProjection = () => `
|
||||||
|
s.sku_id AS skuId,
|
||||||
|
s.name AS productName,
|
||||||
|
s.category AS category,
|
||||||
|
COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock,
|
||||||
|
ISNULL(pin.inQty, 0) AS inQtyToday,
|
||||||
|
ISNULL(m.nearSecondSale, 0) AS outQtyToday,
|
||||||
|
COALESCE(NULLIF(LTRIM(RTRIM(s.bin)), N''), NULLIF(LTRIM(RTRIM(s.other_bin)), N'')) AS location,
|
||||||
|
m.sevenAvgSale AS avgShip7d,
|
||||||
|
m.jsUseDays AS stockDays,
|
||||||
|
m.safetyDays AS safetyDays,
|
||||||
|
m.suggestStock AS suggestStock,
|
||||||
|
s.other_3 AS warehouse,
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(LTRIM(RTRIM(s.pic_big)), N''),
|
||||||
|
NULLIF(LTRIM(RTRIM(s.pic)), N''),
|
||||||
|
cost.pictureSkucode
|
||||||
|
) AS imageUrl,
|
||||||
|
CASE WHEN cfg.configured = 1 THEN N'已配置MKH' ELSE N'未配置MKH' END AS mkhStatus,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
|
||||||
|
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END AS warningOrder,
|
||||||
|
m.date AS monitorDate`;
|
||||||
|
|
||||||
|
async function getModularStockList(query = {}) {
|
||||||
|
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
|
||||||
|
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
|
||||||
|
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
|
||||||
|
const [monitorDate, coverage] = await Promise.all([
|
||||||
|
resolveMonitorDate(),
|
||||||
|
resolveProductCoverage(),
|
||||||
|
]);
|
||||||
|
const { conditions, params } = buildFilters(query, monitorDate, coverage);
|
||||||
|
const baseSql = buildBaseSql(coverage);
|
||||||
|
const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql;
|
||||||
|
|
||||||
|
const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock';
|
||||||
|
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
const orderSql = `${SORTABLE_FIELDS[sortField]} ${sortOrder}`;
|
||||||
|
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
// 已按 s.sku_id 排序时不再追加 tie-breaker,否则 ORDER BY 出现重复列报错 169
|
||||||
|
const orderClause = SORTABLE_FIELDS[sortField] === 's.sku_id'
|
||||||
|
? orderSql
|
||||||
|
: `${orderSql}, s.sku_id ASC`;
|
||||||
|
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT ${buildProjection()}
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY ${orderClause}
|
||||||
|
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
|
||||||
|
params,
|
||||||
|
{ requestTimeout: 180000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const [totalRow] = await cloudDatabase.query(
|
||||||
|
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
|
||||||
|
params,
|
||||||
|
{ requestTimeout: 180000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const [summaryRow] = await buildSummary(whereSql, params);
|
||||||
|
|
||||||
|
// 用产品资料系统数据库补齐图片(业务库目标仓图片字段全为空)
|
||||||
|
const enrichedRows = await attachProductImages(rows);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: enrichedRows.map(mapRow),
|
||||||
|
total: Number(totalRow?.total || 0),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
warehouse: WAREHOUSE_NAME,
|
||||||
|
monitorDate: summaryRow?.monitorDate || monitorDate,
|
||||||
|
summary: {
|
||||||
|
skuCount: summaryRow?.skuCount || 0,
|
||||||
|
outOfStockCount: summaryRow?.outOfStockCount || 0,
|
||||||
|
lowStockDaysCount: summaryRow?.lowStockDaysCount || 0,
|
||||||
|
configuredCount: summaryRow?.configuredCount || 0,
|
||||||
|
unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0),
|
||||||
|
totalStock: summaryRow?.totalStock || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSummary(whereSql, params) {
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT_BIG(*) AS skuCount,
|
||||||
|
SUM(CASE WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 1 ELSE 0 END) AS outOfStockCount,
|
||||||
|
CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock,
|
||||||
|
SUM(CASE WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 ELSE 0 END) AS lowStockDaysCount,
|
||||||
|
SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount,
|
||||||
|
MAX(m.date) AS monitorDate
|
||||||
|
${whereSql}`,
|
||||||
|
params,
|
||||||
|
{ requestTimeout: 180000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = rows[0] || {};
|
||||||
|
|
||||||
|
return [{
|
||||||
|
skuCount: Number(row.skuCount || 0),
|
||||||
|
outOfStockCount: Number(row.outOfStockCount || 0),
|
||||||
|
totalStock: Number(row.totalStock || 0),
|
||||||
|
lowStockDaysCount: Number(row.lowStockDaysCount || 0),
|
||||||
|
configuredCount: Number(row.configuredCount || 0),
|
||||||
|
monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getModularStockFilters() {
|
||||||
|
const [monitorDate, coverage] = await Promise.all([
|
||||||
|
resolveMonitorDate(),
|
||||||
|
resolveProductCoverage(),
|
||||||
|
]);
|
||||||
|
const params = {
|
||||||
|
wmsCoId: WAREHOUSE_WMS_CO_ID,
|
||||||
|
warehouseName: WAREHOUSE_NAME,
|
||||||
|
monitorDate,
|
||||||
|
};
|
||||||
|
if (coverage.available) {
|
||||||
|
params.productCoveredCsv = coverage.csv || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT DISTINCT s.category
|
||||||
|
${buildBaseSql(coverage)} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N''
|
||||||
|
ORDER BY s.category`,
|
||||||
|
params,
|
||||||
|
{ requestTimeout: 180000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories: rows.map((row) => toText(row.category)).filter(Boolean),
|
||||||
|
warningLevels: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING, WARNING_LEVEL.NORMAL],
|
||||||
|
mkhStatuses: [MKH_STATUS.CONFIGURED, MKH_STATUS.UNCONFIGURED],
|
||||||
|
warehouse: WAREHOUSE_NAME,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
WAREHOUSE_NAME,
|
||||||
|
WAREHOUSE_WMS_CO_ID,
|
||||||
|
WARNING_LEVEL,
|
||||||
|
DEFAULT_SAFETY_DAYS,
|
||||||
|
getModularStockList,
|
||||||
|
getModularStockFilters,
|
||||||
|
};
|
||||||
430
backend/src/services/opsBIncentiveBoardService.js
Normal file
430
backend/src/services/opsBIncentiveBoardService.js
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
const dayjs = require('dayjs');
|
||||||
|
const database = require('../config/database');
|
||||||
|
const jianSunDb = require('../config/jianSunDatabase');
|
||||||
|
const cloudDatabase = require('../config/cloudDatabase');
|
||||||
|
|
||||||
|
const DEFAULT_SCOPE_START = '2026-07-15';
|
||||||
|
const DEFAULT_SCOPE_END = '2026-07-21';
|
||||||
|
const DEFAULT_END_DATE = '2026-09-02';
|
||||||
|
const AWARD_PER_ID = 400;
|
||||||
|
const JULY_START = '2026-07-01';
|
||||||
|
const JULY_END = '2026-07-31';
|
||||||
|
|
||||||
|
function text(value, fallback = '') {
|
||||||
|
const valueText = String(value ?? '').trim();
|
||||||
|
return valueText || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validDate(value, fallback) {
|
||||||
|
const valueText = text(value, fallback);
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/u.test(valueText) && dayjs(valueText).isValid() ? valueText : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(value, offset) {
|
||||||
|
return dayjs(value).add(offset, 'day').format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuery(query = {}) {
|
||||||
|
const scopeStart = validDate(query.scopeStart || query.startDate, DEFAULT_SCOPE_START);
|
||||||
|
const scopeEnd = validDate(query.scopeEnd || query.endDate, DEFAULT_SCOPE_END);
|
||||||
|
const endDate = validDate(query.metricEnd || query.asOfDate, DEFAULT_END_DATE);
|
||||||
|
const metricStart = addDays(endDate, -29);
|
||||||
|
const feeCap = scopeStart >= '2026-07-22' ? 0.88 : 0.9;
|
||||||
|
return {
|
||||||
|
scopeStart,
|
||||||
|
scopeEnd: scopeEnd < scopeStart ? scopeStart : scopeEnd,
|
||||||
|
metricStart,
|
||||||
|
metricEnd: endDate,
|
||||||
|
feeCap,
|
||||||
|
ider: text(query.ider),
|
||||||
|
shopName: text(query.shopName),
|
||||||
|
spu: text(query.spu),
|
||||||
|
goodsId: text(query.goodsId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumber(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRow(row = {}, query) {
|
||||||
|
const shippedDays30d = Number(row.shippedDays30d || 0);
|
||||||
|
const performance30d = normalizeNumber(row.performance30d);
|
||||||
|
const fee30d = normalizeNumber(row.fee30d);
|
||||||
|
const feeRatio30d = performance30d > 0 && fee30d !== null ? fee30d / performance30d : null;
|
||||||
|
const firstCreatedAt = row.firstCreatedAt ? new Date(row.firstCreatedAt) : null;
|
||||||
|
const listingStart = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime())
|
||||||
|
? dayjs(firstCreatedAt).startOf('day')
|
||||||
|
: null;
|
||||||
|
const metricEndDay = dayjs(query.metricEnd).startOf('day');
|
||||||
|
const listedCalendarDays = listingStart && metricEndDay.isValid()
|
||||||
|
? Math.max(1, metricEndDay.diff(listingStart, 'day') + 1)
|
||||||
|
: 30;
|
||||||
|
const metricDenominatorDays = Math.min(30, listedCalendarDays);
|
||||||
|
const sellThroughRate30d = shippedDays30d / metricDenominatorDays;
|
||||||
|
const feeQualified = performance30d > 0 && feeRatio30d !== null && feeRatio30d <= query.feeCap;
|
||||||
|
const bonusQualified = feeQualified && sellThroughRate30d > 0.3;
|
||||||
|
const listingDays = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime())
|
||||||
|
? Math.max(0, dayjs(query.metricEnd).diff(dayjs(firstCreatedAt), 'day'))
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
goodsId: text(row.goodsId),
|
||||||
|
ider: text(row.ider),
|
||||||
|
shopName: text(row.shopName),
|
||||||
|
spu: text(row.spu),
|
||||||
|
goodsName: text(row.goodsName),
|
||||||
|
linkType: text(row.linkType),
|
||||||
|
status: Number(row.onsale || 0) === 1 ? '在售' : '已下架',
|
||||||
|
skuCount: Number(row.skuCount || 0),
|
||||||
|
listingDays,
|
||||||
|
julyPerformance: normalizeNumber(row.julyPerformance),
|
||||||
|
julyFee: normalizeNumber(row.julyFee),
|
||||||
|
julyFeeRatio: normalizeNumber(row.julyPerformance) > 0 ? normalizeNumber(row.julyFee) / normalizeNumber(row.julyPerformance) : null,
|
||||||
|
julyGoodsCount: normalizeNumber(row.julyGoodsCount) || 0,
|
||||||
|
julyShippedDays: Number(row.julyShippedDays || 0),
|
||||||
|
julySellThroughRate: Number(row.julyShippedDays || 0) / 31,
|
||||||
|
performance30d,
|
||||||
|
fee30d,
|
||||||
|
feeRatio30d,
|
||||||
|
goodsCount30d: normalizeNumber(row.goodsCount30d) || 0,
|
||||||
|
shippedDays30d,
|
||||||
|
metricDenominatorDays,
|
||||||
|
sellThroughRate30d,
|
||||||
|
feeQualified,
|
||||||
|
sellThroughQualified: sellThroughRate30d > 0.3,
|
||||||
|
bonusQualified,
|
||||||
|
bonus: bonusQualified ? AWARD_PER_ID : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParams(query) {
|
||||||
|
const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000);
|
||||||
|
return {
|
||||||
|
scopeStart: query.scopeStart,
|
||||||
|
scopeEnd: query.scopeEnd,
|
||||||
|
metricStart: query.metricStart,
|
||||||
|
metricEnd: query.metricEnd,
|
||||||
|
julyStart: JULY_START,
|
||||||
|
julyEnd: JULY_END,
|
||||||
|
ider: query.ider,
|
||||||
|
shopName: query.shopName,
|
||||||
|
spu: query.spu,
|
||||||
|
goodsId: query.goodsId,
|
||||||
|
scopeStartEpochSec: epoch(query.scopeStart),
|
||||||
|
scopeEndEpochSec: epoch(addDays(query.scopeEnd, 1)),
|
||||||
|
scopeStartEpochMs: epoch(query.scopeStart) * 1000,
|
||||||
|
scopeEndEpochMs: epoch(addDays(query.scopeEnd, 1)) * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyMetrics() {
|
||||||
|
return { julyPerformance: 0, julyFee: 0, julyGoodsCount: 0, julyShippedDays: 0, performance30d: 0, fee30d: 0, goodsCount30d: 0, shippedDays30d: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSalesMetrics(goodsIds, query) {
|
||||||
|
const metrics = new Map();
|
||||||
|
const ids = [...new Set((goodsIds || []).map((value) => String(value || '').trim()).filter((value) => /^\d+$/u.test(value)))];
|
||||||
|
for (let offset = 0; offset < ids.length; offset += 1000) {
|
||||||
|
const chunk = ids.slice(offset, offset + 1000);
|
||||||
|
const inList = chunk.map((id) => `'${id}'`).join(',');
|
||||||
|
const rows = await cloudDatabase.query(`
|
||||||
|
SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) AS goodsId,
|
||||||
|
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS julyPerformance,
|
||||||
|
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS julyFee,
|
||||||
|
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS julyGoodsCount,
|
||||||
|
COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS julyShippedDays,
|
||||||
|
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS performance30d,
|
||||||
|
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS fee30d,
|
||||||
|
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS goodsCount30d,
|
||||||
|
COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS shippedDays30d
|
||||||
|
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK)
|
||||||
|
WHERE d.item_id IN (${inList})
|
||||||
|
AND d.stat_date BETWEEN @julyStart AND @metricEnd
|
||||||
|
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多')
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0
|
||||||
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id)))
|
||||||
|
`, { julyStart: JULY_START, julyEnd: JULY_END, metricStart: query.metricStart, metricEnd: query.metricEnd }, { requestTimeout: 180000 });
|
||||||
|
rows.forEach((row) => metrics.set(String(row.goodsId), row));
|
||||||
|
}
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdd_Skus 由外部采集系统写入,存在残缺批次(如 2026-09-02 仅 113 个商品)。
|
||||||
|
// 从 upperBound 往回 7 天内挑最新一个商品数不低于窗口最大值 70% 的批次,避免用到不完整采集。
|
||||||
|
async function pickSkuCaptureTime(upperBound) {
|
||||||
|
const end = dayjs(upperBound).isAfter(dayjs(), 'day') ? dayjs().format('YYYY-MM-DD') : dayjs(upperBound).format('YYYY-MM-DD');
|
||||||
|
const dates = Array.from({ length: 7 }, (_, index) => dayjs(end).subtract(index, 'day').format('YYYY-MM-DD'));
|
||||||
|
const counts = await Promise.all(dates.map((captureTime) => jianSunDb.query(
|
||||||
|
'SELECT COUNT(DISTINCT goodsId) AS goodsCount FROM dbo.pdd_Skus WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE createTime = @captureTime',
|
||||||
|
{ captureTime }, { requestTimeout: 60000 }
|
||||||
|
)));
|
||||||
|
const mapped = dates.map((dateValue, index) => ({ date: dateValue, goodsCount: Number(counts[index]?.[0]?.goodsCount || 0) }));
|
||||||
|
const maxCount = Math.max(...mapped.map((item) => item.goodsCount));
|
||||||
|
if (maxCount <= 0) return null;
|
||||||
|
return (mapped.find((item) => item.goodsCount >= maxCount * 0.7) || mapped.find((item) => item.goodsCount === maxCount)).date;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOpsBIncentiveBoardLive(input = {}) {
|
||||||
|
const query = resolveQuery(input);
|
||||||
|
const livePeriod = findPeriod(await loadPeriods(), query);
|
||||||
|
if (livePeriod) query.feeCap = livePeriod.feeCap;
|
||||||
|
const targetRows = await jianSunDb.query(`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid))) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.ider)))) AS ider, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), tm.shopname)))) AS shopName, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.spu)))) AS spu, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.linkType)))) AS linkType FROM dbo.op_TargetManagement tm WITH (NOLOCK) WHERE tm.dept = N'运营中心B' GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid)))`, {}, { requestTimeout: 60000 });
|
||||||
|
const latestCreateTime = await pickSkuCaptureTime(query.metricEnd);
|
||||||
|
if (!latestCreateTime) throw new Error('pdd_Skus 近7天无可用完整采集批次');
|
||||||
|
const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000);
|
||||||
|
const startSec = epoch(query.scopeStart);
|
||||||
|
const endSec = epoch(addDays(query.scopeEnd, 1));
|
||||||
|
const skuRows = await jianSunDb.query(`SELECT CONVERT(NVARCHAR(100), s.goodsId) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.goodsName)))) AS goodsName, MIN(DATEADD(SECOND, CONVERT(INT, CASE WHEN s.createdAt >= 1000000000000 THEN s.createdAt / 1000 ELSE s.createdAt END), CONVERT(DATETIME2, '1970-01-01'))) AS firstCreatedAt, MAX(CASE WHEN TRY_CONVERT(INT, s.isOnsale) = 1 THEN 1 ELSE 0 END) AS onsale, COUNT_BIG(1) AS skuCount FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE s.createTime = @latestCreateTime AND ((s.createdAt >= @startSec AND s.createdAt < @endSec) OR (s.createdAt >= @startMs AND s.createdAt < @endMs)) GROUP BY s.goodsId`, { latestCreateTime, startSec, endSec, startMs: startSec * 1000, endMs: endSec * 1000 }, { requestTimeout: 180000 });
|
||||||
|
const targetMap = new Map(targetRows.map((row) => [String(row.goodsId), row]));
|
||||||
|
const scopeRows = skuRows.flatMap((row) => {
|
||||||
|
const target = targetMap.get(String(row.goodsId));
|
||||||
|
if (!target) return [];
|
||||||
|
if ((query.ider && target.ider !== query.ider) || (query.shopName && target.shopName !== query.shopName) || (query.spu && !String(target.spu || '').includes(query.spu)) || (query.goodsId && !String(row.goodsId).includes(query.goodsId))) return [];
|
||||||
|
return [{ ...row, ...target }];
|
||||||
|
});
|
||||||
|
const salesMetrics = await loadSalesMetrics(scopeRows.map((row) => row.goodsId), query);
|
||||||
|
const rows = scopeRows.map((row) => ({ ...emptyMetrics(), ...row, ...(salesMetrics.get(String(row.goodsId)) || {}) }));
|
||||||
|
let dwsLatestRows = [];
|
||||||
|
try {
|
||||||
|
dwsLatestRows = await cloudDatabase.query(`SELECT MAX(TRY_CONVERT(DATE, stat_date)) AS latestDate FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] WITH (NOLOCK) WHERE ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), platform))), N'') IN (N'Pinduoduo', N'拼多多')`, {}, { requestTimeout: 60000 });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[OpsBIncentiveBoard] latest DWS date unavailable:', error.message);
|
||||||
|
}
|
||||||
|
const normalizedRows = (rows || []).map((row) => normalizeRow(row, query));
|
||||||
|
const qualifiedRows = normalizedRows.filter((row) => row.bonusQualified);
|
||||||
|
const ownerMap = new Map();
|
||||||
|
normalizedRows.forEach((row) => {
|
||||||
|
const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 };
|
||||||
|
current.goodsIdCount += 1;
|
||||||
|
current.goodsCount30d += row.goodsCount30d;
|
||||||
|
current.shippedDays30d += row.shippedDays30d;
|
||||||
|
current.feeQualifiedCount += row.feeQualified ? 1 : 0;
|
||||||
|
current.bonusQualifiedCount += row.bonusQualified ? 1 : 0;
|
||||||
|
current.bonus += row.bonus;
|
||||||
|
ownerMap.set(row.ider, current);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
rows: normalizedRows,
|
||||||
|
bonusRows: qualifiedRows,
|
||||||
|
ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount),
|
||||||
|
summary: {
|
||||||
|
goodsIdCount: normalizedRows.length,
|
||||||
|
feeQualifiedCount: normalizedRows.filter((row) => row.feeQualified).length,
|
||||||
|
sellThroughQualifiedCount: normalizedRows.filter((row) => row.sellThroughQualified).length,
|
||||||
|
bonusQualifiedCount: qualifiedRows.length,
|
||||||
|
bonus: qualifiedRows.length * AWARD_PER_ID,
|
||||||
|
awardPerId: AWARD_PER_ID,
|
||||||
|
},
|
||||||
|
query: { ...query, julyStart: JULY_START, julyEnd: JULY_END, latestDwsDate: dwsLatestRows?.[0]?.latestDate || null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 口径来源:运营中心B激励方案批次表。pullDate = 拉取(冻结)日期,统计窗口固定为 pullDate 往前 30 天。
|
||||||
|
// 批次配置存 MySQL ops_b_incentive_periods 表(可动态新增批次,无需发版),此列表仅作首次种子与兜底。
|
||||||
|
const DEFAULT_SNAPSHOT_PERIODS = [
|
||||||
|
{ scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 },
|
||||||
|
{ scopeStart: '2026-07-22', scopeEnd: '2026-07-28', pullDate: '2026-09-05', feeCap: 0.88 },
|
||||||
|
{ scopeStart: '2026-07-29', scopeEnd: '2026-08-04', pullDate: '2026-09-12', feeCap: 0.875 },
|
||||||
|
{ scopeStart: '2026-08-05', scopeEnd: '2026-08-11', pullDate: '2026-09-19', feeCap: 0.87 },
|
||||||
|
{ scopeStart: '2026-08-12', scopeEnd: '2026-08-18', pullDate: '2026-09-26', feeCap: 0.865 },
|
||||||
|
{ scopeStart: '2026-08-19', scopeEnd: '2026-08-25', pullDate: '2026-10-03', feeCap: 0.86 },
|
||||||
|
{ scopeStart: '2026-08-26', scopeEnd: '2026-09-01', pullDate: '2026-10-10', feeCap: 0.855 },
|
||||||
|
{ scopeStart: '2026-09-02', scopeEnd: '2026-09-08', pullDate: '2026-10-17', feeCap: 0.85 },
|
||||||
|
{ scopeStart: '2026-09-09', scopeEnd: '2026-09-15', pullDate: '2026-10-24', feeCap: 0.845 },
|
||||||
|
{ scopeStart: '2026-09-16', scopeEnd: '2026-09-22', pullDate: '2026-10-31', feeCap: 0.84 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function makePeriodKey(scopeStart, scopeEnd) {
|
||||||
|
return `ops-b-${scopeStart.replace(/-/g, '')}-${scopeEnd.replace(/-/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let periodsCache = null;
|
||||||
|
let periodsCacheExpiresAt = 0;
|
||||||
|
|
||||||
|
async function loadPeriods() {
|
||||||
|
if (periodsCache && Date.now() < periodsCacheExpiresAt) return periodsCache;
|
||||||
|
let rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end");
|
||||||
|
if (!rows.length) {
|
||||||
|
for (const period of DEFAULT_SNAPSHOT_PERIODS) {
|
||||||
|
await database.local.query(
|
||||||
|
'INSERT IGNORE INTO ops_b_incentive_periods (scope_start, scope_end, pull_date, fee_cap, status) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[period.scopeStart, period.scopeEnd, period.pullDate, period.feeCap, 'active']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end");
|
||||||
|
}
|
||||||
|
periodsCache = rows
|
||||||
|
.filter((row) => String(row.status || 'active') === 'active')
|
||||||
|
.map((row) => ({
|
||||||
|
key: makePeriodKey(dayjs(row.scope_start).format('YYYY-MM-DD'), dayjs(row.scope_end).format('YYYY-MM-DD')),
|
||||||
|
scopeStart: dayjs(row.scope_start).format('YYYY-MM-DD'),
|
||||||
|
scopeEnd: dayjs(row.scope_end).format('YYYY-MM-DD'),
|
||||||
|
pullDate: dayjs(row.pull_date).format('YYYY-MM-DD'),
|
||||||
|
feeCap: Number(row.fee_cap),
|
||||||
|
}));
|
||||||
|
periodsCacheExpiresAt = Date.now() + 60000;
|
||||||
|
return periodsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPeriod(periods, query) {
|
||||||
|
return periods.find((period) => period.scopeStart === query.scopeStart && period.scopeEnd === query.scopeEnd) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSnapshot(period) {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
'SELECT rows_json, summary_json, metric_end, finalized, generated_at FROM ops_b_incentive_snapshots WHERE snapshot_key = ? AND status = ? LIMIT 1',
|
||||||
|
[period.key, 'completed']
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(row.summary_json || '{}');
|
||||||
|
return { rows: JSON.parse(row.rows_json || '[]'), summary: meta.summary || {}, ownerSummary: meta.ownerSummary || [], metricEnd: row.metric_end, finalized: Boolean(row.finalized), generatedAt: row.generated_at };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[OpsBIncentiveSnapshot] invalid snapshot:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeSnapshot(period, query, result, finalized) {
|
||||||
|
await database.local.query(`
|
||||||
|
INSERT INTO ops_b_incentive_snapshots
|
||||||
|
(snapshot_key, scope_start, scope_end, metric_end, status, finalized, rows_json, summary_json, generated_at, completed_at, error_message)
|
||||||
|
VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, NOW(), NOW(), NULL)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
metric_end = VALUES(metric_end), finalized = VALUES(finalized), rows_json = VALUES(rows_json), summary_json = VALUES(summary_json),
|
||||||
|
generated_at = VALUES(generated_at), completed_at = VALUES(completed_at), status = 'completed', error_message = NULL
|
||||||
|
`, [period.key, query.scopeStart, query.scopeEnd, query.metricEnd, finalized ? 1 : 0, JSON.stringify(result.rows || []), JSON.stringify({ summary: result.summary || {}, ownerSummary: result.ownerSummary || [] })]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOpsBIncentiveBoard(input = {}) {
|
||||||
|
const query = resolveQuery(input);
|
||||||
|
const period = findPeriod(await loadPeriods(), query);
|
||||||
|
if (period) query.feeCap = period.feeCap;
|
||||||
|
if (!period) return getOpsBIncentiveBoardLive(input);
|
||||||
|
const existing = await readSnapshot(period);
|
||||||
|
if (!existing) {
|
||||||
|
return {
|
||||||
|
pending: true,
|
||||||
|
rows: [],
|
||||||
|
bonusRows: [],
|
||||||
|
ownerSummary: [],
|
||||||
|
summary: {},
|
||||||
|
query: { ...query, latestDwsDate: null, snapshot: { cached: false, finalized: false, pending: true } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const filteredRows = existing.rows.filter((row) => (
|
||||||
|
(!query.ider || row.ider === query.ider)
|
||||||
|
&& (!query.shopName || row.shopName === query.shopName)
|
||||||
|
&& (!query.spu || String(row.spu || '').includes(query.spu))
|
||||||
|
&& (!query.goodsId || String(row.goodsId || '').includes(query.goodsId))
|
||||||
|
));
|
||||||
|
const ownerMap = new Map();
|
||||||
|
filteredRows.forEach((row) => {
|
||||||
|
const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 };
|
||||||
|
current.goodsIdCount += 1;
|
||||||
|
current.goodsCount30d += Number(row.goodsCount30d || 0);
|
||||||
|
current.shippedDays30d += Number(row.shippedDays30d || 0);
|
||||||
|
current.feeQualifiedCount += row.feeQualified ? 1 : 0;
|
||||||
|
current.bonusQualifiedCount += row.bonusQualified ? 1 : 0;
|
||||||
|
current.bonus += Number(row.bonus || 0);
|
||||||
|
ownerMap.set(row.ider, current);
|
||||||
|
});
|
||||||
|
const bonusRows = filteredRows.filter((row) => row.bonusQualified);
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
rows: filteredRows,
|
||||||
|
bonusRows,
|
||||||
|
ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount),
|
||||||
|
summary: {
|
||||||
|
goodsIdCount: filteredRows.length,
|
||||||
|
feeQualifiedCount: filteredRows.filter((row) => row.feeQualified).length,
|
||||||
|
sellThroughQualifiedCount: filteredRows.filter((row) => row.sellThroughQualified).length,
|
||||||
|
bonusQualifiedCount: bonusRows.length,
|
||||||
|
bonus: bonusRows.reduce((sum, row) => sum + Number(row.bonus || 0), 0),
|
||||||
|
awardPerId: AWARD_PER_ID,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
...query,
|
||||||
|
metricEnd: dayjs(existing.metricEnd).format('YYYY-MM-DD'),
|
||||||
|
latestDwsDate: null,
|
||||||
|
snapshot: { cached: true, finalized: existing.finalized, generatedAt: existing.generatedAt },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOpsBIncentiveSnapshots(source = 'scheduled') {
|
||||||
|
const today = dayjs().format('YYYY-MM-DD');
|
||||||
|
const results = [];
|
||||||
|
for (const period of await loadPeriods()) {
|
||||||
|
const { scopeStart, scopeEnd } = period;
|
||||||
|
// 拉取日次日首刷才定档,确保统计窗口包含拉取日全天数据(数仓滞后约一天)。
|
||||||
|
const finalized = today > period.pullDate;
|
||||||
|
const existing = await readSnapshot(period);
|
||||||
|
if (existing?.finalized) {
|
||||||
|
results.push({ key: period.key, rows: existing.rows.length, finalized: true, skipped: true, source });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = await getOpsBIncentiveBoardLive({ scopeStart, scopeEnd, metricEnd: period.pullDate });
|
||||||
|
await writeSnapshot(period, { scopeStart, scopeEnd, metricEnd: period.pullDate }, result, finalized);
|
||||||
|
results.push({ key: period.key, rows: result.rows.length, finalized, source });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOpsBIncentiveBatches() {
|
||||||
|
const periods = await loadPeriods();
|
||||||
|
const snapshots = await database.local.query(
|
||||||
|
"SELECT snapshot_key, finalized, generated_at, JSON_EXTRACT(summary_json, '$.summary.goodsIdCount') goodsIdCount, JSON_EXTRACT(summary_json, '$.summary.bonusQualifiedCount') bonusQualifiedCount, JSON_EXTRACT(summary_json, '$.summary.bonus') bonus FROM ops_b_incentive_snapshots WHERE status = 'completed'"
|
||||||
|
);
|
||||||
|
const snapshotMap = new Map(snapshots.map((row) => [row.snapshot_key, row]));
|
||||||
|
return periods.map((period, index) => {
|
||||||
|
const snapshot = snapshotMap.get(period.key) || {};
|
||||||
|
return {
|
||||||
|
...period,
|
||||||
|
batchNo: index + 1,
|
||||||
|
finalized: Boolean(snapshot.finalized),
|
||||||
|
generatedAt: snapshot.generated_at || null,
|
||||||
|
goodsIdCount: Number(snapshot.goodsIdCount || 0),
|
||||||
|
bonusQualifiedCount: Number(snapshot.bonusQualifiedCount || 0),
|
||||||
|
bonus: Number(snapshot.bonus || 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOpsBIncentiveDaily(input = {}) {
|
||||||
|
const query = resolveQuery(input);
|
||||||
|
const params = buildParams(query);
|
||||||
|
if (!query.goodsId) return { rows: [], query };
|
||||||
|
const rows = await cloudDatabase.query(`
|
||||||
|
WITH daily AS (
|
||||||
|
SELECT TRY_CONVERT(DATE, d.stat_date) AS statDate,
|
||||||
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.goods_count), 0)) AS goodsCount,
|
||||||
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_performance), 0)) AS realPerformance,
|
||||||
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_conclude_fee), 0)) AS realFee
|
||||||
|
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK)
|
||||||
|
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) = @goodsId
|
||||||
|
AND TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd
|
||||||
|
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多')
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
|
||||||
|
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0
|
||||||
|
GROUP BY TRY_CONVERT(DATE, d.stat_date)
|
||||||
|
) SELECT statDate, goodsCount, CASE WHEN goodsCount > 0 THEN N'是' ELSE N'否' END AS isActive, realPerformance, realFee
|
||||||
|
FROM daily ORDER BY statDate ASC
|
||||||
|
`, params, { requestTimeout: 120000 });
|
||||||
|
return { rows: rows || [], query };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getOpsBIncentiveBoard, getOpsBIncentiveBoardLive, getOpsBIncentiveDaily, getOpsBIncentiveBatches, refreshOpsBIncentiveSnapshots, resolveQuery, pickSkuCaptureTime, AWARD_PER_ID, DEFAULT_SNAPSHOT_PERIODS };
|
||||||
943
backend/src/services/productMonitoringService.js
Normal file
943
backend/src/services/productMonitoringService.js
Normal file
@ -0,0 +1,943 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 商品监控:全公司在售商品的基础档案 + 库存 + 标签分级 + 部门运营指标
|
||||||
|
//
|
||||||
|
// 数据来源(2026-09-11 实测确认):
|
||||||
|
// 主表 业务库 rpt.pm_stockMonitor —— 脑图「商品监控」的原始数据源
|
||||||
|
// (5.6 万行 / 日更 / 含 sevenAvgSale 日销、jsStock 库存、jsUseDays 可用天数、
|
||||||
|
// suggestStock 建议备货、purchase 采购负责人、supplier、warehouse、sku_idStatus)
|
||||||
|
// 补充 业务库 rpt.jst_sku —— 图片、条码(sku_code)、材质、颜色、尺寸
|
||||||
|
// 配置 系统库 product_scale_thresholds / product_profit_levels / product_sabc_mappings
|
||||||
|
// (公司 SABC,由「系统设置 → 商品标签配置」维护)
|
||||||
|
// 配置 系统库 product_tag_rules (部门标签 G-S/G-A/G-B/G-C)
|
||||||
|
// 配置 系统库 department_sku_owners (部门 SKU 负责人)
|
||||||
|
//
|
||||||
|
// 判定口径:
|
||||||
|
// - 公司标签 S/A/B/C/D ← productTagConfigService.resolveProductTags()(业绩 + 日销双命中)
|
||||||
|
// - 部门标签 G-S/G-A/G-B/G-C ← product_tag_rules(tag_scope='department'),左闭右开,升序命中即停
|
||||||
|
//
|
||||||
|
// ⚠️ 已知留空字段(等业务给源表后再填):
|
||||||
|
// - 在途库存 inTransitStock → null,前端显示 `--`
|
||||||
|
// - 部门未发货数量 departmentUnshipped → null,前端显示 `--`
|
||||||
|
// (pm_stockMonitor.outOfStock 语义未确认,本次不用)
|
||||||
|
|
||||||
|
const database = require('../config/database');
|
||||||
|
const cloudDatabase = require('../config/cloudDatabase');
|
||||||
|
const productTagConfigService = require('./productTagConfigService');
|
||||||
|
|
||||||
|
const REPORT_SOURCE_TABLE = 'dbo.pm_stockMonitor';
|
||||||
|
// 销售明细(最新,2026-09-09):按 skuoid 聚合可得业绩/销售额、部门、主渠道、主运营
|
||||||
|
const SALES_DETAIL_TABLE = 'dbo.dws_sn_sales_full_info_di';
|
||||||
|
const SALES_WINDOW_DAYS = 7;
|
||||||
|
// 公司标签 S/A/B/C 的「业绩」阈值(系统设置 → 商品标签配置)是 30 天量级
|
||||||
|
// (如「住宅家具 - 书架」爆款线 48000),因此标签判定用 30 天销售额,日销仍用宽表 sevenAvgSale
|
||||||
|
const SALES_PERFORMANCE_WINDOW_DAYS = 30;
|
||||||
|
|
||||||
|
const SALE_STATUS_OPTIONS = Object.freeze(['销售', '停产', '退市']);
|
||||||
|
const DEFAULT_PAGE_SIZE = 50;
|
||||||
|
const MAX_PAGE_SIZE = 200;
|
||||||
|
|
||||||
|
const DEPARTMENT_TAG_SCOPE = 'department';
|
||||||
|
const DEPARTMENT_TAG_LEVELS = Object.freeze(['G-S', 'G-A', 'G-B', 'G-C']);
|
||||||
|
const DEPARTMENT_METRIC_KEYS = Object.freeze(['sales_qty_7d', 'sales_amount_7d']);
|
||||||
|
|
||||||
|
// ==================== 表结构 ====================
|
||||||
|
|
||||||
|
async function ensureTable(tableName, createSql) {
|
||||||
|
await database.local.query(createSql);
|
||||||
|
const rows = await database.local.query(
|
||||||
|
`SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error(`Table ${tableName} create failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureProductMonitoringSchema() {
|
||||||
|
await ensureTable(
|
||||||
|
'product_tag_rules',
|
||||||
|
`CREATE TABLE IF NOT EXISTS product_tag_rules (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department',
|
||||||
|
tag_level VARCHAR(16) NOT NULL COMMENT '部门标签 G-S/G-A/G-B/G-C',
|
||||||
|
metric_key VARCHAR(64) NOT NULL COMMENT 'sales_qty_7d | sales_amount_7d',
|
||||||
|
threshold_min DECIMAL(18,4) NULL,
|
||||||
|
threshold_max DECIMAL(18,4) NULL,
|
||||||
|
priority INT NOT NULL DEFAULT 0,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key),
|
||||||
|
KEY idx_scope_enabled (tag_scope, enabled),
|
||||||
|
KEY idx_metric (metric_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'`
|
||||||
|
);
|
||||||
|
|
||||||
|
await ensureTable(
|
||||||
|
'department_sku_owners',
|
||||||
|
`CREATE TABLE IF NOT EXISTS department_sku_owners (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
department_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser',
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id),
|
||||||
|
KEY idx_dept (department_id),
|
||||||
|
KEY idx_spu (spu),
|
||||||
|
KEY idx_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 部门标签默认四档(不存在时播种,口径:近 7 天销售件数)
|
||||||
|
const existing = await database.local.query(
|
||||||
|
'SELECT COUNT(*) AS c FROM product_tag_rules WHERE tag_scope = ?',
|
||||||
|
[DEPARTMENT_TAG_SCOPE]
|
||||||
|
);
|
||||||
|
if (Number(existing[0]?.c || 0) === 0) {
|
||||||
|
const seeds = [
|
||||||
|
['G-S', 500, null, 1],
|
||||||
|
['G-A', 200, 500, 2],
|
||||||
|
['G-B', 50, 200, 3],
|
||||||
|
['G-C', 0, 50, 4],
|
||||||
|
];
|
||||||
|
for (const [level, min, max, priority] of seeds) {
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO product_tag_rules
|
||||||
|
(tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||||
|
VALUES (?, ?, 'sales_qty_7d', ?, ?, ?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE tag_scope = tag_scope`,
|
||||||
|
[DEPARTMENT_TAG_SCOPE, level, min, max, priority]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 配置读取 ====================
|
||||||
|
|
||||||
|
async function loadDepartmentTagRules() {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
`SELECT id, tag_level, metric_key, threshold_min, threshold_max, priority, enabled
|
||||||
|
FROM product_tag_rules
|
||||||
|
WHERE tag_scope = ? AND enabled = 1
|
||||||
|
ORDER BY priority ASC, id ASC`,
|
||||||
|
[DEPARTMENT_TAG_SCOPE]
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
tagLevel: row.tag_level,
|
||||||
|
metricKey: row.metric_key,
|
||||||
|
thresholdMin: row.threshold_min === null ? null : Number(row.threshold_min),
|
||||||
|
thresholdMax: row.threshold_max === null ? null : Number(row.threshold_max),
|
||||||
|
priority: Number(row.priority || 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公司 SKU 负责人:取自 rpt.V_caterAndOper.oper,**SPU 粒度**(方案第 12 条已确认接受该粒度)
|
||||||
|
async function loadCompanyOwnerIndex() {
|
||||||
|
try {
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT spu, oper FROM dbo.V_caterAndOper WHERE oper IS NOT NULL AND oper <> N''`
|
||||||
|
);
|
||||||
|
const index = new Map();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const spu = String(row.spu || '').trim();
|
||||||
|
const oper = String(row.oper || '').trim();
|
||||||
|
if (spu && oper && !index.has(spu)) index.set(spu, oper);
|
||||||
|
});
|
||||||
|
return index;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ProductMonitoring] 加载公司SKU负责人失败,该列将留空:', error.message);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDepartmentSkuOwners() {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
`SELECT o.department_id, o.owner_type, o.user_id, o.spu, u.name AS user_name, d.name AS department_name
|
||||||
|
FROM department_sku_owners o
|
||||||
|
LEFT JOIN users u ON u.id = o.user_id
|
||||||
|
LEFT JOIN departments d ON d.id = o.department_id`
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
departmentId: row.department_id,
|
||||||
|
departmentName: row.department_name,
|
||||||
|
ownerType: row.owner_type,
|
||||||
|
userId: row.user_id,
|
||||||
|
ownerName: row.user_name,
|
||||||
|
spu: row.spu,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部门标签判定:左闭右开 [min, max),按 priority 升序命中即停;未命中返回 null
|
||||||
|
function matchDepartmentTag(rules, salesQty7d) {
|
||||||
|
if (!rules.length) return null;
|
||||||
|
// ⚠️ 无销售记录(null)不等于「销量 0」:Number(null) 是 0,会把无数据行误判成最低档
|
||||||
|
if (salesQty7d === null || salesQty7d === undefined || salesQty7d === '') return null;
|
||||||
|
const value = Number(salesQty7d);
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
for (const rule of rules) {
|
||||||
|
const geMin = rule.thresholdMin === null || value >= rule.thresholdMin;
|
||||||
|
const ltMax = rule.thresholdMax === null || value < rule.thresholdMax;
|
||||||
|
if (geMin && ltMax) return rule.tagLevel;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 业务库查询 ====================
|
||||||
|
|
||||||
|
// 可排序字段(排序在内存完成,直接用行字段名)
|
||||||
|
const DERIVED_SORT_FIELDS = new Set([
|
||||||
|
'skuId', 'spu', 'category', 'productName', 'supplier', 'warehouse', 'purchaser', 'saleStatus',
|
||||||
|
'companyOwner', 'companySales7d', 'availableStock', 'stockDays', 'suggestedReplenish',
|
||||||
|
'companyTag', 'department', 'departmentTag', 'departmentOwner',
|
||||||
|
'performance7d', 'performance30d', 'departmentSales7d', 'mainSalesChannel', 'mainSalesOperator',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function getLatestReportDate() {
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT MAX(date) AS latestDate FROM ${REPORT_SOURCE_TABLE}`
|
||||||
|
);
|
||||||
|
const latest = rows[0]?.latestDate;
|
||||||
|
if (!latest) {
|
||||||
|
throw new Error('业务库 pm_stockMonitor 无数据,无法确定最新统计日期');
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全量基础行:pm_stockMonitor 主表 + jst_sku 补图片/条码/材质/颜色/尺寸
|
||||||
|
//
|
||||||
|
// ⚠️ 只按统计日过滤、不带任何业务条件——结果会被 buildDataset 缓存,
|
||||||
|
// 业务条件(状态/品类/SPU/SKU/供应商…)与标签过滤都在内存里做,见 applyBaseFilters。
|
||||||
|
async function queryAllReportRows(latestDate) {
|
||||||
|
const params = { latestDate };
|
||||||
|
|
||||||
|
const dataSql = `
|
||||||
|
SELECT
|
||||||
|
m.sku_id AS skuId,
|
||||||
|
m.sku AS skuCode,
|
||||||
|
m.spu AS spu,
|
||||||
|
m.skuName AS productName,
|
||||||
|
m.category AS category,
|
||||||
|
m.supplier AS supplier,
|
||||||
|
m.warehouse AS warehouse,
|
||||||
|
m.purchase AS purchaser,
|
||||||
|
m.area AS area,
|
||||||
|
m.cooperation AS cooperation,
|
||||||
|
m.brand AS brand,
|
||||||
|
m.sku_idStatus AS saleStatus,
|
||||||
|
m.jsStock AS availableStock,
|
||||||
|
m.jsUseDays AS stockDays,
|
||||||
|
m.sevenAvgSale AS companySales7d,
|
||||||
|
m.suggestStock AS suggestedReplenish,
|
||||||
|
m.produceCycle AS produceCycle,
|
||||||
|
m.safetyDays AS safetyDays,
|
||||||
|
s.sku_code AS barcode,
|
||||||
|
s.color AS colorRaw,
|
||||||
|
s.properties_value AS sizeRaw,
|
||||||
|
COALESCE(NULLIF(s.other_10, N''), NULLIF(s.other_2, N'')) AS material,
|
||||||
|
COALESCE(s.pic_big, s.pic) AS imageUrl
|
||||||
|
FROM ${REPORT_SOURCE_TABLE} m
|
||||||
|
LEFT JOIN dbo.jst_sku s ON s.sku_id = m.sku_id
|
||||||
|
WHERE m.date = @latestDate
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows = await cloudDatabase.query(dataSql, params, { requestTimeout: 180000 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rows.map((row) => ({
|
||||||
|
skuId: row.skuId,
|
||||||
|
skuCode: row.skuCode || row.skuId,
|
||||||
|
spu: row.spu,
|
||||||
|
barcode: row.barcode || row.skuId,
|
||||||
|
imageUrl: row.imageUrl || null,
|
||||||
|
category: row.category,
|
||||||
|
productName: row.productName,
|
||||||
|
material: row.material || null,
|
||||||
|
color: row.colorRaw || null,
|
||||||
|
size: row.sizeRaw || null,
|
||||||
|
saleStatus: row.saleStatus,
|
||||||
|
supplier: row.supplier || null,
|
||||||
|
warehouse: row.warehouse || null,
|
||||||
|
purchaser: row.purchaser || null,
|
||||||
|
area: row.area || null,
|
||||||
|
cooperation: row.cooperation || null,
|
||||||
|
brand: row.brand || null,
|
||||||
|
companySales7d: toNumberOrNull(row.companySales7d),
|
||||||
|
availableStock: toNumberOrNull(row.availableStock),
|
||||||
|
inTransitStock: null, // 待业务提供源表
|
||||||
|
stockDays: toNumberOrNull(row.stockDays),
|
||||||
|
suggestedReplenish: toNumberOrNull(row.suggestedReplenish),
|
||||||
|
produceCycle: toNumberOrNull(row.produceCycle),
|
||||||
|
safetyDays: toNumberOrNull(row.safetyDays),
|
||||||
|
departmentUnshipped: null, // 待业务确认 outOfStock 语义
|
||||||
|
})),
|
||||||
|
latestDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumberOrNull(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 汇总统计 ====================
|
||||||
|
|
||||||
|
// 汇总基于「过滤后的最终结果」统计,保证与列表口径一致
|
||||||
|
function buildSummary(rows = []) {
|
||||||
|
let outOfStockCount = 0;
|
||||||
|
let lowStockDaysCount = 0;
|
||||||
|
let totalSuggestedReplenish = 0;
|
||||||
|
let needReplenishCount = 0;
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const stock = row.availableStock === null ? 0 : Number(row.availableStock);
|
||||||
|
const days = row.stockDays === null ? 0 : Number(row.stockDays);
|
||||||
|
if (stock <= 0) outOfStockCount += 1;
|
||||||
|
else if (days < 7) lowStockDaysCount += 1;
|
||||||
|
|
||||||
|
// ⚠️ suggestStock 是有符号的净需求(库存过剩为负数),合计只累计正值,避免负负相加失真
|
||||||
|
const replenish = row.suggestedReplenish === null ? 0 : Number(row.suggestedReplenish);
|
||||||
|
if (replenish > 0) {
|
||||||
|
totalSuggestedReplenish += replenish;
|
||||||
|
needReplenishCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
skuCount: rows.length,
|
||||||
|
outOfStockCount,
|
||||||
|
lowStockDaysCount,
|
||||||
|
totalSuggestedReplenish: Math.round(totalSuggestedReplenish),
|
||||||
|
needReplenishCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 销售明细聚合(业绩 / 部门 / 主渠道 / 主运营) ====================
|
||||||
|
|
||||||
|
// 取销售明细表的最新统计日
|
||||||
|
async function getLatestSalesDate() {
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT MAX(stat_date) AS latestDate FROM ${SALES_DETAIL_TABLE}`
|
||||||
|
);
|
||||||
|
return rows[0]?.latestDate || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 skuoid 聚合近 7 天:销售额(业绩口径)、部门、主销售渠道、主销售运营
|
||||||
|
async function loadSalesAggregatesBySku() {
|
||||||
|
try {
|
||||||
|
const latestDate = await getLatestSalesDate();
|
||||||
|
if (!latestDate) return new Map();
|
||||||
|
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT
|
||||||
|
d.skuoid AS skuoid,
|
||||||
|
SUM(d.sales_amount) AS salesAmount30d,
|
||||||
|
SUM(d.goods_count) AS salesQty30d,
|
||||||
|
SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.sales_amount ELSE 0 END) AS salesAmount7d,
|
||||||
|
SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.goods_count ELSE 0 END) AS salesQty7d,
|
||||||
|
MAX(d.profit_center) AS department,
|
||||||
|
MAX(d.shop_dept) AS shopDept
|
||||||
|
FROM ${SALES_DETAIL_TABLE} d
|
||||||
|
WHERE d.stat_date >= DATEADD(day, -@perfDays, @latestDate)
|
||||||
|
AND d.skuoid IS NOT NULL AND d.skuoid <> N''
|
||||||
|
GROUP BY d.skuoid`,
|
||||||
|
{ days: SALES_WINDOW_DAYS, perfDays: SALES_PERFORMANCE_WINDOW_DAYS, latestDate },
|
||||||
|
{ requestTimeout: 180000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const index = new Map();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const key = String(row.skuoid || '').trim();
|
||||||
|
if (!key) return;
|
||||||
|
index.set(key, {
|
||||||
|
salesAmount7d: toNumberOrNull(row.salesAmount7d),
|
||||||
|
salesQty7d: toNumberOrNull(row.salesQty7d),
|
||||||
|
salesAmount30d: toNumberOrNull(row.salesAmount30d),
|
||||||
|
salesQty30d: toNumberOrNull(row.salesQty30d),
|
||||||
|
department: row.department || row.shopDept || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return index;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ProductMonitoring] 加载销售明细聚合失败,业绩/部门列将留空:', error.message);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主销售渠道 / 主销售运营:近 7 天销售额最高的渠道与运营(脑图口径)
|
||||||
|
async function loadMainChannelAndOperator(skuoids) {
|
||||||
|
const result = new Map();
|
||||||
|
if (!Array.isArray(skuoids) || !skuoids.length) return result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latestDate = await getLatestSalesDate();
|
||||||
|
if (!latestDate) return result;
|
||||||
|
|
||||||
|
// 仅对本页 SKU 查询,避免全表聚合
|
||||||
|
const list = [...new Set(skuoids.filter(Boolean))].slice(0, MAX_PAGE_SIZE);
|
||||||
|
const params = { days: SALES_WINDOW_DAYS, latestDate };
|
||||||
|
const placeholders = list.map((sku, i) => {
|
||||||
|
params[`sku${i}`] = sku;
|
||||||
|
return `@sku${i}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT d.skuoid AS skuoid, d.shop_name AS shopName, d.ider AS operator,
|
||||||
|
SUM(d.sales_amount) AS amt
|
||||||
|
FROM ${SALES_DETAIL_TABLE} d
|
||||||
|
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||||
|
AND d.skuoid IN (${placeholders.join(',')})
|
||||||
|
GROUP BY d.skuoid, d.shop_name, d.ider`,
|
||||||
|
params,
|
||||||
|
{ requestTimeout: 120000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const byChannel = new Map();
|
||||||
|
const byOperator = new Map();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const sku = String(row.skuoid || '').trim();
|
||||||
|
const amt = toNumberOrNull(row.amt) || 0;
|
||||||
|
if (!sku) return;
|
||||||
|
if (row.shopName) {
|
||||||
|
const key = `${sku}||${row.shopName}`;
|
||||||
|
const prev = byChannel.get(key) || 0;
|
||||||
|
byChannel.set(key, prev + amt);
|
||||||
|
}
|
||||||
|
if (row.operator) {
|
||||||
|
const key = `${sku}||${row.operator}`;
|
||||||
|
const prev = byOperator.get(key) || 0;
|
||||||
|
byOperator.set(key, prev + amt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickTop = (map, targetMap) => {
|
||||||
|
map.forEach((amt, key) => {
|
||||||
|
const [sku, name] = key.split('||');
|
||||||
|
const prev = targetMap.get(sku);
|
||||||
|
if (!prev || amt > prev.amt) targetMap.set(sku, { name, amt });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const topChannel = new Map();
|
||||||
|
const topOperator = new Map();
|
||||||
|
pickTop(byChannel, topChannel);
|
||||||
|
pickTop(byOperator, topOperator);
|
||||||
|
|
||||||
|
list.forEach((sku) => {
|
||||||
|
result.set(sku, {
|
||||||
|
mainSalesChannel: topChannel.get(sku)?.name || null,
|
||||||
|
mainSalesOperator: topOperator.get(sku)?.name || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ProductMonitoring] 加载主渠道/主运营失败,相关列将留空:', error.message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行下钻:该 SKU 归属的运营人员列表 + 各自日销(近 7 天日均发货件数)
|
||||||
|
async function getProductOperators(skuoid) {
|
||||||
|
const sku = String(skuoid || '').trim();
|
||||||
|
if (!sku) return [];
|
||||||
|
|
||||||
|
const latestDate = await getLatestSalesDate();
|
||||||
|
if (!latestDate) return [];
|
||||||
|
|
||||||
|
const rows = await cloudDatabase.query(
|
||||||
|
`SELECT d.ider AS operator,
|
||||||
|
SUM(d.goods_count) AS qty7d,
|
||||||
|
SUM(d.sales_amount) AS amt7d,
|
||||||
|
MAX(d.shop_name) AS sampleShop
|
||||||
|
FROM ${SALES_DETAIL_TABLE} d
|
||||||
|
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||||
|
AND d.skuoid = @sku
|
||||||
|
AND d.ider IS NOT NULL AND d.ider <> N''
|
||||||
|
GROUP BY d.ider
|
||||||
|
ORDER BY SUM(d.goods_count) DESC`,
|
||||||
|
{ days: SALES_WINDOW_DAYS, latestDate, sku },
|
||||||
|
{ requestTimeout: 120000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 每位运营的主渠道
|
||||||
|
const channelRows = await cloudDatabase.query(
|
||||||
|
`SELECT d.ider AS operator, d.shop_name AS shopName, SUM(d.sales_amount) AS amt
|
||||||
|
FROM ${SALES_DETAIL_TABLE} d
|
||||||
|
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||||
|
AND d.skuoid = @sku AND d.ider IS NOT NULL AND d.ider <> N''
|
||||||
|
GROUP BY d.ider, d.shop_name`,
|
||||||
|
{ days: SALES_WINDOW_DAYS, latestDate, sku },
|
||||||
|
{ requestTimeout: 120000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const topChannel = new Map();
|
||||||
|
channelRows.forEach((row) => {
|
||||||
|
const amt = toNumberOrNull(row.amt) || 0;
|
||||||
|
const prev = topChannel.get(row.operator);
|
||||||
|
if (!prev || amt > prev.amt) topChannel.set(row.operator, { name: row.shopName, amt });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
const qty7d = toNumberOrNull(row.qty7d) || 0;
|
||||||
|
return {
|
||||||
|
operator: row.operator,
|
||||||
|
dailySales: Number((qty7d / SALES_WINDOW_DAYS).toFixed(2)),
|
||||||
|
salesQty7d: qty7d,
|
||||||
|
salesAmount7d: toNumberOrNull(row.amt7d),
|
||||||
|
mainChannel: topChannel.get(row.operator)?.name || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 筛选选项 ====================
|
||||||
|
|
||||||
|
async function queryFilterOptions(latestDate) {
|
||||||
|
const [dimensionRows, statusRows] = await Promise.all([
|
||||||
|
cloudDatabase.query(
|
||||||
|
`SELECT DISTINCT TOP 300 m.category AS category, m.supplier AS supplier, m.warehouse AS warehouse,
|
||||||
|
m.purchase AS purchaser
|
||||||
|
FROM ${REPORT_SOURCE_TABLE} m
|
||||||
|
WHERE m.date = @latestDate`,
|
||||||
|
{ latestDate }
|
||||||
|
),
|
||||||
|
cloudDatabase.query(
|
||||||
|
`SELECT m.sku_idStatus AS status, COUNT(*) AS c
|
||||||
|
FROM ${REPORT_SOURCE_TABLE} m
|
||||||
|
WHERE m.date = @latestDate
|
||||||
|
GROUP BY m.sku_idStatus`,
|
||||||
|
{ latestDate }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const uniq = (key) => [...new Set(dimensionRows.map((r) => r[key]).filter(Boolean))].sort((a, b) => String(a).localeCompare(String(b), 'zh-CN'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories: uniq('category'),
|
||||||
|
suppliers: uniq('supplier'),
|
||||||
|
warehouses: uniq('warehouse'),
|
||||||
|
purchasers: uniq('purchaser'),
|
||||||
|
saleStatuses: SALE_STATUS_OPTIONS.map((status) => ({
|
||||||
|
status,
|
||||||
|
count: Number(statusRows.find((r) => r.status === status)?.c || 0),
|
||||||
|
})),
|
||||||
|
latestDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据集缓存 ====================
|
||||||
|
//
|
||||||
|
// 实测:mssql 驱动逐行开销约 0.4ms/行,取全量 5.6 万行要 20~30 秒,不能每次请求都跑。
|
||||||
|
// 而「公司标签 / 部门标签 / 部门负责人」依赖系统库配置,无法下推到 SQL,必须拿到全量行再算。
|
||||||
|
// 因此:按统计日缓存一份「已完成派生」的全量数据集,后续请求只在内存里过滤 + 排序 + 分页。
|
||||||
|
// 缓存失效时机:① 统计日变化(日更)② 保存部门标签/负责人配置 ③ 前端带 refresh=1。
|
||||||
|
|
||||||
|
const DATASET_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
let datasetCache = null;
|
||||||
|
let datasetBuilding = null;
|
||||||
|
|
||||||
|
const dateKeyOf = (value) => (value ? new Date(value).toISOString().slice(0, 10) : '');
|
||||||
|
|
||||||
|
async function buildDataset(latestDate) {
|
||||||
|
const key = dateKeyOf(latestDate);
|
||||||
|
const [baseResult, departmentRules, departmentOwners, companyTagConfig, companyOwnerIndex, salesAggIndex] = await Promise.all([
|
||||||
|
queryAllReportRows(latestDate),
|
||||||
|
loadDepartmentTagRules(),
|
||||||
|
loadDepartmentSkuOwners(),
|
||||||
|
loadCompanyTagResolver(),
|
||||||
|
loadCompanyOwnerIndex(),
|
||||||
|
loadSalesAggregatesBySku(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows = baseResult.rows.map((row) => {
|
||||||
|
const salesAgg = salesAggIndex.get(String(row.skuCode || row.skuId).trim()) || null;
|
||||||
|
|
||||||
|
// 业绩口径 = 近 30 天销售额(对齐 SABC 阈值量级),近 7 天销售额单独留一列展示
|
||||||
|
const performance30d = salesAgg?.salesAmount30d ?? null;
|
||||||
|
const performance7d = salesAgg?.salesAmount7d ?? null;
|
||||||
|
const departmentSalesQty7d = salesAgg?.salesQty7d ?? null;
|
||||||
|
|
||||||
|
const companyTag = resolveCompanyTag(companyTagConfig, row.category, performance30d, row.companySales7d);
|
||||||
|
const departmentTag = matchDepartmentTag(departmentRules, departmentSalesQty7d);
|
||||||
|
const department = salesAgg?.department || null;
|
||||||
|
const owner = resolveDepartmentOwner(departmentOwners, { ...row, department });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
companyOwner: companyOwnerIndex.get(String(row.spu || '').trim()) || null,
|
||||||
|
department,
|
||||||
|
// 业绩(销售额)——30 天用于 SABC 判定,7 天用于展示
|
||||||
|
performance7d,
|
||||||
|
performance30d,
|
||||||
|
// 部门近7日日销(件数 / 7)
|
||||||
|
departmentSales7d: departmentSalesQty7d === null
|
||||||
|
? null
|
||||||
|
: Number((departmentSalesQty7d / SALES_WINDOW_DAYS).toFixed(2)),
|
||||||
|
departmentSalesQty7d,
|
||||||
|
// 建议备货:宽表 suggestStock 为有符号净需求,负数代表库存过剩,展示时取 0
|
||||||
|
suggestedReplenish: row.suggestedReplenish === null ? null : Math.max(0, row.suggestedReplenish),
|
||||||
|
companyTag,
|
||||||
|
departmentTag,
|
||||||
|
departmentOwner: owner?.ownerName || null,
|
||||||
|
mainSalesChannel: null,
|
||||||
|
mainSalesOperator: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { key, builtAt: Date.now(), rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDataset(force = false) {
|
||||||
|
const latestDate = await getLatestReportDate();
|
||||||
|
const key = dateKeyOf(latestDate);
|
||||||
|
|
||||||
|
const fresh = datasetCache && datasetCache.key === key
|
||||||
|
&& (Date.now() - datasetCache.builtAt) < DATASET_CACHE_TTL_MS;
|
||||||
|
if (!force && fresh) return { dataset: datasetCache, latestDate };
|
||||||
|
|
||||||
|
// 并发去重:同一统计日的构建只跑一次
|
||||||
|
if (datasetBuilding && datasetBuilding.key === key) {
|
||||||
|
return { dataset: await datasetBuilding.promise, latestDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = buildDataset(latestDate)
|
||||||
|
.then((dataset) => {
|
||||||
|
datasetCache = dataset;
|
||||||
|
console.log(`[ProductMonitoring] 数据集构建完成:${key},共 ${dataset.rows.length} 行`);
|
||||||
|
return dataset;
|
||||||
|
})
|
||||||
|
.finally(() => { datasetBuilding = null; });
|
||||||
|
|
||||||
|
datasetBuilding = { key, promise };
|
||||||
|
return { dataset: await promise, latestDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateDataset() {
|
||||||
|
datasetCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动预热:不阻塞应用启动,构建失败只记日志(首次请求会重试)
|
||||||
|
async function warmProductMonitoringDataset() {
|
||||||
|
try {
|
||||||
|
await getDataset();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ProductMonitoring] 数据集预热失败,将在首次请求时重试:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基础字段过滤(原 SQL WHERE 的等价物,改为内存匹配)
|
||||||
|
function applyBaseFilters(rows = [], query = {}) {
|
||||||
|
const like = (value, keyword) => String(value ?? '').toLowerCase()
|
||||||
|
.includes(String(keyword ?? '').trim().toLowerCase());
|
||||||
|
|
||||||
|
const keywordFields = [
|
||||||
|
['category', 'category'],
|
||||||
|
['spu', 'spu'],
|
||||||
|
['sku', 'skuId'],
|
||||||
|
['productName', 'productName'],
|
||||||
|
['supplier', 'supplier'],
|
||||||
|
['warehouse', 'warehouse'],
|
||||||
|
['purchaser', 'purchaser'],
|
||||||
|
].map(([param, field]) => [field, String(query[param] ?? '').trim()])
|
||||||
|
.filter(([, keyword]) => keyword);
|
||||||
|
|
||||||
|
const saleStatus = String(query.saleStatus ?? '').trim();
|
||||||
|
const includeAll = String(query.includeAll ?? '') === '1';
|
||||||
|
const status = saleStatus || (includeAll ? '' : '销售');
|
||||||
|
|
||||||
|
if (!keywordFields.length && !status) return rows;
|
||||||
|
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (status && String(row.saleStatus || '') !== status) return false;
|
||||||
|
return keywordFields.every(([field, keyword]) => like(row[field], keyword));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主入口 ====================
|
||||||
|
|
||||||
|
async function getMonitoringList(query = {}) {
|
||||||
|
const forceRefresh = String(query.refresh ?? '') === '1';
|
||||||
|
const { dataset, latestDate } = await getDataset(forceRefresh);
|
||||||
|
|
||||||
|
// 1) 基础字段过滤 → 2) 派生字段过滤 → 3) 排序 → 4) 分页
|
||||||
|
const filtered = applyDerivedFilters(applyBaseFilters(dataset.rows, query), query);
|
||||||
|
const sorted = sortRows(filtered, query);
|
||||||
|
|
||||||
|
const total = sorted.length;
|
||||||
|
const page = Math.max(1, Number(query.page) || 1);
|
||||||
|
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(query.pageSize) || DEFAULT_PAGE_SIZE));
|
||||||
|
const pageRows = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
|
// 主渠道 / 主运营只对本页 SKU 查询,控制代价
|
||||||
|
const mainChannelIndex = await loadMainChannelAndOperator(pageRows.map((row) => row.skuCode || row.skuId));
|
||||||
|
const rows = pageRows.map((row) => {
|
||||||
|
const main = mainChannelIndex.get(String(row.skuCode || row.skuId).trim()) || {};
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
mainSalesChannel: main.mainSalesChannel || null,
|
||||||
|
mainSalesOperator: main.mainSalesOperator || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
latestDate,
|
||||||
|
cachedAt: dataset.builtAt,
|
||||||
|
summary: buildSummary(sorted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 派生字段过滤:多选用逗号分隔(companyTag=S,A),文本用包含匹配
|
||||||
|
function applyDerivedFilters(rows = [], query = {}) {
|
||||||
|
const parseList = (value) => String(value ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const companyTags = parseList(query.companyTag);
|
||||||
|
const departmentTags = parseList(query.departmentTag);
|
||||||
|
const departmentKeyword = String(query.department ?? '').trim();
|
||||||
|
const departmentOwnerKeyword = String(query.departmentOwner ?? '').trim().toLowerCase();
|
||||||
|
const companyOwnerKeyword = String(query.companyOwner ?? '').trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!companyTags.length && !departmentTags.length && !departmentKeyword
|
||||||
|
&& !departmentOwnerKeyword && !companyOwnerKeyword) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (companyTags.length && !companyTags.includes(String(row.companyTag || ''))) return false;
|
||||||
|
if (departmentTags.length && !departmentTags.includes(String(row.departmentTag || ''))) return false;
|
||||||
|
if (departmentKeyword && String(row.department || '') !== departmentKeyword) return false;
|
||||||
|
if (departmentOwnerKeyword && !String(row.departmentOwner || '').toLowerCase().includes(departmentOwnerKeyword)) return false;
|
||||||
|
if (companyOwnerKeyword && !String(row.companyOwner || '').toLowerCase().includes(companyOwnerKeyword)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRows(rows = [], query = {}) {
|
||||||
|
const field = DERIVED_SORT_FIELDS.has(query.sortField) ? query.sortField : 'companySales7d';
|
||||||
|
const desc = String(query.sortOrder || '').toUpperCase() !== 'ASC';
|
||||||
|
const direction = desc ? -1 : 1;
|
||||||
|
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const av = a[field];
|
||||||
|
const bv = b[field];
|
||||||
|
// 空值恒排末尾,避免 null 参与数值/字符串比较造成的抖动
|
||||||
|
if (av === null || av === undefined || av === '') {
|
||||||
|
return bv === null || bv === undefined || bv === '' ? String(a.skuId).localeCompare(String(b.skuId)) : 1;
|
||||||
|
}
|
||||||
|
if (bv === null || bv === undefined || bv === '') return -1;
|
||||||
|
|
||||||
|
const an = Number(av);
|
||||||
|
const bn = Number(bv);
|
||||||
|
const bothNumeric = Number.isFinite(an) && Number.isFinite(bn);
|
||||||
|
const delta = bothNumeric
|
||||||
|
? an - bn
|
||||||
|
: String(av).localeCompare(String(bv), 'zh-CN');
|
||||||
|
|
||||||
|
return (delta || String(a.skuId).localeCompare(String(b.skuId))) * direction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公司 SABC:复用「商品标签配置」的阈值,按 (品类, 业绩, 日销) 判定规模标签
|
||||||
|
async function loadCompanyTagResolver() {
|
||||||
|
try {
|
||||||
|
const config = await productTagConfigService.getProductTagConfig();
|
||||||
|
const thresholds = config?.scaleThresholds || [];
|
||||||
|
const byCategory = new Map();
|
||||||
|
thresholds.forEach((t) => {
|
||||||
|
if (t.enabled === false) return;
|
||||||
|
const key = String(t.category || '');
|
||||||
|
if (!byCategory.has(key)) byCategory.set(key, []);
|
||||||
|
byCategory.get(key).push(t);
|
||||||
|
});
|
||||||
|
return { byCategory, mappings: config?.sabcMappings || [] };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ProductMonitoring] 加载公司标签配置失败,标签列将留空:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判定:业绩与日销同时命中(左闭右开),按 priority 升序命中即停
|
||||||
|
function resolveCompanyTag(config, category, performance, sales) {
|
||||||
|
if (!config) return null;
|
||||||
|
const ofCategory = config.byCategory.get(String(category || ''));
|
||||||
|
if (!ofCategory || !ofCategory.length) return null;
|
||||||
|
// 业绩缺失时按 0 处理(区间在业务上必然从 0 起),避免整列留空
|
||||||
|
const perf = performance === null || performance === undefined ? 0 : Number(performance);
|
||||||
|
const sale = sales === null || sales === undefined ? 0 : Number(sales);
|
||||||
|
const scaleTag = productTagConfigService.matchScaleTagOfCategory(ofCategory, perf, sale);
|
||||||
|
if (!scaleTag || scaleTag === '滞销款') return 'D';
|
||||||
|
// 利润等级当前无上游数据源,同规模标签下取最优档作为展示值
|
||||||
|
const hit = config.mappings.find((m) => m.scaleTag === scaleTag && m.enabled !== false);
|
||||||
|
return hit?.sabcTag || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部门负责人:优先「部门 + SPU」精确配置 → 「部门 + SPU 为空」的部门级默认配置
|
||||||
|
function resolveDepartmentOwner(owners, row) {
|
||||||
|
if (!owners.length) return null;
|
||||||
|
const deptName = String(row.department || '').trim();
|
||||||
|
const spu = String(row.spu || '').trim();
|
||||||
|
if (!deptName) return null;
|
||||||
|
|
||||||
|
const ofDept = owners.filter((o) => o.ownerType === 'sku_owner' && o.departmentName === deptName);
|
||||||
|
if (!ofDept.length) return null;
|
||||||
|
|
||||||
|
const bySpu = spu ? ofDept.find((o) => String(o.spu || '').trim() === spu) : null;
|
||||||
|
if (bySpu) return bySpu;
|
||||||
|
return ofDept.find((o) => !o.spu) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMonitoringFilters() {
|
||||||
|
const latestDate = await getLatestReportDate();
|
||||||
|
const [options, departmentRules] = await Promise.all([
|
||||||
|
queryFilterOptions(latestDate),
|
||||||
|
loadDepartmentTagRules(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
companyTags: ['S', 'A', 'B', 'C', 'D'],
|
||||||
|
departmentTags: DEPARTMENT_TAG_LEVELS,
|
||||||
|
departmentTagRules: departmentRules,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 部门标签配置(系统设置用) ====================
|
||||||
|
|
||||||
|
async function getDepartmentTagConfig() {
|
||||||
|
const [rules, owners, departments, users] = await Promise.all([
|
||||||
|
loadDepartmentTagRules(),
|
||||||
|
loadDepartmentSkuOwners(),
|
||||||
|
database.local.query('SELECT id, name, code, status FROM departments WHERE status = ? ORDER BY sort ASC, id ASC', ['active']),
|
||||||
|
database.local.query('SELECT id, name, username, role FROM users ORDER BY id ASC LIMIT 1000'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules: DEPARTMENT_TAG_LEVELS.map((level) => {
|
||||||
|
const hit = rules.find((r) => r.tagLevel === level);
|
||||||
|
return {
|
||||||
|
tagLevel: level,
|
||||||
|
metricKey: hit?.metricKey || 'sales_qty_7d',
|
||||||
|
thresholdMin: hit?.thresholdMin ?? null,
|
||||||
|
thresholdMax: hit?.thresholdMax ?? null,
|
||||||
|
priority: hit?.priority ?? (DEPARTMENT_TAG_LEVELS.indexOf(level) + 1),
|
||||||
|
enabled: Boolean(hit),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
metricKeys: [...DEPARTMENT_METRIC_KEYS],
|
||||||
|
owners,
|
||||||
|
departments: departments.map((d) => ({ id: d.id, name: d.name, code: d.code })),
|
||||||
|
users: users.map((u) => ({ id: u.id, name: u.name, username: u.username, role: u.role })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDepartmentTagConfig(payload = {}) {
|
||||||
|
// 标签/负责人配置变了,已缓存的数据集里的派生字段立即失效
|
||||||
|
invalidateDataset();
|
||||||
|
|
||||||
|
const rules = Array.isArray(payload.rules) ? payload.rules : null;
|
||||||
|
const owners = Array.isArray(payload.owners) ? payload.owners : null;
|
||||||
|
|
||||||
|
if (rules) {
|
||||||
|
validateDepartmentRules(rules);
|
||||||
|
await database.local.query('DELETE FROM product_tag_rules WHERE tag_scope = ?', [DEPARTMENT_TAG_SCOPE]);
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.enabled === false) continue;
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO product_tag_rules
|
||||||
|
(tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
metric_key = VALUES(metric_key),
|
||||||
|
threshold_min = VALUES(threshold_min),
|
||||||
|
threshold_max = VALUES(threshold_max),
|
||||||
|
priority = VALUES(priority),
|
||||||
|
enabled = 1`,
|
||||||
|
[
|
||||||
|
DEPARTMENT_TAG_SCOPE,
|
||||||
|
String(rule.tagLevel),
|
||||||
|
DEPARTMENT_METRIC_KEYS.includes(rule.metricKey) ? rule.metricKey : 'sales_qty_7d',
|
||||||
|
toNumberOrNull(rule.thresholdMin),
|
||||||
|
toNumberOrNull(rule.thresholdMax),
|
||||||
|
Number(rule.priority) || 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owners) {
|
||||||
|
validateDepartmentOwners(owners);
|
||||||
|
await database.local.query('DELETE FROM department_sku_owners');
|
||||||
|
for (const owner of owners) {
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO department_sku_owners (department_id, owner_type, user_id, spu)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE department_id = VALUES(department_id)`,
|
||||||
|
[
|
||||||
|
Number(owner.departmentId),
|
||||||
|
owner.ownerType === 'purchaser' ? 'purchaser' : 'sku_owner',
|
||||||
|
Number(owner.userId),
|
||||||
|
owner.spu ? String(owner.spu).trim() : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDepartmentTagConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDepartmentRules(rules) {
|
||||||
|
const seen = new Set();
|
||||||
|
rules.forEach((rule, index) => {
|
||||||
|
const line = index + 1;
|
||||||
|
if (!DEPARTMENT_TAG_LEVELS.includes(String(rule.tagLevel))) {
|
||||||
|
throw new Error(`第 ${line} 行:部门标签必须是 ${DEPARTMENT_TAG_LEVELS.join(' / ')}`);
|
||||||
|
}
|
||||||
|
const min = toNumberOrNull(rule.thresholdMin);
|
||||||
|
const max = toNumberOrNull(rule.thresholdMax);
|
||||||
|
if (min !== null && max !== null && min >= max) {
|
||||||
|
throw new Error(`第 ${line} 行:${rule.tagLevel} 的起点(${min}) 必须小于终点(${max})`);
|
||||||
|
}
|
||||||
|
if (seen.has(rule.tagLevel)) {
|
||||||
|
throw new Error(`第 ${line} 行:部门标签「${rule.tagLevel}」重复`);
|
||||||
|
}
|
||||||
|
seen.add(rule.tagLevel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDepartmentOwners(owners) {
|
||||||
|
const seen = new Set();
|
||||||
|
owners.forEach((owner, index) => {
|
||||||
|
const line = index + 1;
|
||||||
|
if (!owner.departmentId) throw new Error(`第 ${line} 行:必须选择部门`);
|
||||||
|
if (!owner.userId) throw new Error(`第 ${line} 行:必须选择负责人`);
|
||||||
|
const key = `${owner.departmentId}||${owner.ownerType}||${owner.spu || ''}||${owner.userId}`;
|
||||||
|
if (seen.has(key)) throw new Error(`第 ${line} 行:部门/负责人/SPU 组合重复`);
|
||||||
|
seen.add(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureProductMonitoringSchema,
|
||||||
|
getMonitoringList,
|
||||||
|
getMonitoringFilters,
|
||||||
|
getDepartmentTagConfig,
|
||||||
|
saveDepartmentTagConfig,
|
||||||
|
warmProductMonitoringDataset,
|
||||||
|
invalidateDataset,
|
||||||
|
getProductOperators,
|
||||||
|
matchDepartmentTag,
|
||||||
|
DEPARTMENT_TAG_LEVELS,
|
||||||
|
DEPARTMENT_METRIC_KEYS,
|
||||||
|
SALE_STATUS_OPTIONS,
|
||||||
|
};
|
||||||
521
backend/src/services/productTagConfigService.js
Normal file
521
backend/src/services/productTagConfigService.js
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 商品标签配置:规模标签阈值、利润标签阈值、SABC 映射
|
||||||
|
//
|
||||||
|
// 数据来源:《公司SABC标签配置.xlsx》
|
||||||
|
// - 区块① 分类 × 业绩/日销区间 → 规模标签(148 行)
|
||||||
|
// - 区块② 规模标签 × 利润标签 → SABC(25 行)
|
||||||
|
//
|
||||||
|
// 判定口径(2026-09-11 评审确认):
|
||||||
|
// 1. SABC 保留 5 档,含 D
|
||||||
|
// 2. 利润标签是上游传入的输入值,直接查表;营业利润率仅作展示说明,不参与计算
|
||||||
|
// 3. 区间一律「左闭右开」[起点, 终点);规模标签按 priority 升序命中即停
|
||||||
|
// 4. 业绩与日销必须同时命中
|
||||||
|
// 5. 阈值导入时统一取整
|
||||||
|
// 6. 品类为 '-' 的行跳过
|
||||||
|
// 7. 暂不实现标签自动重算
|
||||||
|
|
||||||
|
const database = require('../config/database');
|
||||||
|
|
||||||
|
const SCALE_TAGS = Object.freeze(['爆款', '热卖款', '动销款', '低动销款']);
|
||||||
|
const FALLBACK_SCALE_TAG = '滞销款';
|
||||||
|
const SCALE_TAG_ORDER = Object.freeze([...SCALE_TAGS, FALLBACK_SCALE_TAG]);
|
||||||
|
const PROFIT_LEVELS = Object.freeze(['一级', '二级', '三级', '四级', '五级']);
|
||||||
|
const SABC_TAGS = Object.freeze(['S', 'A', 'B', 'C', 'D']);
|
||||||
|
|
||||||
|
// 区块① 中作为脏数据占位、导入时跳过的品类值
|
||||||
|
const IGNORED_CATEGORIES = Object.freeze(['-', '']);
|
||||||
|
|
||||||
|
// 有意不参与评级的品类(2026-09-11 决策 B)。
|
||||||
|
// 与「未配置」语义不同:这里是明确决定不评级,不是遗漏,前端要能区分展示。
|
||||||
|
// 存在 system_config 里,无需为它单独建表。
|
||||||
|
const EXCLUDED_CATEGORIES_CONFIG_KEY = 'product_tag_excluded_categories';
|
||||||
|
const DEFAULT_EXCLUDED_CATEGORIES = Object.freeze(['其他 - 配件']);
|
||||||
|
|
||||||
|
// 未评级原因
|
||||||
|
const RATING_SKIP_EXCLUDED = 'excluded';
|
||||||
|
const RATING_SKIP_UNCONFIGURED = 'unconfigured';
|
||||||
|
|
||||||
|
async function ensureTable(tableName, createSql) {
|
||||||
|
await database.local.query(createSql);
|
||||||
|
const rows = await database.local.query(
|
||||||
|
`SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error(`Table ${tableName} create failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureProductTagConfigSchema() {
|
||||||
|
await ensureTable(
|
||||||
|
'product_scale_thresholds',
|
||||||
|
`CREATE TABLE IF NOT EXISTS product_scale_thresholds (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
category VARCHAR(128) NOT NULL COMMENT 'jst_sku.category 的完整值,如 住宅家具 - 电脑桌',
|
||||||
|
display_name VARCHAR(128) NULL COMMENT '配置表里的短名,如 电脑桌/书桌(仅用于展示与分组)',
|
||||||
|
scale_tag VARCHAR(32) NOT NULL COMMENT '爆款/热卖款/动销款/低动销款',
|
||||||
|
performance_min DECIMAL(18,4) NULL COMMENT '业绩起点(含)',
|
||||||
|
performance_max DECIMAL(18,4) NULL COMMENT '业绩终点(不含)',
|
||||||
|
sales_min DECIMAL(18,4) NULL COMMENT '日销起点(含)',
|
||||||
|
sales_max DECIMAL(18,4) NULL COMMENT '日销终点(不含)',
|
||||||
|
priority INT NOT NULL DEFAULT 0 COMMENT '同品类判定优先级,升序命中即停',
|
||||||
|
sort INT NOT NULL DEFAULT 0,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_category_tag (category, scale_tag),
|
||||||
|
KEY idx_category_priority (category, enabled, priority),
|
||||||
|
KEY idx_display_name (display_name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模标签阈值配置'`
|
||||||
|
);
|
||||||
|
|
||||||
|
await ensureTable(
|
||||||
|
'product_profit_levels',
|
||||||
|
`CREATE TABLE IF NOT EXISTS product_profit_levels (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
level_code VARCHAR(16) NOT NULL COMMENT '一级~五级',
|
||||||
|
profit_rate_max DECIMAL(8,4) NULL COMMENT '营业利润率上限(说明用)',
|
||||||
|
description VARCHAR(128) NULL COMMENT '展示文案,如 营业利润率≤0.18',
|
||||||
|
sort INT NOT NULL DEFAULT 0,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_level_code (level_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='利润标签阈值配置'`
|
||||||
|
);
|
||||||
|
|
||||||
|
await ensureTable(
|
||||||
|
'product_sabc_mappings',
|
||||||
|
`CREATE TABLE IF NOT EXISTS product_sabc_mappings (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
scale_tag VARCHAR(32) NOT NULL,
|
||||||
|
profit_level VARCHAR(16) NOT NULL,
|
||||||
|
sabc_tag VARCHAR(8) NOT NULL COMMENT 'S/A/B/C/D',
|
||||||
|
sort INT NOT NULL DEFAULT 0,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_scale_profit (scale_tag, profit_level)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模×利润→SABC 映射'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNullableNumber(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 决策 5:只对「计算溢出型」脏数据取整,合法的小数(如日销起点 0.5)必须保留。
|
||||||
|
// 判据:小数位数 > 2 位(如 7916.34311148132、2063.13142776357)→ 取整;
|
||||||
|
// 0.5 / 1.25 这类 ≤2 位小数的正常阈值原样保留。
|
||||||
|
function normalizeThreshold(value) {
|
||||||
|
const num = toNullableNumber(value);
|
||||||
|
if (num === null) return null;
|
||||||
|
const decimals = String(num).split('.')[1];
|
||||||
|
if (decimals && decimals.length > 2) return Math.round(num);
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value, fallback = 0) {
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value === null || value === undefined ? '' : value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOneOf(value, allowed, label) {
|
||||||
|
if (!allowed.includes(value)) {
|
||||||
|
throw new Error(`${label} 取值非法:${value},允许值 ${allowed.join(' / ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 读取 ====================
|
||||||
|
|
||||||
|
async function loadScaleThresholds() {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
`SELECT id, category, display_name, scale_tag, performance_min, performance_max,
|
||||||
|
sales_min, sales_max, priority, sort, enabled
|
||||||
|
FROM product_scale_thresholds
|
||||||
|
ORDER BY category ASC, priority ASC, id ASC`
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
category: row.category,
|
||||||
|
displayName: row.display_name || row.category,
|
||||||
|
scaleTag: row.scale_tag,
|
||||||
|
performanceMin: toNullableNumber(row.performance_min),
|
||||||
|
performanceMax: toNullableNumber(row.performance_max),
|
||||||
|
salesMin: toNullableNumber(row.sales_min),
|
||||||
|
salesMax: toNullableNumber(row.sales_max),
|
||||||
|
priority: toNumber(row.priority),
|
||||||
|
sort: toNumber(row.sort),
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfitLevels() {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
`SELECT id, level_code, profit_rate_max, description, sort, enabled
|
||||||
|
FROM product_profit_levels
|
||||||
|
ORDER BY sort ASC, id ASC`
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
levelCode: row.level_code,
|
||||||
|
profitRateMax: toNullableNumber(row.profit_rate_max),
|
||||||
|
description: row.description || '',
|
||||||
|
sort: toNumber(row.sort),
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSabcMappings() {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
`SELECT id, scale_tag, profit_level, sabc_tag, sort, enabled
|
||||||
|
FROM product_sabc_mappings
|
||||||
|
ORDER BY sort ASC, id ASC`
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
scaleTag: row.scale_tag,
|
||||||
|
profitLevel: row.profit_level,
|
||||||
|
sabcTag: row.sabc_tag,
|
||||||
|
sort: toNumber(row.sort),
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProductTagConfig() {
|
||||||
|
const [scaleThresholds, profitLevels, sabcMappings, excludedCategories] = await Promise.all([
|
||||||
|
loadScaleThresholds(),
|
||||||
|
loadProfitLevels(),
|
||||||
|
loadSabcMappings(),
|
||||||
|
loadExcludedCategories(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
scaleThresholds,
|
||||||
|
profitLevels,
|
||||||
|
sabcMappings,
|
||||||
|
excludedCategories,
|
||||||
|
meta: {
|
||||||
|
scaleTags: SCALE_TAG_ORDER,
|
||||||
|
profitLevels: PROFIT_LEVELS,
|
||||||
|
sabcTags: SABC_TAGS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 排除品类(不参与评级) ====================
|
||||||
|
|
||||||
|
async function loadExcludedCategories() {
|
||||||
|
try {
|
||||||
|
const rows = await database.local.query(
|
||||||
|
'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1',
|
||||||
|
[EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||||
|
);
|
||||||
|
// 没有配置记录时用默认值(配件不参与评级,决策 B)
|
||||||
|
if (!rows.length || !rows[0].config_value) return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||||
|
const parsed = JSON.parse(rows[0].config_value);
|
||||||
|
if (!Array.isArray(parsed)) return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||||
|
return parsed.map((item) => normalizeText(item)).filter(Boolean);
|
||||||
|
} catch (_error) {
|
||||||
|
return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExcludedCategories(list) {
|
||||||
|
const normalized = [...new Set((list || []).map((item) => normalizeText(item)).filter(Boolean))];
|
||||||
|
const payload = JSON.stringify(normalized);
|
||||||
|
const existing = await database.local.query(
|
||||||
|
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||||
|
[EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||||
|
);
|
||||||
|
if (existing.length) {
|
||||||
|
await database.local.query(
|
||||||
|
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||||
|
[payload, 'json', EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await database.local.query(
|
||||||
|
'INSERT INTO system_config (config_key, config_value, config_type) VALUES (?, ?, ?)',
|
||||||
|
[EXCLUDED_CATEGORIES_CONFIG_KEY, payload, 'json']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 校验 ====================
|
||||||
|
|
||||||
|
function validateScaleThresholds(rows) {
|
||||||
|
const seen = new Set();
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const line = index + 1;
|
||||||
|
const category = normalizeText(row.category);
|
||||||
|
if (!category) throw new Error(`第 ${line} 行:品类(jst_sku.category 完整值)不能为空`);
|
||||||
|
if (IGNORED_CATEGORIES.includes(category)) throw new Error(`第 ${line} 行:品类 "${category}" 已标记为忽略,不应提交`);
|
||||||
|
assertOneOf(normalizeText(row.scaleTag), SCALE_TAGS, `第 ${line} 行 规模标签`);
|
||||||
|
|
||||||
|
const key = `${category}||${row.scaleTag}`;
|
||||||
|
if (seen.has(key)) throw new Error(`第 ${line} 行:品类「${category}」的「${row.scaleTag}」重复`);
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
const pMin = toNullableNumber(row.performanceMin);
|
||||||
|
const pMax = toNullableNumber(row.performanceMax);
|
||||||
|
const sMin = toNullableNumber(row.salesMin);
|
||||||
|
const sMax = toNullableNumber(row.salesMax);
|
||||||
|
|
||||||
|
if (pMin !== null && pMax !== null && pMin >= pMax) {
|
||||||
|
throw new Error(`第 ${line} 行:业绩起点(${pMin}) 必须小于 业绩终点(${pMax})`);
|
||||||
|
}
|
||||||
|
if (sMin !== null && sMax !== null && sMin >= sMax) {
|
||||||
|
throw new Error(`第 ${line} 行:日销起点(${sMin}) 必须小于 日销终点(${sMax})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSabcMappings(rows) {
|
||||||
|
const seen = new Set();
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const line = index + 1;
|
||||||
|
assertOneOf(normalizeText(row.scaleTag), SCALE_TAG_ORDER, `第 ${line} 行 规模标签`);
|
||||||
|
assertOneOf(normalizeText(row.profitLevel), PROFIT_LEVELS, `第 ${line} 行 利润标签`);
|
||||||
|
assertOneOf(normalizeText(row.sabcTag), SABC_TAGS, `第 ${line} 行 SABC`);
|
||||||
|
|
||||||
|
const key = `${row.scaleTag}||${row.profitLevel}`;
|
||||||
|
if (seen.has(key)) throw new Error(`第 ${line} 行:规模标签「${row.scaleTag}」× 利润标签「${row.profitLevel}」重复`);
|
||||||
|
seen.add(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 保存(整表替换) ====================
|
||||||
|
|
||||||
|
async function replaceScaleThresholds(rows) {
|
||||||
|
validateScaleThresholds(rows);
|
||||||
|
await database.local.query('DELETE FROM product_scale_thresholds');
|
||||||
|
if (!rows.length) return 0;
|
||||||
|
|
||||||
|
const values = [];
|
||||||
|
const placeholders = rows.map((row) => {
|
||||||
|
// priority 由文档顺序推导:爆款=1 热卖款=2 动销款=3 低动销款=4
|
||||||
|
const priority = SCALE_TAGS.indexOf(normalizeText(row.scaleTag)) + 1;
|
||||||
|
values.push(
|
||||||
|
normalizeText(row.category),
|
||||||
|
normalizeText(row.displayName) || normalizeText(row.category),
|
||||||
|
normalizeText(row.scaleTag),
|
||||||
|
normalizeThreshold(row.performanceMin),
|
||||||
|
normalizeThreshold(row.performanceMax),
|
||||||
|
normalizeThreshold(row.salesMin),
|
||||||
|
normalizeThreshold(row.salesMax),
|
||||||
|
priority,
|
||||||
|
toNumber(row.sort, priority),
|
||||||
|
row.enabled === false ? 0 : 1
|
||||||
|
);
|
||||||
|
return '(?,?,?,?,?,?,?,?,?,?)';
|
||||||
|
});
|
||||||
|
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO product_scale_thresholds
|
||||||
|
(category, display_name, scale_tag, performance_min, performance_max,
|
||||||
|
sales_min, sales_max, priority, sort, enabled)
|
||||||
|
VALUES ${placeholders.join(',')}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceProfitLevels(rows) {
|
||||||
|
const list = rows && rows.length ? rows : PROFIT_LEVELS.map((levelCode) => ({ levelCode }));
|
||||||
|
list.forEach((row, index) => {
|
||||||
|
assertOneOf(normalizeText(row.levelCode), PROFIT_LEVELS, `第 ${index + 1} 行 利润标签`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
list.forEach((row, index) => {
|
||||||
|
const code = normalizeText(row.levelCode);
|
||||||
|
if (seen.has(code)) throw new Error(`第 ${index + 1} 行:利润标签「${code}」重复`);
|
||||||
|
seen.add(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
await database.local.query('DELETE FROM product_profit_levels');
|
||||||
|
const values = [];
|
||||||
|
const placeholders = list.map((row, index) => {
|
||||||
|
values.push(
|
||||||
|
normalizeText(row.levelCode),
|
||||||
|
toNullableNumber(row.profitRateMax),
|
||||||
|
normalizeText(row.description) || null,
|
||||||
|
toNumber(row.sort, index + 1),
|
||||||
|
row.enabled === false ? 0 : 1
|
||||||
|
);
|
||||||
|
return '(?,?,?,?,?)';
|
||||||
|
});
|
||||||
|
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled)
|
||||||
|
VALUES ${placeholders.join(',')}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return list.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceSabcMappings(rows) {
|
||||||
|
validateSabcMappings(rows);
|
||||||
|
await database.local.query('DELETE FROM product_sabc_mappings');
|
||||||
|
if (!rows.length) return 0;
|
||||||
|
|
||||||
|
const values = [];
|
||||||
|
const placeholders = rows.map((row, index) => {
|
||||||
|
values.push(
|
||||||
|
normalizeText(row.scaleTag),
|
||||||
|
normalizeText(row.profitLevel),
|
||||||
|
normalizeText(row.sabcTag),
|
||||||
|
toNumber(row.sort, index + 1),
|
||||||
|
row.enabled === false ? 0 : 1
|
||||||
|
);
|
||||||
|
return '(?,?,?,?,?)';
|
||||||
|
});
|
||||||
|
|
||||||
|
await database.local.query(
|
||||||
|
`INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled)
|
||||||
|
VALUES ${placeholders.join(',')}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次性保存三个区块(前端「保存配置」按钮)
|
||||||
|
async function saveProductTagConfig(payload = {}) {
|
||||||
|
const result = {};
|
||||||
|
if (Array.isArray(payload.scaleThresholds)) {
|
||||||
|
result.scaleThresholdCount = await replaceScaleThresholds(payload.scaleThresholds);
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.profitLevels)) {
|
||||||
|
result.profitLevelCount = await replaceProfitLevels(payload.profitLevels);
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.sabcMappings)) {
|
||||||
|
result.sabcMappingCount = await replaceSabcMappings(payload.sabcMappings);
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.excludedCategories)) {
|
||||||
|
result.excludedCategoryCount = (await saveExcludedCategories(payload.excludedCategories)).length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 运行时判定 ====================
|
||||||
|
|
||||||
|
// 决策 3 + 4:左闭右开,业绩与日销同时命中;决策 2:按 priority 升序命中即停
|
||||||
|
//
|
||||||
|
// 重要:必须区分两种「没命中」——
|
||||||
|
// ① 该品类有配置阈值,但数值全部落在区间外 → 真·滞销款(兜底档)
|
||||||
|
// ② 该品类**根本没有配置任何阈值** → 返回 null(未评级),不能算滞销款
|
||||||
|
// 否则未配置的品类(如「其他 - 配件」47 万 SKU)会被整批误判成滞销款。
|
||||||
|
function matchScaleTagOfCategory(thresholdsOfCategory, performance, sales) {
|
||||||
|
const ordered = [...(thresholdsOfCategory || [])]
|
||||||
|
.filter((row) => row.enabled !== false)
|
||||||
|
.sort((a, b) => (a.priority - b.priority) || (a.id - b.id));
|
||||||
|
|
||||||
|
if (!ordered.length) return null;
|
||||||
|
|
||||||
|
for (const row of ordered) {
|
||||||
|
const pIn = (row.performanceMin === null || performance >= row.performanceMin)
|
||||||
|
&& (row.performanceMax === null || performance < row.performanceMax);
|
||||||
|
const sIn = (row.salesMin === null || sales >= row.salesMin)
|
||||||
|
&& (row.salesMax === null || sales < row.salesMax);
|
||||||
|
if (pIn && sIn) return row.scaleTag;
|
||||||
|
}
|
||||||
|
return FALLBACK_SCALE_TAG;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScaleThresholdIndex(thresholds) {
|
||||||
|
const index = new Map();
|
||||||
|
(thresholds || []).forEach((row) => {
|
||||||
|
const key = normalizeText(row.category);
|
||||||
|
if (!index.has(key)) index.set(key, []);
|
||||||
|
index.get(key).push(row);
|
||||||
|
});
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSabcIndex(mappings) {
|
||||||
|
const index = new Map();
|
||||||
|
(mappings || []).forEach((row) => {
|
||||||
|
if (row.enabled === false) return;
|
||||||
|
index.set(`${normalizeText(row.scaleTag)}||${normalizeText(row.profitLevel)}`, row.sabcTag);
|
||||||
|
});
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量解析:items = [{ category, performance, sales, profitLevel }]
|
||||||
|
async function resolveProductTags(items) {
|
||||||
|
const [thresholds, mappings, excluded] = await Promise.all([
|
||||||
|
loadScaleThresholds(),
|
||||||
|
loadSabcMappings(),
|
||||||
|
loadExcludedCategories(),
|
||||||
|
]);
|
||||||
|
const thresholdIndex = buildScaleThresholdIndex(thresholds);
|
||||||
|
const sabcIndex = buildSabcIndex(mappings);
|
||||||
|
const excludedSet = new Set(excluded);
|
||||||
|
|
||||||
|
return (items || []).map((item) => {
|
||||||
|
const category = normalizeText(item.category);
|
||||||
|
|
||||||
|
// 有意排除的品类:直接返回未评级,且标明原因,不与「未配置」混淆
|
||||||
|
if (excludedSet.has(category)) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
scaleTag: null,
|
||||||
|
sabcTag: null,
|
||||||
|
rated: false,
|
||||||
|
skippedReason: RATING_SKIP_EXCLUDED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = thresholdIndex.get(category);
|
||||||
|
const scaleTag = matchScaleTagOfCategory(rows, toNullableNumber(item.performance), toNullableNumber(item.sales));
|
||||||
|
const profitLevel = normalizeText(item.profitLevel);
|
||||||
|
const sabcTag = (scaleTag && profitLevel)
|
||||||
|
? (sabcIndex.get(`${scaleTag}||${profitLevel}`) || null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
scaleTag,
|
||||||
|
sabcTag,
|
||||||
|
rated: scaleTag !== null,
|
||||||
|
// 该品类没有任何阈值配置 → 未评级(不能算滞销款)
|
||||||
|
skippedReason: scaleTag === null ? RATING_SKIP_UNCONFIGURED : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SCALE_TAGS,
|
||||||
|
SCALE_TAG_ORDER,
|
||||||
|
FALLBACK_SCALE_TAG,
|
||||||
|
PROFIT_LEVELS,
|
||||||
|
SABC_TAGS,
|
||||||
|
IGNORED_CATEGORIES,
|
||||||
|
DEFAULT_EXCLUDED_CATEGORIES,
|
||||||
|
EXCLUDED_CATEGORIES_CONFIG_KEY,
|
||||||
|
RATING_SKIP_EXCLUDED,
|
||||||
|
RATING_SKIP_UNCONFIGURED,
|
||||||
|
ensureProductTagConfigSchema,
|
||||||
|
getProductTagConfig,
|
||||||
|
saveProductTagConfig,
|
||||||
|
loadExcludedCategories,
|
||||||
|
saveExcludedCategories,
|
||||||
|
resolveProductTags,
|
||||||
|
matchScaleTagOfCategory,
|
||||||
|
// 供单元测试或导入脚本复用
|
||||||
|
normalizeThreshold,
|
||||||
|
validateScaleThresholds,
|
||||||
|
validateSabcMappings,
|
||||||
|
};
|
||||||
@ -2,16 +2,12 @@ const RETIRED_PAGE_KEYS = [
|
|||||||
'planning_upload', 'market_insight', 'agent', 'ai_skill_tags',
|
'planning_upload', 'market_insight', 'agent', 'ai_skill_tags',
|
||||||
'store_checkup', 'review_analysis', 'customer_service_checkup',
|
'store_checkup', 'review_analysis', 'customer_service_checkup',
|
||||||
'pricing_calculator', 'desktop_client_download', 'ai_buyer_show', 'ai_seeding_video',
|
'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([
|
const RETIRED_ENTRY_KEYS = new Set([
|
||||||
'market-insight', 'agent', 'ai-skill-tags', 'platform-switch',
|
'market-insight', 'agent', 'ai-skill-tags', 'platform-switch',
|
||||||
'store-management', 'store-checkup', 'review-analysis', 'customer-service-checkup',
|
'store-management', 'store-checkup', 'review-analysis', 'customer-service-checkup',
|
||||||
'operation-tools', 'pricing-calculator', 'desktop-client-download', 'ai-buyer-show', 'ai-seeding-video',
|
'operation-tools', 'pricing-calculator', 'desktop-client-download', 'ai-buyer-show', 'ai-seeding-video',
|
||||||
'desktop-client-releases',
|
'desktop-client-releases',
|
||||||
'product-monitoring', 'ops-b-incentive',
|
|
||||||
'marketing-management', 'marketing-events', 'promotion-module',
|
|
||||||
'product-management', 'product-analysis',
|
|
||||||
]);
|
]);
|
||||||
const RETIRED_CONFIG_KEYS = [
|
const RETIRED_CONFIG_KEYS = [
|
||||||
'openclaw_chat_config', 'openclaw_chat_skill_tags',
|
'openclaw_chat_config', 'openclaw_chat_skill_tags',
|
||||||
@ -19,7 +15,6 @@ const RETIRED_CONFIG_KEYS = [
|
|||||||
'cs_quality_ai_config', 'desktop_client_releases',
|
'cs_quality_ai_config', 'desktop_client_releases',
|
||||||
'ai_buyer_show_ark_api_key', 'ai_buyer_show_ark_base_url',
|
'ai_buyer_show_ark_api_key', 'ai_buyer_show_ark_base_url',
|
||||||
'ai_buyer_show_vision_model', 'ai_buyer_show_review_model',
|
'ai_buyer_show_vision_model', 'ai_buyer_show_review_model',
|
||||||
'product_tag_excluded_categories',
|
|
||||||
];
|
];
|
||||||
const RETIRED_DATABASE_CONFIG_NAMES = ['ai_buyer_show_database'];
|
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(`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_history');
|
||||||
await db.query('DROP TABLE IF EXISTS pricing_calculator_user_prefs');
|
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']);
|
const rows = await db.query("SELECT config_value FROM system_config WHERE config_key = ?", ['sidebar_navigation']);
|
||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
const current = rows[0].config_value;
|
const current = rows[0].config_value;
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -92,9 +92,11 @@ test('desktop capabilities follow resolved page permissions', () => {
|
|||||||
shopManagement: false,
|
shopManagement: false,
|
||||||
execution: false,
|
execution: false,
|
||||||
logs: true,
|
logs: true,
|
||||||
|
marketingEvents: false,
|
||||||
|
promotion: false,
|
||||||
productTest: 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 () => {
|
test('SSO ticket is consumed exactly once and produces a normal web token', async () => {
|
||||||
|
|||||||
@ -12,10 +12,6 @@ test('retired sidebar cleanup is recursive and idempotent', () => {
|
|||||||
{ key: 'agent' },
|
{ key: 'agent' },
|
||||||
{ key: 'store-management', children: [{ key: 'store-checkup' }] },
|
{ key: 'store-management', children: [{ key: 'store-checkup' }] },
|
||||||
{ key: 'operation-tools', children: [{ key: 'pricing-calculator' }] },
|
{ 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: '管理设置' }] },
|
{ key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'desktop-client-releases' }, { key: 'settings', label: '管理设置' }] },
|
||||||
] });
|
] });
|
||||||
const clean = pruneRetiredSidebarConfig(saved);
|
const clean = pruneRetiredSidebarConfig(saved);
|
||||||
@ -24,7 +20,6 @@ test('retired sidebar cleanup is recursive and idempotent', () => {
|
|||||||
assert.equal(pruneRetiredSidebarConfig('{invalid'), '{invalid');
|
assert.equal(pruneRetiredSidebarConfig('{invalid'), '{invalid');
|
||||||
assert.equal(isRetiredSystemConfigKey('openclaw_chat_skill_tags'), true);
|
assert.equal(isRetiredSystemConfigKey('openclaw_chat_skill_tags'), true);
|
||||||
assert.equal(isRetiredSystemConfigKey('cs_quality_ai_config'), 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(isRetiredDatabaseConfigName('ai_buyer_show_database'), true);
|
||||||
assert.equal(isRetiredSystemConfigKey('site_name'), false);
|
assert.equal(isRetiredSystemConfigKey('site_name'), false);
|
||||||
});
|
});
|
||||||
@ -33,12 +28,12 @@ test('upgrade removes only retired settings and permission rows', async () => {
|
|||||||
const calls = [];
|
const calls = [];
|
||||||
await cleanupRetiredFeatureSettings({ query: async (sql, params) => {
|
await cleanupRetiredFeatureSettings({ query: async (sql, params) => {
|
||||||
calls.push({ 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'));
|
const deletes = calls.filter((call) => call.sql.startsWith('DELETE'));
|
||||||
assert.equal(deletes.length, 5);
|
assert.equal(deletes.length, 5);
|
||||||
assert.ok(deletes.every((call) => call.sql.includes(' WHERE ') && call.params.length > 0));
|
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'));
|
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' }]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,11 +3,14 @@ const assert = require('node:assert/strict');
|
|||||||
const Module = require('node:module');
|
const Module = require('node:module');
|
||||||
const express = require('express');
|
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;
|
const originalLoad = Module._load;
|
||||||
Module._load = function (id, parent, ...rest) {
|
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('/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('/alibaba1688ProductService')) return { getAlibaba1688Product: async (id) => ({ id }) };
|
||||||
if (id.endsWith('/alibaba1688ProductEncodingRegistry')) return { attachEncodingDetail: async (value) => value };
|
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)); });
|
const server = await new Promise((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
|
||||||
try {
|
try {
|
||||||
const base = `http://127.0.0.1:${server.address().port}/api/planning-upload`;
|
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);
|
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);
|
const response = await fetch(base + path);
|
||||||
assert.equal(response.status, 200, path);
|
assert.equal(response.status, 200, path);
|
||||||
assert.equal((await response.json()).success, true);
|
assert.equal((await response.json()).success, true);
|
||||||
|
|||||||
@ -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 = () => {
|
export const getAuthConfig = () => {
|
||||||
return request.get('/api/admin/auth-config');
|
return request.get('/api/admin/auth-config');
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 })
|
|
||||||
);
|
|
||||||
17
frontend/src/api/marketingEvents.js
Normal file
17
frontend/src/api/marketingEvents.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export const getMarketingEventsList = (params) => (
|
||||||
|
request.get('/api/marketing-events/list', { params })
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getMarketingEventsLatestDate = () => (
|
||||||
|
request.get('/api/marketing-events/latest-date')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getMarketingEventsSummary = (params) => (
|
||||||
|
request.get('/api/marketing-events/summary', { params })
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getMarketingEventsStatusCounts = () => (
|
||||||
|
request.get('/api/marketing-events/status-counts')
|
||||||
|
);
|
||||||
16
frontend/src/api/mkhBom.js
Normal file
16
frontend/src/api/mkhBom.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
const DETAIL_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
|
||||||
|
export const getMkhBomList = (params = {}) => request.get(
|
||||||
|
'/api/mkh-bom/list',
|
||||||
|
{ params, timeout: LIST_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 单个 MKH 组合商品的 BOM 配件明细(列表展开 / 打印预览共用)
|
||||||
|
export const getMkhBomDetail = (skuId) => request.get(
|
||||||
|
'/api/mkh-bom/detail',
|
||||||
|
{ params: { skuId }, timeout: DETAIL_TIMEOUT_MS }
|
||||||
|
);
|
||||||
16
frontend/src/api/modularStockMonitor.js
Normal file
16
frontend/src/api/modularStockMonitor.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
const META_TIMEOUT_MS = 60 * 1000;
|
||||||
|
|
||||||
|
// 配件库存监控明细(默认仅「简尚-崔桥工厂半成品包装项目仓」)
|
||||||
|
export const getModularStockList = (params = {}) => request.get(
|
||||||
|
'/api/modular-stock-monitor/list',
|
||||||
|
{ params, timeout: LIST_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 筛选下拉选项(品类 / 预警等级)
|
||||||
|
export const getModularStockFilters = () => request.get(
|
||||||
|
'/api/modular-stock-monitor/filters',
|
||||||
|
{ timeout: META_TIMEOUT_MS }
|
||||||
|
);
|
||||||
@ -2,6 +2,21 @@ import request from './request';
|
|||||||
|
|
||||||
const PLANNING_TABLE_TIMEOUT_MS = 3 * 60 * 1000;
|
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) => {
|
export const getPlanningAlibaba1688ProductDetail = (productId) => {
|
||||||
return request.get(`/api/planning-upload/selection-fission/1688-products/${productId}`, {
|
return request.get(`/api/planning-upload/selection-fission/1688-products/${productId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
42
frontend/src/api/productAnalysis.js
Normal file
42
frontend/src/api/productAnalysis.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
let productAnalysisMetaInFlight = null;
|
||||||
|
const productAnalysisProductsInFlight = new Map();
|
||||||
|
|
||||||
|
export const getProductAnalysisMeta = () => {
|
||||||
|
if (!productAnalysisMetaInFlight) {
|
||||||
|
productAnalysisMetaInFlight = request.get('/api/product-analysis/meta')
|
||||||
|
.finally(() => {
|
||||||
|
productAnalysisMetaInFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return productAnalysisMetaInFlight;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProductAnalysisSignalThresholds = () => (
|
||||||
|
request.get('/api/product-analysis/signal-thresholds', { silentError: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
export const saveProductAnalysisSignalThresholds = (thresholds) => (
|
||||||
|
request.put('/api/product-analysis/signal-thresholds', { thresholds }, { silentError: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getDevelopmentDashboard = (params, config = {}) => (
|
||||||
|
request.get('/api/product-analysis/dashboard', { ...config, params })
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getProductAnalysisProducts = (params) => {
|
||||||
|
const requestKey = JSON.stringify(params || {});
|
||||||
|
if (!productAnalysisProductsInFlight.has(requestKey)) {
|
||||||
|
const inFlight = request.get('/api/product-analysis/products', { params })
|
||||||
|
.finally(() => {
|
||||||
|
productAnalysisProductsInFlight.delete(requestKey);
|
||||||
|
});
|
||||||
|
productAnalysisProductsInFlight.set(requestKey, inFlight);
|
||||||
|
}
|
||||||
|
return productAnalysisProductsInFlight.get(requestKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProductAnalysisTab = (goodsId, tabKey, params) => (
|
||||||
|
request.get(`/api/product-analysis/products/${encodeURIComponent(goodsId)}/tabs/${tabKey}`, { params })
|
||||||
|
);
|
||||||
34
frontend/src/api/productMonitoring.js
Normal file
34
frontend/src/api/productMonitoring.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
const META_TIMEOUT_MS = 60 * 1000;
|
||||||
|
|
||||||
|
// 明细列表(公司标签 / 部门标签 / 负责人 / 汇总)
|
||||||
|
export const getProductMonitoringList = (params = {}) => request.get(
|
||||||
|
'/api/product-monitoring/list',
|
||||||
|
{ params, timeout: LIST_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 筛选下拉选项(品类 / 供应商 / 仓库 / 采购负责人 / 销售状态)
|
||||||
|
export const getProductMonitoringFilters = () => request.get(
|
||||||
|
'/api/product-monitoring/filters',
|
||||||
|
{ timeout: META_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道)
|
||||||
|
export const getProductMonitoringOperators = (sku) => request.get(
|
||||||
|
'/api/product-monitoring/operators',
|
||||||
|
{ params: { sku }, timeout: META_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 部门标签(G-S/G-A/G-B/G-C)+ 部门 SKU 负责人配置
|
||||||
|
export const getDepartmentTagConfig = () => request.get(
|
||||||
|
'/api/product-monitoring/department-config',
|
||||||
|
{ timeout: META_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const saveDepartmentTagConfig = (payload = {}) => request.put(
|
||||||
|
'/api/product-monitoring/department-config',
|
||||||
|
payload,
|
||||||
|
{ timeout: META_TIMEOUT_MS }
|
||||||
|
);
|
||||||
17
frontend/src/api/promotionModule.js
Normal file
17
frontend/src/api/promotionModule.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import request from './request';
|
||||||
|
|
||||||
|
export const getPromotionModuleList = (params) => (
|
||||||
|
request.get('/api/promotion-module/list', { params })
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getPromotionModuleLatestDate = () => (
|
||||||
|
request.get('/api/promotion-module/latest-date')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getPromotionModuleSummary = (params) => (
|
||||||
|
request.get('/api/promotion-module/summary', { params })
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getPromotionModuleShops = () => (
|
||||||
|
request.get('/api/promotion-module/shops')
|
||||||
|
);
|
||||||
@ -2,8 +2,30 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
|||||||
collapsedByDefault: false,
|
collapsedByDefault: false,
|
||||||
darkModeByDefault: false,
|
darkModeByDefault: false,
|
||||||
entries: [
|
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: '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: '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,
|
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
|
||||||
children: [
|
children: [
|
||||||
@ -30,6 +52,28 @@ const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [
|
|||||||
const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
|
const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
|
||||||
const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(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 mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
||||||
const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
|
const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
|
||||||
const configuredKeys = new Set();
|
const configuredKeys = new Set();
|
||||||
@ -45,6 +89,18 @@ const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
|||||||
if (typeof defaultEntry.requiresAdmin !== 'undefined') {
|
if (typeof defaultEntry.requiresAdmin !== 'undefined') {
|
||||||
merged.requiresAdmin = defaultEntry.requiresAdmin;
|
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 hasDefaultChildren = Array.isArray(defaultEntry.children);
|
||||||
const hasConfiguredChildren = Array.isArray(configuredEntry.children);
|
const hasConfiguredChildren = Array.isArray(configuredEntry.children);
|
||||||
|
|
||||||
@ -81,10 +137,15 @@ const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
|||||||
|
|
||||||
const mergeSidebarConfigWithDefaults = (config) => {
|
const mergeSidebarConfigWithDefaults = (config) => {
|
||||||
const defaults = cloneDefaultConfig();
|
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 {
|
return {
|
||||||
...defaults,
|
...defaults,
|
||||||
...config,
|
...config,
|
||||||
entries: mergeSidebarEntries(defaults.entries, config?.entries || []),
|
entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,26 @@ const SIDEBAR_ICON_MAP = {
|
|||||||
|
|
||||||
const SIDEBAR_ROUTE_MAP = {
|
const SIDEBAR_ROUTE_MAP = {
|
||||||
'dashboard-preview': { path: '/planning/dev-style-preview' },
|
'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' },
|
'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' },
|
settings: { path: '/settings' },
|
||||||
'online-desktop-users': { path: '/settings/online-desktop-users' },
|
'online-desktop-users': { path: '/settings/online-desktop-users' },
|
||||||
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
|
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import {
|
|||||||
SycmSimpleTable,
|
SycmSimpleTable,
|
||||||
SycmSparkline,
|
SycmSparkline,
|
||||||
} from '../../components/Common';
|
} from '../../components/Common';
|
||||||
import { getDashboardMeta, getDashboardSummary } from '../../api/dashboard';
|
import { getDevelopmentDashboard, getProductAnalysisMeta } from '../../api/productAnalysis';
|
||||||
import './stylePreview.css';
|
import './stylePreview.css';
|
||||||
|
|
||||||
const overviewMetrics = [
|
const overviewMetrics = [
|
||||||
@ -180,7 +180,7 @@ const DevelopmentStylePreview = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
getDashboardMeta().then((response) => {
|
getProductAnalysisMeta().then((response) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const shops = Array.isArray(response?.data?.shops) ? response.data.shops : [];
|
const shops = Array.isArray(response?.data?.shops) ? response.data.shops : [];
|
||||||
setShopOptions([
|
setShopOptions([
|
||||||
@ -202,7 +202,7 @@ const DevelopmentStylePreview = () => {
|
|||||||
const params = customStartDate && customEndDate
|
const params = customStartDate && customEndDate
|
||||||
? { startDate: customStartDate, endDate: customEndDate, shopName: shopName || undefined }
|
? { startDate: customStartDate, endDate: customEndDate, shopName: shopName || undefined }
|
||||||
: { period: PERIOD_MAP[activePeriod] || 'week', 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 || {};
|
const payload = response?.data || {};
|
||||||
setDashboard(payload);
|
setDashboard(payload);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
|||||||
492
frontend/src/pages/MarketingEvents/index.jsx
Normal file
492
frontend/src/pages/MarketingEvents/index.jsx
Normal file
@ -0,0 +1,492 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DatePicker,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Segmented,
|
||||||
|
Select,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { SycmBusinessPanel, SycmDashboardMetric, SycmPage } from '../../components/Common';
|
||||||
|
import {
|
||||||
|
getMarketingEventsList,
|
||||||
|
getMarketingEventsLatestDate,
|
||||||
|
getMarketingEventsSummary,
|
||||||
|
} from '../../api/marketingEvents';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
function formatTimestamp(ts) {
|
||||||
|
if (!ts) return '-';
|
||||||
|
const num = Number(ts);
|
||||||
|
if (!Number.isFinite(num)) return '-';
|
||||||
|
const date = new Date(num > 1e12 ? num : num * 1000);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(date.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d} ${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return '-';
|
||||||
|
return `¥${num.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
if (value === null || value === undefined) return '-';
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return '-';
|
||||||
|
return num.toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_MAP = {
|
||||||
|
501: { label: '报名成功', color: 'success' },
|
||||||
|
502: { label: '审核中', color: 'processing' },
|
||||||
|
503: { label: '已拒绝', color: 'error' },
|
||||||
|
504: { label: '已取消', color: 'default' },
|
||||||
|
505: { label: '已结束', color: 'warning' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusInfo(status) {
|
||||||
|
if (STATUS_MAP[status]) return STATUS_MAP[status];
|
||||||
|
return { label: `状态 ${status}`, color: 'default' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarketingEvents = () => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dataSource, setDataSource] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [statusCounts, setStatusCounts] = useState({ all: 0 });
|
||||||
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
|
||||||
|
const [sorter, setSorter] = useState({ field: 'activity_goods_start_time', order: 'descend' });
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [groupByGoods, setGroupByGoods] = useState(true);
|
||||||
|
const [queryFilters, setQueryFilters] = useState({});
|
||||||
|
const [latestDate, setLatestDate] = useState('');
|
||||||
|
const [summary, setSummary] = useState(null);
|
||||||
|
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchLatestDate = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getMarketingEventsLatestDate();
|
||||||
|
if (res?.data?.date) {
|
||||||
|
setLatestDate(res.data.date);
|
||||||
|
return res.data.date;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSummary = useCallback(async (date) => {
|
||||||
|
setSummaryLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getMarketingEventsSummary({ date });
|
||||||
|
if (res?.data) {
|
||||||
|
setSummary(res.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSummary(null);
|
||||||
|
} finally {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLatestDate().then((date) => {
|
||||||
|
if (date) fetchSummary(date);
|
||||||
|
});
|
||||||
|
}, [fetchLatestDate, fetchSummary]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
sortBy: sorter.field,
|
||||||
|
sortOrder: sorter.order === 'ascend' ? 'ASC' : 'DESC',
|
||||||
|
...queryFilters,
|
||||||
|
};
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.status = statusFilter;
|
||||||
|
}
|
||||||
|
const result = await getMarketingEventsList(params);
|
||||||
|
const data = result?.data;
|
||||||
|
if (data) {
|
||||||
|
setDataSource(data.list || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
if (data.statusCounts) {
|
||||||
|
setStatusCounts(data.statusCounts);
|
||||||
|
}
|
||||||
|
if (data.filterDate) {
|
||||||
|
setLatestDate(data.filterDate);
|
||||||
|
fetchSummary(data.filterDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDataSource([]);
|
||||||
|
setTotal(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [pagination, sorter, statusFilter, queryFilters, fetchSummary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleTableChange = (pag, _filters, sort) => {
|
||||||
|
setPagination({ current: pag.current, pageSize: pag.pageSize });
|
||||||
|
if (sort.field) {
|
||||||
|
setSorter({ field: sort.field, order: sort.order || 'descend' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
form.resetFields();
|
||||||
|
setQueryFilters({});
|
||||||
|
setStatusFilter('all');
|
||||||
|
setPagination({ current: 1, pageSize: 20 });
|
||||||
|
setSorter({ field: 'activity_goods_start_time', order: 'descend' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinish = (values) => {
|
||||||
|
const filters = {};
|
||||||
|
if (values.shopName) filters.shopName = values.shopName;
|
||||||
|
if (values.activityType) filters.activityType = values.activityType;
|
||||||
|
if (values.activityName) filters.activityName = values.activityName;
|
||||||
|
if (values.activityId) filters.activityId = values.activityId;
|
||||||
|
if (values.goodsName) filters.goodsName = values.goodsName;
|
||||||
|
if (values.goodsId) filters.goodsId = values.goodsId;
|
||||||
|
if (values.dateRange?.length === 2) {
|
||||||
|
filters.dateStart = values.dateRange[0].format('YYYY-MM-DD');
|
||||||
|
filters.dateEnd = values.dateRange[1].format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
setQueryFilters(filters);
|
||||||
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions = useMemo(() => [
|
||||||
|
{ label: `全部 ${statusCounts.all || 0}`, value: 'all' },
|
||||||
|
{ label: `报名成功 ${statusCounts[501] || 0}`, value: 501 },
|
||||||
|
{ label: `审核中 ${statusCounts[502] || 0}`, value: 502 },
|
||||||
|
{ label: `已拒绝 ${statusCounts[503] || 0}`, value: 503 },
|
||||||
|
{ label: `已取消 ${statusCounts[504] || 0}`, value: 504 },
|
||||||
|
{ label: `已结束 ${statusCounts[505] || 0}`, value: 505 },
|
||||||
|
], [statusCounts]);
|
||||||
|
|
||||||
|
// 按商品聚合:同一商品多个活动时,最低价活动作为主行优先展示,其余折叠进展开行
|
||||||
|
const displayData = useMemo(() => {
|
||||||
|
const priceOf = (row) => {
|
||||||
|
const num = Number(row?.minPrice);
|
||||||
|
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
|
||||||
|
};
|
||||||
|
if (!groupByGoods) {
|
||||||
|
return dataSource.map((row) => ({ ...row, activityCount: 1, childActivities: null }));
|
||||||
|
}
|
||||||
|
const groups = new Map();
|
||||||
|
dataSource.forEach((row) => {
|
||||||
|
const key = String(row.goodsId || row.id);
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key).push(row);
|
||||||
|
});
|
||||||
|
const list = [];
|
||||||
|
groups.forEach((rows) => {
|
||||||
|
const sorted = [...rows].sort((left, right) => priceOf(left) - priceOf(right));
|
||||||
|
const [primary, ...rest] = sorted;
|
||||||
|
list.push({
|
||||||
|
...primary,
|
||||||
|
activityCount: sorted.length,
|
||||||
|
childActivities: rest.length ? rest : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}, [dataSource, groupByGoods]);
|
||||||
|
|
||||||
|
const summaryMetrics = useMemo(() => {
|
||||||
|
if (!summary) return [];
|
||||||
|
return [
|
||||||
|
{ key: 'totalRecords', label: '活动记录数', value: formatNumber(summary.totalRecords) },
|
||||||
|
{ key: 'successCount', label: '报名成功', value: formatNumber(summary.successCount) },
|
||||||
|
{ key: 'successRate', label: '报名成功率', value: `${summary.successRate}%` },
|
||||||
|
{ key: 'goodsCount', label: '参与商品数', value: formatNumber(summary.goodsCount) },
|
||||||
|
{ key: 'shopCount', label: '参与店铺数', value: formatNumber(summary.shopCount) },
|
||||||
|
{ key: 'activityIdCount', label: '活动数', value: formatNumber(summary.activityIdCount) },
|
||||||
|
{ key: 'activityTypeCount', label: '活动类型', value: formatNumber(summary.activityTypeCount) },
|
||||||
|
{ key: 'priceRange', label: '价格区间', value: summary.globalMinPrice !== '-' ? `${formatPrice(summary.globalMinPrice)} ~ ${formatPrice(summary.globalMaxPrice)}` : '-' },
|
||||||
|
];
|
||||||
|
}, [summary]);
|
||||||
|
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: '商品图片',
|
||||||
|
dataIndex: 'thumbUrl',
|
||||||
|
key: 'thumbUrl',
|
||||||
|
width: 80,
|
||||||
|
fixed: 'left',
|
||||||
|
render: (value, record) => (
|
||||||
|
value ? (
|
||||||
|
<img
|
||||||
|
className="marketing-events-thumb"
|
||||||
|
src={value}
|
||||||
|
alt={record.goodsName || '商品图片'}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="marketing-events-thumb is-empty">暂无</span>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品名称',
|
||||||
|
dataIndex: 'goodsName',
|
||||||
|
key: 'goodsName',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value, record) => (
|
||||||
|
<div className="marketing-events-goods-cell">
|
||||||
|
{value ? <span className="marketing-events-goods-name" title={value}>{value}</span> : '-'}
|
||||||
|
<span className="marketing-events-goods-id">ID: {record.goodsId || '-'}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '活动名称',
|
||||||
|
dataIndex: 'activityName',
|
||||||
|
key: 'activityName',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value, record) => (
|
||||||
|
<div className="marketing-events-goods-cell">
|
||||||
|
{value ? <span title={value}>{value}</span> : '-'}
|
||||||
|
<span className="marketing-events-goods-id">ID: {record.activityId || '-'}</span>
|
||||||
|
{record.activityCount > 1 ? (
|
||||||
|
<Tag color="blue" style={{ marginRight: 0 }}>共{record.activityCount}个活动 · 已按最低价优先</Tag>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '活动类型',
|
||||||
|
dataIndex: 'activityType',
|
||||||
|
key: 'activityType',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value) => value || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '店铺',
|
||||||
|
dataIndex: 'shopName',
|
||||||
|
key: 'shopName',
|
||||||
|
width: 120,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value) => value || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '报名时间',
|
||||||
|
dataIndex: 'enrollTime',
|
||||||
|
key: 'enrollTime',
|
||||||
|
width: 160,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => formatTimestamp(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '活动开始',
|
||||||
|
dataIndex: 'activityStartTime',
|
||||||
|
key: 'activityStartTime',
|
||||||
|
width: 160,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => formatTimestamp(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '活动结束',
|
||||||
|
dataIndex: 'activityEndTime',
|
||||||
|
key: 'activityEndTime',
|
||||||
|
width: 160,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => formatTimestamp(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最低价',
|
||||||
|
dataIndex: 'minPrice',
|
||||||
|
key: 'minPrice',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => formatPrice(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最高价',
|
||||||
|
dataIndex: 'maxPrice',
|
||||||
|
key: 'maxPrice',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => formatPrice(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (value) => {
|
||||||
|
const info = getStatusInfo(value);
|
||||||
|
return <Tag color={info.color}>{info.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const childActivityColumns = [
|
||||||
|
{ title: '活动名称', dataIndex: 'activityName', width: 200, ellipsis: true, render: (value, record) => (
|
||||||
|
<div className="marketing-events-goods-cell">
|
||||||
|
{value ? <span title={value}>{value}</span> : '-'}
|
||||||
|
<span className="marketing-events-goods-id">ID: {record.activityId || '-'}</span>
|
||||||
|
</div>
|
||||||
|
) },
|
||||||
|
{ title: '活动类型', dataIndex: 'activityType', width: 140, ellipsis: true, render: (value) => value || '-' },
|
||||||
|
{ title: '报名时间', dataIndex: 'enrollTime', width: 160, render: formatTimestamp },
|
||||||
|
{ title: '活动开始', dataIndex: 'activityStartTime', width: 160, render: formatTimestamp },
|
||||||
|
{ title: '活动结束', dataIndex: 'activityEndTime', width: 160, render: formatTimestamp },
|
||||||
|
{ title: '最低价', dataIndex: 'minPrice', width: 100, align: 'right', render: formatPrice },
|
||||||
|
{ title: '最高价', dataIndex: 'maxPrice', width: 100, align: 'right', render: formatPrice },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 100, render: (value) => {
|
||||||
|
const info = getStatusInfo(value);
|
||||||
|
return <Tag color={info.color}>{info.label}</Tag>;
|
||||||
|
} },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SycmPage className="marketing-events-page">
|
||||||
|
<SycmBusinessPanel className="marketing-events-panel" aria-label="营销活动内容区">
|
||||||
|
<div className="marketing-events-panel__heading">
|
||||||
|
<h2>营销活动</h2>
|
||||||
|
<span>查看拼多多平台活动报名记录</span>
|
||||||
|
{latestDate && (
|
||||||
|
<span className="marketing-events-data-date">最新数据日期:{latestDate}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin spinning={summaryLoading}>
|
||||||
|
<div className="marketing-events-summary">
|
||||||
|
{summaryMetrics.map((item) => (
|
||||||
|
<SycmDashboardMetric
|
||||||
|
key={item.key}
|
||||||
|
label={item.label}
|
||||||
|
value={item.value}
|
||||||
|
help={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!summary && !summaryLoading && (
|
||||||
|
<span className="marketing-events-summary__empty">暂无汇总数据</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
className="marketing-events-filter"
|
||||||
|
layout="horizontal"
|
||||||
|
colon={false}
|
||||||
|
onFinish={handleFinish}
|
||||||
|
>
|
||||||
|
<Form.Item label="店铺名称" name="shopName">
|
||||||
|
<Input allowClear placeholder="输入店铺名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="活动类型" name="activityType">
|
||||||
|
<Input allowClear placeholder="输入活动类型" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="活动名称" name="activityName">
|
||||||
|
<Input allowClear placeholder="输入活动名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="活动ID" name="activityId">
|
||||||
|
<Input allowClear placeholder="输入活动ID" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="商品名称" name="goodsName">
|
||||||
|
<Input allowClear placeholder="输入商品名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="商品ID" name="goodsId">
|
||||||
|
<Input allowClear placeholder="输入商品ID" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="活动时间" name="dateRange">
|
||||||
|
<RangePicker allowClear placeholder={['开始日期', '结束日期']} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<div className="marketing-events-filter__actions">
|
||||||
|
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>查询</Button>
|
||||||
|
<Button htmlType="button" icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className="marketing-events-table-toolbar">
|
||||||
|
<Segmented
|
||||||
|
className="marketing-events-status-switch"
|
||||||
|
value={statusFilter}
|
||||||
|
options={statusOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
setStatusFilter(value);
|
||||||
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Segmented
|
||||||
|
value={groupByGoods ? 'group' : 'flat'}
|
||||||
|
options={[
|
||||||
|
{ label: '按商品聚合(最低价优先)', value: 'group' },
|
||||||
|
{ label: '按活动明细', value: 'flat' },
|
||||||
|
]}
|
||||||
|
onChange={(value) => setGroupByGoods(value === 'group')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Table
|
||||||
|
className="marketing-events-table"
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={displayData}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
expandable={groupByGoods ? {
|
||||||
|
expandedRowRender: (record) => (
|
||||||
|
<Table
|
||||||
|
className="marketing-events-child-table"
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
columns={childActivityColumns}
|
||||||
|
dataSource={record.childActivities || []}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
rowExpandable: (record) => Boolean(record.childActivities?.length),
|
||||||
|
} : undefined}
|
||||||
|
pagination={{
|
||||||
|
current: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['20', '50', '100'],
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1600 }}
|
||||||
|
locale={{ emptyText: loading ? '加载中...' : '暂无活动记录' }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</SycmBusinessPanel>
|
||||||
|
</SycmPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarketingEvents;
|
||||||
224
frontend/src/pages/MarketingEvents/style.css
Normal file
224
frontend/src/pages/MarketingEvents/style.css
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
.marketing-events-panel {
|
||||||
|
min-height: calc(100vh - 84px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-panel__heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
padding: var(--sycm-module-padding) var(--sycm-module-padding) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-panel__heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-title);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
line-height: var(--sycm-line-height-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-panel__heading span {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
line-height: var(--sycm-line-height-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-data-date {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-medium);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
margin-top: var(--sycm-space-sm);
|
||||||
|
padding: 0 var(--sycm-module-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-summary .sycm-dashboard-metric {
|
||||||
|
min-height: 88px;
|
||||||
|
padding: var(--sycm-space-sm);
|
||||||
|
border-radius: var(--sycm-radius-lg);
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-summary .sycm-dashboard-metric__label {
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-summary .sycm-dashboard-metric__value {
|
||||||
|
font-size: var(--sycm-font-size-metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-summary__empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: var(--sycm-space-md);
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-filter {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||||
|
gap: var(--sycm-space-sm) var(--sycm-space-xl);
|
||||||
|
margin-top: var(--sycm-space-sm);
|
||||||
|
padding: var(--sycm-module-padding);
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-filter .ant-form-item {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-filter .ant-form-item-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-filter .ant-form-item-label {
|
||||||
|
padding: 0 var(--sycm-space-sm) 0 0;
|
||||||
|
overflow: visible;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-filter .ant-form-item-label > label {
|
||||||
|
height: var(--sycm-control-height);
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-medium);
|
||||||
|
line-height: var(--sycm-control-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-filter .ant-form-item-control,
|
||||||
|
.marketing-events-filter .ant-form-item-control-input,
|
||||||
|
.marketing-events-filter .ant-form-item-control-input-content {
|
||||||
|
min-height: var(--sycm-control-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-filter__actions {
|
||||||
|
display: flex;
|
||||||
|
min-height: var(--sycm-control-height);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
padding-left: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
min-height: 56px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sycm-space-md);
|
||||||
|
padding: var(--sycm-space-sm) var(--sycm-module-padding);
|
||||||
|
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||||
|
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-status-switch {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-child-table {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-child-table .ant-table {
|
||||||
|
background: var(--sycm-bg-page-soft, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-table .ant-table-wrapper,
|
||||||
|
.marketing-events-table .ant-spin-nested-loading,
|
||||||
|
.marketing-events-table .ant-spin-container {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-table .ant-table {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-table .ant-table-cell {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-table .ant-table-thead > tr > th {
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-table .ant-table-tbody > tr > td {
|
||||||
|
color: var(--sycm-text-primary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-table .ant-table-placeholder .ant-table-cell {
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-thumb {
|
||||||
|
display: block;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||||
|
border-radius: var(--sycm-radius-sm);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-thumb.is-empty {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: var(--sycm-font-size-micro);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-goods-cell {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-space-xxs);
|
||||||
|
line-height: var(--sycm-line-height-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-goods-name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-weight: var(--sycm-font-weight-medium);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-goods-id {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: var(--sycm-font-size-micro);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.marketing-events-panel__heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-space-xxs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-filter {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketing-events-filter__actions {
|
||||||
|
padding-left: 72px;
|
||||||
|
}
|
||||||
|
}
|
||||||
440
frontend/src/pages/MkhBomData/index.jsx
Normal file
440
frontend/src/pages/MkhBomData/index.jsx
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert, Button, Card, Input, Space, Spin, Table, Tooltip, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { PrinterOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { SycmPage } from '../../components/Common';
|
||||||
|
import { getMkhBomList, getMkhBomDetail } from '../../api/mkhBom';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const number = (value, digits = 0) => (value === null || value === undefined || value === ''
|
||||||
|
? '0'
|
||||||
|
: Number(value).toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
|
||||||
|
|
||||||
|
const EMPTY_FILTERS = { keyword: '' };
|
||||||
|
|
||||||
|
// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位
|
||||||
|
const PrintPreview = ({ data, onClose }) => {
|
||||||
|
const now = useMemo(() => new Date().toLocaleString('zh-CN'), []);
|
||||||
|
const items = data.items || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mkh-print-mask">
|
||||||
|
<div className="mkh-print-toolbar">
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<PrinterOutlined />} onClick={() => window.print()}>一键打印</Button>
|
||||||
|
<Button onClick={onClose}>关闭</Button>
|
||||||
|
<Text type="secondary">按 Esc 关闭预览</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mkh-print-sheet">
|
||||||
|
<div className="mkh-print-head">
|
||||||
|
<div className="mkh-print-title">MKH-BOM 资料</div>
|
||||||
|
<div className="mkh-print-sub">模块化商品配件明细 · 打印模板</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mkh-print-meta">
|
||||||
|
<div><span>打印时间:</span><b>{now}</b></div>
|
||||||
|
<div><span>配件种类:</span><b>{items.length}</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="mkh-print-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: '170px' }}>MKH商品编码</th>
|
||||||
|
<th>配件明细</th>
|
||||||
|
<th style={{ width: '64px' }}>数量</th>
|
||||||
|
<th style={{ width: '110px' }}>配件仓位</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<tr key={item.index}>
|
||||||
|
{idx === 0 ? (
|
||||||
|
<td className="mkh-print-sku" rowSpan={items.length}>{text(data.skuId)}</td>
|
||||||
|
) : null}
|
||||||
|
<td>{text(item.skuName)}{item.skuId ? <span className="mkh-print-code">{item.skuId}</span> : null}</td>
|
||||||
|
<td className="mkh-center">{number(item.qty)}</td>
|
||||||
|
<td className="mkh-center">{item.location ? text(item.location) : <span className="mkh-muted">未维护</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="mkh-print-sku">{text(data.skuId)}</td>
|
||||||
|
<td colSpan={3} className="mkh-muted">该 MKH 商品的配件明细尚未录入「产品资料系统数据库」</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="mkh-print-foot">
|
||||||
|
<span>
|
||||||
|
MKH 商品编码取自业务库;配件明细与数量取自「产品资料系统数据库」(BOM);
|
||||||
|
配件仓位取库位主数据,未维护时显示「未维护」。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MkhBomData = () => {
|
||||||
|
const [filters, setFilters] = useState(EMPTY_FILTERS);
|
||||||
|
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [sorter, setSorter] = useState({ field: 'skuId', order: 'ASC' });
|
||||||
|
|
||||||
|
const [expandedKeys, setExpandedKeys] = useState({});
|
||||||
|
const [detailCache, setDetailCache] = useState({});
|
||||||
|
const [detailLoading, setDetailLoading] = useState({});
|
||||||
|
|
||||||
|
const [printData, setPrintData] = useState(null);
|
||||||
|
const [printLoading, setPrintLoading] = useState(false);
|
||||||
|
|
||||||
|
const tableWrapRef = useRef(null);
|
||||||
|
const [bodyHeight, setBodyHeight] = useState(420);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tableWrapRef.current;
|
||||||
|
if (!el) return undefined;
|
||||||
|
const RESERVE_BELOW = 104;
|
||||||
|
const measure = () => {
|
||||||
|
const scroller = el.closest('.planning-subsystem-content');
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
let available;
|
||||||
|
if (scroller) {
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect();
|
||||||
|
available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop;
|
||||||
|
} else {
|
||||||
|
available = window.innerHeight - elRect.top - RESERVE_BELOW;
|
||||||
|
}
|
||||||
|
const next = Math.max(240, Math.round(available));
|
||||||
|
setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev));
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
if (el.parentElement) ro.observe(el.parentElement);
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener('resize', measure);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const query = useMemo(() => {
|
||||||
|
const params = { page, pageSize, sortField: sorter.field, sortOrder: sorter.order };
|
||||||
|
Object.entries(filters).forEach(([key, value]) => { if (value) params[key] = value; });
|
||||||
|
return params;
|
||||||
|
}, [page, pageSize, sorter, filters]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await getMkhBomList(query);
|
||||||
|
if (!result || result.success === false) {
|
||||||
|
setError(result?.error || '加载 MKH-BOM 资料失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 });
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const changeFilter = (key) => (value) => {
|
||||||
|
setFilters((old) => ({ ...old, [key]: value || '' }));
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); };
|
||||||
|
|
||||||
|
const loadDetail = useCallback(async (skuId) => {
|
||||||
|
if (detailCache[skuId] || detailLoading[skuId]) return;
|
||||||
|
setDetailLoading((old) => ({ ...old, [skuId]: true }));
|
||||||
|
try {
|
||||||
|
const result = await getMkhBomDetail(skuId);
|
||||||
|
if (!result || result.success === false) {
|
||||||
|
message.error(result?.error || '加载配件明细失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } }));
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e?.response?.data?.error || e?.message || '加载配件明细失败');
|
||||||
|
} finally {
|
||||||
|
setDetailLoading((old) => ({ ...old, [skuId]: false }));
|
||||||
|
}
|
||||||
|
}, [detailCache, detailLoading]);
|
||||||
|
|
||||||
|
const handlePrint = async (skuId) => {
|
||||||
|
setPrintLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getMkhBomDetail(skuId);
|
||||||
|
if (!result || result.success === false) {
|
||||||
|
message.error(result?.error || '加载打印数据失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPrintData(result?.data || { skuId, items: [] });
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e?.response?.data?.error || e?.message || '加载打印数据失败');
|
||||||
|
} finally {
|
||||||
|
setPrintLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: 'MKH商品编码',
|
||||||
|
dataIndex: 'skuId',
|
||||||
|
width: 240,
|
||||||
|
fixed: 'left',
|
||||||
|
sorter: true,
|
||||||
|
render: (value, row) => (
|
||||||
|
<Space size={6}>
|
||||||
|
<span className="mkh-sku">{text(value)}</span>
|
||||||
|
{row.source === 'product_db' ? (
|
||||||
|
<Tooltip title="该编码仅在「产品资料系统数据库」中维护,业务库 jst_sku 暂无对应成品">
|
||||||
|
<span className="mkh-tag mkh-tag--src">资料库</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品名称',
|
||||||
|
dataIndex: 'productName',
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'BOM 状态',
|
||||||
|
dataIndex: 'bomConfigured',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
render: (value, row) => (row.bomConfigured === '已配置'
|
||||||
|
? <span className="mkh-tag mkh-tag--ok">已配置</span>
|
||||||
|
: <span className="mkh-tag mkh-tag--muted">未配置</span>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配件种类',
|
||||||
|
dataIndex: 'accessoryCount',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => number(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配件总数量',
|
||||||
|
dataIndex: 'accessoryQtySum',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => number(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 190,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_value, row) => (
|
||||||
|
<Space size={6}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<PrinterOutlined />}
|
||||||
|
loading={printLoading}
|
||||||
|
onClick={() => handlePrint(row.skuId)}
|
||||||
|
>
|
||||||
|
一键打印
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
const next = !expandedKeys[row.skuId];
|
||||||
|
setExpandedKeys((old) => ({ ...old, [row.skuId]: next }));
|
||||||
|
if (next) loadDetail(row.skuId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expandedKeys[row.skuId] ? '收起' : '配件明细'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [expandedKeys, printLoading, loadDetail]);
|
||||||
|
|
||||||
|
const expandedRowRender = (row) => {
|
||||||
|
const detail = detailCache[row.skuId];
|
||||||
|
if (detailLoading[row.skuId] && !detail) {
|
||||||
|
return <div className="mkh-detail-loading"><Spin size="small" /> 加载配件明细…</div>;
|
||||||
|
}
|
||||||
|
const items = detail?.items || [];
|
||||||
|
return (
|
||||||
|
<div className="mkh-detail">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="mkh-detail-tip">
|
||||||
|
<Text type="secondary">
|
||||||
|
该 MKH 商品在「产品资料系统数据库」中暂无配件 BOM 录入。
|
||||||
|
在产品资料系统里维护好该商品的配件明细后,此处会自动显示配件明细、数量与仓位。
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Table
|
||||||
|
rowKey="index"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={items}
|
||||||
|
scroll={{ x: 720 }}
|
||||||
|
locale={{ emptyText: '暂无配件明细' }}
|
||||||
|
columns={[
|
||||||
|
{ title: '序号', dataIndex: 'index', width: 60, align: 'center' },
|
||||||
|
{
|
||||||
|
title: '配件明细',
|
||||||
|
dataIndex: 'skuName',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v, r) => (
|
||||||
|
<Tooltip title={`${v || ''}${r.skuId ? `(${r.skuId})` : ''}`}>
|
||||||
|
<span>{text(v)}</span>
|
||||||
|
{r.skuId ? <span className="mkh-detail-code">{r.skuId}</span> : null}
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '数量', dataIndex: 'qty', width: 90, align: 'right', render: (v) => number(v) },
|
||||||
|
{
|
||||||
|
title: '配件仓位',
|
||||||
|
dataIndex: 'location',
|
||||||
|
width: 140,
|
||||||
|
render: (v) => (v ? text(v) : (
|
||||||
|
<Tooltip title="上游未回传库位数据">
|
||||||
|
<span className="mkh-muted">未维护</span>
|
||||||
|
</Tooltip>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SycmPage className="mkh-page">
|
||||||
|
<header className="mkh-header">
|
||||||
|
<div className="mkh-header__heading">
|
||||||
|
<Title level={3}>MKH-BOM 资料</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" className="mkh-note">
|
||||||
|
数据口径:MKH 商品编码取自业务库 jst_sku(sku_id LIKE 'MKH%' 且 item_type = 成品,共 4436 条);
|
||||||
|
配件明细与数量取自「产品资料系统数据库」(PostgreSQL / furniture3d_prod)中模块化商品维护的配件明细(配件条码 + 数量);
|
||||||
|
资料库中已维护 BOM 但业务库无对应成品的编码会置顶并标注「资料库」;配件仓位取库位主数据,未维护时显示「未维护」。
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="mkh-filter-card" bordered={false}>
|
||||||
|
<Space wrap size={[12, 12]}>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="MKH商品编码 / 名称"
|
||||||
|
value={filters.keyword}
|
||||||
|
onChange={(e) => changeFilter('keyword')(e.target.value)}
|
||||||
|
style={{ width: 260 }}
|
||||||
|
/>
|
||||||
|
<Button onClick={resetFilters}>重置</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
|
||||||
|
|
||||||
|
{!loading && (data.total || 0) > 0 ? (
|
||||||
|
<Alert
|
||||||
|
type={data.bomRowCount > 0 ? 'info' : 'warning'}
|
||||||
|
showIcon
|
||||||
|
message={data.bomRowCount > 0
|
||||||
|
? `产品资料系统数据库已接入 ${data.bomRowCount} 个模块化商品的配件 BOM`
|
||||||
|
: '产品资料系统数据库中尚未录入模块化商品的配件 BOM'}
|
||||||
|
description={(
|
||||||
|
<span>
|
||||||
|
MKH 商品编码({data.total - (data.bomExtraCount || 0)} 条)取自业务库 jst_sku;
|
||||||
|
配件明细与数量取自「产品资料系统数据库」中模块化商品维护的 BOM。
|
||||||
|
资料库中已维护 BOM、但业务库无对应成品的编码({data.bomExtraCount || 0} 条)会置顶并标注「资料库」。
|
||||||
|
在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
className="mkh-panel"
|
||||||
|
title={<span>MKH 模块化成品({data.total || 0} 条)</span>}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading} tip="正在加载 MKH-BOM 资料…">
|
||||||
|
<div ref={tableWrapRef}>
|
||||||
|
<Table
|
||||||
|
rowKey="skuId"
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.rows || []}
|
||||||
|
expandable={{
|
||||||
|
expandedRowKeys: Object.keys(expandedKeys).filter((k) => expandedKeys[k]),
|
||||||
|
expandedRowRender,
|
||||||
|
onExpand: (expanded, record) => {
|
||||||
|
setExpandedKeys((old) => ({ ...old, [record.skuId]: expanded }));
|
||||||
|
if (expanded) loadDetail(record.skuId);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onChange={(pagination, _filter, nextSorter) => {
|
||||||
|
setPage(pagination.current || 1);
|
||||||
|
setPageSize(pagination.pageSize || 50);
|
||||||
|
if (nextSorter?.field) {
|
||||||
|
setSorter({
|
||||||
|
field: nextSorter.field,
|
||||||
|
order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
current: data.page || page,
|
||||||
|
pageSize: data.pageSize || pageSize,
|
||||||
|
total: data.total || 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [50, 100, 200],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1000, y: bodyHeight }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{printData ? <PrintPreview data={printData} onClose={() => setPrintData(null)} /> : null}
|
||||||
|
</SycmPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MkhBomData;
|
||||||
239
frontend/src/pages/MkhBomData/style.css
Normal file
239
frontend/src/pages/MkhBomData/style.css
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
.mkh-page {
|
||||||
|
padding: 16px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-header {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-header__heading .ant-typography {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-note {
|
||||||
|
display: block;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-filter-card {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-panel {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-sku {
|
||||||
|
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--sycm-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-muted {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-detail-loading {
|
||||||
|
padding: 12px 8px;
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-detail {
|
||||||
|
padding: 4px 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-detail-tip {
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-detail-code {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-tag--ok {
|
||||||
|
color: #0f6e56;
|
||||||
|
background: rgba(29, 158, 117, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-tag--muted {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
background: rgba(136, 135, 128, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-tag--src {
|
||||||
|
color: #0958d9;
|
||||||
|
background: rgba(22, 119, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ A4 打印预览 ============ */
|
||||||
|
.mkh-print-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
overflow: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
padding: 24px 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-sheet {
|
||||||
|
width: 210mm;
|
||||||
|
min-height: 297mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 14mm 12mm;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-head {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px solid #000;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-meta > div {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-meta span {
|
||||||
|
color: #555;
|
||||||
|
min-width: 78px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-table th,
|
||||||
|
.mkh-print-table td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-table thead th {
|
||||||
|
background: #f2f2f2;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-table .mkh-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-table .mkh-muted {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-table .mkh-print-sku {
|
||||||
|
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-code {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-foot {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed #999;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 打印时只输出 A4 纸张,隐藏其余界面 */
|
||||||
|
@media print {
|
||||||
|
body * {
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-sheet,
|
||||||
|
.mkh-print-sheet * {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-toolbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-mask {
|
||||||
|
position: absolute !important;
|
||||||
|
inset: 0 !important;
|
||||||
|
background: #fff !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mkh-print-sheet {
|
||||||
|
width: 210mm;
|
||||||
|
min-height: auto;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 12mm;
|
||||||
|
}
|
||||||
|
}
|
||||||
428
frontend/src/pages/ModularStockMonitor/index.jsx
Normal file
428
frontend/src/pages/ModularStockMonitor/index.jsx
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert, Button, Card, Col, Dropdown, Image, Input, Row, Select,
|
||||||
|
Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { SycmPage } from '../../components/Common';
|
||||||
|
import { getModularStockList, getModularStockFilters } from '../../api/modularStockMonitor';
|
||||||
|
import { exportTableFile } from '../../utils/tableExport';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const WARNING_COLOR = { 缺货: 'red', 预警: 'orange', 正常: 'green' };
|
||||||
|
|
||||||
|
// 表头带口径说明,避免把「真实值 0」误读成「没数据」
|
||||||
|
const TipTitle = ({ title, tip }) => (
|
||||||
|
<Tooltip title={tip}>
|
||||||
|
<span className="msm-th-tip">{title}</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
const number = (value, digits = 2) => value === null || value === undefined || value === ''
|
||||||
|
? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
const int = (value) => number(value, 0);
|
||||||
|
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
|
||||||
|
|
||||||
|
const toOptions = (list = []) => (Array.isArray(list) ? list : [])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((value) => ({ value, label: value }));
|
||||||
|
|
||||||
|
const EMPTY_FILTERS = { keyword: '', category: '', warningLevel: '', mkhStatus: '' };
|
||||||
|
|
||||||
|
const AccessoryStockPanel = () => {
|
||||||
|
const [filters, setFilters] = useState(EMPTY_FILTERS);
|
||||||
|
const [onlyWarning, setOnlyWarning] = useState(false);
|
||||||
|
const [filterOptions, setFilterOptions] = useState({ categories: [], warningLevels: [] });
|
||||||
|
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: null });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [sorter, setSorter] = useState({ field: 'realtimeStock', order: 'DESC' });
|
||||||
|
const tableWrapRef = useRef(null);
|
||||||
|
const [bodyHeight, setBodyHeight] = useState(420);
|
||||||
|
|
||||||
|
// 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc:
|
||||||
|
// 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tableWrapRef.current;
|
||||||
|
if (!el) return undefined;
|
||||||
|
|
||||||
|
// 分页器 + 卡片内边距 + 横向滚动条余量
|
||||||
|
const RESERVE_BELOW = 104;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const scroller = el.closest('.planning-subsystem-content');
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
let available;
|
||||||
|
if (scroller) {
|
||||||
|
const scrollerRect = scroller.getBoundingClientRect();
|
||||||
|
// 加回 scrollTop,抵消滚动对 getBoundingClientRect 的影响
|
||||||
|
available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop;
|
||||||
|
} else {
|
||||||
|
available = window.innerHeight - elRect.top - RESERVE_BELOW;
|
||||||
|
}
|
||||||
|
const next = Math.max(240, Math.round(available));
|
||||||
|
setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
if (el.parentElement) ro.observe(el.parentElement);
|
||||||
|
window.addEventListener('resize', measure);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener('resize', measure);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getModularStockFilters()
|
||||||
|
.then((result) => {
|
||||||
|
const payload = result?.data || {};
|
||||||
|
if (cancelled) return;
|
||||||
|
setFilterOptions({
|
||||||
|
categories: payload.categories || [],
|
||||||
|
warningLevels: payload.warningLevels || ['缺货', '预警', '正常'],
|
||||||
|
mkhStatuses: payload.mkhStatuses || ['已配置MKH', '未配置MKH'],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const query = useMemo(() => {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortField: sorter.field,
|
||||||
|
sortOrder: sorter.order,
|
||||||
|
};
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value) params[key] = value;
|
||||||
|
});
|
||||||
|
if (onlyWarning) params.onlyWarning = '1';
|
||||||
|
return params;
|
||||||
|
}, [page, pageSize, sorter, filters, onlyWarning]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await getModularStockList(query);
|
||||||
|
setData(result?.data || { rows: [], total: 0, summary: {} });
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError?.response?.data?.error || requestError?.message || '加载配件库存监控数据失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const changeFilter = (key) => (value) => {
|
||||||
|
setFilters((old) => ({ ...old, [key]: value || '' }));
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const resetFilters = () => {
|
||||||
|
setFilters(EMPTY_FILTERS);
|
||||||
|
setOnlyWarning(false);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: '图片',
|
||||||
|
dataIndex: 'imageUrl',
|
||||||
|
width: 56,
|
||||||
|
fixed: 'left',
|
||||||
|
exportable: false,
|
||||||
|
render: (value) => (value
|
||||||
|
? <Image src={value} width={36} height={36} style={{ objectFit: 'cover', borderRadius: 4 }} placeholder />
|
||||||
|
: <span className="msm-muted">--</span>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '预警',
|
||||||
|
dataIndex: 'warningLevel',
|
||||||
|
width: 76,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => (value
|
||||||
|
? <Tag color={WARNING_COLOR[value] || 'default'}>{value}</Tag>
|
||||||
|
: <span className="msm-muted">--</span>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <TipTitle
|
||||||
|
title="状态"
|
||||||
|
tip="该 SKU 是否已在「产品资料系统数据库」中建立商品档案。已配置MKH = 产品资料库中能查到该 SKU 编码的商品资料;未配置MKH = 产品资料库中查不到该 SKU 档案"
|
||||||
|
/>,
|
||||||
|
dataIndex: 'mkhStatus',
|
||||||
|
width: 110,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => {
|
||||||
|
const v = value || '未配置MKH';
|
||||||
|
return v === '已配置MKH'
|
||||||
|
? <Tag color="green">{v}</Tag>
|
||||||
|
: <Tag color="default">{v}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SKU编码',
|
||||||
|
dataIndex: 'skuId',
|
||||||
|
width: 176,
|
||||||
|
fixed: 'left',
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => <span className="msm-sku">{text(value)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 必须显式给 width:antd 在 scroll.x 场景下会把无宽度列压成 0 宽(此前该列不可见的原因)
|
||||||
|
title: '商品名称',
|
||||||
|
dataIndex: 'productName',
|
||||||
|
width: 220,
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <TipTitle
|
||||||
|
title="当日入库库存"
|
||||||
|
tip="监控日(pm_stockMonitor 最新快照日)的采购到货入库数量,取自 jst_purchaseItem.inQty。业务库无出入库流水表,0 表示该 SKU 当日无到货"
|
||||||
|
/>,
|
||||||
|
dataIndex: 'inQtyToday',
|
||||||
|
width: 116,
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => (value === null || value === undefined
|
||||||
|
? <span className="msm-muted">--</span>
|
||||||
|
: int(value)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <TipTitle
|
||||||
|
title="当日出库库存"
|
||||||
|
tip="监控日的最近一日发货量,取自 pm_stockMonitor.nearSecondSale。业务库无出入库流水表,0 表示该 SKU 当日无发货"
|
||||||
|
/>,
|
||||||
|
dataIndex: 'outQtyToday',
|
||||||
|
width: 116,
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => (value === null || value === undefined
|
||||||
|
? <span className="msm-muted">--</span>
|
||||||
|
: int(value)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '实时库存',
|
||||||
|
dataIndex: 'realtimeStock',
|
||||||
|
width: 96,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: int,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <TipTitle
|
||||||
|
title="仓位"
|
||||||
|
tip="业务库 jst_sku.bin / other_bin 全库 55.7 万行均为空,上游未回传库位数据,因此该列暂无值"
|
||||||
|
/>,
|
||||||
|
dataIndex: 'location',
|
||||||
|
width: 88,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => (value
|
||||||
|
? <span>{value}</span>
|
||||||
|
: <Tooltip title="上游未提供库位数据">
|
||||||
|
<span className="msm-muted">未维护</span>
|
||||||
|
</Tooltip>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '近7日日均发货量',
|
||||||
|
dataIndex: 'avgShip7d',
|
||||||
|
width: 138,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => number(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '库存可用天数',
|
||||||
|
dataIndex: 'stockDays',
|
||||||
|
width: 116,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => number(value, 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '所在仓库',
|
||||||
|
dataIndex: 'warehouse',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: true,
|
||||||
|
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const exportColumns = useMemo(
|
||||||
|
() => columns.filter((column) => column.exportable !== false),
|
||||||
|
[columns]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExport = async (format) => {
|
||||||
|
if (!data.rows.length) {
|
||||||
|
message.warning('暂无数据可导出');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await exportTableFile(
|
||||||
|
format,
|
||||||
|
`配件库存监控_${data.monitorDate || '最新'}`,
|
||||||
|
exportColumns,
|
||||||
|
data.rows,
|
||||||
|
'配件库存监控'
|
||||||
|
);
|
||||||
|
message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`);
|
||||||
|
} catch (exportError) {
|
||||||
|
message.error(exportError?.message || '导出失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = data.summary || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="msm-filter-card" bordered={false}>
|
||||||
|
<Space wrap size={[12, 12]}>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="SKU 编码 / 名称"
|
||||||
|
value={filters.keyword}
|
||||||
|
onChange={(e) => changeFilter('keyword')(e.target.value)}
|
||||||
|
style={{ width: 220 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
placeholder="品类"
|
||||||
|
options={toOptions(filterOptions.categories)}
|
||||||
|
value={filters.category || undefined}
|
||||||
|
onChange={changeFilter('category')}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="预警等级"
|
||||||
|
options={toOptions(filterOptions.warningLevels)}
|
||||||
|
value={filters.warningLevel || undefined}
|
||||||
|
onChange={changeFilter('warningLevel')}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="MKH 配置状态"
|
||||||
|
options={toOptions(filterOptions.mkhStatuses)}
|
||||||
|
value={filters.mkhStatus || undefined}
|
||||||
|
onChange={changeFilter('mkhStatus')}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
/>
|
||||||
|
<Space size={6}>
|
||||||
|
<Switch checked={onlyWarning} onChange={(checked) => { setOnlyWarning(checked); setPage(1); }} size="small" />
|
||||||
|
<Text type="secondary">仅看缺货 / 预警</Text>
|
||||||
|
</Space>
|
||||||
|
<Button onClick={resetFilters}>重置</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
|
||||||
|
|
||||||
|
<Row gutter={[12, 12]} className="msm-summary-row">
|
||||||
|
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="SKU 总数" value={summary.skuCount || 0} /></Card></Col>
|
||||||
|
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-red"><Statistic title="缺货 SKU" value={summary.outOfStockCount || 0} /></Card></Col>
|
||||||
|
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-orange"><Statistic title="可用天数不足" value={summary.lowStockDaysCount || 0} suffix="个 SKU" /></Card></Col>
|
||||||
|
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-green"><Statistic title="实时库存合计" value={summary.totalStock || 0} /></Card></Col>
|
||||||
|
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="已配置MKH" value={summary.configuredCount || 0} /></Card></Col>
|
||||||
|
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="未配置MKH" value={summary.unconfiguredCount || 0} /></Card></Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
className="msm-panel"
|
||||||
|
title={(
|
||||||
|
<Space size={12} wrap>
|
||||||
|
<span>配件库存明细({data.total || 0} 条)</span>
|
||||||
|
{data.monitorDate ? <Text type="secondary" className="msm-panel__date">监控日 {data.monitorDate}</Text> : null}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
extra={(
|
||||||
|
<Dropdown menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'xlsx', label: '导出 XLSX' },
|
||||||
|
{ key: 'csv', label: '导出 CSV' },
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => handleExport(key),
|
||||||
|
}}>
|
||||||
|
<Button icon={<DownloadOutlined />}>导出</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading} tip="正在加载配件库存监控数据…">
|
||||||
|
<div ref={tableWrapRef}>
|
||||||
|
<Table
|
||||||
|
rowKey="skuId"
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.rows || []}
|
||||||
|
onChange={(pagination, _filter, nextSorter) => {
|
||||||
|
setPage(pagination.current || 1);
|
||||||
|
setPageSize(pagination.pageSize || 50);
|
||||||
|
if (nextSorter?.field) {
|
||||||
|
setSorter({
|
||||||
|
field: nextSorter.field,
|
||||||
|
order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
current: data.page || page,
|
||||||
|
pageSize: data.pageSize || pageSize,
|
||||||
|
total: data.total || 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [50, 100, 200],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1508, y: bodyHeight }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ModularStockMonitor = () => (
|
||||||
|
<SycmPage className="msm-page">
|
||||||
|
<header className="msm-header">
|
||||||
|
<div className="msm-header__heading">
|
||||||
|
<Title level={3}>模块化库存监控</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 7 日日均发货量与可用天数
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" className="msm-note">
|
||||||
|
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 7 日日均发货量、
|
||||||
|
库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖);
|
||||||
|
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」;
|
||||||
|
图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。
|
||||||
|
状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<Tabs
|
||||||
|
className="msm-tabs"
|
||||||
|
activeKey="accessory"
|
||||||
|
items={[{
|
||||||
|
key: 'accessory',
|
||||||
|
label: '配件库存监控',
|
||||||
|
children: <AccessoryStockPanel />,
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
</SycmPage>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ModularStockMonitor;
|
||||||
156
frontend/src/pages/ModularStockMonitor/style.css
Normal file
156
frontend/src/pages/ModularStockMonitor/style.css
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
.msm-page {
|
||||||
|
container-name: modular-stock-monitor;
|
||||||
|
container-type: inline-size;
|
||||||
|
display: flex;
|
||||||
|
min-height: calc(100% - 46px);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-page-section-gap);
|
||||||
|
background: var(--sycm-bg-page-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msm-header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 84px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sycm-space-lg);
|
||||||
|
margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0;
|
||||||
|
padding: 14px calc(var(--sycm-page-padding-x) + 2px);
|
||||||
|
border-bottom: 1px solid var(--sycm-border-base);
|
||||||
|
background: var(--sycm-bg-container);
|
||||||
|
}
|
||||||
|
.msm-header__heading { min-width: 0; }
|
||||||
|
.msm-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-page);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
line-height: var(--sycm-line-height-tight);
|
||||||
|
}
|
||||||
|
.msm-header__heading .ant-typography {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msm-tabs .ant-tabs-nav {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 calc(var(--sycm-page-padding-x) + 2px);
|
||||||
|
border-bottom: 1px solid var(--sycm-border-base);
|
||||||
|
background: var(--sycm-bg-container);
|
||||||
|
}
|
||||||
|
.msm-tabs .ant-tabs-tab {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 4px;
|
||||||
|
font-size: var(--sycm-font-size-title);
|
||||||
|
}
|
||||||
|
.msm-tabs .ant-tabs-content-holder,
|
||||||
|
.msm-tabs .ant-tabs-content,
|
||||||
|
.msm-tabs .ant-tabs-tabpane { min-height: 100%; }
|
||||||
|
.msm-tabs .ant-tabs-tabpane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-page-section-gap);
|
||||||
|
padding-top: var(--sycm-page-section-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msm-filter-card,
|
||||||
|
.msm-panel,
|
||||||
|
.msm-summary-card {
|
||||||
|
border: 1px solid var(--sycm-border-card) !important;
|
||||||
|
border-radius: var(--sycm-radius-lg) !important;
|
||||||
|
box-shadow: var(--sycm-shadow-card) !important;
|
||||||
|
}
|
||||||
|
.msm-filter-card { margin-bottom: 0; }
|
||||||
|
.msm-filter-card .ant-card-body { padding: 14px 16px; }
|
||||||
|
.msm-filter-card .ant-space { width: 100%; }
|
||||||
|
.msm-filter-card .ant-space-item { max-width: 100%; }
|
||||||
|
.msm-filter-card .ant-select,
|
||||||
|
.msm-filter-card .ant-input-affix-wrapper { max-width: 100%; }
|
||||||
|
|
||||||
|
.msm-summary-row { margin-top: -4px; }
|
||||||
|
.msm-summary-row .ant-col { display: flex; }
|
||||||
|
.msm-summary-card {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 98px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.msm-summary-card::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--sycm-color-primary);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
.msm-summary-card.is-red::before { background: var(--sycm-color-danger); }
|
||||||
|
.msm-summary-card.is-orange::before { background: var(--sycm-color-orange); }
|
||||||
|
.msm-summary-card.is-green::before { background: var(--sycm-color-success); }
|
||||||
|
.msm-summary-card .ant-card-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 98px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.msm-summary-card .ant-statistic { width: 100%; }
|
||||||
|
.msm-summary-card .ant-statistic-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
.msm-summary-card .ant-statistic-content {
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-family: var(--sycm-font-family-number);
|
||||||
|
font-size: var(--sycm-font-size-metric);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.msm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); }
|
||||||
|
|
||||||
|
.msm-panel { margin-bottom: 0; overflow: hidden; }
|
||||||
|
.msm-panel .ant-card-head {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom-color: var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
.msm-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); }
|
||||||
|
.msm-panel .ant-card-body { padding: 0 16px 16px; }
|
||||||
|
.msm-panel .ant-table { font-size: var(--sycm-font-size-caption); }
|
||||||
|
.msm-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); }
|
||||||
|
.msm-panel .ant-table-placeholder .ant-table-cell { height: 180px; }
|
||||||
|
.msm-panel__date { font-size: var(--sycm-font-size-caption); }
|
||||||
|
|
||||||
|
.msm-muted { color: var(--sycm-text-tertiary); }
|
||||||
|
/* 表头口径提示:虚线下划线 + help 光标 */
|
||||||
|
.msm-th-tip {
|
||||||
|
border-bottom: 1px dashed var(--sycm-border-strong, var(--sycm-text-tertiary));
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.msm-note {
|
||||||
|
display: block;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.msm-sku {
|
||||||
|
font-family: var(--sycm-font-family-number);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
@container modular-stock-monitor (max-width: 900px) {
|
||||||
|
.msm-filter-card .ant-space-item { flex: 1 1 180px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.msm-header {
|
||||||
|
min-height: 72px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
padding-block: 12px;
|
||||||
|
}
|
||||||
|
.msm-filter-card .ant-select,
|
||||||
|
.msm-filter-card .ant-input-affix-wrapper { width: 100%; }
|
||||||
|
.msm-panel .ant-card-body { padding-inline: 10px; }
|
||||||
|
}
|
||||||
233
frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx
Normal file
233
frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert, Button, Card, Col, DatePicker, Descriptions, Drawer, Dropdown, Input, Row, Select,
|
||||||
|
Space, Spin, Statistic, Table, Tabs, Tag, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { SycmPage } from '../../components/Common';
|
||||||
|
import { getOpsBIncentiveBatches, getOpsBIncentiveBoard, getOpsBIncentiveDaily } from '../../api/planningUpload';
|
||||||
|
import { exportTableFile } from '../../utils/tableExport';
|
||||||
|
import './opsBIncentiveBoard.css';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const FALLBACK_BATCH = { key: 'ops-b-20260715-20260721', batchNo: 1, scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 };
|
||||||
|
const batchLabel = (item) => {
|
||||||
|
const mmdd = (value) => dayjs(value).format('MM-DD');
|
||||||
|
const cap = `${parseFloat((Number(item.feeCap) * 100).toFixed(1))}%`;
|
||||||
|
return `第${item.batchNo}批(${mmdd(item.scopeStart)} 至 ${mmdd(item.scopeEnd)} · 拉取 ${mmdd(item.pullDate)} · 费比≤${cap})${item.finalized ? ' · 已定档' : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const number = (value, digits = 2) => value === null || value === undefined || value === ''
|
||||||
|
? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
const money = (value) => value === null || value === undefined ? '--' : `¥${number(value)}`;
|
||||||
|
const percent = (value) => value === null || value === undefined ? '--' : `${(Number(value) * 100).toFixed(2)}%`;
|
||||||
|
const dateText = (value) => value ? dayjs(value).format('YYYY-MM-DD') : '--';
|
||||||
|
|
||||||
|
const OpsBIncentiveBoardPanel = () => {
|
||||||
|
const [batches, setBatches] = useState([]);
|
||||||
|
const [batch, setBatch] = useState(FALLBACK_BATCH.key);
|
||||||
|
const [metricEnd, setMetricEnd] = useState(dayjs(FALLBACK_BATCH.pullDate));
|
||||||
|
const [filters, setFilters] = useState({ ider: '', shopName: '', spu: '', goodsId: '' });
|
||||||
|
const [data, setData] = useState({ rows: [], ownerSummary: [], summary: {} });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [dailyRows, setDailyRows] = useState([]);
|
||||||
|
const [dailyLoading, setDailyLoading] = useState(false);
|
||||||
|
|
||||||
|
const selectedBatch = useMemo(
|
||||||
|
() => batches.find((item) => item.key === batch) || FALLBACK_BATCH,
|
||||||
|
[batches, batch]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getOpsBIncentiveBatches()
|
||||||
|
.then((result) => {
|
||||||
|
const list = result?.data?.data || result?.data || [];
|
||||||
|
if (cancelled || !Array.isArray(list) || !list.length) return;
|
||||||
|
setBatches(list);
|
||||||
|
if (!list.some((item) => item.key === FALLBACK_BATCH.key)) {
|
||||||
|
setBatch(list[0].key);
|
||||||
|
setMetricEnd(dayjs(list[0].pullDate));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const query = useMemo(() => ({
|
||||||
|
scopeStart: selectedBatch.scopeStart,
|
||||||
|
scopeEnd: selectedBatch.scopeEnd,
|
||||||
|
metricEnd: metricEnd?.format('YYYY-MM-DD') || selectedBatch.pullDate,
|
||||||
|
...filters,
|
||||||
|
}), [selectedBatch, metricEnd, filters]);
|
||||||
|
|
||||||
|
const changeBatch = (value) => {
|
||||||
|
setBatch(value);
|
||||||
|
const next = batches.find((item) => item.key === value);
|
||||||
|
if (next) setMetricEnd(dayjs(next.pullDate));
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true); setError('');
|
||||||
|
try {
|
||||||
|
const result = await getOpsBIncentiveBoard(query);
|
||||||
|
setData(result?.data || result || { rows: [], ownerSummary: [], summary: {} });
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError?.message || '加载运营中心B看板失败');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const openDetail = async (record) => {
|
||||||
|
setSelected(record); setDailyRows([]); setDailyLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getOpsBIncentiveDaily({ ...query, goodsId: record.goodsId });
|
||||||
|
setDailyRows(result?.data?.rows || result?.rows || []);
|
||||||
|
} catch (requestError) {
|
||||||
|
message.error(requestError?.message || '加载每日明细失败');
|
||||||
|
} finally { setDailyLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const iderOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.ider).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]);
|
||||||
|
const shopOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.shopName).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]);
|
||||||
|
// 分组:0=动销率与费比均达标,1=仅一项达标,2=均不达标;组内按动销率降序。
|
||||||
|
const sortedRows = useMemo(() => [...(data.rows || [])].sort((a, b) => {
|
||||||
|
const group = (row) => (row.bonusQualified ? 0 : (row.feeQualified || row.sellThroughQualified ? 1 : 2));
|
||||||
|
const rate = (row) => (row.sellThroughRate30d === null || row.sellThroughRate30d === undefined ? -1 : row.sellThroughRate30d);
|
||||||
|
return group(a) - group(b) || rate(b) - rate(a) || String(a.goodsId).localeCompare(String(b.goodsId));
|
||||||
|
}), [data.rows]);
|
||||||
|
const num = (value) => (value === null || value === undefined || value === '' ? -Infinity : Number(value));
|
||||||
|
const qualifyFilters = [
|
||||||
|
{ text: '达标', value: 'yes' },
|
||||||
|
{ text: '未达标', value: 'no' },
|
||||||
|
];
|
||||||
|
const qualifyFilter = (field) => ({
|
||||||
|
filters: qualifyFilters,
|
||||||
|
onFilter: (value, row) => (value === 'yes' ? Boolean(row[field]) : !row[field]),
|
||||||
|
});
|
||||||
|
const columns = [
|
||||||
|
{ title: 'ID负责人', dataIndex: 'ider', fixed: 'left', width: 100 },
|
||||||
|
{ title: '商品ID', dataIndex: 'goodsId', fixed: 'left', width: 130, render: (value, row) => <Button type="link" className="ops-b-id-link" onClick={() => openDetail(row)}>{value}</Button> },
|
||||||
|
{ title: '商品名称', dataIndex: 'goodsName', width: 220, ellipsis: true },
|
||||||
|
{ title: '店铺名称', dataIndex: 'shopName', width: 150, ellipsis: true },
|
||||||
|
{ title: 'SPU', dataIndex: 'spu', width: 120 },
|
||||||
|
{ title: '链接类型', dataIndex: 'linkType', width: 100 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 80, render: (v) => <Tag color={v === '在售' ? 'green' : 'default'}>{v}</Tag> },
|
||||||
|
{ title: 'SKU数', dataIndex: 'skuCount', width: 80, align: 'right', sorter: (a, b) => num(a.skuCount) - num(b.skuCount) },
|
||||||
|
{ title: '上架时间', dataIndex: 'firstCreatedAt', width: 120, render: dateText, exportValue: dateText },
|
||||||
|
{ title: '上架天数', dataIndex: 'listingDays', width: 90, align: 'right', sorter: (a, b) => num(a.listingDays) - num(b.listingDays), render: (v) => number(v, 0) },
|
||||||
|
{ title: '近30天ID动销率', dataIndex: 'sellThroughRate30d', width: 130, align: 'right', sorter: (a, b) => num(a.sellThroughRate30d) - num(b.sellThroughRate30d), render: percent, exportValue: percent },
|
||||||
|
{ title: '动销率是否达标', dataIndex: 'sellThroughQualified', width: 120, ...qualifyFilter('sellThroughQualified'), render: (v) => <Tag color={v ? 'green' : 'red'}>{v ? '达标' : '未达标'}</Tag>, exportValue: (v) => (v ? '达标' : '未达标') },
|
||||||
|
{ title: '近30天费比', dataIndex: 'feeRatio30d', width: 110, align: 'right', sorter: (a, b) => num(a.feeRatio30d) - num(b.feeRatio30d), render: percent, exportValue: percent },
|
||||||
|
{ title: '费比是否达标', dataIndex: 'feeQualified', width: 105, ...qualifyFilter('feeQualified'), render: (v) => <Tag color={v ? 'green' : 'red'}>{v ? '达标' : '未达标'}</Tag>, exportValue: (v) => (v ? '达标' : '未达标') },
|
||||||
|
{ title: '近30天真实业绩', dataIndex: 'performance30d', width: 135, align: 'right', sorter: (a, b) => num(a.performance30d) - num(b.performance30d), render: money },
|
||||||
|
{ title: '近30天真实费用', dataIndex: 'fee30d', width: 135, align: 'right', sorter: (a, b) => num(a.fee30d) - num(b.fee30d), render: money },
|
||||||
|
{ title: '近30天发货件数', dataIndex: 'goodsCount30d', width: 125, align: 'right', sorter: (a, b) => num(a.goodsCount30d) - num(b.goodsCount30d), render: (v) => number(v, 0) },
|
||||||
|
{ title: '近30天发货天数', dataIndex: 'shippedDays30d', width: 125, align: 'right', sorter: (a, b) => num(a.shippedDays30d) - num(b.shippedDays30d), render: (v) => number(v, 0) },
|
||||||
|
{ title: '应发奖金', dataIndex: 'bonus', width: 100, align: 'right', sorter: (a, b) => num(a.bonus) - num(b.bonus), render: money },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ownerSummaryColumns = [
|
||||||
|
{ title: 'ID负责人', dataIndex: 'ider' },
|
||||||
|
{ title: '商品ID数', dataIndex: 'goodsIdCount' },
|
||||||
|
{ title: '近30天发货件数', dataIndex: 'goodsCount30d', render: (v) => number(v, 0) },
|
||||||
|
{ title: '费比达标', dataIndex: 'feeQualifiedCount' },
|
||||||
|
{ title: '奖金达标', dataIndex: 'bonusQualifiedCount' },
|
||||||
|
{ title: '应发奖金', dataIndex: 'bonus', render: money },
|
||||||
|
];
|
||||||
|
|
||||||
|
const exportFileName = (label) => (
|
||||||
|
`激励看板_${label}_${selectedBatch.scopeStart}_${selectedBatch.scopeEnd}_统计至${metricEnd?.format('YYYYMMDD') || '最新'}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExport = async (scope, format) => {
|
||||||
|
const isSummary = scope === 'summary';
|
||||||
|
const exportColumns = isSummary ? ownerSummaryColumns : columns;
|
||||||
|
const exportRows = isSummary ? (data.ownerSummary || []) : sortedRows;
|
||||||
|
if (!exportRows.length) {
|
||||||
|
message.warning(isSummary ? '负责人汇总暂无数据可导出' : 'ID核心明细暂无数据可导出');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await exportTableFile(format, exportFileName(isSummary ? '负责人汇总' : 'ID核心明细'), exportColumns, exportRows, isSummary ? '负责人汇总' : 'ID核心明细');
|
||||||
|
message.success(`已导出 ${exportRows.length} 条数据(${format.toUpperCase()})`);
|
||||||
|
} catch (exportError) {
|
||||||
|
message.error(exportError?.message || '导出失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportDropdown = (scope) => (
|
||||||
|
<Dropdown menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'xlsx', label: '导出 XLSX' },
|
||||||
|
{ key: 'csv', label: '导出 CSV' },
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => handleExport(scope, key),
|
||||||
|
}}>
|
||||||
|
<Button icon={<DownloadOutlined />}>导出</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = data.summary || {};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="ops-b-filter-card" bordered={false}>
|
||||||
|
<Space wrap size={[12, 12]}>
|
||||||
|
<Select
|
||||||
|
value={batch}
|
||||||
|
options={(batches.length ? batches : [FALLBACK_BATCH]).map((item) => ({ value: item.key, label: batchLabel(item) }))}
|
||||||
|
onChange={changeBatch}
|
||||||
|
style={{ width: 360 }}
|
||||||
|
/>
|
||||||
|
<DatePicker value={metricEnd} allowClear={false} onChange={setMetricEnd} placeholder="统计截止日" />
|
||||||
|
<Select allowClear showSearch placeholder="ID负责人" options={iderOptions} value={filters.ider || undefined} onChange={(value) => setFilters((old) => ({ ...old, ider: value || '' }))} style={{ width: 150 }} />
|
||||||
|
<Select allowClear showSearch placeholder="店铺名称" options={shopOptions} value={filters.shopName || undefined} onChange={(value) => setFilters((old) => ({ ...old, shopName: value || '' }))} style={{ width: 180 }} />
|
||||||
|
<Input allowClear prefix={<SearchOutlined />} placeholder="SPU" value={filters.spu} onChange={(e) => setFilters((old) => ({ ...old, spu: e.target.value }))} style={{ width: 150 }} />
|
||||||
|
<Input allowClear prefix={<SearchOutlined />} placeholder="商品ID" value={filters.goodsId} onChange={(e) => setFilters((old) => ({ ...old, goodsId: e.target.value }))} style={{ width: 170 }} />
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
|
||||||
|
<Row gutter={[12, 12]} className="ops-b-summary-row">
|
||||||
|
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-blue"><Statistic title="商品ID数" value={summary.goodsIdCount || 0} /></Card></Col>
|
||||||
|
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-green"><Statistic title="费比达标ID" value={summary.feeQualifiedCount || 0} /></Card></Col>
|
||||||
|
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-orange"><Statistic title="动销率 > 30%" value={summary.sellThroughQualifiedCount || 0} /></Card></Col>
|
||||||
|
<Col xs={12} md={6}><Card bordered={false} className="ops-b-summary-card is-gold"><Statistic title="应发奖金" value={summary.bonus || 0} prefix="¥" precision={2} /></Card></Col>
|
||||||
|
</Row>
|
||||||
|
<Card bordered={false} className="ops-b-panel" title="负责人汇总" extra={exportDropdown('summary')}>
|
||||||
|
<Table rowKey="ider" size="small" pagination={false} dataSource={data.ownerSummary || []} columns={ownerSummaryColumns} />
|
||||||
|
</Card>
|
||||||
|
<Card bordered={false} className="ops-b-panel" title={`ID核心明细(${(data.rows || []).length} 条)`} extra={exportDropdown('detail')}>
|
||||||
|
<Spin spinning={loading} tip="正在加载运营中心B数据…"><Table rowKey="goodsId" size="small" columns={columns} dataSource={sortedRows} pagination={false} scroll={{ x: 2400, y: 'min(520px, calc(100vh - 460px))' }} /></Spin>
|
||||||
|
</Card>
|
||||||
|
<Drawer className="ops-b-detail-drawer" title={selected ? `${selected.goodsId} · 每日发货明细` : '每日发货明细'} width={760} open={Boolean(selected)} onClose={() => setSelected(null)}>
|
||||||
|
{selected ? <><Descriptions size="small" bordered column={2} items={[{ key: 'name', label: '商品名称', children: selected.goodsName || '--' }, { key: 'owner', label: '负责人', children: selected.ider || '--' }, { key: 'fee', label: '近30天费比', children: percent(selected.feeRatio30d) }, { key: 'bonus', label: '奖金状态', children: selected.bonusQualified ? <Tag color="gold">达标 ¥{AWARD}</Tag> : <Tag>未达标</Tag> }]} /><Table className="ops-b-daily-table" loading={dailyLoading} rowKey="statDate" size="small" pagination={false} dataSource={dailyRows} columns={[{ title: '日期', dataIndex: 'statDate', render: dateText }, { title: '发货件数', dataIndex: 'goodsCount', align: 'right', render: (v) => number(v, 0) }, { title: '是否动销', dataIndex: 'isActive' }, { title: '真实业绩', dataIndex: 'realPerformance', align: 'right', render: money }, { title: '真实费用', dataIndex: 'realFee', align: 'right', render: money }]} /></> : null}
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpsBIncentiveBoard = () => (
|
||||||
|
<SycmPage className="ops-b-incentive-page">
|
||||||
|
<header className="ops-b-header">
|
||||||
|
<div className="ops-b-header__heading">
|
||||||
|
<Title level={3}>激励看板</Title>
|
||||||
|
<Text type="secondary">按中心切换查看商品激励奖金、费比与动销达成情况</Text>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<Tabs
|
||||||
|
className="ops-b-tabs"
|
||||||
|
defaultActiveKey="ops-b"
|
||||||
|
items={[
|
||||||
|
{ key: 'ops-b', label: '运营中心B', children: <OpsBIncentiveBoardPanel /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SycmPage>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AWARD = 400;
|
||||||
|
export default OpsBIncentiveBoard;
|
||||||
160
frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css
Normal file
160
frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
.ops-b-incentive-page {
|
||||||
|
container-name: ops-b-incentive;
|
||||||
|
container-type: inline-size;
|
||||||
|
display: flex;
|
||||||
|
min-height: calc(100% - 46px);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-page-section-gap);
|
||||||
|
background: var(--sycm-bg-page-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-b-header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 84px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sycm-space-lg);
|
||||||
|
margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0;
|
||||||
|
padding: 14px calc(var(--sycm-page-padding-x) + 2px);
|
||||||
|
border-bottom: 1px solid var(--sycm-border-base);
|
||||||
|
background: var(--sycm-bg-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-b-header__heading { min-width: 0; }
|
||||||
|
.ops-b-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-page);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
line-height: var(--sycm-line-height-tight);
|
||||||
|
}
|
||||||
|
.ops-b-header__heading .ant-typography {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-b-tabs .ant-tabs-nav {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 calc(var(--sycm-page-padding-x) + 2px);
|
||||||
|
border-bottom: 1px solid var(--sycm-border-base);
|
||||||
|
background: var(--sycm-bg-container);
|
||||||
|
}
|
||||||
|
.ops-b-tabs .ant-tabs-tab {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 4px;
|
||||||
|
font-size: var(--sycm-font-size-title);
|
||||||
|
}
|
||||||
|
.ops-b-tabs .ant-tabs-tab + .ant-tabs-tab { margin-left: 32px; }
|
||||||
|
.ops-b-tabs .ant-tabs-content-holder,
|
||||||
|
.ops-b-tabs .ant-tabs-content,
|
||||||
|
.ops-b-tabs .ant-tabs-tabpane { min-height: 100%; }
|
||||||
|
.ops-b-tabs .ant-tabs-tabpane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-page-section-gap);
|
||||||
|
padding-top: var(--sycm-page-section-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-b-filter-card,
|
||||||
|
.ops-b-panel,
|
||||||
|
.ops-b-summary-card {
|
||||||
|
border: 1px solid var(--sycm-border-card) !important;
|
||||||
|
border-radius: var(--sycm-radius-lg) !important;
|
||||||
|
box-shadow: var(--sycm-shadow-card) !important;
|
||||||
|
}
|
||||||
|
.ops-b-filter-card { margin-bottom: 0; }
|
||||||
|
.ops-b-filter-card .ant-card-body { padding: 14px 16px; }
|
||||||
|
.ops-b-filter-card .ant-space { width: 100%; }
|
||||||
|
.ops-b-filter-card .ant-space-item { max-width: 100%; }
|
||||||
|
.ops-b-filter-card .ant-select,
|
||||||
|
.ops-b-filter-card .ant-picker,
|
||||||
|
.ops-b-filter-card .ant-input-affix-wrapper { max-width: 100%; }
|
||||||
|
|
||||||
|
.ops-b-summary-row { margin-top: -4px; }
|
||||||
|
.ops-b-summary-row .ant-col { display: flex; }
|
||||||
|
.ops-b-summary-card {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 98px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ops-b-summary-card::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--sycm-color-primary);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
.ops-b-summary-card.is-green::before { background: var(--sycm-color-success); }
|
||||||
|
.ops-b-summary-card.is-orange::before { background: var(--sycm-color-orange); }
|
||||||
|
.ops-b-summary-card.is-gold::before { background: var(--sycm-color-warning); }
|
||||||
|
.ops-b-summary-card .ant-card-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 98px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.ops-b-summary-card .ant-statistic { width: 100%; }
|
||||||
|
.ops-b-summary-card .ant-statistic-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
.ops-b-summary-card .ant-statistic-content {
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-family: var(--sycm-font-family-number);
|
||||||
|
font-size: var(--sycm-font-size-metric);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-b-panel { margin-bottom: 0; overflow: hidden; }
|
||||||
|
.ops-b-panel .ant-card-head {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom-color: var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
.ops-b-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); }
|
||||||
|
.ops-b-panel .ant-card-body { padding: 0 16px 16px; }
|
||||||
|
.ops-b-panel .ant-table { font-size: var(--sycm-font-size-caption); }
|
||||||
|
.ops-b-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); }
|
||||||
|
.ops-b-panel .ant-table-placeholder .ant-table-cell { height: 180px; }
|
||||||
|
.ops-b-id-link {
|
||||||
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--sycm-font-family-number);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
.ops-b-daily-table { margin-top: var(--sycm-space-md); }
|
||||||
|
|
||||||
|
.ops-b-detail-drawer .ant-drawer-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom-color: var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
.ops-b-detail-drawer .ant-drawer-title {
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-title);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
.ops-b-detail-drawer .ant-drawer-body { padding: 20px; }
|
||||||
|
.ops-b-detail-drawer .ant-descriptions { margin-bottom: var(--sycm-space-md); }
|
||||||
|
|
||||||
|
@container ops-b-incentive (max-width: 900px) {
|
||||||
|
.ops-b-filter-card .ant-space-item { flex: 1 1 180px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.ops-b-header {
|
||||||
|
min-height: 72px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
padding-block: 12px;
|
||||||
|
}
|
||||||
|
.ops-b-filter-card .ant-select,
|
||||||
|
.ops-b-filter-card .ant-picker,
|
||||||
|
.ops-b-filter-card .ant-input-affix-wrapper { width: 100%; }
|
||||||
|
.ops-b-panel .ant-card-body { padding-inline: 10px; }
|
||||||
|
.ops-b-detail-drawer .ant-drawer-body { padding: 14px; }
|
||||||
|
}
|
||||||
990
frontend/src/pages/ProductAnalysis/index.jsx
Normal file
990
frontend/src/pages/ProductAnalysis/index.jsx
Normal file
@ -0,0 +1,990 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
DatePicker,
|
||||||
|
Drawer,
|
||||||
|
Empty,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Popover,
|
||||||
|
Segmented,
|
||||||
|
Select,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
getProductAnalysisMeta,
|
||||||
|
getProductAnalysisProducts,
|
||||||
|
getProductAnalysisSignalThresholds,
|
||||||
|
getProductAnalysisTab,
|
||||||
|
saveProductAnalysisSignalThresholds,
|
||||||
|
} from '../../api/productAnalysis';
|
||||||
|
import {
|
||||||
|
SycmBusinessPanel,
|
||||||
|
SycmDashboardMetric,
|
||||||
|
SycmModuleTabs,
|
||||||
|
SycmPage,
|
||||||
|
SycmStatusTag,
|
||||||
|
SycmToolbar,
|
||||||
|
} from '../../components/Common';
|
||||||
|
import {
|
||||||
|
SYCM_CHART_PEER_AVG_COLOR,
|
||||||
|
SYCM_CHART_STORE_COLOR,
|
||||||
|
sycmEchartsBase,
|
||||||
|
sycmLineSeriesDefaults,
|
||||||
|
} from '../../design-system';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
const DEFAULT_TAB = 'refunds';
|
||||||
|
const TAB_ITEMS = [
|
||||||
|
{ key: 'refunds', label: '退款售后' },
|
||||||
|
{ key: 'promotion', label: '推广投放' },
|
||||||
|
{ key: 'activities', label: '活动信息' },
|
||||||
|
{ key: 'navigator', label: '商品领航员' },
|
||||||
|
{ key: 'service-issues', label: '体验问题' },
|
||||||
|
{ key: 'bad-reviews', label: '差评分析' },
|
||||||
|
];
|
||||||
|
const TAB_KEY_SET = new Set(TAB_ITEMS.map((item) => item.key));
|
||||||
|
const RANGE_SHORTCUTS = [
|
||||||
|
{ key: 'realtime', label: '实时' },
|
||||||
|
{ key: 'yesterday', label: '昨日' },
|
||||||
|
{ key: '7d', label: '近7天' },
|
||||||
|
{ key: '30d', label: '近30天' },
|
||||||
|
{ key: 'week', label: '本周' },
|
||||||
|
{ key: 'month', label: '本月' },
|
||||||
|
];
|
||||||
|
const DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS = Object.freeze({
|
||||||
|
ctr: { low: 3, high: 5 },
|
||||||
|
conversion: { low: 0.3, high: 0.5 },
|
||||||
|
});
|
||||||
|
const TRAFFIC_SIGNAL_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'red', label: <span className="product-analysis-signal-filter__label is-red"><i />红灯</span> },
|
||||||
|
{ value: 'yellow', label: <span className="product-analysis-signal-filter__label is-yellow"><i />黄灯</span> },
|
||||||
|
{ value: 'green', label: <span className="product-analysis-signal-filter__label is-green"><i />绿灯</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONEY_KEYS = new Set(['payOrdrAmt', 'customerUnitPrice', 'pieceUnitPrice', 'spend', 'minPrice', 'maxPrice']);
|
||||||
|
const PERCENT_KEYS = new Set([
|
||||||
|
'goodsVcr',
|
||||||
|
'afterSaleRate',
|
||||||
|
'feeRatio',
|
||||||
|
'ctr',
|
||||||
|
'clickConversionRate',
|
||||||
|
'qltyRfndOrdrCntRto1m',
|
||||||
|
'ptHelpPzRate30d',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value, digits = 0) => new Intl.NumberFormat('zh-CN', {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
}).format(toNumber(value));
|
||||||
|
|
||||||
|
const formatCurrency = (value) => `¥${formatNumber(value, 2)}`;
|
||||||
|
const formatPercent = (value) => `${formatNumber(value, 2)}%`;
|
||||||
|
|
||||||
|
const normalizeTrafficSignalThresholds = (value) => {
|
||||||
|
const normalizeMetric = (metric, defaults) => {
|
||||||
|
const low = Number(metric?.low);
|
||||||
|
const high = Number(metric?.high);
|
||||||
|
return {
|
||||||
|
low: Number.isFinite(low) ? low : defaults.low,
|
||||||
|
high: Number.isFinite(high) ? high : defaults.high,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ctr: normalizeMetric(value?.ctr, DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS.ctr),
|
||||||
|
conversion: normalizeMetric(value?.conversion, DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS.conversion),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTrafficSignal = (value, thresholds) => {
|
||||||
|
const numericValue = toNumber(value);
|
||||||
|
if (numericValue < thresholds.low) {
|
||||||
|
return { tone: 'red', label: '偏低', boundary: thresholds.low };
|
||||||
|
}
|
||||||
|
if (numericValue < thresholds.high) {
|
||||||
|
return { tone: 'yellow', label: '关注', boundary: thresholds.high };
|
||||||
|
}
|
||||||
|
return { tone: 'green', label: '良好', boundary: thresholds.high };
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTrafficSignalReason = ({ metricKey, metricLabel, value, signal, row }) => {
|
||||||
|
if (signal.tone === 'red') {
|
||||||
|
if (metricKey === 'ctr' && toNumber(row.signalImpressions) <= 0) {
|
||||||
|
return '近30天暂无推广曝光,点击率按 0% 计';
|
||||||
|
}
|
||||||
|
if (metricKey === 'conversion' && toNumber(row.signalClicks) <= 0) {
|
||||||
|
return '近30天暂无推广点击,转化率按 0% 计';
|
||||||
|
}
|
||||||
|
return `近30天${metricLabel} ${formatPercent(value)},低于 ${formatPercent(signal.boundary)}`;
|
||||||
|
}
|
||||||
|
if (signal.tone === 'yellow') {
|
||||||
|
return `近30天${metricLabel} ${formatPercent(value)},尚未达到 ${formatPercent(signal.boundary)}`;
|
||||||
|
}
|
||||||
|
return `近30天${metricLabel} ${formatPercent(value)},已达到 ${formatPercent(signal.boundary)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductOperatingSignals = ({ row, thresholds }) => {
|
||||||
|
const metrics = row.signalWindowEndDate && !row.signalIsObserving
|
||||||
|
? [
|
||||||
|
{ key: 'ctr', label: '点击率', value: row.signalCtr, thresholds: thresholds.ctr },
|
||||||
|
{ key: 'conversion', label: '转化率', value: row.signalClickConversionRate, thresholds: thresholds.conversion },
|
||||||
|
].map((metric) => {
|
||||||
|
const signal = resolveTrafficSignal(metric.value, metric.thresholds);
|
||||||
|
return {
|
||||||
|
...metric,
|
||||||
|
signal,
|
||||||
|
reason: buildTrafficSignalReason({
|
||||||
|
metricKey: metric.key,
|
||||||
|
metricLabel: metric.label,
|
||||||
|
value: metric.value,
|
||||||
|
signal,
|
||||||
|
row,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!metrics.length && !row.signalIsObserving) {
|
||||||
|
return <span className="product-analysis-signal-unavailable">--</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redReasons = metrics.filter((metric) => metric.signal.tone === 'red');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="product-analysis-signal-cell">
|
||||||
|
{metrics.length ? (
|
||||||
|
<div className="product-analysis-signal-list">
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<Tooltip key={metric.key} title={metric.reason} placement="top">
|
||||||
|
<span className={`product-analysis-signal is-${metric.signal.tone}`}>
|
||||||
|
<span className="product-analysis-signal__lamp" />
|
||||||
|
<span>{metric.label}</span>
|
||||||
|
<strong>{formatPercent(metric.value)}</strong>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{row.signalIsObserving ? (
|
||||||
|
<div className="product-analysis-signal-observing">
|
||||||
|
<span className="product-analysis-signal-observing__dot" />
|
||||||
|
<span>
|
||||||
|
<strong>新品观察期</strong>
|
||||||
|
<small>点击率、转化率于第 8 天起展示</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{redReasons.length ? (
|
||||||
|
<span className="product-analysis-signal-reason" title={redReasons.map((metric) => metric.reason).join(';')}>
|
||||||
|
<ExclamationCircleOutlined />
|
||||||
|
<span>{redReasons.map((metric) => metric.reason).join(';')}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductQualitySignalCell = ({ row }) => {
|
||||||
|
if (!row.signalQualityStatus) {
|
||||||
|
return <span className="product-analysis-signal-unavailable">--</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="product-analysis-signal-cell">
|
||||||
|
<div className="product-analysis-signal-list">
|
||||||
|
<Tooltip title={row.signalQualityReason || '品质退款率同行排名暂无说明'} placement="top">
|
||||||
|
<span className={`product-analysis-signal is-${row.signalQualityStatus}`}>
|
||||||
|
<span className="product-analysis-signal__lamp" />
|
||||||
|
<span>品质退款率</span>
|
||||||
|
<strong>{formatPercent(row.signalQualityRefundRate)}</strong>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TrafficSignalSettings = ({ value, onChange, onReset, onSave, saving }) => (
|
||||||
|
<div className="product-analysis-signal-settings">
|
||||||
|
<div className="product-analysis-signal-settings__header">
|
||||||
|
<strong>经营信号阈值</strong>
|
||||||
|
<span>近30天 · 新品观察期7天</span>
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
{ key: 'ctr', label: '点击率', step: 0.1 },
|
||||||
|
{ key: 'conversion', label: '转化率', step: 0.1 },
|
||||||
|
].map((metric) => (
|
||||||
|
<div className="product-analysis-signal-setting" key={metric.key}>
|
||||||
|
<strong>{metric.label}</strong>
|
||||||
|
<label>
|
||||||
|
<span className="is-red">红/黄</span>
|
||||||
|
<div className="product-analysis-threshold-input">
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
precision={1}
|
||||||
|
step={metric.step}
|
||||||
|
value={value[metric.key].low}
|
||||||
|
onChange={(nextValue) => onChange(metric.key, 'low', nextValue)}
|
||||||
|
/>
|
||||||
|
<span aria-hidden="true">%</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="is-green">黄/绿</span>
|
||||||
|
<div className="product-analysis-threshold-input">
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
precision={1}
|
||||||
|
step={metric.step}
|
||||||
|
value={value[metric.key].high}
|
||||||
|
onChange={(nextValue) => onChange(metric.key, 'high', nextValue)}
|
||||||
|
/>
|
||||||
|
<span aria-hidden="true">%</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="product-analysis-quality-rule">
|
||||||
|
<strong>品质退款率</strong>
|
||||||
|
<div>
|
||||||
|
<span className="is-green">超越30% / 50%同行</span>
|
||||||
|
<span className="is-yellow">同行后30%</span>
|
||||||
|
<span className="is-red">同行后10%</span>
|
||||||
|
</div>
|
||||||
|
<small>仅按主营类目排名判定</small>
|
||||||
|
</div>
|
||||||
|
<div className="product-analysis-signal-settings__footer">
|
||||||
|
<Button type="text" size="small" onClick={onReset}>恢复默认</Button>
|
||||||
|
<Button type="primary" size="small" onClick={onSave} loading={saving}>保存设置</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatTableValue = (key, value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '--';
|
||||||
|
if (MONEY_KEYS.has(key)) return formatCurrency(value);
|
||||||
|
if (PERCENT_KEYS.has(key)) return formatPercent(value);
|
||||||
|
if (key === 'statDate') return value;
|
||||||
|
if (typeof value === 'number' || /^-?\d+(\.\d+)?$/.test(String(value))) return formatNumber(value, 0);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTabMetric = (metric) => {
|
||||||
|
if (metric.unit === '¥') return formatCurrency(metric.value);
|
||||||
|
if (metric.unit === '%') return formatPercent(metric.value);
|
||||||
|
return formatNumber(metric.value, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildShortcutRange = (shortcut, latestDate) => {
|
||||||
|
const end = dayjs(latestDate);
|
||||||
|
if (!end.isValid()) return ['', ''];
|
||||||
|
if (shortcut === 'yesterday') {
|
||||||
|
const yesterday = end.subtract(1, 'day').format('YYYY-MM-DD');
|
||||||
|
return [yesterday, yesterday];
|
||||||
|
}
|
||||||
|
if (shortcut === '7d') return [end.subtract(6, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||||
|
if (shortcut === '30d') return [end.subtract(29, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||||
|
if (shortcut === 'week') {
|
||||||
|
const mondayOffset = end.day() === 0 ? 6 : end.day() - 1;
|
||||||
|
return [end.subtract(mondayOffset, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||||
|
}
|
||||||
|
if (shortcut === 'month') return [end.startOf('month').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||||
|
return [end.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductAnalysis = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const initialTab = TAB_KEY_SET.has(searchParams.get('tab')) ? searchParams.get('tab') : DEFAULT_TAB;
|
||||||
|
const requestedProductId = searchParams.get('productId') || '';
|
||||||
|
const [meta, setMeta] = useState({ latestDate: '', dates: [], shops: [] });
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
shopName: '',
|
||||||
|
ider: '',
|
||||||
|
listingRange: null,
|
||||||
|
keyword: '',
|
||||||
|
onlyActive: false,
|
||||||
|
});
|
||||||
|
const [rangeShortcut, setRangeShortcut] = useState('7d');
|
||||||
|
const [products, setProducts] = useState([]);
|
||||||
|
const [metrics, setMetrics] = useState({});
|
||||||
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
|
||||||
|
const [sorter, setSorter] = useState({ field: 'payOrdrAmt', order: 'descend' });
|
||||||
|
const [productsLoading, setProductsLoading] = useState(true);
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState(null);
|
||||||
|
const [activeTab, setActiveTab] = useState(initialTab);
|
||||||
|
const [tabData, setTabData] = useState(null);
|
||||||
|
const [tabLoading, setTabLoading] = useState(false);
|
||||||
|
const [serviceIssueDetail, setServiceIssueDetail] = useState(null);
|
||||||
|
const [signalThresholds, setSignalThresholds] = useState(() => normalizeTrafficSignalThresholds(null));
|
||||||
|
const [signalThresholdDraft, setSignalThresholdDraft] = useState(() => normalizeTrafficSignalThresholds(null));
|
||||||
|
const [signalStatus, setSignalStatus] = useState('all');
|
||||||
|
const [signalSettingsOpen, setSignalSettingsOpen] = useState(false);
|
||||||
|
const [signalSettingsSaving, setSignalSettingsSaving] = useState(false);
|
||||||
|
const tabCacheRef = useRef(new Map());
|
||||||
|
const productsRequestRef = useRef(0);
|
||||||
|
|
||||||
|
const loadProducts = async (options = {}) => {
|
||||||
|
const requestId = productsRequestRef.current + 1;
|
||||||
|
productsRequestRef.current = requestId;
|
||||||
|
const nextFilters = options.filters || filters;
|
||||||
|
const nextPage = options.page || pagination.current;
|
||||||
|
const nextPageSize = options.pageSize || pagination.pageSize;
|
||||||
|
const nextSorter = options.sorter || sorter;
|
||||||
|
const nextSignalStatus = options.signalStatus || signalStatus;
|
||||||
|
|
||||||
|
setProductsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getProductAnalysisProducts({
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: nextPageSize,
|
||||||
|
startDate: nextFilters.startDate || undefined,
|
||||||
|
endDate: nextFilters.endDate || undefined,
|
||||||
|
shopName: nextFilters.shopName || undefined,
|
||||||
|
ider: nextFilters.ider || undefined,
|
||||||
|
listingFrom: nextFilters.listingRange?.[0] || undefined,
|
||||||
|
listingTo: nextFilters.listingRange?.[1] || undefined,
|
||||||
|
keyword: nextFilters.keyword || undefined,
|
||||||
|
onlyActive: nextFilters.onlyActive || undefined,
|
||||||
|
signalStatus: nextSignalStatus === 'all' ? undefined : nextSignalStatus,
|
||||||
|
sortField: nextSorter.field,
|
||||||
|
sortOrder: nextSorter.order,
|
||||||
|
rangeDays: options.rangeDays,
|
||||||
|
});
|
||||||
|
if (requestId !== productsRequestRef.current) return;
|
||||||
|
|
||||||
|
const data = response.data || {};
|
||||||
|
const nextRows = data.rows || [];
|
||||||
|
setProducts(nextRows);
|
||||||
|
setMetrics(data.metrics || {});
|
||||||
|
setPagination({ current: data.page || nextPage, pageSize: data.pageSize || nextPageSize, total: data.total || 0 });
|
||||||
|
|
||||||
|
setSelectedProduct((current) => {
|
||||||
|
const preferredId = current?.goodsId || requestedProductId;
|
||||||
|
return nextRows.find((row) => String(row.goodsId) === String(preferredId)) || nextRows[0] || null;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== productsRequestRef.current) return;
|
||||||
|
setProducts([]);
|
||||||
|
setMetrics({});
|
||||||
|
setPagination((current) => ({ ...current, total: 0 }));
|
||||||
|
setSelectedProduct(null);
|
||||||
|
message.error(error.response?.data?.error || error.message || '商品数据加载失败');
|
||||||
|
} finally {
|
||||||
|
if (requestId === productsRequestRef.current) setProductsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const bootstrap = async () => {
|
||||||
|
const productsPromise = loadProducts({ filters, page: 1, rangeDays: 7 });
|
||||||
|
try {
|
||||||
|
const [response, thresholdResult] = await Promise.all([
|
||||||
|
getProductAnalysisMeta(),
|
||||||
|
getProductAnalysisSignalThresholds()
|
||||||
|
.then((thresholdResponse) => ({ response: thresholdResponse, error: null }))
|
||||||
|
.catch((error) => ({ response: null, error })),
|
||||||
|
]);
|
||||||
|
if (!active) return;
|
||||||
|
const nextMeta = response.data || {};
|
||||||
|
const [startDate, endDate] = buildShortcutRange('7d', nextMeta.latestDate);
|
||||||
|
const nextFilters = { ...filters, startDate, endDate };
|
||||||
|
setMeta(nextMeta);
|
||||||
|
setFilters(nextFilters);
|
||||||
|
if (thresholdResult.response) {
|
||||||
|
const nextThresholds = normalizeTrafficSignalThresholds(
|
||||||
|
thresholdResult.response.data?.thresholds
|
||||||
|
);
|
||||||
|
setSignalThresholds(nextThresholds);
|
||||||
|
setSignalThresholdDraft(nextThresholds);
|
||||||
|
} else {
|
||||||
|
message.warning('个人经营信号阈值读取失败,当前使用系统默认值');
|
||||||
|
}
|
||||||
|
await productsPromise;
|
||||||
|
} catch (error) {
|
||||||
|
if (!active) return;
|
||||||
|
setProductsLoading(false);
|
||||||
|
message.error(error.response?.data?.error || error.message || '商品分析初始化失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
bootstrap();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
// Initial bootstrap intentionally owns the first product query.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setServiceIssueDetail(null);
|
||||||
|
if (!selectedProduct?.goodsId) {
|
||||||
|
setTabData(null);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${selectedProduct.goodsId}:${filters.startDate}:${filters.endDate}:${activeTab}`;
|
||||||
|
const cached = tabCacheRef.current.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
setTabData(cached);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setTabLoading(true);
|
||||||
|
setTabData(null);
|
||||||
|
getProductAnalysisTab(selectedProduct.goodsId, activeTab, {
|
||||||
|
startDate: filters.startDate,
|
||||||
|
endDate: filters.endDate,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (!active) return;
|
||||||
|
const data = response.data || null;
|
||||||
|
tabCacheRef.current.set(cacheKey, data);
|
||||||
|
setTabData(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!active) return;
|
||||||
|
setTabData({ status: 'error', message: error.response?.data?.error || error.message || '子模块加载失败' });
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setTabLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [activeTab, filters.endDate, filters.startDate, selectedProduct]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
|
if (selectedProduct?.goodsId) nextParams.set('productId', selectedProduct.goodsId);
|
||||||
|
else nextParams.delete('productId');
|
||||||
|
nextParams.set('tab', activeTab);
|
||||||
|
setSearchParams(nextParams, { replace: true });
|
||||||
|
// searchParams is deliberately excluded to avoid URL update loops.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTab, selectedProduct?.goodsId, setSearchParams]);
|
||||||
|
|
||||||
|
const dashboardMetrics = useMemo(() => [
|
||||||
|
{ key: 'total', label: '商品数', value: formatNumber(metrics.total), footerLabel: '有销售', footerValue: formatNumber(metrics.activeGoods) },
|
||||||
|
{ key: 'payOrdrAmt', label: '销售额', value: formatCurrency(metrics.payOrdrAmt) },
|
||||||
|
{ key: 'orderNum', label: '订单量', value: formatNumber(metrics.orderNum) },
|
||||||
|
{ key: 'payOrdrUsrCnt', label: '买家数', value: formatNumber(metrics.payOrdrUsrCnt) },
|
||||||
|
{ key: 'goodsUv', label: '访客数', value: formatNumber(metrics.goodsUv) },
|
||||||
|
{ key: 'goodsVcr', label: '平均成交转化率', value: formatPercent(metrics.goodsVcr) },
|
||||||
|
], [metrics]);
|
||||||
|
|
||||||
|
const productColumns = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
key: 'product',
|
||||||
|
fixed: 'left',
|
||||||
|
width: 310,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="product-analysis-product-cell">
|
||||||
|
<Image width={46} height={46} src={row.hdThumbUrl} preview={false} fallback="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" />
|
||||||
|
<div>
|
||||||
|
<strong title={row.goodsName}>{row.goodsName || '--'}</strong>
|
||||||
|
<span>ID {row.goodsId} · {row.shopName || '--'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: 'SPU', dataIndex: 'spu', key: 'spu', width: 120, ellipsis: true },
|
||||||
|
{ title: 'SKU货号', dataIndex: 'skuCode', key: 'skuCode', width: 130, ellipsis: true },
|
||||||
|
{ title: '负责人', dataIndex: 'idOwner', key: 'idOwner', width: 90 },
|
||||||
|
{ title: '上架时间', dataIndex: 'firstCreatedAt', key: 'firstCreatedAt', width: 105, render: (value) => value || '--' },
|
||||||
|
{
|
||||||
|
title: '近30天推广信号',
|
||||||
|
key: 'trafficSignal',
|
||||||
|
width: 300,
|
||||||
|
render: (_, row) => <ProductOperatingSignals row={row} thresholds={signalThresholds} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '近30天品质信号(商品领航员)',
|
||||||
|
key: 'qualitySignal',
|
||||||
|
width: 190,
|
||||||
|
render: (_, row) => <ProductQualitySignalCell row={row} />,
|
||||||
|
},
|
||||||
|
{ title: '访客数', dataIndex: 'goodsUv', key: 'goodsUv', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) },
|
||||||
|
{ title: '销售额', dataIndex: 'payOrdrAmt', key: 'payOrdrAmt', width: 120, align: 'right', sorter: true, render: formatCurrency },
|
||||||
|
{ title: '订单量', dataIndex: 'orderNum', key: 'orderNum', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) },
|
||||||
|
{ title: '买家数', dataIndex: 'payOrdrUsrCnt', key: 'payOrdrUsrCnt', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) },
|
||||||
|
{ title: '成交件数', dataIndex: 'payOrdrGoodsQty', key: 'payOrdrGoodsQty', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) },
|
||||||
|
{ title: '成交转化率', dataIndex: 'goodsVcr', key: 'goodsVcr', width: 120, align: 'right', sorter: true, render: formatPercent },
|
||||||
|
{ title: '客单价', dataIndex: 'customerUnitPrice', key: 'customerUnitPrice', width: 110, align: 'right', sorter: true, render: formatCurrency },
|
||||||
|
{
|
||||||
|
title: '价格状态',
|
||||||
|
dataIndex: 'isPriceBreak',
|
||||||
|
key: 'isPriceBreak',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
render: (value) => <SycmStatusTag tone={Number(value) === 1 ? 'error' : 'success'}>{Number(value) === 1 ? '破价' : '正常'}</SycmStatusTag>,
|
||||||
|
},
|
||||||
|
], [signalThresholds]);
|
||||||
|
|
||||||
|
// 快照表缺日期时前后端都不补点会导致 x 轴日期跳跃,这里按日历补全缺失日期
|
||||||
|
const tabTrend = useMemo(() => {
|
||||||
|
const trend = tabData?.trend || [];
|
||||||
|
if (trend.length < 2) return trend;
|
||||||
|
const byDate = new Map(trend.map((item) => [String(item.date).slice(0, 10), item]));
|
||||||
|
const dates = [...byDate.keys()].sort();
|
||||||
|
const start = dayjs(dates[0]);
|
||||||
|
const end = dayjs(dates[dates.length - 1]);
|
||||||
|
if (!start.isValid() || !end.isValid() || end.diff(start, 'day') + 1 === trend.length) return trend;
|
||||||
|
const totalDays = end.diff(start, 'day') + 1;
|
||||||
|
if (totalDays > 400) return trend;
|
||||||
|
const filled = [];
|
||||||
|
for (let cursor = start; !cursor.isAfter(end, 'day'); cursor = cursor.add(1, 'day')) {
|
||||||
|
const key = cursor.format('YYYY-MM-DD');
|
||||||
|
filled.push(byDate.get(key) || { date: key, primary: null, secondary: null });
|
||||||
|
}
|
||||||
|
return filled;
|
||||||
|
}, [tabData]);
|
||||||
|
|
||||||
|
const tabChartOption = useMemo(() => {
|
||||||
|
const trend = tabTrend;
|
||||||
|
return {
|
||||||
|
...sycmEchartsBase,
|
||||||
|
grid: { ...sycmEchartsBase.grid, top: 48, right: 54, bottom: 28, left: 52 },
|
||||||
|
legend: { ...sycmEchartsBase.legend, data: [tabData?.trendLabels?.primary, tabData?.trendLabels?.secondary].filter(Boolean) },
|
||||||
|
xAxis: {
|
||||||
|
...sycmEchartsBase.xAxis,
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: trend.map((item) => item.date),
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{ ...sycmEchartsBase.yAxis, type: 'value' },
|
||||||
|
{ ...sycmEchartsBase.yAxis, type: 'value', splitLine: { show: false } },
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
...sycmLineSeriesDefaults,
|
||||||
|
name: tabData?.trendLabels?.primary,
|
||||||
|
data: trend.map((item) => (item.primary === null ? null : toNumber(item.primary))),
|
||||||
|
connectNulls: true,
|
||||||
|
lineStyle: { width: 3, color: SYCM_CHART_STORE_COLOR },
|
||||||
|
itemStyle: { color: SYCM_CHART_STORE_COLOR },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...sycmLineSeriesDefaults,
|
||||||
|
name: tabData?.trendLabels?.secondary,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: trend.map((item) => (item.secondary === null ? null : toNumber(item.secondary))),
|
||||||
|
connectNulls: true,
|
||||||
|
lineStyle: { width: 3, color: SYCM_CHART_PEER_AVG_COLOR },
|
||||||
|
itemStyle: { color: SYCM_CHART_PEER_AVG_COLOR },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [tabTrend, tabData]);
|
||||||
|
|
||||||
|
const tabColumns = useMemo(() => (tabData?.columns || []).map((column) => ({
|
||||||
|
title: column.title,
|
||||||
|
dataIndex: column.key,
|
||||||
|
key: column.key,
|
||||||
|
align: column.align || (column.key === 'statDate' ? 'left' : 'right'),
|
||||||
|
render: (value) => (column.key === 'viewOrders'
|
||||||
|
? <span className="product-analysis-view-orders"><RightOutlined />查看订单</span>
|
||||||
|
: formatTableValue(column.key, value)),
|
||||||
|
})), [tabData]);
|
||||||
|
|
||||||
|
const serviceIssueOrderColumns = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: 'orderSn',
|
||||||
|
key: 'orderSn',
|
||||||
|
width: 210,
|
||||||
|
render: (value) => <Text copyable={{ text: value }}>{value || '--'}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '问题详情',
|
||||||
|
dataIndex: 'problemDetail',
|
||||||
|
key: 'problemDetail',
|
||||||
|
width: 220,
|
||||||
|
render: (value) => <span className="product-analysis-order-detail">{value || '--'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '问题分类',
|
||||||
|
dataIndex: 'categoryDescription',
|
||||||
|
key: 'categoryDescription',
|
||||||
|
width: 200,
|
||||||
|
render: (value) => <span className="product-analysis-order-detail">{value || '--'}</span>,
|
||||||
|
},
|
||||||
|
{ title: '求助时间', dataIndex: 'seekHelpTime', key: 'seekHelpTime', width: 170 },
|
||||||
|
{
|
||||||
|
title: '体验状态',
|
||||||
|
dataIndex: 'isNegative',
|
||||||
|
key: 'isNegative',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
render: (value) => (
|
||||||
|
<SycmStatusTag tone={value ? 'error' : 'default'}>{value ? '负向体验' : '一般问题'}</SycmStatusTag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const openServiceIssueOrders = (row) => {
|
||||||
|
if (tabData?.tabKey !== 'service-issues') return;
|
||||||
|
const orders = (tabData.orderDetails || []).filter((item) => item.issueType === row.issueType);
|
||||||
|
setServiceIssueDetail({ issueType: row.issueType, orders });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
tabCacheRef.current.clear();
|
||||||
|
loadProducts({ page: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignalStatusChange = (nextStatus) => {
|
||||||
|
setSignalStatus(nextStatus);
|
||||||
|
loadProducts({ page: 1, signalStatus: nextStatus });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableChange = (nextPagination, _tableFilters, tableSorter) => {
|
||||||
|
const nextSorter = tableSorter?.field
|
||||||
|
? { field: tableSorter.field, order: tableSorter.order || 'descend' }
|
||||||
|
: sorter;
|
||||||
|
setSorter(nextSorter);
|
||||||
|
loadProducts({ page: nextPagination.current, pageSize: nextPagination.pageSize, sorter: nextSorter });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSignalThresholdDraft = (metricKey, boundaryKey, value) => {
|
||||||
|
setSignalThresholdDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
[metricKey]: {
|
||||||
|
...current[metricKey],
|
||||||
|
[boundaryKey]: Number.isFinite(Number(value)) ? Number(value) : 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSignalThresholdDraft = () => {
|
||||||
|
setSignalThresholdDraft(normalizeTrafficSignalThresholds(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSignalThresholds = async () => {
|
||||||
|
const nextThresholds = normalizeTrafficSignalThresholds(signalThresholdDraft);
|
||||||
|
if (nextThresholds.ctr.low >= nextThresholds.ctr.high
|
||||||
|
|| nextThresholds.conversion.low >= nextThresholds.conversion.high) {
|
||||||
|
message.warning('黄绿分界值必须大于红黄分界值');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSignalSettingsSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await saveProductAnalysisSignalThresholds(nextThresholds);
|
||||||
|
const savedThresholds = normalizeTrafficSignalThresholds(response.data?.thresholds || nextThresholds);
|
||||||
|
setSignalThresholds(savedThresholds);
|
||||||
|
setSignalThresholdDraft(savedThresholds);
|
||||||
|
setSignalSettingsOpen(false);
|
||||||
|
if (signalStatus !== 'all') {
|
||||||
|
await loadProducts({ page: 1, signalStatus });
|
||||||
|
}
|
||||||
|
message.success('个人经营信号阈值已保存');
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.response?.data?.error || error.message || '个人经营信号阈值保存失败');
|
||||||
|
} finally {
|
||||||
|
setSignalSettingsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SycmPage className="product-analysis-page">
|
||||||
|
<SycmBusinessPanel className="product-analysis-filter-panel">
|
||||||
|
<SycmToolbar>
|
||||||
|
<div className="product-analysis-filters">
|
||||||
|
<Select value="pdd" disabled options={[{ value: 'pdd', label: '拼多多' }]} aria-label="平台" />
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
value={filters.shopName || undefined}
|
||||||
|
placeholder="全部店铺"
|
||||||
|
options={(meta.shops || []).map((shop) => ({ value: shop, label: shop }))}
|
||||||
|
onChange={(value) => setFilters((current) => ({ ...current, shopName: value || '' }))}
|
||||||
|
aria-label="店铺"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
value={filters.ider || undefined}
|
||||||
|
placeholder="全部负责人"
|
||||||
|
options={(meta.owners || []).map((owner) => ({ value: owner, label: owner }))}
|
||||||
|
onChange={(value) => setFilters((current) => ({ ...current, ider: value || '' }))}
|
||||||
|
aria-label="负责人"
|
||||||
|
/>
|
||||||
|
<div className="product-analysis-range-shortcuts" role="group" aria-label="时间范围快捷选择">
|
||||||
|
{RANGE_SHORTCUTS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
className={rangeShortcut === item.key ? 'is-active' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
const [startDate, endDate] = buildShortcutRange(item.key, meta.latestDate);
|
||||||
|
setRangeShortcut(item.key);
|
||||||
|
setFilters((current) => ({ ...current, startDate, endDate }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<RangePicker
|
||||||
|
value={filters.startDate && filters.endDate ? [dayjs(filters.startDate), dayjs(filters.endDate)] : null}
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
allowClear={false}
|
||||||
|
disabledDate={(date) => (
|
||||||
|
(meta.minDate && date.isBefore(dayjs(meta.minDate), 'day'))
|
||||||
|
|| (meta.latestDate && date.isAfter(dayjs(meta.latestDate), 'day'))
|
||||||
|
)}
|
||||||
|
onChange={(dates) => {
|
||||||
|
if (!dates?.[0] || !dates?.[1]) return;
|
||||||
|
setRangeShortcut('custom');
|
||||||
|
setFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
startDate: dates[0].format('YYYY-MM-DD'),
|
||||||
|
endDate: dates[1].format('YYYY-MM-DD'),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<RangePicker
|
||||||
|
value={filters.listingRange?.length === 2 ? [dayjs(filters.listingRange[0]), dayjs(filters.listingRange[1])] : null}
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
allowClear
|
||||||
|
placeholder={['上架开始', '上架结束']}
|
||||||
|
onChange={(dates) => setFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
listingRange: dates?.length === 2 ? [dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')] : null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={filters.keyword}
|
||||||
|
placeholder="搜索商品ID / SKU / SPU / 商品名"
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, keyword: event.target.value }))}
|
||||||
|
onPressEnter={handleQuery}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
className="product-analysis-active-filter"
|
||||||
|
checked={filters.onlyActive}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, onlyActive: event.target.checked }))}
|
||||||
|
>
|
||||||
|
仅看有销售/投放
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<Space size={8}>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => loadProducts()} loading={productsLoading}>刷新</Button>
|
||||||
|
<Button type="primary" icon={<SearchOutlined />} onClick={handleQuery} loading={productsLoading}>查询</Button>
|
||||||
|
</Space>
|
||||||
|
</SycmToolbar>
|
||||||
|
</SycmBusinessPanel>
|
||||||
|
|
||||||
|
<SycmBusinessPanel
|
||||||
|
title="综合看板"
|
||||||
|
extra={<Text type="secondary">数据区间 {metrics.startDate || '--'} 至 {metrics.endDate || '--'}</Text>}
|
||||||
|
className="product-analysis-overview-panel"
|
||||||
|
>
|
||||||
|
<div className="product-analysis-metrics">
|
||||||
|
{dashboardMetrics.map(({ key, ...item }) => <SycmDashboardMetric key={key} {...item} help={false} />)}
|
||||||
|
</div>
|
||||||
|
</SycmBusinessPanel>
|
||||||
|
|
||||||
|
<SycmBusinessPanel
|
||||||
|
title="商品(款)"
|
||||||
|
extra={(
|
||||||
|
<Space size={10}>
|
||||||
|
<Segmented
|
||||||
|
className="product-analysis-signal-filter"
|
||||||
|
size="small"
|
||||||
|
options={TRAFFIC_SIGNAL_FILTER_OPTIONS}
|
||||||
|
value={signalStatus}
|
||||||
|
onChange={handleSignalStatusChange}
|
||||||
|
aria-label="经营信号筛选"
|
||||||
|
/>
|
||||||
|
<Text type="secondary">共 {formatNumber(pagination.total)} 款,点击行查看单品模块</Text>
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
open={signalSettingsOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setSignalSettingsOpen(open);
|
||||||
|
if (open) setSignalThresholdDraft(signalThresholds);
|
||||||
|
}}
|
||||||
|
content={(
|
||||||
|
<TrafficSignalSettings
|
||||||
|
value={signalThresholdDraft}
|
||||||
|
onChange={updateSignalThresholdDraft}
|
||||||
|
onReset={resetSignalThresholdDraft}
|
||||||
|
onSave={saveSignalThresholds}
|
||||||
|
saving={signalSettingsSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button size="small" icon={<SettingOutlined />}>信号阈值</Button>
|
||||||
|
</Popover>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
className="product-analysis-table-panel"
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="goodsId"
|
||||||
|
size="small"
|
||||||
|
loading={productsLoading}
|
||||||
|
columns={productColumns}
|
||||||
|
dataSource={products}
|
||||||
|
scroll={{ x: 1890 }}
|
||||||
|
pagination={{
|
||||||
|
...pagination,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${formatNumber(total)} 款`,
|
||||||
|
}}
|
||||||
|
rowSelection={{
|
||||||
|
type: 'radio',
|
||||||
|
selectedRowKeys: selectedProduct?.goodsId ? [selectedProduct.goodsId] : [],
|
||||||
|
onSelect: (record) => setSelectedProduct(record),
|
||||||
|
columnWidth: 42,
|
||||||
|
}}
|
||||||
|
rowClassName={(record) => String(record.goodsId) === String(selectedProduct?.goodsId) ? 'is-selected' : ''}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => setSelectedProduct(record),
|
||||||
|
})}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前筛选条件暂无商品" /> }}
|
||||||
|
/>
|
||||||
|
</SycmBusinessPanel>
|
||||||
|
|
||||||
|
<SycmBusinessPanel
|
||||||
|
title={selectedProduct ? (
|
||||||
|
<span className="product-analysis-selected-product">
|
||||||
|
<Image
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
src={selectedProduct.hdThumbUrl}
|
||||||
|
preview={false}
|
||||||
|
fallback="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="
|
||||||
|
/>
|
||||||
|
<span className="product-analysis-selected-product__copy">
|
||||||
|
<strong title={selectedProduct.goodsName}>{selectedProduct.goodsName || '未命名商品'}</strong>
|
||||||
|
<span>{selectedProduct.shopName} · 商品ID {selectedProduct.goodsId} · SKU {selectedProduct.skuCode || '--'}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : '单品业务模块'}
|
||||||
|
extra={selectedProduct ? <SycmStatusTag tone="info">{selectedProduct.idOwner || '未分配负责人'}</SycmStatusTag> : null}
|
||||||
|
className="product-analysis-detail-panel"
|
||||||
|
>
|
||||||
|
{selectedProduct ? (
|
||||||
|
<>
|
||||||
|
<SycmModuleTabs items={TAB_ITEMS} activeKey={activeTab} onChange={setActiveTab} />
|
||||||
|
<div className="product-analysis-tab-content">
|
||||||
|
{tabLoading ? <Skeleton active paragraph={{ rows: 8 }} /> : null}
|
||||||
|
{!tabLoading && tabData?.status === 'empty' ? (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={tabData.message} />
|
||||||
|
) : null}
|
||||||
|
{!tabLoading && tabData?.status === 'error' ? (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={tabData.message || '子模块加载失败'} />
|
||||||
|
) : null}
|
||||||
|
{!tabLoading && tabData?.status === 'ready' ? (
|
||||||
|
<>
|
||||||
|
<div className="product-analysis-tab-metrics">
|
||||||
|
{(tabData.metrics || []).map((item) => (
|
||||||
|
<SycmDashboardMetric key={item.key} label={item.label} value={formatTabMetric(item)} help={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={`product-analysis-tab-grid${tabData.trend?.length ? '' : ' is-table-only'}`}>
|
||||||
|
{tabData.trend?.length ? (
|
||||||
|
<section className="product-analysis-trend-block">
|
||||||
|
<h3>{tabData.trendTitle || '区间趋势'}</h3>
|
||||||
|
<ReactECharts option={tabChartOption} style={{ height: 286 }} notMerge lazyUpdate />
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<section className="product-analysis-history-block">
|
||||||
|
<h3>{tabData.tableTitle || '历史明细'}</h3>
|
||||||
|
<Table
|
||||||
|
rowKey={(row) => row.id || row.statDate}
|
||||||
|
size="small"
|
||||||
|
columns={tabColumns}
|
||||||
|
dataSource={tabData.rows || []}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 238 }}
|
||||||
|
rowClassName={() => (tabData.tabKey === 'service-issues' ? 'product-analysis-issue-row' : '')}
|
||||||
|
onRow={(row) => (tabData.tabKey === 'service-issues' ? {
|
||||||
|
onClick: () => openServiceIssueOrders(row),
|
||||||
|
onKeyDown: (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
openServiceIssueOrders(row);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
role: 'button',
|
||||||
|
tabIndex: 0,
|
||||||
|
'aria-label': `查看${row.issueType}的具体订单`,
|
||||||
|
} : {})}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请先从商品主表选择一个商品" />
|
||||||
|
)}
|
||||||
|
</SycmBusinessPanel>
|
||||||
|
<Drawer
|
||||||
|
title={serviceIssueDetail
|
||||||
|
? `${serviceIssueDetail.issueType} · ${serviceIssueDetail.orders.length} 笔订单`
|
||||||
|
: '问题订单明细'}
|
||||||
|
width={980}
|
||||||
|
open={Boolean(serviceIssueDetail)}
|
||||||
|
onClose={() => setServiceIssueDetail(null)}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
size="small"
|
||||||
|
columns={serviceIssueOrderColumns}
|
||||||
|
dataSource={serviceIssueDetail?.orders || []}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||||
|
scroll={{ x: 910 }}
|
||||||
|
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无对应订单" /> }}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
</SycmPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductAnalysis;
|
||||||
776
frontend/src/pages/ProductAnalysis/style.css
Normal file
776
frontend/src/pages/ProductAnalysis/style.css
Normal file
@ -0,0 +1,776 @@
|
|||||||
|
.product-analysis-page {
|
||||||
|
container-name: product-analysis;
|
||||||
|
container-type: inline-size;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-page-section-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filter-panel .sycm-business-panel__body {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filter-panel .sycm-toolbar {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filter-panel .sycm-toolbar > .ant-space {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filters {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
grid-template-columns: 96px minmax(170px, 210px) max-content 236px minmax(220px, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-range-shortcuts {
|
||||||
|
display: inline-flex;
|
||||||
|
height: var(--sycm-control-height);
|
||||||
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--sycm-border-base);
|
||||||
|
border-radius: var(--sycm-radius-md);
|
||||||
|
background: var(--sycm-bg-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-range-shortcuts button {
|
||||||
|
min-width: 46px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--sycm-border-light);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-medium);
|
||||||
|
line-height: 30px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--sycm-interaction-duration-fast), background var(--sycm-interaction-duration-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-range-shortcuts button:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-range-shortcuts button:hover {
|
||||||
|
color: var(--sycm-color-primary);
|
||||||
|
background: var(--sycm-color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-range-shortcuts button.is-active {
|
||||||
|
background: var(--sycm-bg-container);
|
||||||
|
color: var(--sycm-color-primary);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--sycm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filters .ant-select,
|
||||||
|
.product-analysis-filters .ant-input-affix-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filters > .ant-input-affix-wrapper {
|
||||||
|
height: var(--sycm-control-height);
|
||||||
|
min-height: var(--sycm-control-height);
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filters > .ant-input-affix-wrapper .ant-input {
|
||||||
|
height: 18px;
|
||||||
|
min-height: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filters .ant-checkbox-wrapper {
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filters .product-analysis-active-filter {
|
||||||
|
display: inline-flex;
|
||||||
|
height: var(--sycm-control-height);
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid var(--sycm-border-light);
|
||||||
|
border-radius: var(--sycm-radius-md);
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
line-height: var(--sycm-line-height-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filters .product-analysis-active-filter .ant-checkbox {
|
||||||
|
top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filters .product-analysis-active-filter > span:last-child {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
padding-inline-start: 6px;
|
||||||
|
line-height: var(--sycm-line-height-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filters .product-analysis-active-filter:hover {
|
||||||
|
border-color: var(--sycm-color-primary-border);
|
||||||
|
background: var(--sycm-color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-metrics,
|
||||||
|
.product-analysis-tab-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-overview-panel > .sycm-business-panel__body {
|
||||||
|
padding: 0 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-overview-panel .product-analysis-metrics {
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0 0 var(--sycm-radius-xl) var(--sycm-radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-overview-panel .sycm-dashboard-metric {
|
||||||
|
min-height: 98px;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--sycm-border-light);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 14px 16px 13px;
|
||||||
|
background: var(--sycm-bg-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-overview-panel .sycm-dashboard-metric:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-overview-panel .sycm-dashboard-metric__label {
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-metrics .sycm-dashboard-metric,
|
||||||
|
.product-analysis-tab-metrics .sycm-dashboard-metric {
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-table-panel > .sycm-business-panel__body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-table-panel .ant-table-wrapper {
|
||||||
|
border-top: 1px solid var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-table-panel .ant-table-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-table-panel .ant-table-row.is-selected > td {
|
||||||
|
background: var(--sycm-interaction-selected-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-table-panel .ant-table-row.is-selected > td:first-child {
|
||||||
|
box-shadow: inset 3px 0 0 var(--sycm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-product-cell {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-product-cell .ant-image,
|
||||||
|
.product-analysis-product-cell img {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
flex: 0 0 46px;
|
||||||
|
border-radius: var(--sycm-radius-sm);
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-product-cell > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-product-cell strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-medium);
|
||||||
|
line-height: 18px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-product-cell span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
line-height: 16px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-cell {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal {
|
||||||
|
display: inline-grid;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 118px;
|
||||||
|
grid-template-columns: 9px auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--sycm-radius-md);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal strong {
|
||||||
|
justify-self: end;
|
||||||
|
color: currentColor;
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal__lamp {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border: 2px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal.is-red {
|
||||||
|
border-color: var(--sycm-color-danger-border);
|
||||||
|
background: var(--sycm-color-danger-bg);
|
||||||
|
color: var(--sycm-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal.is-yellow {
|
||||||
|
border-color: var(--sycm-color-warning-border);
|
||||||
|
background: var(--sycm-color-warning-bg);
|
||||||
|
color: var(--sycm-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal.is-green {
|
||||||
|
border-color: var(--sycm-color-success-border);
|
||||||
|
background: var(--sycm-color-success-bg);
|
||||||
|
color: var(--sycm-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-reason {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--sycm-color-danger);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-reason .anticon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-reason > span:last-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-observing {
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 190px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--sycm-color-primary-border);
|
||||||
|
border-radius: var(--sycm-radius-md);
|
||||||
|
background: var(--sycm-color-primary-bg);
|
||||||
|
color: var(--sycm-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-observing__dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--sycm-color-primary);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--sycm-color-primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-observing > span:last-child {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-observing strong {
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-observing small {
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-unavailable {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-filter {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-filter .ant-segmented-item-label {
|
||||||
|
min-width: 48px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-filter__label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-filter__label i {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: 0 0 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-filter__label.is-red i {
|
||||||
|
background: var(--sycm-color-danger);
|
||||||
|
box-shadow: 0 0 0 3px var(--sycm-color-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-filter__label.is-yellow i {
|
||||||
|
background: var(--sycm-color-warning);
|
||||||
|
box-shadow: 0 0 0 3px var(--sycm-color-warning-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-filter__label.is-green i {
|
||||||
|
background: var(--sycm-color-success);
|
||||||
|
box-shadow: 0 0 0 3px var(--sycm-color-success-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-settings {
|
||||||
|
width: 390px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-settings__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px 2px 12px;
|
||||||
|
border-bottom: 1px solid var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-settings__header strong {
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-body);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-settings__header span {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-setting {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
align-items: end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 13px 2px;
|
||||||
|
border-bottom: 1px solid var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-setting > strong {
|
||||||
|
align-self: center;
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-setting label {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-setting label > span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: var(--sycm-font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-setting label > span.is-red {
|
||||||
|
color: var(--sycm-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-setting label > span.is-green {
|
||||||
|
color: var(--sycm-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-setting .ant-input-number {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-threshold-input {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-threshold-input .ant-input-number {
|
||||||
|
border-start-end-radius: 0;
|
||||||
|
border-end-end-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-threshold-input > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--sycm-border-base);
|
||||||
|
border-left: 0;
|
||||||
|
border-start-end-radius: var(--sycm-radius-sm);
|
||||||
|
border-end-end-radius: var(--sycm-radius-sm);
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-quality-rule {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
|
gap: 8px 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 13px 2px;
|
||||||
|
border-bottom: 1px solid var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-quality-rule > strong {
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-quality-rule > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-quality-rule span {
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: var(--sycm-radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-quality-rule .is-green {
|
||||||
|
color: var(--sycm-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-quality-rule .is-yellow {
|
||||||
|
color: var(--sycm-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-quality-rule .is-red {
|
||||||
|
color: var(--sycm-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-quality-rule small {
|
||||||
|
grid-column: 2;
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-signal-settings__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-detail-panel > .sycm-business-panel__body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-detail-panel > .sycm-business-panel__header {
|
||||||
|
min-height: 64px;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-detail-panel .sycm-business-panel__heading,
|
||||||
|
.product-analysis-detail-panel .sycm-business-panel__copy,
|
||||||
|
.product-analysis-detail-panel .sycm-business-panel__title {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-selected-product {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-selected-product .ant-image,
|
||||||
|
.product-analysis-selected-product img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: 0 0 40px;
|
||||||
|
border-radius: var(--sycm-radius-sm);
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-selected-product__copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-selected-product__copy strong,
|
||||||
|
.product-analysis-selected-product__copy > span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-selected-product__copy strong {
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-body);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-selected-product__copy > span {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-regular);
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-detail-panel .sycm-module-tabs {
|
||||||
|
border-top: 1px solid var(--sycm-border-light);
|
||||||
|
border-bottom: 1px solid var(--sycm-border-light);
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-tab-content {
|
||||||
|
min-height: 280px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-tab-metrics {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-tab-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-tab-grid.is-table-only {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-trend-block,
|
||||||
|
.product-analysis-history-block {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--sycm-border-base);
|
||||||
|
border-radius: var(--sycm-radius-lg);
|
||||||
|
background: var(--sycm-bg-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-trend-block {
|
||||||
|
padding: 14px 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-history-block h3 {
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-trend-block h3,
|
||||||
|
.product-analysis-history-block h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-title);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-history-block .ant-table-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-history-block .product-analysis-issue-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-history-block .product-analysis-issue-row:focus-visible > td {
|
||||||
|
background: var(--sycm-color-primary-soft);
|
||||||
|
outline: 1px solid var(--sycm-color-primary-border);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-view-orders {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--sycm-color-primary);
|
||||||
|
font-weight: var(--sycm-font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-order-detail {
|
||||||
|
display: block;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container product-analysis (max-width: 1180px) {
|
||||||
|
.product-analysis-filters {
|
||||||
|
grid-template-columns: 100px minmax(180px, 1fr) minmax(220px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-range-shortcuts,
|
||||||
|
.product-analysis-filters .ant-picker,
|
||||||
|
.product-analysis-filters .ant-checkbox-wrapper {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-range-shortcuts {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-metrics {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-overview-panel .sycm-dashboard-metric:nth-child(3n) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-tab-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container product-analysis (max-width: 1500px) {
|
||||||
|
.product-analysis-filter-panel .sycm-toolbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filter-panel .sycm-toolbar > .ant-space {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container product-analysis (max-width: 760px) {
|
||||||
|
.product-analysis-filters {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-range-shortcuts,
|
||||||
|
.product-analysis-filters .ant-picker,
|
||||||
|
.product-analysis-filters .ant-input-affix-wrapper,
|
||||||
|
.product-analysis-filters .ant-checkbox-wrapper {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-metrics,
|
||||||
|
.product-analysis-tab-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-overview-panel .sycm-dashboard-metric:nth-child(3n) {
|
||||||
|
border-right: 1px solid var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-overview-panel .sycm-dashboard-metric:nth-child(2n) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@container product-analysis (max-width: 480px) {
|
||||||
|
.product-analysis-filters,
|
||||||
|
.product-analysis-metrics,
|
||||||
|
.product-analysis-tab-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-filters > * {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-overview-panel .sycm-dashboard-metric {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-analysis-overview-panel .sycm-dashboard-metric:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
340
frontend/src/pages/ProductMonitoring/index.jsx
Normal file
340
frontend/src/pages/ProductMonitoring/index.jsx
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert, Button, Card, Col, Drawer, Dropdown, Image, Input, Row, Select,
|
||||||
|
Segmented, Space, Spin, Statistic, Table, Tabs, Tag, Tooltip, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { SycmPage } from '../../components/Common';
|
||||||
|
import {
|
||||||
|
getProductMonitoringList,
|
||||||
|
getProductMonitoringFilters,
|
||||||
|
getProductMonitoringOperators,
|
||||||
|
} from '../../api/productMonitoring';
|
||||||
|
import { exportTableFile } from '../../utils/tableExport';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const number = (value, digits = 2) => value === null || value === undefined || value === ''
|
||||||
|
? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||||
|
const int = (value) => number(value, 0);
|
||||||
|
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
|
||||||
|
|
||||||
|
const COMPANY_TAG_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' };
|
||||||
|
const DEPARTMENT_TAG_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' };
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ key: '销售', label: '销售' },
|
||||||
|
{ key: '停产', label: '停产' },
|
||||||
|
{ key: '退市', label: '退市' },
|
||||||
|
{ key: '__all__', label: '全部' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toOptions = (list = []) => (Array.isArray(list) ? list : [])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((value) => ({ value, label: value }));
|
||||||
|
|
||||||
|
const ProductMonitoringPanel = () => {
|
||||||
|
const [status, setStatus] = useState('销售');
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
category: '', spu: '', sku: '', productName: '', supplier: '', warehouse: '',
|
||||||
|
purchaser: '', companyTag: [], departmentTag: [], departmentOwner: '',
|
||||||
|
});
|
||||||
|
const [filterOptions, setFilterOptions] = useState({ categories: [], suppliers: [], warehouses: [], purchasers: [] });
|
||||||
|
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, latestDate: null });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [sorter, setSorter] = useState({ field: 'companySales7d', order: 'DESC' });
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [operators, setOperators] = useState([]);
|
||||||
|
const [operatorsLoading, setOperatorsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getProductMonitoringFilters()
|
||||||
|
.then((result) => {
|
||||||
|
const payload = result?.data || {};
|
||||||
|
if (cancelled) return;
|
||||||
|
setFilterOptions({
|
||||||
|
categories: payload.categories || [],
|
||||||
|
suppliers: payload.suppliers || [],
|
||||||
|
warehouses: payload.warehouses || [],
|
||||||
|
purchasers: payload.purchasers || [],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const query = useMemo(() => {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortField: sorter.field,
|
||||||
|
sortOrder: sorter.order,
|
||||||
|
};
|
||||||
|
if (status === '__all__') params.includeAll = '1';
|
||||||
|
else params.saleStatus = status;
|
||||||
|
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length) params[key] = value.join(',');
|
||||||
|
} else if (value) {
|
||||||
|
params[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return params;
|
||||||
|
}, [page, pageSize, sorter, status, filters]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await getProductMonitoringList(query);
|
||||||
|
setData(result?.data || { rows: [], total: 0, summary: {} });
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError?.response?.data?.error || requestError?.message || '加载商品监控数据失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const changeStatus = (key) => {
|
||||||
|
setStatus(key);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const changeFilter = (key) => (value) => {
|
||||||
|
setFilters((old) => ({ ...old, [key]: value || '' }));
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const changeMultiFilter = (key) => (value) => {
|
||||||
|
setFilters((old) => ({ ...old, [key]: value || [] }));
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const resetFilters = () => {
|
||||||
|
setFilters({
|
||||||
|
category: '', spu: '', sku: '', productName: '', supplier: '', warehouse: '',
|
||||||
|
purchaser: '', companyTag: [], departmentTag: [], departmentOwner: '',
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = async (record) => {
|
||||||
|
setSelected(record);
|
||||||
|
setOperators([]);
|
||||||
|
setOperatorsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getProductMonitoringOperators(record.skuCode || record.skuId);
|
||||||
|
setOperators(result?.data?.operators || []);
|
||||||
|
} catch (requestError) {
|
||||||
|
message.error(requestError?.response?.data?.error || requestError?.message || '加载运营明细失败');
|
||||||
|
} finally {
|
||||||
|
setOperatorsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo(() => [
|
||||||
|
{ title: '图片', dataIndex: 'imageUrl', width: 64, fixed: 'left', exportable: false, render: (value) => (value ? <Image src={value} width={36} height={36} style={{ objectFit: 'cover', borderRadius: 4 }} placeholder /> : <span className="pm-muted">--</span>) },
|
||||||
|
{ title: '品类', dataIndex: 'category', width: 150, ellipsis: true, sorter: true },
|
||||||
|
{ title: 'SPU', dataIndex: 'spu', width: 130, sorter: true },
|
||||||
|
{ title: 'SKU', dataIndex: 'skuId', width: 150, fixed: 'left', sorter: true, render: (value, row) => <Button type="link" className="pm-id-link" onClick={() => openDetail(row)}>{value}</Button> },
|
||||||
|
{ title: '条码', dataIndex: 'barcode', width: 150, ellipsis: true },
|
||||||
|
{ title: '产品名称', dataIndex: 'productName', width: 240, ellipsis: true, sorter: true, render: (value) => <Tooltip title={value}>{text(value)}</Tooltip> },
|
||||||
|
{ title: '材质', dataIndex: 'material', width: 100, ellipsis: true, render: text },
|
||||||
|
{ title: '颜色', dataIndex: 'color', width: 100, ellipsis: true, render: text },
|
||||||
|
{ title: '尺寸', dataIndex: 'size', width: 140, ellipsis: true, render: text },
|
||||||
|
{ title: '销售状态', dataIndex: 'saleStatus', width: 90, sorter: true, render: (v) => <Tag color={v === '销售' ? 'green' : v === '停产' ? 'orange' : 'default'}>{text(v)}</Tag> },
|
||||||
|
{ title: '供应商', dataIndex: 'supplier', width: 180, ellipsis: true, sorter: true, render: text },
|
||||||
|
{ title: '发货仓库', dataIndex: 'warehouse', width: 120, ellipsis: true, sorter: true, render: text },
|
||||||
|
{ title: '公司SKU负责人', dataIndex: 'companyOwner', width: 130, sorter: true, render: text },
|
||||||
|
{ title: '采购负责人', dataIndex: 'purchaser', width: 110, sorter: true, render: text },
|
||||||
|
{ title: '公司近7日日销', dataIndex: 'companySales7d', width: 120, align: 'right', sorter: true, render: (v) => number(v) },
|
||||||
|
{ title: '可使用库存', dataIndex: 'availableStock', width: 110, align: 'right', sorter: true, render: int },
|
||||||
|
{ title: '在途库存', dataIndex: 'inTransitStock', width: 100, align: 'right', render: (v) => <span className="pm-muted">{text(v)}</span> },
|
||||||
|
{ title: '可使用天数', dataIndex: 'stockDays', width: 110, align: 'right', sorter: true, render: (v) => number(v, 1) },
|
||||||
|
{ title: '公司商品标签', dataIndex: 'companyTag', width: 110, sorter: true, render: (v) => (v ? <Tag color={COMPANY_TAG_COLOR[v] || 'default'}>{v}</Tag> : <span className="pm-muted">--</span>) },
|
||||||
|
{ title: '部门', dataIndex: 'department', width: 110, ellipsis: true, sorter: true, render: text },
|
||||||
|
{ title: '部门的标签', dataIndex: 'departmentTag', width: 110, sorter: true, render: (v) => (v ? <Tag color={DEPARTMENT_TAG_COLOR[v] || 'default'}>{v}</Tag> : <span className="pm-muted">--</span>) },
|
||||||
|
{ title: '部门SKU负责人', dataIndex: 'departmentOwner', width: 130, sorter: true, render: text },
|
||||||
|
{ title: '近30天销售额', dataIndex: 'performance30d', width: 130, align: 'right', sorter: true, render: (v) => number(v) },
|
||||||
|
{ title: '近7天销售额', dataIndex: 'performance7d', width: 120, align: 'right', sorter: true, render: (v) => number(v) },
|
||||||
|
{ title: '部门近7日日销', dataIndex: 'departmentSales7d', width: 120, align: 'right', sorter: true, render: (v) => number(v) },
|
||||||
|
{ title: '部门未发货数量', dataIndex: 'departmentUnshipped', width: 120, align: 'right', render: (v) => <span className="pm-muted">{text(v)}</span> },
|
||||||
|
{ title: '建议备货数量', dataIndex: 'suggestedReplenish', width: 120, align: 'right', sorter: true, render: int },
|
||||||
|
{ title: '部门主销售渠道', dataIndex: 'mainSalesChannel', width: 180, ellipsis: true, sorter: true, render: text },
|
||||||
|
{ title: '部门主销售运营', dataIndex: 'mainSalesOperator', width: 130, sorter: true, render: text },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const exportColumns = useMemo(
|
||||||
|
() => columns.filter((column) => column.exportable !== false),
|
||||||
|
[columns]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExport = async (format) => {
|
||||||
|
if (!data.rows.length) {
|
||||||
|
message.warning('暂无数据可导出');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await exportTableFile(format, `商品监控_${status === '__all__' ? '全部' : status}_${data.latestDate ? String(data.latestDate).slice(0, 10) : '最新'}`, exportColumns, data.rows, '商品监控');
|
||||||
|
message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`);
|
||||||
|
} catch (exportError) {
|
||||||
|
message.error(exportError?.message || '导出失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = data.summary || {};
|
||||||
|
const operatorColumns = [
|
||||||
|
{ title: '运营人员', dataIndex: 'operator', width: 140 },
|
||||||
|
{ title: '日销(近7天日均件数)', dataIndex: 'dailySales', align: 'right', render: (v) => number(v) },
|
||||||
|
{ title: '近7天销售件数', dataIndex: 'salesQty7d', align: 'right', render: int },
|
||||||
|
{ title: '近7天销售额', dataIndex: 'salesAmount7d', align: 'right', render: (v) => number(v) },
|
||||||
|
{ title: '主销售渠道', dataIndex: 'mainChannel', render: text },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="pm-filter-card" bordered={false}>
|
||||||
|
<div className="pm-status-bar">
|
||||||
|
<Text type="secondary">销售状态</Text>
|
||||||
|
<Segmented
|
||||||
|
value={status}
|
||||||
|
onChange={changeStatus}
|
||||||
|
options={STATUS_TABS.map((item) => ({ label: item.label, value: item.key }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space wrap size={[12, 12]}>
|
||||||
|
<Select allowClear showSearch placeholder="品类" options={toOptions(filterOptions.categories)} value={filters.category || undefined} onChange={changeFilter('category')} style={{ width: 180 }} />
|
||||||
|
<Select allowClear showSearch placeholder="供应商" options={toOptions(filterOptions.suppliers)} value={filters.supplier || undefined} onChange={changeFilter('supplier')} style={{ width: 200 }} />
|
||||||
|
<Select allowClear showSearch placeholder="发货仓库" options={toOptions(filterOptions.warehouses)} value={filters.warehouse || undefined} onChange={changeFilter('warehouse')} style={{ width: 150 }} />
|
||||||
|
<Select allowClear showSearch placeholder="采购负责人" options={toOptions(filterOptions.purchasers)} value={filters.purchaser || undefined} onChange={changeFilter('purchaser')} style={{ width: 150 }} />
|
||||||
|
<Select
|
||||||
|
mode="multiple" allowClear placeholder="公司标签" maxTagCount="responsive"
|
||||||
|
options={['S', 'A', 'B', 'C', 'D'].map((value) => ({ value, label: value }))}
|
||||||
|
value={filters.companyTag} onChange={changeMultiFilter('companyTag')} style={{ minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
mode="multiple" allowClear placeholder="部门标签" maxTagCount="responsive"
|
||||||
|
options={['G-S', 'G-A', 'G-B', 'G-C'].map((value) => ({ value, label: value }))}
|
||||||
|
value={filters.departmentTag} onChange={changeMultiFilter('departmentTag')} style={{ minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
<Input allowClear prefix={<SearchOutlined />} placeholder="SPU" value={filters.spu} onChange={(e) => changeFilter('spu')(e.target.value)} style={{ width: 150 }} />
|
||||||
|
<Input allowClear prefix={<SearchOutlined />} placeholder="SKU / 条码" value={filters.sku} onChange={(e) => changeFilter('sku')(e.target.value)} style={{ width: 160 }} />
|
||||||
|
<Input allowClear prefix={<SearchOutlined />} placeholder="产品名称" value={filters.productName} onChange={(e) => changeFilter('productName')(e.target.value)} style={{ width: 180 }} />
|
||||||
|
<Input allowClear prefix={<SearchOutlined />} placeholder="部门SKU负责人" value={filters.departmentOwner} onChange={(e) => changeFilter('departmentOwner')(e.target.value)} style={{ width: 160 }} />
|
||||||
|
<Button onClick={resetFilters}>重置</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
|
||||||
|
|
||||||
|
<Row gutter={[12, 12]} className="pm-summary-row">
|
||||||
|
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-blue"><Statistic title="SKU 总数" value={summary.skuCount || 0} /></Card></Col>
|
||||||
|
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-red"><Statistic title="零库存 SKU" value={summary.outOfStockCount || 0} /></Card></Col>
|
||||||
|
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-orange"><Statistic title="可用天数 < 7 天" value={summary.lowStockDaysCount || 0} /></Card></Col>
|
||||||
|
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-green"><Statistic title="建议备货合计" value={summary.totalSuggestedReplenish || 0} suffix={`(${summary.needReplenishCount || 0} 个 SKU)`} /></Card></Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
className="pm-panel"
|
||||||
|
title={(
|
||||||
|
<Space size={12} wrap>
|
||||||
|
<span>商品明细({data.total || 0} 条)</span>
|
||||||
|
{data.latestDate ? <Text type="secondary" className="pm-panel__date">统计日 {String(data.latestDate).slice(0, 10)}</Text> : null}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
extra={(
|
||||||
|
<Dropdown menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'xlsx', label: '导出 XLSX' },
|
||||||
|
{ key: 'csv', label: '导出 CSV' },
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => handleExport(key),
|
||||||
|
}}>
|
||||||
|
<Button icon={<DownloadOutlined />}>导出</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading} tip="正在加载商品监控数据…">
|
||||||
|
<Table
|
||||||
|
rowKey="skuId"
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.rows || []}
|
||||||
|
onChange={(pagination, _filter, nextSorter) => {
|
||||||
|
setPage(pagination.current || 1);
|
||||||
|
setPageSize(pagination.pageSize || 50);
|
||||||
|
if (nextSorter?.field) {
|
||||||
|
setSorter({
|
||||||
|
field: nextSorter.field,
|
||||||
|
order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
current: data.page || page,
|
||||||
|
pageSize: data.pageSize || pageSize,
|
||||||
|
total: data.total || 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [50, 100, 200],
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 3400, y: 'min(520px, calc(100vh - 520px))' }}
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
className="pm-detail-drawer"
|
||||||
|
title={selected ? `${selected.skuId} · 部门运营明细` : '部门运营明细'}
|
||||||
|
width={820}
|
||||||
|
open={Boolean(selected)}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<Space size={[16, 8]} wrap className="pm-detail-meta">
|
||||||
|
<Text type="secondary">产品名称:{text(selected.productName)}</Text>
|
||||||
|
<Text type="secondary">部门:{text(selected.department)}</Text>
|
||||||
|
<Text type="secondary">部门标签:{text(selected.departmentTag)}</Text>
|
||||||
|
<Text type="secondary">部门SKU负责人:{text(selected.departmentOwner)}</Text>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
className="pm-daily-table"
|
||||||
|
loading={operatorsLoading}
|
||||||
|
rowKey="operator"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={operators}
|
||||||
|
columns={operatorColumns}
|
||||||
|
locale={{ emptyText: '该 SKU 近 7 天暂无销售明细' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProductMonitoring = () => (
|
||||||
|
<SycmPage className="pm-page">
|
||||||
|
<header className="pm-header">
|
||||||
|
<div className="pm-header__heading">
|
||||||
|
<Title level={3}>商品监控</Title>
|
||||||
|
<Text type="secondary">全公司商品档案、库存、标签分级与部门运营指标;点击 SKU 可下钻查看部门运营明细</Text>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<Tabs
|
||||||
|
className="pm-tabs"
|
||||||
|
activeKey="all"
|
||||||
|
items={[{ key: 'all', label: '全公司', children: <ProductMonitoringPanel /> }]}
|
||||||
|
/>
|
||||||
|
</SycmPage>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ProductMonitoring;
|
||||||
167
frontend/src/pages/ProductMonitoring/style.css
Normal file
167
frontend/src/pages/ProductMonitoring/style.css
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
.pm-page {
|
||||||
|
container-name: product-monitoring;
|
||||||
|
container-type: inline-size;
|
||||||
|
display: flex;
|
||||||
|
min-height: calc(100% - 46px);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-page-section-gap);
|
||||||
|
background: var(--sycm-bg-page-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 84px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sycm-space-lg);
|
||||||
|
margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0;
|
||||||
|
padding: 14px calc(var(--sycm-page-padding-x) + 2px);
|
||||||
|
border-bottom: 1px solid var(--sycm-border-base);
|
||||||
|
background: var(--sycm-bg-container);
|
||||||
|
}
|
||||||
|
.pm-header__heading { min-width: 0; }
|
||||||
|
.pm-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-page);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
line-height: var(--sycm-line-height-tight);
|
||||||
|
}
|
||||||
|
.pm-header__heading .ant-typography {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-tabs .ant-tabs-nav {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 calc(var(--sycm-page-padding-x) + 2px);
|
||||||
|
border-bottom: 1px solid var(--sycm-border-base);
|
||||||
|
background: var(--sycm-bg-container);
|
||||||
|
}
|
||||||
|
.pm-tabs .ant-tabs-tab {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 4px;
|
||||||
|
font-size: var(--sycm-font-size-title);
|
||||||
|
}
|
||||||
|
.pm-tabs .ant-tabs-content-holder,
|
||||||
|
.pm-tabs .ant-tabs-content,
|
||||||
|
.pm-tabs .ant-tabs-tabpane { min-height: 100%; }
|
||||||
|
.pm-tabs .ant-tabs-tabpane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-page-section-gap);
|
||||||
|
padding-top: var(--sycm-page-section-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-filter-card,
|
||||||
|
.pm-panel,
|
||||||
|
.pm-summary-card {
|
||||||
|
border: 1px solid var(--sycm-border-card) !important;
|
||||||
|
border-radius: var(--sycm-radius-lg) !important;
|
||||||
|
box-shadow: var(--sycm-shadow-card) !important;
|
||||||
|
}
|
||||||
|
.pm-filter-card { margin-bottom: 0; }
|
||||||
|
.pm-filter-card .ant-card-body { padding: 14px 16px; }
|
||||||
|
.pm-filter-card .ant-space { width: 100%; }
|
||||||
|
.pm-filter-card .ant-space-item { max-width: 100%; }
|
||||||
|
.pm-filter-card .ant-select,
|
||||||
|
.pm-filter-card .ant-input-affix-wrapper { max-width: 100%; }
|
||||||
|
|
||||||
|
.pm-status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-summary-row { margin-top: -4px; }
|
||||||
|
.pm-summary-row .ant-col { display: flex; }
|
||||||
|
.pm-summary-card {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 98px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pm-summary-card::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--sycm-color-primary);
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
.pm-summary-card.is-red::before { background: var(--sycm-color-danger); }
|
||||||
|
.pm-summary-card.is-orange::before { background: var(--sycm-color-orange); }
|
||||||
|
.pm-summary-card.is-green::before { background: var(--sycm-color-success); }
|
||||||
|
.pm-summary-card .ant-card-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 98px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.pm-summary-card .ant-statistic { width: 100%; }
|
||||||
|
.pm-summary-card .ant-statistic-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
.pm-summary-card .ant-statistic-content {
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-family: var(--sycm-font-family-number);
|
||||||
|
font-size: var(--sycm-font-size-metric);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.pm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); }
|
||||||
|
|
||||||
|
.pm-panel { margin-bottom: 0; overflow: hidden; }
|
||||||
|
.pm-panel .ant-card-head {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom-color: var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
.pm-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); }
|
||||||
|
.pm-panel .ant-card-body { padding: 0 16px 16px; }
|
||||||
|
.pm-panel .ant-table { font-size: var(--sycm-font-size-caption); }
|
||||||
|
.pm-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); }
|
||||||
|
.pm-panel .ant-table-placeholder .ant-table-cell { height: 180px; }
|
||||||
|
.pm-panel__date { font-size: var(--sycm-font-size-caption); }
|
||||||
|
.pm-id-link {
|
||||||
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--sycm-font-family-number);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
.pm-muted { color: var(--sycm-text-tertiary); }
|
||||||
|
.pm-daily-table { margin-top: var(--sycm-space-md); }
|
||||||
|
.pm-detail-meta { margin-bottom: var(--sycm-space-sm); }
|
||||||
|
|
||||||
|
.pm-detail-drawer .ant-drawer-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom-color: var(--sycm-border-light);
|
||||||
|
}
|
||||||
|
.pm-detail-drawer .ant-drawer-title {
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-title);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
.pm-detail-drawer .ant-drawer-body { padding: 20px; }
|
||||||
|
|
||||||
|
@container product-monitoring (max-width: 900px) {
|
||||||
|
.pm-filter-card .ant-space-item { flex: 1 1 180px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.pm-header {
|
||||||
|
min-height: 72px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
padding-block: 12px;
|
||||||
|
}
|
||||||
|
.pm-filter-card .ant-select,
|
||||||
|
.pm-filter-card .ant-input-affix-wrapper { width: 100%; }
|
||||||
|
.pm-panel .ant-card-body { padding-inline: 10px; }
|
||||||
|
.pm-detail-drawer .ant-drawer-body { padding: 14px; }
|
||||||
|
}
|
||||||
610
frontend/src/pages/PromotionModule/index.jsx
Normal file
610
frontend/src/pages/PromotionModule/index.jsx
Normal file
@ -0,0 +1,610 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
DatePicker,
|
||||||
|
Drawer,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tooltip,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
SycmBusinessPanel,
|
||||||
|
SycmDashboardMetric,
|
||||||
|
SycmPage,
|
||||||
|
} from '../../components/Common';
|
||||||
|
import {
|
||||||
|
getPromotionModuleList,
|
||||||
|
getPromotionModuleSummary,
|
||||||
|
getPromotionModuleShops,
|
||||||
|
} from '../../api/promotionModule';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
const PROMOTION_TYPES = [
|
||||||
|
{ key: 'stable-cost', label: '稳定成本推广' },
|
||||||
|
{ key: 'full-store', label: '全店托管' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DASHBOARD_METRICS = [
|
||||||
|
{ key: 'orderSpend', label: '成交花费(元)' },
|
||||||
|
{ key: 'gmv', label: '交易额(元)' },
|
||||||
|
{ key: 'roi', label: '实际投产比' },
|
||||||
|
{ key: 'spend', label: '总花费(元)' },
|
||||||
|
{ key: 'netGmv', label: '净交易额(元)' },
|
||||||
|
{ key: 'netRoi', label: '净实际投产比' },
|
||||||
|
{ key: 'netOrderNum', label: '净成交笔数' },
|
||||||
|
{ key: 'costPerNetOrder', label: '每笔净成交花费(元)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS = [
|
||||||
|
{ label: '实时', value: 'realtime' },
|
||||||
|
{ label: '昨日', value: 'yesterday' },
|
||||||
|
{ label: '近7天', value: 'last7d' },
|
||||||
|
{ label: '近30天', value: 'last30d' },
|
||||||
|
{ label: '本周', value: 'week' },
|
||||||
|
{ label: '本月', value: 'month' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtMoney(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return '-';
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRate(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return '-';
|
||||||
|
return `${(n * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return '-';
|
||||||
|
return n.toLocaleString('zh-CN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPlain(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return '-';
|
||||||
|
return n.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PromotionModule = () => {
|
||||||
|
const [promotionType, setPromotionType] = useState('stable-cost');
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [addProductOpen, setAddProductOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [dataSource, setDataSource] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
|
||||||
|
const [sorter, setSorter] = useState({ field: 'orderSpend', order: 'descend' });
|
||||||
|
const [summary, setSummary] = useState(null);
|
||||||
|
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||||
|
const [shops, setShops] = useState([]);
|
||||||
|
const [selectedShop, setSelectedShop] = useState('');
|
||||||
|
const [period, setPeriod] = useState('last7d');
|
||||||
|
const [customDateRange, setCustomDateRange] = useState(null);
|
||||||
|
const [filterDateLabel, setFilterDateLabel] = useState('');
|
||||||
|
|
||||||
|
const fetchShops = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getPromotionModuleShops();
|
||||||
|
if (res?.data) setShops(res.data);
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchShops();
|
||||||
|
}, [fetchShops]);
|
||||||
|
|
||||||
|
const buildParams = useCallback(() => {
|
||||||
|
const p = {};
|
||||||
|
if (period === 'custom' && customDateRange?.length === 2) {
|
||||||
|
p.period = 'custom';
|
||||||
|
p.start = customDateRange[0].format('YYYY-MM-DD');
|
||||||
|
p.end = customDateRange[1].format('YYYY-MM-DD');
|
||||||
|
} else {
|
||||||
|
p.period = period;
|
||||||
|
}
|
||||||
|
if (selectedShop) p.shopName = selectedShop;
|
||||||
|
if (keyword.trim()) p.keyword = keyword.trim();
|
||||||
|
return p;
|
||||||
|
}, [period, customDateRange, selectedShop, keyword]);
|
||||||
|
|
||||||
|
const fetchSummary = useCallback(async () => {
|
||||||
|
setSummaryLoading(true);
|
||||||
|
try {
|
||||||
|
const params = buildParams();
|
||||||
|
const res = await getPromotionModuleSummary(params);
|
||||||
|
if (res?.data) {
|
||||||
|
setSummary(res.data);
|
||||||
|
setFilterDateLabel(res.data.filterDate || '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSummary(null);
|
||||||
|
} finally {
|
||||||
|
setSummaryLoading(false);
|
||||||
|
}
|
||||||
|
}, [buildParams]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
...buildParams(),
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
sortBy: sorter.field,
|
||||||
|
sortOrder: sorter.order === 'ascend' ? 'ASC' : 'DESC',
|
||||||
|
};
|
||||||
|
const result = await getPromotionModuleList(params);
|
||||||
|
const data = result?.data;
|
||||||
|
if (data) {
|
||||||
|
setDataSource(data.list || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDataSource([]);
|
||||||
|
setTotal(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [buildParams, pagination, sorter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSummary();
|
||||||
|
fetchData();
|
||||||
|
}, [fetchSummary, fetchData]);
|
||||||
|
|
||||||
|
const handleTableChange = (pag, _filters, sort) => {
|
||||||
|
setPagination({ current: pag.current, pageSize: pag.pageSize });
|
||||||
|
if (sort.field) {
|
||||||
|
setSorter({ field: sort.field, order: sort.order || 'descend' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePeriodChange = (value) => {
|
||||||
|
setPeriod(value);
|
||||||
|
setCustomDateRange(null);
|
||||||
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShopChange = (value) => {
|
||||||
|
setSelectedShop(value);
|
||||||
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryMetrics = useMemo(() => {
|
||||||
|
if (!summary) return DASHBOARD_METRICS.map((m) => ({ ...m, value: '--' }));
|
||||||
|
const changes = summary.changes || {};
|
||||||
|
return DASHBOARD_METRICS.map((m) => {
|
||||||
|
const raw = summary[m.key];
|
||||||
|
let formatted = '--';
|
||||||
|
if (raw != null && raw !== '-') {
|
||||||
|
if (['orderSpend', 'gmv', 'spend', 'netGmv', 'costPerNetOrder'].includes(m.key)) {
|
||||||
|
formatted = fmtMoney(raw);
|
||||||
|
} else if (m.key === 'netOrderNum') {
|
||||||
|
formatted = fmtNum(raw);
|
||||||
|
} else {
|
||||||
|
formatted = fmtPlain(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 环比:与上一等长周期对比
|
||||||
|
const change = changes[m.key];
|
||||||
|
let trend;
|
||||||
|
let trendTone;
|
||||||
|
if (Number.isFinite(change)) {
|
||||||
|
const percent = Math.abs(change * 100).toFixed(1);
|
||||||
|
if (change > 0) {
|
||||||
|
trend = `↑ ${percent}%`;
|
||||||
|
trendTone = 'up';
|
||||||
|
} else if (change < 0) {
|
||||||
|
trend = `↓ ${percent}%`;
|
||||||
|
trendTone = 'down';
|
||||||
|
} else {
|
||||||
|
trend = '持平';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...m, value: formatted, trend, trendTone };
|
||||||
|
});
|
||||||
|
}, [summary]);
|
||||||
|
|
||||||
|
const baseColumns = [
|
||||||
|
{
|
||||||
|
title: '推广商品',
|
||||||
|
key: 'product',
|
||||||
|
width: 280,
|
||||||
|
fixed: 'left',
|
||||||
|
render: (_, record) => (
|
||||||
|
<div className="promotion-product-cell">
|
||||||
|
{record.thumbUrl ? (
|
||||||
|
<img src={record.thumbUrl} alt={record.goodsName || '商品'} />
|
||||||
|
) : (
|
||||||
|
<span className="promotion-product-cell__image">暂无</span>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<strong>{record.goodsName || '未命名商品'}</strong>
|
||||||
|
<span>商品 ID:{record.goodsId || '-'}</span>
|
||||||
|
<span>店铺:{record.shopName || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成交花费(元)',
|
||||||
|
dataIndex: 'orderSpend',
|
||||||
|
key: 'orderSpend',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtMoney(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '交易额(元)',
|
||||||
|
dataIndex: 'gmv',
|
||||||
|
key: 'gmv',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtMoney(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '实际投产比',
|
||||||
|
dataIndex: 'roi',
|
||||||
|
key: 'roi',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtPlain(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '目标费比ROI',
|
||||||
|
dataIndex: 'targetRoi',
|
||||||
|
key: 'targetRoi',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => {
|
||||||
|
const n = Number(String(v ?? '').trim());
|
||||||
|
if (!v || !Number.isFinite(n) || n <= 0) return '-';
|
||||||
|
return fmtPlain(n / 10000);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '总花费(元)',
|
||||||
|
dataIndex: 'spend',
|
||||||
|
key: 'spend',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtMoney(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成交笔数',
|
||||||
|
dataIndex: 'orderNum',
|
||||||
|
key: 'orderNum',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtNum(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '净交易额(元)',
|
||||||
|
dataIndex: 'netGmv',
|
||||||
|
key: 'netGmv',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtMoney(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '净实际投产比',
|
||||||
|
dataIndex: 'netRoi',
|
||||||
|
key: 'netRoi',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtPlain(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '净成交笔数',
|
||||||
|
dataIndex: 'netOrderNum',
|
||||||
|
key: 'netOrderNum',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtNum(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '净交易额占比',
|
||||||
|
dataIndex: 'netRate',
|
||||||
|
key: 'netRate',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtRate(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结算交易额(元)',
|
||||||
|
dataIndex: 'settlementGmv',
|
||||||
|
key: 'settlementGmv',
|
||||||
|
width: 140,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtMoney(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结算投产比',
|
||||||
|
dataIndex: 'settlementRoi',
|
||||||
|
key: 'settlementRoi',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtPlain(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结算笔数',
|
||||||
|
dataIndex: 'settlementOrder',
|
||||||
|
key: 'settlementOrder',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtNum(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '退款率',
|
||||||
|
dataIndex: 'refundGmvRate30d',
|
||||||
|
key: 'refundGmvRate30d',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtRate(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '退款豁免率',
|
||||||
|
dataIndex: 'exemptRefundGmvRate30d',
|
||||||
|
key: 'exemptRefundGmvRate30d',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtRate(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '曝光量',
|
||||||
|
dataIndex: 'impression',
|
||||||
|
key: 'impression',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtNum(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '点击量',
|
||||||
|
dataIndex: 'click',
|
||||||
|
key: 'click',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtNum(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '点击率',
|
||||||
|
dataIndex: 'ctr',
|
||||||
|
key: 'ctr',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtRate(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '点击转化率',
|
||||||
|
dataIndex: 'cvr',
|
||||||
|
key: 'cvr',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtRate(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '每笔成交花费(元)',
|
||||||
|
dataIndex: 'transactionCost',
|
||||||
|
key: 'transactionCost',
|
||||||
|
width: 150,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtMoney(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '每笔成交金额(元)',
|
||||||
|
dataIndex: 'avgPayAmount',
|
||||||
|
key: 'avgPayAmount',
|
||||||
|
width: 150,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtMoney(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '直接交易额(元)',
|
||||||
|
dataIndex: 'directGmv',
|
||||||
|
key: 'directGmv',
|
||||||
|
width: 140,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtMoney(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '间接交易额(元)',
|
||||||
|
dataIndex: 'indirectGmv',
|
||||||
|
key: 'indirectGmv',
|
||||||
|
width: 140,
|
||||||
|
align: 'right',
|
||||||
|
sorter: true,
|
||||||
|
render: (v) => fmtMoney(v),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SycmPage className="promotion-module-page">
|
||||||
|
<SycmBusinessPanel className="promotion-dashboard" aria-label="推广数据仪表盘">
|
||||||
|
<div className="promotion-dashboard__header">
|
||||||
|
<div>
|
||||||
|
<h2>推广数据</h2>
|
||||||
|
<span>包含稳定成本推广和全店托管数据</span>
|
||||||
|
</div>
|
||||||
|
<div className="promotion-dashboard__tools">
|
||||||
|
<span>统计时间:{filterDateLabel || '--'}</span>
|
||||||
|
{summary?.previousRange ? (
|
||||||
|
<span>上一周期:{summary.previousRange}</span>
|
||||||
|
) : null}
|
||||||
|
<Tooltip title="指标设置">
|
||||||
|
<Button icon={<SettingOutlined />} aria-label="指标设置" onClick={() => setSettingsOpen(true)} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Spin spinning={summaryLoading}>
|
||||||
|
<div className="promotion-dashboard__metrics">
|
||||||
|
{summaryMetrics.map((item) => (
|
||||||
|
<SycmDashboardMetric
|
||||||
|
key={item.key}
|
||||||
|
label={item.label}
|
||||||
|
value={item.value}
|
||||||
|
help={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</SycmBusinessPanel>
|
||||||
|
|
||||||
|
<SycmBusinessPanel className="promotion-workspace" aria-label="推广明细工作台">
|
||||||
|
<Tabs
|
||||||
|
className="promotion-type-tabs"
|
||||||
|
activeKey={promotionType}
|
||||||
|
items={PROMOTION_TYPES}
|
||||||
|
onChange={setPromotionType}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="promotion-filterbar">
|
||||||
|
<Space size={12} wrap>
|
||||||
|
<Select
|
||||||
|
className="promotion-filterbar__shop"
|
||||||
|
value={selectedShop || undefined}
|
||||||
|
placeholder="全部店铺"
|
||||||
|
allowClear
|
||||||
|
onChange={handleShopChange}
|
||||||
|
options={shops.map((s) => ({ label: s, value: s }))}
|
||||||
|
/>
|
||||||
|
<Space.Compact>
|
||||||
|
{PERIOD_OPTIONS.map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
type={period === opt.value ? 'primary' : 'default'}
|
||||||
|
onClick={() => handlePeriodChange(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type={period === 'custom' ? 'primary' : 'default'}
|
||||||
|
onClick={() => setPeriod('custom')}
|
||||||
|
>
|
||||||
|
自定义
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
{period === 'custom' && (
|
||||||
|
<RangePicker
|
||||||
|
value={customDateRange}
|
||||||
|
onChange={setCustomDateRange}
|
||||||
|
placeholder={['开始日期', '结束日期']}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
className="promotion-filterbar__search"
|
||||||
|
allowClear
|
||||||
|
value={keyword}
|
||||||
|
placeholder="搜索商品ID / 商品名"
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onPressEnter={handleSearch}
|
||||||
|
suffix={<SearchOutlined style={{ color: 'var(--sycm-text-tertiary)' }} />}
|
||||||
|
/>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => {
|
||||||
|
setKeyword('');
|
||||||
|
setSelectedShop('');
|
||||||
|
setPeriod('last7d');
|
||||||
|
setCustomDateRange(null);
|
||||||
|
setPagination({ current: 1, pageSize: 20 });
|
||||||
|
}}>刷新</Button>
|
||||||
|
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>查询</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="promotion-selectionbar">
|
||||||
|
<span>已选 {selectedRowKeys.length} 项</span>
|
||||||
|
<span>共 {total} 条推广记录</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Table
|
||||||
|
className="promotion-table"
|
||||||
|
rowKey="id"
|
||||||
|
columns={baseColumns}
|
||||||
|
dataSource={dataSource}
|
||||||
|
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
pagination={{
|
||||||
|
current: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['20', '50', '100'],
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 3000 }}
|
||||||
|
locale={{ emptyText: loading ? '加载中...' : '暂无推广数据' }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</SycmBusinessPanel>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="推广指标设置"
|
||||||
|
width={520}
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
>
|
||||||
|
<div style={{ color: 'var(--sycm-text-tertiary)', fontSize: 12 }}>指标设置功能开发中</div>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="添加推广商品"
|
||||||
|
width={520}
|
||||||
|
open={addProductOpen}
|
||||||
|
onClose={() => setAddProductOpen(false)}
|
||||||
|
>
|
||||||
|
<Input.Search allowClear placeholder="输入商品ID或商品名称" enterButton="查询" />
|
||||||
|
<div style={{ marginTop: 96, textAlign: 'center', color: 'var(--sycm-text-tertiary)' }}>
|
||||||
|
暂无可添加商品
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</SycmPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PromotionModule;
|
||||||
214
frontend/src/pages/PromotionModule/style.css
Normal file
214
frontend/src/pages/PromotionModule/style.css
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
.promotion-module-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-page-section-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard,
|
||||||
|
.promotion-workspace {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard {
|
||||||
|
padding: var(--sycm-module-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard__header,
|
||||||
|
.promotion-actionbar,
|
||||||
|
.promotion-filterbar,
|
||||||
|
.promotion-selectionbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sycm-space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard__header > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-title);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard__header span,
|
||||||
|
.promotion-dashboard__tools,
|
||||||
|
.promotion-filterbar > span,
|
||||||
|
.promotion-selectionbar {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard__tools {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard__metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(110px, 1fr));
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
margin-top: var(--sycm-space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard__metrics .sycm-dashboard-metric {
|
||||||
|
min-height: 96px;
|
||||||
|
padding: var(--sycm-space-sm);
|
||||||
|
border-radius: var(--sycm-radius-lg);
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard__metrics .sycm-dashboard-metric__label {
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-dashboard__metrics .sycm-dashboard-metric__value {
|
||||||
|
font-size: var(--sycm-font-size-metric);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-type-tabs .ant-tabs-nav {
|
||||||
|
min-height: var(--sycm-module-header-min-height);
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 var(--sycm-module-padding);
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-type-tabs .ant-tabs-content-holder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-actionbar {
|
||||||
|
min-height: calc(var(--sycm-control-height) + var(--sycm-space-lg));
|
||||||
|
padding: var(--sycm-space-sm) var(--sycm-module-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-actionbar > div {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-filterbar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: var(--sycm-space-sm) var(--sycm-module-padding);
|
||||||
|
border-top: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||||
|
border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-filterbar .ant-space {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-filterbar__shop {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-filterbar__search {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-selectionbar {
|
||||||
|
min-height: var(--sycm-module-header-min-height);
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0 var(--sycm-module-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-table .ant-table-placeholder .ant-table-cell {
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-product-cell {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-product-cell > img,
|
||||||
|
.promotion-product-cell__image {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
flex: 0 0 52px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: var(--sycm-line-width-base) solid var(--sycm-border-light);
|
||||||
|
border-radius: var(--sycm-radius-sm);
|
||||||
|
background: var(--sycm-bg-subtle);
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: var(--sycm-font-size-micro);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-product-cell > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-product-cell strong,
|
||||||
|
.promotion-product-cell span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-product-cell strong {
|
||||||
|
max-width: 180px;
|
||||||
|
color: var(--sycm-text-strong);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-product-cell span {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-budget-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-space-xxs);
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-metric-settings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--sycm-space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-metric-settings .ant-checkbox-wrapper {
|
||||||
|
margin-inline-start: 0;
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-add-product-empty {
|
||||||
|
margin-top: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.promotion-dashboard__metrics {
|
||||||
|
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-filterbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-filterbar .ant-input-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promotion-filterbar > span {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
340
frontend/src/pages/Settings/DepartmentTagSettings.jsx
Normal file
340
frontend/src/pages/Settings/DepartmentTagSettings.jsx
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert, Button, Card, Input, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, TagsOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
getDepartmentTagConfig,
|
||||||
|
saveDepartmentTagConfig,
|
||||||
|
} from '../../api/productMonitoring';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const METRIC_OPTIONS = [
|
||||||
|
{ value: 'sales_qty_7d', label: '近7天销售件数' },
|
||||||
|
{ value: 'sales_amount_7d', label: '近7天销售额' },
|
||||||
|
];
|
||||||
|
const OWNER_TYPE_OPTIONS = [
|
||||||
|
{ value: 'sku_owner', label: 'SKU负责人' },
|
||||||
|
{ value: 'purchaser', label: '采购负责人' },
|
||||||
|
];
|
||||||
|
const LEVEL_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' };
|
||||||
|
|
||||||
|
const readError = (error) => error?.response?.data?.error || error?.message || '操作失败';
|
||||||
|
|
||||||
|
const useDepartmentConfig = () => {
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await getDepartmentTagConfig();
|
||||||
|
setConfig(result?.data || null);
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(readError(requestError));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
return { config, setConfig, loading, setLoading, error, setError, load };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 部门商品标签配置(G-S / G-A / G-B / G-C) ====================
|
||||||
|
|
||||||
|
export const DepartmentTagSettings = () => {
|
||||||
|
const { config, setConfig, loading, error, load } = useDepartmentConfig();
|
||||||
|
const [rules, setRules] = useState([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.rules) setRules(config.rules.map((item) => ({ ...item })));
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const patch = (tagLevel, key, value) => {
|
||||||
|
setRules((old) => old.map((item) => (item.tagLevel === tagLevel ? { ...item, [key]: value } : item)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await saveDepartmentTagConfig({ rules });
|
||||||
|
setConfig(result?.data || null);
|
||||||
|
message.success('部门商品标签已保存,商品监控列表下次加载即生效');
|
||||||
|
} catch (requestError) {
|
||||||
|
message.error(readError(requestError));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '部门标签',
|
||||||
|
dataIndex: 'tagLevel',
|
||||||
|
width: 120,
|
||||||
|
render: (value) => <Tag color={LEVEL_COLOR[value] || 'default'}>{value}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '判定指标',
|
||||||
|
dataIndex: 'metricKey',
|
||||||
|
width: 180,
|
||||||
|
render: (value, row) => (
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
options={METRIC_OPTIONS}
|
||||||
|
onChange={(next) => patch(row.tagLevel, 'metricKey', next)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '区间起点(含)',
|
||||||
|
dataIndex: 'thresholdMin',
|
||||||
|
width: 160,
|
||||||
|
render: (value, row) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
placeholder="不限"
|
||||||
|
onChange={(e) => patch(row.tagLevel, 'thresholdMin', e.target.value === '' ? null : Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '区间终点(不含)',
|
||||||
|
dataIndex: 'thresholdMax',
|
||||||
|
width: 160,
|
||||||
|
render: (value, row) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
placeholder="不限"
|
||||||
|
onChange={(e) => patch(row.tagLevel, 'thresholdMax', e.target.value === '' ? null : Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '优先级',
|
||||||
|
dataIndex: 'priority',
|
||||||
|
width: 110,
|
||||||
|
render: (value, row) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value ?? 0}
|
||||||
|
onChange={(e) => patch(row.tagLevel, 'priority', Number(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '启用',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 90,
|
||||||
|
render: (value, row) => (
|
||||||
|
<Switch checked={!!value} onChange={(next) => patch(row.tagLevel, 'enabled', next)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-tab-page">
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Space><TagsOutlined /> 部门商品标签配置</Space>}
|
||||||
|
extra={(
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>保存</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
|
||||||
|
<Text type="secondary">
|
||||||
|
商品监控页「部门的标签」按此规则判定:同一 SKU 的<b>近 7 天指标</b>落在 <code>[起点, 终点)</code> 区间即命中,
|
||||||
|
按优先级升序命中即停,未命中留空。留空表示不限(如 G-S 只填起点 500)。
|
||||||
|
</Text>
|
||||||
|
<Table
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
rowKey="tagLevel"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
loading={loading}
|
||||||
|
dataSource={rules}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 部门 SKU 负责人配置 ====================
|
||||||
|
|
||||||
|
export const DepartmentOwnerSettings = () => {
|
||||||
|
const { config, setConfig, loading, error, load } = useDepartmentConfig();
|
||||||
|
const [owners, setOwners] = useState([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.owners) {
|
||||||
|
setOwners(config.owners.map((item, index) => ({
|
||||||
|
key: `${item.departmentId}-${item.ownerType}-${item.spu || ''}-${item.userId}-${index}`,
|
||||||
|
departmentId: item.departmentId,
|
||||||
|
spu: item.spu || '',
|
||||||
|
ownerType: item.ownerType,
|
||||||
|
userId: item.userId,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const departmentOptions = useMemo(
|
||||||
|
() => (config?.departments || []).map((item) => ({ value: item.id, label: item.name })),
|
||||||
|
[config]
|
||||||
|
);
|
||||||
|
const userOptions = useMemo(
|
||||||
|
() => (config?.users || []).map((item) => ({ value: item.id, label: item.name || item.username })),
|
||||||
|
[config]
|
||||||
|
);
|
||||||
|
|
||||||
|
const patch = (key, field, value) => {
|
||||||
|
setOwners((old) => old.map((item) => (item.key === key ? { ...item, [field]: value } : item)));
|
||||||
|
};
|
||||||
|
const addRow = () => {
|
||||||
|
setOwners((old) => [...old, {
|
||||||
|
key: `new-${Date.now()}`,
|
||||||
|
departmentId: undefined,
|
||||||
|
spu: '',
|
||||||
|
ownerType: 'sku_owner',
|
||||||
|
userId: undefined,
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
const removeRow = (key) => setOwners((old) => old.filter((item) => item.key !== key));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const invalid = owners.find((item) => !item.departmentId || !item.userId);
|
||||||
|
if (invalid) {
|
||||||
|
message.warning('每一行都必须选择部门与负责人');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await saveDepartmentTagConfig({ owners });
|
||||||
|
setConfig(result?.data || null);
|
||||||
|
message.success('部门 SKU 负责人已保存');
|
||||||
|
} catch (requestError) {
|
||||||
|
message.error(readError(requestError));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '部门',
|
||||||
|
dataIndex: 'departmentId',
|
||||||
|
width: 200,
|
||||||
|
render: (value, row) => (
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="请选择部门"
|
||||||
|
value={value}
|
||||||
|
options={departmentOptions}
|
||||||
|
onChange={(next) => patch(row.key, 'departmentId', next)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SPU(留空=部门默认)',
|
||||||
|
dataIndex: 'spu',
|
||||||
|
width: 200,
|
||||||
|
render: (value, row) => (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
placeholder="留空表示该部门的默认负责人"
|
||||||
|
onChange={(e) => patch(row.key, 'spu', e.target.value)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '负责人类型',
|
||||||
|
dataIndex: 'ownerType',
|
||||||
|
width: 150,
|
||||||
|
render: (value, row) => (
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
options={OWNER_TYPE_OPTIONS}
|
||||||
|
onChange={(next) => patch(row.key, 'ownerType', next)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '负责人',
|
||||||
|
dataIndex: 'userId',
|
||||||
|
width: 200,
|
||||||
|
render: (value, row) => (
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="请选择负责人"
|
||||||
|
value={value}
|
||||||
|
options={userOptions}
|
||||||
|
onChange={(next) => patch(row.key, 'userId', next)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 90,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Popconfirm title="删除该行?" onConfirm={() => removeRow(row.key)}>
|
||||||
|
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-tab-page">
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Space><TeamOutlined /> 部门 SKU 负责人配置</Space>}
|
||||||
|
extra={(
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||||
|
<Button icon={<PlusOutlined />} onClick={addRow}>新增一行</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>保存</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
|
||||||
|
<Text type="secondary">
|
||||||
|
商品监控页「部门SKU负责人」按此表匹配:先找<b>部门 + SPU</b> 精确配置,找不到再用<b>部门 + SPU 留空</b>的默认负责人。
|
||||||
|
保存会整体覆盖当前配置。
|
||||||
|
</Text>
|
||||||
|
<Table
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
rowKey="key"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
loading={loading}
|
||||||
|
dataSource={owners}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DepartmentTagSettings;
|
||||||
372
frontend/src/pages/Settings/ProductTagSettings.jsx
Normal file
372
frontend/src/pages/Settings/ProductTagSettings.jsx
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Alert, Button, Input, InputNumber, Select, Space, Spin, Table, Tag, Typography, message } from 'antd';
|
||||||
|
import { ReloadOutlined, SaveOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { getProductTagConfig, updateProductTagConfig } from '../../api/admin';
|
||||||
|
import { SycmCard } from '../../components/Common';
|
||||||
|
import './productTagSettings.css';
|
||||||
|
|
||||||
|
const SCALE_TAGS = ['爆款', '热卖款', '动销款', '低动销款'];
|
||||||
|
const SCALE_TAG_ORDER = [...SCALE_TAGS, '滞销款'];
|
||||||
|
const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级'];
|
||||||
|
const SABC_TAGS = ['S', 'A', 'B', 'C', 'D'];
|
||||||
|
|
||||||
|
// 规模标签配色:爆款最优 → 滞销款最差
|
||||||
|
const SCALE_TAG_COLOR = {
|
||||||
|
爆款: 'red',
|
||||||
|
热卖款: 'orange',
|
||||||
|
动销款: 'blue',
|
||||||
|
低动销款: 'default',
|
||||||
|
滞销款: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SABC_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' };
|
||||||
|
|
||||||
|
const numText = (value) => (
|
||||||
|
value === null || value === undefined || value === ''
|
||||||
|
? '--'
|
||||||
|
: Number(value).toLocaleString('zh-CN')
|
||||||
|
);
|
||||||
|
|
||||||
|
const scaleRowKey = (row) => `${row.category}||${row.scaleTag}`;
|
||||||
|
|
||||||
|
const ProductTagSettings = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [scaleThresholds, setScaleThresholds] = useState([]);
|
||||||
|
const [profitLevels, setProfitLevels] = useState([]);
|
||||||
|
const [sabcMappings, setSabcMappings] = useState([]);
|
||||||
|
const [excludedCategories, setExcludedCategories] = useState([]);
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('');
|
||||||
|
const [scaleTagFilter, setScaleTagFilter] = useState();
|
||||||
|
|
||||||
|
const applyConfig = useCallback((config = {}) => {
|
||||||
|
setScaleThresholds(Array.isArray(config.scaleThresholds) ? config.scaleThresholds : []);
|
||||||
|
setProfitLevels(Array.isArray(config.profitLevels) ? config.profitLevels : []);
|
||||||
|
setSabcMappings(Array.isArray(config.sabcMappings) ? config.sabcMappings : []);
|
||||||
|
setExcludedCategories(Array.isArray(config.excludedCategories) ? config.excludedCategories : []);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getProductTagConfig();
|
||||||
|
applyConfig(response?.data?.data || response?.data || {});
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error?.response?.data?.error || error?.message || '加载商品标签配置失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [applyConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
}, [loadConfig]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await updateProductTagConfig({
|
||||||
|
scaleThresholds,
|
||||||
|
profitLevels,
|
||||||
|
sabcMappings,
|
||||||
|
excludedCategories,
|
||||||
|
});
|
||||||
|
applyConfig(response?.data?.data || response?.data || {});
|
||||||
|
message.success(
|
||||||
|
`已保存:规模阈值 ${scaleThresholds.length} 行 / 利润标签 ${profitLevels.length} 行 / `
|
||||||
|
+ `SABC 映射 ${sabcMappings.length} 条 / 排除品类 ${excludedCategories.length} 个`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error?.response?.data?.error || error?.message || '保存商品标签配置失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 规模阈值:原地更新某一行
|
||||||
|
const patchScaleRow = useCallback((rowKey, field, value) => {
|
||||||
|
setScaleThresholds((rows) => rows.map((row) => (
|
||||||
|
scaleRowKey(row) === rowKey ? { ...row, [field]: value } : row
|
||||||
|
)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// SABC 映射:矩阵单元格更新(不存在则新增)
|
||||||
|
const patchSabcCell = useCallback((scaleTag, profitLevel, sabcTag) => {
|
||||||
|
setSabcMappings((rows) => {
|
||||||
|
const index = rows.findIndex((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel);
|
||||||
|
if (index >= 0) {
|
||||||
|
const next = [...rows];
|
||||||
|
next[index] = { ...next[index], sabcTag };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [...rows, { scaleTag, profitLevel, sabcTag, enabled: true }];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sabcValueOf = useCallback((scaleTag, profitLevel) => {
|
||||||
|
const hit = sabcMappings.find((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel);
|
||||||
|
return hit?.sabcTag;
|
||||||
|
}, [sabcMappings]);
|
||||||
|
|
||||||
|
// 候选品类:已配置的品类(含 jst_sku.category 全名)作为联想项,
|
||||||
|
// 未配置的(如「其他 - 配件」)直接手动输入完整值。
|
||||||
|
const dbCategoryOptions = useMemo(() => {
|
||||||
|
const set = new Set();
|
||||||
|
scaleThresholds.forEach((row) => {
|
||||||
|
if (row.category) set.add(row.category);
|
||||||
|
});
|
||||||
|
excludedCategories.forEach((item) => set.add(item));
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN'));
|
||||||
|
}, [scaleThresholds, excludedCategories]);
|
||||||
|
|
||||||
|
const filteredScaleRows = useMemo(() => {
|
||||||
|
const keyword = categoryFilter.trim();
|
||||||
|
return scaleThresholds.filter((row) => (
|
||||||
|
(!keyword || String(row.displayName || '').includes(keyword) || String(row.category || '').includes(keyword))
|
||||||
|
&& (!scaleTagFilter || row.scaleTag === scaleTagFilter)
|
||||||
|
));
|
||||||
|
}, [scaleThresholds, categoryFilter, scaleTagFilter]);
|
||||||
|
|
||||||
|
const scaleColumns = [
|
||||||
|
{
|
||||||
|
title: '品类',
|
||||||
|
dataIndex: 'displayName',
|
||||||
|
width: 200,
|
||||||
|
fixed: 'left',
|
||||||
|
filters: SCALE_TAGS.map((tag) => ({ text: tag, value: tag })),
|
||||||
|
onFilter: (value, record) => record.scaleTag === value,
|
||||||
|
render: (value, record) => (
|
||||||
|
<div className="pts-category-cell">
|
||||||
|
<div className="pts-category-cell__name">{value || record.category}</div>
|
||||||
|
{/* 展示 jst_sku.category 的完整值,便于核对品类名映射 */}
|
||||||
|
{record.category && record.category !== value ? (
|
||||||
|
<div className="pts-category-cell__db">{record.category}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '规模标签',
|
||||||
|
dataIndex: 'scaleTag',
|
||||||
|
width: 110,
|
||||||
|
render: (value) => <Tag color={SCALE_TAG_COLOR[value]}>{value}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '业绩起点',
|
||||||
|
dataIndex: 'performanceMin',
|
||||||
|
width: 130,
|
||||||
|
align: 'right',
|
||||||
|
render: (value, record) => (
|
||||||
|
<InputNumber
|
||||||
|
className="pts-number"
|
||||||
|
value={value}
|
||||||
|
min={0}
|
||||||
|
precision={0}
|
||||||
|
step={1000}
|
||||||
|
onChange={(next) => patchScaleRow(scaleRowKey(record), 'performanceMin', next)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '日销起点',
|
||||||
|
dataIndex: 'salesMin',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
// 日销存在 0.5 这类合法小数(低动销款),不能用 precision=0 强制取整
|
||||||
|
render: (value, record) => (
|
||||||
|
<InputNumber
|
||||||
|
className="pts-number"
|
||||||
|
value={value}
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
step={0.5}
|
||||||
|
onChange={(next) => patchScaleRow(scaleRowKey(record), 'salesMin', next)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '判定优先级',
|
||||||
|
dataIndex: 'priority',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => numText(value),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const profitColumns = [
|
||||||
|
{ title: '利润标签', dataIndex: 'levelCode', width: 120 },
|
||||||
|
{
|
||||||
|
title: '营业利润率上限',
|
||||||
|
dataIndex: 'profitRateMax',
|
||||||
|
width: 160,
|
||||||
|
align: 'right',
|
||||||
|
render: (value, record) => (
|
||||||
|
<InputNumber
|
||||||
|
className="pts-number"
|
||||||
|
value={value}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
precision={4}
|
||||||
|
onChange={(next) => setProfitLevels((rows) => rows.map((row) => (
|
||||||
|
row.levelCode === record.levelCode ? { ...row, profitRateMax: next } : row
|
||||||
|
)))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '说明(展示用,不参与计算)',
|
||||||
|
dataIndex: 'description',
|
||||||
|
render: (value, record) => (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
placeholder="如:营业利润率≤0.18"
|
||||||
|
onChange={(event) => setProfitLevels((rows) => rows.map((row) => (
|
||||||
|
row.levelCode === record.levelCode ? { ...row, description: event.target.value } : row
|
||||||
|
)))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// SABC 映射矩阵:每行一个规模标签,每列一个利润标签
|
||||||
|
const matrixColumns = [
|
||||||
|
{
|
||||||
|
title: '规模标签 \ 利润标签',
|
||||||
|
dataIndex: 'scaleTag',
|
||||||
|
width: 150,
|
||||||
|
fixed: 'left',
|
||||||
|
render: (value) => <Tag color={SCALE_TAG_COLOR[value]}>{value}</Tag>,
|
||||||
|
},
|
||||||
|
...PROFIT_LEVELS.map((level) => ({
|
||||||
|
title: level,
|
||||||
|
dataIndex: level,
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Select
|
||||||
|
className="pts-sabc-select"
|
||||||
|
value={sabcValueOf(record.scaleTag, level)}
|
||||||
|
placeholder="--"
|
||||||
|
options={SABC_TAGS.map((tag) => ({ value: tag, label: tag }))}
|
||||||
|
onChange={(value) => patchSabcCell(record.scaleTag, level, value)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matrixRows = SCALE_TAG_ORDER.map((scaleTag) => {
|
||||||
|
const row = { key: scaleTag, scaleTag };
|
||||||
|
PROFIT_LEVELS.forEach((level) => { row[level] = scaleTag; });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pts-page">
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="标签配置保存在本项目独立系统数据库"
|
||||||
|
description="规模标签阈值按「分类」分别设置;判定时区间取左闭右开 [起点, 终点),业绩与日销需同时命中,按优先级升序命中即停,全部未命中则判为「滞销款」。保存后标签不会自动重算,需等待后续数据刷新。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SycmCard
|
||||||
|
title="规模标签阈值"
|
||||||
|
extra={<Typography.Text type="secondary">{`共 ${filteredScaleRows.length} / ${scaleThresholds.length} 行`}</Typography.Text>}
|
||||||
|
>
|
||||||
|
<Space wrap size={[12, 12]} className="pts-filter-bar">
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="搜索分类"
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(event) => setCategoryFilter(event.target.value)}
|
||||||
|
style={{ width: 220 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="规模标签"
|
||||||
|
value={scaleTagFilter}
|
||||||
|
options={SCALE_TAGS.map((tag) => ({ value: tag, label: tag }))}
|
||||||
|
onChange={setScaleTagFilter}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
/>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={loadConfig} loading={loading}>重新加载</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>保存配置</Button>
|
||||||
|
</Space>
|
||||||
|
<Spin spinning={loading} tip="正在加载标签配置…">
|
||||||
|
<Table
|
||||||
|
className="pts-table"
|
||||||
|
rowKey={scaleRowKey}
|
||||||
|
size="small"
|
||||||
|
columns={scaleColumns}
|
||||||
|
dataSource={filteredScaleRows}
|
||||||
|
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 行` }}
|
||||||
|
scroll={{ x: 760 }}
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
</SycmCard>
|
||||||
|
|
||||||
|
<SycmCard title="利润标签阈值">
|
||||||
|
<Table
|
||||||
|
className="pts-table"
|
||||||
|
rowKey="levelCode"
|
||||||
|
size="small"
|
||||||
|
columns={profitColumns}
|
||||||
|
dataSource={profitLevels}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</SycmCard>
|
||||||
|
|
||||||
|
<SycmCard
|
||||||
|
title="SABC 映射"
|
||||||
|
extra={<Typography.Text type="secondary">规模标签 × 利润标签 → 公司商品标签</Typography.Text>}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
className="pts-table pts-matrix"
|
||||||
|
rowKey="key"
|
||||||
|
size="small"
|
||||||
|
columns={matrixColumns}
|
||||||
|
dataSource={matrixRows}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 700 }}
|
||||||
|
/>
|
||||||
|
<Space wrap size={[8, 8]} className="pts-legend">
|
||||||
|
<Typography.Text type="secondary">取值说明:</Typography.Text>
|
||||||
|
{SABC_TAGS.map((tag) => (
|
||||||
|
<Tag key={tag} color={SABC_COLOR[tag]}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
<Typography.Text type="secondary">(S 最优,D 最差;D 档为本次评审确认保留)</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</SycmCard>
|
||||||
|
|
||||||
|
<SycmCard
|
||||||
|
title="不参与评级的品类"
|
||||||
|
extra={<Typography.Text type="secondary">{`已排除 ${excludedCategories.length} 个`}</Typography.Text>}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="这里的品类不会被判定规模标签,也不会出现在商品监控的标签统计中"
|
||||||
|
description="与「未配置」不同:未配置是还没设置阈值(商品会显示为未评级、需要补配置);这里是有意排除,属于既定业务口径。改动同样不会自动触发重算。"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
className="pts-exclude-select"
|
||||||
|
placeholder="输入或粘贴 jst_sku.category 的完整值后回车,如:其他 - 配件"
|
||||||
|
value={excludedCategories}
|
||||||
|
onChange={(value) => setExcludedCategories(value)}
|
||||||
|
options={dbCategoryOptions.map((value) => ({ value, label: value }))}
|
||||||
|
tokenSeparators={[',', ',', '\n']}
|
||||||
|
/>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||||
|
当前库中「其他 -」开头的非家具品类还有:其他 - 赠品、其他 - 福利品、其他 - 虚拟产品,
|
||||||
|
如果它们也不评级,一并加进上面的列表。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</SycmCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductTagSettings;
|
||||||
@ -9,7 +9,7 @@ import {
|
|||||||
SaveOutlined, ReloadOutlined, ApiOutlined, AimOutlined,
|
SaveOutlined, ReloadOutlined, ApiOutlined, AimOutlined,
|
||||||
EditOutlined, DeleteOutlined, PlusOutlined, GlobalOutlined,
|
EditOutlined, DeleteOutlined, PlusOutlined, GlobalOutlined,
|
||||||
UploadOutlined, PictureOutlined, FieldTimeOutlined, CloudUploadOutlined, DownloadOutlined,
|
UploadOutlined, PictureOutlined, FieldTimeOutlined, CloudUploadOutlined, DownloadOutlined,
|
||||||
RobotOutlined, KeyOutlined
|
RobotOutlined, KeyOutlined, TagsOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { SycmCard, SycmPage } from '../../components/Common';
|
import { SycmCard, SycmPage } from '../../components/Common';
|
||||||
import { getRedisConfig, updateRedisConfig, switchRedisRuntimeProfile, testRedisConfig, getWorkOrderConfig, updateWorkOrderConfig, testWorkOrderConfig, getSystemConfig, updateSystemConfig, uploadLogo, startBackfill, getBackfillStatus, getS3Config, updateS3Config, testS3Config, downloadProductCodeImportTemplate, downloadProductCodeBatchImportTemplate, importProductCodeWorkbook, deleteProductCodeItems, deleteSelectedProductCodeItems } from '../../api/admin';
|
import { getRedisConfig, updateRedisConfig, switchRedisRuntimeProfile, testRedisConfig, getWorkOrderConfig, updateWorkOrderConfig, testWorkOrderConfig, getSystemConfig, updateSystemConfig, uploadLogo, startBackfill, getBackfillStatus, getS3Config, updateS3Config, testS3Config, downloadProductCodeImportTemplate, downloadProductCodeBatchImportTemplate, importProductCodeWorkbook, deleteProductCodeItems, deleteSelectedProductCodeItems } from '../../api/admin';
|
||||||
@ -24,6 +24,8 @@ import SidebarSettings from './SidebarSettings';
|
|||||||
import DatabaseSettings from './DatabaseSettings';
|
import DatabaseSettings from './DatabaseSettings';
|
||||||
import ApiKeySettings from './ApiKeySettings';
|
import ApiKeySettings from './ApiKeySettings';
|
||||||
import SelectionFissionAiSettings from './SelectionFissionAiSettings';
|
import SelectionFissionAiSettings from './SelectionFissionAiSettings';
|
||||||
|
import ProductTagSettings from './ProductTagSettings';
|
||||||
|
import DepartmentTagSettings, { DepartmentOwnerSettings } from './DepartmentTagSettings';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
@ -31,7 +33,7 @@ const { Option } = Select;
|
|||||||
const SETTINGS_TAB_KEYS = new Set([
|
const SETTINGS_TAB_KEYS = new Set([
|
||||||
'site', 'sidebar', 'auth', 'databases', 'redis', 'workOrderSystem', 'apiKeys',
|
'site', 'sidebar', 'auth', 'databases', 'redis', 'workOrderSystem', 'apiKeys',
|
||||||
'selectionFissionAi', 'alibaba1688ImageSearch',
|
'selectionFissionAi', 'alibaba1688ImageSearch',
|
||||||
'productCodeImport', 'kpiTargets', 'backfill', 'user', 'dept',
|
'productCodeImport', 'kpiTargets', 'productTags', 'backfill', 'user', 'dept',
|
||||||
'permission', 's3',
|
'permission', 's3',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -1798,6 +1800,21 @@ const Settings = () => {
|
|||||||
label: <span className="settings-tab-label"><AimOutlined /> 目标值配置</span>,
|
label: <span className="settings-tab-label"><AimOutlined /> 目标值配置</span>,
|
||||||
content: <KpiTargetsSettings />
|
content: <KpiTargetsSettings />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'productTags',
|
||||||
|
label: <span className="settings-tab-label"><TagsOutlined /> 商品标签配置</span>,
|
||||||
|
content: <ProductTagSettings />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'departmentTags',
|
||||||
|
label: <span className="settings-tab-label"><TagsOutlined /> 部门商品标签配置</span>,
|
||||||
|
content: <DepartmentTagSettings />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'departmentOwners',
|
||||||
|
label: <span className="settings-tab-label"><TeamOutlined /> 部门SKU负责人配置</span>,
|
||||||
|
content: <DepartmentOwnerSettings />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'backfill',
|
key: 'backfill',
|
||||||
label: <span className="settings-tab-label"><FieldTimeOutlined /> {'\u6570\u636e\u56de\u6eaf'}</span>,
|
label: <span className="settings-tab-label"><FieldTimeOutlined /> {'\u6570\u636e\u56de\u6eaf'}</span>,
|
||||||
|
|||||||
106
frontend/src/pages/Settings/productTagSettings.css
Normal file
106
frontend/src/pages/Settings/productTagSettings.css
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/* 商品标签配置(系统设置 → 商品标签配置)
|
||||||
|
* 全部使用 design-system 的 --sycm-* token,不引入新色值
|
||||||
|
*/
|
||||||
|
|
||||||
|
.pts-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sycm-page-section-gap);
|
||||||
|
padding-bottom: var(--sycm-page-section-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-filter-bar {
|
||||||
|
margin-bottom: var(--sycm-space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-filter-bar .ant-input-affix-wrapper,
|
||||||
|
.pts-filter-bar .ant-select {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格:与 OpsBIncentiveBoard 保持一致的视觉 */
|
||||||
|
.pts-table .ant-table {
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-table .ant-table-thead > tr > th {
|
||||||
|
padding: var(--sycm-table-header-padding-y) var(--sycm-table-header-padding-x);
|
||||||
|
color: var(--sycm-text-secondary);
|
||||||
|
font-size: var(--sycm-font-size-caption);
|
||||||
|
font-weight: var(--sycm-font-weight-medium);
|
||||||
|
background: var(--sycm-bg-table-head);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-table .ant-table-tbody > tr > td {
|
||||||
|
padding: var(--sycm-table-cell-padding-y) var(--sycm-table-cell-padding-x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-table .ant-table-tbody > tr:hover > td {
|
||||||
|
background: var(--sycm-bg-table-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-table .ant-table-placeholder .ant-table-cell {
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 阈值输入框:去掉数字输入的上下箭头,避免表格里视觉噪声 */
|
||||||
|
.pts-number {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-number input {
|
||||||
|
text-align: right;
|
||||||
|
font-family: var(--sycm-font-family-number);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-number .ant-input-number-handler-wrap {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SABC 映射矩阵 */
|
||||||
|
.pts-matrix .ant-table-tbody > tr > td {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-sabc-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-sabc-select .ant-select-selection-item {
|
||||||
|
font-family: var(--sycm-font-family-number);
|
||||||
|
font-weight: var(--sycm-font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-legend {
|
||||||
|
margin-top: var(--sycm-space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品类单元格:主行显示配置短名,次行显示 jst_sku.category 完整值 */
|
||||||
|
.pts-category-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-category-cell__name {
|
||||||
|
color: var(--sycm-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-category-cell__db {
|
||||||
|
color: var(--sycm-text-tertiary);
|
||||||
|
font-size: var(--sycm-font-size-micro);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pts-exclude-select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.pts-filter-bar .ant-input-affix-wrapper,
|
||||||
|
.pts-filter-bar .ant-select {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,9 +9,16 @@ const Setup = lazy(() => import('../pages/Setup'));
|
|||||||
const Settings = lazy(() => import('../pages/Settings'));
|
const Settings = lazy(() => import('../pages/Settings'));
|
||||||
const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers'));
|
const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers'));
|
||||||
const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks'));
|
const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks'));
|
||||||
|
const MarketingEvents = lazy(() => import('../pages/MarketingEvents'));
|
||||||
const ProductInformation = lazy(() => import('../pages/ProductInformation'));
|
const ProductInformation = lazy(() => import('../pages/ProductInformation'));
|
||||||
|
const PromotionModule = lazy(() => import('../pages/PromotionModule'));
|
||||||
const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder'));
|
const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder'));
|
||||||
const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview'));
|
const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview'));
|
||||||
|
const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis'));
|
||||||
|
const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard'));
|
||||||
|
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
|
||||||
|
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
|
||||||
|
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
|
||||||
|
|
||||||
const lazyLoadingElement = <PageLoadingFallback />;
|
const lazyLoadingElement = <PageLoadingFallback />;
|
||||||
|
|
||||||
@ -64,10 +71,46 @@ const router = createBrowserRouter([
|
|||||||
path: 'planning',
|
path: 'planning',
|
||||||
element: <Navigate to={HOME_URL} replace />,
|
element: <Navigate to={HOME_URL} replace />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/marketing-events',
|
||||||
|
element: <Navigate to="/planning/operation/marketing-events/activity-products" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/marketing-events/activity-products',
|
||||||
|
element: renderLazyComponent(MarketingEvents),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/marketing-tools',
|
||||||
|
element: <Navigate to="/planning/operation/marketing-events" replace />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'planning/operation/product-information',
|
path: 'planning/operation/product-information',
|
||||||
element: renderLazyComponent(ProductInformation),
|
element: renderLazyComponent(ProductInformation),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/product-analysis',
|
||||||
|
element: renderLazyComponent(ProductAnalysis),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/promotion-module',
|
||||||
|
element: renderLazyComponent(PromotionModule),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/ops-b-incentive',
|
||||||
|
element: renderLazyComponent(OpsBIncentiveBoard),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/product-monitoring',
|
||||||
|
element: renderLazyComponent(ProductMonitoring),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/modular-stock-monitor',
|
||||||
|
element: renderLazyComponent(ModularStockMonitor),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'planning/operation/mkh-bom-data',
|
||||||
|
element: renderLazyComponent(MkhBomData),
|
||||||
|
},
|
||||||
{ path: 'planning/operation/store-checkup', element: <Navigate to={HOME_URL} replace /> },
|
{ path: 'planning/operation/store-checkup', element: <Navigate to={HOME_URL} replace /> },
|
||||||
{ path: 'planning/operation/review-analysis', element: <Navigate to={HOME_URL} replace /> },
|
{ path: 'planning/operation/review-analysis', element: <Navigate to={HOME_URL} replace /> },
|
||||||
{ path: 'planning/operation/customer-service-checkup', element: <Navigate to={HOME_URL} replace /> },
|
{ path: 'planning/operation/customer-service-checkup', element: <Navigate to={HOME_URL} replace /> },
|
||||||
|
|||||||
@ -13,15 +13,10 @@ test('legacy saved menus cannot resurrect retired features and keep business cus
|
|||||||
{ key: 'desktop-client-releases', routeKey: 'desktop-client-releases' },
|
{ key: 'desktop-client-releases', routeKey: 'desktop-client-releases' },
|
||||||
] },
|
] },
|
||||||
{ key: 'product-monitoring', routeKey: 'product-monitoring', label: '我的商品监控', order: 1 },
|
{ key: 'product-monitoring', routeKey: 'product-monitoring', label: '我的商品监控', order: 1 },
|
||||||
{ key: 'ops-b-incentive', routeKey: 'ops-b-incentive' },
|
|
||||||
{ key: 'marketing-management', children: [{ key: 'marketing-events', routeKey: 'marketing-events' }] },
|
|
||||||
{ key: 'product-management', children: [{ key: 'product-analysis', routeKey: 'product-analysis' }] },
|
|
||||||
] }));
|
] }));
|
||||||
const entries = result.entries.flatMap((entry) => [entry, ...(entry.children || [])]);
|
const entries = result.entries.flatMap((entry) => [entry, ...(entry.children || [])]);
|
||||||
assert.ok(entries.every((entry) => ![
|
assert.ok(entries.every((entry) => !['market-insight', 'agent', 'ai-skill-tags', 'store-management', 'operation-tools', 'desktop-client-releases'].includes(entry.key)));
|
||||||
'market-insight', 'agent', 'ai-skill-tags', 'store-management', 'operation-tools',
|
assert.equal(entries.find((entry) => entry.key === 'product-monitoring').label, '我的商品监控');
|
||||||
'desktop-client-releases', 'product-monitoring', 'ops-b-incentive',
|
|
||||||
'marketing-management', 'marketing-events', 'product-management', 'product-analysis',
|
|
||||||
].includes(entry.key)));
|
|
||||||
assert.ok(entries.some((entry) => entry.key === 'settings'));
|
assert.ok(entries.some((entry) => entry.key === 'settings'));
|
||||||
|
assert.ok(entries.some((entry) => entry.key === 'product-analysis'));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,6 +11,29 @@ const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access');
|
|||||||
const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json');
|
const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json');
|
||||||
const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json');
|
const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析后端实际监听端口。
|
||||||
|
* 优先级:CANGKU_BACKEND_PORT 环境变量 > backend/.env 的 PORT > 默认 4001。
|
||||||
|
* 前端 dev server 的 /api 代理必须指向同一端口,否则请求会被转发到空端口。
|
||||||
|
*/
|
||||||
|
function resolveBackendPort() {
|
||||||
|
const fromEnv = Number(process.env.CANGKU_BACKEND_PORT);
|
||||||
|
if (Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv < 65536) {
|
||||||
|
return fromEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPath = path.join(ROOT_DIR, 'backend', '.env');
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
const matched = fs.readFileSync(envPath, 'utf8').match(/^PORT=(\d+)\s*$/mu);
|
||||||
|
const fromFile = Number(matched?.[1]);
|
||||||
|
if (Number.isInteger(fromFile) && fromFile > 0 && fromFile < 65536) {
|
||||||
|
return fromFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 4001;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSshArgs(config) {
|
export function buildSshArgs(config) {
|
||||||
return [
|
return [
|
||||||
'-N', '-T',
|
'-N', '-T',
|
||||||
@ -124,12 +147,14 @@ export async function main(mode = process.argv[2] || 'tunnel') {
|
|||||||
CANGKU_REMOTE_SUPERVISOR: 'true',
|
CANGKU_REMOTE_SUPERVISOR: 'true',
|
||||||
};
|
};
|
||||||
if (kind === 'frontend' && !childEnvironment.VITE_API_PROXY_TARGET) {
|
if (kind === 'frontend' && !childEnvironment.VITE_API_PROXY_TARGET) {
|
||||||
childEnvironment.VITE_API_PROXY_TARGET = `http://127.0.0.1:${process.env.CANGKU_BACKEND_PORT || 4000}`;
|
childEnvironment.VITE_API_PROXY_TARGET = `http://localhost:${resolveBackendPort()}`;
|
||||||
}
|
}
|
||||||
const child = spawn(npmCommand, args, {
|
const child = spawn(npmCommand, args, {
|
||||||
cwd: ROOT_DIR,
|
cwd: ROOT_DIR,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: childEnvironment,
|
env: childEnvironment,
|
||||||
|
// Windows 下 .cmd 不是可执行文件,必须经 shell 启动,否则 spawn 直接抛 EINVAL
|
||||||
|
shell: process.platform === 'win32',
|
||||||
});
|
});
|
||||||
children.add(child);
|
children.add(child);
|
||||||
child.once('exit', (code) => {
|
child.once('exit', (code) => {
|
||||||
|
|||||||
111
sql/20260911_create_product_tag_config_system_mysql.sql
Normal file
111
sql/20260911_create_product_tag_config_system_mysql.sql
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
-- 商品标签配置:规模标签阈值 / 利润标签阈值 / SABC 映射
|
||||||
|
-- 目标库:系统库 shujuku3_system(MySQL / MariaDB)
|
||||||
|
-- 创建日期:2026-09-11
|
||||||
|
--
|
||||||
|
-- 说明:
|
||||||
|
-- 这三张表也会由 backend/src/services/localSchema.js 的 ensureProductTagConfigSchema()
|
||||||
|
-- 在服务启动时自动创建(CREATE TABLE IF NOT EXISTS)。本文件供 DBA 手工建表使用,
|
||||||
|
-- 因为开发环境使用的 shujuku3_external 账号没有 DDL 权限。
|
||||||
|
--
|
||||||
|
-- 执行:mysql -h <host> -P <port> -u <ddl_user> -p shujuku3_system < 20260911_create_product_tag_config_system_mysql.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_scale_thresholds (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
category VARCHAR(128) NOT NULL COMMENT 'jst_sku.category 的完整值,如 住宅家具 - 电脑桌',
|
||||||
|
display_name VARCHAR(128) NULL COMMENT '配置表里的短名,如 电脑桌/书桌(仅用于展示与分组)',
|
||||||
|
scale_tag VARCHAR(32) NOT NULL COMMENT '爆款/热卖款/动销款/低动销款',
|
||||||
|
performance_min DECIMAL(18,4) NULL COMMENT '业绩起点(含)',
|
||||||
|
performance_max DECIMAL(18,4) NULL COMMENT '业绩终点(不含)',
|
||||||
|
sales_min DECIMAL(18,4) NULL COMMENT '日销起点(含)',
|
||||||
|
sales_max DECIMAL(18,4) NULL COMMENT '日销终点(不含)',
|
||||||
|
priority INT NOT NULL DEFAULT 0 COMMENT '同品类判定优先级,升序命中即停',
|
||||||
|
sort INT NOT NULL DEFAULT 0,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_category_tag (category, scale_tag),
|
||||||
|
KEY idx_category_priority (category, enabled, priority),
|
||||||
|
KEY idx_display_name (display_name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模标签阈值配置';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_profit_levels (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
level_code VARCHAR(16) NOT NULL COMMENT '一级~五级',
|
||||||
|
profit_rate_max DECIMAL(8,4) NULL COMMENT '营业利润率上限(说明用)',
|
||||||
|
description VARCHAR(128) NULL COMMENT '展示文案,如 营业利润率≤0.18',
|
||||||
|
sort INT NOT NULL DEFAULT 0,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_level_code (level_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='利润标签阈值配置';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_sabc_mappings (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
scale_tag VARCHAR(32) NOT NULL,
|
||||||
|
profit_level VARCHAR(16) NOT NULL,
|
||||||
|
sabc_tag VARCHAR(8) NOT NULL COMMENT 'S/A/B/C/D',
|
||||||
|
sort INT NOT NULL DEFAULT 0,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_scale_profit (scale_tag, profit_level)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模×利润→SABC 映射';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 初始数据
|
||||||
|
-- 生成方式:node backend/scripts/seed_product_tag_config_from_xlsx.js --print-sql
|
||||||
|
-- 数据来源:《公司SABC标签配置.xlsx》
|
||||||
|
-- - 规模阈值 148 行(37 品类 × 4 档;品类为 '-' 的行已忽略;浮点阈值已取整)
|
||||||
|
-- - 利润标签 5 行
|
||||||
|
-- - SABC 映射 25 行
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 利润标签(区块② 的 J 列)
|
||||||
|
INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort) VALUES
|
||||||
|
('一级', 0.1800, '营业利润率≤0.18', 1),
|
||||||
|
('二级', 0.1600, '营业利润率≤0.16', 2),
|
||||||
|
('三级', 0.1200, '营业利润率≤0.12', 3),
|
||||||
|
('四级', 0.0800, '营业利润率≤0.08', 4),
|
||||||
|
('五级', 0.0800, '营业利润率≤0.08', 5)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
profit_rate_max = VALUES(profit_rate_max),
|
||||||
|
description = VALUES(description),
|
||||||
|
sort = VALUES(sort);
|
||||||
|
|
||||||
|
-- SABC 映射(区块② 的 H/L 列,5×5 矩阵)
|
||||||
|
INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort) VALUES
|
||||||
|
('爆款', '一级', 'S', 1),
|
||||||
|
('爆款', '二级', 'S', 2),
|
||||||
|
('爆款', '三级', 'S', 3),
|
||||||
|
('爆款', '四级', 'A', 4),
|
||||||
|
('爆款', '五级', 'B', 5),
|
||||||
|
('热卖款', '一级', 'S', 6),
|
||||||
|
('热卖款', '二级', 'A', 7),
|
||||||
|
('热卖款', '三级', 'A', 8),
|
||||||
|
('热卖款', '四级', 'A', 9),
|
||||||
|
('热卖款', '五级', 'B', 10),
|
||||||
|
('动销款', '一级', 'A', 11),
|
||||||
|
('动销款', '二级', 'B', 12),
|
||||||
|
('动销款', '三级', 'B', 13),
|
||||||
|
('动销款', '四级', 'B', 14),
|
||||||
|
('动销款', '五级', 'C', 15),
|
||||||
|
('低动销款', '一级', 'B', 16),
|
||||||
|
('低动销款', '二级', 'C', 17),
|
||||||
|
('低动销款', '三级', 'C', 18),
|
||||||
|
('低动销款', '四级', 'C', 19),
|
||||||
|
('低动销款', '五级', 'D', 20),
|
||||||
|
('滞销款', '一级', 'C', 21),
|
||||||
|
('滞销款', '二级', 'D', 22),
|
||||||
|
('滞销款', '三级', 'D', 23),
|
||||||
|
('滞销款', '四级', 'D', 24),
|
||||||
|
('滞销款', '五级', 'D', 25)
|
||||||
|
ON DUPLICATE KEY UPDATE sabc_tag = VALUES(sabc_tag), sort = VALUES(sort);
|
||||||
|
|
||||||
|
-- 规模标签阈值(148 行,见单独生成的文件)
|
||||||
|
-- 因为行数较多,请使用:
|
||||||
|
-- node backend/scripts/seed_product_tag_config_from_xlsx.js
|
||||||
|
-- 直接把 xlsx 导入数据库,或加 --print-sql 生成 INSERT 语句后手工执行。
|
||||||
7
sql/create_product_analysis_user_prefs_system_mysql.sql
Normal file
7
sql/create_product_analysis_user_prefs_system_mysql.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- 商品分析:用户个性化阈值表(系统库,MariaDB/MySQL)
|
||||||
|
CREATE TABLE IF NOT EXISTS product_analysis_user_prefs (
|
||||||
|
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
|
||||||
|
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分析用户个性化配置';
|
||||||
Loading…
x
Reference in New Issue
Block a user