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

营销活动

- 查看拼多多平台活动报名记录 - {latestDate && ( - 最新数据日期:{latestDate} - )} -
- - -
- {summaryMetrics.map((item) => ( - - ))} - {!summary && !summaryLoading && ( - 暂无汇总数据 - )} -
-
- -
- - - - - - - - - - - - - - - - - - - - - -
- - -
-
- -
- { - setStatusFilter(value); - setPagination((prev) => ({ ...prev, current: 1 })); - }} - /> - setGroupByGoods(value === 'group')} - /> -
- - - ( -
- ), - rowExpandable: (record) => Boolean(record.childActivities?.length), - } : undefined} - pagination={{ - current: pagination.current, - pageSize: pagination.pageSize, - total, - showSizeChanger: true, - pageSizeOptions: ['20', '50', '100'], - showTotal: (t) => `共 ${t} 条`, - }} - scroll={{ x: 1600 }} - locale={{ emptyText: loading ? '加载中...' : '暂无活动记录' }} - size="small" - /> - - - - ); -}; - -export default MarketingEvents; diff --git a/frontend/src/pages/MarketingEvents/style.css b/frontend/src/pages/MarketingEvents/style.css deleted file mode 100644 index 510366d..0000000 --- a/frontend/src/pages/MarketingEvents/style.css +++ /dev/null @@ -1,224 +0,0 @@ -.marketing-events-panel { - min-height: calc(100vh - 84px); - overflow: hidden; -} - -.marketing-events-panel__heading { - display: flex; - align-items: baseline; - gap: var(--sycm-space-sm); - padding: var(--sycm-module-padding) var(--sycm-module-padding) 0; -} - -.marketing-events-panel__heading h2 { - margin: 0; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); - line-height: var(--sycm-line-height-tight); -} - -.marketing-events-panel__heading span { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); - line-height: var(--sycm-line-height-normal); -} - -.marketing-events-data-date { - margin-left: auto; - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); - white-space: nowrap; -} - -.marketing-events-summary { - display: grid; - grid-template-columns: repeat(4, minmax(110px, 1fr)); - gap: var(--sycm-space-sm); - margin-top: var(--sycm-space-sm); - padding: 0 var(--sycm-module-padding); -} - -.marketing-events-summary .sycm-dashboard-metric { - min-height: 88px; - padding: var(--sycm-space-sm); - border-radius: var(--sycm-radius-lg); - background: var(--sycm-bg-subtle); -} - -.marketing-events-summary .sycm-dashboard-metric__label { - font-size: var(--sycm-font-size-caption); -} - -.marketing-events-summary .sycm-dashboard-metric__value { - font-size: var(--sycm-font-size-metric); -} - -.marketing-events-summary__empty { - grid-column: 1 / -1; - padding: var(--sycm-space-md); - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); - text-align: center; -} - -.marketing-events-filter { - display: grid; - grid-template-columns: repeat(3, minmax(180px, 1fr)); - gap: var(--sycm-space-sm) var(--sycm-space-xl); - margin-top: var(--sycm-space-sm); - padding: var(--sycm-module-padding); - background: var(--sycm-bg-subtle); -} - -.marketing-events-filter .ant-form-item { - min-width: 0; - margin-bottom: 0; -} - -.marketing-events-filter .ant-form-item-row { - display: grid; - grid-template-columns: 72px minmax(0, 1fr); - align-items: center; -} - -.marketing-events-filter .ant-form-item-label { - padding: 0 var(--sycm-space-sm) 0 0; - overflow: visible; - text-align: right; -} - -.marketing-events-filter .ant-form-item-label > label { - height: var(--sycm-control-height); - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); - line-height: var(--sycm-control-height); -} - -.marketing-events-filter .ant-form-item-control, -.marketing-events-filter .ant-form-item-control-input, -.marketing-events-filter .ant-form-item-control-input-content { - min-height: var(--sycm-control-height); -} - -.marketing-events-filter__actions { - display: flex; - min-height: var(--sycm-control-height); - align-items: center; - gap: var(--sycm-space-sm); - padding-left: 72px; -} - -.marketing-events-table-toolbar { - display: flex; - min-height: 56px; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-md); - padding: var(--sycm-space-sm) var(--sycm-module-padding); - border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); - overflow-x: auto; -} - -.marketing-events-status-switch { - flex: 0 0 auto; -} - -.marketing-events-child-table { - margin: 0; -} - -.marketing-events-child-table .ant-table { - background: var(--sycm-bg-page-soft, #fafafa); -} - -.marketing-events-table .ant-table-wrapper, -.marketing-events-table .ant-spin-nested-loading, -.marketing-events-table .ant-spin-container { - min-width: 0; -} - -.marketing-events-table .ant-table { - min-width: 0; -} - -.marketing-events-table .ant-table-cell { - vertical-align: middle; -} - -.marketing-events-table .ant-table-thead > tr > th { - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-semibold); - white-space: nowrap; -} - -.marketing-events-table .ant-table-tbody > tr > td { - color: var(--sycm-text-primary); - font-size: var(--sycm-font-size-caption); -} - -.marketing-events-table .ant-table-placeholder .ant-table-cell { - height: 220px; -} - -.marketing-events-thumb { - display: block; - width: 52px; - height: 52px; - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-radius: var(--sycm-radius-sm); - object-fit: cover; -} - -.marketing-events-thumb.is-empty { - display: inline-flex; - align-items: center; - justify-content: center; - width: 52px; - height: 52px; - background: var(--sycm-bg-subtle); - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); -} - -.marketing-events-goods-cell { - display: flex; - min-width: 0; - flex-direction: column; - gap: var(--sycm-space-xxs); - line-height: var(--sycm-line-height-normal); -} - -.marketing-events-goods-name { - overflow: hidden; - color: var(--sycm-text-strong); - font-weight: var(--sycm-font-weight-medium); - text-overflow: ellipsis; - white-space: nowrap; -} - -.marketing-events-goods-id { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); -} - -@media (max-width: 767px) { - .marketing-events-panel__heading { - align-items: flex-start; - flex-direction: column; - gap: var(--sycm-space-xxs); - } - - .marketing-events-filter { - grid-template-columns: 1fr; - gap: var(--sycm-space-sm); - } - - .marketing-events-filter__actions { - padding-left: 72px; - } -} diff --git a/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx b/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx deleted file mode 100644 index 78f580b..0000000 --- a/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx +++ /dev/null @@ -1,233 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Alert, Button, Card, Col, DatePicker, Descriptions, Drawer, Dropdown, Input, Row, Select, - Space, Spin, Statistic, Table, Tabs, Tag, Typography, message, -} from 'antd'; -import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import { SycmPage } from '../../components/Common'; -import { getOpsBIncentiveBatches, getOpsBIncentiveBoard, getOpsBIncentiveDaily } from '../../api/planningUpload'; -import { exportTableFile } from '../../utils/tableExport'; -import './opsBIncentiveBoard.css'; - -const { Title, Text } = Typography; -const FALLBACK_BATCH = { key: 'ops-b-20260715-20260721', batchNo: 1, scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 }; -const batchLabel = (item) => { - const mmdd = (value) => dayjs(value).format('MM-DD'); - const cap = `${parseFloat((Number(item.feeCap) * 100).toFixed(1))}%`; - return `第${item.batchNo}批(${mmdd(item.scopeStart)} 至 ${mmdd(item.scopeEnd)} · 拉取 ${mmdd(item.pullDate)} · 费比≤${cap})${item.finalized ? ' · 已定档' : ''}`; -}; - -const number = (value, digits = 2) => value === null || value === undefined || value === '' - ? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); -const money = (value) => value === null || value === undefined ? '--' : `¥${number(value)}`; -const percent = (value) => value === null || value === undefined ? '--' : `${(Number(value) * 100).toFixed(2)}%`; -const dateText = (value) => value ? dayjs(value).format('YYYY-MM-DD') : '--'; - -const OpsBIncentiveBoardPanel = () => { - const [batches, setBatches] = useState([]); - const [batch, setBatch] = useState(FALLBACK_BATCH.key); - const [metricEnd, setMetricEnd] = useState(dayjs(FALLBACK_BATCH.pullDate)); - const [filters, setFilters] = useState({ ider: '', shopName: '', spu: '', goodsId: '' }); - const [data, setData] = useState({ rows: [], ownerSummary: [], summary: {} }); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [selected, setSelected] = useState(null); - const [dailyRows, setDailyRows] = useState([]); - const [dailyLoading, setDailyLoading] = useState(false); - - const selectedBatch = useMemo( - () => batches.find((item) => item.key === batch) || FALLBACK_BATCH, - [batches, batch] - ); - - useEffect(() => { - let cancelled = false; - getOpsBIncentiveBatches() - .then((result) => { - const list = result?.data?.data || result?.data || []; - if (cancelled || !Array.isArray(list) || !list.length) return; - setBatches(list); - if (!list.some((item) => item.key === FALLBACK_BATCH.key)) { - setBatch(list[0].key); - setMetricEnd(dayjs(list[0].pullDate)); - } - }) - .catch(() => {}); - return () => { cancelled = true; }; - }, []); - - const query = useMemo(() => ({ - scopeStart: selectedBatch.scopeStart, - scopeEnd: selectedBatch.scopeEnd, - metricEnd: metricEnd?.format('YYYY-MM-DD') || selectedBatch.pullDate, - ...filters, - }), [selectedBatch, metricEnd, filters]); - - const changeBatch = (value) => { - setBatch(value); - const next = batches.find((item) => item.key === value); - if (next) setMetricEnd(dayjs(next.pullDate)); - }; - - const load = useCallback(async () => { - setLoading(true); setError(''); - try { - const result = await getOpsBIncentiveBoard(query); - setData(result?.data || result || { rows: [], ownerSummary: [], summary: {} }); - } catch (requestError) { - setError(requestError?.message || '加载运营中心B看板失败'); - } finally { setLoading(false); } - }, [query]); - - useEffect(() => { load(); }, [load]); - - const openDetail = async (record) => { - setSelected(record); setDailyRows([]); setDailyLoading(true); - try { - const result = await getOpsBIncentiveDaily({ ...query, goodsId: record.goodsId }); - setDailyRows(result?.data?.rows || result?.rows || []); - } catch (requestError) { - message.error(requestError?.message || '加载每日明细失败'); - } finally { setDailyLoading(false); } - }; - - const iderOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.ider).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]); - const shopOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.shopName).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]); - // 分组:0=动销率与费比均达标,1=仅一项达标,2=均不达标;组内按动销率降序。 - const sortedRows = useMemo(() => [...(data.rows || [])].sort((a, b) => { - const group = (row) => (row.bonusQualified ? 0 : (row.feeQualified || row.sellThroughQualified ? 1 : 2)); - const rate = (row) => (row.sellThroughRate30d === null || row.sellThroughRate30d === undefined ? -1 : row.sellThroughRate30d); - return group(a) - group(b) || rate(b) - rate(a) || String(a.goodsId).localeCompare(String(b.goodsId)); - }), [data.rows]); - const num = (value) => (value === null || value === undefined || value === '' ? -Infinity : Number(value)); - const qualifyFilters = [ - { text: '达标', value: 'yes' }, - { text: '未达标', value: 'no' }, - ]; - const qualifyFilter = (field) => ({ - filters: qualifyFilters, - onFilter: (value, row) => (value === 'yes' ? Boolean(row[field]) : !row[field]), - }); - const columns = [ - { title: 'ID负责人', dataIndex: 'ider', fixed: 'left', width: 100 }, - { title: '商品ID', dataIndex: 'goodsId', fixed: 'left', width: 130, render: (value, row) => }, - { title: '商品名称', dataIndex: 'goodsName', width: 220, ellipsis: true }, - { title: '店铺名称', dataIndex: 'shopName', width: 150, ellipsis: true }, - { title: 'SPU', dataIndex: 'spu', width: 120 }, - { title: '链接类型', dataIndex: 'linkType', width: 100 }, - { title: '状态', dataIndex: 'status', width: 80, render: (v) => {v} }, - { title: 'SKU数', dataIndex: 'skuCount', width: 80, align: 'right', sorter: (a, b) => num(a.skuCount) - num(b.skuCount) }, - { title: '上架时间', dataIndex: 'firstCreatedAt', width: 120, render: dateText, exportValue: dateText }, - { title: '上架天数', dataIndex: 'listingDays', width: 90, align: 'right', sorter: (a, b) => num(a.listingDays) - num(b.listingDays), render: (v) => number(v, 0) }, - { title: '近30天ID动销率', dataIndex: 'sellThroughRate30d', width: 130, align: 'right', sorter: (a, b) => num(a.sellThroughRate30d) - num(b.sellThroughRate30d), render: percent, exportValue: percent }, - { title: '动销率是否达标', dataIndex: 'sellThroughQualified', width: 120, ...qualifyFilter('sellThroughQualified'), render: (v) => {v ? '达标' : '未达标'}, exportValue: (v) => (v ? '达标' : '未达标') }, - { title: '近30天费比', dataIndex: 'feeRatio30d', width: 110, align: 'right', sorter: (a, b) => num(a.feeRatio30d) - num(b.feeRatio30d), render: percent, exportValue: percent }, - { title: '费比是否达标', dataIndex: 'feeQualified', width: 105, ...qualifyFilter('feeQualified'), render: (v) => {v ? '达标' : '未达标'}, exportValue: (v) => (v ? '达标' : '未达标') }, - { title: '近30天真实业绩', dataIndex: 'performance30d', width: 135, align: 'right', sorter: (a, b) => num(a.performance30d) - num(b.performance30d), render: money }, - { title: '近30天真实费用', dataIndex: 'fee30d', width: 135, align: 'right', sorter: (a, b) => num(a.fee30d) - num(b.fee30d), render: money }, - { title: '近30天发货件数', dataIndex: 'goodsCount30d', width: 125, align: 'right', sorter: (a, b) => num(a.goodsCount30d) - num(b.goodsCount30d), render: (v) => number(v, 0) }, - { title: '近30天发货天数', dataIndex: 'shippedDays30d', width: 125, align: 'right', sorter: (a, b) => num(a.shippedDays30d) - num(b.shippedDays30d), render: (v) => number(v, 0) }, - { title: '应发奖金', dataIndex: 'bonus', width: 100, align: 'right', sorter: (a, b) => num(a.bonus) - num(b.bonus), render: money }, - ]; - - const ownerSummaryColumns = [ - { title: 'ID负责人', dataIndex: 'ider' }, - { title: '商品ID数', dataIndex: 'goodsIdCount' }, - { title: '近30天发货件数', dataIndex: 'goodsCount30d', render: (v) => number(v, 0) }, - { title: '费比达标', dataIndex: 'feeQualifiedCount' }, - { title: '奖金达标', dataIndex: 'bonusQualifiedCount' }, - { title: '应发奖金', dataIndex: 'bonus', render: money }, - ]; - - const exportFileName = (label) => ( - `激励看板_${label}_${selectedBatch.scopeStart}_${selectedBatch.scopeEnd}_统计至${metricEnd?.format('YYYYMMDD') || '最新'}` - ); - - const handleExport = async (scope, format) => { - const isSummary = scope === 'summary'; - const exportColumns = isSummary ? ownerSummaryColumns : columns; - const exportRows = isSummary ? (data.ownerSummary || []) : sortedRows; - if (!exportRows.length) { - message.warning(isSummary ? '负责人汇总暂无数据可导出' : 'ID核心明细暂无数据可导出'); - return; - } - try { - await exportTableFile(format, exportFileName(isSummary ? '负责人汇总' : 'ID核心明细'), exportColumns, exportRows, isSummary ? '负责人汇总' : 'ID核心明细'); - message.success(`已导出 ${exportRows.length} 条数据(${format.toUpperCase()})`); - } catch (exportError) { - message.error(exportError?.message || '导出失败'); - } - }; - - const exportDropdown = (scope) => ( - handleExport(scope, key), - }}> - - - ); - - const summary = data.summary || {}; - return ( - <> - - - setFilters((old) => ({ ...old, ider: value || '' }))} style={{ width: 150 }} /> - } placeholder="SPU" value={filters.spu} onChange={(e) => setFilters((old) => ({ ...old, spu: e.target.value }))} style={{ width: 150 }} /> - } placeholder="商品ID" value={filters.goodsId} onChange={(e) => setFilters((old) => ({ ...old, goodsId: e.target.value }))} style={{ width: 170 }} /> - - - - {error ? 重试} /> : null} - - - - - - - -
- - -
- - setSelected(null)}> - {selected ? <>达标 ¥{AWARD} : 未达标 }]} />
number(v, 0) }, { title: '是否动销', dataIndex: 'isActive' }, { title: '真实业绩', dataIndex: 'realPerformance', align: 'right', render: money }, { title: '真实费用', dataIndex: 'realFee', align: 'right', render: money }]} /> : null} - - - ); -}; - -const OpsBIncentiveBoard = () => ( - -
-
- 激励看板 - 按中心切换查看商品激励奖金、费比与动销达成情况 -
-
- }, - ]} - /> -
-); - -const AWARD = 400; -export default OpsBIncentiveBoard; diff --git a/frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css b/frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css deleted file mode 100644 index 68fa2c8..0000000 --- a/frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css +++ /dev/null @@ -1,160 +0,0 @@ -.ops-b-incentive-page { - container-name: ops-b-incentive; - container-type: inline-size; - display: flex; - min-height: calc(100% - 46px); - flex-direction: column; - gap: var(--sycm-page-section-gap); - background: var(--sycm-bg-page-soft); -} - -.ops-b-header { - display: flex; - min-height: 84px; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-lg); - margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0; - padding: 14px calc(var(--sycm-page-padding-x) + 2px); - border-bottom: 1px solid var(--sycm-border-base); - background: var(--sycm-bg-container); -} - -.ops-b-header__heading { min-width: 0; } -.ops-b-header h3 { - margin: 0; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-page); - font-weight: var(--sycm-font-weight-semibold); - line-height: var(--sycm-line-height-tight); -} -.ops-b-header__heading .ant-typography { - display: block; - margin-top: 6px; - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); -} - -.ops-b-tabs .ant-tabs-nav { - margin: 0; - padding: 0 calc(var(--sycm-page-padding-x) + 2px); - border-bottom: 1px solid var(--sycm-border-base); - background: var(--sycm-bg-container); -} -.ops-b-tabs .ant-tabs-tab { - margin: 0; - padding: 12px 4px; - font-size: var(--sycm-font-size-title); -} -.ops-b-tabs .ant-tabs-tab + .ant-tabs-tab { margin-left: 32px; } -.ops-b-tabs .ant-tabs-content-holder, -.ops-b-tabs .ant-tabs-content, -.ops-b-tabs .ant-tabs-tabpane { min-height: 100%; } -.ops-b-tabs .ant-tabs-tabpane { - display: flex; - flex-direction: column; - gap: var(--sycm-page-section-gap); - padding-top: var(--sycm-page-section-gap); -} - -.ops-b-filter-card, -.ops-b-panel, -.ops-b-summary-card { - border: 1px solid var(--sycm-border-card) !important; - border-radius: var(--sycm-radius-lg) !important; - box-shadow: var(--sycm-shadow-card) !important; -} -.ops-b-filter-card { margin-bottom: 0; } -.ops-b-filter-card .ant-card-body { padding: 14px 16px; } -.ops-b-filter-card .ant-space { width: 100%; } -.ops-b-filter-card .ant-space-item { max-width: 100%; } -.ops-b-filter-card .ant-select, -.ops-b-filter-card .ant-picker, -.ops-b-filter-card .ant-input-affix-wrapper { max-width: 100%; } - -.ops-b-summary-row { margin-top: -4px; } -.ops-b-summary-row .ant-col { display: flex; } -.ops-b-summary-card { - position: relative; - width: 100%; - min-height: 98px; - overflow: hidden; -} -.ops-b-summary-card::before { - position: absolute; - inset: 0 auto 0 0; - width: 3px; - background: var(--sycm-color-primary); - content: ''; -} -.ops-b-summary-card.is-green::before { background: var(--sycm-color-success); } -.ops-b-summary-card.is-orange::before { background: var(--sycm-color-orange); } -.ops-b-summary-card.is-gold::before { background: var(--sycm-color-warning); } -.ops-b-summary-card .ant-card-body { - display: flex; - min-height: 98px; - align-items: center; - padding: 16px 18px; -} -.ops-b-summary-card .ant-statistic { width: 100%; } -.ops-b-summary-card .ant-statistic-title { - margin-bottom: 8px; - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); -} -.ops-b-summary-card .ant-statistic-content { - color: var(--sycm-text-strong); - font-family: var(--sycm-font-family-number); - font-size: var(--sycm-font-size-metric); - line-height: 1.1; -} - -.ops-b-panel { margin-bottom: 0; overflow: hidden; } -.ops-b-panel .ant-card-head { - min-height: 48px; - padding: 0 16px; - border-bottom-color: var(--sycm-border-light); -} -.ops-b-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); } -.ops-b-panel .ant-card-body { padding: 0 16px 16px; } -.ops-b-panel .ant-table { font-size: var(--sycm-font-size-caption); } -.ops-b-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); } -.ops-b-panel .ant-table-placeholder .ant-table-cell { height: 180px; } -.ops-b-id-link { - height: auto; - padding: 0; - font-family: var(--sycm-font-family-number); - font-weight: var(--sycm-font-weight-semibold); -} -.ops-b-daily-table { margin-top: var(--sycm-space-md); } - -.ops-b-detail-drawer .ant-drawer-header { - padding: 16px 20px; - border-bottom-color: var(--sycm-border-light); -} -.ops-b-detail-drawer .ant-drawer-title { - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); -} -.ops-b-detail-drawer .ant-drawer-body { padding: 20px; } -.ops-b-detail-drawer .ant-descriptions { margin-bottom: var(--sycm-space-md); } - -@container ops-b-incentive (max-width: 900px) { - .ops-b-filter-card .ant-space-item { flex: 1 1 180px; } -} - -@media (max-width: 767px) { - .ops-b-header { - min-height: 72px; - align-items: flex-start; - flex-direction: column; - gap: var(--sycm-space-sm); - padding-block: 12px; - } - .ops-b-filter-card .ant-select, - .ops-b-filter-card .ant-picker, - .ops-b-filter-card .ant-input-affix-wrapper { width: 100%; } - .ops-b-panel .ant-card-body { padding-inline: 10px; } - .ops-b-detail-drawer .ant-drawer-body { padding: 14px; } -} diff --git a/frontend/src/pages/ProductAnalysis/index.jsx b/frontend/src/pages/ProductAnalysis/index.jsx deleted file mode 100644 index b785632..0000000 --- a/frontend/src/pages/ProductAnalysis/index.jsx +++ /dev/null @@ -1,990 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { - Button, - Checkbox, - DatePicker, - Drawer, - Empty, - Image, - Input, - InputNumber, - Popover, - Segmented, - Select, - Skeleton, - Space, - Table, - Tooltip, - Typography, - message, -} from 'antd'; -import { - ExclamationCircleOutlined, - ReloadOutlined, - RightOutlined, - SearchOutlined, - SettingOutlined, -} from '@ant-design/icons'; -import ReactECharts from 'echarts-for-react'; -import dayjs from 'dayjs'; -import { useSearchParams } from 'react-router-dom'; -import { - getProductAnalysisMeta, - getProductAnalysisProducts, - getProductAnalysisSignalThresholds, - getProductAnalysisTab, - saveProductAnalysisSignalThresholds, -} from '../../api/productAnalysis'; -import { - SycmBusinessPanel, - SycmDashboardMetric, - SycmModuleTabs, - SycmPage, - SycmStatusTag, - SycmToolbar, -} from '../../components/Common'; -import { - SYCM_CHART_PEER_AVG_COLOR, - SYCM_CHART_STORE_COLOR, - sycmEchartsBase, - sycmLineSeriesDefaults, -} from '../../design-system'; -import './style.css'; - -const { Text } = Typography; -const { RangePicker } = DatePicker; - -const DEFAULT_TAB = 'refunds'; -const TAB_ITEMS = [ - { key: 'refunds', label: '退款售后' }, - { key: 'promotion', label: '推广投放' }, - { key: 'activities', label: '活动信息' }, - { key: 'navigator', label: '商品领航员' }, - { key: 'service-issues', label: '体验问题' }, - { key: 'bad-reviews', label: '差评分析' }, -]; -const TAB_KEY_SET = new Set(TAB_ITEMS.map((item) => item.key)); -const RANGE_SHORTCUTS = [ - { key: 'realtime', label: '实时' }, - { key: 'yesterday', label: '昨日' }, - { key: '7d', label: '近7天' }, - { key: '30d', label: '近30天' }, - { key: 'week', label: '本周' }, - { key: 'month', label: '本月' }, -]; -const DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS = Object.freeze({ - ctr: { low: 3, high: 5 }, - conversion: { low: 0.3, high: 0.5 }, -}); -const TRAFFIC_SIGNAL_FILTER_OPTIONS = [ - { value: 'all', label: '全部' }, - { value: 'red', label: 红灯 }, - { value: 'yellow', label: 黄灯 }, - { value: 'green', label: 绿灯 }, -]; - -const MONEY_KEYS = new Set(['payOrdrAmt', 'customerUnitPrice', 'pieceUnitPrice', 'spend', 'minPrice', 'maxPrice']); -const PERCENT_KEYS = new Set([ - 'goodsVcr', - 'afterSaleRate', - 'feeRatio', - 'ctr', - 'clickConversionRate', - 'qltyRfndOrdrCntRto1m', - 'ptHelpPzRate30d', -]); - -const toNumber = (value) => { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; -}; - -const formatNumber = (value, digits = 0) => new Intl.NumberFormat('zh-CN', { - minimumFractionDigits: digits, - maximumFractionDigits: digits, -}).format(toNumber(value)); - -const formatCurrency = (value) => `¥${formatNumber(value, 2)}`; -const formatPercent = (value) => `${formatNumber(value, 2)}%`; - -const normalizeTrafficSignalThresholds = (value) => { - const normalizeMetric = (metric, defaults) => { - const low = Number(metric?.low); - const high = Number(metric?.high); - return { - low: Number.isFinite(low) ? low : defaults.low, - high: Number.isFinite(high) ? high : defaults.high, - }; - }; - return { - ctr: normalizeMetric(value?.ctr, DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS.ctr), - conversion: normalizeMetric(value?.conversion, DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS.conversion), - }; -}; - -const resolveTrafficSignal = (value, thresholds) => { - const numericValue = toNumber(value); - if (numericValue < thresholds.low) { - return { tone: 'red', label: '偏低', boundary: thresholds.low }; - } - if (numericValue < thresholds.high) { - return { tone: 'yellow', label: '关注', boundary: thresholds.high }; - } - return { tone: 'green', label: '良好', boundary: thresholds.high }; -}; - -const buildTrafficSignalReason = ({ metricKey, metricLabel, value, signal, row }) => { - if (signal.tone === 'red') { - if (metricKey === 'ctr' && toNumber(row.signalImpressions) <= 0) { - return '近30天暂无推广曝光,点击率按 0% 计'; - } - if (metricKey === 'conversion' && toNumber(row.signalClicks) <= 0) { - return '近30天暂无推广点击,转化率按 0% 计'; - } - return `近30天${metricLabel} ${formatPercent(value)},低于 ${formatPercent(signal.boundary)}`; - } - if (signal.tone === 'yellow') { - return `近30天${metricLabel} ${formatPercent(value)},尚未达到 ${formatPercent(signal.boundary)}`; - } - return `近30天${metricLabel} ${formatPercent(value)},已达到 ${formatPercent(signal.boundary)}`; -}; - -const ProductOperatingSignals = ({ row, thresholds }) => { - const metrics = row.signalWindowEndDate && !row.signalIsObserving - ? [ - { key: 'ctr', label: '点击率', value: row.signalCtr, thresholds: thresholds.ctr }, - { key: 'conversion', label: '转化率', value: row.signalClickConversionRate, thresholds: thresholds.conversion }, - ].map((metric) => { - const signal = resolveTrafficSignal(metric.value, metric.thresholds); - return { - ...metric, - signal, - reason: buildTrafficSignalReason({ - metricKey: metric.key, - metricLabel: metric.label, - value: metric.value, - signal, - row, - }), - }; - }) - : []; - - if (!metrics.length && !row.signalIsObserving) { - return --; - } - - const redReasons = metrics.filter((metric) => metric.signal.tone === 'red'); - - return ( -
- {metrics.length ? ( -
- {metrics.map((metric) => ( - - - - {metric.label} - {formatPercent(metric.value)} - - - ))} -
- ) : null} - {row.signalIsObserving ? ( -
- - - 新品观察期 - 点击率、转化率于第 8 天起展示 - -
- ) : null} - {redReasons.length ? ( - metric.reason).join(';')}> - - {redReasons.map((metric) => metric.reason).join(';')} - - ) : null} -
- ); -}; - -const ProductQualitySignalCell = ({ row }) => { - if (!row.signalQualityStatus) { - return --; - } - return ( -
-
- - - - 品质退款率 - {formatPercent(row.signalQualityRefundRate)} - - -
-
- ); -}; - -const TrafficSignalSettings = ({ value, onChange, onReset, onSave, saving }) => ( -
-
- 经营信号阈值 - 近30天 · 新品观察期7天 -
- {[ - { key: 'ctr', label: '点击率', step: 0.1 }, - { key: 'conversion', label: '转化率', step: 0.1 }, - ].map((metric) => ( -
- {metric.label} - - -
- ))} -
- 品质退款率 -
- 超越30% / 50%同行 - 同行后30% - 同行后10% -
- 仅按主营类目排名判定 -
-
- - -
-
-); - -const formatTableValue = (key, value) => { - if (value === null || value === undefined || value === '') return '--'; - if (MONEY_KEYS.has(key)) return formatCurrency(value); - if (PERCENT_KEYS.has(key)) return formatPercent(value); - if (key === 'statDate') return value; - if (typeof value === 'number' || /^-?\d+(\.\d+)?$/.test(String(value))) return formatNumber(value, 0); - return value; -}; - -const formatTabMetric = (metric) => { - if (metric.unit === '¥') return formatCurrency(metric.value); - if (metric.unit === '%') return formatPercent(metric.value); - return formatNumber(metric.value, 0); -}; - -const buildShortcutRange = (shortcut, latestDate) => { - const end = dayjs(latestDate); - if (!end.isValid()) return ['', '']; - if (shortcut === 'yesterday') { - const yesterday = end.subtract(1, 'day').format('YYYY-MM-DD'); - return [yesterday, yesterday]; - } - if (shortcut === '7d') return [end.subtract(6, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]; - if (shortcut === '30d') return [end.subtract(29, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]; - if (shortcut === 'week') { - const mondayOffset = end.day() === 0 ? 6 : end.day() - 1; - return [end.subtract(mondayOffset, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]; - } - if (shortcut === 'month') return [end.startOf('month').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]; - return [end.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]; -}; - -const ProductAnalysis = () => { - const [searchParams, setSearchParams] = useSearchParams(); - const initialTab = TAB_KEY_SET.has(searchParams.get('tab')) ? searchParams.get('tab') : DEFAULT_TAB; - const requestedProductId = searchParams.get('productId') || ''; - const [meta, setMeta] = useState({ latestDate: '', dates: [], shops: [] }); - const [filters, setFilters] = useState({ - startDate: '', - endDate: '', - shopName: '', - ider: '', - listingRange: null, - keyword: '', - onlyActive: false, - }); - const [rangeShortcut, setRangeShortcut] = useState('7d'); - const [products, setProducts] = useState([]); - const [metrics, setMetrics] = useState({}); - const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 }); - const [sorter, setSorter] = useState({ field: 'payOrdrAmt', order: 'descend' }); - const [productsLoading, setProductsLoading] = useState(true); - const [selectedProduct, setSelectedProduct] = useState(null); - const [activeTab, setActiveTab] = useState(initialTab); - const [tabData, setTabData] = useState(null); - const [tabLoading, setTabLoading] = useState(false); - const [serviceIssueDetail, setServiceIssueDetail] = useState(null); - const [signalThresholds, setSignalThresholds] = useState(() => normalizeTrafficSignalThresholds(null)); - const [signalThresholdDraft, setSignalThresholdDraft] = useState(() => normalizeTrafficSignalThresholds(null)); - const [signalStatus, setSignalStatus] = useState('all'); - const [signalSettingsOpen, setSignalSettingsOpen] = useState(false); - const [signalSettingsSaving, setSignalSettingsSaving] = useState(false); - const tabCacheRef = useRef(new Map()); - const productsRequestRef = useRef(0); - - const loadProducts = async (options = {}) => { - const requestId = productsRequestRef.current + 1; - productsRequestRef.current = requestId; - const nextFilters = options.filters || filters; - const nextPage = options.page || pagination.current; - const nextPageSize = options.pageSize || pagination.pageSize; - const nextSorter = options.sorter || sorter; - const nextSignalStatus = options.signalStatus || signalStatus; - - setProductsLoading(true); - try { - const response = await getProductAnalysisProducts({ - page: nextPage, - pageSize: nextPageSize, - startDate: nextFilters.startDate || undefined, - endDate: nextFilters.endDate || undefined, - shopName: nextFilters.shopName || undefined, - ider: nextFilters.ider || undefined, - listingFrom: nextFilters.listingRange?.[0] || undefined, - listingTo: nextFilters.listingRange?.[1] || undefined, - keyword: nextFilters.keyword || undefined, - onlyActive: nextFilters.onlyActive || undefined, - signalStatus: nextSignalStatus === 'all' ? undefined : nextSignalStatus, - sortField: nextSorter.field, - sortOrder: nextSorter.order, - rangeDays: options.rangeDays, - }); - if (requestId !== productsRequestRef.current) return; - - const data = response.data || {}; - const nextRows = data.rows || []; - setProducts(nextRows); - setMetrics(data.metrics || {}); - setPagination({ current: data.page || nextPage, pageSize: data.pageSize || nextPageSize, total: data.total || 0 }); - - setSelectedProduct((current) => { - const preferredId = current?.goodsId || requestedProductId; - return nextRows.find((row) => String(row.goodsId) === String(preferredId)) || nextRows[0] || null; - }); - } catch (error) { - if (requestId !== productsRequestRef.current) return; - setProducts([]); - setMetrics({}); - setPagination((current) => ({ ...current, total: 0 })); - setSelectedProduct(null); - message.error(error.response?.data?.error || error.message || '商品数据加载失败'); - } finally { - if (requestId === productsRequestRef.current) setProductsLoading(false); - } - }; - - useEffect(() => { - let active = true; - const bootstrap = async () => { - const productsPromise = loadProducts({ filters, page: 1, rangeDays: 7 }); - try { - const [response, thresholdResult] = await Promise.all([ - getProductAnalysisMeta(), - getProductAnalysisSignalThresholds() - .then((thresholdResponse) => ({ response: thresholdResponse, error: null })) - .catch((error) => ({ response: null, error })), - ]); - if (!active) return; - const nextMeta = response.data || {}; - const [startDate, endDate] = buildShortcutRange('7d', nextMeta.latestDate); - const nextFilters = { ...filters, startDate, endDate }; - setMeta(nextMeta); - setFilters(nextFilters); - if (thresholdResult.response) { - const nextThresholds = normalizeTrafficSignalThresholds( - thresholdResult.response.data?.thresholds - ); - setSignalThresholds(nextThresholds); - setSignalThresholdDraft(nextThresholds); - } else { - message.warning('个人经营信号阈值读取失败,当前使用系统默认值'); - } - await productsPromise; - } catch (error) { - if (!active) return; - setProductsLoading(false); - message.error(error.response?.data?.error || error.message || '商品分析初始化失败'); - } - }; - bootstrap(); - return () => { - active = false; - }; - // Initial bootstrap intentionally owns the first product query. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - let active = true; - setServiceIssueDetail(null); - if (!selectedProduct?.goodsId) { - setTabData(null); - return () => { - active = false; - }; - } - - const cacheKey = `${selectedProduct.goodsId}:${filters.startDate}:${filters.endDate}:${activeTab}`; - const cached = tabCacheRef.current.get(cacheKey); - if (cached) { - setTabData(cached); - return () => { - active = false; - }; - } - - setTabLoading(true); - setTabData(null); - getProductAnalysisTab(selectedProduct.goodsId, activeTab, { - startDate: filters.startDate, - endDate: filters.endDate, - }) - .then((response) => { - if (!active) return; - const data = response.data || null; - tabCacheRef.current.set(cacheKey, data); - setTabData(data); - }) - .catch((error) => { - if (!active) return; - setTabData({ status: 'error', message: error.response?.data?.error || error.message || '子模块加载失败' }); - }) - .finally(() => { - if (active) setTabLoading(false); - }); - - return () => { - active = false; - }; - }, [activeTab, filters.endDate, filters.startDate, selectedProduct]); - - useEffect(() => { - const nextParams = new URLSearchParams(searchParams); - if (selectedProduct?.goodsId) nextParams.set('productId', selectedProduct.goodsId); - else nextParams.delete('productId'); - nextParams.set('tab', activeTab); - setSearchParams(nextParams, { replace: true }); - // searchParams is deliberately excluded to avoid URL update loops. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTab, selectedProduct?.goodsId, setSearchParams]); - - const dashboardMetrics = useMemo(() => [ - { key: 'total', label: '商品数', value: formatNumber(metrics.total), footerLabel: '有销售', footerValue: formatNumber(metrics.activeGoods) }, - { key: 'payOrdrAmt', label: '销售额', value: formatCurrency(metrics.payOrdrAmt) }, - { key: 'orderNum', label: '订单量', value: formatNumber(metrics.orderNum) }, - { key: 'payOrdrUsrCnt', label: '买家数', value: formatNumber(metrics.payOrdrUsrCnt) }, - { key: 'goodsUv', label: '访客数', value: formatNumber(metrics.goodsUv) }, - { key: 'goodsVcr', label: '平均成交转化率', value: formatPercent(metrics.goodsVcr) }, - ], [metrics]); - - const productColumns = useMemo(() => [ - { - title: '商品', - key: 'product', - fixed: 'left', - width: 310, - render: (_, row) => ( -
- -
- {row.goodsName || '--'} - ID {row.goodsId} · {row.shopName || '--'} -
-
- ), - }, - { title: 'SPU', dataIndex: 'spu', key: 'spu', width: 120, ellipsis: true }, - { title: 'SKU货号', dataIndex: 'skuCode', key: 'skuCode', width: 130, ellipsis: true }, - { title: '负责人', dataIndex: 'idOwner', key: 'idOwner', width: 90 }, - { title: '上架时间', dataIndex: 'firstCreatedAt', key: 'firstCreatedAt', width: 105, render: (value) => value || '--' }, - { - title: '近30天推广信号', - key: 'trafficSignal', - width: 300, - render: (_, row) => , - }, - { - title: '近30天品质信号(商品领航员)', - key: 'qualitySignal', - width: 190, - render: (_, row) => , - }, - { title: '访客数', dataIndex: 'goodsUv', key: 'goodsUv', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) }, - { title: '销售额', dataIndex: 'payOrdrAmt', key: 'payOrdrAmt', width: 120, align: 'right', sorter: true, render: formatCurrency }, - { title: '订单量', dataIndex: 'orderNum', key: 'orderNum', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) }, - { title: '买家数', dataIndex: 'payOrdrUsrCnt', key: 'payOrdrUsrCnt', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) }, - { title: '成交件数', dataIndex: 'payOrdrGoodsQty', key: 'payOrdrGoodsQty', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) }, - { title: '成交转化率', dataIndex: 'goodsVcr', key: 'goodsVcr', width: 120, align: 'right', sorter: true, render: formatPercent }, - { title: '客单价', dataIndex: 'customerUnitPrice', key: 'customerUnitPrice', width: 110, align: 'right', sorter: true, render: formatCurrency }, - { - title: '价格状态', - dataIndex: 'isPriceBreak', - key: 'isPriceBreak', - width: 100, - align: 'center', - render: (value) => {Number(value) === 1 ? '破价' : '正常'}, - }, - ], [signalThresholds]); - - // 快照表缺日期时前后端都不补点会导致 x 轴日期跳跃,这里按日历补全缺失日期 - const tabTrend = useMemo(() => { - const trend = tabData?.trend || []; - if (trend.length < 2) return trend; - const byDate = new Map(trend.map((item) => [String(item.date).slice(0, 10), item])); - const dates = [...byDate.keys()].sort(); - const start = dayjs(dates[0]); - const end = dayjs(dates[dates.length - 1]); - if (!start.isValid() || !end.isValid() || end.diff(start, 'day') + 1 === trend.length) return trend; - const totalDays = end.diff(start, 'day') + 1; - if (totalDays > 400) return trend; - const filled = []; - for (let cursor = start; !cursor.isAfter(end, 'day'); cursor = cursor.add(1, 'day')) { - const key = cursor.format('YYYY-MM-DD'); - filled.push(byDate.get(key) || { date: key, primary: null, secondary: null }); - } - return filled; - }, [tabData]); - - const tabChartOption = useMemo(() => { - const trend = tabTrend; - return { - ...sycmEchartsBase, - grid: { ...sycmEchartsBase.grid, top: 48, right: 54, bottom: 28, left: 52 }, - legend: { ...sycmEchartsBase.legend, data: [tabData?.trendLabels?.primary, tabData?.trendLabels?.secondary].filter(Boolean) }, - xAxis: { - ...sycmEchartsBase.xAxis, - type: 'category', - boundaryGap: false, - data: trend.map((item) => item.date), - }, - yAxis: [ - { ...sycmEchartsBase.yAxis, type: 'value' }, - { ...sycmEchartsBase.yAxis, type: 'value', splitLine: { show: false } }, - ], - series: [ - { - ...sycmLineSeriesDefaults, - name: tabData?.trendLabels?.primary, - data: trend.map((item) => (item.primary === null ? null : toNumber(item.primary))), - connectNulls: true, - lineStyle: { width: 3, color: SYCM_CHART_STORE_COLOR }, - itemStyle: { color: SYCM_CHART_STORE_COLOR }, - }, - { - ...sycmLineSeriesDefaults, - name: tabData?.trendLabels?.secondary, - yAxisIndex: 1, - data: trend.map((item) => (item.secondary === null ? null : toNumber(item.secondary))), - connectNulls: true, - lineStyle: { width: 3, color: SYCM_CHART_PEER_AVG_COLOR }, - itemStyle: { color: SYCM_CHART_PEER_AVG_COLOR }, - }, - ], - }; - }, [tabTrend, tabData]); - - const tabColumns = useMemo(() => (tabData?.columns || []).map((column) => ({ - title: column.title, - dataIndex: column.key, - key: column.key, - align: column.align || (column.key === 'statDate' ? 'left' : 'right'), - render: (value) => (column.key === 'viewOrders' - ? 查看订单 - : formatTableValue(column.key, value)), - })), [tabData]); - - const serviceIssueOrderColumns = useMemo(() => [ - { - title: '订单号', - dataIndex: 'orderSn', - key: 'orderSn', - width: 210, - render: (value) => {value || '--'}, - }, - { - title: '问题详情', - dataIndex: 'problemDetail', - key: 'problemDetail', - width: 220, - render: (value) => {value || '--'}, - }, - { - title: '问题分类', - dataIndex: 'categoryDescription', - key: 'categoryDescription', - width: 200, - render: (value) => {value || '--'}, - }, - { title: '求助时间', dataIndex: 'seekHelpTime', key: 'seekHelpTime', width: 170 }, - { - title: '体验状态', - dataIndex: 'isNegative', - key: 'isNegative', - width: 110, - align: 'center', - render: (value) => ( - {value ? '负向体验' : '一般问题'} - ), - }, - ], []); - - const openServiceIssueOrders = (row) => { - if (tabData?.tabKey !== 'service-issues') return; - const orders = (tabData.orderDetails || []).filter((item) => item.issueType === row.issueType); - setServiceIssueDetail({ issueType: row.issueType, orders }); - }; - - const handleQuery = () => { - tabCacheRef.current.clear(); - loadProducts({ page: 1 }); - }; - - const handleSignalStatusChange = (nextStatus) => { - setSignalStatus(nextStatus); - loadProducts({ page: 1, signalStatus: nextStatus }); - }; - - const handleTableChange = (nextPagination, _tableFilters, tableSorter) => { - const nextSorter = tableSorter?.field - ? { field: tableSorter.field, order: tableSorter.order || 'descend' } - : sorter; - setSorter(nextSorter); - loadProducts({ page: nextPagination.current, pageSize: nextPagination.pageSize, sorter: nextSorter }); - }; - - const updateSignalThresholdDraft = (metricKey, boundaryKey, value) => { - setSignalThresholdDraft((current) => ({ - ...current, - [metricKey]: { - ...current[metricKey], - [boundaryKey]: Number.isFinite(Number(value)) ? Number(value) : 0, - }, - })); - }; - - const resetSignalThresholdDraft = () => { - setSignalThresholdDraft(normalizeTrafficSignalThresholds(null)); - }; - - const saveSignalThresholds = async () => { - const nextThresholds = normalizeTrafficSignalThresholds(signalThresholdDraft); - if (nextThresholds.ctr.low >= nextThresholds.ctr.high - || nextThresholds.conversion.low >= nextThresholds.conversion.high) { - message.warning('黄绿分界值必须大于红黄分界值'); - return; - } - setSignalSettingsSaving(true); - try { - const response = await saveProductAnalysisSignalThresholds(nextThresholds); - const savedThresholds = normalizeTrafficSignalThresholds(response.data?.thresholds || nextThresholds); - setSignalThresholds(savedThresholds); - setSignalThresholdDraft(savedThresholds); - setSignalSettingsOpen(false); - if (signalStatus !== 'all') { - await loadProducts({ page: 1, signalStatus }); - } - message.success('个人经营信号阈值已保存'); - } catch (error) { - message.error(error.response?.data?.error || error.message || '个人经营信号阈值保存失败'); - } finally { - setSignalSettingsSaving(false); - } - }; - - return ( - - - -
- ({ value: shop, label: shop }))} - onChange={(value) => setFilters((current) => ({ ...current, shopName: value || '' }))} - aria-label="店铺" - /> - } - value={filters.keyword} - placeholder="搜索商品ID / SKU / SPU / 商品名" - onChange={(event) => setFilters((current) => ({ ...current, keyword: event.target.value }))} - onPressEnter={handleQuery} - /> - setFilters((current) => ({ ...current, onlyActive: event.target.checked }))} - > - 仅看有销售/投放 - -
- - - - -
-
- - 数据区间 {metrics.startDate || '--'} 至 {metrics.endDate || '--'}} - className="product-analysis-overview-panel" - > -
- {dashboardMetrics.map(({ key, ...item }) => )} -
-
- - - - 共 {formatNumber(pagination.total)} 款,点击行查看单品模块 - { - setSignalSettingsOpen(open); - if (open) setSignalThresholdDraft(signalThresholds); - }} - content={( - - )} - > - - - - )} - className="product-analysis-table-panel" - > -
`共 ${formatNumber(total)} 款`, - }} - rowSelection={{ - type: 'radio', - selectedRowKeys: selectedProduct?.goodsId ? [selectedProduct.goodsId] : [], - onSelect: (record) => setSelectedProduct(record), - columnWidth: 42, - }} - rowClassName={(record) => String(record.goodsId) === String(selectedProduct?.goodsId) ? 'is-selected' : ''} - onRow={(record) => ({ - onClick: () => setSelectedProduct(record), - })} - onChange={handleTableChange} - locale={{ emptyText: }} - /> - - - - - - {selectedProduct.goodsName || '未命名商品'} - {selectedProduct.shopName} · 商品ID {selectedProduct.goodsId} · SKU {selectedProduct.skuCode || '--'} - - - ) : '单品业务模块'} - extra={selectedProduct ? {selectedProduct.idOwner || '未分配负责人'} : null} - className="product-analysis-detail-panel" - > - {selectedProduct ? ( - <> - -
- {tabLoading ? : null} - {!tabLoading && tabData?.status === 'empty' ? ( - - ) : null} - {!tabLoading && tabData?.status === 'error' ? ( - - ) : null} - {!tabLoading && tabData?.status === 'ready' ? ( - <> -
- {(tabData.metrics || []).map((item) => ( - - ))} -
-
- {tabData.trend?.length ? ( -
-

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

- -
- ) : null} -
-

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

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

推广数据

- 包含稳定成本推广和全店托管数据 -
-
- 统计时间:{filterDateLabel || '--'} - {summary?.previousRange ? ( - 上一周期:{summary.previousRange} - ) : null} - -
-
- -
- {summaryMetrics.map((item) => ( - - ))} -
-
-
- - - - -
- - setKeyword(e.target.value)} - onPressEnter={handleSearch} - suffix={} - /> - - - -
- -
- 已选 {selectedRowKeys.length} 项 - 共 {total} 条推广记录 -
- - -
`共 ${t} 条`, - }} - scroll={{ x: 3000 }} - locale={{ emptyText: loading ? '加载中...' : '暂无推广数据' }} - size="small" - /> - - - - setSettingsOpen(false)} - > -
指标设置功能开发中
-
- - setAddProductOpen(false)} - > - -
- 暂无可添加商品 -
-
- - ); -}; - -export default PromotionModule; diff --git a/frontend/src/pages/PromotionModule/style.css b/frontend/src/pages/PromotionModule/style.css deleted file mode 100644 index bfa4e74..0000000 --- a/frontend/src/pages/PromotionModule/style.css +++ /dev/null @@ -1,214 +0,0 @@ -.promotion-module-page { - display: flex; - flex-direction: column; - gap: var(--sycm-page-section-gap); -} - -.promotion-dashboard, -.promotion-workspace { - overflow: hidden; -} - -.promotion-dashboard { - padding: var(--sycm-module-padding); -} - -.promotion-dashboard__header, -.promotion-actionbar, -.promotion-filterbar, -.promotion-selectionbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--sycm-space-md); -} - -.promotion-dashboard__header > div:first-child { - display: flex; - min-width: 0; - align-items: baseline; - gap: var(--sycm-space-sm); -} - -.promotion-dashboard__header h2 { - margin: 0; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); -} - -.promotion-dashboard__header span, -.promotion-dashboard__tools, -.promotion-filterbar > span, -.promotion-selectionbar { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); -} - -.promotion-dashboard__tools { - display: flex; - flex: 0 0 auto; - align-items: center; - gap: var(--sycm-space-sm); -} - -.promotion-dashboard__metrics { - display: grid; - grid-template-columns: repeat(8, minmax(110px, 1fr)); - gap: var(--sycm-space-sm); - margin-top: var(--sycm-space-sm); -} - -.promotion-dashboard__metrics .sycm-dashboard-metric { - min-height: 96px; - padding: var(--sycm-space-sm); - border-radius: var(--sycm-radius-lg); - background: var(--sycm-bg-subtle); -} - -.promotion-dashboard__metrics .sycm-dashboard-metric__label { - font-size: var(--sycm-font-size-caption); -} - -.promotion-dashboard__metrics .sycm-dashboard-metric__value { - font-size: var(--sycm-font-size-metric); -} - -.promotion-type-tabs .ant-tabs-nav { - min-height: var(--sycm-module-header-min-height); - margin: 0 !important; - padding: 0 var(--sycm-module-padding); - background: var(--sycm-bg-subtle); -} - -.promotion-type-tabs .ant-tabs-content-holder { - display: none; -} - -.promotion-actionbar { - min-height: calc(var(--sycm-control-height) + var(--sycm-space-lg)); - padding: var(--sycm-space-sm) var(--sycm-module-padding); -} - -.promotion-actionbar > div { - display: flex; - gap: var(--sycm-space-sm); -} - -.promotion-filterbar { - justify-content: flex-start; - padding: var(--sycm-space-sm) var(--sycm-module-padding); - border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); - background: var(--sycm-bg-subtle); -} - -.promotion-filterbar .ant-space { - flex-wrap: nowrap; -} - -.promotion-filterbar__shop { - width: 160px; -} - -.promotion-filterbar__search { - width: 240px; -} - -.promotion-selectionbar { - min-height: var(--sycm-module-header-min-height); - justify-content: flex-start; - padding: 0 var(--sycm-module-padding); -} - -.promotion-table .ant-table-placeholder .ant-table-cell { - height: 320px; -} - -.promotion-product-cell { - display: flex; - min-width: 0; - align-items: flex-start; - gap: var(--sycm-space-sm); -} - -.promotion-product-cell > img, -.promotion-product-cell__image { - display: inline-flex; - width: 52px; - height: 52px; - flex: 0 0 52px; - align-items: center; - justify-content: center; - border: var(--sycm-line-width-base) solid var(--sycm-border-light); - border-radius: var(--sycm-radius-sm); - background: var(--sycm-bg-subtle); - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); - object-fit: cover; -} - -.promotion-product-cell > div { - min-width: 0; -} - -.promotion-product-cell strong, -.promotion-product-cell span { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.promotion-product-cell strong { - max-width: 180px; - color: var(--sycm-text-strong); - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); -} - -.promotion-product-cell span { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-caption); -} - -.promotion-budget-cell { - display: flex; - flex-direction: column; - gap: var(--sycm-space-xxs); - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); -} - -.promotion-metric-settings { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--sycm-space-sm); -} - -.promotion-metric-settings .ant-checkbox-wrapper { - margin-inline-start: 0; - font-size: var(--sycm-font-size-caption); -} - -.promotion-add-product-empty { - margin-top: 96px; -} - -@media (max-width: 1180px) { - .promotion-dashboard__metrics { - grid-template-columns: repeat(4, minmax(110px, 1fr)); - } - - .promotion-filterbar { - flex-wrap: wrap; - } - - .promotion-filterbar .ant-input-search { - width: 100%; - } - - .promotion-filterbar > span { - margin-left: 0; - } -} diff --git a/frontend/src/pages/Settings/DepartmentTagSettings.jsx b/frontend/src/pages/Settings/DepartmentTagSettings.jsx deleted file mode 100644 index 505525a..0000000 --- a/frontend/src/pages/Settings/DepartmentTagSettings.jsx +++ /dev/null @@ -1,340 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - Alert, Button, Card, Input, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message, -} from 'antd'; -import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, TagsOutlined, TeamOutlined } from '@ant-design/icons'; -import { - getDepartmentTagConfig, - saveDepartmentTagConfig, -} from '../../api/productMonitoring'; - -const { Text } = Typography; - -const METRIC_OPTIONS = [ - { value: 'sales_qty_7d', label: '近7天销售件数' }, - { value: 'sales_amount_7d', label: '近7天销售额' }, -]; -const OWNER_TYPE_OPTIONS = [ - { value: 'sku_owner', label: 'SKU负责人' }, - { value: 'purchaser', label: '采购负责人' }, -]; -const LEVEL_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' }; - -const readError = (error) => error?.response?.data?.error || error?.message || '操作失败'; - -const useDepartmentConfig = () => { - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - - const load = useCallback(async () => { - setLoading(true); - setError(''); - try { - const result = await getDepartmentTagConfig(); - setConfig(result?.data || null); - } catch (requestError) { - setError(readError(requestError)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { load(); }, [load]); - - return { config, setConfig, loading, setLoading, error, setError, load }; -}; - -// ==================== 部门商品标签配置(G-S / G-A / G-B / G-C) ==================== - -export const DepartmentTagSettings = () => { - const { config, setConfig, loading, error, load } = useDepartmentConfig(); - const [rules, setRules] = useState([]); - const [saving, setSaving] = useState(false); - - useEffect(() => { - if (config?.rules) setRules(config.rules.map((item) => ({ ...item }))); - }, [config]); - - const patch = (tagLevel, key, value) => { - setRules((old) => old.map((item) => (item.tagLevel === tagLevel ? { ...item, [key]: value } : item))); - }; - - const handleSave = async () => { - setSaving(true); - try { - const result = await saveDepartmentTagConfig({ rules }); - setConfig(result?.data || null); - message.success('部门商品标签已保存,商品监控列表下次加载即生效'); - } catch (requestError) { - message.error(readError(requestError)); - } finally { - setSaving(false); - } - }; - - const columns = [ - { - title: '部门标签', - dataIndex: 'tagLevel', - width: 120, - render: (value) => {value}, - }, - { - title: '判定指标', - dataIndex: 'metricKey', - width: 180, - render: (value, row) => ( - patch(row.tagLevel, 'thresholdMin', e.target.value === '' ? null : Number(e.target.value))} - /> - ), - }, - { - title: '区间终点(不含)', - dataIndex: 'thresholdMax', - width: 160, - render: (value, row) => ( - patch(row.tagLevel, 'thresholdMax', e.target.value === '' ? null : Number(e.target.value))} - /> - ), - }, - { - title: '优先级', - dataIndex: 'priority', - width: 110, - render: (value, row) => ( - patch(row.tagLevel, 'priority', Number(e.target.value) || 0)} - /> - ), - }, - { - title: '启用', - dataIndex: 'enabled', - width: 90, - render: (value, row) => ( - patch(row.tagLevel, 'enabled', next)} /> - ), - }, - ]; - - return ( -
- - 部门商品标签配置} - extra={( - - - - - )} - > - {error ? : null} - - 商品监控页「部门的标签」按此规则判定:同一 SKU 的近 7 天指标落在 [起点, 终点) 区间即命中, - 按优先级升序命中即停,未命中留空。留空表示不限(如 G-S 只填起点 500)。 - -
- - - - ); -}; - -// ==================== 部门 SKU 负责人配置 ==================== - -export const DepartmentOwnerSettings = () => { - const { config, setConfig, loading, error, load } = useDepartmentConfig(); - const [owners, setOwners] = useState([]); - const [saving, setSaving] = useState(false); - - useEffect(() => { - if (config?.owners) { - setOwners(config.owners.map((item, index) => ({ - key: `${item.departmentId}-${item.ownerType}-${item.spu || ''}-${item.userId}-${index}`, - departmentId: item.departmentId, - spu: item.spu || '', - ownerType: item.ownerType, - userId: item.userId, - }))); - } - }, [config]); - - const departmentOptions = useMemo( - () => (config?.departments || []).map((item) => ({ value: item.id, label: item.name })), - [config] - ); - const userOptions = useMemo( - () => (config?.users || []).map((item) => ({ value: item.id, label: item.name || item.username })), - [config] - ); - - const patch = (key, field, value) => { - setOwners((old) => old.map((item) => (item.key === key ? { ...item, [field]: value } : item))); - }; - const addRow = () => { - setOwners((old) => [...old, { - key: `new-${Date.now()}`, - departmentId: undefined, - spu: '', - ownerType: 'sku_owner', - userId: undefined, - }]); - }; - const removeRow = (key) => setOwners((old) => old.filter((item) => item.key !== key)); - - const handleSave = async () => { - const invalid = owners.find((item) => !item.departmentId || !item.userId); - if (invalid) { - message.warning('每一行都必须选择部门与负责人'); - return; - } - setSaving(true); - try { - const result = await saveDepartmentTagConfig({ owners }); - setConfig(result?.data || null); - message.success('部门 SKU 负责人已保存'); - } catch (requestError) { - message.error(readError(requestError)); - } finally { - setSaving(false); - } - }; - - const columns = [ - { - title: '部门', - dataIndex: 'departmentId', - width: 200, - render: (value, row) => ( - patch(row.key, 'spu', e.target.value)} - /> - ), - }, - { - title: '负责人类型', - dataIndex: 'ownerType', - width: 150, - render: (value, row) => ( - patch(row.key, 'userId', next)} - style={{ width: '100%' }} - /> - ), - }, - { - title: '操作', - width: 90, - render: (_, row) => ( - removeRow(row.key)}> - - - ), - }, - ]; - - return ( -
- - 部门 SKU 负责人配置} - extra={( - - - - - - )} - > - {error ? : null} - - 商品监控页「部门SKU负责人」按此表匹配:先找部门 + SPU 精确配置,找不到再用部门 + SPU 留空的默认负责人。 - 保存会整体覆盖当前配置。 - -
- - - - ); -}; - -export default DepartmentTagSettings; diff --git a/frontend/src/pages/Settings/ProductTagSettings.jsx b/frontend/src/pages/Settings/ProductTagSettings.jsx deleted file mode 100644 index cdb3b26..0000000 --- a/frontend/src/pages/Settings/ProductTagSettings.jsx +++ /dev/null @@ -1,372 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Alert, Button, Input, InputNumber, Select, Space, Spin, Table, Tag, Typography, message } from 'antd'; -import { ReloadOutlined, SaveOutlined, SearchOutlined } from '@ant-design/icons'; -import { getProductTagConfig, updateProductTagConfig } from '../../api/admin'; -import { SycmCard } from '../../components/Common'; -import './productTagSettings.css'; - -const SCALE_TAGS = ['爆款', '热卖款', '动销款', '低动销款']; -const SCALE_TAG_ORDER = [...SCALE_TAGS, '滞销款']; -const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级']; -const SABC_TAGS = ['S', 'A', 'B', 'C', 'D']; - -// 规模标签配色:爆款最优 → 滞销款最差 -const SCALE_TAG_COLOR = { - 爆款: 'red', - 热卖款: 'orange', - 动销款: 'blue', - 低动销款: 'default', - 滞销款: 'default', -}; - -const SABC_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' }; - -const numText = (value) => ( - value === null || value === undefined || value === '' - ? '--' - : Number(value).toLocaleString('zh-CN') -); - -const scaleRowKey = (row) => `${row.category}||${row.scaleTag}`; - -const ProductTagSettings = () => { - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [scaleThresholds, setScaleThresholds] = useState([]); - const [profitLevels, setProfitLevels] = useState([]); - const [sabcMappings, setSabcMappings] = useState([]); - const [excludedCategories, setExcludedCategories] = useState([]); - const [categoryFilter, setCategoryFilter] = useState(''); - const [scaleTagFilter, setScaleTagFilter] = useState(); - - const applyConfig = useCallback((config = {}) => { - setScaleThresholds(Array.isArray(config.scaleThresholds) ? config.scaleThresholds : []); - setProfitLevels(Array.isArray(config.profitLevels) ? config.profitLevels : []); - setSabcMappings(Array.isArray(config.sabcMappings) ? config.sabcMappings : []); - setExcludedCategories(Array.isArray(config.excludedCategories) ? config.excludedCategories : []); - }, []); - - const loadConfig = useCallback(async () => { - setLoading(true); - try { - const response = await getProductTagConfig(); - applyConfig(response?.data?.data || response?.data || {}); - } catch (error) { - message.error(error?.response?.data?.error || error?.message || '加载商品标签配置失败'); - } finally { - setLoading(false); - } - }, [applyConfig]); - - useEffect(() => { - loadConfig(); - }, [loadConfig]); - - const handleSave = async () => { - setSaving(true); - try { - const response = await updateProductTagConfig({ - scaleThresholds, - profitLevels, - sabcMappings, - excludedCategories, - }); - applyConfig(response?.data?.data || response?.data || {}); - message.success( - `已保存:规模阈值 ${scaleThresholds.length} 行 / 利润标签 ${profitLevels.length} 行 / ` - + `SABC 映射 ${sabcMappings.length} 条 / 排除品类 ${excludedCategories.length} 个` - ); - } catch (error) { - message.error(error?.response?.data?.error || error?.message || '保存商品标签配置失败'); - } finally { - setSaving(false); - } - }; - - // 规模阈值:原地更新某一行 - const patchScaleRow = useCallback((rowKey, field, value) => { - setScaleThresholds((rows) => rows.map((row) => ( - scaleRowKey(row) === rowKey ? { ...row, [field]: value } : row - ))); - }, []); - - // SABC 映射:矩阵单元格更新(不存在则新增) - const patchSabcCell = useCallback((scaleTag, profitLevel, sabcTag) => { - setSabcMappings((rows) => { - const index = rows.findIndex((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel); - if (index >= 0) { - const next = [...rows]; - next[index] = { ...next[index], sabcTag }; - return next; - } - return [...rows, { scaleTag, profitLevel, sabcTag, enabled: true }]; - }); - }, []); - - const sabcValueOf = useCallback((scaleTag, profitLevel) => { - const hit = sabcMappings.find((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel); - return hit?.sabcTag; - }, [sabcMappings]); - - // 候选品类:已配置的品类(含 jst_sku.category 全名)作为联想项, - // 未配置的(如「其他 - 配件」)直接手动输入完整值。 - const dbCategoryOptions = useMemo(() => { - const set = new Set(); - scaleThresholds.forEach((row) => { - if (row.category) set.add(row.category); - }); - excludedCategories.forEach((item) => set.add(item)); - return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN')); - }, [scaleThresholds, excludedCategories]); - - const filteredScaleRows = useMemo(() => { - const keyword = categoryFilter.trim(); - return scaleThresholds.filter((row) => ( - (!keyword || String(row.displayName || '').includes(keyword) || String(row.category || '').includes(keyword)) - && (!scaleTagFilter || row.scaleTag === scaleTagFilter) - )); - }, [scaleThresholds, categoryFilter, scaleTagFilter]); - - const scaleColumns = [ - { - title: '品类', - dataIndex: 'displayName', - width: 200, - fixed: 'left', - filters: SCALE_TAGS.map((tag) => ({ text: tag, value: tag })), - onFilter: (value, record) => record.scaleTag === value, - render: (value, record) => ( -
-
{value || record.category}
- {/* 展示 jst_sku.category 的完整值,便于核对品类名映射 */} - {record.category && record.category !== value ? ( -
{record.category}
- ) : null} -
- ), - }, - { - title: '规模标签', - dataIndex: 'scaleTag', - width: 110, - render: (value) => {value}, - }, - { - title: '业绩起点', - dataIndex: 'performanceMin', - width: 130, - align: 'right', - render: (value, record) => ( - patchScaleRow(scaleRowKey(record), 'performanceMin', next)} - /> - ), - }, - { - title: '日销起点', - dataIndex: 'salesMin', - width: 120, - align: 'right', - // 日销存在 0.5 这类合法小数(低动销款),不能用 precision=0 强制取整 - render: (value, record) => ( - patchScaleRow(scaleRowKey(record), 'salesMin', next)} - /> - ), - }, - { - title: '判定优先级', - dataIndex: 'priority', - width: 100, - align: 'right', - render: (value) => numText(value), - }, - ]; - - const profitColumns = [ - { title: '利润标签', dataIndex: 'levelCode', width: 120 }, - { - title: '营业利润率上限', - dataIndex: 'profitRateMax', - width: 160, - align: 'right', - render: (value, record) => ( - setProfitLevels((rows) => rows.map((row) => ( - row.levelCode === record.levelCode ? { ...row, profitRateMax: next } : row - )))} - /> - ), - }, - { - title: '说明(展示用,不参与计算)', - dataIndex: 'description', - render: (value, record) => ( - setProfitLevels((rows) => rows.map((row) => ( - row.levelCode === record.levelCode ? { ...row, description: event.target.value } : row - )))} - /> - ), - }, - ]; - - // SABC 映射矩阵:每行一个规模标签,每列一个利润标签 - const matrixColumns = [ - { - title: '规模标签 \ 利润标签', - dataIndex: 'scaleTag', - width: 150, - fixed: 'left', - render: (value) => {value}, - }, - ...PROFIT_LEVELS.map((level) => ({ - title: level, - dataIndex: level, - width: 110, - align: 'center', - render: (_, record) => ( - } - placeholder="搜索分类" - value={categoryFilter} - onChange={(event) => setCategoryFilter(event.target.value)} - style={{ width: 220 }} - /> -
`共 ${total} 行` }} - scroll={{ x: 760 }} - /> - - - - -
- - - 规模标签 × 利润标签 → 公司商品标签} - > -
- - 取值说明: - {SABC_TAGS.map((tag) => ( - {tag} - ))} - (S 最优,D 最差;D 档为本次评审确认保留) - - - - {`已排除 ${excludedCategories.length} 个`}} - > - - + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ { + setStatusFilter(value); + setPagination((prev) => ({ ...prev, current: 1 })); + }} + /> + setGroupByGoods(value === 'group')} + /> +
+ + +
( +
+ ), + rowExpandable: (record) => Boolean(record.childActivities?.length), + } : undefined} + pagination={{ + current: pagination.current, + pageSize: pagination.pageSize, + total, + showSizeChanger: true, + pageSizeOptions: ['20', '50', '100'], + showTotal: (t) => `共 ${t} 条`, + }} + scroll={{ x: 1600 }} + locale={{ emptyText: loading ? '加载中...' : '暂无活动记录' }} + size="small" + /> + + + + ); +}; + +export default MarketingEvents; diff --git a/frontend/src/pages/MarketingEvents/style.css b/frontend/src/pages/MarketingEvents/style.css new file mode 100644 index 0000000..510366d --- /dev/null +++ b/frontend/src/pages/MarketingEvents/style.css @@ -0,0 +1,224 @@ +.marketing-events-panel { + min-height: calc(100vh - 84px); + overflow: hidden; +} + +.marketing-events-panel__heading { + display: flex; + align-items: baseline; + gap: var(--sycm-space-sm); + padding: var(--sycm-module-padding) var(--sycm-module-padding) 0; +} + +.marketing-events-panel__heading h2 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} + +.marketing-events-panel__heading span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + line-height: var(--sycm-line-height-normal); +} + +.marketing-events-data-date { + margin-left: auto; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + white-space: nowrap; +} + +.marketing-events-summary { + display: grid; + grid-template-columns: repeat(4, minmax(110px, 1fr)); + gap: var(--sycm-space-sm); + margin-top: var(--sycm-space-sm); + padding: 0 var(--sycm-module-padding); +} + +.marketing-events-summary .sycm-dashboard-metric { + min-height: 88px; + padding: var(--sycm-space-sm); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-subtle); +} + +.marketing-events-summary .sycm-dashboard-metric__label { + font-size: var(--sycm-font-size-caption); +} + +.marketing-events-summary .sycm-dashboard-metric__value { + font-size: var(--sycm-font-size-metric); +} + +.marketing-events-summary__empty { + grid-column: 1 / -1; + padding: var(--sycm-space-md); + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); + text-align: center; +} + +.marketing-events-filter { + display: grid; + grid-template-columns: repeat(3, minmax(180px, 1fr)); + gap: var(--sycm-space-sm) var(--sycm-space-xl); + margin-top: var(--sycm-space-sm); + padding: var(--sycm-module-padding); + background: var(--sycm-bg-subtle); +} + +.marketing-events-filter .ant-form-item { + min-width: 0; + margin-bottom: 0; +} + +.marketing-events-filter .ant-form-item-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + align-items: center; +} + +.marketing-events-filter .ant-form-item-label { + padding: 0 var(--sycm-space-sm) 0 0; + overflow: visible; + text-align: right; +} + +.marketing-events-filter .ant-form-item-label > label { + height: var(--sycm-control-height); + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + line-height: var(--sycm-control-height); +} + +.marketing-events-filter .ant-form-item-control, +.marketing-events-filter .ant-form-item-control-input, +.marketing-events-filter .ant-form-item-control-input-content { + min-height: var(--sycm-control-height); +} + +.marketing-events-filter__actions { + display: flex; + min-height: var(--sycm-control-height); + align-items: center; + gap: var(--sycm-space-sm); + padding-left: 72px; +} + +.marketing-events-table-toolbar { + display: flex; + min-height: 56px; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-md); + padding: var(--sycm-space-sm) var(--sycm-module-padding); + border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); + overflow-x: auto; +} + +.marketing-events-status-switch { + flex: 0 0 auto; +} + +.marketing-events-child-table { + margin: 0; +} + +.marketing-events-child-table .ant-table { + background: var(--sycm-bg-page-soft, #fafafa); +} + +.marketing-events-table .ant-table-wrapper, +.marketing-events-table .ant-spin-nested-loading, +.marketing-events-table .ant-spin-container { + min-width: 0; +} + +.marketing-events-table .ant-table { + min-width: 0; +} + +.marketing-events-table .ant-table-cell { + vertical-align: middle; +} + +.marketing-events-table .ant-table-thead > tr > th { + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-semibold); + white-space: nowrap; +} + +.marketing-events-table .ant-table-tbody > tr > td { + color: var(--sycm-text-primary); + font-size: var(--sycm-font-size-caption); +} + +.marketing-events-table .ant-table-placeholder .ant-table-cell { + height: 220px; +} + +.marketing-events-thumb { + display: block; + width: 52px; + height: 52px; + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-radius: var(--sycm-radius-sm); + object-fit: cover; +} + +.marketing-events-thumb.is-empty { + display: inline-flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + background: var(--sycm-bg-subtle); + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); +} + +.marketing-events-goods-cell { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--sycm-space-xxs); + line-height: var(--sycm-line-height-normal); +} + +.marketing-events-goods-name { + overflow: hidden; + color: var(--sycm-text-strong); + font-weight: var(--sycm-font-weight-medium); + text-overflow: ellipsis; + white-space: nowrap; +} + +.marketing-events-goods-id { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); +} + +@media (max-width: 767px) { + .marketing-events-panel__heading { + align-items: flex-start; + flex-direction: column; + gap: var(--sycm-space-xxs); + } + + .marketing-events-filter { + grid-template-columns: 1fr; + gap: var(--sycm-space-sm); + } + + .marketing-events-filter__actions { + padding-left: 72px; + } +} diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx new file mode 100644 index 0000000..192d65a --- /dev/null +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -0,0 +1,440 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, Button, Card, Input, Space, Spin, Table, Tooltip, Typography, message, +} from 'antd'; +import { PrinterOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { getMkhBomList, getMkhBomDetail } from '../../api/mkhBom'; +import './style.css'; + +const { Title, Text } = Typography; + +const number = (value, digits = 0) => (value === null || value === undefined || value === '' + ? '0' + : Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + })); + +const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); + +const EMPTY_FILTERS = { keyword: '' }; + +// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 +const PrintPreview = ({ data, onClose }) => { + const now = useMemo(() => new Date().toLocaleString('zh-CN'), []); + const items = data.items || []; + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
+
+ + + + 按 Esc 关闭预览 + +
+ +
+
+
MKH-BOM 资料
+
模块化商品配件明细 · 打印模板
+
+ +
+
打印时间:{now}
+
配件种类:{items.length}
+
+ +
+ + + + + + + + + + {items.map((item, idx) => ( + + {idx === 0 ? ( + + ) : null} + + + + + ))} + {items.length === 0 ? ( + + + + + ) : null} + +
MKH商品编码配件明细数量配件仓位
{text(data.skuId)}{text(item.skuName)}{item.skuId ? {item.skuId} : null}{number(item.qty)}{item.location ? text(item.location) : 未维护}
{text(data.skuId)}该 MKH 商品的配件明细尚未录入「产品资料系统数据库」
+ +
+ + MKH 商品编码取自业务库;配件明细与数量取自「产品资料系统数据库」(BOM); + 配件仓位取库位主数据,未维护时显示「未维护」。 + +
+ + + ); +}; + +const MkhBomData = () => { + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [sorter, setSorter] = useState({ field: 'skuId', order: 'ASC' }); + + const [expandedKeys, setExpandedKeys] = useState({}); + const [detailCache, setDetailCache] = useState({}); + const [detailLoading, setDetailLoading] = useState({}); + + const [printData, setPrintData] = useState(null); + const [printLoading, setPrintLoading] = useState(false); + + const tableWrapRef = useRef(null); + const [bodyHeight, setBodyHeight] = useState(420); + + useEffect(() => { + const el = tableWrapRef.current; + if (!el) return undefined; + const RESERVE_BELOW = 104; + const measure = () => { + const scroller = el.closest('.planning-subsystem-content'); + const elRect = el.getBoundingClientRect(); + let available; + if (scroller) { + const scrollerRect = scroller.getBoundingClientRect(); + available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop; + } else { + available = window.innerHeight - elRect.top - RESERVE_BELOW; + } + const next = Math.max(240, Math.round(available)); + setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev)); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + if (el.parentElement) ro.observe(el.parentElement); + window.addEventListener('resize', measure); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); + + const query = useMemo(() => { + const params = { page, pageSize, sortField: sorter.field, sortOrder: sorter.order }; + Object.entries(filters).forEach(([key, value]) => { if (value) params[key] = value; }); + return params; + }, [page, pageSize, sorter, filters]); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const result = await getMkhBomList(query); + if (!result || result.success === false) { + setError(result?.error || '加载 MKH-BOM 资料失败'); + return; + } + setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 }); + } catch (requestError) { + setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败'); + } finally { + setLoading(false); + } + }, [query]); + + useEffect(() => { load(); }, [load]); + + const changeFilter = (key) => (value) => { + setFilters((old) => ({ ...old, [key]: value || '' })); + setPage(1); + }; + const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); }; + + const loadDetail = useCallback(async (skuId) => { + if (detailCache[skuId] || detailLoading[skuId]) return; + setDetailLoading((old) => ({ ...old, [skuId]: true })); + try { + const result = await getMkhBomDetail(skuId); + if (!result || result.success === false) { + message.error(result?.error || '加载配件明细失败'); + return; + } + setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } })); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '加载配件明细失败'); + } finally { + setDetailLoading((old) => ({ ...old, [skuId]: false })); + } + }, [detailCache, detailLoading]); + + const handlePrint = async (skuId) => { + setPrintLoading(true); + try { + const result = await getMkhBomDetail(skuId); + if (!result || result.success === false) { + message.error(result?.error || '加载打印数据失败'); + return; + } + setPrintData(result?.data || { skuId, items: [] }); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '加载打印数据失败'); + } finally { + setPrintLoading(false); + } + }; + + const columns = useMemo(() => [ + { + title: 'MKH商品编码', + dataIndex: 'skuId', + width: 240, + fixed: 'left', + sorter: true, + render: (value, row) => ( + + {text(value)} + {row.source === 'product_db' ? ( + + 资料库 + + ) : null} + + ), + }, + { + title: '商品名称', + dataIndex: 'productName', + ellipsis: true, + sorter: true, + render: (value) => {text(value)}, + }, + { + title: 'BOM 状态', + dataIndex: 'bomConfigured', + width: 100, + align: 'center', + render: (value, row) => (row.bomConfigured === '已配置' + ? 已配置 + : 未配置), + }, + { + title: '配件种类', + dataIndex: 'accessoryCount', + width: 110, + align: 'right', + sorter: true, + render: (value) => number(value), + }, + { + title: '配件总数量', + dataIndex: 'accessoryQtySum', + width: 120, + align: 'right', + sorter: true, + render: (value) => number(value), + }, + { + title: '操作', + key: 'action', + width: 190, + fixed: 'right', + render: (_value, row) => ( + + + + + ), + }, + ], [expandedKeys, printLoading, loadDetail]); + + const expandedRowRender = (row) => { + const detail = detailCache[row.skuId]; + if (detailLoading[row.skuId] && !detail) { + return
加载配件明细…
; + } + const items = detail?.items || []; + return ( +
+ {items.length === 0 ? ( +
+ + 该 MKH 商品在「产品资料系统数据库」中暂无配件 BOM 录入。 + 在产品资料系统里维护好该商品的配件明细后,此处会自动显示配件明细、数量与仓位。 + +
+ ) : null} + ( + + {text(v)} + {r.skuId ? {r.skuId} : null} + + ), + }, + { title: '数量', dataIndex: 'qty', width: 90, align: 'right', render: (v) => number(v) }, + { + title: '配件仓位', + dataIndex: 'location', + width: 140, + render: (v) => (v ? text(v) : ( + + 未维护 + + )), + }, + ]} + /> + + ); + }; + + return ( + +
+
+ MKH-BOM 资料 + + 模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位 + + + 数据口径:MKH 商品编码取自业务库 jst_sku(sku_id LIKE 'MKH%' 且 item_type = 成品,共 4436 条); + 配件明细与数量取自「产品资料系统数据库」(PostgreSQL / furniture3d_prod)中模块化商品维护的配件明细(配件条码 + 数量); + 资料库中已维护 BOM 但业务库无对应成品的编码会置顶并标注「资料库」;配件仓位取库位主数据,未维护时显示「未维护」。 + +
+
+ + + + } + placeholder="MKH商品编码 / 名称" + value={filters.keyword} + onChange={(e) => changeFilter('keyword')(e.target.value)} + style={{ width: 260 }} + /> + + + + + + {error ? 重试} /> : null} + + {!loading && (data.total || 0) > 0 ? ( + 0 ? 'info' : 'warning'} + showIcon + message={data.bomRowCount > 0 + ? `产品资料系统数据库已接入 ${data.bomRowCount} 个模块化商品的配件 BOM` + : '产品资料系统数据库中尚未录入模块化商品的配件 BOM'} + description={( + + MKH 商品编码({data.total - (data.bomExtraCount || 0)} 条)取自业务库 jst_sku; + 配件明细与数量取自「产品资料系统数据库」中模块化商品维护的 BOM。 + 资料库中已维护 BOM、但业务库无对应成品的编码({data.bomExtraCount || 0} 条)会置顶并标注「资料库」。 + 在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 + + )} + /> + ) : null} + + MKH 模块化成品({data.total || 0} 条)} + > + +
+
expandedKeys[k]), + expandedRowRender, + onExpand: (expanded, record) => { + setExpandedKeys((old) => ({ ...old, [record.skuId]: expanded })); + if (expanded) loadDetail(record.skuId); + }, + }} + onChange={(pagination, _filter, nextSorter) => { + setPage(pagination.current || 1); + setPageSize(pagination.pageSize || 50); + if (nextSorter?.field) { + setSorter({ + field: nextSorter.field, + order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC', + }); + } + }} + pagination={{ + current: data.page || page, + pageSize: data.pageSize || pageSize, + total: data.total || 0, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200], + showTotal: (total) => `共 ${total} 条`, + }} + scroll={{ x: 1000, y: bodyHeight }} + /> + + + + + {printData ? setPrintData(null)} /> : null} + + ); +}; + +export default MkhBomData; diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css new file mode 100644 index 0000000..e25d910 --- /dev/null +++ b/frontend/src/pages/MkhBomData/style.css @@ -0,0 +1,239 @@ +.mkh-page { + padding: 16px 20px 24px; +} + +.mkh-header { + margin-bottom: 14px; +} + +.mkh-header__heading .ant-typography { + margin-bottom: 4px; +} + +.mkh-note { + display: block; + max-width: 1180px; + margin-top: 6px; + font-size: 12px; + line-height: 1.7; +} + +.mkh-filter-card { + margin-bottom: 12px; +} + +.mkh-panel { + margin-top: 4px; +} + +.mkh-sku { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + color: var(--sycm-text-primary); +} + +.mkh-muted { + color: var(--sycm-text-tertiary); +} + +.mkh-detail-loading { + padding: 12px 8px; + color: var(--sycm-text-secondary); +} + +.mkh-detail { + padding: 4px 8px 8px; +} + +.mkh-detail-tip { + padding: 4px 0 8px; + font-size: 12px; +} + +.mkh-detail-code { + display: block; + margin-top: 2px; + font-family: 'SFMono-Regular', Consolas, Menlo, monospace; + font-size: 11px; + color: var(--sycm-text-tertiary); +} + +.mkh-tag { + display: inline-block; + padding: 0 6px; + border-radius: 3px; + font-size: 12px; + line-height: 18px; + white-space: nowrap; +} + +.mkh-tag--ok { + color: #0f6e56; + background: rgba(29, 158, 117, 0.14); +} + +.mkh-tag--muted { + color: var(--sycm-text-tertiary); + background: rgba(136, 135, 128, 0.14); +} + +.mkh-tag--src { + color: #0958d9; + background: rgba(22, 119, 255, 0.12); +} + +/* ============ A4 打印预览 ============ */ +.mkh-print-mask { + position: fixed; + inset: 0; + z-index: 2000; + overflow: auto; + background: rgba(0, 0, 0, 0.55); + padding: 24px 0 40px; +} + +.mkh-print-toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + justify-content: center; + padding: 12px; + margin-bottom: 16px; +} + +.mkh-print-sheet { + width: 210mm; + min-height: 297mm; + margin: 0 auto; + padding: 14mm 12mm; + background: #fff; + color: #000; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35); + box-sizing: border-box; + font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; + font-size: 12px; + line-height: 1.5; +} + +.mkh-print-head { + text-align: center; + border-bottom: 2px solid #000; + padding-bottom: 10px; + margin-bottom: 12px; +} + +.mkh-print-title { + font-size: 22px; + font-weight: 700; + letter-spacing: 2px; +} + +.mkh-print-sub { + margin-top: 4px; + font-size: 12px; + color: #444; +} + +.mkh-print-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 24px; + margin-bottom: 12px; + font-size: 13px; +} + +.mkh-print-meta > div { + display: flex; +} + +.mkh-print-meta span { + color: #555; + min-width: 78px; +} + +.mkh-print-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.mkh-print-table th, +.mkh-print-table td { + border: 1px solid #000; + padding: 6px 8px; + text-align: left; + word-break: break-all; +} + +.mkh-print-table thead th { + background: #f2f2f2; + font-weight: 700; +} + +.mkh-print-table .mkh-center { + text-align: center; +} + +.mkh-print-table .mkh-muted { + color: #999; +} + +.mkh-print-table .mkh-print-sku { + font-family: 'SFMono-Regular', Consolas, Menlo, monospace; + font-size: 11px; + vertical-align: middle; +} + +.mkh-print-code { + display: block; + margin-top: 2px; + font-family: 'SFMono-Regular', Consolas, Menlo, monospace; + font-size: 10px; + color: #666; +} + +.mkh-print-foot { + margin-top: 16px; + padding-top: 8px; + border-top: 1px dashed #999; + font-size: 11px; + color: #666; +} + +/* 打印时只输出 A4 纸张,隐藏其余界面 */ +@media print { + body * { + visibility: hidden !important; + } + + .mkh-print-sheet, + .mkh-print-sheet * { + visibility: visible !important; + } + + .mkh-print-toolbar { + display: none !important; + } + + .mkh-print-mask { + position: absolute !important; + inset: 0 !important; + background: #fff !important; + overflow: visible !important; + padding: 0 !important; + } + + .mkh-print-sheet { + width: 210mm; + min-height: auto; + margin: 0 !important; + padding: 0 !important; + box-shadow: none !important; + } + + @page { + size: A4; + margin: 12mm; + } +} diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx new file mode 100644 index 0000000..f9d6cc6 --- /dev/null +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -0,0 +1,428 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, Button, Card, Col, Dropdown, Image, Input, Row, Select, + Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message, +} from 'antd'; +import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { getModularStockList, getModularStockFilters } from '../../api/modularStockMonitor'; +import { exportTableFile } from '../../utils/tableExport'; +import './style.css'; + +const { Title, Text } = Typography; + +const WARNING_COLOR = { 缺货: 'red', 预警: 'orange', 正常: 'green' }; + +// 表头带口径说明,避免把「真实值 0」误读成「没数据」 +const TipTitle = ({ title, tip }) => ( + + {title} + +); + +const number = (value, digits = 2) => value === null || value === undefined || value === '' + ? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); +const int = (value) => number(value, 0); +const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); + +const toOptions = (list = []) => (Array.isArray(list) ? list : []) + .filter(Boolean) + .map((value) => ({ value, label: value })); + +const EMPTY_FILTERS = { keyword: '', category: '', warningLevel: '', mkhStatus: '' }; + +const AccessoryStockPanel = () => { + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [onlyWarning, setOnlyWarning] = useState(false); + const [filterOptions, setFilterOptions] = useState({ categories: [], warningLevels: [] }); + const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: null }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [sorter, setSorter] = useState({ field: 'realtimeStock', order: 'DESC' }); + const tableWrapRef = useRef(null); + const [bodyHeight, setBodyHeight] = useState(420); + + // 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc: + // 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行 + useEffect(() => { + const el = tableWrapRef.current; + if (!el) return undefined; + + // 分页器 + 卡片内边距 + 横向滚动条余量 + const RESERVE_BELOW = 104; + + const measure = () => { + const scroller = el.closest('.planning-subsystem-content'); + const elRect = el.getBoundingClientRect(); + let available; + if (scroller) { + const scrollerRect = scroller.getBoundingClientRect(); + // 加回 scrollTop,抵消滚动对 getBoundingClientRect 的影响 + available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop; + } else { + available = window.innerHeight - elRect.top - RESERVE_BELOW; + } + const next = Math.max(240, Math.round(available)); + setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev)); + }; + + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + if (el.parentElement) ro.observe(el.parentElement); + window.addEventListener('resize', measure); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); + + useEffect(() => { + let cancelled = false; + getModularStockFilters() + .then((result) => { + const payload = result?.data || {}; + if (cancelled) return; + setFilterOptions({ + categories: payload.categories || [], + warningLevels: payload.warningLevels || ['缺货', '预警', '正常'], + mkhStatuses: payload.mkhStatuses || ['已配置MKH', '未配置MKH'], + }); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const query = useMemo(() => { + const params = { + page, + pageSize, + sortField: sorter.field, + sortOrder: sorter.order, + }; + Object.entries(filters).forEach(([key, value]) => { + if (value) params[key] = value; + }); + if (onlyWarning) params.onlyWarning = '1'; + return params; + }, [page, pageSize, sorter, filters, onlyWarning]); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const result = await getModularStockList(query); + setData(result?.data || { rows: [], total: 0, summary: {} }); + } catch (requestError) { + setError(requestError?.response?.data?.error || requestError?.message || '加载配件库存监控数据失败'); + } finally { + setLoading(false); + } + }, [query]); + + useEffect(() => { load(); }, [load]); + + const changeFilter = (key) => (value) => { + setFilters((old) => ({ ...old, [key]: value || '' })); + setPage(1); + }; + const resetFilters = () => { + setFilters(EMPTY_FILTERS); + setOnlyWarning(false); + setPage(1); + }; + + const columns = useMemo(() => [ + { + title: '图片', + dataIndex: 'imageUrl', + width: 56, + fixed: 'left', + exportable: false, + render: (value) => (value + ? + : --), + }, + { + title: '预警', + dataIndex: 'warningLevel', + width: 76, + sorter: true, + render: (value) => (value + ? {value} + : --), + }, + { + title: , + dataIndex: 'mkhStatus', + width: 110, + sorter: true, + render: (value) => { + const v = value || '未配置MKH'; + return v === '已配置MKH' + ? {v} + : {v}; + }, + }, + { + title: 'SKU编码', + dataIndex: 'skuId', + width: 176, + fixed: 'left', + sorter: true, + render: (value) => {text(value)}, + }, + { + // 必须显式给 width:antd 在 scroll.x 场景下会把无宽度列压成 0 宽(此前该列不可见的原因) + title: '商品名称', + dataIndex: 'productName', + width: 220, + ellipsis: true, + sorter: true, + render: (value) => {text(value)}, + }, + { + title: , + dataIndex: 'inQtyToday', + width: 116, + align: 'right', + render: (value) => (value === null || value === undefined + ? -- + : int(value)), + }, + { + title: , + dataIndex: 'outQtyToday', + width: 116, + align: 'right', + render: (value) => (value === null || value === undefined + ? -- + : int(value)), + }, + { + title: '实时库存', + dataIndex: 'realtimeStock', + width: 96, + align: 'right', + sorter: true, + render: int, + }, + { + title: , + dataIndex: 'location', + width: 88, + sorter: true, + render: (value) => (value + ? {value} + : + 未维护 + ), + }, + { + title: '近7日日均发货量', + dataIndex: 'avgShip7d', + width: 138, + align: 'right', + sorter: true, + render: (value) => number(value), + }, + { + title: '库存可用天数', + dataIndex: 'stockDays', + width: 116, + align: 'right', + sorter: true, + render: (value) => number(value, 1), + }, + { + title: '所在仓库', + dataIndex: 'warehouse', + width: 200, + ellipsis: true, + sorter: true, + render: (value) => {text(value)}, + }, + ], []); + + const exportColumns = useMemo( + () => columns.filter((column) => column.exportable !== false), + [columns] + ); + + const handleExport = async (format) => { + if (!data.rows.length) { + message.warning('暂无数据可导出'); + return; + } + try { + await exportTableFile( + format, + `配件库存监控_${data.monitorDate || '最新'}`, + exportColumns, + data.rows, + '配件库存监控' + ); + message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`); + } catch (exportError) { + message.error(exportError?.message || '导出失败'); + } + }; + + const summary = data.summary || {}; + + return ( + <> + + + } + placeholder="SKU 编码 / 名称" + value={filters.keyword} + onChange={(e) => changeFilter('keyword')(e.target.value)} + style={{ width: 220 }} + /> + +
{ + setPage(pagination.current || 1); + setPageSize(pagination.pageSize || 50); + if (nextSorter?.field) { + setSorter({ + field: nextSorter.field, + order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC', + }); + } + }} + pagination={{ + current: data.page || page, + pageSize: data.pageSize || pageSize, + total: data.total || 0, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200], + showTotal: (total) => `共 ${total} 条`, + }} + scroll={{ x: 1508, y: bodyHeight }} + /> + + + + + ); +}; + +const ModularStockMonitor = () => ( + +
+
+ 模块化库存监控 + + 配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 7 日日均发货量与可用天数 + + + 数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 7 日日均发货量、 + 库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖); + 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」; + 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 + 状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。 + +
+
+ , + }]} + /> +
+); + +export default ModularStockMonitor; diff --git a/frontend/src/pages/ModularStockMonitor/style.css b/frontend/src/pages/ModularStockMonitor/style.css new file mode 100644 index 0000000..80ea127 --- /dev/null +++ b/frontend/src/pages/ModularStockMonitor/style.css @@ -0,0 +1,156 @@ +.msm-page { + container-name: modular-stock-monitor; + container-type: inline-size; + display: flex; + min-height: calc(100% - 46px); + flex-direction: column; + gap: var(--sycm-page-section-gap); + background: var(--sycm-bg-page-soft); +} + +.msm-header { + display: flex; + min-height: 84px; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-lg); + margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0; + padding: 14px calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} +.msm-header__heading { min-width: 0; } +.msm-header h3 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-page); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} +.msm-header__heading .ant-typography { + display: block; + margin-top: 6px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.msm-tabs .ant-tabs-nav { + margin: 0; + padding: 0 calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} +.msm-tabs .ant-tabs-tab { + margin: 0; + padding: 12px 4px; + font-size: var(--sycm-font-size-title); +} +.msm-tabs .ant-tabs-content-holder, +.msm-tabs .ant-tabs-content, +.msm-tabs .ant-tabs-tabpane { min-height: 100%; } +.msm-tabs .ant-tabs-tabpane { + display: flex; + flex-direction: column; + gap: var(--sycm-page-section-gap); + padding-top: var(--sycm-page-section-gap); +} + +.msm-filter-card, +.msm-panel, +.msm-summary-card { + border: 1px solid var(--sycm-border-card) !important; + border-radius: var(--sycm-radius-lg) !important; + box-shadow: var(--sycm-shadow-card) !important; +} +.msm-filter-card { margin-bottom: 0; } +.msm-filter-card .ant-card-body { padding: 14px 16px; } +.msm-filter-card .ant-space { width: 100%; } +.msm-filter-card .ant-space-item { max-width: 100%; } +.msm-filter-card .ant-select, +.msm-filter-card .ant-input-affix-wrapper { max-width: 100%; } + +.msm-summary-row { margin-top: -4px; } +.msm-summary-row .ant-col { display: flex; } +.msm-summary-card { + position: relative; + width: 100%; + min-height: 98px; + overflow: hidden; +} +.msm-summary-card::before { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--sycm-color-primary); + content: ''; +} +.msm-summary-card.is-red::before { background: var(--sycm-color-danger); } +.msm-summary-card.is-orange::before { background: var(--sycm-color-orange); } +.msm-summary-card.is-green::before { background: var(--sycm-color-success); } +.msm-summary-card .ant-card-body { + display: flex; + min-height: 98px; + align-items: center; + padding: 16px 18px; +} +.msm-summary-card .ant-statistic { width: 100%; } +.msm-summary-card .ant-statistic-title { + margin-bottom: 8px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} +.msm-summary-card .ant-statistic-content { + color: var(--sycm-text-strong); + font-family: var(--sycm-font-family-number); + font-size: var(--sycm-font-size-metric); + line-height: 1.1; +} +.msm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); } + +.msm-panel { margin-bottom: 0; overflow: hidden; } +.msm-panel .ant-card-head { + min-height: 48px; + padding: 0 16px; + border-bottom-color: var(--sycm-border-light); +} +.msm-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); } +.msm-panel .ant-card-body { padding: 0 16px 16px; } +.msm-panel .ant-table { font-size: var(--sycm-font-size-caption); } +.msm-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); } +.msm-panel .ant-table-placeholder .ant-table-cell { height: 180px; } +.msm-panel__date { font-size: var(--sycm-font-size-caption); } + +.msm-muted { color: var(--sycm-text-tertiary); } +/* 表头口径提示:虚线下划线 + help 光标 */ +.msm-th-tip { + border-bottom: 1px dashed var(--sycm-border-strong, var(--sycm-text-tertiary)); + cursor: help; +} +.msm-note { + display: block; + max-width: 1180px; + margin-top: 4px; + font-size: 12px; + line-height: 1.6; +} +.msm-sku { + font-family: var(--sycm-font-family-number); + font-weight: var(--sycm-font-weight-semibold); +} + +@container modular-stock-monitor (max-width: 900px) { + .msm-filter-card .ant-space-item { flex: 1 1 180px; } +} + +@media (max-width: 767px) { + .msm-header { + min-height: 72px; + align-items: flex-start; + flex-direction: column; + gap: var(--sycm-space-sm); + padding-block: 12px; + } + .msm-filter-card .ant-select, + .msm-filter-card .ant-input-affix-wrapper { width: 100%; } + .msm-panel .ant-card-body { padding-inline: 10px; } +} diff --git a/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx b/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx new file mode 100644 index 0000000..78f580b --- /dev/null +++ b/frontend/src/pages/PlanningUpload/OpsBIncentiveBoard.jsx @@ -0,0 +1,233 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, Button, Card, Col, DatePicker, Descriptions, Drawer, Dropdown, Input, Row, Select, + Space, Spin, Statistic, Table, Tabs, Tag, Typography, message, +} from 'antd'; +import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { SycmPage } from '../../components/Common'; +import { getOpsBIncentiveBatches, getOpsBIncentiveBoard, getOpsBIncentiveDaily } from '../../api/planningUpload'; +import { exportTableFile } from '../../utils/tableExport'; +import './opsBIncentiveBoard.css'; + +const { Title, Text } = Typography; +const FALLBACK_BATCH = { key: 'ops-b-20260715-20260721', batchNo: 1, scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 }; +const batchLabel = (item) => { + const mmdd = (value) => dayjs(value).format('MM-DD'); + const cap = `${parseFloat((Number(item.feeCap) * 100).toFixed(1))}%`; + return `第${item.batchNo}批(${mmdd(item.scopeStart)} 至 ${mmdd(item.scopeEnd)} · 拉取 ${mmdd(item.pullDate)} · 费比≤${cap})${item.finalized ? ' · 已定档' : ''}`; +}; + +const number = (value, digits = 2) => value === null || value === undefined || value === '' + ? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); +const money = (value) => value === null || value === undefined ? '--' : `¥${number(value)}`; +const percent = (value) => value === null || value === undefined ? '--' : `${(Number(value) * 100).toFixed(2)}%`; +const dateText = (value) => value ? dayjs(value).format('YYYY-MM-DD') : '--'; + +const OpsBIncentiveBoardPanel = () => { + const [batches, setBatches] = useState([]); + const [batch, setBatch] = useState(FALLBACK_BATCH.key); + const [metricEnd, setMetricEnd] = useState(dayjs(FALLBACK_BATCH.pullDate)); + const [filters, setFilters] = useState({ ider: '', shopName: '', spu: '', goodsId: '' }); + const [data, setData] = useState({ rows: [], ownerSummary: [], summary: {} }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [selected, setSelected] = useState(null); + const [dailyRows, setDailyRows] = useState([]); + const [dailyLoading, setDailyLoading] = useState(false); + + const selectedBatch = useMemo( + () => batches.find((item) => item.key === batch) || FALLBACK_BATCH, + [batches, batch] + ); + + useEffect(() => { + let cancelled = false; + getOpsBIncentiveBatches() + .then((result) => { + const list = result?.data?.data || result?.data || []; + if (cancelled || !Array.isArray(list) || !list.length) return; + setBatches(list); + if (!list.some((item) => item.key === FALLBACK_BATCH.key)) { + setBatch(list[0].key); + setMetricEnd(dayjs(list[0].pullDate)); + } + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const query = useMemo(() => ({ + scopeStart: selectedBatch.scopeStart, + scopeEnd: selectedBatch.scopeEnd, + metricEnd: metricEnd?.format('YYYY-MM-DD') || selectedBatch.pullDate, + ...filters, + }), [selectedBatch, metricEnd, filters]); + + const changeBatch = (value) => { + setBatch(value); + const next = batches.find((item) => item.key === value); + if (next) setMetricEnd(dayjs(next.pullDate)); + }; + + const load = useCallback(async () => { + setLoading(true); setError(''); + try { + const result = await getOpsBIncentiveBoard(query); + setData(result?.data || result || { rows: [], ownerSummary: [], summary: {} }); + } catch (requestError) { + setError(requestError?.message || '加载运营中心B看板失败'); + } finally { setLoading(false); } + }, [query]); + + useEffect(() => { load(); }, [load]); + + const openDetail = async (record) => { + setSelected(record); setDailyRows([]); setDailyLoading(true); + try { + const result = await getOpsBIncentiveDaily({ ...query, goodsId: record.goodsId }); + setDailyRows(result?.data?.rows || result?.rows || []); + } catch (requestError) { + message.error(requestError?.message || '加载每日明细失败'); + } finally { setDailyLoading(false); } + }; + + const iderOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.ider).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]); + const shopOptions = useMemo(() => [...new Set((data.rows || []).map((row) => row.shopName).filter(Boolean))].map((value) => ({ value, label: value })), [data.rows]); + // 分组:0=动销率与费比均达标,1=仅一项达标,2=均不达标;组内按动销率降序。 + const sortedRows = useMemo(() => [...(data.rows || [])].sort((a, b) => { + const group = (row) => (row.bonusQualified ? 0 : (row.feeQualified || row.sellThroughQualified ? 1 : 2)); + const rate = (row) => (row.sellThroughRate30d === null || row.sellThroughRate30d === undefined ? -1 : row.sellThroughRate30d); + return group(a) - group(b) || rate(b) - rate(a) || String(a.goodsId).localeCompare(String(b.goodsId)); + }), [data.rows]); + const num = (value) => (value === null || value === undefined || value === '' ? -Infinity : Number(value)); + const qualifyFilters = [ + { text: '达标', value: 'yes' }, + { text: '未达标', value: 'no' }, + ]; + const qualifyFilter = (field) => ({ + filters: qualifyFilters, + onFilter: (value, row) => (value === 'yes' ? Boolean(row[field]) : !row[field]), + }); + const columns = [ + { title: 'ID负责人', dataIndex: 'ider', fixed: 'left', width: 100 }, + { title: '商品ID', dataIndex: 'goodsId', fixed: 'left', width: 130, render: (value, row) => }, + { title: '商品名称', dataIndex: 'goodsName', width: 220, ellipsis: true }, + { title: '店铺名称', dataIndex: 'shopName', width: 150, ellipsis: true }, + { title: 'SPU', dataIndex: 'spu', width: 120 }, + { title: '链接类型', dataIndex: 'linkType', width: 100 }, + { title: '状态', dataIndex: 'status', width: 80, render: (v) => {v} }, + { title: 'SKU数', dataIndex: 'skuCount', width: 80, align: 'right', sorter: (a, b) => num(a.skuCount) - num(b.skuCount) }, + { title: '上架时间', dataIndex: 'firstCreatedAt', width: 120, render: dateText, exportValue: dateText }, + { title: '上架天数', dataIndex: 'listingDays', width: 90, align: 'right', sorter: (a, b) => num(a.listingDays) - num(b.listingDays), render: (v) => number(v, 0) }, + { title: '近30天ID动销率', dataIndex: 'sellThroughRate30d', width: 130, align: 'right', sorter: (a, b) => num(a.sellThroughRate30d) - num(b.sellThroughRate30d), render: percent, exportValue: percent }, + { title: '动销率是否达标', dataIndex: 'sellThroughQualified', width: 120, ...qualifyFilter('sellThroughQualified'), render: (v) => {v ? '达标' : '未达标'}, exportValue: (v) => (v ? '达标' : '未达标') }, + { title: '近30天费比', dataIndex: 'feeRatio30d', width: 110, align: 'right', sorter: (a, b) => num(a.feeRatio30d) - num(b.feeRatio30d), render: percent, exportValue: percent }, + { title: '费比是否达标', dataIndex: 'feeQualified', width: 105, ...qualifyFilter('feeQualified'), render: (v) => {v ? '达标' : '未达标'}, exportValue: (v) => (v ? '达标' : '未达标') }, + { title: '近30天真实业绩', dataIndex: 'performance30d', width: 135, align: 'right', sorter: (a, b) => num(a.performance30d) - num(b.performance30d), render: money }, + { title: '近30天真实费用', dataIndex: 'fee30d', width: 135, align: 'right', sorter: (a, b) => num(a.fee30d) - num(b.fee30d), render: money }, + { title: '近30天发货件数', dataIndex: 'goodsCount30d', width: 125, align: 'right', sorter: (a, b) => num(a.goodsCount30d) - num(b.goodsCount30d), render: (v) => number(v, 0) }, + { title: '近30天发货天数', dataIndex: 'shippedDays30d', width: 125, align: 'right', sorter: (a, b) => num(a.shippedDays30d) - num(b.shippedDays30d), render: (v) => number(v, 0) }, + { title: '应发奖金', dataIndex: 'bonus', width: 100, align: 'right', sorter: (a, b) => num(a.bonus) - num(b.bonus), render: money }, + ]; + + const ownerSummaryColumns = [ + { title: 'ID负责人', dataIndex: 'ider' }, + { title: '商品ID数', dataIndex: 'goodsIdCount' }, + { title: '近30天发货件数', dataIndex: 'goodsCount30d', render: (v) => number(v, 0) }, + { title: '费比达标', dataIndex: 'feeQualifiedCount' }, + { title: '奖金达标', dataIndex: 'bonusQualifiedCount' }, + { title: '应发奖金', dataIndex: 'bonus', render: money }, + ]; + + const exportFileName = (label) => ( + `激励看板_${label}_${selectedBatch.scopeStart}_${selectedBatch.scopeEnd}_统计至${metricEnd?.format('YYYYMMDD') || '最新'}` + ); + + const handleExport = async (scope, format) => { + const isSummary = scope === 'summary'; + const exportColumns = isSummary ? ownerSummaryColumns : columns; + const exportRows = isSummary ? (data.ownerSummary || []) : sortedRows; + if (!exportRows.length) { + message.warning(isSummary ? '负责人汇总暂无数据可导出' : 'ID核心明细暂无数据可导出'); + return; + } + try { + await exportTableFile(format, exportFileName(isSummary ? '负责人汇总' : 'ID核心明细'), exportColumns, exportRows, isSummary ? '负责人汇总' : 'ID核心明细'); + message.success(`已导出 ${exportRows.length} 条数据(${format.toUpperCase()})`); + } catch (exportError) { + message.error(exportError?.message || '导出失败'); + } + }; + + const exportDropdown = (scope) => ( + handleExport(scope, key), + }}> + + + ); + + const summary = data.summary || {}; + return ( + <> + + + setFilters((old) => ({ ...old, ider: value || '' }))} style={{ width: 150 }} /> + } placeholder="SPU" value={filters.spu} onChange={(e) => setFilters((old) => ({ ...old, spu: e.target.value }))} style={{ width: 150 }} /> + } placeholder="商品ID" value={filters.goodsId} onChange={(e) => setFilters((old) => ({ ...old, goodsId: e.target.value }))} style={{ width: 170 }} /> + + + + {error ? 重试} /> : null} + + + + + + + +
+ + +
+ + setSelected(null)}> + {selected ? <>达标 ¥{AWARD} : 未达标 }]} />
number(v, 0) }, { title: '是否动销', dataIndex: 'isActive' }, { title: '真实业绩', dataIndex: 'realPerformance', align: 'right', render: money }, { title: '真实费用', dataIndex: 'realFee', align: 'right', render: money }]} /> : null} + + + ); +}; + +const OpsBIncentiveBoard = () => ( + +
+
+ 激励看板 + 按中心切换查看商品激励奖金、费比与动销达成情况 +
+
+ }, + ]} + /> +
+); + +const AWARD = 400; +export default OpsBIncentiveBoard; diff --git a/frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css b/frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css new file mode 100644 index 0000000..68fa2c8 --- /dev/null +++ b/frontend/src/pages/PlanningUpload/opsBIncentiveBoard.css @@ -0,0 +1,160 @@ +.ops-b-incentive-page { + container-name: ops-b-incentive; + container-type: inline-size; + display: flex; + min-height: calc(100% - 46px); + flex-direction: column; + gap: var(--sycm-page-section-gap); + background: var(--sycm-bg-page-soft); +} + +.ops-b-header { + display: flex; + min-height: 84px; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-lg); + margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0; + padding: 14px calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} + +.ops-b-header__heading { min-width: 0; } +.ops-b-header h3 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-page); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} +.ops-b-header__heading .ant-typography { + display: block; + margin-top: 6px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.ops-b-tabs .ant-tabs-nav { + margin: 0; + padding: 0 calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} +.ops-b-tabs .ant-tabs-tab { + margin: 0; + padding: 12px 4px; + font-size: var(--sycm-font-size-title); +} +.ops-b-tabs .ant-tabs-tab + .ant-tabs-tab { margin-left: 32px; } +.ops-b-tabs .ant-tabs-content-holder, +.ops-b-tabs .ant-tabs-content, +.ops-b-tabs .ant-tabs-tabpane { min-height: 100%; } +.ops-b-tabs .ant-tabs-tabpane { + display: flex; + flex-direction: column; + gap: var(--sycm-page-section-gap); + padding-top: var(--sycm-page-section-gap); +} + +.ops-b-filter-card, +.ops-b-panel, +.ops-b-summary-card { + border: 1px solid var(--sycm-border-card) !important; + border-radius: var(--sycm-radius-lg) !important; + box-shadow: var(--sycm-shadow-card) !important; +} +.ops-b-filter-card { margin-bottom: 0; } +.ops-b-filter-card .ant-card-body { padding: 14px 16px; } +.ops-b-filter-card .ant-space { width: 100%; } +.ops-b-filter-card .ant-space-item { max-width: 100%; } +.ops-b-filter-card .ant-select, +.ops-b-filter-card .ant-picker, +.ops-b-filter-card .ant-input-affix-wrapper { max-width: 100%; } + +.ops-b-summary-row { margin-top: -4px; } +.ops-b-summary-row .ant-col { display: flex; } +.ops-b-summary-card { + position: relative; + width: 100%; + min-height: 98px; + overflow: hidden; +} +.ops-b-summary-card::before { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--sycm-color-primary); + content: ''; +} +.ops-b-summary-card.is-green::before { background: var(--sycm-color-success); } +.ops-b-summary-card.is-orange::before { background: var(--sycm-color-orange); } +.ops-b-summary-card.is-gold::before { background: var(--sycm-color-warning); } +.ops-b-summary-card .ant-card-body { + display: flex; + min-height: 98px; + align-items: center; + padding: 16px 18px; +} +.ops-b-summary-card .ant-statistic { width: 100%; } +.ops-b-summary-card .ant-statistic-title { + margin-bottom: 8px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} +.ops-b-summary-card .ant-statistic-content { + color: var(--sycm-text-strong); + font-family: var(--sycm-font-family-number); + font-size: var(--sycm-font-size-metric); + line-height: 1.1; +} + +.ops-b-panel { margin-bottom: 0; overflow: hidden; } +.ops-b-panel .ant-card-head { + min-height: 48px; + padding: 0 16px; + border-bottom-color: var(--sycm-border-light); +} +.ops-b-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); } +.ops-b-panel .ant-card-body { padding: 0 16px 16px; } +.ops-b-panel .ant-table { font-size: var(--sycm-font-size-caption); } +.ops-b-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); } +.ops-b-panel .ant-table-placeholder .ant-table-cell { height: 180px; } +.ops-b-id-link { + height: auto; + padding: 0; + font-family: var(--sycm-font-family-number); + font-weight: var(--sycm-font-weight-semibold); +} +.ops-b-daily-table { margin-top: var(--sycm-space-md); } + +.ops-b-detail-drawer .ant-drawer-header { + padding: 16px 20px; + border-bottom-color: var(--sycm-border-light); +} +.ops-b-detail-drawer .ant-drawer-title { + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); +} +.ops-b-detail-drawer .ant-drawer-body { padding: 20px; } +.ops-b-detail-drawer .ant-descriptions { margin-bottom: var(--sycm-space-md); } + +@container ops-b-incentive (max-width: 900px) { + .ops-b-filter-card .ant-space-item { flex: 1 1 180px; } +} + +@media (max-width: 767px) { + .ops-b-header { + min-height: 72px; + align-items: flex-start; + flex-direction: column; + gap: var(--sycm-space-sm); + padding-block: 12px; + } + .ops-b-filter-card .ant-select, + .ops-b-filter-card .ant-picker, + .ops-b-filter-card .ant-input-affix-wrapper { width: 100%; } + .ops-b-panel .ant-card-body { padding-inline: 10px; } + .ops-b-detail-drawer .ant-drawer-body { padding: 14px; } +} diff --git a/frontend/src/pages/ProductAnalysis/index.jsx b/frontend/src/pages/ProductAnalysis/index.jsx new file mode 100644 index 0000000..b785632 --- /dev/null +++ b/frontend/src/pages/ProductAnalysis/index.jsx @@ -0,0 +1,990 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Button, + Checkbox, + DatePicker, + Drawer, + Empty, + Image, + Input, + InputNumber, + Popover, + Segmented, + Select, + Skeleton, + Space, + Table, + Tooltip, + Typography, + message, +} from 'antd'; +import { + ExclamationCircleOutlined, + ReloadOutlined, + RightOutlined, + SearchOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import ReactECharts from 'echarts-for-react'; +import dayjs from 'dayjs'; +import { useSearchParams } from 'react-router-dom'; +import { + getProductAnalysisMeta, + getProductAnalysisProducts, + getProductAnalysisSignalThresholds, + getProductAnalysisTab, + saveProductAnalysisSignalThresholds, +} from '../../api/productAnalysis'; +import { + SycmBusinessPanel, + SycmDashboardMetric, + SycmModuleTabs, + SycmPage, + SycmStatusTag, + SycmToolbar, +} from '../../components/Common'; +import { + SYCM_CHART_PEER_AVG_COLOR, + SYCM_CHART_STORE_COLOR, + sycmEchartsBase, + sycmLineSeriesDefaults, +} from '../../design-system'; +import './style.css'; + +const { Text } = Typography; +const { RangePicker } = DatePicker; + +const DEFAULT_TAB = 'refunds'; +const TAB_ITEMS = [ + { key: 'refunds', label: '退款售后' }, + { key: 'promotion', label: '推广投放' }, + { key: 'activities', label: '活动信息' }, + { key: 'navigator', label: '商品领航员' }, + { key: 'service-issues', label: '体验问题' }, + { key: 'bad-reviews', label: '差评分析' }, +]; +const TAB_KEY_SET = new Set(TAB_ITEMS.map((item) => item.key)); +const RANGE_SHORTCUTS = [ + { key: 'realtime', label: '实时' }, + { key: 'yesterday', label: '昨日' }, + { key: '7d', label: '近7天' }, + { key: '30d', label: '近30天' }, + { key: 'week', label: '本周' }, + { key: 'month', label: '本月' }, +]; +const DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS = Object.freeze({ + ctr: { low: 3, high: 5 }, + conversion: { low: 0.3, high: 0.5 }, +}); +const TRAFFIC_SIGNAL_FILTER_OPTIONS = [ + { value: 'all', label: '全部' }, + { value: 'red', label: 红灯 }, + { value: 'yellow', label: 黄灯 }, + { value: 'green', label: 绿灯 }, +]; + +const MONEY_KEYS = new Set(['payOrdrAmt', 'customerUnitPrice', 'pieceUnitPrice', 'spend', 'minPrice', 'maxPrice']); +const PERCENT_KEYS = new Set([ + 'goodsVcr', + 'afterSaleRate', + 'feeRatio', + 'ctr', + 'clickConversionRate', + 'qltyRfndOrdrCntRto1m', + 'ptHelpPzRate30d', +]); + +const toNumber = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const formatNumber = (value, digits = 0) => new Intl.NumberFormat('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, +}).format(toNumber(value)); + +const formatCurrency = (value) => `¥${formatNumber(value, 2)}`; +const formatPercent = (value) => `${formatNumber(value, 2)}%`; + +const normalizeTrafficSignalThresholds = (value) => { + const normalizeMetric = (metric, defaults) => { + const low = Number(metric?.low); + const high = Number(metric?.high); + return { + low: Number.isFinite(low) ? low : defaults.low, + high: Number.isFinite(high) ? high : defaults.high, + }; + }; + return { + ctr: normalizeMetric(value?.ctr, DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS.ctr), + conversion: normalizeMetric(value?.conversion, DEFAULT_TRAFFIC_SIGNAL_THRESHOLDS.conversion), + }; +}; + +const resolveTrafficSignal = (value, thresholds) => { + const numericValue = toNumber(value); + if (numericValue < thresholds.low) { + return { tone: 'red', label: '偏低', boundary: thresholds.low }; + } + if (numericValue < thresholds.high) { + return { tone: 'yellow', label: '关注', boundary: thresholds.high }; + } + return { tone: 'green', label: '良好', boundary: thresholds.high }; +}; + +const buildTrafficSignalReason = ({ metricKey, metricLabel, value, signal, row }) => { + if (signal.tone === 'red') { + if (metricKey === 'ctr' && toNumber(row.signalImpressions) <= 0) { + return '近30天暂无推广曝光,点击率按 0% 计'; + } + if (metricKey === 'conversion' && toNumber(row.signalClicks) <= 0) { + return '近30天暂无推广点击,转化率按 0% 计'; + } + return `近30天${metricLabel} ${formatPercent(value)},低于 ${formatPercent(signal.boundary)}`; + } + if (signal.tone === 'yellow') { + return `近30天${metricLabel} ${formatPercent(value)},尚未达到 ${formatPercent(signal.boundary)}`; + } + return `近30天${metricLabel} ${formatPercent(value)},已达到 ${formatPercent(signal.boundary)}`; +}; + +const ProductOperatingSignals = ({ row, thresholds }) => { + const metrics = row.signalWindowEndDate && !row.signalIsObserving + ? [ + { key: 'ctr', label: '点击率', value: row.signalCtr, thresholds: thresholds.ctr }, + { key: 'conversion', label: '转化率', value: row.signalClickConversionRate, thresholds: thresholds.conversion }, + ].map((metric) => { + const signal = resolveTrafficSignal(metric.value, metric.thresholds); + return { + ...metric, + signal, + reason: buildTrafficSignalReason({ + metricKey: metric.key, + metricLabel: metric.label, + value: metric.value, + signal, + row, + }), + }; + }) + : []; + + if (!metrics.length && !row.signalIsObserving) { + return --; + } + + const redReasons = metrics.filter((metric) => metric.signal.tone === 'red'); + + return ( +
+ {metrics.length ? ( +
+ {metrics.map((metric) => ( + + + + {metric.label} + {formatPercent(metric.value)} + + + ))} +
+ ) : null} + {row.signalIsObserving ? ( +
+ + + 新品观察期 + 点击率、转化率于第 8 天起展示 + +
+ ) : null} + {redReasons.length ? ( + metric.reason).join(';')}> + + {redReasons.map((metric) => metric.reason).join(';')} + + ) : null} +
+ ); +}; + +const ProductQualitySignalCell = ({ row }) => { + if (!row.signalQualityStatus) { + return --; + } + return ( +
+
+ + + + 品质退款率 + {formatPercent(row.signalQualityRefundRate)} + + +
+
+ ); +}; + +const TrafficSignalSettings = ({ value, onChange, onReset, onSave, saving }) => ( +
+
+ 经营信号阈值 + 近30天 · 新品观察期7天 +
+ {[ + { key: 'ctr', label: '点击率', step: 0.1 }, + { key: 'conversion', label: '转化率', step: 0.1 }, + ].map((metric) => ( +
+ {metric.label} + + +
+ ))} +
+ 品质退款率 +
+ 超越30% / 50%同行 + 同行后30% + 同行后10% +
+ 仅按主营类目排名判定 +
+
+ + +
+
+); + +const formatTableValue = (key, value) => { + if (value === null || value === undefined || value === '') return '--'; + if (MONEY_KEYS.has(key)) return formatCurrency(value); + if (PERCENT_KEYS.has(key)) return formatPercent(value); + if (key === 'statDate') return value; + if (typeof value === 'number' || /^-?\d+(\.\d+)?$/.test(String(value))) return formatNumber(value, 0); + return value; +}; + +const formatTabMetric = (metric) => { + if (metric.unit === '¥') return formatCurrency(metric.value); + if (metric.unit === '%') return formatPercent(metric.value); + return formatNumber(metric.value, 0); +}; + +const buildShortcutRange = (shortcut, latestDate) => { + const end = dayjs(latestDate); + if (!end.isValid()) return ['', '']; + if (shortcut === 'yesterday') { + const yesterday = end.subtract(1, 'day').format('YYYY-MM-DD'); + return [yesterday, yesterday]; + } + if (shortcut === '7d') return [end.subtract(6, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]; + if (shortcut === '30d') return [end.subtract(29, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]; + if (shortcut === 'week') { + const mondayOffset = end.day() === 0 ? 6 : end.day() - 1; + return [end.subtract(mondayOffset, 'day').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]; + } + if (shortcut === 'month') return [end.startOf('month').format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]; + return [end.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')]; +}; + +const ProductAnalysis = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const initialTab = TAB_KEY_SET.has(searchParams.get('tab')) ? searchParams.get('tab') : DEFAULT_TAB; + const requestedProductId = searchParams.get('productId') || ''; + const [meta, setMeta] = useState({ latestDate: '', dates: [], shops: [] }); + const [filters, setFilters] = useState({ + startDate: '', + endDate: '', + shopName: '', + ider: '', + listingRange: null, + keyword: '', + onlyActive: false, + }); + const [rangeShortcut, setRangeShortcut] = useState('7d'); + const [products, setProducts] = useState([]); + const [metrics, setMetrics] = useState({}); + const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 }); + const [sorter, setSorter] = useState({ field: 'payOrdrAmt', order: 'descend' }); + const [productsLoading, setProductsLoading] = useState(true); + const [selectedProduct, setSelectedProduct] = useState(null); + const [activeTab, setActiveTab] = useState(initialTab); + const [tabData, setTabData] = useState(null); + const [tabLoading, setTabLoading] = useState(false); + const [serviceIssueDetail, setServiceIssueDetail] = useState(null); + const [signalThresholds, setSignalThresholds] = useState(() => normalizeTrafficSignalThresholds(null)); + const [signalThresholdDraft, setSignalThresholdDraft] = useState(() => normalizeTrafficSignalThresholds(null)); + const [signalStatus, setSignalStatus] = useState('all'); + const [signalSettingsOpen, setSignalSettingsOpen] = useState(false); + const [signalSettingsSaving, setSignalSettingsSaving] = useState(false); + const tabCacheRef = useRef(new Map()); + const productsRequestRef = useRef(0); + + const loadProducts = async (options = {}) => { + const requestId = productsRequestRef.current + 1; + productsRequestRef.current = requestId; + const nextFilters = options.filters || filters; + const nextPage = options.page || pagination.current; + const nextPageSize = options.pageSize || pagination.pageSize; + const nextSorter = options.sorter || sorter; + const nextSignalStatus = options.signalStatus || signalStatus; + + setProductsLoading(true); + try { + const response = await getProductAnalysisProducts({ + page: nextPage, + pageSize: nextPageSize, + startDate: nextFilters.startDate || undefined, + endDate: nextFilters.endDate || undefined, + shopName: nextFilters.shopName || undefined, + ider: nextFilters.ider || undefined, + listingFrom: nextFilters.listingRange?.[0] || undefined, + listingTo: nextFilters.listingRange?.[1] || undefined, + keyword: nextFilters.keyword || undefined, + onlyActive: nextFilters.onlyActive || undefined, + signalStatus: nextSignalStatus === 'all' ? undefined : nextSignalStatus, + sortField: nextSorter.field, + sortOrder: nextSorter.order, + rangeDays: options.rangeDays, + }); + if (requestId !== productsRequestRef.current) return; + + const data = response.data || {}; + const nextRows = data.rows || []; + setProducts(nextRows); + setMetrics(data.metrics || {}); + setPagination({ current: data.page || nextPage, pageSize: data.pageSize || nextPageSize, total: data.total || 0 }); + + setSelectedProduct((current) => { + const preferredId = current?.goodsId || requestedProductId; + return nextRows.find((row) => String(row.goodsId) === String(preferredId)) || nextRows[0] || null; + }); + } catch (error) { + if (requestId !== productsRequestRef.current) return; + setProducts([]); + setMetrics({}); + setPagination((current) => ({ ...current, total: 0 })); + setSelectedProduct(null); + message.error(error.response?.data?.error || error.message || '商品数据加载失败'); + } finally { + if (requestId === productsRequestRef.current) setProductsLoading(false); + } + }; + + useEffect(() => { + let active = true; + const bootstrap = async () => { + const productsPromise = loadProducts({ filters, page: 1, rangeDays: 7 }); + try { + const [response, thresholdResult] = await Promise.all([ + getProductAnalysisMeta(), + getProductAnalysisSignalThresholds() + .then((thresholdResponse) => ({ response: thresholdResponse, error: null })) + .catch((error) => ({ response: null, error })), + ]); + if (!active) return; + const nextMeta = response.data || {}; + const [startDate, endDate] = buildShortcutRange('7d', nextMeta.latestDate); + const nextFilters = { ...filters, startDate, endDate }; + setMeta(nextMeta); + setFilters(nextFilters); + if (thresholdResult.response) { + const nextThresholds = normalizeTrafficSignalThresholds( + thresholdResult.response.data?.thresholds + ); + setSignalThresholds(nextThresholds); + setSignalThresholdDraft(nextThresholds); + } else { + message.warning('个人经营信号阈值读取失败,当前使用系统默认值'); + } + await productsPromise; + } catch (error) { + if (!active) return; + setProductsLoading(false); + message.error(error.response?.data?.error || error.message || '商品分析初始化失败'); + } + }; + bootstrap(); + return () => { + active = false; + }; + // Initial bootstrap intentionally owns the first product query. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + let active = true; + setServiceIssueDetail(null); + if (!selectedProduct?.goodsId) { + setTabData(null); + return () => { + active = false; + }; + } + + const cacheKey = `${selectedProduct.goodsId}:${filters.startDate}:${filters.endDate}:${activeTab}`; + const cached = tabCacheRef.current.get(cacheKey); + if (cached) { + setTabData(cached); + return () => { + active = false; + }; + } + + setTabLoading(true); + setTabData(null); + getProductAnalysisTab(selectedProduct.goodsId, activeTab, { + startDate: filters.startDate, + endDate: filters.endDate, + }) + .then((response) => { + if (!active) return; + const data = response.data || null; + tabCacheRef.current.set(cacheKey, data); + setTabData(data); + }) + .catch((error) => { + if (!active) return; + setTabData({ status: 'error', message: error.response?.data?.error || error.message || '子模块加载失败' }); + }) + .finally(() => { + if (active) setTabLoading(false); + }); + + return () => { + active = false; + }; + }, [activeTab, filters.endDate, filters.startDate, selectedProduct]); + + useEffect(() => { + const nextParams = new URLSearchParams(searchParams); + if (selectedProduct?.goodsId) nextParams.set('productId', selectedProduct.goodsId); + else nextParams.delete('productId'); + nextParams.set('tab', activeTab); + setSearchParams(nextParams, { replace: true }); + // searchParams is deliberately excluded to avoid URL update loops. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, selectedProduct?.goodsId, setSearchParams]); + + const dashboardMetrics = useMemo(() => [ + { key: 'total', label: '商品数', value: formatNumber(metrics.total), footerLabel: '有销售', footerValue: formatNumber(metrics.activeGoods) }, + { key: 'payOrdrAmt', label: '销售额', value: formatCurrency(metrics.payOrdrAmt) }, + { key: 'orderNum', label: '订单量', value: formatNumber(metrics.orderNum) }, + { key: 'payOrdrUsrCnt', label: '买家数', value: formatNumber(metrics.payOrdrUsrCnt) }, + { key: 'goodsUv', label: '访客数', value: formatNumber(metrics.goodsUv) }, + { key: 'goodsVcr', label: '平均成交转化率', value: formatPercent(metrics.goodsVcr) }, + ], [metrics]); + + const productColumns = useMemo(() => [ + { + title: '商品', + key: 'product', + fixed: 'left', + width: 310, + render: (_, row) => ( +
+ +
+ {row.goodsName || '--'} + ID {row.goodsId} · {row.shopName || '--'} +
+
+ ), + }, + { title: 'SPU', dataIndex: 'spu', key: 'spu', width: 120, ellipsis: true }, + { title: 'SKU货号', dataIndex: 'skuCode', key: 'skuCode', width: 130, ellipsis: true }, + { title: '负责人', dataIndex: 'idOwner', key: 'idOwner', width: 90 }, + { title: '上架时间', dataIndex: 'firstCreatedAt', key: 'firstCreatedAt', width: 105, render: (value) => value || '--' }, + { + title: '近30天推广信号', + key: 'trafficSignal', + width: 300, + render: (_, row) => , + }, + { + title: '近30天品质信号(商品领航员)', + key: 'qualitySignal', + width: 190, + render: (_, row) => , + }, + { title: '访客数', dataIndex: 'goodsUv', key: 'goodsUv', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) }, + { title: '销售额', dataIndex: 'payOrdrAmt', key: 'payOrdrAmt', width: 120, align: 'right', sorter: true, render: formatCurrency }, + { title: '订单量', dataIndex: 'orderNum', key: 'orderNum', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) }, + { title: '买家数', dataIndex: 'payOrdrUsrCnt', key: 'payOrdrUsrCnt', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) }, + { title: '成交件数', dataIndex: 'payOrdrGoodsQty', key: 'payOrdrGoodsQty', width: 100, align: 'right', sorter: true, render: (value) => formatNumber(value) }, + { title: '成交转化率', dataIndex: 'goodsVcr', key: 'goodsVcr', width: 120, align: 'right', sorter: true, render: formatPercent }, + { title: '客单价', dataIndex: 'customerUnitPrice', key: 'customerUnitPrice', width: 110, align: 'right', sorter: true, render: formatCurrency }, + { + title: '价格状态', + dataIndex: 'isPriceBreak', + key: 'isPriceBreak', + width: 100, + align: 'center', + render: (value) => {Number(value) === 1 ? '破价' : '正常'}, + }, + ], [signalThresholds]); + + // 快照表缺日期时前后端都不补点会导致 x 轴日期跳跃,这里按日历补全缺失日期 + const tabTrend = useMemo(() => { + const trend = tabData?.trend || []; + if (trend.length < 2) return trend; + const byDate = new Map(trend.map((item) => [String(item.date).slice(0, 10), item])); + const dates = [...byDate.keys()].sort(); + const start = dayjs(dates[0]); + const end = dayjs(dates[dates.length - 1]); + if (!start.isValid() || !end.isValid() || end.diff(start, 'day') + 1 === trend.length) return trend; + const totalDays = end.diff(start, 'day') + 1; + if (totalDays > 400) return trend; + const filled = []; + for (let cursor = start; !cursor.isAfter(end, 'day'); cursor = cursor.add(1, 'day')) { + const key = cursor.format('YYYY-MM-DD'); + filled.push(byDate.get(key) || { date: key, primary: null, secondary: null }); + } + return filled; + }, [tabData]); + + const tabChartOption = useMemo(() => { + const trend = tabTrend; + return { + ...sycmEchartsBase, + grid: { ...sycmEchartsBase.grid, top: 48, right: 54, bottom: 28, left: 52 }, + legend: { ...sycmEchartsBase.legend, data: [tabData?.trendLabels?.primary, tabData?.trendLabels?.secondary].filter(Boolean) }, + xAxis: { + ...sycmEchartsBase.xAxis, + type: 'category', + boundaryGap: false, + data: trend.map((item) => item.date), + }, + yAxis: [ + { ...sycmEchartsBase.yAxis, type: 'value' }, + { ...sycmEchartsBase.yAxis, type: 'value', splitLine: { show: false } }, + ], + series: [ + { + ...sycmLineSeriesDefaults, + name: tabData?.trendLabels?.primary, + data: trend.map((item) => (item.primary === null ? null : toNumber(item.primary))), + connectNulls: true, + lineStyle: { width: 3, color: SYCM_CHART_STORE_COLOR }, + itemStyle: { color: SYCM_CHART_STORE_COLOR }, + }, + { + ...sycmLineSeriesDefaults, + name: tabData?.trendLabels?.secondary, + yAxisIndex: 1, + data: trend.map((item) => (item.secondary === null ? null : toNumber(item.secondary))), + connectNulls: true, + lineStyle: { width: 3, color: SYCM_CHART_PEER_AVG_COLOR }, + itemStyle: { color: SYCM_CHART_PEER_AVG_COLOR }, + }, + ], + }; + }, [tabTrend, tabData]); + + const tabColumns = useMemo(() => (tabData?.columns || []).map((column) => ({ + title: column.title, + dataIndex: column.key, + key: column.key, + align: column.align || (column.key === 'statDate' ? 'left' : 'right'), + render: (value) => (column.key === 'viewOrders' + ? 查看订单 + : formatTableValue(column.key, value)), + })), [tabData]); + + const serviceIssueOrderColumns = useMemo(() => [ + { + title: '订单号', + dataIndex: 'orderSn', + key: 'orderSn', + width: 210, + render: (value) => {value || '--'}, + }, + { + title: '问题详情', + dataIndex: 'problemDetail', + key: 'problemDetail', + width: 220, + render: (value) => {value || '--'}, + }, + { + title: '问题分类', + dataIndex: 'categoryDescription', + key: 'categoryDescription', + width: 200, + render: (value) => {value || '--'}, + }, + { title: '求助时间', dataIndex: 'seekHelpTime', key: 'seekHelpTime', width: 170 }, + { + title: '体验状态', + dataIndex: 'isNegative', + key: 'isNegative', + width: 110, + align: 'center', + render: (value) => ( + {value ? '负向体验' : '一般问题'} + ), + }, + ], []); + + const openServiceIssueOrders = (row) => { + if (tabData?.tabKey !== 'service-issues') return; + const orders = (tabData.orderDetails || []).filter((item) => item.issueType === row.issueType); + setServiceIssueDetail({ issueType: row.issueType, orders }); + }; + + const handleQuery = () => { + tabCacheRef.current.clear(); + loadProducts({ page: 1 }); + }; + + const handleSignalStatusChange = (nextStatus) => { + setSignalStatus(nextStatus); + loadProducts({ page: 1, signalStatus: nextStatus }); + }; + + const handleTableChange = (nextPagination, _tableFilters, tableSorter) => { + const nextSorter = tableSorter?.field + ? { field: tableSorter.field, order: tableSorter.order || 'descend' } + : sorter; + setSorter(nextSorter); + loadProducts({ page: nextPagination.current, pageSize: nextPagination.pageSize, sorter: nextSorter }); + }; + + const updateSignalThresholdDraft = (metricKey, boundaryKey, value) => { + setSignalThresholdDraft((current) => ({ + ...current, + [metricKey]: { + ...current[metricKey], + [boundaryKey]: Number.isFinite(Number(value)) ? Number(value) : 0, + }, + })); + }; + + const resetSignalThresholdDraft = () => { + setSignalThresholdDraft(normalizeTrafficSignalThresholds(null)); + }; + + const saveSignalThresholds = async () => { + const nextThresholds = normalizeTrafficSignalThresholds(signalThresholdDraft); + if (nextThresholds.ctr.low >= nextThresholds.ctr.high + || nextThresholds.conversion.low >= nextThresholds.conversion.high) { + message.warning('黄绿分界值必须大于红黄分界值'); + return; + } + setSignalSettingsSaving(true); + try { + const response = await saveProductAnalysisSignalThresholds(nextThresholds); + const savedThresholds = normalizeTrafficSignalThresholds(response.data?.thresholds || nextThresholds); + setSignalThresholds(savedThresholds); + setSignalThresholdDraft(savedThresholds); + setSignalSettingsOpen(false); + if (signalStatus !== 'all') { + await loadProducts({ page: 1, signalStatus }); + } + message.success('个人经营信号阈值已保存'); + } catch (error) { + message.error(error.response?.data?.error || error.message || '个人经营信号阈值保存失败'); + } finally { + setSignalSettingsSaving(false); + } + }; + + return ( + + + +
+ ({ value: shop, label: shop }))} + onChange={(value) => setFilters((current) => ({ ...current, shopName: value || '' }))} + aria-label="店铺" + /> + } + value={filters.keyword} + placeholder="搜索商品ID / SKU / SPU / 商品名" + onChange={(event) => setFilters((current) => ({ ...current, keyword: event.target.value }))} + onPressEnter={handleQuery} + /> + setFilters((current) => ({ ...current, onlyActive: event.target.checked }))} + > + 仅看有销售/投放 + +
+ + + + +
+
+ + 数据区间 {metrics.startDate || '--'} 至 {metrics.endDate || '--'}} + className="product-analysis-overview-panel" + > +
+ {dashboardMetrics.map(({ key, ...item }) => )} +
+
+ + + + 共 {formatNumber(pagination.total)} 款,点击行查看单品模块 + { + setSignalSettingsOpen(open); + if (open) setSignalThresholdDraft(signalThresholds); + }} + content={( + + )} + > + + + + )} + className="product-analysis-table-panel" + > +
`共 ${formatNumber(total)} 款`, + }} + rowSelection={{ + type: 'radio', + selectedRowKeys: selectedProduct?.goodsId ? [selectedProduct.goodsId] : [], + onSelect: (record) => setSelectedProduct(record), + columnWidth: 42, + }} + rowClassName={(record) => String(record.goodsId) === String(selectedProduct?.goodsId) ? 'is-selected' : ''} + onRow={(record) => ({ + onClick: () => setSelectedProduct(record), + })} + onChange={handleTableChange} + locale={{ emptyText: }} + /> + + + + + + {selectedProduct.goodsName || '未命名商品'} + {selectedProduct.shopName} · 商品ID {selectedProduct.goodsId} · SKU {selectedProduct.skuCode || '--'} + + + ) : '单品业务模块'} + extra={selectedProduct ? {selectedProduct.idOwner || '未分配负责人'} : null} + className="product-analysis-detail-panel" + > + {selectedProduct ? ( + <> + +
+ {tabLoading ? : null} + {!tabLoading && tabData?.status === 'empty' ? ( + + ) : null} + {!tabLoading && tabData?.status === 'error' ? ( + + ) : null} + {!tabLoading && tabData?.status === 'ready' ? ( + <> +
+ {(tabData.metrics || []).map((item) => ( + + ))} +
+
+ {tabData.trend?.length ? ( +
+

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

+ +
+ ) : null} +
+

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

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

推广数据

+ 包含稳定成本推广和全店托管数据 +
+
+ 统计时间:{filterDateLabel || '--'} + {summary?.previousRange ? ( + 上一周期:{summary.previousRange} + ) : null} + +
+
+ +
+ {summaryMetrics.map((item) => ( + + ))} +
+
+
+ + + + +
+ + setKeyword(e.target.value)} + onPressEnter={handleSearch} + suffix={} + /> + + + +
+ +
+ 已选 {selectedRowKeys.length} 项 + 共 {total} 条推广记录 +
+ + +
`共 ${t} 条`, + }} + scroll={{ x: 3000 }} + locale={{ emptyText: loading ? '加载中...' : '暂无推广数据' }} + size="small" + /> + + + + setSettingsOpen(false)} + > +
指标设置功能开发中
+
+ + setAddProductOpen(false)} + > + +
+ 暂无可添加商品 +
+
+ + ); +}; + +export default PromotionModule; diff --git a/frontend/src/pages/PromotionModule/style.css b/frontend/src/pages/PromotionModule/style.css new file mode 100644 index 0000000..bfa4e74 --- /dev/null +++ b/frontend/src/pages/PromotionModule/style.css @@ -0,0 +1,214 @@ +.promotion-module-page { + display: flex; + flex-direction: column; + gap: var(--sycm-page-section-gap); +} + +.promotion-dashboard, +.promotion-workspace { + overflow: hidden; +} + +.promotion-dashboard { + padding: var(--sycm-module-padding); +} + +.promotion-dashboard__header, +.promotion-actionbar, +.promotion-filterbar, +.promotion-selectionbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-md); +} + +.promotion-dashboard__header > div:first-child { + display: flex; + min-width: 0; + align-items: baseline; + gap: var(--sycm-space-sm); +} + +.promotion-dashboard__header h2 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); +} + +.promotion-dashboard__header span, +.promotion-dashboard__tools, +.promotion-filterbar > span, +.promotion-selectionbar { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); +} + +.promotion-dashboard__tools { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--sycm-space-sm); +} + +.promotion-dashboard__metrics { + display: grid; + grid-template-columns: repeat(8, minmax(110px, 1fr)); + gap: var(--sycm-space-sm); + margin-top: var(--sycm-space-sm); +} + +.promotion-dashboard__metrics .sycm-dashboard-metric { + min-height: 96px; + padding: var(--sycm-space-sm); + border-radius: var(--sycm-radius-lg); + background: var(--sycm-bg-subtle); +} + +.promotion-dashboard__metrics .sycm-dashboard-metric__label { + font-size: var(--sycm-font-size-caption); +} + +.promotion-dashboard__metrics .sycm-dashboard-metric__value { + font-size: var(--sycm-font-size-metric); +} + +.promotion-type-tabs .ant-tabs-nav { + min-height: var(--sycm-module-header-min-height); + margin: 0 !important; + padding: 0 var(--sycm-module-padding); + background: var(--sycm-bg-subtle); +} + +.promotion-type-tabs .ant-tabs-content-holder { + display: none; +} + +.promotion-actionbar { + min-height: calc(var(--sycm-control-height) + var(--sycm-space-lg)); + padding: var(--sycm-space-sm) var(--sycm-module-padding); +} + +.promotion-actionbar > div { + display: flex; + gap: var(--sycm-space-sm); +} + +.promotion-filterbar { + justify-content: flex-start; + padding: var(--sycm-space-sm) var(--sycm-module-padding); + border-top: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-bottom: var(--sycm-line-width-base) solid var(--sycm-border-light); + background: var(--sycm-bg-subtle); +} + +.promotion-filterbar .ant-space { + flex-wrap: nowrap; +} + +.promotion-filterbar__shop { + width: 160px; +} + +.promotion-filterbar__search { + width: 240px; +} + +.promotion-selectionbar { + min-height: var(--sycm-module-header-min-height); + justify-content: flex-start; + padding: 0 var(--sycm-module-padding); +} + +.promotion-table .ant-table-placeholder .ant-table-cell { + height: 320px; +} + +.promotion-product-cell { + display: flex; + min-width: 0; + align-items: flex-start; + gap: var(--sycm-space-sm); +} + +.promotion-product-cell > img, +.promotion-product-cell__image { + display: inline-flex; + width: 52px; + height: 52px; + flex: 0 0 52px; + align-items: center; + justify-content: center; + border: var(--sycm-line-width-base) solid var(--sycm-border-light); + border-radius: var(--sycm-radius-sm); + background: var(--sycm-bg-subtle); + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); + object-fit: cover; +} + +.promotion-product-cell > div { + min-width: 0; +} + +.promotion-product-cell strong, +.promotion-product-cell span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.promotion-product-cell strong { + max-width: 180px; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); +} + +.promotion-product-cell span { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-caption); +} + +.promotion-budget-cell { + display: flex; + flex-direction: column; + gap: var(--sycm-space-xxs); + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.promotion-metric-settings { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sycm-space-sm); +} + +.promotion-metric-settings .ant-checkbox-wrapper { + margin-inline-start: 0; + font-size: var(--sycm-font-size-caption); +} + +.promotion-add-product-empty { + margin-top: 96px; +} + +@media (max-width: 1180px) { + .promotion-dashboard__metrics { + grid-template-columns: repeat(4, minmax(110px, 1fr)); + } + + .promotion-filterbar { + flex-wrap: wrap; + } + + .promotion-filterbar .ant-input-search { + width: 100%; + } + + .promotion-filterbar > span { + margin-left: 0; + } +} diff --git a/frontend/src/pages/Settings/DepartmentTagSettings.jsx b/frontend/src/pages/Settings/DepartmentTagSettings.jsx new file mode 100644 index 0000000..505525a --- /dev/null +++ b/frontend/src/pages/Settings/DepartmentTagSettings.jsx @@ -0,0 +1,340 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, Button, Card, Input, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message, +} from 'antd'; +import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, TagsOutlined, TeamOutlined } from '@ant-design/icons'; +import { + getDepartmentTagConfig, + saveDepartmentTagConfig, +} from '../../api/productMonitoring'; + +const { Text } = Typography; + +const METRIC_OPTIONS = [ + { value: 'sales_qty_7d', label: '近7天销售件数' }, + { value: 'sales_amount_7d', label: '近7天销售额' }, +]; +const OWNER_TYPE_OPTIONS = [ + { value: 'sku_owner', label: 'SKU负责人' }, + { value: 'purchaser', label: '采购负责人' }, +]; +const LEVEL_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' }; + +const readError = (error) => error?.response?.data?.error || error?.message || '操作失败'; + +const useDepartmentConfig = () => { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const result = await getDepartmentTagConfig(); + setConfig(result?.data || null); + } catch (requestError) { + setError(readError(requestError)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + return { config, setConfig, loading, setLoading, error, setError, load }; +}; + +// ==================== 部门商品标签配置(G-S / G-A / G-B / G-C) ==================== + +export const DepartmentTagSettings = () => { + const { config, setConfig, loading, error, load } = useDepartmentConfig(); + const [rules, setRules] = useState([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (config?.rules) setRules(config.rules.map((item) => ({ ...item }))); + }, [config]); + + const patch = (tagLevel, key, value) => { + setRules((old) => old.map((item) => (item.tagLevel === tagLevel ? { ...item, [key]: value } : item))); + }; + + const handleSave = async () => { + setSaving(true); + try { + const result = await saveDepartmentTagConfig({ rules }); + setConfig(result?.data || null); + message.success('部门商品标签已保存,商品监控列表下次加载即生效'); + } catch (requestError) { + message.error(readError(requestError)); + } finally { + setSaving(false); + } + }; + + const columns = [ + { + title: '部门标签', + dataIndex: 'tagLevel', + width: 120, + render: (value) => {value}, + }, + { + title: '判定指标', + dataIndex: 'metricKey', + width: 180, + render: (value, row) => ( + patch(row.tagLevel, 'thresholdMin', e.target.value === '' ? null : Number(e.target.value))} + /> + ), + }, + { + title: '区间终点(不含)', + dataIndex: 'thresholdMax', + width: 160, + render: (value, row) => ( + patch(row.tagLevel, 'thresholdMax', e.target.value === '' ? null : Number(e.target.value))} + /> + ), + }, + { + title: '优先级', + dataIndex: 'priority', + width: 110, + render: (value, row) => ( + patch(row.tagLevel, 'priority', Number(e.target.value) || 0)} + /> + ), + }, + { + title: '启用', + dataIndex: 'enabled', + width: 90, + render: (value, row) => ( + patch(row.tagLevel, 'enabled', next)} /> + ), + }, + ]; + + return ( +
+ + 部门商品标签配置} + extra={( + + + + + )} + > + {error ? : null} + + 商品监控页「部门的标签」按此规则判定:同一 SKU 的近 7 天指标落在 [起点, 终点) 区间即命中, + 按优先级升序命中即停,未命中留空。留空表示不限(如 G-S 只填起点 500)。 + +
+ + + + ); +}; + +// ==================== 部门 SKU 负责人配置 ==================== + +export const DepartmentOwnerSettings = () => { + const { config, setConfig, loading, error, load } = useDepartmentConfig(); + const [owners, setOwners] = useState([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (config?.owners) { + setOwners(config.owners.map((item, index) => ({ + key: `${item.departmentId}-${item.ownerType}-${item.spu || ''}-${item.userId}-${index}`, + departmentId: item.departmentId, + spu: item.spu || '', + ownerType: item.ownerType, + userId: item.userId, + }))); + } + }, [config]); + + const departmentOptions = useMemo( + () => (config?.departments || []).map((item) => ({ value: item.id, label: item.name })), + [config] + ); + const userOptions = useMemo( + () => (config?.users || []).map((item) => ({ value: item.id, label: item.name || item.username })), + [config] + ); + + const patch = (key, field, value) => { + setOwners((old) => old.map((item) => (item.key === key ? { ...item, [field]: value } : item))); + }; + const addRow = () => { + setOwners((old) => [...old, { + key: `new-${Date.now()}`, + departmentId: undefined, + spu: '', + ownerType: 'sku_owner', + userId: undefined, + }]); + }; + const removeRow = (key) => setOwners((old) => old.filter((item) => item.key !== key)); + + const handleSave = async () => { + const invalid = owners.find((item) => !item.departmentId || !item.userId); + if (invalid) { + message.warning('每一行都必须选择部门与负责人'); + return; + } + setSaving(true); + try { + const result = await saveDepartmentTagConfig({ owners }); + setConfig(result?.data || null); + message.success('部门 SKU 负责人已保存'); + } catch (requestError) { + message.error(readError(requestError)); + } finally { + setSaving(false); + } + }; + + const columns = [ + { + title: '部门', + dataIndex: 'departmentId', + width: 200, + render: (value, row) => ( + patch(row.key, 'spu', e.target.value)} + /> + ), + }, + { + title: '负责人类型', + dataIndex: 'ownerType', + width: 150, + render: (value, row) => ( + patch(row.key, 'userId', next)} + style={{ width: '100%' }} + /> + ), + }, + { + title: '操作', + width: 90, + render: (_, row) => ( + removeRow(row.key)}> + + + ), + }, + ]; + + return ( +
+ + 部门 SKU 负责人配置} + extra={( + + + + + + )} + > + {error ? : null} + + 商品监控页「部门SKU负责人」按此表匹配:先找部门 + SPU 精确配置,找不到再用部门 + SPU 留空的默认负责人。 + 保存会整体覆盖当前配置。 + +
+ + + + ); +}; + +export default DepartmentTagSettings; diff --git a/frontend/src/pages/Settings/ProductTagSettings.jsx b/frontend/src/pages/Settings/ProductTagSettings.jsx new file mode 100644 index 0000000..cdb3b26 --- /dev/null +++ b/frontend/src/pages/Settings/ProductTagSettings.jsx @@ -0,0 +1,372 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Input, InputNumber, Select, Space, Spin, Table, Tag, Typography, message } from 'antd'; +import { ReloadOutlined, SaveOutlined, SearchOutlined } from '@ant-design/icons'; +import { getProductTagConfig, updateProductTagConfig } from '../../api/admin'; +import { SycmCard } from '../../components/Common'; +import './productTagSettings.css'; + +const SCALE_TAGS = ['爆款', '热卖款', '动销款', '低动销款']; +const SCALE_TAG_ORDER = [...SCALE_TAGS, '滞销款']; +const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级']; +const SABC_TAGS = ['S', 'A', 'B', 'C', 'D']; + +// 规模标签配色:爆款最优 → 滞销款最差 +const SCALE_TAG_COLOR = { + 爆款: 'red', + 热卖款: 'orange', + 动销款: 'blue', + 低动销款: 'default', + 滞销款: 'default', +}; + +const SABC_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' }; + +const numText = (value) => ( + value === null || value === undefined || value === '' + ? '--' + : Number(value).toLocaleString('zh-CN') +); + +const scaleRowKey = (row) => `${row.category}||${row.scaleTag}`; + +const ProductTagSettings = () => { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [scaleThresholds, setScaleThresholds] = useState([]); + const [profitLevels, setProfitLevels] = useState([]); + const [sabcMappings, setSabcMappings] = useState([]); + const [excludedCategories, setExcludedCategories] = useState([]); + const [categoryFilter, setCategoryFilter] = useState(''); + const [scaleTagFilter, setScaleTagFilter] = useState(); + + const applyConfig = useCallback((config = {}) => { + setScaleThresholds(Array.isArray(config.scaleThresholds) ? config.scaleThresholds : []); + setProfitLevels(Array.isArray(config.profitLevels) ? config.profitLevels : []); + setSabcMappings(Array.isArray(config.sabcMappings) ? config.sabcMappings : []); + setExcludedCategories(Array.isArray(config.excludedCategories) ? config.excludedCategories : []); + }, []); + + const loadConfig = useCallback(async () => { + setLoading(true); + try { + const response = await getProductTagConfig(); + applyConfig(response?.data?.data || response?.data || {}); + } catch (error) { + message.error(error?.response?.data?.error || error?.message || '加载商品标签配置失败'); + } finally { + setLoading(false); + } + }, [applyConfig]); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const handleSave = async () => { + setSaving(true); + try { + const response = await updateProductTagConfig({ + scaleThresholds, + profitLevels, + sabcMappings, + excludedCategories, + }); + applyConfig(response?.data?.data || response?.data || {}); + message.success( + `已保存:规模阈值 ${scaleThresholds.length} 行 / 利润标签 ${profitLevels.length} 行 / ` + + `SABC 映射 ${sabcMappings.length} 条 / 排除品类 ${excludedCategories.length} 个` + ); + } catch (error) { + message.error(error?.response?.data?.error || error?.message || '保存商品标签配置失败'); + } finally { + setSaving(false); + } + }; + + // 规模阈值:原地更新某一行 + const patchScaleRow = useCallback((rowKey, field, value) => { + setScaleThresholds((rows) => rows.map((row) => ( + scaleRowKey(row) === rowKey ? { ...row, [field]: value } : row + ))); + }, []); + + // SABC 映射:矩阵单元格更新(不存在则新增) + const patchSabcCell = useCallback((scaleTag, profitLevel, sabcTag) => { + setSabcMappings((rows) => { + const index = rows.findIndex((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel); + if (index >= 0) { + const next = [...rows]; + next[index] = { ...next[index], sabcTag }; + return next; + } + return [...rows, { scaleTag, profitLevel, sabcTag, enabled: true }]; + }); + }, []); + + const sabcValueOf = useCallback((scaleTag, profitLevel) => { + const hit = sabcMappings.find((row) => row.scaleTag === scaleTag && row.profitLevel === profitLevel); + return hit?.sabcTag; + }, [sabcMappings]); + + // 候选品类:已配置的品类(含 jst_sku.category 全名)作为联想项, + // 未配置的(如「其他 - 配件」)直接手动输入完整值。 + const dbCategoryOptions = useMemo(() => { + const set = new Set(); + scaleThresholds.forEach((row) => { + if (row.category) set.add(row.category); + }); + excludedCategories.forEach((item) => set.add(item)); + return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN')); + }, [scaleThresholds, excludedCategories]); + + const filteredScaleRows = useMemo(() => { + const keyword = categoryFilter.trim(); + return scaleThresholds.filter((row) => ( + (!keyword || String(row.displayName || '').includes(keyword) || String(row.category || '').includes(keyword)) + && (!scaleTagFilter || row.scaleTag === scaleTagFilter) + )); + }, [scaleThresholds, categoryFilter, scaleTagFilter]); + + const scaleColumns = [ + { + title: '品类', + dataIndex: 'displayName', + width: 200, + fixed: 'left', + filters: SCALE_TAGS.map((tag) => ({ text: tag, value: tag })), + onFilter: (value, record) => record.scaleTag === value, + render: (value, record) => ( +
+
{value || record.category}
+ {/* 展示 jst_sku.category 的完整值,便于核对品类名映射 */} + {record.category && record.category !== value ? ( +
{record.category}
+ ) : null} +
+ ), + }, + { + title: '规模标签', + dataIndex: 'scaleTag', + width: 110, + render: (value) => {value}, + }, + { + title: '业绩起点', + dataIndex: 'performanceMin', + width: 130, + align: 'right', + render: (value, record) => ( + patchScaleRow(scaleRowKey(record), 'performanceMin', next)} + /> + ), + }, + { + title: '日销起点', + dataIndex: 'salesMin', + width: 120, + align: 'right', + // 日销存在 0.5 这类合法小数(低动销款),不能用 precision=0 强制取整 + render: (value, record) => ( + patchScaleRow(scaleRowKey(record), 'salesMin', next)} + /> + ), + }, + { + title: '判定优先级', + dataIndex: 'priority', + width: 100, + align: 'right', + render: (value) => numText(value), + }, + ]; + + const profitColumns = [ + { title: '利润标签', dataIndex: 'levelCode', width: 120 }, + { + title: '营业利润率上限', + dataIndex: 'profitRateMax', + width: 160, + align: 'right', + render: (value, record) => ( + setProfitLevels((rows) => rows.map((row) => ( + row.levelCode === record.levelCode ? { ...row, profitRateMax: next } : row + )))} + /> + ), + }, + { + title: '说明(展示用,不参与计算)', + dataIndex: 'description', + render: (value, record) => ( + setProfitLevels((rows) => rows.map((row) => ( + row.levelCode === record.levelCode ? { ...row, description: event.target.value } : row + )))} + /> + ), + }, + ]; + + // SABC 映射矩阵:每行一个规模标签,每列一个利润标签 + const matrixColumns = [ + { + title: '规模标签 \ 利润标签', + dataIndex: 'scaleTag', + width: 150, + fixed: 'left', + render: (value) => {value}, + }, + ...PROFIT_LEVELS.map((level) => ({ + title: level, + dataIndex: level, + width: 110, + align: 'center', + render: (_, record) => ( + } + placeholder="搜索分类" + value={categoryFilter} + onChange={(event) => setCategoryFilter(event.target.value)} + style={{ width: 220 }} + /> +
`共 ${total} 行` }} + scroll={{ x: 760 }} + /> + + + + +
+ + + 规模标签 × 利润标签 → 公司商品标签} + > +
+ + 取值说明: + {SABC_TAGS.map((tag) => ( + {tag} + ))} + (S 最优,D 最差;D 档为本次评审确认保留) + + + + {`已排除 ${excludedCategories.length} 个`}} + > + + - + ) : null} @@ -83,7 +83,7 @@ const PrintPreview = ({ data, onClose }) => {
- MKH 商品编码与组合配件取自业务库;「产品资料系统数据库」中维护的配件 BOM 作为补充; + MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充; 配件仓位取库位主数据,未维护时显示「未维护」。
@@ -286,8 +286,8 @@ const MkhBomData = () => { {items.length === 0 ? (
- 该 MKH 商品在业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。 - 上游维护好该商品的组合配件后,此处会自动显示配件明细、数量与仓位。 + 该 MKH 商品在聚水潭商品 BOM、业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。 + 上游维护好该商品的正式 BOM 后,此处会自动显示配件明细、数量与仓位。
) : null} @@ -338,7 +338,7 @@ const MkhBomData = () => { 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条); - 配件明细与数量优先取自业务中台组合商品 BOM,产品资料系统数据库(PostgreSQL / furniture3d_prod)中维护的配件明细作为补充; + 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。 @@ -367,14 +367,14 @@ const MkhBomData = () => { showIcon message={data.bomRowCount > 0 ? `已接入 ${data.bomRowCount} 个模块化商品的配件 BOM` - : '业务组合 BOM 和产品资料系统数据库中尚无可用的模块化商品配件明细'} + : '聚水潭商品 BOM 及补充来源中尚无可用的模块化商品配件明细'} description={( MKH 商品编码({data.total || 0} 条)仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”; - 配件明细与数量取自业务组合 BOM({data.bomBusinessRowCount || 0} 个), - 「产品资料系统数据库」维护的 BOM({data.bomProductDbRowCount || 0} 个)作为补充。 + 配件明细优先取自聚水潭商品 BOM({data.bomOfficialRowCount || 0} 个), + 业务组合 BOM({data.bomBusinessRowCount || 0} 个)与产品资料系统({data.bomProductDbRowCount || 0} 个)作为补充。 BOM 中没有指定仓库商品档案的组合编码不会加入商品列表。 - 在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 + 上游补充正式 BOM 后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 )} /> From 006737e4aef787d8493d6a86c4d7fd5b46663b46 Mon Sep 17 00:00:00 2001 From: lunanting Date: Thu, 17 Sep 2026 09:39:25 +0800 Subject: [PATCH 09/70] fix: show MKH component warehouse locations --- backend/src/services/mkhBomService.js | 107 ++++++++++++++++++++---- backend/test/mkhBomService.test.js | 19 ++++- frontend/src/pages/MkhBomData/index.jsx | 5 +- 3 files changed, 112 insertions(+), 19 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 61d83f8..3f992de 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -39,7 +39,9 @@ // // ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo; // sku_id 可能带“-包装序号”后缀,需按基础配件编码匹配。优先显示 bin_group / bin, -// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与 wh_id(仓库)。 +// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与实际仓库名称。 +// 尚未分箱时使用 rpt.jst_inventoryInfo 的正库存仓库;连库存记录也没有时, +// 回退到 jst_sku.other_3 的商品档案仓库,并明确标注没有箱号,避免误报具体仓位。 // // ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 @@ -50,10 +52,12 @@ const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 200; const BOM_CACHE_TTL_MS = 2 * 60 * 1000; const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000; +const COMPONENT_LOCATION_CACHE_TTL_MS = 2 * 60 * 1000; let businessBomCache = null; let officialBomIndexCache = null; const officialBomDetailCache = new Map(); +const componentLocationCache = new Map(); let productBomCache = null; // MKH 商品编码 = 模块化成品 @@ -518,11 +522,12 @@ function matchesPackSku(componentCode, packSkuCode) { } function formatPackLocation(row) { + const warehouseName = toText(row.warehouseName); const warehouseId = toText(row.warehouseId); + const warehouse = warehouseName || (warehouseId ? `仓库 ${warehouseId}` : ''); const binGroup = toText(row.binGroup); const bin = toText(row.bin); const packId = toText(row.packId); - const warehouse = warehouseId ? `仓库 ${warehouseId}` : ''; const position = [binGroup, bin].filter(Boolean).join(' / ') || (packId ? `箱号 ${packId}` : ''); return [warehouse, position].filter(Boolean).join(' · '); @@ -550,6 +555,37 @@ function buildPackLocationMap(rows, componentCodes) { return result; } +function buildInventoryWarehouseMap(rows, componentCodes) { + const positiveWarehouses = new Map(componentCodes.map((code) => [code, new Set()])); + const defaultWarehouses = new Map(); + + rows.forEach((row) => { + const componentCode = componentCodes.find((code) => normalizeCode(code) === normalizeCode(row.skuId)); + if (!componentCode) return; + + const defaultWarehouse = toText(row.defaultWarehouse); + if (defaultWarehouse) defaultWarehouses.set(componentCode, defaultWarehouse); + + const warehouseName = toText(row.warehouseName); + if (warehouseName && (toNumber(row.availableQty) ?? 0) > 0) { + positiveWarehouses.get(componentCode).add(warehouseName); + } + }); + + const result = new Map(); + componentCodes.forEach((code) => { + const warehouses = [...positiveWarehouses.get(code)]; + if (warehouses.length > 0) { + result.set(code, `${warehouses.join('、')}(未分箱)`); + return; + } + + const defaultWarehouse = defaultWarehouses.get(code); + if (defaultWarehouse) result.set(code, `${defaultWarehouse}(暂无箱号)`); + }); + return result; +} + /** * 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。 * BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。 @@ -672,28 +708,54 @@ async function getMkhBomDetail(mkhSkuId) { const codes = components.map((item) => item.barcode).filter(Boolean); const stockMap = new Map(); const locationMap = new Map(); + const now = Date.now(); + const uncachedCodes = codes.filter((code) => { + const cached = componentLocationCache.get(normalizeCode(code)); + if (!cached || cached.expiresAt <= now) return true; + stockMap.set(code, cached.stock); + if (cached.location) locationMap.set(code, cached.location); + return false; + }); - if (codes.length > 0) { - const placeholders = codes.map((_, index) => `@c${index}`).join(', '); - const codeParams = Object.fromEntries(codes.map((code, index) => [`c${index}`, code])); + if (uncachedCodes.length > 0) { + const placeholders = uncachedCodes.map((_, index) => `@c${index}`).join(', '); + const codeParams = Object.fromEntries(uncachedCodes.map((code, index) => [`c${index}`, code])); + let stockQuerySucceeded = false; + let packQuerySucceeded = false; 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`, + `SELECT s.sku_id AS skuId, + s.other_3 AS defaultWarehouse, + w.name AS warehouseName, + SUM(COALESCE(i.qty, 0)) AS qty, + SUM(COALESCE(i.qty, 0) + - COALESCE(i.order_lock, 0) + - COALESCE(i.pick_lock, 0)) AS availableQty + FROM dbo.jst_sku s WITH (NOLOCK) + LEFT JOIN rpt.jst_inventoryInfo i WITH (NOLOCK) ON i.sku_id = s.sku_id + LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = i.wms_co_id + WHERE s.sku_id IN (${placeholders}) + GROUP BY s.sku_id, s.other_3, i.wms_co_id, w.name`, codeParams, { requestTimeout: 180000 } ); - stockRows.forEach((row) => stockMap.set(toText(row.skuId), toNumber(row.qty) ?? 0)); + stockRows.forEach((row) => { + const code = uncachedCodes.find((candidate) => normalizeCode(candidate) === normalizeCode(row.skuId)); + if (!code) return; + stockMap.set(code, (stockMap.get(code) || 0) + (toNumber(row.qty) ?? 0)); + }); + buildInventoryWarehouseMap(stockRows, uncachedCodes).forEach((location, code) => { + locationMap.set(code, location); + }); + stockQuerySucceeded = true; } catch (error) { console.warn('[MkhBom] 配件库存查询降级:', error.message); } try { const locationParams = {}; - const locationConditions = codes.map((code, index) => { + const locationConditions = uncachedCodes.map((code, index) => { locationParams[`lc${index}`] = code; locationParams[`lp${index}`] = `${code}-%`; return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`; @@ -701,23 +763,37 @@ async function getMkhBomDetail(mkhSkuId) { const locRows = await cloudDatabase.query( `SELECT p.sku_id AS skuId, p.pack_id AS packId, - p.wh_id AS warehouseId, + p.wms_co_id AS warehouseId, + w.name AS warehouseName, p.bin AS bin, p.bin_group AS binGroup, SUM(p.qty) AS qty - FROM rpt.jst_packInventoryInfo p + FROM rpt.jst_packInventoryInfo p WITH (NOLOCK) + LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = p.wms_co_id WHERE ${locationConditions.join(' OR ')} - GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group + GROUP BY p.sku_id, p.pack_id, p.wms_co_id, w.name, p.bin, p.bin_group HAVING SUM(p.qty) > 0`, locationParams, { requestTimeout: 120000 } ); - buildPackLocationMap(locRows, codes).forEach((location, code) => { + buildPackLocationMap(locRows, uncachedCodes).forEach((location, code) => { locationMap.set(code, location); }); + packQuerySucceeded = true; } catch (error) { console.warn('[MkhBom] 配件箱号/仓位查询降级:', error.message); } + + if (stockQuerySucceeded && packQuerySucceeded) { + const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS; + uncachedCodes.forEach((code) => { + componentLocationCache.set(normalizeCode(code), { + stock: stockMap.get(code) ?? 0, + location: locationMap.get(code) || null, + expiresAt, + }); + }); + } } const items = components.map((component, index) => ({ @@ -755,5 +831,6 @@ module.exports = { matchesPackSku, formatPackLocation, buildPackLocationMap, + buildInventoryWarehouseMap, }, }; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 93e7010..2b8d6d8 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -155,12 +155,27 @@ test('pack inventory locations match a BOM component and its package suffixes', test('pack inventory location prefers bins and falls back to positive-stock box ids', () => { const locations = _private.buildPackLocationMap([ { - skuId: 'PART-1-1', warehouseId: 1, binGroup: '成品区', bin: 'A-01', packId: 'BOX-1', qty: 2, + skuId: 'PART-1-1', warehouseId: 1, warehouseName: '半成品仓', binGroup: '成品区', bin: 'A-01', packId: 'BOX-1', qty: 2, }, { skuId: 'PART-1-2', warehouseId: 1, binGroup: null, bin: '', packId: 'BOX-2', qty: 1, }, ], ['PART-1']); - assert.equal(locations.get('PART-1'), '仓库 1 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); + assert.equal(locations.get('PART-1'), '半成品仓 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); +}); + +test('inventory warehouse fallback distinguishes unboxed stock from a warehouse with no box record', () => { + const locations = _private.buildInventoryWarehouseMap([ + { + skuId: 'PART-1', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 12, + }, + { + skuId: 'PART-2', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 0, + }, + ], ['PART-1', 'PART-2', 'PART-3']); + + assert.equal(locations.get('PART-1'), '实际库存仓(未分箱)'); + assert.equal(locations.get('PART-2'), '默认半成品仓(暂无箱号)'); + assert.equal(locations.has('PART-3'), false); }); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index c6f2b55..ee278b3 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -84,7 +84,7 @@ const PrintPreview = ({ data, onClose }) => {
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充; - 配件仓位取库位主数据,未维护时显示「未维护」。 + 配件仓位优先取包装箱与具体仓位,尚未分箱时显示库存仓库或商品档案仓库。
@@ -339,7 +339,8 @@ const MkhBomData = () => { 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条); 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; - 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。 + 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位优先显示箱号或具体仓位, + 尚未分箱时显示实际库存仓库,没有库存记录时显示商品档案仓库。 From fe831c32fd3190d9dce5a1f7137c05be0e1d0f2d Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 10:01:13 +0800 Subject: [PATCH 10/70] =?UTF-8?q?feat:=20MKH-BOM=20=E8=A1=A5=E8=B4=A7?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=E6=8E=92=E9=99=A4=E5=9C=A8=E9=80=94=E5=8A=A0?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E3=80=81=E7=BC=BA=E8=B4=A7=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E3=80=81=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补货预警:接入加工单(链接服务器 bi_processing_work_orders),有未完成加工单则不预警,新增在途加工量列 - 新增每日 09:00 缺货/预警通知推送(cron + 统一通知中心 + 幂等键) - 新增导出:商品清单 / 配件明细,XLSX / CSV,按当前筛选条件全量 - 配件仓位:新增 loadComponentLocations 批量加载器(导出/列表展开共用) --- backend/src/api/mkhBom.js | 64 ++ backend/src/index.js | 4 + backend/src/jobs/mkhReplenishAlertJob.js | 343 +++++++ backend/src/services/mkhBomService.js | 872 ++++++++++++++++-- .../src/services/mkhReplenishAlertSettings.js | 227 +++++ backend/test/mkhBomService.test.js | 296 ++++++ backend/test/mkhReplenishAlertJob.test.js | 139 +++ frontend/src/api/mkhBom.js | 31 + frontend/src/pages/MkhBomData/index.jsx | 595 +++++++++++- frontend/src/pages/MkhBomData/style.css | 129 +++ 10 files changed, 2610 insertions(+), 90 deletions(-) create mode 100644 backend/src/jobs/mkhReplenishAlertJob.js create mode 100644 backend/src/services/mkhReplenishAlertSettings.js create mode 100644 backend/test/mkhReplenishAlertJob.test.js diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index ff1978f..afe8956 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -4,7 +4,17 @@ const express = require('express'); const { getMkhBomList, getMkhBomDetail, + listMkhBomExportRows, } = require('../services/mkhBomService'); +const { + getMkhReplenishAlertAdminConfig, + resetMkhReplenishAlertState, + saveMkhReplenishAlertConfig, +} = require('../services/mkhReplenishAlertSettings'); +const { checkMkhReplenishAlerts } = require('../jobs/mkhReplenishAlertJob'); +const { + saveYydNotificationCenterConfig, +} = require('../services/yydNotificationCenterSettings'); const router = express.Router(); @@ -37,4 +47,58 @@ router.get('/detail', async (req, res) => { } }); +// 导出:当前筛选条件下的全部数据(不分页) +// mode=products(默认)→ 商品清单;mode=components → 配件明细摊平 +router.get('/export', async (req, res) => { + try { + res.json({ success: true, data: await listMkhBomExportRows(req.query || {}) }); + } catch (error) { + sendError(res, error, '导出 MKH-BOM 资料失败'); + } +}); + +// 补货预警推送设置(接收人 / 开关 / 推送范围) +router.get('/alert-config', async (req, res) => { + try { + res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() }); + } catch (error) { + sendError(res, error, '加载补货预警推送设置失败'); + } +}); + +// 保存补货预警推送设置 +router.put('/alert-config', async (req, res) => { + try { + const data = await saveMkhReplenishAlertConfig(req.body || {}); + res.json({ success: true, data }); + } catch (error) { + sendError(res, error, '保存补货预警推送设置失败'); + } +}); + +// 立即检查并推送(dryRun: 只预览不发送;force: 忽略「只推新增」推全量;reset: 先清空已推送记录) +router.post('/alert-check', async (req, res) => { + try { + const body = req.body || {}; + if (body.reset === true) await resetMkhReplenishAlertState(); + const result = await checkMkhReplenishAlerts({ + dryRun: body.dryRun === true, + force: body.force === true, + }); + res.json({ success: true, data: result }); + } catch (error) { + sendError(res, error, '执行补货预警推送失败'); + } +}); + +// 保存统一通知中心凭据(补货预警的推送通道) +router.put('/notification-center', async (req, res) => { + try { + await saveYydNotificationCenterConfig(req.body || {}); + res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() }); + } catch (error) { + sendError(res, error, '保存统一通知中心配置失败'); + } +}); + module.exports = router; diff --git a/backend/src/index.js b/backend/src/index.js index 96a2fe2..88daa64 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -50,6 +50,9 @@ const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); const { startAlibaba1688ImageSearchExpiryScheduler, } = require('./jobs/alibaba1688ImageSearchExpiryJob'); +const { + startMkhReplenishAlertScheduler, +} = require('./jobs/mkhReplenishAlertJob'); const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); const { ensureLocalSchema } = require('./services/localSchema'); const { @@ -724,6 +727,7 @@ async function startServer(options = {}) { startOpsBIncentiveSnapshotSchedulers(); startGoodsFirstSeenScheduler(); startAlibaba1688ImageSearchExpiryScheduler(); + startMkhReplenishAlertScheduler(); // Keep the first selection-pool page request from paying the remote schema // inspection round-trip over the planning database tunnel. diff --git a/backend/src/jobs/mkhReplenishAlertJob.js b/backend/src/jobs/mkhReplenishAlertJob.js new file mode 100644 index 0000000..dd1f2c1 --- /dev/null +++ b/backend/src/jobs/mkhReplenishAlertJob.js @@ -0,0 +1,343 @@ +'use strict'; + +// MKH 成品补货预警 · 定时推送 +// +// 业务口径(2026-09-17 业务确认): +// ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货) +// ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送, +// 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒 +// ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人) +// ④ 时间:每天早上 09:00(Asia/Shanghai) +// +// 被未完成加工单豁免的「已排产」商品**不进入**推送清单(口径见 mkhBomService 文件头 ⑧)。 + +const crypto = require('crypto'); +const cron = require('node-cron'); +const { + listReplenishAlertRows, +} = require('../services/mkhBomService'); +const { + getMkhReplenishAlertRuntimeConfig, + loadMkhReplenishAlertState, + resolveMkhReplenishAlertRecipient, + saveMkhReplenishAlertState, +} = require('../services/mkhReplenishAlertSettings'); +const { + isYydNotificationCenterConfigured, + sendYydNotification, +} = require('../services/yydNotificationCenterClient'); +const { + getYydNotificationCenterRuntimeConfig, +} = require('../services/yydNotificationCenterSettings'); + +const DEFAULT_CRON = '0 9 * * *'; +const TIMEZONE = 'Asia/Shanghai'; +const OUT_OF_STOCK = '缺货'; +const WARNING = '预警'; +// 正文最多列出多少个商品(超出只报数量,避免超出通知中心 4800 字上限) +const MAX_LISTED_ITEMS = 20; +const STARTUP_DELAY_MS = 45 * 1000; + +function formatQty(value) { + if (value === null || value === undefined || value === '') return '—'; + const num = Number(value); + if (!Number.isFinite(num)) return '—'; + return Number.isInteger(num) ? String(num) : String(Number(num.toFixed(2))); +} + +function formatChinaTime(value) { + const date = value instanceof Date ? value : new Date(value); + if (!Number.isFinite(date.getTime())) return '—'; + return new Intl.DateTimeFormat('zh-CN', { + timeZone: TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); +} + +/** 北京时间 YYYYMMDD —— 幂等键按「天 + 本批商品」去重 */ +function chinaDateKey(date = new Date()) { + const text = new Intl.DateTimeFormat('en-CA', { + timeZone: TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date instanceof Date ? date : new Date(date)); + return text.replace(/\D/g, ''); +} + +/** + * 对比「当前预警清单」与「已推送记录」: + * fresh 本次新出现的(需要推送) + * persisted 之前推过、目前仍在预警的(不重复推) + * restored 之前推过、现在已经不预警的(补货完成,从记录里剔除) + */ +function diffAlertState(alerts = [], state = {}) { + const notified = state?.notified && typeof state.notified === 'object' ? state.notified : {}; + const fresh = []; + const persisted = []; + alerts.forEach((row) => { + const skuId = String(row?.skuId || ''); + if (!skuId) return; + if (notified[skuId]) persisted.push(row); + else fresh.push(row); + }); + const alertIds = new Set(alerts.map((row) => String(row?.skuId || '')).filter(Boolean)); + const restored = Object.keys(notified).filter((skuId) => !alertIds.has(skuId)); + return { fresh, persisted, restored }; +} + +/** + * 生成下一版「已推送」记录:只保留当前仍在预警的商品。 + * touch=false 时不刷新 lastNotifiedAt(用于「没有新预警、本轮未真正推送」的场景)。 + */ +function buildNotifiedSnapshot(alerts = [], state = {}, nowIso, options = {}) { + const touch = options.touch !== false; + const prev = state?.notified && typeof state.notified === 'object' ? state.notified : {}; + const next = {}; + alerts.forEach((row) => { + const skuId = String(row?.skuId || ''); + if (!skuId) return; + const old = prev[skuId]; + const firstNotifiedAt = old?.firstNotifiedAt || nowIso; + next[skuId] = { + level: row.warningLevel || null, + firstNotifiedAt, + lastNotifiedAt: touch ? nowIso : (old?.lastNotifiedAt || firstNotifiedAt), + dailySale: Number(row.dailySale) || 0, + stock: Number(row.stock) || 0, + }; + }); + return next; +} + +/** 本批商品的指纹 —— 同一天内同一批商品只推一次(服务重启后重复触发也能被通知中心去重) */ +function buildAlertSignature(alerts = []) { + const parts = alerts + .map((row) => `${String(row?.skuId || '')}:${row?.warningLevel || ''}`) + .sort(); + return crypto.createHash('sha1').update(parts.join('|'), 'utf8').digest('hex').slice(0, 16); +} + +function describeAlert(row) { + const lines = [ + `**${row.warningLevel === OUT_OF_STOCK ? '🔴 缺货' : '🟠 预警'}** — \`${row.skuId}\``, + String(row.productName || '').trim() || '(无名称)', + `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | 建议补货 ${formatQty(row.replenishQty)}`, + ]; + return lines.join('\n'); +} + +function buildMkhReplenishNotification({ + alerts = [], + allAlerts = alerts, + snapshot = {}, + recipientAlias, + dateKey, +}) { + const listed = alerts.slice(0, MAX_LISTED_ITEMS); + const outOfStockCount = alerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length; + + const totalCount = allAlerts.length || alerts.length; + const totalOutOfStock = Number.isFinite(Number(snapshot.outOfStockCount)) + ? Number(snapshot.outOfStockCount) + : allAlerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length; + const totalWarningOnly = Number.isFinite(Number(snapshot.warningOnlyCount)) + ? Number(snapshot.warningOnlyCount) + : Math.max(0, totalCount - totalOutOfStock); + + const titleParts = [`MKH 成品补货预警 · 新增 ${alerts.length} 个`]; + if (outOfStockCount > 0) titleParts.push(`含 ${outOfStockCount} 个已缺货`); + + const blocks = [ + `**仓库:** 简尚-崔桥工厂品牌仓(MKH 模块化成品)`, + `**监控日:** ${snapshot.monitorDate || '—'} | 日销口径:近 ${snapshot.saleWindowDays || 7} 日 | 补货阈值:可用天数低于 ${snapshot.replenishDaysThreshold || 3} 天`, + `**本次新增:** ${alerts.length} 个${listed.length < alerts.length ? `(仅列出前 ${listed.length} 个)` : ''}`, + ]; + + listed.forEach((row, index) => { + blocks.push(`**${index + 1}.** ${describeAlert(row)}`); + }); + + blocks.push( + [ + `---`, + `当前共 **${totalCount}** 个 MKH 商品需要补货(缺货 ${totalOutOfStock} | ${snapshot.replenishDaysThreshold || 3}天内预警 ${totalWarningOnly})。`, + `建议补货合计 ${formatQty(snapshot.replenishQtyTotal)} 件。`, + snapshot.workOrderDegraded === true + ? `> ⚠️ 加工单数据源暂不可用,在途量按 0 处理,预警数量可能偏多。` + : '', + ].filter(Boolean).join('\n\n') + ); + + const actionUrl = String( + process.env.MKH_REPLENISH_ALERT_ACTION_URL + || 'https://pddg.jiansuntuzhi.cn/planning/operation/mkh-bom-data?scope=warning' + ).trim(); + + return { + idempotencyKey: `mkh_replenish_alert:${recipientAlias}:${dateKey}:${buildAlertSignature(alerts)}:v1`, + recipientAlias, + title: titleParts.join(' · ').slice(0, 100), + content: blocks.join('\n\n').slice(0, 4800), + actionTitle: '查看补货清单', + actionUrl, + }; +} + +/** + * 执行一次检查。可选参数: + * force 忽略「只推新增」,把当前全部预警商品推一次(手动触发 / 测试用) + * dryRun 只计算不发送,用于预览将推送的内容 + */ +async function checkMkhReplenishAlerts(options = {}) { + const now = options.now === undefined ? new Date() : new Date(options.now); + const nowIso = now.toISOString(); + const dryRun = options.dryRun === true; + + const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true }); + const snapshot = await listReplenishAlertRows({ scope: config.scope }); + const alerts = snapshot.rows || []; + const state = await loadMkhReplenishAlertState(); + const { fresh, persisted, restored } = diffAlertState(alerts, state); + + const summary = { + scope: snapshot.scope, + totalCount: snapshot.totalCount, + outOfStockCount: snapshot.outOfStockCount, + warningOnlyCount: snapshot.warningOnlyCount, + replenishQtyTotal: snapshot.replenishQtyTotal, + freshCount: fresh.length, + persistedCount: persisted.length, + restoredCount: restored.length, + monitorDate: snapshot.monitorDate, + workOrderDegraded: snapshot.workOrderDegraded, + }; + + if (!config.enabled) return { sent: false, reason: 'disabled', summary }; + + // 预警清空(都补完了)→ 清空已推送记录,下次再预警重新提醒 + if (!alerts.length) { + if (!dryRun) await saveMkhReplenishAlertState({ notified: {}, lastRunAt: nowIso }); + return { sent: false, reason: 'no_alert', summary }; + } + + const targets = options.force === true ? alerts : fresh; + + if (!targets.length) { + if (!dryRun) { + await saveMkhReplenishAlertState({ + notified: buildNotifiedSnapshot(alerts, state, nowIso, { touch: false }), + lastRunAt: nowIso, + }); + } + return { sent: false, reason: 'no_new_alert', summary }; + } + + const recipient = await resolveMkhReplenishAlertRecipient({ config }); + if (!recipient.alias) return { sent: false, reason: 'recipient_not_configured', summary }; + + const payload = buildMkhReplenishNotification({ + alerts: targets, + allAlerts: alerts, + snapshot, + recipientAlias: recipient.alias, + dateKey: chinaDateKey(now), + }); + + // 预览不受「通知中心是否配置」影响,方便先核对口径 + if (dryRun) { + return { + sent: false, + reason: 'dry_run', + summary, + recipient: recipient.alias, + recipientSource: recipient.source, + payload, + }; + } + + let centerConfig = null; + try { + centerConfig = await getYydNotificationCenterRuntimeConfig(); + } catch (_error) { + centerConfig = null; + } + if (!isYydNotificationCenterConfigured(centerConfig)) { + return { + sent: false, + reason: 'notification_center_not_configured', + summary, + recipient: recipient.alias, + payload, + }; + } + + const response = await sendYydNotification(payload); + await saveMkhReplenishAlertState({ + notified: buildNotifiedSnapshot(alerts, state, nowIso), + lastRunAt: nowIso, + }); + + return { + sent: true, + recipient: recipient.alias, + recipientSource: recipient.source, + newCount: targets.length, + deliveryId: response?.id || response?.data?.id || null, + summary, + }; +} + +async function runMkhReplenishAlertJob(source = 'scheduled', options = {}) { + try { + const result = await checkMkhReplenishAlerts(options); + if (result.sent) { + console.log(`[MkhReplenishAlert] ${source} 已推送 ${result.newCount} 个新增预警 → ${result.recipient}`); + } else { + console.log(`[MkhReplenishAlert] ${source} 跳过推送: ${result.reason}`); + } + return result; + } catch (error) { + console.error(`[MkhReplenishAlert] ${source} 执行失败:`, error.message || error); + return { sent: false, reason: 'error', error: error.message || String(error) }; + } +} + +function startMkhReplenishAlertScheduler() { + const disabled = String(process.env.DISABLE_MKH_REPLENISH_ALERT || '').trim().toLowerCase() === 'true'; + if (disabled) { + console.log('[Scheduler] MKH 补货预警推送已禁用'); + return; + } + const expression = String(process.env.MKH_REPLENISH_ALERT_CRON || DEFAULT_CRON).trim(); + cron.schedule(expression, () => { + runMkhReplenishAlertJob('daily_schedule').catch(() => {}); + }, { timezone: TIMEZONE }); + + const startupTimer = setTimeout(() => { + runMkhReplenishAlertJob('startup').catch(() => {}); + }, STARTUP_DELAY_MS); + if (typeof startupTimer.unref === 'function') startupTimer.unref(); + + console.log(`[Scheduler] MKH 补货预警推送已注册: ${expression} (${TIMEZONE})`); +} + +module.exports = { + DEFAULT_CRON, + MAX_LISTED_ITEMS, + TIMEZONE, + buildAlertSignature, + buildMkhReplenishNotification, + buildNotifiedSnapshot, + checkMkhReplenishAlerts, + chinaDateKey, + diffAlertState, + formatQty, + runMkhReplenishAlertJob, + startMkhReplenishAlertScheduler, +}; diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 61d83f8..0c15965 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -37,11 +37,65 @@ // payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同 // (无 SKU 条码),也不能直接当配件 BOM。 // -// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo; -// sku_id 可能带“-包装序号”后缀,需按基础配件编码匹配。优先显示 bin_group / bin, -// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与 wh_id(仓库)。 +// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) +// sku_id 可能带“-装箱序号”后缀(DNZ0019110N08V01-27),按基础配件编码匹配; +// 仓库取 jst_warehouseInfo.name(wh_id → Id);有真货位(bin_group / bin)就显示货位, +// 没有则退回「仓库 · 箱号 pack_id」,两者都取不到时前端显示「未维护」。 +// · 列表展开(/detail)与导出配件明细共用 loadComponentLocations(),按编码缓存 10 分钟。 +// ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱** +// (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、 +// wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**,`wms_co_id` 全 NULL。 +// 且表内容与 MKH 配件**零交集**:正式 BOM 的 784 个配件编码(DNZ0398xx / DNZ0409xx, +// jst_sku.item_type=成品)在该表一个都查不到;表里存的是老型号半成品 +// (DNZ0134xx / DNZ0199xx / DNZ0203xx,item_type=半成品,名称如 +// 「G4号;云霜灰色+珍珠白色抽面;100/60/71.2」)。 +// 结论:MKH 配件在**任何已接入数据源**里都没有货位/装箱数据 +// (jst_sku.bin / other_bin 全库 558,674 行全空;ERP 主库 JianSun.jst_packInventoryInfo +// 同源同量;产品资料库 external.warehouse_locations_v1 仅 1 行)。 +// → 仓位列只能显示「未维护」,需业务在聚水潭为配件维护装箱/货位后才会自动出现。 // // ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 +// +// ── ⑦ 补货预警(2026-09-17 业务确认口径)────────────────────────── +// 规则:近 7 日日销 > 0 且 现有库存 < 3 天日销 → 预警补货 +// · 近7日日销 rpt.pm_stockMonitor.sevenAvgSale +// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照) +// · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; +// 该仓对 MKH 成品只覆盖约 256 / 4408 条,缺失时回退监控快照 jsStock +// · 可用天数 = 现有库存 / 近7日日销 +// · 建议补货量 = ceil(3 × 近7日日销 − 现有库存),不足 0 取 0 +// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / sevenAvgSale 不同源 +// (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67, +// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近7日日销」为准。 +// 实测(2026-09-16 快照):4408 个 MKH 成品中 114 个有日销、13 个可用天数 < 3 天。 +// +// ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── +// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** +// (工厂已经在做了,不重复提示补货),改标为「已排产」。 +// · 数据源:业务库同实例的链接服务器 +// LS_JS_DMP → JSDataMiddlePlatform.dbo.bi_processing_work_orders +// (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表) +// · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓' +// AND 状态 <> '作废' ← 作废单据一律不核算 +// AND 收货状态 <> '全部入库' ← 未完成(全部入库 ⟺ 状态=完成) +// · 在途加工量 = Σ(数量 − 已入库数量),按 商品编码 归集 +// ⚠️ 为什么走 getPool 而不是 cloudDatabase.query: +// query() 会经 rewriteReportReplicaSql() 把 `dbo.xxx` 重写成 `rpt.xxx`, +// 跨库四段名 `LS_JS_DMP.JSDataMiddlePlatform.dbo.t` 会被改坏成 +// `LS_JS_DMP.JSDataMiddlePlatform.rpt.t`。getPool() 不经过该重写。 +// 实测(2026-09-16):全表 679 行,其中 完成 498(全部入库)/ 作废 176 / +// 已确认 3 / 待审核 2;属半成品包装仓且未完成且非作废的只有 5 单 40 件。 +// ⚠️ 作废单在半成品包装仓有 79 单 740 件,若不过滤会虚增约 18 倍在途量。 +// +// ── ⑨ 导出(2026-09-17)────────────────────────────────────────── +// 导出与列表**共用同一套筛选/排序口径**(filterRows + sortRows),区别只有两点: +// · 不分页 —— 返回当前筛选条件下的全部行,而不是当前页 +// · 两种形态 —— products(商品清单,列同页面表格)/ components(配件明细摊平) +// 官方 BOM 明细按 SKU 分批读取(单批上限 MAX_PAGE_SIZE=200,见 loadOfficialBomForSkuBatches), +// 只对筛选结果取数,因此「仅预警」这类小范围导出只需 1 批查询; +// 每批结果都进 officialBomDetailCache,重复导出直接命中缓存。 +// ⚠️ products 形态**不返回 components** —— 4408 行商品若都挂上配件明细, +// 响应会膨胀到几十 MB,而商品清单本身用不到明细。 const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -50,16 +104,48 @@ const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 200; const BOM_CACHE_TTL_MS = 2 * 60 * 1000; const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000; +// 配件仓位(箱及仓位库)按编码缓存;单批编码数受 MSSQL 单语句 2100 参数上限约束 +// (每个编码占 2 个参数:精确匹配 + 带后缀前缀匹配),300 × 2 = 600 个参数,安全。 +const COMPONENT_LOCATION_CACHE_TTL_MS = 10 * 60 * 1000; +const COMPONENT_LOCATION_CHUNK_SIZE = 300; let businessBomCache = null; let officialBomIndexCache = null; const officialBomDetailCache = new Map(); let productBomCache = null; +const componentLocationCache = new Map(); // MKH 商品编码 = 模块化成品 const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓'; const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品' AND s.other_3 = N'${DESIGNATED_WAREHOUSE}'`; +// ── 补货预警参数(见文件头 ⑦)──────────────────────────────────── +// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得) +const BRAND_WMS_CO_ID = 15870495; +const SALE_WINDOW_DAYS = 7; +// 预警阈值:现有库存可用天数低于 3 天即需补货 +const REPLENISH_DAYS_THRESHOLD = 3; + +const WARNING_LEVEL = Object.freeze({ + OUT_OF_STOCK: '缺货', + WARNING: '预警', + SCHEDULED: '已排产', + NORMAL: '正常', + NO_SALE: '无销量', +}); + +// ── 在途加工单参数(见文件头 ⑧)────────────────────────────────── +// 加工仓储方:加工单的加工地,即半成品包装项目仓(产出入品牌仓) +const WORK_ORDER_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓'; +// 作废状态:作废单据不参与在途量核算 +const WORK_ORDER_VOID_STATUS = '作废'; +// 收货状态:该值表示已全部入库,等价于「已完成」 +const WORK_ORDER_RECEIVED_STATUS = '全部入库'; +// 加工单表位于同一实例的链接服务器上,四段名必须走 getPool 查询(原因见文件头 ⑧) +const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_orders'; +// 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量 +const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000; + const toText = (value) => { if (value === null || value === undefined) return null; const text = String(value).trim(); @@ -72,24 +158,104 @@ const toNumber = (value) => { return Number.isFinite(parsed) ? parsed : null; }; +// 日期转 YYYY-MM-DD(按本地时区),用于监控日展示 +const toDateText = (value) => { + if (value === null || value === undefined || value === '') return null; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +}; + // 编码归一化:去横线/空格 + 大写,用于跨库匹配 const normalizeCode = (value) => { const text = toText(value); return text ? text.toUpperCase().replace(/[-\s]/g, '') : null; }; -const buildFilters = (query = {}) => { - const conditions = []; - const params = {}; +// ── 补货预警:进程内索引(见文件头 ⑦)──────────────────────────── +// ⚠️ 为什么不在 SQL 里关联: +// 实测(2026-09-17)在 ORDER BY / CASE 中对 OUTER APPLY 的列做 DECIMAL 算术, +// 会让 SQL Server 选到灾难性执行计划 —— +// · 按「可用天数」排序:128.8 秒 +// · 统计预警条数(CASE 内引用 apply 列):97.1 秒 +// · 同结构但只 COUNT:0.098 秒 +// 而「一次全量取数 + 应用层计算」只要约 0.2 秒。 +// 故 MKH 商品(4,408 条)与监控快照(4,411 行)一次性读入进程内索引, +// 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。 +const REPLENISH_PARAMS = Object.freeze({ + brandWarehouse: DESIGNATED_WAREHOUSE, + brandWmsCoId: BRAND_WMS_CO_ID, +}); +// 商品集合是主数据、监控快照为日更,10 分钟缓存足够 +const MKH_CACHE_TTL_MS = 10 * 60 * 1000; +let mkhProductCache = null; +let replenishIndexCache = null; +// 加工单在途量索引(见文件头 ⑧) +let workOrderIndexCache = null; - const keyword = toText(query.keyword); - if (keyword) { - params.keyword = `%${keyword}%`; - conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)'); +const TRUTHY_FLAGS = new Set(['1', 'true', 'yes', 'on']); + +const parseFlag = (value) => TRUTHY_FLAGS.has(String(value ?? '').trim().toLowerCase()); + +// 列表展示范围(内存过滤) +const SCOPES = Object.freeze({ WARNING: 'warning', SOLD: 'sold', ALL: 'all' }); + +/** + * 解析展示范围: + * warning —— 仅需补货(近7日日销 > 0 且 可用天数 < 3 天) + * sold —— 有近7日日销的商品 + * all —— 全部 MKH 成品 + * 兼容旧参数 warningOnly=1(等价 scope=warning)。 + */ +function resolveScope(query = {}) { + const raw = String(query.scope ?? '').trim().toLowerCase(); + if (raw === SCOPES.WARNING || raw === SCOPES.SOLD || raw === SCOPES.ALL) return raw; + if (parseFlag(query.warningOnly)) return SCOPES.WARNING; + return SCOPES.ALL; +} + +const matchesKeyword = (row, keyword) => { + if (!keyword) return true; + const needle = keyword.toLowerCase(); + return String(row.skuId || '').toLowerCase().includes(needle) + || String(row.productName || '').toLowerCase().includes(needle); +}; + +function filterRows(rows, { keyword, scope }) { + return rows.filter((row) => { + if (scope === SCOPES.WARNING && !row.hasWarning) return false; + if (scope === SCOPES.SOLD && !(toNumber(row.dailySale) > 0)) return false; + return matchesKeyword(row, keyword); + }); +} + +/** + * 排序:stockDays(可用天数)/ skuId。 + * ⚠️ 无销量的商品 stockDays 为 null,无论升降序都恒定排在最后, + * 否则它们会占据「最紧急」的首屏位置。 + */ +function sortRows(rows, field, order) { + const direction = order === 'DESC' ? -1 : 1; + const bySku = (a, b) => String(a.skuId || '').localeCompare(String(b.skuId || '')); + const list = [...rows]; + + if (field === 'stockDays') { + list.sort((a, b) => { + const av = toNumber(a.stockDays); + const bv = toNumber(b.stockDays); + if (av === null && bv === null) return bySku(a, b); + if (av === null) return 1; + if (bv === null) return -1; + if (av !== bv) return (av - bv) * direction; + return bySku(a, b); + }); + return list; } - return { conditions, params }; -}; + list.sort((a, b) => bySku(a, b) * direction); + return list; +} /** * 把 componentDetails 原始 JSON 解析成统一结构。 @@ -510,6 +676,332 @@ function summarizeConfiguredBoms(officialBom, businessBom, productBom) { const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0); +/** + * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 + * + * 口径:近7日日销 > 0 且 现有库存 < 3 天日销 → 预警。 + * 无近7日日销的商品不参与预警,标记为「无销量」。 + * + * @param {object} row + * row.sevenAvgSale 近7日日均发货量 + * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) + * row.monitorStock 监控快照库存(fallback) + * row.safetyDays 源系统安全天数(仅展示) + * row.suggestStock 源系统建议备货量(仅展示对照) + * row.monitorDate 监控快照日期 + */ +function buildReplenishMetrics(row = {}) { + const dailySale = Math.max(0, toNumber(row.sevenAvgSale) ?? 0); + const realtimeStock = toNumber(row.realtimeStock); + const monitorStock = toNumber(row.monitorStock) ?? 0; + const hasRealtime = realtimeStock !== null; + const stock = hasRealtime ? realtimeStock : monitorStock; + + // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 + const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0); + const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0); + const hasPendingWorkOrder = pendingWorkOrderQty > 0 || pendingWorkOrderCount > 0; + + const base = { + dailySale, + stock, + pendingWorkOrderQty, + pendingWorkOrderCount, + hasPendingWorkOrder, + pendingWorkOrderEta: toDateText(row.pendingWorkOrderEta), + pendingWorkOrderNo: toText(row.pendingWorkOrderNo), + thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)), + stockSource: hasRealtime ? 'realtime' : 'monitor', + safetyDays: toNumber(row.safetyDays), + suggestStock: toNumber(row.suggestStock) ?? 0, + monitorDate: toDateText(row.monitorDate), + }; + + if (dailySale <= 0) { + return { + ...base, + thresholdQty: 0, + stockDays: null, + replenishQty: 0, + warningLevel: WARNING_LEVEL.NO_SALE, + warningOrder: 5, + hasWarning: false, + }; + } + + const stockDays = stock / dailySale; + + // 已有未完成加工单 → 不再列入补货预警(业务口径,见文件头 ⑧) + if (hasPendingWorkOrder) { + return { + ...base, + stockDays: Number(stockDays.toFixed(2)), + replenishQty: 0, + warningLevel: WARNING_LEVEL.SCHEDULED, + warningOrder: 4, + hasWarning: false, + }; + } + + const replenishQty = Math.max(0, Math.ceil(base.thresholdQty - stock)); + const level = stockDays <= 0 + ? WARNING_LEVEL.OUT_OF_STOCK + : (stockDays < REPLENISH_DAYS_THRESHOLD ? WARNING_LEVEL.WARNING : WARNING_LEVEL.NORMAL); + + return { + ...base, + stockDays: Number(stockDays.toFixed(2)), + replenishQty, + warningLevel: level, + // 排序权重:缺货 1 < 预警 2 < 正常 3 < 已排产 4 < 无销量 5 + warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1 : (level === WARNING_LEVEL.WARNING ? 2 : 3), + hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING, + }; +} + +/** + * MKH 商品集合(指定仓库的模块化成品)—— 进程内缓存。 + * 返回 [{ skuId, productName, category, material }],按编码升序。 + */ +async function loadMkhProductIndex() { + const now = Date.now(); + if (mkhProductCache?.value && mkhProductCache.expiresAt > now) { + return mkhProductCache.value; + } + if (mkhProductCache?.pending) return mkhProductCache.pending; + + const pending = cloudDatabase.query( + `SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category, s.other_10 AS material + FROM dbo.jst_sku s + WHERE ${MKH_WHERE} + ORDER BY s.sku_id ASC`, + {}, + { requestTimeout: 180000 } + ).then((rows) => rows.map((row) => ({ + skuId: toText(row.skuId), + productName: toText(row.productName), + category: toText(row.category), + material: toText(row.material), + })).filter((row) => row.skuId)); + + mkhProductCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + return value; + } catch (error) { + mkhProductCache = null; + throw error; + } +} + +/** + * 未完成加工单(在途量)索引 —— 进程内缓存 { bySku: Map<归一化编码, 在途指标>, degraded }。 + * 只保留「半成品包装项目仓加工、未完成、非作废」的加工单,口径见文件头 ⑧。 + * ⚠️ 该数据源是链接服务器,取不到时**降级为空索引**(补货预警照常跑), + * 并缩短缓存避免长时间带着错误在途量。degraded=true 会被透出到接口。 + */ +async function loadPendingWorkOrderIndex() { + const now = Date.now(); + if (workOrderIndexCache?.value && workOrderIndexCache.expiresAt > now) { + return workOrderIndexCache.value; + } + if (workOrderIndexCache?.pending) return workOrderIndexCache.pending; + + const pending = (async () => { + // ⚠️ 必须走 getPool:cloudDatabase.query() 会把 `dbo.xxx` 重写成 `rpt.xxx`, + // 跨库四段名会被改坏(见文件头 ⑧)。 + const pool = await cloudDatabase.getPool(); + const request = pool.request(); + request.input('workOrderWarehouse', WORK_ORDER_WAREHOUSE); + request.input('voidStatus', WORK_ORDER_VOID_STATUS); + request.input('receivedStatus', WORK_ORDER_RECEIVED_STATUS); + + const result = await request.query(` + SELECT [商品编码] AS skuId, + SUM(CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))) AS pendingQty, + COUNT(DISTINCT [加工单号]) AS orderCount, + MIN(TRY_CONVERT(DATETIME, [预计加工完成时间], 111)) AS eta, + MIN(CAST([加工单号] AS NVARCHAR(40))) AS firstOrderNo + FROM ${WORK_ORDER_TABLE} WITH (NOLOCK) + WHERE [加工仓储方] = @workOrderWarehouse + AND [状态] <> @voidStatus + AND [收货状态] <> @receivedStatus + GROUP BY [商品编码]`); + + const bySku = new Map(); + (result.recordset || []).forEach((row) => { + const key = normalizeCode(row.skuId); + if (!key) return; + const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0); + const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0); + if (pendingQty <= 0 && orderCount <= 0) return; + bySku.set(key, { + pendingWorkOrderQty: pendingQty, + pendingWorkOrderCount: orderCount, + pendingWorkOrderEta: row.eta instanceof Date ? row.eta : null, + pendingWorkOrderNo: toText(row.firstOrderNo), + }); + }); + return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; + })().catch((error) => { + console.warn('[MKH-BOM] 加工单在途量读取失败,已按「无在途加工单」降级:', error.message); + return { bySku: new Map(), degraded: true, error: error.message, ttl: WORK_ORDER_DEGRADED_TTL_MS }; + }); + + workOrderIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + return value; + } catch (error) { + workOrderIndexCache = null; + throw error; + } +} + +/** + * 补货预警索引 —— 进程内缓存 { bySku: Map<归一化编码, 指标>, monitorDate }。 + * 一次读入品牌仓监控快照与实时库存,指标计算全部在应用层完成(原因见文件头 ⑦)。 + */ +async function loadReplenishIndex() { + const now = Date.now(); + if (replenishIndexCache?.value && replenishIndexCache.expiresAt > now) { + return replenishIndexCache.value; + } + if (replenishIndexCache?.pending) return replenishIndexCache.pending; + + const pending = Promise.all([ + // 监控快照:同一天只有一行,按 date DESC 取首条即可 + cloudDatabase.query( + `SELECT m.sku_id AS skuId, m.sevenAvgSale AS sevenAvgSale, m.jsStock AS monitorStock, + m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, m.date AS monitorDate + FROM dbo.pm_stockMonitor m + WHERE m.warehouse = @brandWarehouse AND m.sku_id LIKE N'MKH%' + ORDER BY m.sku_id ASC, m.date DESC`, + REPLENISH_PARAMS, + { requestTimeout: 180000 } + ), + // 实时库存:同一 SKU 可能多行,按 updateTime DESC 取首条 + cloudDatabase.query( + `SELECT i.sku_id AS skuId, i.qty AS qty + FROM dbo.jst_inventoryInfo i + WHERE i.wms_co_id = @brandWmsCoId AND i.sku_id LIKE N'MKH%' + ORDER BY i.sku_id ASC, i.updateTime DESC`, + REPLENISH_PARAMS, + { requestTimeout: 180000 } + ), + // 在途加工单(见文件头 ⑧) + loadPendingWorkOrderIndex(), + ]).then(([monitorRows, stockRows, workOrderIndex]) => { + const workOrderBySku = workOrderIndex.bySku; + const latestStock = new Map(); + stockRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty)); + }); + + const seenMonitor = new Set(); + const bySku = new Map(); + let monitorDate = null; + monitorRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (!key || seenMonitor.has(key)) return; + seenMonitor.add(key); + if (!monitorDate) monitorDate = toDateText(row.monitorDate); + bySku.set(key, buildReplenishMetrics({ + sevenAvgSale: row.sevenAvgSale, + // 实时库存缺失(而非为 0)时才回退监控快照 + realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, + monitorStock: row.monitorStock, + safetyDays: row.safetyDays, + suggestStock: row.suggestStock, + monitorDate: row.monitorDate, + ...(workOrderBySku.get(key) || null), + })); + }); + + return { + bySku, + workOrderBySku, + workOrderDegraded: workOrderIndex.degraded === true, + monitorDate, + }; + }); + + replenishIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + return value; + } catch (error) { + replenishIndexCache = null; + throw error; + } +} + +/** + * MKH 商品 × 库存/日销指标 × 在途加工单(内存合并)。 + * 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。 + */ +async function buildMergedReplenishRows() { + const [products, replenishIndex] = await Promise.all([ + loadMkhProductIndex(), + loadReplenishIndex(), + ]); + + const rows = products.map((product) => { + const key = normalizeCode(product.skuId); + const metrics = replenishIndex.bySku.get(key); + if (metrics) return { ...product, ...metrics }; + // 监控快照里没有该商品时,仍要带上在途加工单信息(见文件头 ⑧) + return { + ...product, + ...buildReplenishMetrics(replenishIndex.workOrderBySku.get(key) || {}), + }; + }); + + return { rows, replenishIndex }; +} + +/** + * 补货预警清单(全量、不分页)—— 供定时推送任务使用。 + * scope = 'warning'(默认)→ 缺货 + 3 天内预警 + * scope = 'outOfStock' → 仅缺货 + * ⚠️ 返回的是「需要补货」的商品,不包含已被未完成加工单豁免的「已排产」商品。 + */ +async function listReplenishAlertRows(options = {}) { + const scope = toText(options.scope); + const levels = scope === 'outOfStock' + ? [WARNING_LEVEL.OUT_OF_STOCK] + : [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING]; + + const { rows, replenishIndex } = await buildMergedReplenishRows(); + const alerts = rows.filter((row) => levels.includes(row.warningLevel)); + + // 最紧急的排前面:缺货 → 可用天数最少 → 编码升序 + alerts.sort((a, b) => ( + (a.warningOrder || 9) - (b.warningOrder || 9) + || ((toNumber(a.stockDays) ?? 0) - (toNumber(b.stockDays) ?? 0)) + || String(a.skuId).localeCompare(String(b.skuId)) + )); + + return { + rows: alerts, + totalCount: alerts.length, + outOfStockCount: alerts.filter((row) => row.warningLevel === WARNING_LEVEL.OUT_OF_STOCK).length, + warningOnlyCount: alerts.filter((row) => row.warningLevel === WARNING_LEVEL.WARNING).length, + replenishQtyTotal: Number( + alerts.reduce((sum, row) => sum + (toNumber(row.replenishQty) || 0), 0).toFixed(2) + ), + monitorDate: replenishIndex.monitorDate, + workOrderDegraded: replenishIndex.workOrderDegraded === true, + replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, + saleWindowDays: SALE_WINDOW_DAYS, + scope: scope === 'outOfStock' ? 'outOfStock' : 'warning', + }; +} + function matchesPackSku(componentCode, packSkuCode) { const component = toText(componentCode)?.toUpperCase(); const packSku = toText(packSkuCode)?.toUpperCase(); @@ -518,11 +1010,14 @@ function matchesPackSku(componentCode, packSkuCode) { } function formatPackLocation(row) { + const warehouseName = toText(row.warehouseName); const warehouseId = toText(row.warehouseId); const binGroup = toText(row.binGroup); const bin = toText(row.bin); const packId = toText(row.packId); - const warehouse = warehouseId ? `仓库 ${warehouseId}` : ''; + // 仓库优先显示名称(wh_id → jst_warehouseInfo.name),取不到时退回 id + const warehouse = warehouseName || (warehouseId ? `仓库 ${warehouseId}` : ''); + // 有真货位(bin_group / bin)才叫「仓位」;否则退回箱号 const position = [binGroup, bin].filter(Boolean).join(' / ') || (packId ? `箱号 ${packId}` : ''); return [warehouse, position].filter(Boolean).join(' · '); @@ -550,6 +1045,72 @@ function buildPackLocationMap(rows, componentCodes) { return result; } +/** + * 配件仓位批量查询(列表展开 / 单品明细 / 导出配件明细共用)。 + * 数据源:rpt.jst_packInventoryInfo(箱及仓位库)+ rpt.jst_warehouseInfo(仓名) + * · 按基础配件编码匹配,兼容库内 sku_id 带「-装箱序号」后缀的形态 + * · 单个配件跨多个箱时最多列出 10 个位置,超出补「等 N 个位置」 + * · 结果按编码缓存(TTL 10 分钟);查询失败按 null 降级且不写缓存,下次重试 + */ +async function loadComponentLocations(codes = []) { + const now = Date.now(); + const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); + const result = new Map(); + const missing = []; + unique.forEach((code) => { + const hit = componentLocationCache.get(code); + if (hit && hit.expiresAt > now) result.set(code, hit.location); + else missing.push(code); + }); + if (missing.length === 0) return result; + + const chunks = []; + for (let i = 0; i < missing.length; i += COMPONENT_LOCATION_CHUNK_SIZE) { + chunks.push(missing.slice(i, i + COMPONENT_LOCATION_CHUNK_SIZE)); + } + + // 组内串行(单个接口最多同时打两条查询,避免拖垮连接池) + for (const chunk of chunks) { + try { + const params = {}; + const conditions = chunk.map((code, index) => { + params[`lc${index}`] = code; + params[`lp${index}`] = `${code}-%`; + return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`; + }); + const rows = await cloudDatabase.query( + `SELECT p.sku_id AS skuId, + p.pack_id AS packId, + p.wh_id AS warehouseId, + p.bin AS bin, + p.bin_group AS binGroup, + w.name AS warehouseName, + SUM(p.qty) AS qty + FROM rpt.jst_packInventoryInfo p WITH (NOLOCK) + LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.Id = p.wh_id + WHERE ${conditions.join(' OR ')} + GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group, w.name + HAVING SUM(p.qty) > 0`, + params, + { requestTimeout: 120000 } + ); + const byCode = buildPackLocationMap(rows, chunk); + const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS; + chunk.forEach((code) => { + const location = byCode.get(code) || null; + componentLocationCache.set(code, { location, expiresAt }); + result.set(code, location); + }); + } catch (error) { + console.warn('[MkhBom] 配件仓位查询降级:', error.message); + chunk.forEach((code) => { + if (!result.has(code)) result.set(code, null); + }); + } + } + return result; +} + /** * 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。 * BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。 @@ -558,53 +1119,52 @@ 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 keyword = toText(query.keyword); + const scope = resolveScope(query); + // 默认按可用天数升序 —— 最紧急的排最前 + const sortField = String(query.sortField || 'stockDays'); const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - const orderSql = sortField === 'skuId' ? `s.sku_id ${sortOrder}` : `s.sku_id ASC`; - const offset = (page - 1) * pageSize; - const [businessRows, totalRows, officialBomIndex, businessBom, productBom] = await Promise.all([ - 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 ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`, - params, - { requestTimeout: 180000 } - ), - cloudDatabase.query( - `SELECT COUNT_BIG(*) AS total ${whereSql}`, - params, - { requestTimeout: 180000 } - ), + const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([ + buildMergedReplenishRows(), loadOfficialBomIndex(), loadBusinessBom(), loadPgBom(), ]); - const officialBom = await loadOfficialBomForSkus(businessRows.map((row) => row.skuId)); + // 商品 × 库存/日销指标 × 在途加工单(内存合并,与补货预警推送共用) + const { rows: merged, replenishIndex } = mergedResult; + + // 全量统计(不受关键词 / 范围筛选影响) + const warningRows = merged.filter((row) => row.hasWarning); + const stats = { + mkhTotalCount: merged.length, + soldCount: merged.filter((row) => toNumber(row.dailySale) > 0).length, + warningCount: warningRows.length, + outOfStockCount: warningRows.filter((row) => row.warningLevel === WARNING_LEVEL.OUT_OF_STOCK).length, + // 已排产:有未完成加工单、按业务口径不再预警的商品数(见文件头 ⑧) + scheduledCount: merged.filter((row) => row.warningLevel === WARNING_LEVEL.SCHEDULED).length, + pendingWorkOrderSkuCount: merged.filter((row) => row.hasPendingWorkOrder).length, + pendingWorkOrderQtyTotal: Number( + merged.reduce((sum, row) => sum + (toNumber(row.pendingWorkOrderQty) || 0), 0).toFixed(2) + ), + monitorDate: replenishIndex.monitorDate, + }; + + // 筛选 → 排序 → 分页(全部在内存完成,见文件头 ⑦) + const filtered = filterRows(merged, { keyword, scope }); + const sorted = sortRows(filtered, sortField, sortOrder); + const offset = (page - 1) * pageSize; + const pageRows = sorted.slice(offset, offset + pageSize); + + // 只对当前页补 BOM 明细,避免全量扫描 + const officialBom = await loadOfficialBomForSkus(pageRows.map((row) => row.skuId)); const bomSummary = summarizeConfiguredBoms(officialBomIndex, businessBom, productBom); - const [totalRow] = totalRows; - const mapped = businessRows.map((row) => { - const skuId = toText(row.skuId); - const matched = matchBomSources(officialBom, businessBom, productBom, skuId); + const rows = pageRows.map((row) => { + const matched = matchBomSources(officialBom, businessBom, productBom, row.skuId); const components = matched?.components || []; - return { - skuId, - productName: toText(row.productName), - category: toText(row.category), - material: toText(row.material), + ...row, accessoryCount: components.length, accessoryQtySum: sumQty(components), bomConfigured: components.length > 0 ? '已配置' : '未配置', @@ -615,8 +1175,8 @@ async function getMkhBomList(query = {}) { }); return { - rows: mapped, - total: Number(totalRow?.total || 0), + rows, + total: sorted.length, bomRowCount: bomSummary.total, bomOfficialRowCount: bomSummary.official, bomBusinessRowCount: bomSummary.business, @@ -625,7 +1185,15 @@ async function getMkhBomList(query = {}) { bomReady: bomSummary.total > 0, page, pageSize, - configuredInPage: mapped.filter((row) => row.accessoryCount > 0).length, + scope, + configuredInPage: rows.filter((row) => row.accessoryCount > 0).length, + ...stats, + replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, + saleWindowDays: SALE_WINDOW_DAYS, + // 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理 + workOrderDegraded: replenishIndex.workOrderDegraded === true, + workOrderWarehouse: WORK_ORDER_WAREHOUSE, + warningInPage: rows.filter((row) => row.hasWarning).length, }; } @@ -692,27 +1260,9 @@ async function getMkhBomDetail(mkhSkuId) { } try { - const locationParams = {}; - const locationConditions = codes.map((code, index) => { - locationParams[`lc${index}`] = code; - locationParams[`lp${index}`] = `${code}-%`; - return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`; - }); - const locRows = await cloudDatabase.query( - `SELECT p.sku_id AS skuId, - p.pack_id AS packId, - p.wh_id AS warehouseId, - p.bin AS bin, - p.bin_group AS binGroup, - SUM(p.qty) AS qty - FROM rpt.jst_packInventoryInfo p - WHERE ${locationConditions.join(' OR ')} - GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group - HAVING SUM(p.qty) > 0`, - locationParams, - { requestTimeout: 120000 } - ); - buildPackLocationMap(locRows, codes).forEach((location, code) => { + // 与列表展开 / 导出配件明细走同一个批量加载器(含仓名解析与 10 分钟缓存) + const located = await loadComponentLocations(codes); + located.forEach((location, code) => { locationMap.set(code, location); }); } catch (error) { @@ -742,9 +1292,164 @@ async function getMkhBomDetail(mkhSkuId) { }; } +// ── ⑨ 导出 ──────────────────────────────────────────────────────── +const EXPORT_MODES = Object.freeze({ + PRODUCTS: 'products', + COMPONENTS: 'components', +}); + +/** + * 分批读取正式 BOM 明细。 + * loadOfficialBomForSkus() 单次只处理 MAX_PAGE_SIZE 个 SKU,导出全量时必须自行分批; + * 组内并发、组间串行,避免一次打满 mssql 连接池。 + * 每批结果都会写入 officialBomDetailCache,同一天重复导出直接命中缓存、不再查库。 + */ +async function loadOfficialBomForSkuBatches(skuIds = [], concurrency = 4) { + const unique = Array.from(new Map( + skuIds + .map((skuId) => [normalizeCode(skuId), toText(skuId)]) + .filter(([key, value]) => key && value) + ).values()); + const bySku = new Map(); + if (!unique.length) return { bySku, bySpuPrefix: [], entries: [] }; + + const batches = []; + for (let i = 0; i < unique.length; i += MAX_PAGE_SIZE) { + batches.push(unique.slice(i, i + MAX_PAGE_SIZE)); + } + + for (let i = 0; i < batches.length; i += concurrency) { + const group = batches.slice(i, i + concurrency); + // eslint-disable-next-line no-await-in-loop -- 有意分组串行:组内并发 4 路,避免打满连接池 + const parts = await Promise.all(group.map((batch) => loadOfficialBomForSkus(batch))); + parts.forEach((part) => { + part.bySku.forEach((components, key) => bySku.set(key, components)); + }); + } + + return { bySku, bySpuPrefix: [], entries: [] }; +} + +/** + * 商品清单导出行(纯函数,便于回归测试)。 + * 列口径与页面表格保持一致,只是去掉「操作」列、把 null 统一成可读值。 + */ +function buildProductExportRows(matchedRows = []) { + return matchedRows.map(({ row, components = [] }) => ({ + skuId: toText(row.skuId), + productName: toText(row.productName), + warningLevel: toText(row.warningLevel), + dailySale: toNumber(row.dailySale) ?? 0, + stock: toNumber(row.stock) ?? 0, + pendingWorkOrderQty: toNumber(row.pendingWorkOrderQty) ?? 0, + stockDays: toNumber(row.stockDays), + replenishQty: toNumber(row.replenishQty) ?? 0, + bomConfigured: components.length > 0 ? '已配置' : '未配置', + accessoryCount: components.length, + accessoryQtySum: sumQty(components), + })); +} + +/** + * 配件明细导出行(纯函数)—— 按打印模板摊平:一行一个配件。 + * ⚠️ 同一商品的 MKH 编码逐行重复、不做纵向合并:合并单元格会让 Excel 筛选/透视失效, + * 业务拿这份数据多半是继续加工,扁平结构更合用。 + */ +function buildComponentExportRows(matchedRows = []) { + const rows = []; + matchedRows.forEach(({ row, components = [] }) => { + components.forEach((component, index) => { + rows.push({ + skuId: toText(row.skuId), + productName: toText(row.productName), + index: index + 1, + componentBarcode: toText(component.barcode || component.code), + componentName: toText(component.name), + qty: toNumber(component.qty) ?? 0, + location: toText(component.location), + }); + }); + }); + return rows; +} + +/** + * 导出:当前筛选条件下的全部行(不分页),见文件头 ⑨。 + * mode = products(默认)→ 商品清单;mode = components → 配件明细摊平 + */ +async function listMkhBomExportRows(query = {}) { + const requestedMode = String(query.mode || '').trim().toLowerCase(); + const mode = requestedMode === EXPORT_MODES.COMPONENTS + ? EXPORT_MODES.COMPONENTS + : EXPORT_MODES.PRODUCTS; + const keyword = toText(query.keyword); + const scope = resolveScope(query); + const sortField = String(query.sortField || 'stockDays'); + const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + // 与列表接口完全相同的筛选 / 排序口径:先全量合并,再内存筛选排序(见文件头 ⑦) + const { rows: merged, replenishIndex } = await buildMergedReplenishRows(); + const sorted = sortRows(filterRows(merged, { keyword, scope }), sortField, sortOrder); + + const [officialBom, businessBom, productBom] = await Promise.all([ + loadOfficialBomForSkuBatches(sorted.map((row) => row.skuId)), + loadBusinessBom(), + loadPgBom(), + ]); + + const matchedRows = sorted.map((row) => { + const matched = matchBomSources(officialBom, businessBom, productBom, row.skuId); + return { + row, + components: matched?.components || [], + matchType: matched?.matchType || null, + dataSource: matched?.dataSource || null, + }; + }); + + // ⚠️ 配件明细导出必须单独补仓位:BOM 明细本身不带仓位(见文件头 ⑤), + // 需要拿配件编码去「箱及仓位库」批量取,再按编码合并回明细行。 + // 页面展开走 /detail(同样会补),但导出走的是这条路径,漏掉会整列出空。 + if (mode === EXPORT_MODES.COMPONENTS) { + const componentCodes = matchedRows + .flatMap((item) => item.components.map((component) => component.barcode || component.code)); + const locationMap = await loadComponentLocations(componentCodes); + matchedRows.forEach((item) => { + item.components = item.components.map((component) => ({ + ...component, + location: locationMap.get(toText(component.barcode || component.code)) + || component.location + || null, + })); + }); + } + + const rows = mode === EXPORT_MODES.COMPONENTS + ? buildComponentExportRows(matchedRows) + : buildProductExportRows(matchedRows); + + return { + mode, + rows, + total: rows.length, + skuCount: sorted.length, + bomConfiguredCount: matchedRows.filter((item) => item.components.length > 0).length, + scope, + keyword, + sortField, + sortOrder, + monitorDate: replenishIndex.monitorDate, + replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, + saleWindowDays: SALE_WINDOW_DAYS, + workOrderDegraded: replenishIndex.workOrderDegraded === true, + }; +} + module.exports = { getMkhBomList, getMkhBomDetail, + listReplenishAlertRows, + listMkhBomExportRows, _private: { normalizeCode, groupOfficialBomRows, @@ -755,5 +1460,24 @@ module.exports = { matchesPackSku, formatPackLocation, buildPackLocationMap, + loadComponentLocations, + COMPONENT_LOCATION_CHUNK_SIZE, + buildReplenishMetrics, + loadPendingWorkOrderIndex, + parseFlag, + resolveScope, + filterRows, + sortRows, + matchesKeyword, + REPLENISH_DAYS_THRESHOLD, + SALE_WINDOW_DAYS, + SCOPES, + WARNING_LEVEL, + WORK_ORDER_WAREHOUSE, + WORK_ORDER_VOID_STATUS, + WORK_ORDER_RECEIVED_STATUS, + EXPORT_MODES, + buildProductExportRows, + buildComponentExportRows, }, }; diff --git a/backend/src/services/mkhReplenishAlertSettings.js b/backend/src/services/mkhReplenishAlertSettings.js new file mode 100644 index 0000000..ff91799 --- /dev/null +++ b/backend/src/services/mkhReplenishAlertSettings.js @@ -0,0 +1,227 @@ +'use strict'; + +// MKH 成品补货预警推送 —— 配置 + 推送状态(本地库 system_config) +// +// ── 配置键 mkh_replenish_alert_config ───────────────────────────── +// { enabled, recipientAlias, scope } +// · recipientAlias 为空时回退顺序:环境变量 → 统一通知中心默认收件人 +// · scope: 'warning'(缺货 + 3 天内预警,默认)| 'outOfStock'(仅缺货) +// +// ── 状态键 mkh_replenish_alert_state ────────────────────────────── +// { version, notified: { [skuId]: { level, firstNotifiedAt, lastNotifiedAt, ... } }, lastRunAt } +// · 用于实现「只提醒新出现的预警」:已在 notified 里的商品不再重复推送; +// 商品恢复到正常范围后从 notified 移除,下次再预警会重新提醒。 + +const database = require('../config/database'); +const { + getYydNotificationCenterRuntimeConfig, + toAdminConfig: toNotificationCenterAdminConfig, +} = require('./yydNotificationCenterSettings'); + +const CONFIG_KEY = 'mkh_replenish_alert_config'; +const STATE_KEY = 'mkh_replenish_alert_state'; +const CONFIG_CACHE_TTL_MS = 30 * 1000; + +const SCOPES = Object.freeze({ WARNING: 'warning', OUT_OF_STOCK: 'outOfStock' }); + +const SCOPE_OPTIONS = Object.freeze([ + { value: SCOPES.WARNING, label: '缺货 + 3天内预警' }, + { value: SCOPES.OUT_OF_STOCK, label: '仅缺货' }, +]); + +let cachedConfig = null; +let cachedAt = 0; + +function normalizeText(value, maxLength = 200) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function safeJsonParse(value, fallback = {}) { + if (value && typeof value === 'object') return value; + try { + const parsed = JSON.parse(String(value || '')); + return parsed && typeof parsed === 'object' ? parsed : fallback; + } catch (_error) { + return fallback; + } +} + +function normalizeScope(value) { + const text = normalizeText(value, 40).toLowerCase(); + if (!text) return SCOPES.WARNING; + if (text === 'outofstock' || text === 'out_of_stock' || text === '缺货') return SCOPES.OUT_OF_STOCK; + if (text === 'warning' || text === 'warn' || text === '预警' || text === 'all') return SCOPES.WARNING; + return SCOPES.WARNING; +} + +function normalizeFlag(value, fallback = true) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const text = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(text)) return true; + if (['0', 'false', 'no', 'off'].includes(text)) return false; + return fallback; +} + +async function readSystemConfigValue(key) { + const rows = await database.local.query( + 'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1', + [key] + ); + return rows?.[0]?.configValue; +} + +async function upsertSystemConfigValue(key, value, configType = 'json') { + const payload = typeof value === 'string' ? value : JSON.stringify(value); + const rows = await database.local.query( + 'SELECT id FROM system_config WHERE config_key = ? LIMIT 1', + [key] + ); + if (rows?.[0]) { + await database.local.query( + 'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?', + [payload, configType, key] + ); + } else { + await database.local.insert('system_config', { + config_key: key, + config_value: payload, + config_type: configType, + }); + } + return payload; +} + +// ── 配置 ──────────────────────────────────────────────────────── +async function getMkhReplenishAlertRuntimeConfig({ forceRefresh = false } = {}) { + if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) { + return { ...cachedConfig }; + } + let stored = {}; + try { + stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {}); + } catch (error) { + console.warn('读取补货预警推送配置失败,使用默认值:', error.message); + } + cachedConfig = { + enabled: normalizeFlag(stored.enabled, true), + recipientAlias: normalizeText(stored.recipientAlias || stored.recipient_alias, 50), + scope: normalizeScope(stored.scope), + recipientAliasFallback: normalizeText(process.env.MKH_REPLENISH_ALERT_RECIPIENT_ALIAS, 50), + }; + cachedAt = Date.now(); + return { ...cachedConfig }; +} + +/** + * 解析实际收件人 alias:配置填写值 → 环境变量 → 统一通知中心默认收件人。 + */ +async function resolveMkhReplenishAlertRecipient(options = {}) { + const config = options.config || await getMkhReplenishAlertRuntimeConfig(); + if (config.recipientAlias) return { alias: config.recipientAlias, source: 'config' }; + if (config.recipientAliasFallback) return { alias: config.recipientAliasFallback, source: 'environment' }; + let center = {}; + try { + center = await getYydNotificationCenterRuntimeConfig({ forceRefresh: options.forceRefresh === true }); + } catch (_error) { + center = {}; + } + const alias = normalizeText(center?.recipientAlias, 50); + return { alias, source: alias ? 'notification_center' : 'none' }; +} + +async function getMkhReplenishAlertAdminConfig() { + const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true }); + const recipient = await resolveMkhReplenishAlertRecipient({ config }); + let center = {}; + try { + center = await getYydNotificationCenterRuntimeConfig(); + } catch (_error) { + center = {}; + } + const centerAdmin = toNotificationCenterAdminConfig(center); + const state = await loadMkhReplenishAlertState(); + return { + enabled: config.enabled, + scope: config.scope, + recipientAlias: config.recipientAlias, + effectiveRecipientAlias: recipient.alias || '', + recipientSource: recipient.source, + notificationCenterConfigured: centerAdmin.configured, + notificationCenterBaseUrl: centerAdmin.baseUrl, + notificationCenter: { + configured: centerAdmin.configured, + baseUrl: centerAdmin.baseUrl, + projectCode: centerAdmin.projectCode, + projectSecretConfigured: centerAdmin.projectSecretConfigured, + projectSecretPreview: centerAdmin.projectSecretPreview, + recipientAlias: centerAdmin.recipientAlias, + }, + notifiedCount: Object.keys(state.notified || {}).length, + lastRunAt: state.lastRunAt || null, + scopeOptions: SCOPE_OPTIONS.map((item) => ({ ...item })), + }; +} + +async function saveMkhReplenishAlertConfig(input = {}) { + const current = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true }); + const rawAlias = input.recipientAlias ?? input.recipient_alias; + const next = { + enabled: normalizeFlag(input.enabled, current.enabled), + recipientAlias: normalizeText(rawAlias === undefined ? current.recipientAlias : rawAlias, 50), + scope: normalizeScope(input.scope || current.scope), + }; + await upsertSystemConfigValue(CONFIG_KEY, next); + cachedConfig = { ...next, recipientAliasFallback: current.recipientAliasFallback }; + cachedAt = Date.now(); + return getMkhReplenishAlertAdminConfig(); +} + +// ── 推送状态 ──────────────────────────────────────────────────── +async function loadMkhReplenishAlertState() { + let parsed = {}; + try { + parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {}); + } catch (error) { + console.warn('读取补货预警推送状态失败,按空状态处理:', error.message); + } + const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified) + ? parsed.notified + : {}; + return { + version: 1, + notified, + lastRunAt: normalizeText(parsed.lastRunAt, 40) || null, + }; +} + +async function saveMkhReplenishAlertState(state = {}) { + const value = { + version: 1, + notified: state.notified && typeof state.notified === 'object' ? state.notified : {}, + lastRunAt: normalizeText(state.lastRunAt, 40) || null, + }; + await upsertSystemConfigValue(STATE_KEY, value); + return value; +} + +/** 清空「已推送」记录,让下一次检查重新按新增口径判断(便于测试)。 */ +async function resetMkhReplenishAlertState() { + return saveMkhReplenishAlertState({ notified: {}, lastRunAt: null }); +} + +module.exports = { + CONFIG_KEY, + STATE_KEY, + SCOPES, + SCOPE_OPTIONS, + getMkhReplenishAlertAdminConfig, + getMkhReplenishAlertRuntimeConfig, + loadMkhReplenishAlertState, + normalizeFlag, + normalizeScope, + resetMkhReplenishAlertState, + resolveMkhReplenishAlertRecipient, + saveMkhReplenishAlertConfig, + saveMkhReplenishAlertState, +}; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 93e7010..190ca90 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -164,3 +164,299 @@ test('pack inventory location prefers bins and falls back to positive-stock box assert.equal(locations.get('PART-1'), '仓库 1 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); }); + +// ── 补货预警:近 7 日日销 > 0 且 现有库存 < 3 天日销 ──────────────── +const metrics = (row) => _private.buildReplenishMetrics(row); + +test('replenish: stock below 3 days of sales is flagged as stockout or warning', () => { + const stockout = metrics({ sevenAvgSale: 2.55, realtimeStock: 0, monitorStock: 0 }); + assert.equal(stockout.stockDays, 0); + assert.equal(stockout.warningLevel, '缺货'); + assert.equal(stockout.hasWarning, true); + assert.equal(stockout.replenishQty, 8); // ceil(3 * 2.55 - 0) = ceil(7.65) + + const warning = metrics({ sevenAvgSale: 2, realtimeStock: 1, monitorStock: 1 }); + assert.equal(warning.stockDays, 0.5); + assert.equal(warning.warningLevel, '预警'); + assert.equal(warning.hasWarning, true); + assert.equal(warning.replenishQty, 5); // ceil(6 - 1) +}); + +test('replenish: the threshold is exactly 3 days of sales', () => { + const atThreshold = metrics({ sevenAvgSale: 2, realtimeStock: 6, monitorStock: 6 }); + assert.equal(atThreshold.stockDays, 3); + assert.equal(atThreshold.warningLevel, '正常'); + assert.equal(atThreshold.hasWarning, false); + assert.equal(atThreshold.replenishQty, 0); + + const below = metrics({ sevenAvgSale: 2, realtimeStock: 5.9, monitorStock: 5.9 }); + assert.equal(below.hasWarning, true); +}); + +test('replenish: stock prefers the brand-warehouse realtime value, snapshot only as fallback', () => { + const realtime = metrics({ sevenAvgSale: 1, realtimeStock: 4, monitorStock: 9 }); + assert.equal(realtime.stock, 4); + assert.equal(realtime.stockSource, 'realtime'); + + // 实时库存「无记录」(null)才回退监控快照 + const fallback = metrics({ sevenAvgSale: 1, realtimeStock: null, monitorStock: 9 }); + assert.equal(fallback.stock, 9); + assert.equal(fallback.stockSource, 'monitor'); + + // 实时库存为 0 是有效值,不得被快照覆盖 + const zero = metrics({ sevenAvgSale: 1, realtimeStock: 0, monitorStock: 9 }); + assert.equal(zero.stock, 0); + assert.equal(zero.warningLevel, '缺货'); +}); + +test('replenish: products without 7-day sales never warn', () => { + const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, monitorStock: 0 }); + assert.equal(noSale.hasWarning, false); + assert.equal(noSale.warningLevel, '无销量'); + assert.equal(noSale.stockDays, null); + assert.equal(noSale.replenishQty, 0); + assert.equal(noSale.thresholdQty, 0); +}); + +test('replenish scope resolves from scope or the legacy warningOnly flag, then filters rows', () => { + const rows = [ + { skuId: 'A', hasWarning: true, dailySale: 2 }, + { skuId: 'B', hasWarning: false, dailySale: 2 }, + { skuId: 'C', hasWarning: false, dailySale: 0 }, + ]; + + assert.equal(_private.resolveScope({}), 'all'); + assert.equal(_private.resolveScope({ warningOnly: '1' }), 'warning'); + assert.equal(_private.resolveScope({ scope: 'sold' }), 'sold'); + assert.equal(_private.resolveScope({ scope: 'bogus', warningOnly: 'true' }), 'warning'); + + assert.deepEqual(_private.filterRows(rows, { scope: 'warning' }).map((r) => r.skuId), ['A']); + assert.deepEqual(_private.filterRows(rows, { scope: 'sold' }).map((r) => r.skuId), ['A', 'B']); + assert.deepEqual(_private.filterRows(rows, { keyword: 'b' }).map((r) => r.skuId), ['B']); +}); + +test('replenish sort puts the most urgent first and no-sale products last', () => { + const rows = [ + { skuId: 'NO-SALE', stockDays: null }, + { skuId: 'B', stockDays: 2.8 }, + { skuId: 'A', stockDays: 0 }, + { skuId: 'C', stockDays: 12 }, + ]; + + assert.deepEqual( + _private.sortRows(rows, 'stockDays', 'ASC').map((r) => r.skuId), + ['A', 'B', 'C', 'NO-SALE'] + ); + assert.deepEqual( + _private.sortRows(rows, 'stockDays', 'DESC').map((r) => r.skuId), + ['C', 'B', 'A', 'NO-SALE'] + ); + assert.deepEqual( + _private.sortRows(rows, 'skuId', 'ASC').map((r) => r.skuId), + ['A', 'B', 'C', 'NO-SALE'] + ); +}); + +// ── 在途加工单:半成品包装仓有未完成加工单就不再预警(2026-09-17 口径)── +test('replenish: a pending work order suppresses the replenish warning', () => { + // 库存为 0、本该判「缺货」,但半成品包装项目仓已有未完成加工单 + const scheduled = metrics({ + sevenAvgSale: 2, + realtimeStock: 0, + monitorStock: 0, + pendingWorkOrderQty: 20, + pendingWorkOrderCount: 1, + // 用本地时间构造,避免测试机时区不同导致日期串变化 + pendingWorkOrderEta: new Date(2026, 9, 1), + pendingWorkOrderNo: '893495', + }); + assert.equal(scheduled.hasWarning, false); + assert.equal(scheduled.warningLevel, '已排产'); + assert.equal(scheduled.replenishQty, 0); + assert.equal(scheduled.hasPendingWorkOrder, true); + assert.equal(scheduled.pendingWorkOrderQty, 20); + assert.equal(scheduled.pendingWorkOrderCount, 1); + assert.equal(scheduled.pendingWorkOrderEta, '2026-10-01'); + assert.equal(scheduled.pendingWorkOrderNo, '893495'); + // 可用天数仍如实反映现有库存,便于业务复核 + assert.equal(scheduled.stockDays, 0); + + // 同样库存但无加工单时仍然预警 —— 确认抑制效果确实来自加工单 + const bare = metrics({ sevenAvgSale: 2, realtimeStock: 0, monitorStock: 0 }); + assert.equal(bare.hasWarning, true); + assert.equal(bare.warningLevel, '缺货'); +}); + +test('replenish: pending work order fields default to empty without any order', () => { + const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 }); + assert.equal(none.hasPendingWorkOrder, false); + assert.equal(none.pendingWorkOrderQty, 0); + assert.equal(none.pendingWorkOrderCount, 0); + assert.equal(none.pendingWorkOrderEta, null); + assert.equal(none.pendingWorkOrderNo, null); + + // 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理 + const zeroQty = metrics({ + sevenAvgSale: 1, + realtimeStock: 0, + pendingWorkOrderQty: 0, + pendingWorkOrderCount: 2, + }); + assert.equal(zeroQty.hasPendingWorkOrder, true); + assert.equal(zeroQty.warningLevel, '已排产'); + assert.equal(zeroQty.hasWarning, false); +}); + +test('replenish: products without sales stay "no sale" even with a pending work order', () => { + const noSale = metrics({ + sevenAvgSale: 0, + realtimeStock: 0, + pendingWorkOrderQty: 5, + pendingWorkOrderCount: 1, + }); + assert.equal(noSale.warningLevel, '无销量'); + assert.equal(noSale.hasWarning, false); + // 在途量仍要透出,页面上应能看到 + assert.equal(noSale.pendingWorkOrderQty, 5); +}); + +test('pending work order filters keep the business-confirmed source and exclusions', () => { + assert.equal(_private.WORK_ORDER_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓'); + assert.equal(_private.WORK_ORDER_VOID_STATUS, '作废'); + assert.equal(_private.WORK_ORDER_RECEIVED_STATUS, '全部入库'); + assert.equal(_private.WARNING_LEVEL.SCHEDULED, '已排产'); +}); + +// ── 导出(见服务文件头 ⑨)──────────────────────────────────────── +test('export: product rows follow the page table columns', () => { + const rows = _private.buildProductExportRows([ + { + row: { + skuId: 'MKHDNZ0409260700428', + productName: 'Z字翻转|25刨花板斜切', + warningLevel: '缺货', + dailySale: 2.97, + stock: 0, + pendingWorkOrderQty: 0, + stockDays: 0, + replenishQty: 9, + }, + components: [{ barcode: 'A1', name: '铁搁板销', qty: 4 }, { barcode: 'A2', name: '自攻螺丝', qty: 12 }], + }, + { + row: { + skuId: 'MKHDNZ0409260600117', + productName: '无销量商品', + warningLevel: '无销量', + dailySale: 0, + stock: 10, + pendingWorkOrderQty: null, + stockDays: null, + replenishQty: 0, + }, + components: [], + }, + ]); + + assert.equal(rows.length, 2); + assert.deepEqual(rows[0], { + skuId: 'MKHDNZ0409260700428', + productName: 'Z字翻转|25刨花板斜切', + warningLevel: '缺货', + dailySale: 2.97, + stock: 0, + pendingWorkOrderQty: 0, + stockDays: 0, + replenishQty: 9, + bomConfigured: '已配置', + accessoryCount: 2, + accessoryQtySum: 16, + }); + // 无销量 → stockDays 为 null(前端导出时渲染成「无销量」),在途量缺省归 0 + assert.equal(rows[1].stockDays, null); + assert.equal(rows[1].pendingWorkOrderQty, 0); + assert.equal(rows[1].bomConfigured, '未配置'); + assert.equal(rows[1].accessoryQtySum, 0); +}); + +test('export: component rows are flattened one row per component', () => { + const rows = _private.buildComponentExportRows([ + { + row: { skuId: 'MKHDNZ0409260700428', productName: 'Z字翻转' }, + components: [ + { barcode: 'DNZ039864N1410V01', name: '台面', qty: 1, location: 'A-01 成品区' }, + { barcode: 'GBSFY-01', name: '说明书', qty: 2, location: null }, + ], + }, + { row: { skuId: 'MKHDNZ0409260600117', productName: '无配件商品' }, components: [] }, + ]); + + // 无配件的商品不产生任何导出行(不像页面那样占位) + assert.equal(rows.length, 2); + assert.deepEqual(rows[0], { + skuId: 'MKHDNZ0409260700428', + productName: 'Z字翻转', + index: 1, + componentBarcode: 'DNZ039864N1410V01', + componentName: '台面', + qty: 1, + location: 'A-01 成品区', + }); + // 同一商品的编码逐行重复(不做纵向合并,保证 Excel 可筛选) + assert.equal(rows[1].skuId, 'MKHDNZ0409260700428'); + assert.equal(rows[1].index, 2); + assert.equal(rows[1].location, null); + assert.equal(rows[1].qty, 2); +}); + +test('export: component rows fall back to code when barcode is missing', () => { + const rows = _private.buildComponentExportRows([ + { row: { skuId: 'MKH-X' }, components: [{ code: 'ONLY-CODE', name: '仅编码配件', qty: 3 }] }, + ]); + assert.equal(rows[0].componentBarcode, 'ONLY-CODE'); +}); + +test('export: mode constants cover both shapes', () => { + assert.deepEqual(_private.EXPORT_MODES, { PRODUCTS: 'products', COMPONENTS: 'components' }); +}); + +test('pack inventory location prefers the warehouse name and keeps the box fallback', () => { + const locations = _private.buildPackLocationMap([ + { + skuId: 'PART-1-1', + warehouseId: 1, + warehouseName: '常州简尚家居有限公司', + binGroup: null, + bin: '', + packId: 'BOX-1', + qty: 3, + }, + { + skuId: 'PART-1-2', + warehouseId: 1, + warehouseName: '常州简尚家居有限公司', + binGroup: '成品区', + bin: 'A-01', + packId: 'BOX-2', + qty: 1, + }, + ], ['PART-1']); + + assert.equal( + locations.get('PART-1'), + '常州简尚家居有限公司 · 箱号 BOX-1、常州简尚家居有限公司 · 成品区 / A-01' + ); +}); + +test('pack inventory location falls back to the warehouse id when the name is missing', () => { + const locations = _private.buildPackLocationMap([ + { skuId: 'PART-1-1', warehouseId: 4, warehouseName: null, bin: '', packId: 'BOX-9', qty: 1 }, + ], ['PART-1']); + + assert.equal(locations.get('PART-1'), '仓库 4 · 箱号 BOX-9'); +}); + +test('pack inventory location chunk size stays under the MSSQL parameter limit', () => { + // 每个编码占 2 个参数(精确匹配 @lcN + 带后缀前缀匹配 @lpN),MSSQL 单语句上限 2100 + assert.equal(_private.COMPONENT_LOCATION_CHUNK_SIZE * 2 <= 2000, true); +}); diff --git a/backend/test/mkhReplenishAlertJob.test.js b/backend/test/mkhReplenishAlertJob.test.js new file mode 100644 index 0000000..14faff6 --- /dev/null +++ b/backend/test/mkhReplenishAlertJob.test.js @@ -0,0 +1,139 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildAlertSignature, + buildMkhReplenishNotification, + buildNotifiedSnapshot, + chinaDateKey, + diffAlertState, + formatQty, +} = require('../src/jobs/mkhReplenishAlertJob'); + +const alert = (skuId, level = '缺货', extra = {}) => ({ + skuId, + warningLevel: level, + productName: `商品 ${skuId}`, + dailySale: 1, + stock: 0, + replenishQty: 3, + ...extra, +}); + +test('diffAlertState splits fresh / persisted / restored', () => { + const state = { notified: { A: { level: '缺货' }, B: { level: '预警' }, Z: { level: '缺货' } } }; + const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B'), alert('C')], state); + + assert.deepEqual(fresh.map((r) => r.skuId), ['C']); + assert.deepEqual(persisted.map((r) => r.skuId), ['A', 'B']); + // Z 已补货恢复,应从已推送记录里剔除 + assert.deepEqual(restored, ['Z']); +}); + +test('diffAlertState treats empty state as all fresh', () => { + const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B')], {}); + assert.equal(fresh.length, 2); + assert.equal(persisted.length, 0); + assert.deepEqual(restored, []); +}); + +test('buildNotifiedSnapshot keeps firstNotifiedAt and drops restored skus', () => { + const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-01T01:00:00.000Z' } } }; + const next = buildNotifiedSnapshot([alert('A'), alert('B')], state, '2026-09-17T01:00:00.000Z'); + + assert.deepEqual(Object.keys(next).sort(), ['A', 'B']); + assert.equal(next.A.firstNotifiedAt, '2026-09-01T01:00:00.000Z'); + assert.equal(next.A.lastNotifiedAt, '2026-09-17T01:00:00.000Z'); + assert.equal(next.B.firstNotifiedAt, '2026-09-17T01:00:00.000Z'); +}); + +test('buildNotifiedSnapshot with touch=false does not refresh lastNotifiedAt', () => { + const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-05T01:00:00.000Z' } } }; + const next = buildNotifiedSnapshot([alert('A')], state, '2026-09-17T01:00:00.000Z', { touch: false }); + assert.equal(next.A.lastNotifiedAt, '2026-09-05T01:00:00.000Z'); +}); + +test('buildAlertSignature is order independent but content sensitive', () => { + const a = alert('A'); + const b = alert('B', '预警'); + assert.equal(buildAlertSignature([a, b]), buildAlertSignature([b, a])); + assert.notEqual(buildAlertSignature([a, b]), buildAlertSignature([a])); + // 等级变化也算变化(缺货 → 预警 应产生新指纹) + assert.notEqual(buildAlertSignature([alert('A', '缺货')]), buildAlertSignature([alert('A', '预警')])); +}); + +test('chinaDateKey renders Beijing date regardless of UTC offset', () => { + // 2026-09-16T17:30:00Z === 2026-09-17 01:30 (北京) → 跨日必须算 09-17 + assert.equal(chinaDateKey(new Date('2026-09-16T17:30:00Z')), '20260917'); + assert.equal(chinaDateKey(new Date('2026-09-16T01:00:00Z')), '20260916'); +}); + +test('formatQty trims trailing zeros and tolerates bad input', () => { + assert.equal(formatQty(3), '3'); + assert.equal(formatQty(2.9700000001), '2.97'); + assert.equal(formatQty(0.28), '0.28'); + assert.equal(formatQty(null), '—'); + assert.equal(formatQty('abc'), '—'); +}); + +test('notification carries recipient, idempotency key and shortfall details', () => { + const fresh = [alert('MKHDNZ0409260700428', '缺货', { dailySale: 2.97, replenishQty: 9 })]; + const all = [...fresh, alert('MKHDNZ0409260700486', '预警', { dailySale: 2, stock: 1, replenishQty: 5 })]; + const snapshot = { + monitorDate: '2026-09-16', + saleWindowDays: 7, + replenishDaysThreshold: 3, + outOfStockCount: 1, + warningOnlyCount: 1, + replenishQtyTotal: 14, + }; + + const payload = buildMkhReplenishNotification({ + alerts: fresh, + allAlerts: all, + snapshot, + recipientAlias: '苏适', + dateKey: '20260917', + }); + + assert.equal(payload.recipientAlias, '苏适'); + assert.equal(payload.idempotencyKey, `mkh_replenish_alert:苏适:20260917:${buildAlertSignature(fresh)}:v1`); + assert.ok(payload.title.includes('新增 1 个')); + assert.ok(payload.title.includes('含 1 个已缺货')); + assert.ok(payload.content.includes('MKHDNZ0409260700428')); + assert.ok(payload.content.includes('现有库存 0')); + assert.ok(payload.content.includes('建议补货 9')); + // 汇总用全量口径,不是本批新增 + assert.ok(payload.content.includes('当前共 **2** 个')); + assert.equal(payload.actionTitle, '查看补货清单'); + assert.ok(payload.content.length <= 4800); +}); + +test('notification truncates long lists but keeps the totals', () => { + const many = Array.from({ length: 40 }, (_, i) => alert(`MKH${String(i).padStart(4, '0')}`)); + const payload = buildMkhReplenishNotification({ + alerts: many, + allAlerts: many, + snapshot: { outOfStockCount: 40, warningOnlyCount: 0, replenishQtyTotal: 120 }, + recipientAlias: '苏适', + dateKey: '20260917', + }); + + assert.ok(payload.content.includes('仅列出前 20 个')); + assert.ok(payload.content.includes('当前共 **40** 个')); + assert.equal(payload.content.includes('MKH0039'), false); + assert.ok(payload.content.length <= 4800); +}); + +test('notification flags degraded work-order source', () => { + const payload = buildMkhReplenishNotification({ + alerts: [alert('A')], + allAlerts: [alert('A')], + snapshot: { outOfStockCount: 1, warningOnlyCount: 0, replenishQtyTotal: 3, workOrderDegraded: true }, + recipientAlias: '苏适', + dateKey: '20260917', + }); + assert.ok(payload.content.includes('加工单数据源暂不可用')); +}); diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js index 26cb884..fdd3341 100644 --- a/frontend/src/api/mkhBom.js +++ b/frontend/src/api/mkhBom.js @@ -2,6 +2,7 @@ import request from './request'; const LIST_TIMEOUT_MS = 3 * 60 * 1000; const DETAIL_TIMEOUT_MS = 3 * 60 * 1000; +const EXPORT_TIMEOUT_MS = 5 * 60 * 1000; // MKH-BOM 资料列表(模块化组合商品 + 配件汇总) export const getMkhBomList = (params = {}) => request.get( @@ -14,3 +15,33 @@ export const getMkhBomDetail = (skuId) => request.get( '/api/mkh-bom/detail', { params: { skuId }, timeout: DETAIL_TIMEOUT_MS } ); + +// 导出:当前筛选条件下的全部数据(不分页) +// mode=products → 商品清单;mode=components → 配件明细摊平 +// ⚠️ 「全部」范围下官方 BOM 明细要分批拉取,耗时明显高于列表接口,故单独放大超时 +export const getMkhBomExportRows = (params = {}) => request.get( + '/api/mkh-bom/export', + { params, timeout: EXPORT_TIMEOUT_MS } +); + +// 补货预警推送设置(接收人 / 开关 / 推送范围) +export const getMkhReplenishAlertConfig = () => request.get('/api/mkh-bom/alert-config'); + +// 保存补货预警推送设置 +export const saveMkhReplenishAlertConfig = (payload = {}) => request.put( + '/api/mkh-bom/alert-config', + payload +); + +// 立即检查并推送(dryRun 只预览 / force 忽略「只推新增」/ reset 先清空已推送记录) +export const checkMkhReplenishAlert = (payload = {}) => request.post( + '/api/mkh-bom/alert-check', + payload, + { timeout: LIST_TIMEOUT_MS } +); + +// 保存统一通知中心凭据(补货预警的推送通道) +export const saveMkhNotificationCenter = (payload = {}) => request.put( + '/api/mkh-bom/notification-center', + payload +); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index c6f2b55..5f91c0f 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -1,10 +1,22 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, Button, Card, Input, Space, Spin, Table, Tooltip, Typography, message, + Alert, Button, Card, Collapse, Dropdown, Input, Modal, Segmented, Select, Space, Spin, Switch, + Table, Tooltip, Typography, message, } from 'antd'; -import { PrinterOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { + BellOutlined, DownloadOutlined, PrinterOutlined, ReloadOutlined, SearchOutlined, +} from '@ant-design/icons'; import { SycmPage } from '../../components/Common'; -import { getMkhBomList, getMkhBomDetail } from '../../api/mkhBom'; +import { + getMkhBomList, + getMkhBomDetail, + getMkhBomExportRows, + getMkhReplenishAlertConfig, + saveMkhReplenishAlertConfig, + saveMkhNotificationCenter, + checkMkhReplenishAlert, +} from '../../api/mkhBom'; +import { exportTableFile } from '../../utils/tableExport'; import './style.css'; const { Title, Text } = Typography; @@ -18,7 +30,93 @@ const number = (value, digits = 0) => (value === null || value === undefined || const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); -const EMPTY_FILTERS = { keyword: '' }; +const EMPTY_FILTERS = { keyword: '', scope: 'warning' }; + +// 预警等级 → 标签配色 +const WARNING_TONE = { + 缺货: 'danger', + 预警: 'warn', + 已排产: 'scheduled', + 正常: 'ok', + 无销量: 'muted', +}; + +// 展示范围切换 +const SCOPE_OPTIONS = [ + { label: '仅预警', value: 'warning' }, + { label: '有日销', value: 'sold' }, + { label: '全部', value: 'all' }, +]; + +// ── 导出(列口径与后端 listMkhBomExportRows 的字段一一对应)────────── +// 商品清单:与页面表格列一致,去掉「操作」列 +const PRODUCT_EXPORT_COLUMNS = [ + { title: 'MKH商品编码', dataIndex: 'skuId' }, + { title: '商品名称', dataIndex: 'productName' }, + { title: '预警', dataIndex: 'warningLevel' }, + { title: '近7日日销', dataIndex: 'dailySale' }, + { title: '现有库存', dataIndex: 'stock' }, + { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, + { + title: '可用天数', + dataIndex: 'stockDays', + // 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨 + exportValue: (value) => (value === null || value === undefined ? '无销量' : value), + }, + { + title: '建议补货量', + dataIndex: 'replenishQty', + // 与页面一致:非预警商品不显示数字,避免被当成「需要补 0 件」 + exportValue: (value, row) => ( + (row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : '' + ), + }, + { title: 'BOM 状态', dataIndex: 'bomConfigured' }, + { title: '配件种类', dataIndex: 'accessoryCount' }, + { title: '配件总数量', dataIndex: 'accessoryQtySum' }, +]; + +// 配件明细:与打印模板一致,额外带部件条码便于下游核对 +const COMPONENT_EXPORT_COLUMNS = [ + { title: 'MKH商品编码', dataIndex: 'skuId' }, + { title: '部件条码', dataIndex: 'componentBarcode' }, + { title: '配件名称', dataIndex: 'componentName' }, + { title: '配件数量', dataIndex: 'qty' }, + { title: '仓位', dataIndex: 'location' }, +]; + +// 导出下拉菜单:key = `-` +const EXPORT_MENU_ITEMS = [ + { key: 'products-xlsx', label: '商品清单 · XLSX' }, + { key: 'products-csv', label: '商品清单 · CSV' }, + { type: 'divider' }, + { key: 'components-xlsx', label: '配件明细 · XLSX' }, + { key: 'components-csv', label: '配件明细 · CSV' }, +]; + +const EXPORT_SCOPE_LABEL = { warning: '仅预警', sold: '有日销', all: '全部' }; + +// 补货预警推送:范围选项与跳过原因文案 +const ALERT_SCOPE_OPTIONS = [ + { label: '缺货 + 3天内预警', value: 'warning' }, + { label: '仅缺货', value: 'outOfStock' }, +]; + +const ALERT_REASON_TEXT = { + no_new_alert: '没有新出现的预警,未发送(已推送过的不重复提醒)', + no_alert: '当前没有需要补货的 MKH 商品,未发送', + disabled: '推送开关已关闭,未发送', + recipient_not_configured: '未配置收件人,未发送', + notification_center_not_configured: '统一通知中心尚未配置,未发送', + error: '执行出错,未发送', +}; + +const ALERT_RECIPIENT_SOURCE_TEXT = { + config: '来自本页配置', + environment: '来自环境变量', + notification_center: '来自统一通知中心默认收件人', + none: '未找到可用的收件人', +}; // A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 const PrintPreview = ({ data, onClose }) => { @@ -99,7 +197,8 @@ const MkhBomData = () => { const [error, setError] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); - const [sorter, setSorter] = useState({ field: 'skuId', order: 'ASC' }); + // 默认按可用天数升序 —— 补货预警场景下最紧急的排最前 + const [sorter, setSorter] = useState({ field: 'stockDays', order: 'ASC' }); const [expandedKeys, setExpandedKeys] = useState({}); const [detailCache, setDetailCache] = useState({}); @@ -108,6 +207,19 @@ const MkhBomData = () => { const [printData, setPrintData] = useState(null); const [printLoading, setPrintLoading] = useState(false); + // 导出(当前筛选条件下的全部数据) + const [exporting, setExporting] = useState(false); + + // 补货预警推送设置 + const [alertOpen, setAlertOpen] = useState(false); + const [alertConfig, setAlertConfig] = useState(null); + const [alertLoading, setAlertLoading] = useState(false); + const [alertBusy, setAlertBusy] = useState(false); + const [alertForm, setAlertForm] = useState({ enabled: true, recipientAlias: '', scope: 'warning' }); + const [alertResult, setAlertResult] = useState(null); + const [centerForm, setCenterForm] = useState({ baseUrl: '', projectCode: '', projectSecret: '' }); + const [centerSaving, setCenterSaving] = useState(false); + const tableWrapRef = useRef(null); const [bodyHeight, setBodyHeight] = useState(420); @@ -170,6 +282,146 @@ const MkhBomData = () => { }; const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); }; + // ── 导出 ──────────────────────────────────────────────────── + // 与列表共用同一套筛选/排序口径,但走不分页接口(导出当前筛选条件下的「全部」行)。 + const handleExport = async (menuKey) => { + const [mode, format] = String(menuKey || '').split('-'); + if (!mode || !format) return; + + setExporting(true); + try { + const result = await getMkhBomExportRows({ + mode, + keyword: filters.keyword, + scope: filters.scope, + sortField: sorter.field, + sortOrder: sorter.order, + }); + if (!result || result.success === false) { + message.error(result?.error || '导出失败'); + return; + } + + const payload = result.data || {}; + const rows = payload.rows || []; + if (!rows.length) { + message.warning('当前筛选条件下没有可导出的数据'); + return; + } + + const isComponents = mode === 'components'; + const scopeLabel = EXPORT_SCOPE_LABEL[payload.scope] || '全部'; + const stamp = payload.monitorDate || new Date().toISOString().slice(0, 10); + + await exportTableFile( + format, + `${isComponents ? 'MKH配件明细' : 'MKH商品清单'}_${scopeLabel}_${stamp}`, + isComponents ? COMPONENT_EXPORT_COLUMNS : PRODUCT_EXPORT_COLUMNS, + rows, + isComponents ? '配件明细' : '商品清单' + ); + message.success(`已导出 ${rows.length} 行(${format.toUpperCase()})`); + } catch (exportError) { + message.error(exportError?.response?.data?.error || exportError?.message || '导出失败'); + } finally { + setExporting(false); + } + }; + + // ── 补货预警推送 ──────────────────────────────────────────── + const applyAlertConfig = (config = {}) => { + setAlertConfig(config); + setAlertForm({ + enabled: config.enabled !== false, + recipientAlias: config.recipientAlias || '', + scope: config.scope || 'warning', + }); + setCenterForm({ + baseUrl: config.notificationCenter?.baseUrl || '', + projectCode: config.notificationCenter?.projectCode || '', + projectSecret: '', + }); + }; + + const saveCenterConfig = async () => { + setCenterSaving(true); + try { + const result = await saveMkhNotificationCenter(centerForm); + if (!result || result.success === false) { + message.error(result?.error || '保存推送通道失败'); + return; + } + applyAlertConfig(result.data || {}); + message.success('推送通道配置已保存'); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '保存推送通道失败'); + } finally { + setCenterSaving(false); + } + }; + + const openAlertSettings = async () => { + setAlertOpen(true); + setAlertResult(null); + setAlertLoading(true); + try { + const result = await getMkhReplenishAlertConfig(); + if (!result || result.success === false) { + message.error(result?.error || '加载推送设置失败'); + return; + } + applyAlertConfig(result.data || {}); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '加载推送设置失败'); + } finally { + setAlertLoading(false); + } + }; + + const saveAlertSettings = async () => { + setAlertBusy(true); + try { + const result = await saveMkhReplenishAlertConfig(alertForm); + if (!result || result.success === false) { + message.error(result?.error || '保存推送设置失败'); + return; + } + applyAlertConfig(result.data || {}); + message.success('推送设置已保存'); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '保存推送设置失败'); + } finally { + setAlertBusy(false); + } + }; + + const runAlertCheck = async (payload, doneText) => { + setAlertBusy(true); + setAlertResult(null); + try { + const result = await checkMkhReplenishAlert(payload); + if (!result || result.success === false) { + message.error(result?.error || '执行推送失败'); + return; + } + const outcome = result.data || {}; + setAlertResult(outcome); + if (outcome.sent) { + message.success(`${doneText || '已推送'}:新增 ${outcome.newCount} 个预警 → ${outcome.recipient}`); + } else if (outcome.reason === 'dry_run') { + message.info('已生成预览内容,未实际发送'); + } else { + message.warning(ALERT_REASON_TEXT[outcome.reason] || `未发送:${outcome.reason || '无新预警'}`); + } + const refreshed = await getMkhReplenishAlertConfig().catch(() => null); + if (refreshed?.success) applyAlertConfig(refreshed.data || {}); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '执行推送失败'); + } finally { + setAlertBusy(false); + } + }; + const loadDetail = useCallback(async (skuId) => { if (detailCache[skuId] || detailLoading[skuId]) return; setDetailLoading((old) => ({ ...old, [skuId]: true })); @@ -207,7 +459,7 @@ const MkhBomData = () => { { title: 'MKH商品编码', dataIndex: 'skuId', - width: 240, + width: 230, fixed: 'left', sorter: true, render: (value) => {text(value)}, @@ -216,9 +468,76 @@ const MkhBomData = () => { title: '商品名称', dataIndex: 'productName', ellipsis: true, - sorter: true, render: (value) => {text(value)}, }, + { + title: '预警', + dataIndex: 'warningLevel', + width: 84, + align: 'center', + render: (value) => ( + {text(value)} + ), + }, + { + title: '近7日日销', + dataIndex: 'dailySale', + width: 106, + align: 'right', + render: (value) => (Number(value) > 0 ? number(value, 2) : 0), + }, + { + title: '现有库存', + dataIndex: 'stock', + width: 100, + align: 'right', + render: (value, row) => ( + + {number(value)} + + ), + }, + { + title: '在途加工量', + dataIndex: 'pendingWorkOrderQty', + width: 116, + align: 'right', + render: (value, row) => (row.hasPendingWorkOrder ? ( + + {number(value)} + + ) : —), + }, + { + title: '可用天数', + dataIndex: 'stockDays', + width: 106, + align: 'right', + sorter: true, + render: (value, row) => (value === null || value === undefined + ? 无销量 + : {number(value, 2)}), + }, + { + title: '建议补货量', + dataIndex: 'replenishQty', + width: 116, + align: 'right', + render: (value, row) => (row.hasWarning && Number(value) > 0 + ? {number(value)} + : —), + }, { title: 'BOM 状态', dataIndex: 'bomConfigured', @@ -231,23 +550,21 @@ const MkhBomData = () => { { title: '配件种类', dataIndex: 'accessoryCount', - width: 110, + width: 96, align: 'right', - sorter: true, render: (value) => number(value), }, { title: '配件总数量', dataIndex: 'accessoryQtySum', - width: 120, + width: 110, align: 'right', - sorter: true, render: (value) => number(value), }, { title: '操作', key: 'action', - width: 190, + width: 200, fixed: 'right', render: (_value, row) => ( @@ -334,12 +651,18 @@ const MkhBomData = () => {
MKH-BOM 资料 - 模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位 + 模块化(MKH 开头)成品的配件明细与补货预警:近 7 日日销核算,库存低于 3 天日销即预警 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条); - 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; - 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。 + 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充。 +
+ 补货预警:近7日日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 + 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近7日日销,低于 3 天即预警,缺货单独标识; + 建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无近7日日销的商品不参与预警。 +
+ 在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算 + (作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。
@@ -354,13 +677,69 @@ const MkhBomData = () => { onChange={(e) => changeFilter('keyword')(e.target.value)} style={{ width: 260 }} /> + + handleExport(key) }} + > + + +
{error ? 重试} /> : null} + {!loading && !error && data.workOrderDegraded ? ( + 刷新数据} + /> + ) : null} + + {!loading && (data.warningCount || 0) > 0 ? ( + + 按近 {data.saleWindowDays || 7} 日日销核算(监控日 {data.monitorDate || '—'}): + 其中 {data.outOfStockCount || 0} 个已缺货, + {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 + 建议补货量 = {data.replenishDaysThreshold || 3} × 近7日日销 − 现有库存。点「只看预警」即为补货清单。 + {(data.scheduledCount || 0) > 0 ? ( + <> +
+ 另有 {data.scheduledCount} 个商品在「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」已有未完成加工单 + (在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。 + + ) : null} + + )} + action={( + + )} + /> + ) : null} + {!loading && (data.total || 0) > 0 ? ( 0 ? 'info' : 'warning'} @@ -418,13 +797,197 @@ const MkhBomData = () => { pageSizeOptions: [50, 100, 200], showTotal: (total) => `共 ${total} 条`, }} - scroll={{ x: 1000, y: bodyHeight }} + scroll={{ x: 1696, y: bodyHeight }} /> {printData ? setPrintData(null)} /> : null} + + setAlertOpen(false)} + width={680} + destroyOnHidden + styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }} + footer={( + + + + + + + )} + > + + + +
+
+ 开启推送 + setAlertForm((old) => ({ ...old, enabled: checked }))} + /> + 关闭后自动任务不再发送,本页仍可手动推送 +
+ +
+ 收件人 + setAlertForm((old) => ({ ...old, recipientAlias: e.target.value }))} + style={{ width: 280 }} + /> +
+ +
+ 推送范围 + setCenterForm((old) => ({ ...old, baseUrl: e.target.value }))} + placeholder="https://sc.jiansun.vip/notification-center" + style={{ width: 380 }} + /> +
+
+ 项目代码 + setCenterForm((old) => ({ ...old, projectCode: e.target.value }))} + style={{ width: 280 }} + /> +
+
+ 项目密钥 + setCenterForm((old) => ({ ...old, projectSecret: e.target.value }))} + placeholder={alertConfig?.notificationCenter?.projectSecretConfigured + ? `已配置(${alertConfig.notificationCenter.projectSecretPreview}),留空则不修改` + : '未配置'} + style={{ width: 280 }} + /> +
+
+ +
+
+ ), + }]} + /> + + {alertResult ? ( + +
+ 当前预警 {alertResult.summary?.totalCount ?? 0} 个 + (缺货 {alertResult.summary?.outOfStockCount ?? 0} | + {alertResult.summary?.replenishDaysThreshold || 3}天内 {alertResult.summary?.warningOnlyCount ?? 0}) + ,本次新增 {alertResult.summary?.freshCount ?? 0} 个,历史已推 {alertResult.summary?.persistedCount ?? 0} 个。 +
+ {alertResult.payload ? ( +
+
{alertResult.payload.title}
+
{alertResult.payload.content}
+ 收件人:{alertResult.payload.recipientAlias} +
+ ) : null} + + )} + /> + ) : null} + +
+ 「立即检查并推送」按线上口径执行(只推新出现的);「重推全部预警」会清空已推送记录后重新推送当前全部预警,用于首次验证。 +
+
+
); }; diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index e25d910..b591377 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -82,6 +82,47 @@ background: rgba(22, 119, 255, 0.12); } +/* ============ 补货预警 ============ */ +.mkh-alert { + margin-bottom: 12px; +} + +/* 缺货 */ +.mkh-tag--danger { + color: #a8071a; + background: rgba(255, 77, 79, 0.14); +} + +/* 预警 */ +.mkh-tag--warn { + color: #ad4e00; + background: rgba(250, 173, 20, 0.2); +} + +/* 可用天数低于阈值 */ +.mkh-days--warn { + color: #cf1322; + font-weight: 600; +} + +/* 建议补货量 */ +.mkh-replenish { + color: #0958d9; + font-weight: 600; +} + +/* 已排产:半成品包装仓已有未完成加工单,按业务口径不再预警 */ +.mkh-tag--scheduled { + color: #0958d9; + background: rgba(22, 119, 255, 0.14); +} + +/* 在途加工量(未完成加工单待入库数量) */ +.mkh-inbound { + color: #0958d9; + font-weight: 600; +} + /* ============ A4 打印预览 ============ */ .mkh-print-mask { position: fixed; @@ -237,3 +278,91 @@ margin: 12mm; } } + +/* ── 补货预警推送设置弹窗 ──────────────────────────── */ +.mkh-alert-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.mkh-alert-row { + display: flex; + align-items: center; + gap: 10px; +} + +.mkh-alert-label { + flex: none; + width: 68px; + color: var(--sycm-text-secondary); +} + +.mkh-alert-meta { + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--sycm-border-color); + border-radius: 8px; + background: var(--sycm-bg-subtle); + font-size: 12px; + line-height: 2; + color: var(--sycm-text-secondary); +} + +.mkh-alert-result { + margin-top: 14px; +} + +.mkh-alert-result-body { + font-size: 12px; + line-height: 1.8; +} + +.mkh-alert-preview { + margin-top: 8px; + padding: 10px 12px; + border: 1px solid var(--sycm-border-color); + border-radius: 8px; + background: var(--sycm-bg-container); +} + +.mkh-alert-preview-title { + margin-bottom: 6px; + font-weight: 600; + color: var(--sycm-text-strong); +} + +.mkh-alert-preview pre { + margin: 0 0 6px; + font-family: inherit; + font-size: 12px; + line-height: 1.8; + white-space: pre-wrap; + word-break: break-word; + color: var(--sycm-text-primary); +} + +.mkh-alert-hint { + margin-top: 10px; + font-size: 12px; + line-height: 1.7; + color: var(--sycm-text-tertiary); +} + +/* 推送通道(统一通知中心)折叠区 */ +.mkh-alert-center { + margin-top: 6px; +} + +.mkh-alert-center .ant-collapse-header { + padding: 8px 0 !important; +} + +.mkh-alert-center .ant-collapse-content-box { + padding: 0 0 4px !important; +} + +.mkh-alert-form--inner { + margin-top: 4px; +} From 3a87d8ecbaffe3a13d0d3bec83d04142c39cd90e Mon Sep 17 00:00:00 2001 From: lunanting Date: Thu, 17 Sep 2026 10:26:54 +0800 Subject: [PATCH 11/70] fix: use pack inventory for BOM locations --- backend/src/services/mkhBomService.js | 62 ++++--------------------- backend/test/mkhBomService.test.js | 20 ++------ frontend/src/pages/MkhBomData/index.jsx | 8 ++-- 3 files changed, 17 insertions(+), 73 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 3f992de..0c7528b 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -39,9 +39,8 @@ // // ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo; // sku_id 可能带“-包装序号”后缀,需按基础配件编码匹配。优先显示 bin_group / bin, -// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与实际仓库名称。 -// 尚未分箱时使用 rpt.jst_inventoryInfo 的正库存仓库;连库存记录也没有时, -// 回退到 jst_sku.other_3 的商品档案仓库,并明确标注没有箱号,避免误报具体仓位。 +// 仓位为空时显示净数量大于 0 的 pack_id(箱号)与 wh_id(仓库)。 +// 该列只使用箱及仓位库存表,不用库存仓库或商品档案仓库冒充仓位。 // // ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 @@ -555,37 +554,6 @@ function buildPackLocationMap(rows, componentCodes) { return result; } -function buildInventoryWarehouseMap(rows, componentCodes) { - const positiveWarehouses = new Map(componentCodes.map((code) => [code, new Set()])); - const defaultWarehouses = new Map(); - - rows.forEach((row) => { - const componentCode = componentCodes.find((code) => normalizeCode(code) === normalizeCode(row.skuId)); - if (!componentCode) return; - - const defaultWarehouse = toText(row.defaultWarehouse); - if (defaultWarehouse) defaultWarehouses.set(componentCode, defaultWarehouse); - - const warehouseName = toText(row.warehouseName); - if (warehouseName && (toNumber(row.availableQty) ?? 0) > 0) { - positiveWarehouses.get(componentCode).add(warehouseName); - } - }); - - const result = new Map(); - componentCodes.forEach((code) => { - const warehouses = [...positiveWarehouses.get(code)]; - if (warehouses.length > 0) { - result.set(code, `${warehouses.join('、')}(未分箱)`); - return; - } - - const defaultWarehouse = defaultWarehouses.get(code); - if (defaultWarehouse) result.set(code, `${defaultWarehouse}(暂无箱号)`); - }); - return result; -} - /** * 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。 * BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。 @@ -725,18 +693,10 @@ async function getMkhBomDetail(mkhSkuId) { try { const stockRows = await cloudDatabase.query( - `SELECT s.sku_id AS skuId, - s.other_3 AS defaultWarehouse, - w.name AS warehouseName, - SUM(COALESCE(i.qty, 0)) AS qty, - SUM(COALESCE(i.qty, 0) - - COALESCE(i.order_lock, 0) - - COALESCE(i.pick_lock, 0)) AS availableQty - FROM dbo.jst_sku s WITH (NOLOCK) - LEFT JOIN rpt.jst_inventoryInfo i WITH (NOLOCK) ON i.sku_id = s.sku_id - LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = i.wms_co_id - WHERE s.sku_id IN (${placeholders}) - GROUP BY s.sku_id, s.other_3, i.wms_co_id, w.name`, + `SELECT i.sku_id AS skuId, SUM(i.qty) AS qty + FROM rpt.jst_inventoryInfo i WITH (NOLOCK) + WHERE i.sku_id IN (${placeholders}) + GROUP BY i.sku_id`, codeParams, { requestTimeout: 180000 } ); @@ -745,9 +705,6 @@ async function getMkhBomDetail(mkhSkuId) { if (!code) return; stockMap.set(code, (stockMap.get(code) || 0) + (toNumber(row.qty) ?? 0)); }); - buildInventoryWarehouseMap(stockRows, uncachedCodes).forEach((location, code) => { - locationMap.set(code, location); - }); stockQuerySucceeded = true; } catch (error) { console.warn('[MkhBom] 配件库存查询降级:', error.message); @@ -763,15 +720,13 @@ async function getMkhBomDetail(mkhSkuId) { const locRows = await cloudDatabase.query( `SELECT p.sku_id AS skuId, p.pack_id AS packId, - p.wms_co_id AS warehouseId, - w.name AS warehouseName, + p.wh_id AS warehouseId, p.bin AS bin, p.bin_group AS binGroup, SUM(p.qty) AS qty FROM rpt.jst_packInventoryInfo p WITH (NOLOCK) - LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = p.wms_co_id WHERE ${locationConditions.join(' OR ')} - GROUP BY p.sku_id, p.pack_id, p.wms_co_id, w.name, p.bin, p.bin_group + GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group HAVING SUM(p.qty) > 0`, locationParams, { requestTimeout: 120000 } @@ -831,6 +786,5 @@ module.exports = { matchesPackSku, formatPackLocation, buildPackLocationMap, - buildInventoryWarehouseMap, }, }; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 2b8d6d8..346e9bb 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -155,27 +155,17 @@ test('pack inventory locations match a BOM component and its package suffixes', test('pack inventory location prefers bins and falls back to positive-stock box ids', () => { const locations = _private.buildPackLocationMap([ { - skuId: 'PART-1-1', warehouseId: 1, warehouseName: '半成品仓', binGroup: '成品区', bin: 'A-01', packId: 'BOX-1', qty: 2, + skuId: 'PART-1-1', warehouseId: 1, binGroup: '成品区', bin: 'A-01', packId: 'BOX-1', qty: 2, }, { skuId: 'PART-1-2', warehouseId: 1, binGroup: null, bin: '', packId: 'BOX-2', qty: 1, }, ], ['PART-1']); - assert.equal(locations.get('PART-1'), '半成品仓 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); + assert.equal(locations.get('PART-1'), '仓库 1 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); }); -test('inventory warehouse fallback distinguishes unboxed stock from a warehouse with no box record', () => { - const locations = _private.buildInventoryWarehouseMap([ - { - skuId: 'PART-1', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 12, - }, - { - skuId: 'PART-2', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 0, - }, - ], ['PART-1', 'PART-2', 'PART-3']); - - assert.equal(locations.get('PART-1'), '实际库存仓(未分箱)'); - assert.equal(locations.get('PART-2'), '默认半成品仓(暂无箱号)'); - assert.equal(locations.has('PART-3'), false); +test('components without pack inventory rows do not receive a fallback warehouse location', () => { + const locations = _private.buildPackLocationMap([], ['PART-1']); + assert.equal(locations.has('PART-1'), false); }); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index ee278b3..57cc591 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -84,7 +84,7 @@ const PrintPreview = ({ data, onClose }) => {
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充; - 配件仓位优先取包装箱与具体仓位,尚未分箱时显示库存仓库或商品档案仓库。 + 配件仓位只取箱及仓位库存表;没有箱号或仓位记录时显示「未维护」。
@@ -317,7 +317,7 @@ const MkhBomData = () => { dataIndex: 'location', width: 140, render: (v) => (v ? text(v) : ( - + 未维护 )), @@ -339,8 +339,8 @@ const MkhBomData = () => { 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条); 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; - 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位优先显示箱号或具体仓位, - 尚未分箱时显示实际库存仓库,没有库存记录时显示商品档案仓库。 + 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo, + 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。 From 80028665c5084994c87f674aa785c53fdb9b1d02 Mon Sep 17 00:00:00 2001 From: lunanting Date: Thu, 17 Sep 2026 11:14:46 +0800 Subject: [PATCH 12/70] docs: allow explicit owner merge authorization --- AGENTS.md | 2 +- 仓库提交与合并规范.md | 11 ++++++----- 服务器部署规范.md | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e6092a6..f0d1dce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ - 保留他人的未提交修改和已有提交,仅暂存本任务内容;不得清理或覆盖未知工作。 - 推送前同步远端,逐项检查最终差异并验证;被拒绝后先查明并合并更新,禁止任何形式的强推。 - 不改写已发布历史,不批量选择冲突的某一方;不明确的行为取舍需要负责人确认。 -- 所有变更通过 PR 进入 `main`,不得直接推送主分支;独立审核与最新内容的验证通过后由维护者合并。 +- 所有变更通过 PR 进入 `main`,不得直接推送主分支;默认由非提交者审核。仓库所有者或管理员明确授权某个 PR 合并时,该书面授权可以代替独立审核,但仍必须同步最新主分支、验证最终提交并保留授权记录。 - 不提交凭据、私钥、环境秘密或真实业务数据;不把合并授权当成生产部署授权。 - 新建或修改数据表、映射表、查询和同步任务时,必须按实际过滤、关联、排序和唯一性设计索引,用执行计划和有代表性数据验证负载;禁止无边界全表扫描、无节制并发和重复全量同步。 - 开发机器使用中央系统数据库和公网 TLS Redis,无需安装本地数据库;生产部署必须先将代码通过 PR 合入 `main`,并在 `/opt/cangku/deploy.lock` 锁内从服务器 Git 工作区快进拉取和验证。 diff --git a/仓库提交与合并规范.md b/仓库提交与合并规范.md index d225d51..ad4fe68 100644 --- a/仓库提交与合并规范.md +++ b/仓库提交与合并规范.md @@ -168,12 +168,12 @@ npm --prefix frontend run check 合并要求: -1. 至少一名非提交者的维护者或开发者审核最终差异;AI 自我复查可以辅助,但不代替独立审核。提交 AI 不得用另一个会话或共享账号冒充独立审核者。 +1. 默认由至少一名非提交者的维护者或开发者审核最终差异;AI 自我复查可以辅助,但不代替独立审核。仓库所有者或管理员对明确的 PR 作出书面合并指令时,该授权可以代替独立审核,但必须在 PR 中记录授权人、授权范围和当时的最终提交 SHA。提交 AI 不得伪造授权、冒用其他账号或把模糊的日常开发指令解释为合并授权。 2. 审核需关注是否撤销他人逻辑、丢失配置/依赖、改变接口与权限、破坏已上线功能,不能只看有无冲突或测试绿灯。 -3. 审核和检查必须对应最终待合并提交;后续新提交或冲突解决后需要重新确认,不能沿用失效的批准。 -4. 合并前再次确认最新 `main` 已被纳入任务分支,并验证组合后的行为。其他 PR 先合并导致主分支前进时,更新本 PR、重新检查并确认审核后再合并。 +3. 审核、所有者/管理员授权和检查必须对应最终待合并提交;后续新增业务代码、依赖变更或冲突解决后需要重新确认,不能沿用失效的批准。授权后仅补充不改变行为的合并记录或文档时,应在 PR 中说明并重新检查最终差异。 +4. 合并前再次确认最新 `main` 已被纳入任务分支,并验证组合后的行为。其他 PR 先合并导致主分支前进时,更新本 PR、重新检查并确认审核或所有者/管理员授权仍适用于最终提交后再合并。 5. 由维护者通过仓库 PR 合并功能依次处理;同时有多个待合并 PR 时串行合并。有合并队列时使用针对最新组合提交的检查,避免两份旧验证同时放行。 -6. 未解决冲突、必需检查失败、尚未审核或同步状态不清楚时不得合并。缺少独立审核者时保持 PR 待审核,不自行批准。 +6. 未解决冲突、必需检查失败、同步状态不清楚,或既无独立审核也无所有者/管理员明确授权时不得合并。授权例外不允许绕过失败检查、未解决冲突、数据库风险证据或分支保护,也不允许直接推送 `main`。 7. 合并后核对主分支结果。删除任务分支前确认已合并且无人继续使用,不删除其他开发者的分支。 ## 7. 发现误合并或代码被覆盖 @@ -190,6 +190,7 @@ npm --prefix frontend run check - [ ] 涉及数据表、索引、映射、查询或同步时,已验证执行计划、数据量边界和负载控制,并在 PR 记录真实证据。 - [ ] 已同步最新主分支及远端任务分支,未强推、未改写已发布历史。 - [ ] 已逐项审核最终差异;冲突处理保留双方有效行为,疑点已确认。 +- [ ] 已取得独立审核,或已在 PR 中记录仓库所有者/管理员针对最终提交的明确合并授权。 - [ ] 已完成受影响的验证,记录准确结果和提交 SHA。 - [ ] PR 已说明范围、兼容性、验证、风险和回退方法。 - [ ] 已明确报告当前状态:本地提交 / 已推送 / PR 待审核 / 已合并;没有把推送成功说成合并或部署成功。 @@ -198,6 +199,6 @@ npm --prefix frontend run check 本文件规定协作流程,`AGENTS.md` 提供 AI 读取入口;它们不是权限控制,不能保证每个 AI 工具自动读取或遵守。使用不自动读取这些文件的工具时,开发者必须主动提供规则。 -仓库管理员应在服务端落实以下保护,并以实际设置为准:保护 `main`、禁止直接及强制推送和删除、要求 PR 与独立审核、使过期审核失效、绑定真实运行的必需检查、限制合并权限、按个人发放最小权限凭据。没有真正运行的检查不能仅凭同名状态作为合并依据。 +仓库管理员应在服务端落实以下保护,并以实际设置为准:保护 `main`、禁止直接及强制推送和删除、要求通过 PR 合并、使过期审核失效、绑定真实运行的必需检查、限制合并权限、按个人发放最小权限凭据。团队可默认要求独立审核;需要允许所有者/管理员授权例外时,应确保只有真实所有者或管理员能够执行并保留可审计记录。没有真正运行的检查不能仅凭同名状态作为合并依据。 **添加本文件不等于上述保护已经启用。** 此文档变更不修改仓库权限、分支保护、CI 或生产部署;如需启用服务端限制,应由管理员单独配置并验证。 diff --git a/服务器部署规范.md b/服务器部署规范.md index bf288a6..c13a962 100644 --- a/服务器部署规范.md +++ b/服务器部署规范.md @@ -59,8 +59,8 @@ git diff --stat origin/main...HEAD 2. 本地已有修改先按 `仓库提交与合并规范.md` 提交到唯一任务分支,再同步最新 `origin/main`。 3. 如果主仓库已有更新,先合并到任务分支,逐处处理冲突,重新检查最终差异并运行受影响测试。 4. 禁止使用强制推送、`git reset --hard`、批量选择 `ours/theirs` 或复制旧目录覆盖新代码来解决冲突。 -5. 将任务分支推送到主仓库,创建 PR,经过审核和验证后合并到 `main`。这里的“先推送主仓库”指推送任务分支并通过 PR 合并,禁止直接推送 `main`。 -6. PR 合并后再次执行 `git fetch origin`,记录准备部署的 `origin/main` 完整 SHA。部署 SHA 必须与审核通过的最终提交一致。 +5. 将任务分支推送到主仓库并创建 PR;经过独立审核,或取得仓库所有者/管理员针对该 PR 最终提交的明确授权,并完成验证后合并到 `main`。这里的“先推送主仓库”指推送任务分支并通过 PR 合并,禁止直接推送 `main`。 +6. PR 合并后再次执行 `git fetch origin`,记录准备部署的 `origin/main` 完整 SHA。部署 SHA 必须与审核或所有者/管理员授权覆盖的最终提交一致。 如果本次变更涉及数据表、索引、查询、数据映射或同步任务,部署者还必须确认 PR 已按《仓库提交与合并规范》记录索引与执行计划证据、分批/并发/超时边界、大表锁影响和回退方式。证据缺失、依赖全表扫描、可能对系统库或第三方云库造成不可控负载时,不得部署。 From 986174076fcd2080a8e9a55c673f942c91d1d516 Mon Sep 17 00:00:00 2001 From: lunanting Date: Thu, 17 Sep 2026 11:42:50 +0800 Subject: [PATCH 13/70] =?UTF-8?q?fix:=20=E4=BB=85=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E6=AD=A3=E5=BC=8FBOM=E7=88=B6=E9=A1=B9=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/services/mkhBomService.js | 18 +++++++++++++++--- frontend/src/pages/MkhBomData/index.jsx | 4 ++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 0c7528b..b6694c8 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -8,7 +8,10 @@ // // ① MKH 商品编码(列表主数据)—— 业务库 JSReportReplica(MSSQL / schema=rpt) // rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品' -// AND other_3 = N'简尚-崔桥工厂品牌仓' (当前 4408 条) +// AND other_3 = N'简尚-崔桥工厂品牌仓' +// AND 该编码存在正式 BOM 配件明细 (当前 4223 条) +// · 商品档案里的 item_type/labels 不能单独作为成品依据:上游也会把部分配件 +// 标成“成品/模块化成品”。只有 jst_skuBomItem 的父项才进入本页面。 // · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198 // // ② 配件明细(BOM)—— 聚水潭商品 BOM(主来源) @@ -59,9 +62,18 @@ const officialBomDetailCache = new Map(); const componentLocationCache = new Map(); let productBomCache = null; -// MKH 商品编码 = 模块化成品 +// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。 +// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。 const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓'; -const MKH_WHERE = `s.sku_id LIKE N'MKH%' AND s.item_type = N'成品' AND s.other_3 = N'${DESIGNATED_WAREHOUSE}'`; +const MKH_WHERE = `s.sku_id LIKE N'MKH%' + AND s.item_type = N'成品' + AND s.other_3 = N'${DESIGNATED_WAREHOUSE}' + AND EXISTS ( + SELECT 1 + FROM rpt.jst_skuBomItem parent_bom WITH (NOLOCK) + WHERE parent_bom.sku_id = s.sku_id + AND NULLIF(LTRIM(RTRIM(parent_bom.map_outer_sku_id)), N'') IS NOT NULL + )`; const toText = (value) => { if (value === null || value === undefined) return null; diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 57cc591..85e265f 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -337,7 +337,7 @@ const MkhBomData = () => { 模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位 - 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条); + 数据口径:MKH 商品编码取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”,并且必须是聚水潭正式 BOM 的父项; 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo, 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。 @@ -371,7 +371,7 @@ const MkhBomData = () => { : '聚水潭商品 BOM 及补充来源中尚无可用的模块化商品配件明细'} description={( - MKH 商品编码({data.total || 0} 条)仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”; + MKH 商品编码({data.total || 0} 条)取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的正式 BOM 父项; 配件明细优先取自聚水潭商品 BOM({data.bomOfficialRowCount || 0} 个), 业务组合 BOM({data.bomBusinessRowCount || 0} 个)与产品资料系统({data.bomProductDbRowCount || 0} 个)作为补充。 BOM 中没有指定仓库商品档案的组合编码不会加入商品列表。 From e1c2ad1ea6c2e167c505b0c04f8c7051cf58044d Mon Sep 17 00:00:00 2001 From: lunanting Date: Thu, 17 Sep 2026 11:45:37 +0800 Subject: [PATCH 14/70] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E9=83=A8=E7=BD=B2=E8=BF=90=E8=A1=8C=E6=97=B6=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 服务器部署规范.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/服务器部署规范.md b/服务器部署规范.md index c13a962..ba4a23d 100644 --- a/服务器部署规范.md +++ b/服务器部署规范.md @@ -112,15 +112,18 @@ test "$(git rev-parse "origin/main^{commit}")" = "$DEPLOY_SHA" || { exit 1 } -git switch main +git checkout main git pull --ff-only origin main test "$(git rev-parse HEAD)" = "$DEPLOY_SHA" || { echo '服务器 HEAD 与待部署 SHA 不一致,停止部署' exit 1 } -npm --prefix frontend ci -npm --prefix frontend run build +docker run --rm \ + -v /opt/cangku/app/frontend:/workspace \ + -w /workspace \ + node:22-bookworm-slim \ + sh -lc 'npm ci && npm run build' if grep -q '^SOURCE_GIT_SHA=' /opt/cangku/shared/backend.env; then sed -i "s/^SOURCE_GIT_SHA=.*/SOURCE_GIT_SHA=$DEPLOY_SHA/" /opt/cangku/shared/backend.env else From 31a4b0aebb2d68a6f70b1e2d63d98d5c9faaca5e Mon Sep 17 00:00:00 2001 From: lunanting Date: Thu, 17 Sep 2026 11:54:03 +0800 Subject: [PATCH 15/70] =?UTF-8?q?build:=20=E4=BD=BF=E7=94=A8=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E7=94=9F=E4=BA=A7=E7=8E=AF=E5=A2=83=E7=9A=84=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=9E=84=E5=BB=BA=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/production/build-frontend.sh | 43 ++ frontend/package-lock.json | 1042 ++++++++++++++++++++------- frontend/package.json | 5 +- 服务器部署规范.md | 6 +- 4 files changed, 841 insertions(+), 255 deletions(-) create mode 100755 deploy/production/build-frontend.sh diff --git a/deploy/production/build-frontend.sh b/deploy/production/build-frontend.sh new file mode 100755 index 0000000..9daeb54 --- /dev/null +++ b/deploy/production/build-frontend.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +FRONTEND_DIR="$REPO_DIR/frontend" +SOURCE_GIT_FULL_SHA="$(git -C "$REPO_DIR" rev-parse HEAD)" +SOURCE_GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short HEAD)" +SOURCE_GIT_REF="$(git -C "$REPO_DIR" branch --show-current 2>/dev/null || true)" +SOURCE_GIT_REF="${SOURCE_GIT_REF:-main}" + +test -f "$FRONTEND_DIR/package-lock.json" || { + echo 'frontend/package-lock.json 不存在,停止构建' >&2 + exit 1 +} + +docker run --rm \ + -e "SOURCE_GIT_SHA=$SOURCE_GIT_SHA" \ + -e "SOURCE_GIT_FULL_SHA=$SOURCE_GIT_FULL_SHA" \ + -e "SOURCE_GIT_REF=$SOURCE_GIT_REF" \ + -v "$FRONTEND_DIR:/host-frontend" \ + node:22-bookworm-slim \ + sh -lc ' + set -eu + mkdir -p /workspace + tar -C /host-frontend --exclude=node_modules --exclude=dist -cf - . | tar -C /workspace -xf - + cd /workspace + npm ci --no-audit --no-fund + npm run build + mkdir -p /host-frontend/dist + find /host-frontend/dist -mindepth 1 -delete + cp -a /workspace/dist/. /host-frontend/dist/ + ' + +node - "$FRONTEND_DIR/dist/__build_version__.json" "$SOURCE_GIT_FULL_SHA" <<'NODE' +const fs = require('node:fs'); +const [versionPath, expectedSha] = process.argv.slice(2); +const metadata = JSON.parse(fs.readFileSync(versionPath, 'utf8')); +if (metadata.sourceGitFullSha !== expectedSha) { + throw new Error(`前端构建 SHA 不匹配:${metadata.sourceGitFullSha} != ${expectedSha}`); +} +process.stdout.write(`前端构建已验证:${metadata.version}\n`); +NODE diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0c1d584..3733cfc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", - "vite": "npm:rolldown-vite@7.2.5" + "vite": "7.2.7" } }, "node_modules/@ant-design/colors": { @@ -447,40 +447,6 @@ "react": ">=16.10.2" } }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", @@ -493,6 +459,448 @@ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -752,41 +1160,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@oxc-project/runtime": { - "version": "0.97.0", - "resolved": "https://registry.npmmirror.com/@oxc-project/runtime/-/runtime-0.97.0.tgz", - "integrity": "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==", - "dev": true, - "license": "MIT", + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.97.0", - "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.97.0.tgz", - "integrity": "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "node": "^22.20 || ^24.12 || >=25" } }, "node_modules/@rc-component/async-validator": { @@ -1004,10 +1392,31 @@ "url": "https://opencollective.com/immer" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", "cpu": [ "arm64" ], @@ -1016,15 +1425,12 @@ "optional": true, "os": [ "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", "cpu": [ "arm64" ], @@ -1033,15 +1439,12 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.50.tgz", - "integrity": "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", "cpu": [ "x64" ], @@ -1050,15 +1453,26 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.50.tgz", - "integrity": "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", "cpu": [ "x64" ], @@ -1067,15 +1481,12 @@ "optional": true, "os": [ "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.50.tgz", - "integrity": "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", "cpu": [ "arm" ], @@ -1084,15 +1495,26 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.50.tgz", - "integrity": "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", "cpu": [ "arm64" ], @@ -1101,15 +1523,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.50.tgz", - "integrity": "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", "cpu": [ "arm64" ], @@ -1118,15 +1537,110 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.50.tgz", - "integrity": "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", "cpu": [ "x64" ], @@ -1135,15 +1649,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.50.tgz", - "integrity": "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", "cpu": [ "x64" ], @@ -1152,15 +1663,26 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", "cpu": [ "arm64" ], @@ -1169,32 +1691,12 @@ "optional": true, "os": [ "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.50.tgz", - "integrity": "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.7" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", "cpu": [ "arm64" ], @@ -1203,15 +1705,12 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", "cpu": [ "ia32" ], @@ -1220,15 +1719,12 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", "cpu": [ "x64" ], @@ -1237,17 +1733,21 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -1261,17 +1761,6 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1381,9 +1870,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -2059,6 +2548,8 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -2169,6 +2660,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", @@ -2919,6 +3452,8 @@ "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", "dev": true, "license": "MPL-2.0", + "optional": true, + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -2956,6 +3491,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2977,6 +3513,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2998,6 +3535,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3019,6 +3557,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3040,6 +3579,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3061,6 +3601,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3082,6 +3623,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3103,6 +3645,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3124,6 +3667,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3145,6 +3689,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3166,6 +3711,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4279,46 +4825,52 @@ "node": ">=4" } }, - "node_modules/rolldown": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-beta.50.tgz", - "integrity": "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==", + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.97.0", - "@rolldown/pluginutils": "1.0.0-beta.50" + "@types/estree": "1.0.9" }, "bin": { - "rolldown": "bin/cli.mjs" + "rollup": "dist/bin/rollup" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-beta.50", - "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", - "@rolldown/binding-darwin-x64": "1.0.0-beta.50", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", - "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" } }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", - "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", - "dev": true, - "license": "MIT" - }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -4609,19 +5161,17 @@ } }, "node_modules/vite": { - "name": "rolldown-vite", - "version": "7.2.5", - "resolved": "https://registry.npmmirror.com/rolldown-vite/-/rolldown-vite-7.2.5.tgz", - "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", + "version": "7.2.7", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.97.0", + "esbuild": "^0.25.0", "fdir": "^6.5.0", - "lightningcss": "^1.30.2", "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rolldown": "1.0.0-beta.50", + "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "bin": { @@ -4638,9 +5188,9 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", + "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -4653,15 +5203,15 @@ "@types/node": { "optional": true }, - "esbuild": { - "optional": true - }, "jiti": { "optional": true }, "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, diff --git a/frontend/package.json b/frontend/package.json index 519e4ca..6b5c08b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,9 +37,6 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", - "vite": "npm:rolldown-vite@7.2.5" - }, - "overrides": { - "vite": "npm:rolldown-vite@7.2.5" + "vite": "7.2.7" } } diff --git a/服务器部署规范.md b/服务器部署规范.md index ba4a23d..5c47b4d 100644 --- a/服务器部署规范.md +++ b/服务器部署规范.md @@ -119,11 +119,7 @@ test "$(git rev-parse HEAD)" = "$DEPLOY_SHA" || { exit 1 } -docker run --rm \ - -v /opt/cangku/app/frontend:/workspace \ - -w /workspace \ - node:22-bookworm-slim \ - sh -lc 'npm ci && npm run build' +bash deploy/production/build-frontend.sh if grep -q '^SOURCE_GIT_SHA=' /opt/cangku/shared/backend.env; then sed -i "s/^SOURCE_GIT_SHA=.*/SOURCE_GIT_SHA=$DEPLOY_SHA/" /opt/cangku/shared/backend.env else From 5302b22fc1474ad4834ab79b6da1c9b81e179e27 Mon Sep 17 00:00:00 2001 From: lunanting Date: Thu, 17 Sep 2026 11:55:37 +0800 Subject: [PATCH 16/70] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E6=9C=8D=E5=8A=A1=E5=99=A8=E6=97=A7=E7=89=88Git?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/production/build-frontend.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/production/build-frontend.sh b/deploy/production/build-frontend.sh index 9daeb54..5a7408c 100755 --- a/deploy/production/build-frontend.sh +++ b/deploy/production/build-frontend.sh @@ -4,9 +4,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" FRONTEND_DIR="$REPO_DIR/frontend" -SOURCE_GIT_FULL_SHA="$(git -C "$REPO_DIR" rev-parse HEAD)" -SOURCE_GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short HEAD)" -SOURCE_GIT_REF="$(git -C "$REPO_DIR" branch --show-current 2>/dev/null || true)" +SOURCE_GIT_FULL_SHA="$(cd "$REPO_DIR" && git rev-parse HEAD)" +SOURCE_GIT_SHA="$(cd "$REPO_DIR" && git rev-parse --short HEAD)" +SOURCE_GIT_REF="$(cd "$REPO_DIR" && git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" SOURCE_GIT_REF="${SOURCE_GIT_REF:-main}" test -f "$FRONTEND_DIR/package-lock.json" || { From f69d26796ed881d935fb7adfa3721fd190a0073d Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 12:18:32 +0800 Subject: [PATCH 17/70] =?UTF-8?q?feat:=20MKH-BOM=20=E8=A1=A5=E8=B4=A7?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=E6=94=AF=E6=8C=81=E5=BE=85=E5=8F=91=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E9=A9=B1=E5=8A=A8=E4=B8=8E=E9=85=8D=E4=BB=B6=E4=B8=8D?= =?UTF-8?q?=E8=B6=B3=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补货预警在原有「销量 + 库存」口径之外增加两条判定,均不改变已上线行为: 1. 待发订单驱动 - MKH 成品条码历史无销售量、但存在未发货订单时,同样进入补货预警。 - 建议补货量按「未发货件数 − 现有库存」计算,无订单时不参与该口径。 - 列表新增待发货订单列,展示件数、单数与最早下单时间。 2. 配件不足 - 对判定为缺货或预警的商品,按 BOM 计算可组装数: 可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小值。 - 可组装数为 0 时标记「配件不足,无法补货」并保留在清单中, 建议补货量显示「—」,同时给出短板配件及其库存。 - 配件库存只认「简尚-崔桥工厂半成品包装项目仓」的现有库存, 按业务确认不计采购在途。 - 配件库存查询失败时降级为空索引并标记 componentStockDegraded, 不写入缓存,避免把瞬时故障固化为错误数据。 导出与通知文案同步:导出新增「配件不足」列、补货量按同一口径输出; 缺货通知在配件不足时插入短板配件提示并把建议补货量显示为「—」。 验证:backend 相关单测全部通过(新增 9 例);语法检查通过; 前端 eslint 零错误且 vite build 通过;接口实测与页面渲染结果见 PR。 --- backend/src/jobs/mkhReplenishAlertJob.js | 55 +- backend/src/services/mkhBomService.js | 688 +++++++++++++++++----- backend/test/mkhBomService.test.js | 248 +++++++- backend/test/mkhReplenishAlertJob.test.js | 55 ++ frontend/src/pages/MkhBomData/index.jsx | 154 ++++- frontend/src/pages/MkhBomData/style.css | 13 + 6 files changed, 1016 insertions(+), 197 deletions(-) diff --git a/backend/src/jobs/mkhReplenishAlertJob.js b/backend/src/jobs/mkhReplenishAlertJob.js index dd1f2c1..f0f2231 100644 --- a/backend/src/jobs/mkhReplenishAlertJob.js +++ b/backend/src/jobs/mkhReplenishAlertJob.js @@ -4,6 +4,11 @@ // // 业务口径(2026-09-17 业务确认): // ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货) +// · 含「订单驱动」商品:近7日日销 = 0、但存在未发货订单的 MKH 成品 +// (口径与取数见 mkhBomService 文件头 ⑩;正文里以「待发货订单 N 件」代替日销展示) +// · 含「配件不足」商品:配件现有库存可组装数 = 0(口径见 mkhBomService 文件头 ⑪) +// —— 这类商品**补不出去**,正文里明确写「配件不足,无法补货」并列出短板配件, +// 不给出建议补货量(否则业务会照着补不到的目标去采购) // ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送, // 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒 // ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人) @@ -123,12 +128,41 @@ function buildAlertSignature(alerts = []) { return crypto.createHash('sha1').update(parts.join('|'), 'utf8').digest('hex').slice(0, 16); } +/** + * 短板配件文案(见 mkhBomService 文件头 ⑪):只列「可支配数 ≤ 0」的配件, + * 即真正卡住组装的那些,最多 2 个,避免正文过长。 + */ +function formatShortageNote(row) { + const bottlenecks = Array.isArray(row?.bottleneckComponents) ? row.bottleneckComponents : []; + const detail = bottlenecks + .filter((item) => Number(item?.available) <= 0) + .slice(0, 2) + .map((item) => { + const occupied = Number(item.occupied) || 0; + return `${item.code}(库存 ${formatQty(item.stock)}` + + `${occupied > 0 ? ` − 加工单占用 ${formatQty(occupied)}` : ''})`; + }) + .join('、'); + return detail ? `,短板配件:${detail}` : ''; +} + function describeAlert(row) { + // 配件不足(见 mkhBomService 文件头 ⑪):可组装数 = 0 → 补不出来, + // 建议补货量不再给数字(显示「—」),与页面口径保持一致。 + const replenishText = row.componentShortage ? '建议补货 —' : `建议补货 ${formatQty(row.replenishQty)}`; + // 订单驱动(近7日日销=0 但有未发货订单,见 mkhBomService 文件头 ⑩): + // 此时日销必然是 0,直接显示会让人误以为「没销量却要补货」,改展示待发货订单量 + const metric = row.orderDriven + ? `待发货订单 ${formatQty(row.pendingOrderQty)} 件 | 现有库存 ${formatQty(row.stock)} | ${replenishText}(近7日暂无日销,按订单补)` + : `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | ${replenishText}`; const lines = [ `**${row.warningLevel === OUT_OF_STOCK ? '🔴 缺货' : '🟠 预警'}** — \`${row.skuId}\``, String(row.productName || '').trim() || '(无名称)', - `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | 建议补货 ${formatQty(row.replenishQty)}`, + metric, ]; + if (row.componentShortage) { + lines.splice(2, 0, `⚠️ **配件不足,无法补货**(现有配件可组装 ${formatQty(row.assemblableQty)} 台${formatShortageNote(row)})`); + } return lines.join('\n'); } @@ -168,9 +202,21 @@ function buildMkhReplenishNotification({ `---`, `当前共 **${totalCount}** 个 MKH 商品需要补货(缺货 ${totalOutOfStock} | ${snapshot.replenishDaysThreshold || 3}天内预警 ${totalWarningOnly})。`, `建议补货合计 ${formatQty(snapshot.replenishQtyTotal)} 件。`, + Number(snapshot.orderDrivenCount) > 0 + ? `其中 **${snapshot.orderDrivenCount}** 个近7日无日销、因有未发货订单(合计 ${formatQty(snapshot.pendingOrderQtyTotal)} 件)而列入预警。` + : '', + Number(snapshot.componentShortageCount) > 0 + ? `其中 **${snapshot.componentShortageCount}** 个因**配件库存不足**(可组装数 = 0)暂时补不出来,建议补货量显示「—」,需先采购配件。` + : '', snapshot.workOrderDegraded === true ? `> ⚠️ 加工单数据源暂不可用,在途量按 0 处理,预警数量可能偏多。` : '', + snapshot.pendingOrderDegraded === true + ? `> ⚠️ 待发货订单数据源暂不可用,待发货量按 0 处理,可能遗漏「无日销但有订单」的商品。` + : '', + snapshot.componentStockDegraded === true + ? `> ⚠️ 配件库存数据源暂不可用,本次**未做**「配件不足」判定,可能存在补不出来的商品。` + : '', ].filter(Boolean).join('\n\n') ); @@ -211,11 +257,18 @@ async function checkMkhReplenishAlerts(options = {}) { outOfStockCount: snapshot.outOfStockCount, warningOnlyCount: snapshot.warningOnlyCount, replenishQtyTotal: snapshot.replenishQtyTotal, + // 订单驱动(近7日无日销但有未发货订单)的商品数 / 待发货件数,口径见 mkhBomService 文件头 ⑩ + orderDrivenCount: snapshot.orderDrivenCount, + pendingOrderQtyTotal: snapshot.pendingOrderQtyTotal, + // 配件不足(可组装数 = 0)的商品数,口径见 mkhBomService 文件头 ⑪ + componentShortageCount: snapshot.componentShortageCount, freshCount: fresh.length, persistedCount: persisted.length, restoredCount: restored.length, monitorDate: snapshot.monitorDate, workOrderDegraded: snapshot.workOrderDegraded, + pendingOrderDegraded: snapshot.pendingOrderDegraded, + componentStockDegraded: snapshot.componentStockDegraded, }; if (!config.enabled) return { sent: false, reason: 'disabled', summary }; diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 32deaa2..7e8c0fc 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -37,29 +37,28 @@ // payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同 // (无 SKU 条码),也不能直接当配件 BOM。 // -// ⑤ 配件仓位 —— 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) +// ⑤ 配件仓位 —— **唯一数据源** 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) +// 2026-09-17 业务口径:仓位列**只认这张表**,其他来源一律不用(原来那套 +// 「库存表正数量仓(未分箱)→ 商品档案仓(暂无箱号)」的兜底链已删除 —— +// 那不是仓位,标出来会让人以为料就在那儿)。 // sku_id 可能带“-装箱序号”后缀(DNZ0019110N08V01-27),按基础配件编码匹配。 -// 取值是一条**四级兜底链**,宁可标注「未分箱 / 暂无箱号」也不误报具体货位: -// 1) 真货位 bin_group / bin → 「仓名 · bin_group / bin」 -// 2) 箱号(净数量 > 0 的 pack_id) → 「仓名 · 箱号 pack_id」 -// 3) 尚未分箱 → rpt.jst_inventoryInfo 正库存仓库 → 「仓名(未分箱)」 -// 4) 连库存记录也没有 → jst_sku.other_3 商品档案仓库 → 「仓名(暂无箱号)」 -// 取不到任何一层时前端显示「未维护」。 -// · 列表展开(/detail)、导出配件明细共用 loadComponentLocations(),按编码缓存 10 分钟。 +// 取值只有两层: +// 1) 真货位 bin_group / bin → 「仓名 · bin_group / bin」 +// 2) 箱号(净数量 > 0 的 pack_id) → 「仓名 · 箱号 pack_id」 +// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。 +// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(), +// 只打一条查询,按编码缓存 10 分钟。 // ⚠️ 关联列别踩(2026-09-17 实测):仓名必须用 `jst_warehouseInfo.Id = p.wh_id`; // 该表 `wms_co_id` **全表 NULL**,用 `w.wms_co_id = p.wms_co_id` 关联 85,152 行全部取不到仓名。 -// (库存表相反:`jst_inventoryInfo` 必须用 `w.wms_co_id = i.wms_co_id`,194,053 行仅 316 行无仓名。) // ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱** // (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、 // wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**。 -// 且表内容与 MKH 配件**零交集**:正式 BOM 的 784 个配件编码在该表一个都查不到; -// 表里存的是老型号半成品(DNZ0134xx / DNZ0199xx / DNZ0203xx,item_type=半成品)。 -// 结论:MKH 配件在第 1、2 层**必然落空**,实际显示值全部来自第 3 层 -// (jst_inventoryInfo 的实时库存仓库,如 DNZ039864N1410V01 → -// 「简尚-崔桥工厂半成品包装项目仓(未分箱)」),需业务在聚水潭维护装箱货位后 -// 第 1、2 层才会生效。 +// 且表内容与 MKH 配件**零交集**:正式 BOM 的 715 个配件编码在该表一个都查不到 +// (精确 + `code-%` 前缀都是 0;箱表里是 DNZ0135xx/0199xx/0203xx 老型号半成品)。 +// → MKH 配件仓位列当前**必然全部显示「未维护」**,这是数据源现状,不是代码问题; +// 需业务在聚水潭给配件做装箱/维护货位后才会自动出现(代码不用再动)。 // -// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总。 +// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。 // // ── ⑦ 补货预警(2026-09-17 业务确认口径)────────────────────────── // 规则:近 7 日日销 > 0 且 现有库存 < 3 天日销 → 预警补货 @@ -101,7 +100,59 @@ // 每批结果都进 officialBomDetailCache,重复导出直接命中缓存。 // ⚠️ products 形态**不返回 components** —— 4408 行商品若都挂上配件明细, // 响应会膨胀到几十 MB,而商品清单本身用不到明细。 - +// +// ── ⑩ 待发货订单(2026-09-17 业务确认口径)──────────────────────── +// 规则:**近 7 日日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 +// 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」 +// 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。 +// · 数据源:业务库 rpt.jst_Orders(订单**主表**,实时更新,实测最新到今天) +// ⚠️ 不要用 rpt.jst_OrderItem(订单明细表)—— 实测**已停更在 2026-07-16** +// (主表最新 o_id≈9409xxx,明细最大 o_id 仅 8439xxx;近 5 天订单有明细的为 0 条)。 +// 主表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]` +// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。 +// 实测逐单对账(2026-07-01~07-16,13.5 万单):全表普通订单仅 76.9% 能对上 +// (多为 `skus='0.'` 空汇总、组合装展开),但**MKH 成品订单 100% 可用**, +// 故本口径只解析 MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。 +// · 未发货判定:`send_date IS NULL AND status NOT IN ('Cancelled','Merged')` +// (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单; +// 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求) +// · 待发货订单量 = Σ 该成品在未发货订单中的件数(同一单多次出现会累加) +// · 预警级别(业务确认):现有库存 < 待发货订单量 → 缺货;否则 → 预警 +// · 建议补货量 = max(0, 待发货订单量 − 现有库存) +// ⚠️ 该分支无法算「可用天数」(日销为 0,除数为零): +// 缺货时记 stockDays = 0(保证排在最前),预警(库存已够发)时记 null。 +// ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)—— +// 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。 +// 实测(2026-09-17):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件, +// 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。 +// +// ── ⑪ 配件不足 / 可组装数(2026-09-17 业务确认口径)──────────────── +// 规则:**缺货 / 预警的 MKH 成品,若其配件现有库存「可组装数 = 0」,则提示 +// 「配件不足无法补货」**,建议补货量显示为「—」(不给出补不到的补货目标)。 +// · 可组装数 = min over 配件 ⌊ 可支配配件数 ÷ 单台用量 ⌋ (木桶短板决定) +// 可支配配件数 = 现有库存 − 未完成加工单占用(不足 0 取 0) +// ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件 +// 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集: +// 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) +// · 配件现有库存 rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓 +// (wms_co_id = 15718196)—— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。 +// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**, +// 跨仓正库存为 0,所以不跨仓汇总。 +// · 单台用量 rm_qty(jst_skuBomItem)实测取值为 1 / 2 / 4,必须参与计算,不能当 1。 +// ⚠️ 不能用 `jst_packInventoryInfo` 当配件库存 —— 它是「箱及仓位库存」, +// 与 MKH 配件零交集(见 ⑤),仓位口径专用。 +// · 采购在途(purchase_qty)**不计入**可用量(业务确认:只认现有库存)。 +// 锁定列(order_lock / pick_lock / lock_qty / virtual_qty)实测几乎全为 0 +// (本页 80 个配件里仅 2 条非零且值为 1),故直接用 qty。 +// · 判定范围:**缺货 + 预警都判**(业务确认),不改变原有预警级别与排序, +// 只额外打标记;「已排产」商品本来就不预警,不参与判定。 +// · 无 BOM 配件明细的商品 → 可组装数未知(组件缺失 ≠ 配件不足),不做判定。 +// 实测(2026-09-17):预警 26 个(缺货 9 + 预警 17)中 **10 个可组装数 = 0** +// (缺货 6 + 预警 4);扣加工单占用前后仅 1 个商品变化(185 → 180 台), +// 说明占用规模小但口径上必须扣。 +// ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标), +// 接口透出 componentStockDegraded=true。 +// const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -109,9 +160,8 @@ const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 200; const BOM_CACHE_TTL_MS = 2 * 60 * 1000; const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000; -// 配件仓位 + 配件库存(箱及仓位库 + 库存表)按编码统一缓存;单批编码数受 MSSQL -// 单语句 2100 参数上限约束(每个编码最多占 2 个参数:精确匹配 + 带后缀前缀匹配), -// 300 × 2 = 600 个参数,安全。 +// 配件仓位(只查「箱及仓位库存」)按编码缓存;单批编码数受 MSSQL 单语句 2100 参数 +// 上限约束(每个编码占 2 个参数:精确匹配 + 带后缀前缀匹配),300 × 2 = 600 个参数,安全。 const COMPONENT_LOCATION_CACHE_TTL_MS = 10 * 60 * 1000; const COMPONENT_LOCATION_CHUNK_SIZE = 300; @@ -152,6 +202,28 @@ const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_ // 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量 const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000; +// ── 待发货订单参数(见文件头 ⑩)────────────────────────────────── +// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里 +const PENDING_ORDER_TABLE = 'rpt.jst_Orders'; +// 不计入待发货需求的状态:Cancelled 已取消 / Merged 已合并进别的单 +const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']); +// 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s) +const PENDING_ORDER_SKU_LIKE = '%MKH%'; +// 订单数据源异常时的降级缓存时长 +const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000; + +// ── 配件不足 / 可组装数参数(见文件头 ⑪)──────────────────────── +// 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id +const COMPONENT_STOCK_WMS_CO_ID = 15718196; +const COMPONENT_STOCK_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓'; +// 单批编码数:每个编码占 1 个参数,300 远低于 MSSQL 单语句 2100 上限 +const COMPONENT_STOCK_CHUNK_SIZE = 300; +const COMPONENT_STOCK_CACHE_TTL_MS = 10 * 60 * 1000; +// 配件库存不可用时的降级缓存时长(短一点,尽快恢复判定) +const COMPONENT_STOCK_DEGRADED_TTL_MS = 60 * 1000; +// 文案 / Tooltip 里最多列出的短板配件数 +const COMPONENT_SHORTAGE_MAX_LIST = 3; + const toText = (value) => { if (value === null || value === undefined) return null; const text = String(value).trim(); @@ -199,6 +271,10 @@ let mkhProductCache = null; let replenishIndexCache = null; // 加工单在途量索引(见文件头 ⑧) let workOrderIndexCache = null; +// 待发货订单索引(见文件头 ⑩) +let pendingOrderIndexCache = null; +// 配件库存(按编码缓存,见文件头 ⑪) +const componentStockCache = new Map(); const TRUTHY_FLAGS = new Set(['1', 'true', 'yes', 'on']); @@ -685,16 +761,21 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( /** * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 * - * 口径:近7日日销 > 0 且 现有库存 < 3 天日销 → 预警。 - * 无近7日日销的商品不参与预警,标记为「无销量」。 + * 口径(见文件头 ⑦ + ⑩): + * ① 近7日日销 > 0 且 现有库存 < 3 天日销 → 预警 + * ② 近7日日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩) + * 两者都不满足 → 无销量,不参与预警。 + * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 * * @param {object} row - * row.sevenAvgSale 近7日日均发货量 - * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) - * row.monitorStock 监控快照库存(fallback) - * row.safetyDays 源系统安全天数(仅展示) - * row.suggestStock 源系统建议备货量(仅展示对照) - * row.monitorDate 监控快照日期 + * row.sevenAvgSale 近7日日均发货量 + * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) + * row.monitorStock 监控快照库存(fallback) + * row.safetyDays 源系统安全天数(仅展示) + * row.suggestStock 源系统建议备货量(仅展示对照) + * row.monitorDate 监控快照日期 + * row.pendingWorkOrderQty 在途加工量(未完成且非作废) + * row.pendingOrderQty 待发货订单量(未发货订单件数) */ function buildReplenishMetrics(row = {}) { const dailySale = Math.max(0, toNumber(row.sevenAvgSale) ?? 0); @@ -708,6 +789,12 @@ function buildReplenishMetrics(row = {}) { const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0); const hasPendingWorkOrder = pendingWorkOrderQty > 0 || pendingWorkOrderCount > 0; + // 待发货订单(见文件头 ⑩):当前未发货订单里的件数 + const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0); + const pendingOrderCount = Math.max(0, toNumber(row.pendingOrderCount) ?? 0); + // 快照日销还是 0、但已经有订单 → 由订单驱动预警 + const orderDriven = dailySale <= 0 && pendingOrderQty > 0; + const base = { dailySale, stock, @@ -716,6 +803,10 @@ function buildReplenishMetrics(row = {}) { hasPendingWorkOrder, pendingWorkOrderEta: toDateText(row.pendingWorkOrderEta), pendingWorkOrderNo: toText(row.pendingWorkOrderNo), + pendingOrderQty, + pendingOrderCount, + pendingOrderEarliest: toDateText(row.pendingOrderEarliest), + orderDriven, thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)), stockSource: hasRealtime ? 'realtime' : 'monitor', safetyDays: toNumber(row.safetyDays), @@ -723,7 +814,8 @@ function buildReplenishMetrics(row = {}) { monitorDate: toDateText(row.monitorDate), }; - if (dailySale <= 0) { + // 既无近7日日销、也无未发货订单 → 不参与预警 + if (dailySale <= 0 && !orderDriven) { return { ...base, thresholdQty: 0, @@ -735,13 +827,11 @@ function buildReplenishMetrics(row = {}) { }; } - const stockDays = stock / dailySale; - - // 已有未完成加工单 → 不再列入补货预警(业务口径,见文件头 ⑧) + // 已有未完成加工单 → 不再列入补货预警(业务口径见文件头 ⑧;优先级高于订单驱动) if (hasPendingWorkOrder) { return { ...base, - stockDays: Number(stockDays.toFixed(2)), + stockDays: dailySale > 0 ? Number((stock / dailySale).toFixed(2)) : null, replenishQty: 0, warningLevel: WARNING_LEVEL.SCHEDULED, warningOrder: 4, @@ -749,6 +839,21 @@ function buildReplenishMetrics(row = {}) { }; } + // 订单驱动(见文件头 ⑩):日销为 0 算不出可用天数,改按「能否覆盖待发货」判级 + if (orderDriven) { + const notEnough = stock < pendingOrderQty; + return { + ...base, + // 缺货记 0(保证排在最前);库存已够发货时记 null(无日销,算不出天数) + stockDays: notEnough ? 0 : null, + replenishQty: Math.max(0, pendingOrderQty - stock), + warningLevel: notEnough ? WARNING_LEVEL.OUT_OF_STOCK : WARNING_LEVEL.WARNING, + warningOrder: notEnough ? 1 : 2, + hasWarning: true, + }; + } + + const stockDays = stock / dailySale; const replenishQty = Math.max(0, Math.ceil(base.thresholdQty - stock)); const level = stockDays <= 0 ? WARNING_LEVEL.OUT_OF_STOCK @@ -765,6 +870,80 @@ function buildReplenishMetrics(row = {}) { }; } +/** + * 未完成加工单对配件的占用(纯函数,见文件头 ⑪)。 + * 占用[配件编码] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) + * 加工单在途的成品已经把配件预定/领走,计算可组装数时必须先扣掉,否则会高估补货能力。 + * @param {Array<{skuId: string, pendingWorkOrderQty: number}>} workOrders 未完成加工单(成品级) + * @param {Map} bomBySku 归一化成品编码 → 配件明细 + * @returns {Map} 配件编码 → 被占用量 + */ +function buildComponentOccupation(workOrders = [], bomBySku) { + const occupation = new Map(); + if (!bomBySku || typeof bomBySku.get !== 'function') return occupation; + + workOrders.forEach((workOrder) => { + const pending = Math.max(0, toNumber(workOrder?.pendingWorkOrderQty) ?? 0); + if (pending <= 0) return; + const key = normalizeCode(workOrder.skuId); + const components = key ? bomBySku.get(key) : null; + if (!Array.isArray(components) || components.length === 0) return; + components.forEach((component) => { + const code = toText(component?.barcode) || toText(component?.code); + if (!code) return; + const perQty = Math.max(1, toNumber(component.qty) ?? 1); + occupation.set(code, (occupation.get(code) || 0) + pending * perQty); + }); + }); + + return occupation; +} + +/** + * 配件可组装数 / 配件不足判定(纯函数,见文件头 ⑪)。 + * 可组装数 = min over 配件 ⌊ max(0, 库存 − 加工单占用) ÷ 单台用量 ⌋ + * 「木桶短板」决定能组装几台;可组装数 = 0 即配件不足、无法补货。 + * @param {Array} components 配件明细 [{ barcode, code, name, qty }] + * @param {Map} stockByCode 配件现有库存 + * @param {Map} occupiedByCode 加工单占用 + * @returns {null | {assemblableQty:number, shortage:boolean, bottlenecks:Array}} 无配件明细时返回 null(未知 ≠ 不足) + */ +function buildComponentReadiness(components, stockByCode, occupiedByCode) { + if (!Array.isArray(components) || components.length === 0) return null; + + let assemblable = Infinity; + const bottlenecks = []; + components.forEach((component) => { + const code = toText(component?.barcode) || toText(component?.code); + if (!code) return; + const perQty = Math.max(1, toNumber(component.qty) ?? 1); + const stock = Math.max(0, toNumber(stockByCode?.get(code)) ?? 0); + const occupied = Math.max(0, toNumber(occupiedByCode?.get(code)) ?? 0); + const available = Math.max(0, stock - occupied); + const can = Math.floor(available / perQty); + if (can < assemblable) assemblable = can; + bottlenecks.push({ + code, + name: toText(component.name) || code, + perQty, + stock, + occupied, + available, + can, + }); + }); + + if (!Number.isFinite(assemblable)) return null; + // 短板优先(可组装数最少),同分按编码稳定排序 + bottlenecks.sort((a, b) => a.can - b.can || a.code.localeCompare(b.code)); + + return { + assemblableQty: assemblable, + shortage: assemblable <= 0, + bottlenecks: bottlenecks.slice(0, COMPONENT_SHORTAGE_MAX_LIST), + }; +} + /** * MKH 商品集合(指定仓库的模块化成品)—— 进程内缓存。 * 返回 [{ skuId, productName, category, material }],按编码升序。 @@ -843,6 +1022,8 @@ async function loadPendingWorkOrderIndex() { const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0); if (pendingQty <= 0 && orderCount <= 0) return; bySku.set(key, { + // 原始商品编码:配件占用展开(见文件头 ⑪)需要拿它回查 BOM,故一并保留 + skuId: toText(row.skuId), pendingWorkOrderQty: pendingQty, pendingWorkOrderCount: orderCount, pendingWorkOrderEta: row.eta instanceof Date ? row.eta : null, @@ -866,6 +1047,110 @@ async function loadPendingWorkOrderIndex() { } } +/** + * 解析 jst_Orders.skus 文本列 → [{ code, qty }](纯函数,便于回归测试)。 + * 格式:`<总件数>.<条码>[*数量][,<条码>[*数量]]…` + * 例:`1.MKHDNZ0409260700426` → 1 个成品 1 件 + * `2.A*1,B*1` → A、B 各 1 件 + * `3.C*1,D*1,E*1` → 总件数前缀只作校验,不参与计算 + * · 省略 `*数量` 时按 1 件;解析不了的片段直接丢弃(可用率实测见文件头 ⑩) + */ +function parseOrderSkus(skus) { + const text = toText(skus); + if (!text) return []; + const body = text.replace(/^\d+\./, ''); + const items = []; + body.split(',').forEach((part) => { + const segment = part.trim(); + if (!segment) return; + const matched = segment.match(/^(.*?)(?:\*(\d+))?$/); + if (!matched) return; + const code = toText(matched[1]); + if (!code) return; + const parsedQty = matched[2] ? Number(matched[2]) : 1; + items.push({ code, qty: Number.isFinite(parsedQty) && parsedQty > 0 ? parsedQty : 1 }); + }); + return items; +} + +/** 是否为 MKH **成品**编码(`MKH-` 打头的是组合装,见文件头 ⑩,不计入成品需求) */ +const isMkhProductCode = (code) => { + const text = toText(code); + return Boolean(text) && /^MKH/i.test(text) && !/^MKH-/i.test(text); +}; + +/** + * 待发货订单索引 —— 进程内缓存 + * { bySku: Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }>, degraded }。 + * 口径见文件头 ⑩:未发货 = send_date 为空且状态非取消/合并,商品取自主表 skus 文本列。 + * ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。 + */ +async function loadPendingOrderIndex() { + const now = Date.now(); + if (pendingOrderIndexCache?.value && pendingOrderIndexCache.expiresAt > now) { + return pendingOrderIndexCache.value; + } + if (pendingOrderIndexCache?.pending) return pendingOrderIndexCache.pending; + + const statusParams = {}; + const statusPlaceholders = PENDING_ORDER_EXCLUDED_STATUS.map((status, index) => { + statusParams[`pendingOrderStatus${index}`] = status; + return `@pendingOrderStatus${index}`; + }).join(', '); + + const pending = cloudDatabase.query( + `SELECT o.o_id AS oId, + o.order_date AS orderDate, + o.skus AS skus + FROM ${PENDING_ORDER_TABLE} o WITH (NOLOCK) + WHERE o.skus LIKE @pendingOrderSkuLike + AND o.send_date IS NULL + AND o.status NOT IN (${statusPlaceholders})`, + { pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams }, + { requestTimeout: 180000 } + ).then((rows) => { + const bySku = new Map(); + rows.forEach((row) => { + const orderDate = row.orderDate instanceof Date ? row.orderDate : null; + parseOrderSkus(row.skus).forEach(({ code, qty }) => { + if (!isMkhProductCode(code)) return; + const key = normalizeCode(code); + if (!key) return; + const entry = bySku.get(key) || { + pendingOrderQty: 0, + pendingOrderCount: 0, + pendingOrderEarliest: null, + }; + entry.pendingOrderQty += qty; + entry.pendingOrderCount += 1; + if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { + entry.pendingOrderEarliest = orderDate; + } + bySku.set(key, entry); + }); + }); + return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; + }).catch((error) => { + console.warn('[MKH-BOM] 待发货订单读取失败,已按「无待发货订单」降级:', error.message); + return { + bySku: new Map(), + degraded: true, + error: error.message, + ttl: PENDING_ORDER_DEGRADED_TTL_MS, + }; + }); + + pendingOrderIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + pendingOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + return value; + } catch (error) { + pendingOrderIndexCache = null; + throw error; + } +} + /** * 补货预警索引 —— 进程内缓存 { bySku: Map<归一化编码, 指标>, monitorDate }。 * 一次读入品牌仓监控快照与实时库存,指标计算全部在应用层完成(原因见文件头 ⑦)。 @@ -899,8 +1184,11 @@ async function loadReplenishIndex() { ), // 在途加工单(见文件头 ⑧) loadPendingWorkOrderIndex(), - ]).then(([monitorRows, stockRows, workOrderIndex]) => { + // 待发货订单(见文件头 ⑩) + loadPendingOrderIndex(), + ]).then(([monitorRows, stockRows, workOrderIndex, pendingOrderIndex]) => { const workOrderBySku = workOrderIndex.bySku; + const pendingOrderBySku = pendingOrderIndex.bySku; const latestStock = new Map(); stockRows.forEach((row) => { const key = normalizeCode(row.skuId); @@ -924,13 +1212,16 @@ async function loadReplenishIndex() { suggestStock: row.suggestStock, monitorDate: row.monitorDate, ...(workOrderBySku.get(key) || null), + ...(pendingOrderBySku.get(key) || null), })); }); return { bySku, workOrderBySku, + pendingOrderBySku, workOrderDegraded: workOrderIndex.degraded === true, + pendingOrderDegraded: pendingOrderIndex.degraded === true, monitorDate, }; }); @@ -946,6 +1237,112 @@ async function loadReplenishIndex() { } } +/** + * 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。 + * · 按基础配件编码精确匹配,SUM(qty) 汇总 + * · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存) + * · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标), + * 且失败的分片不写缓存,下次调用重试 + * @returns {Promise<{stockMap: Map, degraded: boolean}>} + */ +async function loadComponentStock(codes = []) { + const now = Date.now(); + const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); + const stockMap = new Map(); + const missing = []; + unique.forEach((code) => { + const hit = componentStockCache.get(code); + if (hit && hit.expiresAt > now) stockMap.set(code, hit.qty); + else missing.push(code); + }); + if (missing.length === 0) return { stockMap, degraded: false }; + + for (let i = 0; i < missing.length; i += COMPONENT_STOCK_CHUNK_SIZE) { + const chunk = missing.slice(i, i + COMPONENT_STOCK_CHUNK_SIZE); + try { + const params = { componentStockWmsCoId: COMPONENT_STOCK_WMS_CO_ID }; + const placeholders = chunk.map((code, index) => { + params[`cs${index}`] = code; + return `@cs${index}`; + }); + // eslint-disable-next-line no-await-in-loop -- 有意分片串行,避免一次打满连接池 + const rows = await cloudDatabase.query( + `SELECT i.sku_id AS code, SUM(i.qty) AS qty + FROM dbo.jst_inventoryInfo i WITH (NOLOCK) + WHERE i.wms_co_id = @componentStockWmsCoId + AND i.sku_id IN (${placeholders.join(', ')}) + GROUP BY i.sku_id`, + params, + { requestTimeout: 120000 } + ); + const found = new Map(); + rows.forEach((row) => { + const code = toText(row.code); + if (code) found.set(code, toNumber(row.qty) ?? 0); + }); + const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS; + chunk.forEach((code) => { + const qty = found.get(code) ?? 0; + componentStockCache.set(code, { qty, expiresAt }); + stockMap.set(code, qty); + }); + } catch (error) { + console.warn('[MKH-BOM] 配件库存查询失败,本次不做配件不足判定:', error.message); + return { stockMap, degraded: true }; + } + } + + return { stockMap, degraded: false }; +} + +/** + * 批量计算商品的可组装数(见文件头 ⑪):列表 / 推送共用,防止口径漂移。 + * 依赖三份已缓存的索引,本身不再单独缓存: + * · 成品 BOM 明细 —— loadOfficialBomForSkuBatches(按 SKU 缓存 15 分钟) + * · 配件现有库存 —— loadComponentStock(按编码缓存 10 分钟) + * · 加工单占用 —— loadPendingWorkOrderIndex(缓存 10 分钟)按 BOM 展开 + * @param {string[]} skuIds 成品编码(只传缺货 / 预警的商品,避免全量扫描) + * @returns {Promise<{bySku: Map, degraded: boolean}>} key = 归一化成品编码 + */ +async function loadMkhComponentReadiness(skuIds = []) { + const requested = Array.from(new Set(skuIds.map((skuId) => toText(skuId)).filter(Boolean))); + if (requested.length === 0) return { bySku: new Map(), degraded: false }; + + const [alertBom, workOrderIndex] = await Promise.all([ + loadOfficialBomForSkuBatches(requested), + loadPendingWorkOrderIndex(), + ]); + + // 配件全集:预警商品自己的配件 + 加工单商品的配件(后者用于算占用) + const workOrders = [...workOrderIndex.bySku.values()]; + const workOrderSkuIds = workOrders.map((item) => item.skuId).filter(Boolean); + const workOrderBom = workOrderSkuIds.length > 0 + ? await loadOfficialBomForSkuBatches(workOrderSkuIds) + : { bySku: new Map() }; + + const occupation = buildComponentOccupation(workOrders, workOrderBom.bySku); + + const codes = new Set(occupation.keys()); + alertBom.bySku.forEach((components) => { + components.forEach((component) => { + const code = toText(component?.barcode) || toText(component?.code); + if (code) codes.add(code); + }); + }); + + const { stockMap, degraded } = await loadComponentStock([...codes]); + // 配件库存不可用 → 整批放弃判定(组件缺失 ≠ 配件不足,误标会误导业务) + if (degraded) return { bySku: new Map(), degraded: true }; + + const bySku = new Map(); + alertBom.bySku.forEach((components, key) => { + const readiness = buildComponentReadiness(components, stockMap, occupation); + if (readiness) bySku.set(key, readiness); + }); + + return { bySku, degraded: false }; +} + /** * MKH 商品 × 库存/日销指标 × 在途加工单(内存合并)。 * 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。 @@ -960,14 +1357,36 @@ async function buildMergedReplenishRows() { const key = normalizeCode(product.skuId); const metrics = replenishIndex.bySku.get(key); if (metrics) return { ...product, ...metrics }; - // 监控快照里没有该商品时,仍要带上在途加工单信息(见文件头 ⑧) + // 监控快照里没有该商品时,仍要带上在途加工单(⑧)与待发货订单(⑩)信息 return { ...product, - ...buildReplenishMetrics(replenishIndex.workOrderBySku.get(key) || {}), + ...buildReplenishMetrics({ + ...(replenishIndex.workOrderBySku?.get(key) || null), + ...(replenishIndex.pendingOrderBySku?.get(key) || null), + }), }; }); - return { rows, replenishIndex }; + // 配件可组装数 / 配件不足(见文件头 ⑪):只对「缺货 / 预警」的商品计算, + // 「已排产」「无销量」等本来不需要补货的商品不参与,避免无谓查询。 + const alertSkuIds = rows.filter((row) => row.hasWarning).map((row) => row.skuId); + const readiness = alertSkuIds.length > 0 + ? await loadMkhComponentReadiness(alertSkuIds) + : { bySku: new Map(), degraded: false }; + + rows.forEach((row) => { + const hit = row.hasWarning ? readiness.bySku.get(normalizeCode(row.skuId)) : null; + // 未判定(非预警 / 无 BOM 明细 / 配件库存降级)一律保持 false + null,前端据此显示「—」 + row.componentShortage = hit?.shortage === true; + row.assemblableQty = hit ? hit.assemblableQty : null; + row.bottleneckComponents = hit ? hit.bottlenecks : []; + }); + + return { + rows, + replenishIndex, + componentStockDegraded: readiness.degraded === true, + }; } /** @@ -982,7 +1401,7 @@ async function listReplenishAlertRows(options = {}) { ? [WARNING_LEVEL.OUT_OF_STOCK] : [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING]; - const { rows, replenishIndex } = await buildMergedReplenishRows(); + const { rows, replenishIndex, componentStockDegraded } = await buildMergedReplenishRows(); const alerts = rows.filter((row) => levels.includes(row.warningLevel)); // 最紧急的排前面:缺货 → 可用天数最少 → 编码升序 @@ -1000,8 +1419,17 @@ async function listReplenishAlertRows(options = {}) { replenishQtyTotal: Number( alerts.reduce((sum, row) => sum + (toNumber(row.replenishQty) || 0), 0).toFixed(2) ), + // 订单驱动预警(日销=0 但有未发货订单)的条数,见文件头 ⑩ + orderDrivenCount: alerts.filter((row) => row.orderDriven).length, + pendingOrderQtyTotal: Number( + alerts.reduce((sum, row) => sum + (toNumber(row.pendingOrderQty) || 0), 0).toFixed(2) + ), monitorDate: replenishIndex.monitorDate, workOrderDegraded: replenishIndex.workOrderDegraded === true, + pendingOrderDegraded: replenishIndex.pendingOrderDegraded === true, + // 配件不足(见文件头 ⑪):可组装数 = 0、补不出货的商品数 + componentShortageCount: alerts.filter((row) => row.componentShortage).length, + componentStockDegraded: componentStockDegraded === true, replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, saleWindowDays: SALE_WINDOW_DAYS, scope: scope === 'outOfStock' ? 'outOfStock' : 'warning', @@ -1051,113 +1479,40 @@ function buildPackLocationMap(rows, componentCodes) { return result; } -function buildInventoryWarehouseMap(rows, componentCodes) { - const positiveWarehouses = new Map(componentCodes.map((code) => [code, new Set()])); - const defaultWarehouses = new Map(); - - rows.forEach((row) => { - const componentCode = componentCodes.find((code) => normalizeCode(code) === normalizeCode(row.skuId)); - if (!componentCode) return; - - const defaultWarehouse = toText(row.defaultWarehouse); - if (defaultWarehouse) defaultWarehouses.set(componentCode, defaultWarehouse); - - const warehouseName = toText(row.warehouseName); - if (warehouseName && (toNumber(row.availableQty) ?? 0) > 0) { - positiveWarehouses.get(componentCode).add(warehouseName); - } - }); - - const result = new Map(); - componentCodes.forEach((code) => { - const warehouses = [...positiveWarehouses.get(code)]; - if (warehouses.length > 0) { - result.set(code, `${warehouses.join('、')}(未分箱)`); - return; - } - - const defaultWarehouse = defaultWarehouses.get(code); - if (defaultWarehouse) result.set(code, `${defaultWarehouse}(暂无箱号)`); - }); - return result; -} - /** - * 配件仓位 + 配件库存批量查询(列表展开 / 单品明细 / 导出配件明细共用)。 - * 数据源:rpt.jst_inventoryInfo(库存,及「未分箱 / 暂无箱号」兜底仓)、 - * rpt.jst_packInventoryInfo(箱及仓位库)、jst_sku.other_3(商品档案仓) + * 配件仓位批量查询(列表展开 / 单品明细 / 导出配件明细共用)。 + * 数据源**只有一个**:rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」), + * 仓名左连 rpt.jst_warehouseInfo(`Id = wh_id`,见文件头 ⑤ 的关联列警告)。 * · 按基础配件编码匹配,兼容库内 sku_id 带「-装箱序号」后缀的形态 - * · 仓位走文件头 ⑤ 的四级兜底链:真货位 → 箱号 → 库存仓(未分箱)→ 档案仓(暂无箱号) + * · 取值只有两层:真货位 bin_group / bin → 箱号 pack_id; + * 该表查不到就是空,**不回退**库存表仓 / 商品档案仓(那不是仓位) * · 单个配件跨多个箱时最多列出 10 个位置,超出补「等 N 个位置」 - * · 结果按编码缓存(TTL 10 分钟);**两条查询都成功才写缓存**, - * 任一降级都不写,避免把不完整结果固化 10 分钟 - * · 单元格超限由 COMPONENT_LOCATION_CHUNK_SIZE 分片,见常量处说明 - * @returns {Promise<{stockMap: Map, locationMap: Map}>} + * · 结果按编码缓存(TTL 10 分钟);查询失败**不写缓存**,下次调用重试 + * @returns {Promise>} 配件编码 → 仓位文本(查不到的编码不含 key) */ -async function loadComponentLocationData(codes = []) { +async function loadComponentLocations(codes = []) { const now = Date.now(); const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); - const stockMap = new Map(); const locationMap = new Map(); const missing = []; unique.forEach((code) => { - const hit = componentLocationCache.get(normalizeCode(code)); + const hit = componentLocationCache.get(code); if (hit && hit.expiresAt > now) { - stockMap.set(code, hit.stock ?? 0); if (hit.location) locationMap.set(code, hit.location); } else { missing.push(code); } }); - if (missing.length === 0) return { stockMap, locationMap }; + if (missing.length === 0) return locationMap; + // 单元格超限由 COMPONENT_LOCATION_CHUNK_SIZE 分片,见常量处说明 const chunks = []; for (let i = 0; i < missing.length; i += COMPONENT_LOCATION_CHUNK_SIZE) { chunks.push(missing.slice(i, i + COMPONENT_LOCATION_CHUNK_SIZE)); } - // 组内串行(单个接口最多同时打两条查询,避免拖垮连接池) + // 组间串行(本函数只打一条查询,避免一次塞满连接池) for (const chunk of chunks) { - const packLocations = new Map(); // 第 1、2 层:真货位 / 箱号 - let fallbackLocations = new Map(); // 第 3、4 层:未分箱 / 暂无箱号 - let stockQuerySucceeded = false; - let packQuerySucceeded = false; - - // ① 配件库存 + 仓名 —— 一次查询同时供 stockMap 与兜底仓位使用 - try { - const parameters = {}; - const placeholders = chunk.map((code, index) => { - parameters[`c${index}`] = code; - return `@c${index}`; - }).join(', '); - const stockRows = await cloudDatabase.query( - `SELECT s.sku_id AS skuId, - s.other_3 AS defaultWarehouse, - w.name AS warehouseName, - SUM(COALESCE(i.qty, 0)) AS qty, - SUM(COALESCE(i.qty, 0) - - COALESCE(i.order_lock, 0) - - COALESCE(i.pick_lock, 0)) AS availableQty - FROM dbo.jst_sku s WITH (NOLOCK) - LEFT JOIN rpt.jst_inventoryInfo i WITH (NOLOCK) ON i.sku_id = s.sku_id - LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = i.wms_co_id - WHERE s.sku_id IN (${placeholders}) - GROUP BY s.sku_id, s.other_3, i.wms_co_id, w.name`, - parameters, - { requestTimeout: 180000 } - ); - stockRows.forEach((row) => { - const code = chunk.find((candidate) => normalizeCode(candidate) === normalizeCode(row.skuId)); - if (!code) return; - stockMap.set(code, (stockMap.get(code) || 0) + (toNumber(row.qty) ?? 0)); - }); - fallbackLocations = buildInventoryWarehouseMap(stockRows, chunk); - stockQuerySucceeded = true; - } catch (error) { - console.warn('[MkhBom] 配件库存查询降级:', error.message); - } - - // ② 箱及仓位(第 1、2 层)—— 仓名必须用 jst_warehouseInfo.Id = p.wh_id,见文件头 ⑤ try { const params = {}; const conditions = chunk.map((code, index) => { @@ -1165,14 +1520,14 @@ async function loadComponentLocationData(codes = []) { params[`lp${index}`] = `${code}-%`; return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`; }); - const locRows = await cloudDatabase.query( - `SELECT p.sku_id AS skuId, - p.pack_id AS packId, - p.wh_id AS warehouseId, - p.bin AS bin, + const rows = await cloudDatabase.query( + `SELECT p.sku_id AS skuId, + p.pack_id AS packId, + p.wh_id AS warehouseId, + p.bin AS bin, p.bin_group AS binGroup, - w.name AS warehouseName, - SUM(p.qty) AS qty + w.name AS warehouseName, + SUM(p.qty) AS qty FROM rpt.jst_packInventoryInfo p WITH (NOLOCK) LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.Id = p.wh_id WHERE ${conditions.join(' OR ')} @@ -1181,36 +1536,19 @@ async function loadComponentLocationData(codes = []) { params, { requestTimeout: 120000 } ); - buildPackLocationMap(locRows, chunk).forEach((location, code) => { - packLocations.set(code, location); + const byCode = buildPackLocationMap(rows, chunk); + const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS; + chunk.forEach((code) => { + const location = byCode.get(code) || null; + componentLocationCache.set(code, { location, expiresAt }); + if (location) locationMap.set(code, location); }); - packQuerySucceeded = true; } catch (error) { - console.warn('[MkhBom] 配件箱号/仓位查询降级:', error.message); + // 失败不写缓存,下次调用重试 + console.warn('[MkhBom] 配件仓位查询降级:', error.message); } - - const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS; - chunk.forEach((code) => { - const location = packLocations.get(code) || fallbackLocations.get(code) || null; - if (stockQuerySucceeded && packQuerySucceeded) { - componentLocationCache.set(normalizeCode(code), { - stock: stockMap.get(code) ?? 0, - location, - expiresAt, - }); - } - if (location) locationMap.set(code, location); - }); } - return { stockMap, locationMap }; -} - -/** - * 兼容入口:只要仓位映射(列表展开 / 导出配件明细)。 - */ -async function loadComponentLocations(codes = []) { - const { locationMap } = await loadComponentLocationData(codes); return locationMap; } @@ -1235,7 +1573,7 @@ async function getMkhBomList(query = {}) { loadPgBom(), ]); // 商品 × 库存/日销指标 × 在途加工单(内存合并,与补货预警推送共用) - const { rows: merged, replenishIndex } = mergedResult; + const { rows: merged, replenishIndex, componentStockDegraded } = mergedResult; // 全量统计(不受关键词 / 范围筛选影响) const warningRows = merged.filter((row) => row.hasWarning); @@ -1250,6 +1588,14 @@ async function getMkhBomList(query = {}) { pendingWorkOrderQtyTotal: Number( merged.reduce((sum, row) => sum + (toNumber(row.pendingWorkOrderQty) || 0), 0).toFixed(2) ), + // 待发货订单(见文件头 ⑩):有未发货订单的成品数 / 件数,以及其中「靠订单才预警」的数量 + pendingOrderSkuCount: merged.filter((row) => (toNumber(row.pendingOrderQty) || 0) > 0).length, + pendingOrderQtyTotal: Number( + merged.reduce((sum, row) => sum + (toNumber(row.pendingOrderQty) || 0), 0).toFixed(2) + ), + orderDrivenCount: warningRows.filter((row) => row.orderDriven).length, + // 配件不足(见文件头 ⑪):可组装数 = 0,即现有配件凑不出一台、补不出来的商品数 + componentShortageCount: warningRows.filter((row) => row.componentShortage).length, monitorDate: replenishIndex.monitorDate, }; @@ -1296,6 +1642,11 @@ async function getMkhBomList(query = {}) { // 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理 workOrderDegraded: replenishIndex.workOrderDegraded === true, workOrderWarehouse: WORK_ORDER_WAREHOUSE, + // 待发货订单数据源不可用时为 true,此时待发货量按 0 处理(退化为旧口径) + pendingOrderDegraded: replenishIndex.pendingOrderDegraded === true, + // 配件库存数据源不可用时为 true,此时**不做**配件不足判定(见文件头 ⑪) + componentStockDegraded: componentStockDegraded === true, + componentStockWarehouse: COMPONENT_STOCK_WAREHOUSE, warningInPage: rows.filter((row) => row.hasWarning).length, }; } @@ -1341,9 +1692,8 @@ async function getMkhBomDetail(mkhSkuId) { const components = matched?.components || []; const codes = components.map((item) => item.barcode).filter(Boolean); - // 与列表展开 / 导出配件明细走同一个批量加载器 —— - // 含仓名解析、文件头 ⑤ 的四级兜底链、库存与仓位的统一 10 分钟缓存 - const { stockMap, locationMap } = await loadComponentLocationData(codes); + // 与列表展开 / 导出配件明细走同一个加载器:只查「箱及仓位库存」,见文件头 ⑤ + const locationMap = await loadComponentLocations(codes); const items = components.map((component, index) => ({ index: index + 1, @@ -1353,7 +1703,6 @@ async function getMkhBomDetail(mkhSkuId) { role: component.role, qty: toNumber(component.qty) ?? 0, location: locationMap.get(component.barcode) || null, - stock: stockMap.get(component.barcode) ?? 0, })); return { @@ -1417,8 +1766,12 @@ function buildProductExportRows(matchedRows = []) { dailySale: toNumber(row.dailySale) ?? 0, stock: toNumber(row.stock) ?? 0, pendingWorkOrderQty: toNumber(row.pendingWorkOrderQty) ?? 0, + pendingOrderQty: toNumber(row.pendingOrderQty) ?? 0, stockDays: toNumber(row.stockDays), replenishQty: toNumber(row.replenishQty) ?? 0, + // 配件不足(见文件头 ⑪):可组装数 = 0 → 现有配件凑不出一台,标注出来避免业务照着补 + componentShortage: row.componentShortage === true ? '配件不足' : '', + assemblableQty: toNumber(row.assemblableQty), bomConfigured: components.length > 0 ? '已配置' : '未配置', accessoryCount: components.length, accessoryQtySum: sumQty(components), @@ -1535,13 +1888,24 @@ module.exports = { matchesPackSku, formatPackLocation, buildPackLocationMap, - buildInventoryWarehouseMap, loadComponentLocations, - loadComponentLocationData, COMPONENT_LOCATION_CHUNK_SIZE, COMPONENT_LOCATION_CACHE_TTL_MS, buildReplenishMetrics, loadPendingWorkOrderIndex, + loadPendingOrderIndex, + parseOrderSkus, + isMkhProductCode, + PENDING_ORDER_EXCLUDED_STATUS, + // ⑪ 配件不足 / 可组装数 + buildComponentOccupation, + buildComponentReadiness, + loadComponentStock, + loadMkhComponentReadiness, + COMPONENT_STOCK_WMS_CO_ID, + COMPONENT_STOCK_WAREHOUSE, + COMPONENT_STOCK_CHUNK_SIZE, + COMPONENT_SHORTAGE_MAX_LIST, parseFlag, resolveScope, filterRows, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 62e2c01..227523e 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -165,19 +165,24 @@ test('pack inventory location prefers bins and falls back to positive-stock box assert.equal(locations.get('PART-1'), '半成品仓 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); }); -test('inventory warehouse fallback distinguishes unboxed stock from a warehouse with no box record', () => { - const locations = _private.buildInventoryWarehouseMap([ - { - skuId: 'PART-1', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 12, - }, - { - skuId: 'PART-2', defaultWarehouse: '默认半成品仓', warehouseName: '实际库存仓', availableQty: 0, - }, - ], ['PART-1', 'PART-2', 'PART-3']); +// ── 配件仓位:只认「箱及仓位库存」,不做库存表/商品档案仓回退(2026-09-17 业务口径)── +test('component location is sourced only from the pack inventory table', () => { + assert.equal(typeof _private.loadComponentLocations, 'function'); + // 兜底链已删除:这两个符号不应再存在(存在即有代码走回退) + assert.equal(_private.loadComponentLocationData, undefined); + assert.equal(_private.buildInventoryWarehouseMap, undefined); +}); - assert.equal(locations.get('PART-1'), '实际库存仓(未分箱)'); - assert.equal(locations.get('PART-2'), '默认半成品仓(暂无箱号)'); - assert.equal(locations.has('PART-3'), false); +test('pack inventory without bin/bin_group falls back to the box id, never to a warehouse', () => { + const locations = _private.buildPackLocationMap([ + { + skuId: 'PART-1-1', warehouseId: 1, warehouseName: '半成品仓', binGroup: null, bin: null, packId: 'BOX-9', qty: 3, + }, + ], ['PART-1', 'PART-2']); + + assert.equal(locations.get('PART-1'), '半成品仓 · 箱号 BOX-9'); + // 该表没有记录 → 无仓位,交给前端显示「未维护」,不回退任何仓库 + assert.equal(locations.has('PART-2'), false); }); // ── 补货预警:近 7 日日销 > 0 且 现有库存 < 3 天日销 ──────────────── @@ -342,6 +347,107 @@ test('pending work order filters keep the business-confirmed source and exclusio assert.equal(_private.WARNING_LEVEL.SCHEDULED, '已排产'); }); +// ── 待发货订单(见服务文件头 ⑩)────────────────────────────────── +test('pending order: jst_Orders.skus text column is parsed into code/quantity pairs', () => { + // 单商品、省略 *数量 + assert.deepEqual(_private.parseOrderSkus('1.MKHDNZ0409260700426'), [ + { code: 'MKHDNZ0409260700426', qty: 1 }, + ]); + // 多商品、每个带 *数量(总件数前缀 2. 不参与计算) + assert.deepEqual(_private.parseOrderSkus('2.A*1,B*1'), [ + { code: 'A', qty: 1 }, + { code: 'B', qty: 1 }, + ]); + // 同一单里成品与组合装混排:文本照常解析,是否成品由 isMkhProductCode 判定 + assert.deepEqual( + _private.parseOrderSkus('3.GBGJ-ZWJ024903AN1407V01*1,GBGJ-ZWJ024903BN1407V01*1,MKHDNZ0409260600022*1'), + [ + { code: 'GBGJ-ZWJ024903AN1407V01', qty: 1 }, + { code: 'GBGJ-ZWJ024903BN1407V01', qty: 1 }, + { code: 'MKHDNZ0409260600022', qty: 1 }, + ] + ); + // 空值 / 空汇总(实测 23% 的普通订单是 `0.`,必须安全返回空数组) + assert.deepEqual(_private.parseOrderSkus('0.'), []); + assert.deepEqual(_private.parseOrderSkus(''), []); + assert.deepEqual(_private.parseOrderSkus(null), []); + assert.deepEqual(_private.parseOrderSkus('2.MKHDNZ0409260601388*2'), [ + { code: 'MKHDNZ0409260601388', qty: 2 }, + ]); +}); + +test('pending order: only MKH finished products count, combination codes are excluded', () => { + assert.equal(_private.isMkhProductCode('MKHDNZ0409260700426'), true); + assert.equal(_private.isMkhProductCode('MKHSJ0425080100098'), true); + // `MKH-` 打头的是组合装(拼接码),不是成品,不计入成品需求 + assert.equal(_private.isMkhProductCode('MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1'), false); + assert.equal(_private.isMkhProductCode('GBGJ-ZWJ024903AN1407V01'), false); + assert.equal(_private.isMkhProductCode(''), false); + assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']); +}); + +test('replenish: no sales but unshipped orders still trigger the warning', () => { + // 库存不够发货 → 缺货,补货量 = 待发货件数 − 现有库存 + const notEnough = metrics({ + sevenAvgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2, + }); + assert.equal(notEnough.hasWarning, true); + assert.equal(notEnough.warningLevel, '缺货'); + assert.equal(notEnough.replenishQty, 3); + assert.equal(notEnough.orderDriven, true); + // 无日销基数 → 缺货时记 0,保证排在预警清单最前 + assert.equal(notEnough.stockDays, 0); + assert.equal(notEnough.pendingOrderQty, 4); + assert.equal(notEnough.pendingOrderCount, 2); + + // 库存刚好够发 → 预警(不再缺货),补货量为 0 + const exactlyEnough = metrics({ + sevenAvgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4, + }); + assert.equal(exactlyEnough.warningLevel, '预警'); + assert.equal(exactlyEnough.hasWarning, true); + assert.equal(exactlyEnough.replenishQty, 0); + // 库存已够发时算不出可用天数,记 null(页面显示「按订单」) + assert.equal(exactlyEnough.stockDays, null); +}); + +test('replenish: order-driven warning does not override the pending work order exemption', () => { + const scheduled = metrics({ + sevenAvgSale: 0, + realtimeStock: 0, + pendingOrderQty: 5, + pendingOrderCount: 1, + pendingWorkOrderQty: 6, + pendingWorkOrderCount: 1, + }); + assert.equal(scheduled.warningLevel, '已排产'); + assert.equal(scheduled.hasWarning, false); + assert.equal(scheduled.replenishQty, 0); + // 待发货量仍要透出,页面上应能看到 + assert.equal(scheduled.pendingOrderQty, 5); + assert.equal(scheduled.orderDriven, true); +}); + +test('replenish: pending order fields default to empty without any unshipped order', () => { + const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 }); + assert.equal(none.pendingOrderQty, 0); + assert.equal(none.pendingOrderCount, 0); + assert.equal(none.pendingOrderEarliest, null); + assert.equal(none.orderDriven, false); + + // 无日销且无订单 → 仍是「无销量」,不参与预警 + const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); + assert.equal(noSale.warningLevel, '无销量'); + assert.equal(noSale.orderDriven, false); + + // 有日销的商品照旧走原口径,但待发货量也要带上(页面新增列) + const withSale = metrics({ sevenAvgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); + assert.equal(withSale.warningLevel, '缺货'); + assert.equal(withSale.orderDriven, false); + assert.equal(withSale.pendingOrderQty, 3); + assert.equal(withSale.replenishQty, 6); // ceil(3 × 2 − 0),不受订单量影响 +}); + // ── 导出(见服务文件头 ⑨)──────────────────────────────────────── test('export: product rows follow the page table columns', () => { const rows = _private.buildProductExportRows([ @@ -381,8 +487,11 @@ test('export: product rows follow the page table columns', () => { dailySale: 2.97, stock: 0, pendingWorkOrderQty: 0, + pendingOrderQty: 0, stockDays: 0, replenishQty: 9, + componentShortage: '', + assemblableQty: null, bomConfigured: '已配置', accessoryCount: 2, accessoryQtySum: 16, @@ -390,6 +499,7 @@ test('export: product rows follow the page table columns', () => { // 无销量 → stockDays 为 null(前端导出时渲染成「无销量」),在途量缺省归 0 assert.equal(rows[1].stockDays, null); assert.equal(rows[1].pendingWorkOrderQty, 0); + assert.equal(rows[1].pendingOrderQty, 0); assert.equal(rows[1].bomConfigured, '未配置'); assert.equal(rows[1].accessoryQtySum, 0); }); @@ -475,3 +585,117 @@ test('pack inventory location chunk size stays under the MSSQL parameter limit', // 每个编码占 2 个参数(精确匹配 @lcN + 带后缀前缀匹配 @lpN),MSSQL 单语句上限 2100 assert.equal(_private.COMPONENT_LOCATION_CHUNK_SIZE * 2 <= 2000, true); }); + +// ── 配件不足 / 可组装数(见服务文件头 ⑪)────────────────────────── +test('component readiness: assemblable qty is limited by the tightest component', () => { + const components = [ + { barcode: 'A', name: '配件A', qty: 1 }, + { barcode: 'B', name: '配件B', qty: 2 }, + ]; + // A: floor(10 / 1) = 10,B: floor(7 / 2) = 3 → 木桶短板是 B + const ready = _private.buildComponentReadiness(components, new Map([['A', 10], ['B', 7]]), new Map()); + + assert.equal(ready.assemblableQty, 3); + assert.equal(ready.shortage, false); + assert.equal(ready.bottlenecks[0].code, 'B'); + assert.equal(ready.bottlenecks[0].can, 3); + // Tooltip 要能显示「单台用量」,不能默认当 1 + assert.equal(ready.bottlenecks[0].perQty, 2); +}); + +test('component readiness: zero assemblable qty marks 配件不足', () => { + const components = [ + { barcode: 'A', name: '配件A', qty: 1 }, + { barcode: 'B', name: '配件B', qty: 1 }, + ]; + // B 在该仓没有库存记录 → 视为 0(loadComponentStock 会缓存 0),一台都凑不出来 + const ready = _private.buildComponentReadiness(components, new Map([['A', 500]]), new Map()); + + assert.equal(ready.assemblableQty, 0); + assert.equal(ready.shortage, true); + assert.equal(ready.bottlenecks[0].code, 'B'); + assert.equal(ready.bottlenecks[0].stock, 0); + assert.equal(ready.bottlenecks[0].available, 0); +}); + +test('component readiness: pending work order occupation is deducted from stock', () => { + const components = [{ barcode: 'A', name: '配件A', qty: 2 }]; + // 库存 10、单台用量 2 → 5 台;扣掉加工单占用 6 件 → floor((10 − 6) / 2) = 2 + const ready = _private.buildComponentReadiness( + components, + new Map([['A', 10]]), + new Map([['A', 6]]) + ); + assert.equal(ready.assemblableQty, 2); + assert.equal(ready.bottlenecks[0].occupied, 6); + assert.equal(ready.bottlenecks[0].available, 4); + + // 占用超过库存 → 可支配数归 0,不能算成负数 + const exhausted = _private.buildComponentReadiness( + components, + new Map([['A', 10]]), + new Map([['A', 20]]) + ); + assert.equal(exhausted.assemblableQty, 0); + assert.equal(exhausted.shortage, true); + assert.equal(exhausted.bottlenecks[0].available, 0); +}); + +test('component readiness: missing BOM detail means unknown, not 配件不足', () => { + // 没有配件明细 → 判不了(组件缺失 ≠ 配件不足),返回 null 让调用方跳过标记 + assert.equal(_private.buildComponentReadiness([], new Map(), new Map()), null); + assert.equal(_private.buildComponentReadiness(null, new Map(), new Map()), null); + assert.equal(_private.buildComponentReadiness(undefined, new Map(), new Map()), null); +}); + +test('component occupation: pending work orders expand into component quantities', () => { + const bomBySku = new Map([['MKHA1', [ + { barcode: 'A', qty: 2 }, + { barcode: 'B', qty: 1 }, + ]]]); + const occupation = _private.buildComponentOccupation([ + { skuId: 'MKHA1', pendingWorkOrderQty: 5 }, + // 加工单商品没有 BOM → 不产生占用(不抛错、不写脏数据) + { skuId: 'MKH-NO-BOM', pendingWorkOrderQty: 9 }, + // 在途 0 或非数字 → 忽略 + { skuId: 'MKHA1', pendingWorkOrderQty: 0 }, + { skuId: 'MKHA1', pendingWorkOrderQty: null }, + ], bomBySku); + + assert.equal(occupation.get('A'), 10); + assert.equal(occupation.get('B'), 5); + assert.equal(occupation.size, 2); +}); + +test('component shortage keeps the business-confirmed source and scope', () => { + // 配件库存只认半成品包装仓、只判现有库存(不含采购在途) + assert.equal(_private.COMPONENT_STOCK_WMS_CO_ID, 15718196); + assert.equal(_private.COMPONENT_STOCK_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓'); + // 每个编码占 1 个参数,MSSQL 单语句上限 2100 + assert.equal(_private.COMPONENT_STOCK_CHUNK_SIZE <= 2000, true); + assert.equal(typeof _private.loadComponentStock, 'function'); + assert.equal(typeof _private.loadMkhComponentReadiness, 'function'); +}); + +test('export: 配件不足 rows are flagged and carry no replenish target', () => { + const rows = _private.buildProductExportRows([ + { + row: { + skuId: 'MKHDNZ0409260600843', + productName: 'K字单板基础', + warningLevel: '缺货', + dailySale: 0, + stock: 0, + pendingOrderQty: 1, + replenishQty: 1, + componentShortage: true, + assemblableQty: 0, + }, + components: [{ barcode: 'DNZ040958N1440V01', name: '白架通用前拉杆', qty: 1 }], + }, + ]); + + assert.equal(rows[0].componentShortage, '配件不足'); + // 可组装数一并导出,便于业务核对短板 + assert.equal(rows[0].assemblableQty, 0); +}); diff --git a/backend/test/mkhReplenishAlertJob.test.js b/backend/test/mkhReplenishAlertJob.test.js index 14faff6..8e4bf57 100644 --- a/backend/test/mkhReplenishAlertJob.test.js +++ b/backend/test/mkhReplenishAlertJob.test.js @@ -137,3 +137,58 @@ test('notification flags degraded work-order source', () => { }); assert.ok(payload.content.includes('加工单数据源暂不可用')); }); + +// ── 配件不足(见 mkhBomService 文件头 ⑪)────────────────────────── +test('notification spells out 配件不足 and drops the replenish target', () => { + const shortage = alert('MKHDNZ0409260600843', '缺货', { + dailySale: 0, + stock: 0, + replenishQty: 1, + componentShortage: true, + assemblableQty: 0, + bottleneckComponents: [ + { code: 'DNZ040958N1440V01', name: '白架通用前拉杆', perQty: 1, stock: 0, occupied: 0, available: 0, can: 0 }, + { code: 'DNZ041106B01V01', name: '通用小K', perQty: 1, stock: 23, occupied: 0, available: 23, can: 23 }, + ], + }); + + const payload = buildMkhReplenishNotification({ + alerts: [shortage], + allAlerts: [shortage], + snapshot: { + outOfStockCount: 1, + warningOnlyCount: 0, + replenishQtyTotal: 1, + componentShortageCount: 1, + }, + recipientAlias: '苏适', + dateKey: '20260917', + }); + + assert.ok(payload.content.includes('配件不足,无法补货')); + // 补不出来的商品不再给补货数字,避免业务照着一个补不到的目标去采购 + assert.ok(payload.content.includes('建议补货 —')); + assert.equal(payload.content.includes('建议补货 1'), false); + // 只列真正卡住的短板(可支配数 ≤ 0),库存充足的不列 + assert.ok(payload.content.includes('DNZ040958N1440V01')); + assert.equal(payload.content.includes('DNZ041106B01V01'), false); + // 汇总段说明本批里有几个配件不足 + assert.ok(payload.content.includes('因**配件库存不足**')); +}); + +test('notification flags degraded component stock source', () => { + const payload = buildMkhReplenishNotification({ + alerts: [alert('A')], + allAlerts: [alert('A')], + snapshot: { + outOfStockCount: 1, + warningOnlyCount: 0, + replenishQtyTotal: 3, + componentStockDegraded: true, + }, + recipientAlias: '苏适', + dateKey: '20260917', + }); + assert.ok(payload.content.includes('配件库存数据源暂不可用')); + assert.ok(payload.content.includes('未做')); +}); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index cbb70dc..ac9454e 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -41,6 +41,23 @@ const WARNING_TONE = { 无销量: 'muted', }; +// 配件不足说明(口径见后端 mkhBomService 文件头 ⑪): +// 可组装数 = min(⌊(配件库存 − 未完成加工单占用) ÷ 单台用量⌋),为 0 即凑不出一台、补不出来 +const shortageTooltip = (row) => { + const bottlenecks = Array.isArray(row.bottleneckComponents) ? row.bottleneckComponents : []; + const details = bottlenecks + .filter((item) => Number(item.available) <= 0) + .map((item) => `短板 ${item.code}${item.name ? `(${item.name})` : ''}` + + `:库存 ${number(item.stock)}` + + `${Number(item.occupied) > 0 ? `,未完成加工单占用 ${number(item.occupied)}` : ''}` + + `,单台用量 ${number(item.perQty)}`); + return [ + `配件不足,无法补货:现有配件最多只能组装 ${number(row.assemblableQty)} 台`, + ...details, + '口径:可组装数 = 各配件 ⌊(库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小;为 0 即补不出来', + ].filter(Boolean).join(';'); +}; + // 展示范围切换 const SCOPE_OPTIONS = [ { label: '仅预警', value: 'warning' }, @@ -57,20 +74,27 @@ const PRODUCT_EXPORT_COLUMNS = [ { title: '近7日日销', dataIndex: 'dailySale' }, { title: '现有库存', dataIndex: 'stock' }, { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, + { title: '待发货订单', dataIndex: 'pendingOrderQty' }, { title: '可用天数', dataIndex: 'stockDays', - // 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨 - exportValue: (value) => (value === null || value === undefined ? '无销量' : value), + // 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨; + // 订单驱动(日销=0 但有未发货订单)同样是 null,口径不同要分开说明 + exportValue: (value, row) => { + if (value !== null && value !== undefined) return value; + return row.orderDriven ? '无日销·按订单' : '无销量'; + }, }, { title: '建议补货量', dataIndex: 'replenishQty', - // 与页面一致:非预警商品不显示数字,避免被当成「需要补 0 件」 - exportValue: (value, row) => ( - (row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : '' - ), + // 与页面一致:非预警商品不显示数字;配件不足(可组装数 = 0)时显示「—」不给补货目标 + exportValue: (value, row) => { + if (row.componentShortage) return '—'; + return (row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : ''; + }, }, + { title: '配件不足', dataIndex: 'componentShortage' }, { title: 'BOM 状态', dataIndex: 'bomConfigured' }, { title: '配件种类', dataIndex: 'accessoryCount' }, { title: '配件总数量', dataIndex: 'accessoryQtySum' }, @@ -182,7 +206,7 @@ const PrintPreview = ({ data, onClose }) => {
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充; - 配件仓位优先取包装箱与具体仓位,尚未分箱时显示库存仓库或商品档案仓库。 + 配件仓位只取聚水潭「箱及仓位库存」,未维护货位时显示箱号,无装箱记录显示「未维护」。
@@ -473,10 +497,18 @@ const MkhBomData = () => { { title: '预警', dataIndex: 'warningLevel', - width: 84, + width: 140, align: 'center', - render: (value) => ( - {text(value)} + render: (value, row) => ( + + {text(value)} + {/* 配件不足(后端文件头 ⑪):可组装数 = 0,补不出来 —— 只加标记,不改变预警级别 */} + {row.componentShortage ? ( + + 配件不足 + + ) : null} + ), }, { @@ -519,24 +551,58 @@ const MkhBomData = () => {
) : —), }, + { + title: '待发货订单', + dataIndex: 'pendingOrderQty', + width: 116, + align: 'right', + render: (value, row) => (Number(value) > 0 ? ( + + {number(value)} + + ) : —), + }, { title: '可用天数', dataIndex: 'stockDays', width: 106, align: 'right', sorter: true, - render: (value, row) => (value === null || value === undefined - ? 无销量 - : {number(value, 2)}), + render: (value, row) => { + if (value === null || value === undefined) { + // 订单驱动(近7日无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同 + return row.orderDriven + ? 按订单 + : 无销量; + } + return {number(value, 2)}; + }, }, { title: '建议补货量', dataIndex: 'replenishQty', - width: 116, + width: 130, align: 'right', - render: (value, row) => (row.hasWarning && Number(value) > 0 - ? {number(value)} - : —), + render: (value, row) => { + // 配件不足(后端文件头 ⑪):凑不出一台,不给补货目标,避免业务照着补不到的量去采购 + if (row.componentShortage) { + return ( + + — + + ); + } + return row.hasWarning && Number(value) > 0 + ? {number(value)} + : —; + }, }, { title: 'BOM 状态', @@ -656,15 +722,24 @@ const MkhBomData = () => { 数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条); 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充。 - 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位优先显示箱号或具体仓位, - 尚未分箱时显示实际库存仓库,没有库存记录时显示商品档案仓库。 + 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取聚水潭「箱及仓位库存」, + 有维护货位则显示货位、未维护货位则显示箱号,该表没有记录即显示「未维护」。
补货预警:近7日日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近7日日销,低于 3 天即预警,缺货单独标识; - 建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无近7日日销的商品不参与预警。 + 建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。 +
+ 待发货订单:近7日暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 + (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; + 预警级别按「现有库存 < 待发货件数 → 缺货,否则预警」,建议补货量 = 待发货件数 − 现有库存, + 可用天数因无日销基数而显示「按订单」。
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算 (作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。 +
+ 配件不足:缺货 / 预警的成品若配件「可组装数 = 0」则无法补货,标记「配件不足」、建议补货量显示「—」。 + 可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ BOM 单台用量⌋ 取最小值(木桶短板决定), + 配件库存取「{data.componentStockWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」的现有库存,不含采购在途。
@@ -713,18 +788,45 @@ const MkhBomData = () => { /> ) : null} + {!loading && !error && data.pendingOrderDegraded ? ( + 刷新数据} + /> + ) : null} + + {!loading && !error && data.componentStockDegraded ? ( + 刷新数据} + /> + ) : null} + {!loading && (data.warningCount || 0) > 0 ? ( 按近 {data.saleWindowDays || 7} 日日销核算(监控日 {data.monitorDate || '—'}): 其中 {data.outOfStockCount || 0} 个已缺货, {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 建议补货量 = {data.replenishDaysThreshold || 3} × 近7日日销 − 现有库存。点「只看预警」即为补货清单。 + {(data.orderDrivenCount || 0) > 0 ? ( + <> +
+ 其中 {data.orderDrivenCount} 个近 7 日暂无日销,因聚水潭存在未发货订单 + (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件)而列入预警,建议补货量按待发货件数核算。 + + ) : null} {(data.scheduledCount || 0) > 0 ? ( <>
@@ -732,6 +834,14 @@ const MkhBomData = () => { (在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。 ) : null} + {(data.componentShortageCount || 0) > 0 ? ( + <> +
+ 其中 {data.componentShortageCount} 个商品配件不足 + :现有配件最多只能组装 0 台(已扣除未完成加工单占用的配件),补不出来, + 建议补货量显示「—」,需先采购配件。 + + ) : null} )} action={( @@ -799,7 +909,7 @@ const MkhBomData = () => { pageSizeOptions: [50, 100, 200], showTotal: (total) => `共 ${total} 条`, }} - scroll={{ x: 1696, y: bodyHeight }} + scroll={{ x: 1766, y: bodyHeight }} /> diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index b591377..790d760 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -123,6 +123,19 @@ font-weight: 600; } +/* 待发货订单(聚水潭未发货订单件数,见文件头 ⑩) */ +.mkh-order { + color: #ad4e00; + font-weight: 600; +} + +/* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */ +.mkh-tag--shortage { + color: #722ed1; + background: rgba(114, 46, 209, 0.14); + border-color: rgba(114, 46, 209, 0.35); +} + /* ============ A4 打印预览 ============ */ .mkh-print-mask { position: fixed; From 3e02c3e393017af0b9812afcbaf46114480bb285 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 14:06:50 +0800 Subject: [PATCH 18/70] =?UTF-8?q?feat:=20=E6=97=A5=E9=94=80=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E7=94=B1=E8=BF=91=207=20=E5=A4=A9=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E8=BF=91=2015=20=E5=A4=A9=EF=BC=88MKH=20=E8=A1=A5=E8=B4=A7?= =?UTF-8?q?=E9=A2=84=E8=AD=A6=20+=20=E6=A8=A1=E5=9D=97=E5=8C=96=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务要求「近7日日销」改「近30天日销」。实测业务库与产品资料库均无 30 天日销, 已实现的最长周期是 pm_stockMonitor.fifteenAvgSale(15 天),经与业务确认改用 15 天。 为什么没有 30 天(2026-09-17 实测,避免重复排查): - rpt.pm_stockMonitor 只有 threeAvgSale(3天)/sevenAvgSale(7天)/fifteenAvgSale(15天), 且整表只保留 1 天快照(2026-09-16),攒不出 30 天历史 - rpt.dws_sn_sales_full_info_di(stat_date+skuid+goods_count,新鲜到 09-15) 里 MKH 商品 0 个;目标仓 794 个 SKU 命中也是 0 - rpt.dws_order_sales_cost_detail_di 停在 2025-04(历史归档) - rpt.jst_salesOutboundInfo 只有出库单头,无 SKU 明细;业务库无出库明细表 - rpt.op_BarcodeSalePrice30dDaily 名字带 30d,但只有 salePrice 没有销量 改动: - backend/src/services/mkhBomService.js · SALE_WINDOW_DAYS 7 → 15,新增 DAILY_SALE_COLUMN='fifteenAvgSale', 监控快照 SQL 按该常量插值取数(以后改口径只改常量) · buildReplenishMetrics 入参 sevenAvgSale → avgSale(语义与周期解耦) · 文件头 ⑦ 补「为什么是 15 天」的完整排查结论;⑦ / ⑩ / ⑪ 实测数字按新口径更新 - backend/src/services/modularStockMonitorService.js · 取数列与 API 字段名 sevenAvgSale/avgShip7d → fifteenAvgSale/avgShip15d (字段名同步改,避免名不副实) - frontend/src/pages/MkhBomData/index.jsx · 列头与口径说明「近7日」→「近15天」;动态文案兜底 7 → 15 - frontend/src/pages/ModularStockMonitor/index.jsx · 列头「近7日日均发货量」→「近15天日均发货量」,dataIndex 同步改 · 口径说明补充:可用天数仍沿用 jsUseDays(源系统口径),与日销并非同一时间窗 影响(同一份 09-16 快照,7 天 vs 15 天): 有销商品 114 → 170 周期拉长,更多商品进得了「有销」集合 补货预警 25 → 19 反而变少:15 天日销普遍低于 7 天(近期销量走低), 可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少 缺货 9 → 10 已排产 11 → 13 订单驱动 14 → 8 更多商品自己有日销,不再需要靠订单兜底 配件不足 10 → 12 「预警变少」是口径变化的正常结果,不是 bug。 验证: - 后端语法检查 151 个文件通过 - mkhBomService + mkhReplenishAlertJob 单测 52/52 通过 - 后端全量 334 tests / 333 pass(唯一失败是 resilientSecretStore 的 Windows 文件权限断言,与本改动无关的环境固有问题) - 接口实测:total 4223 / warningCount 19 / outOfStockCount 10 / saleWindowDays 15 / componentShortageCount 12 --- backend/src/services/mkhBomService.js | 74 +++++++++++++------ .../services/modularStockMonitorService.js | 12 +-- backend/test/mkhBomService.test.js | 40 +++++----- backend/test/mkhReplenishAlertJob.test.js | 2 +- frontend/src/pages/MkhBomData/index.jsx | 28 +++---- .../src/pages/ModularStockMonitor/index.jsx | 10 ++- 6 files changed, 99 insertions(+), 67 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 14bd9a4..c6fb7b2 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -64,19 +64,40 @@ // ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。 // // ── ⑦ 补货预警(2026-09-17 业务确认口径)────────────────────────── -// 规则:近 7 日日销 > 0 且 现有库存 < 3 天日销 → 预警补货 -// · 近7日日销 rpt.pm_stockMonitor.sevenAvgSale +// 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货 +// · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale // (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照) // · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; // 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①), // 缺失时回退监控快照 jsStock -// · 可用天数 = 现有库存 / 近7日日销 -// · 建议补货量 = ceil(3 × 近7日日销 − 现有库存),不足 0 取 0 -// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / sevenAvgSale 不同源 +// · 可用天数 = 现有库存 / 近15天日销 +// · 建议补货量 = ceil(3 × 近15天日销 − 现有库存),不足 0 取 0 +// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / fifteenAvgSale 不同源 // (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67, -// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近7日日销」为准。 -// 实测(2026-09-16 快照,当时成品口径尚未收紧为「正式 BOM 父项」): -// 4408 个 MKH 成品中 114 个有日销、13 个可用天数 < 3 天。 +// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近15天日销」为准。 +// +// ── 为什么是 15 天而不是业务最初要求的 30 天 ── +// 2026-09-17 业务要求「近7日日销」改「近30天日销」,但实测全库**没有 30 天日销**: +// · pm_stockMonitor 只有 threeAvgSale(3天) / sevenAvgSale(7天) / fifteenAvgSale(15天), +// 且整表**只保留 1 天快照**(2026-09-16),攒不出 30 天历史 +// · rpt.dws_sn_sales_full_info_di(stat_date+skuid+goods_count,数据新鲜到 09-15) +// 里 MKH 商品 **0 个**;目标仓 794 个 SKU 命中也是 0 +// · rpt.dws_order_sales_cost_detail_di 停在 2025-04(历史归档) +// · rpt.jst_salesOutboundInfo 只有出库**单头**,无 SKU 明细;业务库无出库明细表 +// · rpt.op_BarcodeSalePrice30dDaily 名字带 30d,但只有 salePrice **没有销量** +// → 经与业务确认,改用十五天口径 fifteenAvgSale(已实现的最长周期)。 +// ⚠️ 若日后上游补了 30 天字段,只需改 loadReplenishIndex 里的列名 + DAILY_SALE_DAYS。 +// +// 实测(2026-09-16 品牌仓快照,当时成品口径尚未收紧为「正式 BOM 父项」): +// 4408 个 MKH 成品中,7 天有销 114 个、15 天有销 170 个 +// 实测(2026-09-17 切换当天,同一份 09-16 快照,7 天 vs 15 天): +// 有销商品 114 → 170 ← 周期拉长,更多商品进得了「有销」集合 +// 补货预警 25 → 19 ← 反而变少:15 天日销普遍低于 7 天(近期销量走低), +// 可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少 +// 缺货 9 → 10 +// 已排产 11 → 13 +// 订单驱动 14 → 8 ← 更多商品自己有日销了,不再需要靠订单兜底 +// ⚠️ 「预警变少」不是 bug,是口径变化的正常结果;若业务想要 30 天,需上游先补数。 // // ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** @@ -107,7 +128,7 @@ // 响应会膨胀到几十 MB,而商品清单本身用不到明细。 // // ── ⑩ 待发货订单(2026-09-17 业务确认口径)──────────────────────── -// 规则:**近 7 日日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 +// 规则:**近 15 天日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 // 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」 // 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。 // · 数据源:业务库 rpt.jst_Orders(订单**主表**,实时更新,实测最新到今天) @@ -128,8 +149,10 @@ // 缺货时记 stockDays = 0(保证排在最前),预警(库存已够发)时记 null。 // ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)—— // 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。 -// 实测(2026-09-17):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件, +// 实测(2026-09-17,7 天口径下):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件, // 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。 +// ⚠️ 改 15 天口径后实测:订单驱动从 14 个降到 **8 个** +// (日销基数 114 → 170,原本靠订单兜底的商品自己有日销了),与预期一致。 // // ── ⑪ 配件不足 / 可组装数(2026-09-17 业务确认口径)──────────────── // 规则:**缺货 / 预警的 MKH 成品,若其配件现有库存「可组装数 = 0」,则提示 @@ -152,8 +175,8 @@ // · 判定范围:**缺货 + 预警都判**(业务确认),不改变原有预警级别与排序, // 只额外打标记;「已排产」商品本来就不预警,不参与判定。 // · 无 BOM 配件明细的商品 → 可组装数未知(组件缺失 ≠ 配件不足),不做判定。 -// 实测(2026-09-17,并入最新 main 后复核):预警 25 个(缺货 9 + 预警 16)中 **10 个可组装数 = 0** -// (缺货 6 + 预警 4);扣加工单占用前后仅 1 个商品变化(185 → 180 台), +// 实测(2026-09-17,15 天口径):预警 19 个(缺货 10 + 预警 9)中 **12 个可组装数 = 0** +// (缺货 7 + 预警 5);扣加工单占用前后仅 1 个商品变化(185 → 180 台), // 说明占用规模小但口径上必须扣。 // ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标), // 接口透出 componentStockDegraded=true。 @@ -192,7 +215,13 @@ const MKH_WHERE = `s.sku_id LIKE N'MKH%' // ── 补货预警参数(见文件头 ⑦)──────────────────────────────────── // 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得) const BRAND_WMS_CO_ID = 15870495; -const SALE_WINDOW_DAYS = 7; +// 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据, +// 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。 +// ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列, +// 两者必须成对修改;下方 SQL 已按 DAILY_SALE_COLUMN 插值,改常量即可生效。 +const SALE_WINDOW_DAYS = 15; +// 日销取数列(rpt.pm_stockMonitor)—— 必须与 SALE_WINDOW_DAYS 保持一致 +const DAILY_SALE_COLUMN = 'fifteenAvgSale'; // 预警阈值:现有库存可用天数低于 3 天即需补货 const REPLENISH_DAYS_THRESHOLD = 3; @@ -299,8 +328,8 @@ const SCOPES = Object.freeze({ WARNING: 'warning', SOLD: 'sold', ALL: 'all' }); /** * 解析展示范围: - * warning —— 仅需补货(近7日日销 > 0 且 可用天数 < 3 天) - * sold —— 有近7日日销的商品 + * warning —— 仅需补货(近15天日销 > 0 且 可用天数 < 3 天) + * sold —— 有近15天日销的商品 * all —— 全部 MKH 成品 * 兼容旧参数 warningOnly=1(等价 scope=warning)。 */ @@ -776,13 +805,13 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 * * 口径(见文件头 ⑦ + ⑩): - * ① 近7日日销 > 0 且 现有库存 < 3 天日销 → 预警 - * ② 近7日日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩) + * ① 近15天日销 > 0 且 现有库存 < 3 天日销 → 预警 + * ② 近15天日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩) * 两者都不满足 → 无销量,不参与预警。 * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 * * @param {object} row - * row.sevenAvgSale 近7日日均发货量 + * row.avgSale 近15天日均发货量(取自 rpt.pm_stockMonitor.fifteenAvgSale) * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) * row.monitorStock 监控快照库存(fallback) * row.safetyDays 源系统安全天数(仅展示) @@ -792,7 +821,7 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( * row.pendingOrderQty 待发货订单量(未发货订单件数) */ function buildReplenishMetrics(row = {}) { - const dailySale = Math.max(0, toNumber(row.sevenAvgSale) ?? 0); + const dailySale = Math.max(0, toNumber(row.avgSale) ?? 0); const realtimeStock = toNumber(row.realtimeStock); const monitorStock = toNumber(row.monitorStock) ?? 0; const hasRealtime = realtimeStock !== null; @@ -828,7 +857,7 @@ function buildReplenishMetrics(row = {}) { monitorDate: toDateText(row.monitorDate), }; - // 既无近7日日销、也无未发货订单 → 不参与预警 + // 既无近15天日销、也无未发货订单 → 不参与预警 if (dailySale <= 0 && !orderDriven) { return { ...base, @@ -1179,7 +1208,7 @@ async function loadReplenishIndex() { const pending = Promise.all([ // 监控快照:同一天只有一行,按 date DESC 取首条即可 cloudDatabase.query( - `SELECT m.sku_id AS skuId, m.sevenAvgSale AS sevenAvgSale, m.jsStock AS monitorStock, + `SELECT m.sku_id AS skuId, m.${DAILY_SALE_COLUMN} AS fifteenAvgSale, m.jsStock AS monitorStock, m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, m.date AS monitorDate FROM dbo.pm_stockMonitor m WHERE m.warehouse = @brandWarehouse AND m.sku_id LIKE N'MKH%' @@ -1218,7 +1247,8 @@ async function loadReplenishIndex() { seenMonitor.add(key); if (!monitorDate) monitorDate = toDateText(row.monitorDate); bySku.set(key, buildReplenishMetrics({ - sevenAvgSale: row.sevenAvgSale, + // 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据) + avgSale: row.fifteenAvgSale, // 实时库存缺失(而非为 0)时才回退监控快照 realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, monitorStock: row.monitorStock, diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 1d219cd..ea2dc2b 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -5,7 +5,7 @@ // 需求来源:《模块化库存监控.xlsx》 // 一级功能:模块化库存监控 二级功能:配件库存监控 // 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 | -// 仓位 | 近7日日均发货量 | 库存可用天数 | 所在仓库 +// 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库 // 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码 // // 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认): @@ -14,7 +14,7 @@ // 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库 // 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得) // 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU: -// sevenAvgSale 近7日日均发货量 +// fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下) // jsUseDays 库存可用天数 // safetyDays 安全天数(默认 15) // suggestStock 建议备货量 @@ -76,7 +76,7 @@ const SORTABLE_FIELDS = Object.freeze({ productName: 's.name', category: 's.category', realtimeStock: 'realtimeStock', - avgShip7d: 'm.sevenAvgSale', + avgShip15d: 'm.fifteenAvgSale', stockDays: 'm.jsUseDays', warningLevel: 'warningOrder', mkhStatus: 'mkhStatus', @@ -108,7 +108,7 @@ OUTER APPLY ( ORDER BY i.updateTime DESC ) inv OUTER APPLY ( - SELECT TOP 1 m2.sevenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock, + SELECT TOP 1 m2.fifteenAvgSale, 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 @@ -352,7 +352,7 @@ const mapRow = (row = {}) => { outQtyToday: toNumber(row.outQtyToday) ?? 0, realtimeStock, location: toText(row.location), - avgShip7d: toNumber(row.avgShip7d), + avgShip15d: toNumber(row.avgShip15d), stockDays, safetyDays, suggestStock: toNumber(row.suggestStock), @@ -368,7 +368,7 @@ const buildProjection = () => ` 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.fifteenAvgSale AS avgShip15d, m.jsUseDays AS stockDays, m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 5cfcfb8..1dd6578 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -190,17 +190,17 @@ test('pack inventory without bin/bin_group falls back to the box id, never to a assert.equal(locations.has('PART-2'), false); }); -// ── 补货预警:近 7 日日销 > 0 且 现有库存 < 3 天日销 ──────────────── +// ── 补货预警:近 15 天日销 > 0 且 现有库存 < 3 天日销 ─────────────── const metrics = (row) => _private.buildReplenishMetrics(row); test('replenish: stock below 3 days of sales is flagged as stockout or warning', () => { - const stockout = metrics({ sevenAvgSale: 2.55, realtimeStock: 0, monitorStock: 0 }); + const stockout = metrics({ avgSale: 2.55, realtimeStock: 0, monitorStock: 0 }); assert.equal(stockout.stockDays, 0); assert.equal(stockout.warningLevel, '缺货'); assert.equal(stockout.hasWarning, true); assert.equal(stockout.replenishQty, 8); // ceil(3 * 2.55 - 0) = ceil(7.65) - const warning = metrics({ sevenAvgSale: 2, realtimeStock: 1, monitorStock: 1 }); + const warning = metrics({ avgSale: 2, realtimeStock: 1, monitorStock: 1 }); assert.equal(warning.stockDays, 0.5); assert.equal(warning.warningLevel, '预警'); assert.equal(warning.hasWarning, true); @@ -208,34 +208,34 @@ test('replenish: stock below 3 days of sales is flagged as stockout or warning', }); test('replenish: the threshold is exactly 3 days of sales', () => { - const atThreshold = metrics({ sevenAvgSale: 2, realtimeStock: 6, monitorStock: 6 }); + const atThreshold = metrics({ avgSale: 2, realtimeStock: 6, monitorStock: 6 }); assert.equal(atThreshold.stockDays, 3); assert.equal(atThreshold.warningLevel, '正常'); assert.equal(atThreshold.hasWarning, false); assert.equal(atThreshold.replenishQty, 0); - const below = metrics({ sevenAvgSale: 2, realtimeStock: 5.9, monitorStock: 5.9 }); + const below = metrics({ avgSale: 2, realtimeStock: 5.9, monitorStock: 5.9 }); assert.equal(below.hasWarning, true); }); test('replenish: stock prefers the brand-warehouse realtime value, snapshot only as fallback', () => { - const realtime = metrics({ sevenAvgSale: 1, realtimeStock: 4, monitorStock: 9 }); + const realtime = metrics({ avgSale: 1, realtimeStock: 4, monitorStock: 9 }); assert.equal(realtime.stock, 4); assert.equal(realtime.stockSource, 'realtime'); // 实时库存「无记录」(null)才回退监控快照 - const fallback = metrics({ sevenAvgSale: 1, realtimeStock: null, monitorStock: 9 }); + const fallback = metrics({ avgSale: 1, realtimeStock: null, monitorStock: 9 }); assert.equal(fallback.stock, 9); assert.equal(fallback.stockSource, 'monitor'); // 实时库存为 0 是有效值,不得被快照覆盖 - const zero = metrics({ sevenAvgSale: 1, realtimeStock: 0, monitorStock: 9 }); + const zero = metrics({ avgSale: 1, realtimeStock: 0, monitorStock: 9 }); assert.equal(zero.stock, 0); assert.equal(zero.warningLevel, '缺货'); }); test('replenish: products without 7-day sales never warn', () => { - const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, monitorStock: 0 }); + const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 }); assert.equal(noSale.hasWarning, false); assert.equal(noSale.warningLevel, '无销量'); assert.equal(noSale.stockDays, null); @@ -286,7 +286,7 @@ test('replenish sort puts the most urgent first and no-sale products last', () = test('replenish: a pending work order suppresses the replenish warning', () => { // 库存为 0、本该判「缺货」,但半成品包装项目仓已有未完成加工单 const scheduled = metrics({ - sevenAvgSale: 2, + avgSale: 2, realtimeStock: 0, monitorStock: 0, pendingWorkOrderQty: 20, @@ -307,13 +307,13 @@ test('replenish: a pending work order suppresses the replenish warning', () => { assert.equal(scheduled.stockDays, 0); // 同样库存但无加工单时仍然预警 —— 确认抑制效果确实来自加工单 - const bare = metrics({ sevenAvgSale: 2, realtimeStock: 0, monitorStock: 0 }); + const bare = metrics({ avgSale: 2, realtimeStock: 0, monitorStock: 0 }); assert.equal(bare.hasWarning, true); assert.equal(bare.warningLevel, '缺货'); }); test('replenish: pending work order fields default to empty without any order', () => { - const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 }); + const none = metrics({ avgSale: 1, realtimeStock: 0 }); assert.equal(none.hasPendingWorkOrder, false); assert.equal(none.pendingWorkOrderQty, 0); assert.equal(none.pendingWorkOrderCount, 0); @@ -322,7 +322,7 @@ test('replenish: pending work order fields default to empty without any order', // 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理 const zeroQty = metrics({ - sevenAvgSale: 1, + avgSale: 1, realtimeStock: 0, pendingWorkOrderQty: 0, pendingWorkOrderCount: 2, @@ -334,7 +334,7 @@ test('replenish: pending work order fields default to empty without any order', test('replenish: products without sales stay "no sale" even with a pending work order', () => { const noSale = metrics({ - sevenAvgSale: 0, + avgSale: 0, realtimeStock: 0, pendingWorkOrderQty: 5, pendingWorkOrderCount: 1, @@ -394,7 +394,7 @@ test('pending order: only MKH finished products count, combination codes are exc test('replenish: no sales but unshipped orders still trigger the warning', () => { // 库存不够发货 → 缺货,补货量 = 待发货件数 − 现有库存 const notEnough = metrics({ - sevenAvgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2, + avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2, }); assert.equal(notEnough.hasWarning, true); assert.equal(notEnough.warningLevel, '缺货'); @@ -407,7 +407,7 @@ test('replenish: no sales but unshipped orders still trigger the warning', () => // 库存刚好够发 → 预警(不再缺货),补货量为 0 const exactlyEnough = metrics({ - sevenAvgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4, + avgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4, }); assert.equal(exactlyEnough.warningLevel, '预警'); assert.equal(exactlyEnough.hasWarning, true); @@ -418,7 +418,7 @@ test('replenish: no sales but unshipped orders still trigger the warning', () => test('replenish: order-driven warning does not override the pending work order exemption', () => { const scheduled = metrics({ - sevenAvgSale: 0, + avgSale: 0, realtimeStock: 0, pendingOrderQty: 5, pendingOrderCount: 1, @@ -434,19 +434,19 @@ test('replenish: order-driven warning does not override the pending work order e }); test('replenish: pending order fields default to empty without any unshipped order', () => { - const none = metrics({ sevenAvgSale: 1, realtimeStock: 0 }); + const none = metrics({ avgSale: 1, realtimeStock: 0 }); assert.equal(none.pendingOrderQty, 0); assert.equal(none.pendingOrderCount, 0); assert.equal(none.pendingOrderEarliest, null); assert.equal(none.orderDriven, false); // 无日销且无订单 → 仍是「无销量」,不参与预警 - const noSale = metrics({ sevenAvgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); + const noSale = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); assert.equal(noSale.warningLevel, '无销量'); assert.equal(noSale.orderDriven, false); // 有日销的商品照旧走原口径,但待发货量也要带上(页面新增列) - const withSale = metrics({ sevenAvgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); + const withSale = metrics({ avgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); assert.equal(withSale.warningLevel, '缺货'); assert.equal(withSale.orderDriven, false); assert.equal(withSale.pendingOrderQty, 3); diff --git a/backend/test/mkhReplenishAlertJob.test.js b/backend/test/mkhReplenishAlertJob.test.js index 8e4bf57..7e90c92 100644 --- a/backend/test/mkhReplenishAlertJob.test.js +++ b/backend/test/mkhReplenishAlertJob.test.js @@ -83,7 +83,7 @@ test('notification carries recipient, idempotency key and shortfall details', () const all = [...fresh, alert('MKHDNZ0409260700486', '预警', { dailySale: 2, stock: 1, replenishQty: 5 })]; const snapshot = { monitorDate: '2026-09-16', - saleWindowDays: 7, + saleWindowDays: 15, replenishDaysThreshold: 3, outOfStockCount: 1, warningOnlyCount: 1, diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 47aa9c4..1c6b546 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -71,7 +71,7 @@ const PRODUCT_EXPORT_COLUMNS = [ { title: 'MKH商品编码', dataIndex: 'skuId' }, { title: '商品名称', dataIndex: 'productName' }, { title: '预警', dataIndex: 'warningLevel' }, - { title: '近7日日销', dataIndex: 'dailySale' }, + { title: '近15天日销', dataIndex: 'dailySale' }, { title: '现有库存', dataIndex: 'stock' }, { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, { title: '待发货订单', dataIndex: 'pendingOrderQty' }, @@ -512,7 +512,7 @@ const MkhBomData = () => { ), }, { - title: '近7日日销', + title: '近15天日销', dataIndex: 'dailySale', width: 106, align: 'right', @@ -562,7 +562,7 @@ const MkhBomData = () => { `聚水潭未发货订单 ${row.pendingOrderCount || 0} 单,合计 ${number(value)} 件`, row.pendingOrderEarliest ? `最早一单下单于 ${row.pendingOrderEarliest}` : '', '已取消 / 已合并单据不核算', - row.orderDriven ? '该商品近 7 日暂无日销,按待发货订单量列入补货预警' : '', + row.orderDriven ? '该商品近 15 天暂无日销,按待发货订单量列入补货预警' : '', ].filter(Boolean).join(';')} > {number(value)} @@ -577,9 +577,9 @@ const MkhBomData = () => { sorter: true, render: (value, row) => { if (value === null || value === undefined) { - // 订单驱动(近7日无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同 + // 订单驱动(近15天无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同 return row.orderDriven - ? 按订单 + ? 按订单 : 无销量; } return {number(value, 2)}; @@ -717,7 +717,7 @@ const MkhBomData = () => {
MKH-BOM 资料 - 模块化(MKH 开头)成品的配件明细与补货预警:近 7 日日销核算,库存低于 3 天日销即预警 + 模块化(MKH 开头)成品的配件明细与补货预警:近 15 天日销核算,库存低于 3 天日销即预警 数据口径:MKH 商品编码取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”,并且必须是聚水潭正式 BOM 的父项; @@ -725,11 +725,11 @@ const MkhBomData = () => { 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo, 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
- 补货预警:近7日日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 - 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近7日日销,低于 3 天即预警,缺货单独标识; - 建议补货量 = 3 × 近7日日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。 + 补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 + 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识; + 建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。
- 待发货订单:近7日暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 + 待发货订单:近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; 预警级别按「现有库存 < 待发货件数 → 缺货,否则预警」,建议补货量 = 待发货件数 − 现有库存, 可用天数因无日销基数而显示「按订单」。 @@ -793,7 +793,7 @@ const MkhBomData = () => { className="mkh-alert" type="warning" showIcon - message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近7日无日销但有订单」的商品,请稍后点击「刷新数据」重试。" + message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近15天无日销但有订单」的商品,请稍后点击「刷新数据」重试。" action={} /> ) : null} @@ -816,14 +816,14 @@ const MkhBomData = () => { message={`补货预警:${data.warningCount} 个 MKH 商品需要补货`} description={( - 按近 {data.saleWindowDays || 7} 日日销核算(监控日 {data.monitorDate || '—'}): + 按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}): 其中 {data.outOfStockCount || 0} 个已缺货, {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 - 建议补货量 = {data.replenishDaysThreshold || 3} × 近7日日销 − 现有库存。点「只看预警」即为补货清单。 + 建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 − 现有库存。点「只看预警」即为补货清单。 {(data.orderDrivenCount || 0) > 0 ? ( <>
- 其中 {data.orderDrivenCount} 个近 7 日暂无日销,因聚水潭存在未发货订单 + 其中 {data.orderDrivenCount} 个近 15 天暂无日销,因聚水潭存在未发货订单 (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件)而列入预警,建议补货量按待发货件数核算。 ) : null} diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index f9d6cc6..d121390 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -233,8 +233,8 @@ const AccessoryStockPanel = () => { ), }, { - title: '近7日日均发货量', - dataIndex: 'avgShip7d', + title: '近15天日均发货量', + dataIndex: 'avgShip15d', width: 138, align: 'right', sorter: true, @@ -402,13 +402,15 @@ const ModularStockMonitor = () => (
模块化库存监控 - 配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 7 日日均发货量与可用天数 + 配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 15 天日均发货量与可用天数 - 数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 7 日日均发货量、 + 数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、 库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖); 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」; 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 + 注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天); + 库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。 状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。
From 8ba052649daf09cc4ce4ec8dcfe4e82d08d5694a Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 15:04:50 +0800 Subject: [PATCH 19/70] =?UTF-8?q?feat:=20=E8=A1=A5=E8=B4=A7=E9=A2=84?= =?UTF-8?q?=E8=AD=A6-=E6=97=A5=E9=94=80=E4=B8=BA0=E6=9C=89=E4=BB=A3?= =?UTF-8?q?=E5=8F=91=E8=AE=A2=E5=8D=95=E6=97=B6=E6=8C=89=E5=BA=93=E5=AD=98?= =?UTF-8?q?=C3=B7=E4=BB=A3=E5=8F=91=E4=BB=B6=E6=95=B0=E7=AE=97=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E5=A4=A9=E6=95=B0=E9=A2=84=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⑩订单驱动分支改为:可用天数 = 现有库存 ÷ 代发(待发货)订单件数 · 可用天数 = 0(无库存)→ 缺货 · 0 < 可用天数 < 3 天 → 预警(建议补到 3 天缓冲:3×代发件数−现有库存) · 可用天数 ≥ 3 天 → 库存充足,不预警(阈值语义与日销分支一致) - buildReplenishMetrics 订单驱动分支不再把 stockDays 记 null,统一如实记录 - 同步前端「可用天数」列/导出/数据口径说明文案(不再显示「按订单」) - 更新 mkhBomService.test.js 对应用例,单测 40/40 通过 --- backend/src/services/mkhBomService.js | 56 +++++++++++++++++----- backend/test/mkhBomService.test.js | 64 +++++++++++++++++-------- frontend/src/pages/MkhBomData/index.jsx | 33 ++++++++----- 3 files changed, 108 insertions(+), 45 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index c6fb7b2..ed4a607 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -143,16 +143,25 @@ // (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单; // 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求) // · 待发货订单量 = Σ 该成品在未发货订单中的件数(同一单多次出现会累加) -// · 预警级别(业务确认):现有库存 < 待发货订单量 → 缺货;否则 → 预警 -// · 建议补货量 = max(0, 待发货订单量 − 现有库存) -// ⚠️ 该分支无法算「可用天数」(日销为 0,除数为零): -// 缺货时记 stockDays = 0(保证排在最前),预警(库存已够发)时记 null。 +// · 预警判定(2026-09-17 业务补充口径):日销为 0,但仍用「可用天数」衡量风险 —— +// 可用天数 = 现有库存 ÷ 代发(待发货)订单件数 +// · 可用天数 = 0(无库存) → 缺货 +// · 0 < 可用天数 < 3 天 → 预警 +// · 可用天数 ≥ 3 天 → 库存充足,不预警(阈值语义与日销分支一致) +// ⚠️ 代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0),不会出现除零; +// 可用天数统一如实记录(保留 2 位小数),不再像以前那样缺货记 0 / 够发记 null。 +// · 建议补货量: +// 缺货 → max(0, 代发订单件数 − 现有库存)(先补到能覆盖整笔订单) +// 预警(0<天数<3) → max(0, 3 × 代发订单件数 − 现有库存)(补到 3 天缓冲) +// 正常(天数≥3) → 0 // ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)—— // 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。 // 实测(2026-09-17,7 天口径下):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件, // 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。 // ⚠️ 改 15 天口径后实测:订单驱动从 14 个降到 **8 个** // (日销基数 114 → 170,原本靠订单兜底的商品自己有日销了),与预期一致。 +// ⚠️ 同日又叠加「可用天数 ≤ 3 天」阈值(2026-09-17 补充):代发件数大、库存也能撑 3 天 +// 以上的商品不再预警,订单驱动预警数会在此 8 个基础上进一步减少(具体数以接口为准)。 // // ── ⑪ 配件不足 / 可组装数(2026-09-17 业务确认口径)──────────────── // 规则:**缺货 / 预警的 MKH 成品,若其配件现有库存「可组装数 = 0」,则提示 @@ -806,7 +815,8 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( * * 口径(见文件头 ⑦ + ⑩): * ① 近15天日销 > 0 且 现有库存 < 3 天日销 → 预警 - * ② 近15天日销 = 0 但存在未发货订单 → 订单驱动预警(见文件头 ⑩) + * ② 近15天日销 = 0 且有代发(待发货)订单 → 订单驱动预警(见文件头 ⑩): + * 可用天数 = 现有库存 ÷ 代发订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警 * 两者都不满足 → 无销量,不参与预警。 * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 * @@ -882,17 +892,37 @@ function buildReplenishMetrics(row = {}) { }; } - // 订单驱动(见文件头 ⑩):日销为 0 算不出可用天数,改按「能否覆盖待发货」判级 + // 订单驱动(见文件头 ⑩):日销为 0 但有代发(待发货)订单、有库存 + // → 用「现有库存 ÷ 代发订单件数」估算可用天数,≤ 3 天即补货预警 + // ⚠️ 阈值语义与日销分支一致:可用天数 < 3 天为「预警」、= 0(无库存)为「缺货」、 + // ≥ 3 天视为库存充足不预警;代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0) if (orderDriven) { - const notEnough = stock < pendingOrderQty; + const orderStockDays = stock / pendingOrderQty; + let level; + let replenishQty; + if (orderStockDays <= 0) { + // 完全没货(库存 ≤ 0):缺货,建议补到能覆盖整笔代发订单 + level = WARNING_LEVEL.OUT_OF_STOCK; + replenishQty = Math.max(0, pendingOrderQty - stock); + } else if (orderStockDays < REPLENISH_DAYS_THRESHOLD) { + // 有货但可用天数不足 3 天:预警,建议补到 3 天缓冲(3 × 代发件数 − 现有库存) + level = WARNING_LEVEL.WARNING; + replenishQty = Math.max(0, Math.ceil(REPLENISH_DAYS_THRESHOLD * pendingOrderQty - stock)); + } else { + // 可用天数 ≥ 3 天:库存充足,不预警 + level = WARNING_LEVEL.NORMAL; + replenishQty = 0; + } return { ...base, - // 缺货记 0(保证排在最前);库存已够发货时记 null(无日销,算不出天数) - stockDays: notEnough ? 0 : null, - replenishQty: Math.max(0, pendingOrderQty - stock), - warningLevel: notEnough ? WARNING_LEVEL.OUT_OF_STOCK : WARNING_LEVEL.WARNING, - warningOrder: notEnough ? 1 : 2, - hasWarning: true, + // 可用天数统一记录(保留 2 位小数,用于排序与展示) + stockDays: Number(orderStockDays.toFixed(2)), + replenishQty, + warningLevel: level, + // 排序权重:缺货 1 < 预警 2 < 正常 3(与日销分支一致) + warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1 + : (level === WARNING_LEVEL.WARNING ? 2 : 3), + hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING, }; } diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 1dd6578..21e6d1e 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -391,29 +391,55 @@ test('pending order: only MKH finished products count, combination codes are exc assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']); }); -test('replenish: no sales but unshipped orders still trigger the warning', () => { - // 库存不够发货 → 缺货,补货量 = 待发货件数 − 现有库存 - const notEnough = metrics({ - avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, pendingOrderCount: 2, +test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => { + // 可用天数 = 现有库存 ÷ 代发订单件数;≤ 3 天即预警,= 0(无库存)为缺货。 + // 完全没货:可用天数 0 → 缺货,补货量 = 代发件数 − 现有库存 + const outOfStock = metrics({ + avgSale: 0, realtimeStock: 0, monitorStock: 0, pendingOrderQty: 4, pendingOrderCount: 2, }); - assert.equal(notEnough.hasWarning, true); - assert.equal(notEnough.warningLevel, '缺货'); - assert.equal(notEnough.replenishQty, 3); - assert.equal(notEnough.orderDriven, true); - // 无日销基数 → 缺货时记 0,保证排在预警清单最前 - assert.equal(notEnough.stockDays, 0); - assert.equal(notEnough.pendingOrderQty, 4); - assert.equal(notEnough.pendingOrderCount, 2); + assert.equal(outOfStock.hasWarning, true); + assert.equal(outOfStock.warningLevel, '缺货'); + assert.equal(outOfStock.orderDriven, true); + assert.equal(outOfStock.stockDays, 0); + assert.equal(outOfStock.replenishQty, 4); + assert.equal(outOfStock.pendingOrderQty, 4); + assert.equal(outOfStock.pendingOrderCount, 2); - // 库存刚好够发 → 预警(不再缺货),补货量为 0 - const exactlyEnough = metrics({ + // 有货但只够 0.25 天 → 预警(不再因「库存 < 订单件数」直接判缺货) + const short = metrics({ + avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, + }); + assert.equal(short.warningLevel, '预警'); + assert.equal(short.hasWarning, true); + assert.equal(short.stockDays, 0.25); + // 补到 3 天缓冲:ceil(3 × 4 − 1) = 11 + assert.equal(short.replenishQty, 11); + + // 可用天数正好 1 天 → 仍预警,补到 3 天缓冲:ceil(3 × 4 − 4) = 8 + const oneDay = metrics({ avgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4, }); - assert.equal(exactlyEnough.warningLevel, '预警'); - assert.equal(exactlyEnough.hasWarning, true); - assert.equal(exactlyEnough.replenishQty, 0); - // 库存已够发时算不出可用天数,记 null(页面显示「按订单」) - assert.equal(exactlyEnough.stockDays, null); + assert.equal(oneDay.warningLevel, '预警'); + assert.equal(oneDay.stockDays, 1); + assert.equal(oneDay.replenishQty, 8); + + // 可用天数正好 3 天(库存=12,件数=4)→ 阈值取「< 3」,故不预警 + const atThreshold = metrics({ + avgSale: 0, realtimeStock: 12, monitorStock: 12, pendingOrderQty: 4, + }); + assert.equal(atThreshold.warningLevel, '正常'); + assert.equal(atThreshold.hasWarning, false); + assert.equal(atThreshold.stockDays, 3); + assert.equal(atThreshold.replenishQty, 0); + + // 可用天数超 3 天(库存=13,件数=4 → 3.25 天)→ 库存充足,不预警,但仍标记 orderDriven + const enough = metrics({ + avgSale: 0, realtimeStock: 13, monitorStock: 13, pendingOrderQty: 4, + }); + assert.equal(enough.warningLevel, '正常'); + assert.equal(enough.hasWarning, false); + assert.equal(enough.orderDriven, true); + assert.equal(enough.stockDays, 3.25); }); test('replenish: order-driven warning does not override the pending work order exemption', () => { diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 1c6b546..0fd2e30 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -78,8 +78,8 @@ const PRODUCT_EXPORT_COLUMNS = [ { title: '可用天数', dataIndex: 'stockDays', - // 无销量商品 stockDays 为 null,导出成文字而非空白,一眼能分辨; - // 订单驱动(日销=0 但有未发货订单)同样是 null,口径不同要分开说明 + // 无销量且无代发订单的商品 stockDays 为 null,导出成文字而非空白; + // 订单驱动分支(日销=0 但有代发/待发货订单)现已如实给出可用天数(库存÷代发件数),不会进这里 exportValue: (value, row) => { if (value !== null && value !== undefined) return value; return row.orderDriven ? '无日销·按订单' : '无销量'; @@ -562,7 +562,7 @@ const MkhBomData = () => { `聚水潭未发货订单 ${row.pendingOrderCount || 0} 单,合计 ${number(value)} 件`, row.pendingOrderEarliest ? `最早一单下单于 ${row.pendingOrderEarliest}` : '', '已取消 / 已合并单据不核算', - row.orderDriven ? '该商品近 15 天暂无日销,按待发货订单量列入补货预警' : '', + row.orderDriven ? '该商品近 15 天暂无日销,按代发(待发货)订单量列入补货预警,可用天数 = 库存 ÷ 代发件数' : '', ].filter(Boolean).join(';')} > {number(value)} @@ -577,12 +577,19 @@ const MkhBomData = () => { sorter: true, render: (value, row) => { if (value === null || value === undefined) { - // 订单驱动(近15天无日销、按待发货订单预警)与「无销量」都算不出天数,但含义不同 - return row.orderDriven - ? 按订单 - : 无销量; + // 真正算不出天数:无日销且无代发订单 → 无销量;订单驱动分支现已如实给出天数,不会进这里 + return 无销量; } - return {number(value, 2)}; + const text = {number(value, 2)}; + if (row.orderDriven) { + // 近 15 天无日销,可用天数按「库存 ÷ 代发订单件数」估算,≤ 3 天即预警 + return ( + + {text} + + ); + } + return text; }, }, { @@ -729,10 +736,10 @@ const MkhBomData = () => { 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识; 建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。
- 待发货订单:近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 + 待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; - 预警级别按「现有库存 < 待发货件数 → 缺货,否则预警」,建议补货量 = 待发货件数 − 现有库存, - 可用天数因无日销基数而显示「按订单」。 + 可用天数 = 现有库存 ÷ 代发(待发货)订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警; + 建议补货量:缺货时按「代发件数 − 现有库存」、预警时按「3 × 代发件数 − 现有库存」补到 3 天缓冲。
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算 (作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。 @@ -823,8 +830,8 @@ const MkhBomData = () => { {(data.orderDrivenCount || 0) > 0 ? ( <>
- 其中 {data.orderDrivenCount} 个近 15 天暂无日销,因聚水潭存在未发货订单 - (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件)而列入预警,建议补货量按待发货件数核算。 + 其中 {data.orderDrivenCount} 个近 15 天暂无日销,按代发(待发货)订单列入预警 + (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件),可用天数 = 库存 ÷ 代发件数、≤ 3 天即预警。 ) : null} {(data.scheduledCount || 0) > 0 ? ( From 75a87957231f71a780f2417224ade85d3f7a9378 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 15:44:30 +0800 Subject: [PATCH 20/70] =?UTF-8?q?=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=EF=BC=9A=E8=A1=A8=E5=A4=B4"SKU=E7=BC=96?= =?UTF-8?q?=E7=A0=81"=E6=94=B9=E4=B8=BA"=E5=95=86=E5=93=81=E7=BC=96?= =?UTF-8?q?=E7=A0=81"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frontend/src/pages/ModularStockMonitor/index.jsx - 表格列头 "SKU编码" → "商品编码" - MKH 配置状态 tooltip 文案同步调整 - 搜索框 placeholder "SKU 编码 / 名称" → "商品编码 / 名称" - backend/src/services/modularStockMonitorService.js - 文件头注释同步更新(仅文案,无逻辑改动) 关于"从 BOM 维护匹配已配置 MKH 成品编码": 已探测 rpt.pm_bomBaseInfo 与目标仓 SKU 的编码体系完全脱钩 (sku_id/i_id/name 均 0 命中),若直接切换数据源会导致已配置数 从 661 归零。经用户确认,本次保留现有产品资料库判定口径, 仅做表头文案变更。 --- backend/src/services/modularStockMonitorService.js | 4 ++-- frontend/src/pages/ModularStockMonitor/index.jsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index ea2dc2b..51c6274 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -4,9 +4,9 @@ // // 需求来源:《模块化库存监控.xlsx》 // 一级功能:模块化库存监控 二级功能:配件库存监控 -// 表头:图片 | 预警 | SKU编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 | +// 表头:图片 | 预警 | 商品编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 | // 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库 -// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的 SKU 编码 +// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的商品编码 // // 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认): // 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库 diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index d121390..fb8db99 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -157,7 +157,7 @@ const AccessoryStockPanel = () => { { title: , dataIndex: 'mkhStatus', width: 110, @@ -170,7 +170,7 @@ const AccessoryStockPanel = () => { }, }, { - title: 'SKU编码', + title: '商品编码', dataIndex: 'skuId', width: 176, fixed: 'left', @@ -291,7 +291,7 @@ const AccessoryStockPanel = () => { } - placeholder="SKU 编码 / 名称" + placeholder="商品编码 / 名称" value={filters.keyword} onChange={(e) => changeFilter('keyword')(e.target.value)} style={{ width: 220 }} From e6ea1246f98e3f38ff7da422fc3712cefb30891a Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 16:01:20 +0800 Subject: [PATCH 21/70] =?UTF-8?q?=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=EF=BC=9A=E5=B7=B2=E9=85=8D=E7=BD=AEMKH?= =?UTF-8?q?=E6=94=B9=E4=B8=BA"=E8=A2=ABMKH=E6=A8=A1=E5=9D=97=E5=8C=96?= =?UTF-8?q?=E6=88=90=E5=93=81=E5=BC=95=E7=94=A8"=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/src/services/modularStockMonitorService.js - 「已配置MKH」判定口径切回 jst_comboProductItem 组合装引用: 该 SKU 出现在 jst_comboProductItem.src_sku_id 且 combine_sku_id 以 'MKH' 开头 即视为被 MKH 模块化成品(组合装/成品档案)引用为配件。 - 移除产品资料系统数据库覆盖清单逻辑(resolveProductCoverage / productCoveredCsv), 产品资料库仅保留作图片来源。 - 实测目标仓 794 SKU 中 29 个命中(原产品资料库口径 661 已废弃)。 - frontend/src/pages/ModularStockMonitor/index.jsx - 状态列 tooltip 文案同步为「是否被 MKH 模块化成品作为配件引用」。 注:本改动会令已配置MKH数量从 661 降至 29(数据使然,非 bug)。 --- .../services/modularStockMonitorService.js | 125 ++++-------------- .../src/pages/ModularStockMonitor/index.jsx | 2 +- 2 files changed, 30 insertions(+), 97 deletions(-) diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 51c6274..4dc5af0 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -50,27 +50,18 @@ const WARNING_LEVEL = Object.freeze({ 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 命中。 +// 模块化配置状态(2026-09-17 业务确认口径): +// 「已配置MKH」= 该 SKU 作为配件被某个 MKH 模块化成品引用 +// (业务库 jst_comboProductItem 中 src_sku_id = 该 SKU +// 且 combine_sku_id 以 'MKH' 开头,即 MKH 组合装 / 成品档案) +// 「未配置MKH」= 未被任何 MKH 模块化成品引用 +// 实测目标仓 794 个 SKU 中 29 个命中(32 个被任意组合装引用,其中 29 个属 MKH 体系)。 +// ⚠️ 之前的「产品资料系统数据库商品档案」口径(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', @@ -85,21 +76,19 @@ const SORTABLE_FIELDS = Object.freeze({ }); /** - * 「已配置MKH」判定表达式。 - * available = true —— 用产品资料库覆盖清单(@productCoveredCsv,逗号分隔的 SKU 编码) - * available = false —— 产品资料库不可用时回退到业务库 MKH BOM 判定,保证页面可用 + * 「已配置MKH」判定表达式(2026-09-17 口径): + * 该 SKU 作为配件被某个 MKH 模块化成品引用,即业务库 jst_comboProductItem 中 + * src_sku_id = 该 SKU 且 combine_sku_id 以 'MKH' 开头(MKH 组合装 / 成品档案)。 */ -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 buildConfiguredExistsSql = () => ( + `EXISTS ( + SELECT 1 FROM dbo.jst_comboProductItem x + WHERE x.src_sku_id = s.sku_id + AND x.combine_sku_id LIKE N'MKH%' + )` ); -const buildBaseSql = (coverage = {}) => ` +const buildBaseSql = () => ` FROM dbo.jst_sku s OUTER APPLY ( SELECT TOP 1 i.qty @@ -127,9 +116,9 @@ OUTER APPLY ( 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 --- 模块化配置状态:见文件头部「口径变更说明」 +-- 模块化配置状态:见文件头部「已配置MKH」口径说明 OUTER APPLY ( - SELECT CAST(CASE WHEN ${buildConfiguredExistsSql(coverage)} THEN 1 ELSE 0 END AS BIT) AS configured + SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured ) cfg WHERE s.other_3 = @warehouseName`; @@ -180,7 +169,7 @@ async function resolveMonitorDate() { return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); } -const buildFilters = (query = {}, monitorDate = null, coverage = {}) => { +const buildFilters = (query = {}, monitorDate = null) => { const conditions = []; const params = { wmsCoId: WAREHOUSE_WMS_CO_ID, @@ -188,11 +177,6 @@ const buildFilters = (query = {}, monitorDate = null, coverage = {}) => { monitorDate, }; - // 「已配置MKH」判定依赖产品资料库覆盖清单(逗号分隔的 SKU 编码) - if (coverage.available) { - params.productCoveredCsv = coverage.csv || ''; - } - const keyword = toText(query.keyword); if (keyword) { params.keyword = `%${keyword}%`; @@ -284,53 +268,11 @@ async function attachProductImages(rows = []) { } /** - * 解析目标仓 SKU 的「产品资料库覆盖清单」,用于「已配置MKH」判定。 - * 返回 { available, csv, set, total, coveredCount },结果按 TTL 缓存。 - * 产品资料库不可用时 available = false,SQL 侧自动回退到业务库 MKH BOM 判定。 + * 「已配置MKH」判定说明(2026-09-17): + * 直接由 buildBaseSql 的 cfg 子查询判定(jst_comboProductItem.src_sku_id + * 命中且 combine_sku_id 以 'MKH' 开头),不再依赖产品资料系统数据库。 + * 产品资料库仅用于补齐商品图片(见 attachProductImages)。 */ -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); @@ -390,12 +332,9 @@ 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 monitorDate = await resolveMonitorDate(); + const { conditions, params } = buildFilters(query, monitorDate); + const baseSql = buildBaseSql(); const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql; const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock'; @@ -474,22 +413,16 @@ async function buildSummary(whereSql, params) { } async function getModularStockFilters() { - const [monitorDate, coverage] = await Promise.all([ - resolveMonitorDate(), - resolveProductCoverage(), - ]); + const monitorDate = await resolveMonitorDate(); 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'' + ${buildBaseSql()} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N'' ORDER BY s.category`, params, { requestTimeout: 180000 } diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index fb8db99..daee125 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -157,7 +157,7 @@ const AccessoryStockPanel = () => { { title: , dataIndex: 'mkhStatus', width: 110, From 0bb7c5905d7c37266a640cd8632fb5dfc19fff54 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 16:20:17 +0800 Subject: [PATCH 22/70] =?UTF-8?q?fix(=E6=A8=A1=E5=9D=97=E5=8C=96=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E7=9B=91=E6=8E=A7):=20=E5=B7=B2=E9=85=8D=E7=BD=AEMKH?= =?UTF-8?q?=20=E6=94=B9=E4=BB=8E=E8=81=9A=E6=B0=B4=E6=BD=AD=20SKU=20?= =?UTF-8?q?=E7=BA=A7=20BOM=20=E7=BB=B4=E6=8A=A4(jst=5FskuBomItem)=E5=88=A4?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务反馈上一版「已配置MKH」数量不对(仅 29 个),原因是用了 jst_comboProductItem 组合装引用口径,漏掉了真正以 MKH 成品为父项的 BOM 配件。 修正: - 数据源由 jst_comboProductItem 改为 rpt.jst_skuBomItem(聚水潭 SKU 级 BOM 明细, 含配件商品编码 map_outer_sku_id)。 - 判定口径:目标仓商品编码出现在 jst_skuBomItem.map_outer_sku_id,且父项 sku_id 以 'MKH' 开头(即被某 MKH 模块化成品 BOM 引用为配件) → 已配置MKH。 - 实测目标仓 794 个 SKU 中 699 个命中(map_outer_sku_id 匹配、父项 MKH 过滤), 95 个未配置,合计 794,与接口返回 summary 一致。 - 同步前端状态列 tooltip 与页脚口径说明。 - 移除对「产品资料系统数据库商品档案」「jst_comboProductItem 组合装」两套旧口径的依赖。 注:pm_bomBaseInfo(聚水潭 BOM 维护) 的配件行无 SKU 编码列(仅中文品名/位号), 其组件 SKU 编码实际落在 jst_skuBomItem.map_outer_sku_id,故采用后者。 --- .../services/modularStockMonitorService.js | 39 +++++++++++-------- .../src/pages/ModularStockMonitor/index.jsx | 4 +- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 4dc5af0..4e2bc3e 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -50,13 +50,18 @@ const WARNING_LEVEL = Object.freeze({ NORMAL: '正常', }); -// 模块化配置状态(2026-09-17 业务确认口径): -// 「已配置MKH」= 该 SKU 作为配件被某个 MKH 模块化成品引用 -// (业务库 jst_comboProductItem 中 src_sku_id = 该 SKU -// 且 combine_sku_id 以 'MKH' 开头,即 MKH 组合装 / 成品档案) -// 「未配置MKH」= 未被任何 MKH 模块化成品引用 -// 实测目标仓 794 个 SKU 中 29 个命中(32 个被任意组合装引用,其中 29 个属 MKH 体系)。 -// ⚠️ 之前的「产品资料系统数据库商品档案」口径(661/794)已废弃,改用组合装引用口径。 +// 模块化配置状态(2026-09-17 业务确认口径,2026-09-17 晚修正): +// 「已配置MKH」= 该 SKU 作为「配件商品编码」出现在聚水潭 SKU 级 BOM 维护中, +// 被某个 MKH 模块化成品引用: +// 业务库 rpt.jst_skuBomItem 中 map_outer_sku_id = 该 SKU +// 且父项 sku_id 以 'MKH' 开头(即 MKH 成品档案,如 MKHSJZ0033260600001)。 +// 「未配置MKH」= 未出现在任何 MKH 成品的 BOM 配件清单中。 +// 实测目标仓 794 个 SKU 中 **699** 个命中(按 map_outer_sku_id 匹配、父项 MKH 过滤)。 +// ⚠️ 历史口径均已废弃: +// · 早期「产品资料系统数据库商品档案」口径(661/794)—— 覆盖面过宽、与 BOM 无关; +// · 上一版「jst_comboProductItem 组合装引用」口径(29/794)—— 仅命中 MKH- 组合装, +// 漏掉了真正以 MKH 成品为父项的 BOM 配件,数量明显偏低。 +// ✅ 当前采用 rpt.jst_skuBomItem(聚水潭 SKU BOM 明细,含配件商品编码 map_outer_sku_id)。 const MKH_STATUS = Object.freeze({ CONFIGURED: '已配置MKH', UNCONFIGURED: '未配置MKH', @@ -76,15 +81,15 @@ const SORTABLE_FIELDS = Object.freeze({ }); /** - * 「已配置MKH」判定表达式(2026-09-17 口径): - * 该 SKU 作为配件被某个 MKH 模块化成品引用,即业务库 jst_comboProductItem 中 - * src_sku_id = 该 SKU 且 combine_sku_id 以 'MKH' 开头(MKH 组合装 / 成品档案)。 + * 「已配置MKH」判定表达式(2026-09-17 晚修正口径): + * 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护 + * rpt.jst_skuBomItem 中,且父项 sku_id 以 'MKH' 开头(即被某个 MKH 模块化成品引用)。 */ const buildConfiguredExistsSql = () => ( `EXISTS ( - SELECT 1 FROM dbo.jst_comboProductItem x - WHERE x.src_sku_id = s.sku_id - AND x.combine_sku_id LIKE N'MKH%' + SELECT 1 FROM rpt.jst_skuBomItem x + WHERE x.map_outer_sku_id = s.sku_id + AND x.sku_id LIKE N'MKH%' )` ); @@ -268,10 +273,10 @@ async function attachProductImages(rows = []) { } /** - * 「已配置MKH」判定说明(2026-09-17): - * 直接由 buildBaseSql 的 cfg 子查询判定(jst_comboProductItem.src_sku_id - * 命中且 combine_sku_id 以 'MKH' 开头),不再依赖产品资料系统数据库。 - * 产品资料库仅用于补齐商品图片(见 attachProductImages)。 + * 「已配置MKH」判定说明(2026-09-17 晚修正): + * 直接由 buildBaseSql 的 cfg 子查询判定,数据源为聚水潭 SKU 级 BOM 维护 + * rpt.jst_skuBomItem(map_outer_sku_id = 该 SKU 且父项 sku_id 以 'MKH' 开头), + * 不再依赖产品资料系统数据库。产品资料库仅用于补齐商品图片(见 attachProductImages)。 */ const mapRow = (row = {}) => { diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index daee125..5512c22 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -157,7 +157,7 @@ const AccessoryStockPanel = () => { { title: , dataIndex: 'mkhStatus', width: 110, @@ -411,7 +411,7 @@ const ModularStockMonitor = () => ( 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天); 库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。 - 状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。 + 状态列:基于聚水潭 SKU 级 BOM 维护(rpt.jst_skuBomItem)判断该商品是否作为「配件商品编码」被某 MKH 成品 BOM 引用——已配置MKH = 出现在某 MKH 成品的 BOM 配件清单中;未配置MKH = 未出现。
From bc8c46d5be8dc27ac291bbb89c8d90cb0523b672 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 16:41:25 +0800 Subject: [PATCH 23/70] =?UTF-8?q?=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=EF=BC=9A=E6=9C=AA=E9=85=8D=E7=BD=AEMKH=20?= =?UTF-8?q?=E4=B8=8D=E8=BF=9B=E4=BB=BB=E4=BD=95=E7=BC=BA=E8=B4=A7/?= =?UTF-8?q?=E9=A2=84=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务确认(2026-09-17):未配置MKH 的配件不属于模块化体系,预警列一律显示「正常」, 既不报缺货(实时库存<=0)也不报预警(可用天数<=7)。 实现: - 新增 buildWarningOrderSql() 统一表达式(0缺货/1预警/2正常),未配置MKH(cfg.configured=0) 直接判 2。 - 列表映射 mapRow 改用 SQL 算好的 warningOrder 推导 warningLevel(删除重复 normalizeWarningLevel, 避免前后端各算一份导致排序与显示不一致)。 - 汇总计数 buildSummary.outOfStockCount / lowStockDaysCount 改用统一表达式,不再计入未配置MKH。 - 筛选 buildFilters:按预警等级筛选、onlyWarning=1(仅看缺货/预警)均排除未配置MKH。 实测(接口直跑,目标仓 794 SKU): - 未配置MKH 95 个全部判「正常」(其中 55 个库存=0、58 个可用天数<=7,原会预警)。 - summary.outOfStockCount=13 / lowStockDaysCount=3,与已配置MKH 页面分布一致。 - onlyWarning=1 返回 16 条,0 条未配置MKH。 --- .../services/modularStockMonitorService.js | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 4e2bc3e..5b3d0da 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -41,7 +41,8 @@ const WAREHOUSE_WMS_CO_ID = 15718196; const DEFAULT_PAGE_SIZE = 50; const MAX_PAGE_SIZE = 200; const DEFAULT_SAFETY_DAYS = 15; -// 预警阈值:库存可用天数 <= 7 天即预警 +// 预警阈值:库存可用天数 <= 7 天即预警(缺货 = 实时库存 <= 0) +// ⚠️ 2026-09-17 业务确认:未配置MKH 的配件不进任何预警(见 buildWarningOrderSql) const LOW_STOCK_DAYS_THRESHOLD = 7; const WARNING_LEVEL = Object.freeze({ @@ -93,6 +94,22 @@ const buildConfiguredExistsSql = () => ( )` ); +/** + * 统一的「预警等级(数值)」表达式(2026-09-17 业务确认:未配置MKH 不进任何预警)。 + * 0 = 缺货 / 1 = 预警 / 2 = 正常 + * 未配置MKH(cfg.configured = 0)一律判为「正常」,不报缺货也不报预警。 + * 注意 cfg / inv / m 三个别名都来自 buildBaseSql 的 OUTER APPLY,WHERE / SELECT 中均可引用。 + * 列表映射、汇总计数、筛选、排序四处共用此表达式,避免口径不一致。 + */ +const buildWarningOrderSql = () => ( + `CASE + WHEN cfg.configured = 0 THEN 2 + 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` +); + const buildBaseSql = () => ` FROM dbo.jst_sku s OUTER APPLY ( @@ -139,16 +156,6 @@ const toText = (value) => { 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, @@ -209,20 +216,14 @@ const buildFilters = (query = {}, monitorDate = null) => { 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`); + // 用统一表达式,未配置MKH 不参与预警判定(筛「缺货 / 预警」时不会漏出未配置MKH) + conditions.push(`${buildWarningOrderSql()} = @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}) - )`); + // 仅看缺货 / 预警:统一表达式 <= 1(未配置MKH 一律为 2,自动排除) + conditions.push(`${buildWarningOrderSql()} <= 1`); } return { conditions, params }; @@ -283,7 +284,14 @@ 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); + // 预警等级以 SQL 端 buildWarningOrderSql() 的判定为准(已含「未配置MKH 不进预警」规则), + // 前端显示与排序口径保持一致,避免前后端各算一份。 + const warningOrder = Number(row.warningOrder ?? WARNING_ORDER[WARNING_LEVEL.NORMAL]); + const warningLevel = warningOrder === WARNING_ORDER[WARNING_LEVEL.OUT_OF_STOCK] + ? WARNING_LEVEL.OUT_OF_STOCK + : warningOrder === WARNING_ORDER[WARNING_LEVEL.WARNING] + ? WARNING_LEVEL.WARNING + : WARNING_LEVEL.NORMAL; return { skuId: toText(row.skuId), @@ -292,7 +300,7 @@ const mapRow = (row = {}) => { // 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空) imageUrl: toText(row.productImageUrl) || toText(row.imageUrl), warningLevel, - warningOrder: WARNING_ORDER[warningLevel], + warningOrder, mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED, // 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量 inQtyToday: toNumber(row.inQtyToday) ?? 0, @@ -326,11 +334,7 @@ const buildProjection = () => ` 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, + ${buildWarningOrderSql()} AS warningOrder, m.date AS monitorDate`; async function getModularStockList(query = {}) { @@ -395,9 +399,9 @@ 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, + SUM(CASE WHEN ${buildWarningOrderSql()} = 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 ${buildWarningOrderSql()} = 1 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}`, From be52bdd0b50f3a85721b7413693063c300b0b476 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Thu, 17 Sep 2026 18:14:57 +0800 Subject: [PATCH 24/70] =?UTF-8?q?feat(=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7/MKH-BOM):=20=E4=B8=A4=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8C=E5=90=8C=E6=AD=A5=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E3=80=8D=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:配件库存监控、MKH-BOM 资料两个页面各加一个同步按钮,任何时候点击都能 立即拉取最新数据,不必等凌晨 00:00 的定时刷新。 后端: - mkhBomService 新增 clearMkhBomCache():清空本页全部服务端缓存 (businessBom / officialBomIndex / officialBomDetail / productBom / mkhProduct / replenishIndex / workOrderIndex / pendingOrderIndex / componentStock / componentLocation),返回 syncedAt + 各缓存清除条目数。 - modularStockMonitorService 新增 syncModularStock():本页无进程内缓存 (每次请求直连副本库),同步动作 = 重新解析并返回最新监控日,前端据此重拉列表。 - 新增接口 POST /api/mkh-bom/sync、POST /api/modular-stock-monitor/sync。 前端: - api/mkhBom.js 新增 syncMkhBom;api/modularStockMonitor.js 新增 syncModularStock。 - 两个页面工具栏各加「同步最新数据」主按钮(SyncOutlined,带 loading 态), 点击 → 调后端 sync → 成功后重新拉列表(MKH-BOM 页同时清空展开明细缓存 detailCache,避免看到旧的配件明细)→ 提示「已同步最新数据」; 失败时展示后端返回的错误信息。 口径说明(重要): - 两页数据来自副本库 JSReportReplica,由上游聚水潭同步;应用层无法触发上游拉取。 本按钮的作用是「清服务端缓存 + 直连副本库重取最新」,保证看到的不是 2~15 分钟前的缓存结果,而不是重新跑一遍上游同步。 - 后端已内置的 dailyRefresh(cron 0 0 * * *,Asia/Shanghai)刷的是 summary_daily_* / summary_monthly_* 汇总表,不覆盖这两页数据源, 与本按钮既不重复也不冲突,可并存。 验证: - 后端 4 个改动文件 node --check 全部通过。 - 前端 4 个改动文件(含 2 个 jsx)用 @babel/parser 解析全部通过。 - 实测:syncModularStock 返回 monitorDate=2026-09-16; clearMkhBomCache 返回 syncedAt 与各缓存清除条目数。 - 单测 mkhBomService.test.js 40/40 通过,无回归。 - 前端 lint/build 未运行:frontend/node_modules 仍是之前被中断 npm ci 破坏的 坏目录(frontend/node_modules_broken_0917/,未纳入提交)。 合并前需在本机 npm ci 恢复后,打开两个页面确认按钮渲染与点击效果。 - 后端改动需重启服务后才生效。 --- backend/src/api/mkhBom.js | 11 +++++++ backend/src/api/modularStockMonitor.js | 10 +++++++ backend/src/services/mkhBomService.js | 29 +++++++++++++++++++ .../services/modularStockMonitorService.js | 12 ++++++++ frontend/src/api/mkhBom.js | 8 +++++ frontend/src/api/modularStockMonitor.js | 7 +++++ frontend/src/pages/MkhBomData/index.jsx | 23 ++++++++++++++- .../src/pages/ModularStockMonitor/index.jsx | 23 +++++++++++++-- 8 files changed, 120 insertions(+), 3 deletions(-) diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index afe8956..9614490 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -5,6 +5,7 @@ const { getMkhBomList, getMkhBomDetail, listMkhBomExportRows, + clearMkhBomCache, } = require('../services/mkhBomService'); const { getMkhReplenishAlertAdminConfig, @@ -101,4 +102,14 @@ router.put('/notification-center', async (req, res) => { } }); +// 手动同步:清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), +// 前端「同步」按钮调用后再拉一次列表即可直连副本库取最新快照 +router.post('/sync', async (_req, res) => { + try { + res.json({ success: true, data: clearMkhBomCache() }); + } catch (error) { + sendError(res, error, '同步 MKH-BOM 数据失败'); + } +}); + module.exports = router; diff --git a/backend/src/api/modularStockMonitor.js b/backend/src/api/modularStockMonitor.js index ce34dd1..6139f06 100644 --- a/backend/src/api/modularStockMonitor.js +++ b/backend/src/api/modularStockMonitor.js @@ -4,6 +4,7 @@ const express = require('express'); const { getModularStockList, getModularStockFilters, + syncModularStock, } = require('../services/modularStockMonitorService'); const router = express.Router(); @@ -36,4 +37,13 @@ router.get('/filters', async (_req, res) => { } }); +// 手动同步:重新取最新监控日,前端「同步」按钮调用后再拉一次列表即拿到最新快照 +router.post('/sync', async (_req, res) => { + try { + res.json({ success: true, data: await syncModularStock() }); + } catch (error) { + sendError(res, error, '同步配件库存监控数据失败'); + } +}); + module.exports = router; diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index ed4a607..d74260b 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -1947,8 +1947,37 @@ async function listMkhBomExportRows(query = {}) { }; } +// 手动同步:清空本页全部服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 配件仓位)。 +// 页面「同步」按钮调用——清掉后下一次查询直连副本库,拿到的是上游最新快照, +// 而不是 2~15 分钟前的缓存结果。 +// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。 +function clearMkhBomCache() { + businessBomCache = null; + officialBomIndexCache = null; + productBomCache = null; + mkhProductCache = null; + replenishIndexCache = null; + workOrderIndexCache = null; + pendingOrderIndexCache = null; + + const cleared = [ + ['officialBomDetailCache', officialBomDetailCache.size], + ['componentLocationCache', componentLocationCache.size], + ['componentStockCache', componentStockCache.size], + ]; + officialBomDetailCache.clear(); + componentLocationCache.clear(); + componentStockCache.clear(); + + return { + syncedAt: new Date().toISOString(), + cleared: cleared.map(([name, entries]) => ({ name, entries })), + }; +} + module.exports = { getMkhBomList, + clearMkhBomCache, getMkhBomDetail, listReplenishAlertRows, listMkhBomExportRows, diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 5b3d0da..b436771 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -445,6 +445,17 @@ async function getModularStockFilters() { }; } +// 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回, +// 前端据此刷新列表到最新快照。 +// 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。 +async function syncModularStock() { + const monitorDate = await resolveMonitorDate(); + return { + syncedAt: new Date().toISOString(), + monitorDate, + }; +} + module.exports = { WAREHOUSE_NAME, WAREHOUSE_WMS_CO_ID, @@ -452,4 +463,5 @@ module.exports = { DEFAULT_SAFETY_DAYS, getModularStockList, getModularStockFilters, + syncModularStock, }; diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js index fdd3341..4d61416 100644 --- a/frontend/src/api/mkhBom.js +++ b/frontend/src/api/mkhBom.js @@ -45,3 +45,11 @@ export const saveMkhNotificationCenter = (payload = {}) => request.put( '/api/mkh-bom/notification-center', payload ); + +// 手动同步:清空服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), +// 随后重新拉列表即直连副本库取最新快照,可随时点击 +export const syncMkhBom = () => request.post( + '/api/mkh-bom/sync', + null, + { timeout: LIST_TIMEOUT_MS } +); diff --git a/frontend/src/api/modularStockMonitor.js b/frontend/src/api/modularStockMonitor.js index 19257e7..ec44585 100644 --- a/frontend/src/api/modularStockMonitor.js +++ b/frontend/src/api/modularStockMonitor.js @@ -14,3 +14,10 @@ export const getModularStockFilters = () => request.get( '/api/modular-stock-monitor/filters', { timeout: META_TIMEOUT_MS } ); + +// 手动同步:后端重新取最新监控日,前端随后再拉一次列表即拿到最新快照 +export const syncModularStock = () => request.post( + '/api/modular-stock-monitor/sync', + null, + { timeout: META_TIMEOUT_MS } +); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 0fd2e30..33968da 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -4,7 +4,7 @@ import { Table, Tooltip, Typography, message, } from 'antd'; import { - BellOutlined, DownloadOutlined, PrinterOutlined, ReloadOutlined, SearchOutlined, + BellOutlined, DownloadOutlined, PrinterOutlined, ReloadOutlined, SearchOutlined, SyncOutlined, } from '@ant-design/icons'; import { SycmPage } from '../../components/Common'; import { @@ -15,6 +15,7 @@ import { saveMkhReplenishAlertConfig, saveMkhNotificationCenter, checkMkhReplenishAlert, + syncMkhBom, } from '../../api/mkhBom'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -233,6 +234,7 @@ const MkhBomData = () => { // 导出(当前筛选条件下的全部数据) const [exporting, setExporting] = useState(false); + const [syncing, setSyncing] = useState(false); // 补货预警推送设置 const [alertOpen, setAlertOpen] = useState(false); @@ -718,6 +720,22 @@ const MkhBomData = () => { ); }; + // 手动同步:清掉服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), + // 再拉一次列表即可直连副本库取最新快照;可随时点击,不必等凌晨的定时刷新 + const handleSync = async () => { + setSyncing(true); + try { + await syncMkhBom(); + setDetailCache({}); + await load(); + message.success('已同步最新数据'); + } catch (syncError) { + message.error(syncError?.response?.data?.error || syncError?.message || '同步失败'); + } finally { + setSyncing(false); + } + }; + return (
@@ -772,6 +790,9 @@ const MkhBomData = () => { /> + + + { const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: null }); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [syncing, setSyncing] = useState(false); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [sorter, setSorter] = useState({ field: 'realtimeStock', order: 'DESC' }); @@ -282,6 +283,21 @@ const AccessoryStockPanel = () => { } }; + // 手动同步:后端重新取最新监控日(清服务端缓存)后再拉一次列表, + // 拿到的是副本库当前最新快照;可随时点击,不受 24 点定时任务限制 + const handleSync = async () => { + setSyncing(true); + try { + await syncModularStock(); + await load(); + message.success('已同步最新数据'); + } catch (syncError) { + message.error(syncError?.response?.data?.error || syncError?.message || '同步失败'); + } finally { + setSyncing(false); + } + }; + const summary = data.summary || {}; return ( @@ -327,6 +343,9 @@ const AccessoryStockPanel = () => { + + + From b7aca2343b6108a1faf3f1ca56dbda8ef863a39a Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Fri, 18 Sep 2026 17:44:01 +0800 Subject: [PATCH 25/70] =?UTF-8?q?feat(=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E7=9B=91=E6=8E=A7/MKH-BOM):=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E5=9C=A8=E9=80=94=E3=80=8D=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E5=88=97=E4=B8=8E=E5=9C=A8=E9=80=94=E6=98=8E=E7=BB=86=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=EF=BC=8C=E8=A1=A5=E8=B4=A7=E9=A2=84=E8=AD=A6=E6=94=B9?= =?UTF-8?q?=E6=8C=89=E7=BB=93=E4=BD=99=E5=BA=93=E5=AD=98=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配件库存监控: - 列表「实时库存」后新增「采购在途数量」列,口径 SUM(MAX(0, jst_purchaseItem.qty - inQty)) - 顶部「实时库存合计」后新增可点击「采购在途」卡片,弹窗展示在途明细 (商品编码/商品名称/采购数量/已入库/在途数量/计划到货日期/采购单号),支持关键词、排序、分页 - 新增后端接口 GET /api/modular-stock-monitor/purchase-in-transit - 筛选器品类改直查 jst_sku 主表,避免新增 OUTER APPLY 拖慢筛选 - 采购在途列仅取数展示,不参与 ORDER BY / 聚合(规避 MSSQL 对 OUTER APPLY 列的灾难性执行计划) MKH-BOM 补货预警: - 日销驱动与已排产分支改用「结余库存 = 现有库存 - 待发货件数」判定可用天数与预警等级 - 建议补货量按 ceil(3 天日销 + 待发货件数 - 现有库存) 计算 - 新增 scope=work-order 范围筛选;列表新增「结余库存」列 - 打印模板剥离公司名前缀,打印字号调大 测试:mkhBomService 单测新增结余库存缓冲/紧张用例与回归用例 --- backend/src/api/modularStockMonitor.js | 10 + backend/src/services/mkhBomService.js | 66 +++++-- .../services/modularStockMonitorService.js | 153 +++++++++++++-- backend/test/mkhBomService.test.js | 34 +++- frontend/src/api/modularStockMonitor.js | 6 + frontend/src/pages/MkhBomData/index.jsx | 65 ++++++- frontend/src/pages/MkhBomData/style.css | 11 +- .../src/pages/ModularStockMonitor/index.jsx | 178 +++++++++++++++++- .../src/pages/ModularStockMonitor/style.css | 10 + 9 files changed, 483 insertions(+), 50 deletions(-) diff --git a/backend/src/api/modularStockMonitor.js b/backend/src/api/modularStockMonitor.js index 6139f06..08deddf 100644 --- a/backend/src/api/modularStockMonitor.js +++ b/backend/src/api/modularStockMonitor.js @@ -4,6 +4,7 @@ const express = require('express'); const { getModularStockList, getModularStockFilters, + getPurchaseInTransitList, syncModularStock, } = require('../services/modularStockMonitorService'); @@ -37,6 +38,15 @@ router.get('/filters', async (_req, res) => { } }); +// 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细 +router.get('/purchase-in-transit', async (req, res) => { + try { + res.json({ success: true, data: await getPurchaseInTransitList(req.query || {}) }); + } catch (error) { + sendError(res, error, '加载采购在途明细失败'); + } +}); + // 手动同步:重新取最新监控日,前端「同步」按钮调用后再拉一次列表即拿到最新快照 router.post('/sync', async (_req, res) => { try { diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index d74260b..6cb027b 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -70,8 +70,11 @@ // · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; // 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①), // 缺失时回退监控快照 jsStock -// · 可用天数 = 现有库存 / 近15天日销 -// · 建议补货量 = ceil(3 × 近15天日销 − 现有库存),不足 0 取 0 +// · 结余库存 = max(0, 现有库存 − 待发货订单) +// ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单** +// (见 ⑫)。待发货订单 = 0 时结余库存 ≡ 现有库存,与旧口径完全等价。 +// · 可用天数 = 结余库存 / 近15天日销 +// · 建议补货量 = ceil(3 × 近15天日销 + 待发货订单 − 现有库存),不足 0 取 0 // ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / fifteenAvgSale 不同源 // (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67, // 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近15天日销」为准。 @@ -190,6 +193,27 @@ // ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标), // 接口透出 componentStockDegraded=true。 // +// ── ⑫ 结余库存(2026-09-18 业务口径修正)────────────────────────── +// 背景:`MKHDNZ0409260601376` 现有库存 3、待发货订单 3,业务认为结余库存应为 0、 +// 可用天数 0、应判缺货;但旧代码里「待发货订单」**只在日销 = 0 时参与判定** +// (`orderDriven = dailySale <= 0 && pendingOrderQty > 0`),该品日销 0.27 > 0, +// 于是走日销分支 3 ≥ 0.81(3 天日销)→ 判「正常」,3 件待发订单被完全忽略。 +// 新口径:**待发货订单对所有商品都参与计算,不再区分日销是否为 0** +// · 结余库存 availableStock = max(0, 现有库存 − 待发货订单) +// · 可用天数 = 结余库存 ÷ 近15天日销(日销 > 0 时) +// · 缺货:结余库存 ≤ 0(订单都发不出去);预警:0 < 可用天数 < 3;否则正常 +// · 建议补货量 = ceil(3 × 近15天日销 + 待发货订单 − 现有库存) +// (先补到能把订单发掉,再补 3 天缓冲) +// ⚠️ ⑩ 的订单驱动分支(日销 = 0)**保持不变**:那里本来就是以代发订单件数为分母, +// 语义是「库存能覆盖几倍订单」,与日销无关,改动会误伤已有的订单驱动预警。 +// 实测(2026-09-18,4,223 个成品):既有日销又有待发订单的商品 38 个, +// 其中 **4 个等级变化、全部升级为缺货**: +// MKHDNZ0409260700503(日销1.67 库存1 待发2) 预警0.60天 → 缺货0天 +// MKHDNZ0409260601547(日销0.42 库存1 待发1) 预警2.38天 → 缺货0天 +// MKHDNZ0409260601527(日销0.13 库存1 待发1) 正常7.69天 → 缺货0天 +// MKHDNZ0409260601376(日销0.27 库存3 待发3) 正常11.11天 → 缺货0天 +// 全量分布:缺货 10→14、预警 6→4、正常 165→163(已排产 / 无销量不变)。 +// const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -333,18 +357,23 @@ const TRUTHY_FLAGS = new Set(['1', 'true', 'yes', 'on']); const parseFlag = (value) => TRUTHY_FLAGS.has(String(value ?? '').trim().toLowerCase()); // 列表展示范围(内存过滤) -const SCOPES = Object.freeze({ WARNING: 'warning', SOLD: 'sold', ALL: 'all' }); +const SCOPES = Object.freeze({ + WARNING: 'warning', SOLD: 'sold', WORK_ORDER: 'work-order', ALL: 'all', +}); /** * 解析展示范围: - * warning —— 仅需补货(近15天日销 > 0 且 可用天数 < 3 天) - * sold —— 有近15天日销的商品 - * all —— 全部 MKH 成品 + * warning —— 仅需补货(缺货 + 3 天内预警) + * sold —— 有近15天日销的商品 + * work-order —— 有未完成加工单(在途加工量 > 0)的商品 + * all —— 全部 MKH 成品 * 兼容旧参数 warningOnly=1(等价 scope=warning)。 */ function resolveScope(query = {}) { const raw = String(query.scope ?? '').trim().toLowerCase(); - if (raw === SCOPES.WARNING || raw === SCOPES.SOLD || raw === SCOPES.ALL) return raw; + if (raw === SCOPES.WARNING || raw === SCOPES.SOLD || raw === SCOPES.WORK_ORDER || raw === SCOPES.ALL) { + return raw; + } if (parseFlag(query.warningOnly)) return SCOPES.WARNING; return SCOPES.ALL; } @@ -360,6 +389,7 @@ function filterRows(rows, { keyword, scope }) { return rows.filter((row) => { if (scope === SCOPES.WARNING && !row.hasWarning) return false; if (scope === SCOPES.SOLD && !(toNumber(row.dailySale) > 0)) return false; + if (scope === SCOPES.WORK_ORDER && !row.hasPendingWorkOrder) return false; return matchesKeyword(row, keyword); }); } @@ -813,8 +843,10 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( /** * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 * - * 口径(见文件头 ⑦ + ⑩): - * ① 近15天日销 > 0 且 现有库存 < 3 天日销 → 预警 + * 口径(见文件头 ⑦ + ⑩ + ⑫): + * ① 近15天日销 > 0 且 结余库存 < 3 天日销 → 预警 + * 结余库存 = max(0, 现有库存 − 待发货订单),待发订单为 0 时即现有库存(见 ⑫) + * 结余库存 = 0 → 缺货(订单都发不出去) * ② 近15天日销 = 0 且有代发(待发货)订单 → 订单驱动预警(见文件头 ⑩): * 可用天数 = 现有库存 ÷ 代发订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警 * 两者都不满足 → 无销量,不参与预警。 @@ -845,12 +877,16 @@ function buildReplenishMetrics(row = {}) { // 待发货订单(见文件头 ⑩):当前未发货订单里的件数 const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0); const pendingOrderCount = Math.max(0, toNumber(row.pendingOrderCount) ?? 0); - // 快照日销还是 0、但已经有订单 → 由订单驱动预警 + // 快照日销还是 0、但已经有订单 → 由订单驱动预警(⑩ 专属分支,分母用代发订单件数) const orderDriven = dailySale <= 0 && pendingOrderQty > 0; + // 结余库存(见文件头 ⑫):已下单未发货的部分已被占用,先扣掉才是可自由支配的库存。 + // ⚠️ 不分日销是否为 0,所有口径统一按结余库存计算(2026-09-18 口径修正)。 + const availableStock = Math.max(0, stock - pendingOrderQty); const base = { dailySale, stock, + availableStock, pendingWorkOrderQty, pendingWorkOrderCount, hasPendingWorkOrder, @@ -884,7 +920,7 @@ function buildReplenishMetrics(row = {}) { if (hasPendingWorkOrder) { return { ...base, - stockDays: dailySale > 0 ? Number((stock / dailySale).toFixed(2)) : null, + stockDays: dailySale > 0 ? Number((availableStock / dailySale).toFixed(2)) : null, replenishQty: 0, warningLevel: WARNING_LEVEL.SCHEDULED, warningOrder: 4, @@ -926,9 +962,11 @@ function buildReplenishMetrics(row = {}) { }; } - const stockDays = stock / dailySale; - const replenishQty = Math.max(0, Math.ceil(base.thresholdQty - stock)); - const level = stockDays <= 0 + // 日销驱动(见文件头 ⑦ + ⑫):一律用**结余库存**衡量,待发货订单先从库存里扣掉 + const stockDays = availableStock / dailySale; + // 建议补货量 = 3 天日销缓冲 + 待发货订单缺口(先补到能把订单发掉,再补 3 天缓冲) + const replenishQty = Math.max(0, Math.ceil(base.thresholdQty + pendingOrderQty - stock)); + const level = availableStock <= 0 ? WARNING_LEVEL.OUT_OF_STOCK : (stockDays < REPLENISH_DAYS_THRESHOLD ? WARNING_LEVEL.WARNING : WARNING_LEVEL.NORMAL); diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index b436771..c99abae 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -28,8 +28,11 @@ // 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty // (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794) // -// ⚠️ 业务库确实无数据源的字段: -// - 仓位 location:rpt.jst_sku.bin / other_bin 全库 557,475 行填充数为 0 +// ✅ 仓位 location(2026-09-18 改源,旧的「无数据源」结论作废): +// - 旧源 rpt.jst_sku.bin / other_bin 在目标仓 794 行**恒为空**,所以页面长期显示「未维护」 +// - 新源 rpt.jst_packInventoryInfo.bin(真货位):该表 2026-09-18 10:48 被上游刷新后 +// 目标仓 794 个 SKU **命中 727 个**(如 ZT-8 / BCP01-01-1) +// - 详见 buildLocationSql() 注释 const cloudDatabase = require('../config/cloudDatabase'); const productDb = require('../config/productInformationDatabase'); @@ -110,6 +113,24 @@ const buildWarningOrderSql = () => ( END` ); +/** + * 统一的「仓位」表达式(2026-09-18 改源)。 + * 旧源 `jst_sku.bin / other_bin` 在目标仓 794 行**恒为空**(页面因此长期显示「未维护」); + * 真货位在 `rpt.jst_packInventoryInfo.bin` —— 该表 2026-09-18 10:48 被上游刷新后, + * 目标仓 794 个 SKU **命中 727 个**(实测),如 ZT-8 / BCP01-01-1。 + * · 只取 `qty > 0` 的箱,同 SKU 多箱时取库存最大的那个(列表页只展示主货位) + * · 取不到才回退商品档案的 bin / other_bin(目前仍全空,保留兜底不影响结果) + * 与 MKH-BOM 页 `mkhBomService.loadComponentLocations()` 同源,区别只在本页取单个主货位。 + * ⚠️ 列表映射、仓位筛选两处共用此表达式,改口径时只改这里。 + */ +const buildLocationSql = () => ( + `COALESCE( + ploc.bin, + NULLIF(LTRIM(RTRIM(s.bin)), N''), + NULLIF(LTRIM(RTRIM(s.other_bin)), N'') + )` +); + const buildBaseSql = () => ` FROM dbo.jst_sku s OUTER APPLY ( @@ -132,12 +153,31 @@ OUTER APPLY ( AND p.delivery_date >= @monitorDate AND p.delivery_date < DATEADD(DAY, 1, @monitorDate) ) pin +-- 采购在途:该 SKU 全部采购单中「已下单 − 已入库」的未到货部分合计 +-- jst_purchaseItem.qty = 采购数量,inQty = 已入库数量,plan_arrive_qty 全库恒为 0(不可用)、 +-- is_delivery 全库恒为 0(不可信),故在途口径 = SUM(MAX(0, qty − inQty))。 +-- ⚠️ 此列只参与「取数/展示」,绝不参与 ORDER BY / 聚合(参考本文件头 MSSQL 坑说明, +-- 对 OUTER APPLY 列做排序/聚合会触发灾难性执行计划)。 +OUTER APPLY ( + SELECT CAST(SUM(CASE WHEN p.qty > ISNULL(p.inQty, 0) THEN p.qty - ISNULL(p.inQty, 0) ELSE 0 END) AS DECIMAL(18, 2)) AS transit + FROM dbo.jst_purchaseItem p + WHERE p.sku_id = s.sku_id +) ptr 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 +-- 仓位:主货位(真货位),见 buildLocationSql() 说明 +OUTER APPLY ( + SELECT TOP 1 NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin + FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK) + WHERE pk.sku_id = s.sku_id + AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL + AND ISNULL(pk.qty, 0) > 0 + ORDER BY pk.qty DESC +) ploc -- 模块化配置状态:见文件头部「已配置MKH」口径说明 OUTER APPLY ( SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured @@ -204,7 +244,7 @@ const buildFilters = (query = {}, monitorDate = null) => { 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`); + conditions.push(`${buildLocationSql()} = @location`); } const mkhStatus = toText(query.mkhStatus); @@ -306,6 +346,7 @@ const mapRow = (row = {}) => { inQtyToday: toNumber(row.inQtyToday) ?? 0, outQtyToday: toNumber(row.outQtyToday) ?? 0, realtimeStock, + purchaseTransit: toNumber(row.purchaseTransit) ?? 0, location: toText(row.location), avgShip15d: toNumber(row.avgShip15d), stockDays, @@ -320,9 +361,10 @@ const buildProjection = () => ` s.name AS productName, s.category AS category, COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock, + ISNULL(ptr.transit, 0) AS purchaseTransit, 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, + ${buildLocationSql()} AS location, m.fifteenAvgSale AS avgShip15d, m.jsUseDays AS stockDays, m.safetyDays AS safetyDays, @@ -391,6 +433,7 @@ async function getModularStockList(query = {}) { configuredCount: summaryRow?.configuredCount || 0, unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0), totalStock: summaryRow?.totalStock || 0, + totalPurchaseTransit: summaryRow?.totalPurchaseTransit || 0, }, }; } @@ -401,6 +444,7 @@ async function buildSummary(whereSql, params) { COUNT_BIG(*) AS skuCount, SUM(CASE WHEN ${buildWarningOrderSql()} = 0 THEN 1 ELSE 0 END) AS outOfStockCount, CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock, + CAST(ISNULL(SUM(ISNULL(ptr.transit, 0)), 0) AS BIGINT) AS totalPurchaseTransit, SUM(CASE WHEN ${buildWarningOrderSql()} = 1 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 @@ -408,13 +452,13 @@ async function buildSummary(whereSql, params) { params, { requestTimeout: 180000 } ); - const row = rows[0] || {}; return [{ skuCount: Number(row.skuCount || 0), outOfStockCount: Number(row.outOfStockCount || 0), totalStock: Number(row.totalStock || 0), + totalPurchaseTransit: Number(row.totalPurchaseTransit || 0), lowStockDaysCount: Number(row.lowStockDaysCount || 0), configuredCount: Number(row.configuredCount || 0), monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null, @@ -422,19 +466,17 @@ async function buildSummary(whereSql, params) { } async function getModularStockFilters() { - const monitorDate = await resolveMonitorDate(); - const params = { - wmsCoId: WAREHOUSE_WMS_CO_ID, - warehouseName: WAREHOUSE_NAME, - monitorDate, - }; - + // 2026-09-18 修正:品类下拉不需要 JOIN 库存/采购/监控等重表,只用 SKU 主表即可, + // 避免 buildBaseSql 里新增采购在途 OUTER APPLY 后把 filters 接口拖慢。 const rows = await cloudDatabase.query( `SELECT DISTINCT s.category - ${buildBaseSql()} AND s.category IS NOT NULL AND LTRIM(RTRIM(s.category)) <> N'' + FROM dbo.jst_sku s + WHERE s.other_3 = @warehouseName + AND s.category IS NOT NULL + AND LTRIM(RTRIM(s.category)) <> N'' ORDER BY s.category`, - params, - { requestTimeout: 180000 } + { warehouseName: WAREHOUSE_NAME }, + { requestTimeout: 60000 } ); return { @@ -445,6 +487,86 @@ async function getModularStockFilters() { }; } +/** + * 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细。 + * 口径:在途数量 = qty − inQty(已下单但尚未入库的部分)。 + * 支持 keyword 搜索商品编码 / 名称,支持按 transitQty(默认)/ skuId / purchaseQty / deliveryDate 排序。 + */ +async function getPurchaseInTransitList(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 keyword = toText(query.keyword); + const params = { warehouseName: WAREHOUSE_NAME }; + let whereSql = `s.other_3 = @warehouseName AND p.qty > ISNULL(p.inQty, 0)`; + if (keyword) { + params.keyword = `%${keyword}%`; + whereSql += ` AND (p.sku_id LIKE @keyword OR p.name LIKE @keyword)`; + } + + const sortField = String(query.sortField || '').trim(); + const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + const orderMap = { + skuId: 'p.sku_id', + deliveryDate: 'p.delivery_date', + transitQty: 'transitQty', + purchaseQty: 'p.qty', + }; + const orderCol = orderMap[sortField] || 'transitQty'; + const orderClause = sortField === 'skuId' + ? `${orderCol} ${sortOrder}` + : `${orderCol} ${sortOrder}, p.sku_id ASC`; + + const offset = (page - 1) * pageSize; + + const [rows, totalRow] = await Promise.all([ + cloudDatabase.query( + `SELECT + p.sku_id AS skuId, + p.name AS productName, + CAST(p.qty AS BIGINT) AS purchaseQty, + CAST(ISNULL(p.inQty, 0) AS BIGINT) AS inQty, + CAST(p.qty - ISNULL(p.inQty, 0) AS BIGINT) AS transitQty, + p.delivery_date AS deliveryDate, + CAST(p.po_id AS NVARCHAR(50)) AS poId, + CAST(p.poi_id AS NVARCHAR(50)) AS poiId + FROM rpt.jst_purchaseItem p + INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id + WHERE ${whereSql} + ORDER BY ${orderClause} + OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`, + params, + { requestTimeout: 180000 } + ), + cloudDatabase.query( + `SELECT COUNT_BIG(*) AS total + FROM rpt.jst_purchaseItem p + INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id + WHERE ${whereSql}`, + params, + { requestTimeout: 180000 } + ), + ]); + + return { + rows: rows.map((row) => ({ + skuId: toText(row.skuId), + productName: toText(row.productName), + purchaseQty: Number(row.purchaseQty || 0), + inQty: Number(row.inQty || 0), + transitQty: Number(row.transitQty || 0), + deliveryDate: row.deliveryDate ? new Date(row.deliveryDate).toISOString().slice(0, 10) : null, + poId: toText(row.poId), + poiId: toText(row.poiId), + })), + total: Number(totalRow?.[0]?.total || 0), + page, + pageSize, + warehouse: WAREHOUSE_NAME, + }; +} + // 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回, // 前端据此刷新列表到最新快照。 // 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。 @@ -463,5 +585,6 @@ module.exports = { DEFAULT_SAFETY_DAYS, getModularStockList, getModularStockFilters, + getPurchaseInTransitList, syncModularStock, }; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 21e6d1e..876d9fc 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -471,12 +471,42 @@ test('replenish: pending order fields default to empty without any unshipped ord assert.equal(noSale.warningLevel, '无销量'); assert.equal(noSale.orderDriven, false); - // 有日销的商品照旧走原口径,但待发货量也要带上(页面新增列) + // 有日销的商品:待发货订单先从库存里扣掉(结余库存口径,见服务文件头 ⑫) const withSale = metrics({ avgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); assert.equal(withSale.warningLevel, '缺货'); assert.equal(withSale.orderDriven, false); assert.equal(withSale.pendingOrderQty, 3); - assert.equal(withSale.replenishQty, 6); // ceil(3 × 2 − 0),不受订单量影响 + assert.equal(withSale.availableStock, 0); // max(0, 0 − 3) + assert.equal(withSale.stockDays, 0); + assert.equal(withSale.replenishQty, 9); // ceil(3 × 2 + 3 − 0):先补订单缺口 3,再补 3 天缓冲 6 + + // 结余库存 > 0 时按结余算可用天数:库存 10 − 待发 3 = 7,7 ÷ 2 = 3.5 天 → 正常 + const buffered = metrics({ avgSale: 2, realtimeStock: 10, pendingOrderQty: 3 }); + assert.equal(buffered.availableStock, 7); + assert.equal(buffered.stockDays, 3.5); + assert.equal(buffered.warningLevel, '正常'); + assert.equal(buffered.replenishQty, 0); // ceil(6 + 3 − 10) < 0 → 0 + + // 结余库存不足以支撑 3 天日销 → 预警(MKHDNZ0409260601376 同型:库存3 待发3 日销0.27) + const tight = metrics({ avgSale: 1, realtimeStock: 5, pendingOrderQty: 3 }); + assert.equal(tight.availableStock, 2); + assert.equal(tight.stockDays, 2); + assert.equal(tight.warningLevel, '预警'); + assert.equal(tight.replenishQty, 1); // ceil(3 + 3 − 5) +}); + +// 回归:MKHDNZ0409260601376(2026-09-18 业务反馈) +// 现有库存 3、待发货订单 3 → 结余库存 0、可用天数 0,应判「缺货」。 +// 旧代码只在 dailySale <= 0 时才让待发货订单参与判定,本例日销 0.27 > 0 导致漏报。 +test('replenish: pending orders are deducted even when daily sale is positive (⑫)', () => { + const row = metrics({ avgSale: 0.27, realtimeStock: 3, pendingOrderQty: 3, pendingOrderCount: 3 }); + assert.equal(row.orderDriven, false); + assert.equal(row.availableStock, 0); + assert.equal(row.stockDays, 0); + assert.equal(row.warningLevel, '缺货'); + assert.equal(row.hasWarning, true); + assert.equal(row.warningOrder, 1); + assert.equal(row.replenishQty, 1); // ceil(3 × 0.27 + 3 − 3) = ceil(0.81) }); // ── 导出(见服务文件头 ⑨)──────────────────────────────────────── diff --git a/frontend/src/api/modularStockMonitor.js b/frontend/src/api/modularStockMonitor.js index ec44585..ebafba9 100644 --- a/frontend/src/api/modularStockMonitor.js +++ b/frontend/src/api/modularStockMonitor.js @@ -15,6 +15,12 @@ export const getModularStockFilters = () => request.get( { timeout: META_TIMEOUT_MS } ); +// 采购在途明细列表(目标仓全部「采购数量 > 已入库数量」的采购单明细) +export const getPurchaseInTransitList = (params = {}) => request.get( + '/api/modular-stock-monitor/purchase-in-transit', + { params, timeout: LIST_TIMEOUT_MS } +); + // 手动同步:后端重新取最新监控日,前端随后再拉一次列表即拿到最新快照 export const syncModularStock = () => request.post( '/api/modular-stock-monitor/sync', diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 33968da..f5d9f8d 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -63,6 +63,7 @@ const shortageTooltip = (row) => { const SCOPE_OPTIONS = [ { label: '仅预警', value: 'warning' }, { label: '有日销', value: 'sold' }, + { label: '加工在途', value: 'work-order' }, { label: '全部', value: 'all' }, ]; @@ -76,6 +77,7 @@ const PRODUCT_EXPORT_COLUMNS = [ { title: '现有库存', dataIndex: 'stock' }, { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, { title: '待发货订单', dataIndex: 'pendingOrderQty' }, + { title: '结余库存', dataIndex: 'availableStock' }, { title: '可用天数', dataIndex: 'stockDays', @@ -119,7 +121,9 @@ const EXPORT_MENU_ITEMS = [ { key: 'components-csv', label: '配件明细 · CSV' }, ]; -const EXPORT_SCOPE_LABEL = { warning: '仅预警', sold: '有日销', all: '全部' }; +const EXPORT_SCOPE_LABEL = { + warning: '仅预警', sold: '有日销', 'work-order': '加工在途', all: '全部', +}; // 补货预警推送:范围选项与跳过原因文案 const ALERT_SCOPE_OPTIONS = [ @@ -144,6 +148,17 @@ const ALERT_RECIPIENT_SOURCE_TEXT = { }; // A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 +// 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」), +// 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。 +const stripWarehousePrefix = (value) => String(value || '') + .split('、') + .map((part) => { + const idx = part.lastIndexOf('·'); + return (idx >= 0 ? part.slice(idx + 1) : part).trim(); + }) + .filter(Boolean) + .join('、'); + const PrintPreview = ({ data, onClose }) => { const now = useMemo(() => new Date().toLocaleString('zh-CN'), []); const items = data.items || []; @@ -178,7 +193,7 @@ const PrintPreview = ({ data, onClose }) => {
{text(data.skuId)}该 MKH 商品的配件明细尚未录入「产品资料系统数据库」该 MKH 商品尚无可用的聚水潭商品 BOM 明细
- + @@ -192,7 +207,7 @@ const PrintPreview = ({ data, onClose }) => { ) : null} - + ))} {items.length === 0 ? ( @@ -305,6 +320,12 @@ const MkhBomData = () => { const changeFilter = (key) => (value) => { setFilters((old) => ({ ...old, [key]: value || '' })); setPage(1); + // 点击「仅预警 / 有日销 / 加工在途 / 全部」后自动滚动到下方表格明细 + if (key === 'scope') { + setTimeout(() => { + tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); + } }; const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); }; @@ -571,6 +592,23 @@ const MkhBomData = () => { ) : —), }, + { + // 结余库存(2026-09-18 新增口径):现有库存先扣掉待发货订单,才是可自由支配的库存, + // 补货预警 / 可用天数一律按它算(待发订单为 0 时 ≡ 现有库存) + title: '结余库存', + dataIndex: 'availableStock', + width: 100, + align: 'right', + render: (value, row) => ( + 0 + ? `结余库存 = 现有库存 ${number(row.stock)} − 待发货订单 ${number(row.pendingOrderQty)}` + : '无待发货订单,结余库存 = 现有库存'} + > + {number(value)} + + ), + }, { title: '可用天数', dataIndex: 'stockDays', @@ -591,7 +629,15 @@ const MkhBomData = () => { ); } - return text; + return ( + 0 + ? `可用天数 = 结余库存 ${number(row.availableStock)} ÷ 近15天日销 ${number(row.dailySale, 2)}(结余库存已扣待发货订单)` + : `可用天数 = 现有库存 ${number(row.stock)} ÷ 近15天日销 ${number(row.dailySale, 2)}` + }> + {text} + + ); }, }, { @@ -751,8 +797,10 @@ const MkhBomData = () => { 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 - 该仓无记录时回退监控快照库存;可用天数 = 现有库存 ÷ 近15天日销,低于 3 天即预警,缺货单独标识; - 建议补货量 = 3 × 近15天日销 − 现有库存(向上取整)。无日销且无订单的商品不参与预警。 + 该仓无记录时回退监控快照库存;结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0), + 可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货; + 建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。 + 无日销且无订单的商品不参与预警。
待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; @@ -785,6 +833,7 @@ const MkhBomData = () => { options={[ { label: `仅预警 (${data.warningCount ?? 0})`, value: 'warning' }, { label: `有日销 (${data.soldCount ?? 0})`, value: 'sold' }, + { label: `加工在途 (${data.pendingWorkOrderSkuCount ?? 0})`, value: 'work-order' }, { label: `全部 (${data.mkhTotalCount ?? 0})`, value: 'all' }, ]} /> @@ -847,7 +896,7 @@ const MkhBomData = () => { 按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}): 其中 {data.outOfStockCount || 0} 个已缺货, {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 - 建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 − 现有库存。点「只看预警」即为补货清单。 + 建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。 {(data.orderDrivenCount || 0) > 0 ? ( <>
@@ -937,7 +986,7 @@ const MkhBomData = () => { pageSizeOptions: [50, 100, 200], showTotal: (total) => `共 ${total} 条`, }} - scroll={{ x: 1766, y: bodyHeight }} + scroll={{ x: 1866, y: bodyHeight }} /> diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index 790d760..55300ae 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -235,7 +235,10 @@ .mkh-print-table .mkh-print-sku { font-family: 'SFMono-Regular', Consolas, Menlo, monospace; - font-size: 11px; + /* 2026-09-18 业务要求调大:原来是 11px,车间核对编码时太小 */ + font-size: 14px; + font-weight: 700; + letter-spacing: 0.3px; vertical-align: middle; } @@ -243,8 +246,10 @@ display: block; margin-top: 2px; font-family: 'SFMono-Regular', Consolas, Menlo, monospace; - font-size: 10px; - color: #666; + /* 2026-09-18 业务要求调大:原来是 10px / #666,扫码与人工核对都偏小偏淡 */ + font-size: 12px; + font-weight: 600; + color: #222; } .mkh-print-foot { diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index 0f7a78c..ec08b52 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -1,11 +1,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, Button, Card, Col, Dropdown, Image, Input, Row, Select, + Alert, Button, Card, Col, Dropdown, Image, Input, Modal, Row, Select, Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message, } from 'antd'; import { DownloadOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'; import { SycmPage } from '../../components/Common'; -import { getModularStockList, getModularStockFilters, syncModularStock } from '../../api/modularStockMonitor'; +import { getModularStockList, getModularStockFilters, getPurchaseInTransitList, syncModularStock } from '../../api/modularStockMonitor'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -45,6 +45,15 @@ const AccessoryStockPanel = () => { const tableWrapRef = useRef(null); const [bodyHeight, setBodyHeight] = useState(420); + // 采购在途明细弹窗状态 + const [transitVisible, setTransitVisible] = useState(false); + const [transitData, setTransitData] = useState({ rows: [], total: 0 }); + const [transitLoading, setTransitLoading] = useState(false); + const [transitPage, setTransitPage] = useState(1); + const [transitPageSize, setTransitPageSize] = useState(50); + const [transitKeyword, setTransitKeyword] = useState(''); + const [transitSorter, setTransitSorter] = useState({ field: 'transitQty', order: 'DESC' }); + // 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc: // 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行 useEffect(() => { @@ -135,6 +144,56 @@ const AccessoryStockPanel = () => { setPage(1); }; + // 点击顶部统计卡片 → 自动设置对应筛选条件并滚动到表格明细 + const applySummaryFilter = (type) => () => { + const next = { ...EMPTY_FILTERS }; + let warning = false; + if (type === 'outOfStock') { + next.warningLevel = '缺货'; + warning = true; + } else if (type === 'lowStockDays') { + next.warningLevel = '预警'; + warning = true; + } else if (type === 'configured') { + next.mkhStatus = '已配置MKH'; + } else if (type === 'unconfigured') { + next.mkhStatus = '未配置MKH'; + } + setFilters(next); + setOnlyWarning(warning); + setPage(1); + setTimeout(() => { + tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); + }; + + // 打开采购在途明细弹窗 + const openPurchaseTransit = () => { + setTransitVisible(true); + setTransitPage(1); + setTransitKeyword(''); + setTransitSorter({ field: 'transitQty', order: 'DESC' }); + loadPurchaseTransit(1, '', { field: 'transitQty', order: 'DESC' }); + }; + + const loadPurchaseTransit = useCallback(async (p = transitPage, kw = transitKeyword, sort = transitSorter) => { + setTransitLoading(true); + try { + const result = await getPurchaseInTransitList({ + page: p, + pageSize: transitPageSize, + keyword: kw, + sortField: sort.field, + sortOrder: sort.order, + }); + setTransitData(result?.data || { rows: [], total: 0 }); + } catch (err) { + message.error(err?.response?.data?.error || err?.message || '加载采购在途明细失败'); + } finally { + setTransitLoading(false); + } + }, [transitPage, transitPageSize, transitKeyword, transitSorter]); + const columns = useMemo(() => [ { title: '图片', @@ -219,10 +278,22 @@ const AccessoryStockPanel = () => { sorter: true, render: int, }, + { + title: , + dataIndex: 'purchaseTransit', + width: 120, + align: 'right', + render: (value) => (value === null || value === undefined || Number(value) === 0 + ? -- + : int(value)), + }, { title: , dataIndex: 'location', width: 88, @@ -353,11 +424,32 @@ const AccessoryStockPanel = () => {
- - + + + + + + + + + + - - + + + + + + + + + + + + + + + { + + + 采购在途明细 + + 共 {transitData.total || 0} 条采购单 / 在途 {summary.totalPurchaseTransit || 0} 件 + + + )} + open={transitVisible} + onCancel={() => setTransitVisible(false)} + width={960} + footer={null} + destroyOnHidden + > + + { setTransitKeyword(value); setTransitPage(1); loadPurchaseTransit(1, value, transitSorter); }} + style={{ width: 260 }} + /> +
MKH商品编码MKH商品编码 配件明细 数量 配件仓位{text(item.skuName)}{item.skuId ? {item.skuId} : null} {number(item.qty)}{item.location ? text(item.location) : 未维护}{item.location ? text(stripWarehousePrefix(item.location)) : 未维护}
`${row.skuId}-${row.poiId || row.poId || Math.random()}`} + size="small" + loading={transitLoading} + columns={[ + { + title: '商品编码', dataIndex: 'skuId', width: 176, render: (v) => {text(v)}, + }, + { + title: '商品名称', dataIndex: 'productName', width: 240, ellipsis: true, render: (v) => {text(v)}, + }, + { title: '采购数量', dataIndex: 'purchaseQty', width: 96, align: 'right', render: int, sorter: true }, + { title: '已入库', dataIndex: 'inQty', width: 84, align: 'right', render: int, sorter: true }, + { + title: '在途数量', dataIndex: 'transitQty', width: 96, align: 'right', sorter: true, + render: (value) => {int(value)}, + }, + { + title: '计划到货日期', dataIndex: 'deliveryDate', width: 120, align: 'center', sorter: true, + render: (value) => text(value), + }, + { title: '采购单号', dataIndex: 'poId', width: 116, render: text }, + ]} + dataSource={transitData.rows || []} + pagination={{ + current: transitPage, + pageSize: transitPageSize, + total: transitData.total || 0, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200], + showTotal: (total) => `共 ${total} 条`, + }} + onChange={(pagination, _filters, nextSorter) => { + setTransitPage(pagination.current || 1); + setTransitPageSize(pagination.pageSize || 50); + const nextSort = nextSorter?.field + ? { field: nextSorter.field, order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC' } + : { field: 'transitQty', order: 'DESC' }; + setTransitSorter(nextSort); + loadPurchaseTransit(pagination.current || 1, transitKeyword, nextSort); + }} + scroll={{ x: 860, y: 360 }} + /> + + ); }; @@ -426,7 +587,8 @@ const ModularStockMonitor = () => ( 数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、 库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖); - 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」; + 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准; + 仓位取聚水潭「箱及仓位库存」(jst_packInventoryInfo)的真货位,同商品多箱时显示库存最大的主货位,查不到才显示「未维护」; 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天); 库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。 diff --git a/frontend/src/pages/ModularStockMonitor/style.css b/frontend/src/pages/ModularStockMonitor/style.css index 80ea127..6068a4b 100644 --- a/frontend/src/pages/ModularStockMonitor/style.css +++ b/frontend/src/pages/ModularStockMonitor/style.css @@ -87,6 +87,7 @@ .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.is-blue::before { background: var(--sycm-color-primary); } .msm-summary-card .ant-card-body { display: flex; min-height: 98px; @@ -107,6 +108,15 @@ } .msm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); } +.msm-summary-card.is-clickable { + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.msm-summary-card.is-clickable:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + .msm-panel { margin-bottom: 0; overflow: hidden; } .msm-panel .ant-card-head { min-height: 48px; From f156b90dbba0ed396fbd5320a7b84b93c2ca4ea3 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 19 Sep 2026 12:25:43 +0800 Subject: [PATCH 26/70] =?UTF-8?q?fix:=20MKH-BOM=20=E5=BE=85=E5=8F=91?= =?UTF-8?q?=E8=B4=A7=E8=AE=A2=E5=8D=95=E5=90=88=E5=B9=B6=E4=B8=A4=E5=BC=A0?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E8=A1=A8=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=A4=A9?= =?UTF-8?q?=E7=8C=AB/=E6=B7=98=E5=AE=9D=E8=AE=A2=E5=8D=95=E6=BC=8F?= =?UTF-8?q?=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 MKHDNZ0409260700059、MKHDNZ0409260601228 等成品明明有待发货订单, 补货预警却显示 0 单、判为「不预警」,业务反馈到系统。 根因 业务库的订单按渠道拆成**互不重叠的两张表**,而 loadPendingOrderIndex() 只读了 rpt.jst_Orders(京东/京喜/拼多多/抖音/得物/唯品会), 天猫 / 淘宝 / 1688 / 阿里巴巴渠道的待发货需求**全部被漏掉**。 实测(2026-09-19,近 30 天窗口) - 两表店铺名交集 0(jst_Orders 398 店 vs qmcust 78 店),so_id 交集 5 条, o_id 交集 **0 条** —— 合并无重复计件风险; - jst_Orders 里 `shop_name LIKE '%天猫%'` = **0 行** (不是命名差异:同一品牌在不同平台是不同店铺名,按品牌名反查会误判); - MKH 成品待发货:仅 jst_Orders = 36 编码 / 50 件 → 合并后 = **40 编码 / 56 件**,补回 4 个编码: MKHDNZ0409260700059 / MKHDNZ0409260601228 / MKHDNZ0409260601530 / MKHDNZ0409260700498。 修复 - PENDING_ORDER_TABLE → PENDING_ORDER_TABLES(两张表,新增 qmcust 源); - Promise.allSettled 并行读取,按 o_id 去重后合并(去重为防御性逻辑); - 部分源失败时**保留成功源的数据**并透出 degraded=true,用短 TTL 尽快重试, 不再像原先那样单源失败就整表降级为空索引(那会静默漏报全部需求); - 口径范围:**渠道全并**(2026-09-19 业务确认),含 1688 / 阿里巴巴 / 分销导单专用店铺。 测试 - 新增 2 个回归测试:两张表都读到 + 跨表同 o_id 只计一次 + 组合装不计入; 单源失败时 degraded=true 且成功源数据保留。 - 后端 337 项:336 通过,1 项失败为 Windows 平台固有差异 (resilientSecretStore 断言 0o600,Windows 实际 0o666),与本改动无关。 - 语法检查 151 文件通过。 --- backend/src/services/mkhBomService.js | 116 +++++++++++++++++--------- backend/test/mkhBomService.test.js | 79 ++++++++++++++++++ 2 files changed, 157 insertions(+), 38 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 6cb027b..0d97c89 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -134,14 +134,32 @@ // 规则:**近 15 天日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 // 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」 // 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。 -// · 数据源:业务库 rpt.jst_Orders(订单**主表**,实时更新,实测最新到今天) -// ⚠️ 不要用 rpt.jst_OrderItem(订单明细表)—— 实测**已停更在 2026-07-16** -// (主表最新 o_id≈9409xxx,明细最大 o_id 仅 8439xxx;近 5 天订单有明细的为 0 条)。 -// 主表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]` -// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。 -// 实测逐单对账(2026-07-01~07-16,13.5 万单):全表普通订单仅 76.9% 能对上 -// (多为 `skus='0.'` 空汇总、组合装展开),但**MKH 成品订单 100% 可用**, -// 故本口径只解析 MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。 +// · 数据源:业务库**两张订单主表,必须都读**(2026-09-19 修正,见下 ⑩-a) +// · rpt.jst_Orders —— 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会 +// · rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单 +// 两张表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]` +// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。 +// ⚠️ 不要用明细表(jst_OrderItem / jst_qmcust_orderlist_item):主表直读免关联, +// skus 列一次性带出全部商品;实测 MKH 成品订单 100% 可解析,故只解析 +// MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。 +// +// ── ⑩-a 为什么是两张表(2026-09-19 修正,重要)──────────────────── +// 现象:`MKHDNZ0409260700059` / `MKHDNZ0409260601228` 明明有待发货订单, +// 系统却显示 0 单、不给预警,业务反馈到系统。 +// 根因:**业务库的订单按渠道拆成了互不重叠的两张表**,本服务原先只读了 +// `rpt.jst_Orders` —— 天猫 / 淘宝 / 1688 / 阿里巴巴渠道的待发货需求**全被漏掉**。 +// 实测(2026-09-19,近 30 天窗口): +// · 两表店铺名**交集 = 0**(jst_Orders 398 店 vs qmcust 78 店),so_id 交集仅 5 条, +// o_id 交集 **0 条**(两表的 o_id 完全独立,合并安全); +// · 同一品牌在不同平台是**不同店铺名**(例「欧意朗」在 jst_Orders 是拼多多/得物/唯品会, +// 在天猫/淘宝则是另一张表里的店)→ **按品牌名反查会误判「表里有」,必须按完整店铺名核对**; +// · `jst_Orders` 里 `shop_name LIKE '%天猫%'` = **0 行**(不是命名差异,是真的没有)。 +// 影响面(MKH 成品待发货):仅 jst_Orders = 36 编码 / 50 件; +// 合并后 = **40 编码 / 56 件**,补进来的新编码 4 个: +// MKHDNZ0409260700059 / MKHDNZ0409260601228 / MKHDNZ0409260601530 / MKHDNZ0409260700498; +// 实时单量:jst_Orders 未发货含 MKH 52 单 + qmcust 10 单。 +// 性能:4.91M 行的 qmcust 上该查询约 0.17s(LIKE '%MKH%' 全扫),可忽略。 +// 口径范围:**渠道全并**(2026-09-19 业务确认),含 1688 / 阿里巴巴 / 分销导单专用店铺。 // · 未发货判定:`send_date IS NULL AND status NOT IN ('Cancelled','Merged')` // (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单; // 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求) @@ -279,11 +297,16 @@ const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_ const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000; // ── 待发货订单参数(见文件头 ⑩)────────────────────────────────── -// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里 -const PENDING_ORDER_TABLE = 'rpt.jst_Orders'; +// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里。 +// ⚠️ **两张表必须都读**(2026-09-19 修正):业务库的订单按渠道拆成互不重叠的两张表, +// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部待发货需求。 +const PENDING_ORDER_TABLES = Object.freeze([ + 'rpt.jst_Orders', // 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会 + 'rpt.jst_qmcust_orderlist', // 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单 +]); // 不计入待发货需求的状态:Cancelled 已取消 / Merged 已合并进别的单 const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']); -// 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s) +// 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s / 4.91M 行约 0.17s) const PENDING_ORDER_SKU_LIKE = '%MKH%'; // 订单数据源异常时的降级缓存时长 const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000; @@ -1209,46 +1232,62 @@ async function loadPendingOrderIndex() { return `@pendingOrderStatus${index}`; }).join(', '); - const pending = cloudDatabase.query( + // 两张订单表**并行**读取后合并(见文件头 ⑩:渠道互不重叠,只读一张会漏需求) + const pending = Promise.allSettled(PENDING_ORDER_TABLES.map((table) => cloudDatabase.query( `SELECT o.o_id AS oId, o.order_date AS orderDate, o.skus AS skus - FROM ${PENDING_ORDER_TABLE} o WITH (NOLOCK) + FROM ${table} o WITH (NOLOCK) WHERE o.skus LIKE @pendingOrderSkuLike AND o.send_date IS NULL AND o.status NOT IN (${statusPlaceholders})`, { pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams }, { requestTimeout: 180000 } - ).then((rows) => { + ))).then((results) => { const bySku = new Map(); - rows.forEach((row) => { - const orderDate = row.orderDate instanceof Date ? row.orderDate : null; - parseOrderSkus(row.skus).forEach(({ code, qty }) => { - if (!isMkhProductCode(code)) return; - const key = normalizeCode(code); - if (!key) return; - const entry = bySku.get(key) || { - pendingOrderQty: 0, - pendingOrderCount: 0, - pendingOrderEarliest: null, - }; - entry.pendingOrderQty += qty; - entry.pendingOrderCount += 1; - if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { - entry.pendingOrderEarliest = orderDate; + const seenOrderIds = new Set(); + const failed = []; + + results.forEach((result, index) => { + if (result.status !== 'fulfilled') { + failed.push(`${PENDING_ORDER_TABLES[index]}: ${result.reason?.message || result.reason}`); + return; + } + result.value.forEach((row) => { + // 两表 o_id 实测零交集(2026-09-19 校验),去重只是防御: + // 万一上游把同一张单补录进另一张表,也不会重复计件。 + const orderId = row.oId === null || row.oId === undefined ? null : String(row.oId); + if (orderId !== null) { + if (seenOrderIds.has(orderId)) return; + seenOrderIds.add(orderId); } - bySku.set(key, entry); + const orderDate = row.orderDate instanceof Date ? row.orderDate : null; + parseOrderSkus(row.skus).forEach(({ code, qty }) => { + if (!isMkhProductCode(code)) return; + const key = normalizeCode(code); + if (!key) return; + const entry = bySku.get(key) || { + pendingOrderQty: 0, + pendingOrderCount: 0, + pendingOrderEarliest: null, + }; + entry.pendingOrderQty += qty; + entry.pendingOrderCount += 1; + if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { + entry.pendingOrderEarliest = orderDate; + } + bySku.set(key, entry); + }); }); }); + + if (failed.length) { + // 部分源失败:仍用成功源的数据(比全空更接近真实),但标 degraded 让接口透出, + // 并用短 TTL 尽快重试补齐。 + console.warn('[MKH-BOM] 待发货订单部分数据源读取失败,需求口径可能不完整:', failed.join(' | ')); + return { bySku, degraded: true, error: failed.join(' | '), ttl: PENDING_ORDER_DEGRADED_TTL_MS }; + } return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; - }).catch((error) => { - console.warn('[MKH-BOM] 待发货订单读取失败,已按「无待发货订单」降级:', error.message); - return { - bySku: new Map(), - degraded: true, - error: error.message, - ttl: PENDING_ORDER_DEGRADED_TTL_MS, - }; }); pendingOrderIndexCache = { pending, value: null, expiresAt: 0 }; @@ -2038,6 +2077,7 @@ module.exports = { parseOrderSkus, isMkhProductCode, PENDING_ORDER_EXCLUDED_STATUS, + PENDING_ORDER_TABLES, // ⑪ 配件不足 / 可组装数 buildComponentOccupation, buildComponentReadiness, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 876d9fc..c409030 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -389,6 +389,85 @@ test('pending order: only MKH finished products count, combination codes are exc assert.equal(_private.isMkhProductCode('GBGJ-ZWJ024903AN1407V01'), false); assert.equal(_private.isMkhProductCode(''), false); assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']); + // 订单按渠道拆成互不重叠的两张表:只读 jst_Orders 会漏掉天猫/淘宝/1688 的待发货需求, + // 这两项是口径回归保护(2026-09-19,`MKHDNZ0409260700059` 漏报的根因) + assert.deepEqual( + [..._private.PENDING_ORDER_TABLES], + ['rpt.jst_Orders', 'rpt.jst_qmcust_orderlist'] + ); +}); + +test('pending order: reads both channel tables, merges and dedupes by o_id', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + const queried = []; + + cloudDatabase.query = async (sql) => { + if (sql.includes('jst_qmcust_orderlist')) { + queried.push('qmcust'); + return [ + { oId: 9001, orderDate: new Date('2026-09-18T00:00:00Z'), skus: '1.MKHDNZ0409260700059' }, + { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260601228' }, + ]; + } + queried.push('orders'); + return [ + // 与 qmcust 同 o_id → 必须只算一次(防御上游把同一单补录进另一张表) + { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260601228' }, + { oId: 9003, orderDate: new Date('2026-09-17T00:00:00Z'), skus: '1.MKHDNZ0409260700059*2' }, + // 组合装(MKH- 打头)不计入成品需求 + { + oId: 9004, + orderDate: new Date('2026-09-16T00:00:00Z'), + skus: '1.MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1', + }, + ]; + }; + + try { + svc.clearMkhBomCache(); + const index = await _private.loadPendingOrderIndex(); + + assert.deepEqual(queried.sort(), ['orders', 'qmcust'], '两张订单表都必须读到'); + assert.equal(index.degraded, false); + + const a = index.bySku.get('MKHDNZ0409260700059'); + assert.equal(a.pendingOrderQty, 3, '两表合并:1 + 2'); + assert.equal(a.pendingOrderCount, 2); + + const b = index.bySku.get('MKHDNZ0409260601228'); + assert.equal(b.pendingOrderQty, 1, '同 o_id 跨表重复只计一次'); + assert.equal(b.pendingOrderCount, 1); + + assert.equal(index.bySku.size, 2, '组合装不进入索引'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('pending order: one failing channel degrades but still returns the other table', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + cloudDatabase.query = async (sql) => { + if (sql.includes('jst_qmcust_orderlist')) throw new Error('qmcust unavailable'); + return [{ oId: 9100, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260700059' }]; + }; + + try { + svc.clearMkhBomCache(); + const index = await _private.loadPendingOrderIndex(); + + assert.equal(index.degraded, true, '部分源失败必须透出 degraded'); + assert.match(index.error, /qmcust unavailable/); + assert.equal(index.bySku.get('MKHDNZ0409260700059').pendingOrderQty, 1, '成功源的数据仍要保留'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } }); test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => { From 3e64702f125b9200a17cd812c1af3cf70ce0b986 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 19 Sep 2026 13:24:15 +0800 Subject: [PATCH 27/70] =?UTF-8?q?fix:=20MKH-BOM=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=97=A5=E9=94=80=E8=AE=A2=E5=8D=95=E8=A1=A8=E5=85=9C=E5=BA=95?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E4=B8=8A=E6=B8=B8=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E6=BC=8F=E7=AE=97=E5=AF=BC=E8=87=B4=E3=80=8C=E5=BA=93=E5=AD=98?= =?UTF-8?q?=200=20=E5=8D=B4=E4=B8=8D=E6=8A=A5=E7=BC=BA=E8=B4=A7=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 - 业务反馈 MKHDNZ0409260601548 近 15 天实发 2 件、库存 0,页面既不报缺货也不进任何预警。 - 根因是双重盲区: ① 上游 rpt.pm_stockMonitor.fifteenAvgSale 漏算 —— 快照 09-18 下,窗口内有真实发货的 178 个 MKH 成品编码里有 24 个被记成 0;其中 16 个订单全部来自样品专用店铺(上游同样 不计样品单,属合理口径),剩 8 个是真实店铺(天猫/京东/拼多多)。该列是**保留两位 小数的日均**(最小非零 0.07 = 1 件 ÷ 15 天),无法用四舍五入解释。 ② 快照日销 = 0 且待发货订单 = 0 时,判定直接落到「无销量」分支、hasWarning=false, 彻底排除在预警之外 —— 库存为 0 也救不回来。 改动 - 日销兜底(服务文件头新增 ⑦-a):**快照日销 ≤ 0 且近 15 天有真实发货**时,日销改用 订单表实算值 = 已发货件数 ÷ 15;快照 > 0 一律以上游为准,不覆盖,避免两套口径打架。 · 排除样品/测试专用店铺(shop_name 含「样品」「测试」),与上游口径对齐 · 窗口 = 含今天在内的 15 个自然日,与分母同口径 · 结果透出 dailySaleSource('snapshot'|'orders') / snapshotDailySale / recentShippedQty - 取数:loadPendingOrderIndex 重构为 loadOrderIndex,一条查询同时覆盖「待发货 (send_date IS NULL)」与「近 15 天已发货(send_date 在窗口内)」。实测合并后总耗时 约 1.25s ≈ 单查待发货,而拆成两条独立查询要在现有开销之上再加一次全表扫描 (首轮冷执行计划下 qmcust 单表可劣化到 20s+)。 - PENDING_ORDER_TABLES 更名 ORDER_TABLES:两张表的口径没变,只是不再仅服务待发货。 - 前端:日销来自兜底时加虚线下划 + 悬停说明,业务拿该数字与源系统 fifteenAvgSale 对照时不会被误判为数据错误。 验证 - 语法检查:151 文件通过。 - 后端测试:338 项 337 通过。唯一失败为 resilientSecretStore 的权限断言(期望 0o600、 Windows 实际 0o666),平台固有,与本改动无关。 - 新增回归测试:兜底生效 / 快照非零不覆盖 / 无发货不兜底 / 与订单驱动的优先级 / 订单源降级时退化成旧口径;订单索引测试补充「已发货同编码跨表累加」与「样品、测试店排除」断言。 - 真实数据端到端(本地接口 /api/mkh-bom/list,4,223 个成品): · MKHDNZ0409260601548:日销 0 → 0.1333(source=orders),不再隐身 · 日销 > 0 的商品 177 → 198;有效预警(缺货 + 预警)18 → 28 · 21 个等级变化**全部是「无销量 → 缺货/正常/已排产」**,没有任何既有预警被降级 · 其中 10 个新缺货均为「库存 0 + 近 15 天真实发货 9~27 件」的真盲区 · 样品店排除按整行生效:实测某编码窗口内 5 件(含样品店 1 件)→ 索引记为 4 件 - 前端 eslint 通过、vite build 通过。 风险与回退 - 只影响「快照日销 = 0」的商品,快照 > 0 的商品行为完全不变。 - 订单源不可用时兜底自动失效,退化成旧口径(不会误报)。 - 回退:本提交可整体 revert,无表结构变更、无数据迁移。 --- backend/src/services/mkhBomService.js | 180 ++++++++++++++++++------ backend/test/mkhBomService.test.js | 132 ++++++++++++++--- frontend/src/pages/MkhBomData/index.jsx | 18 ++- frontend/src/pages/MkhBomData/style.css | 7 + 4 files changed, 280 insertions(+), 57 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 0d97c89..94c7428 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -102,6 +102,36 @@ // 订单驱动 14 → 8 ← 更多商品自己有日销了,不再需要靠订单兜底 // ⚠️ 「预警变少」不是 bug,是口径变化的正常结果;若业务想要 30 天,需上游先补数。 // +// ── ⑦-a 日销兜底:订单表实算(2026-09-19 新增)──────────────────── +// 问题:上游快照 pm_stockMonitor.fifteenAvgSale 会**漏算**。实测(快照 09-18, +// 窗口内有发货的 MKH 成品共 178 个编码)**24 个被记成 0**,其中 16 个是 +// 「订单全部来自样品专用店铺」(上游同样不计样品单,属合理口径), +// 剩 **8 个是真实店铺**(天猫 / 京东 / 拼多多),这才是异常。 +// 业务实例:`MKHDNZ0409260601548` 近 15 天实发 2 件(09-09 / 09-19), +// 快照日销却是 0,库存又是 0 → 走「无销量」分支彻底隐身,**连缺货都不报**。 +// ⚠️ 该列是**保留两位小数的日均**,不是取整值:品牌仓 177 个非零值范围 +// 0.07 ~ 4.96,含小数的占 170 个,最小非零 0.07 = 1 件 ÷ 15 天。 +// 所以「发过货却记 0」**无法用四舍五入解释**,就是上游没算进去。 +// 兜底规则:**快照日销 ≤ 0 且近 SALE_WINDOW_DAYS 天有真实发货**时, +// 日销改用订单表实算值 = 已发货件数 ÷ SALE_WINDOW_DAYS, +// 随后照常走 ⑦/⑫ 的日销分支(结余库存、3 天阈值、建议补货量都不改)。 +// · **只在快照 ≤ 0 时兜底**:快照 > 0 一律以上游为准,不覆盖,避免两套口径打架 +// · **排除样品 / 测试专用店铺**(shop_name 含「样品」或「测试」),与上游口径对齐; +// 不排除的话那 16 个样品单编码会被误拉进预警 +// · 窗口 = 含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径 +// · 数据源与 ⑩ 的待发货订单**同一次查询、同一组表**:一次扫表同时拿到 +// 「待发货」与「近15天已发货」两份数据。实测合并后总耗时 ≈ 单查待发货 +// (均约 1.25s),而拆成两条独立查询要在现有开销之上**再加一次全表扫描**。 +// · 兜底生效的商品带 `dailySaleSource='orders'` 与 `recentShippedQty`, +// 前端据此提示「该值来自订单实算、非上游快照」,避免与源系统数字对不上时误判 +// · 订单源不可用时 shippedBySku 为空 → 兜底整体不生效,退化成旧口径(不会误报) +// 实测(2026-09-19,窗口随当天滚动):近 15 天有发货 187 个编码,其中 +// 165 个快照本就 > 0(兜底不生效),**22 个快照 = 0 → 兜底生效**, +// 含本问题的 MKHDNZ0409260601548,以及 09-18 才发货、快照尚未跟上的 +// MKHDNZ04102607000xx 一批。 +// ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」, +// 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。 +// // ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // (工厂已经在做了,不重复提示补货),改标为「已排产」。 @@ -296,20 +326,24 @@ const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_ // 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量 const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000; -// ── 待发货订单参数(见文件头 ⑩)────────────────────────────────── -// 订单主表本地副本;含 `send_date` 与 `status`,明细在 `skus` 文本列里。 +// ── 订单参数(见文件头 ⑩ 待发货 / ⑦-a 近15天已发货)────────────── +// 订单主表本地副本;含 `send_date`(发货日)、`shop_name`(店铺)与 `status`, +// 明细在 `skus` 文本列里。 // ⚠️ **两张表必须都读**(2026-09-19 修正):业务库的订单按渠道拆成互不重叠的两张表, -// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部待发货需求。 -const PENDING_ORDER_TABLES = Object.freeze([ +// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部需求。 +// ⚠️ 待发货(⑩)与近15天已发货(⑦-a)**共用这一组表、共用同一次查询**。 +const ORDER_TABLES = Object.freeze([ 'rpt.jst_Orders', // 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会 'rpt.jst_qmcust_orderlist', // 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单 ]); -// 不计入待发货需求的状态:Cancelled 已取消 / Merged 已合并进别的单 +// 不计入需求的订单状态:Cancelled 已取消 / Merged 已合并进别的单 const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']); -// 只捞含 MKH 的订单,避免全表扫描(实测 1.17M 行约 1.6s / 4.91M 行约 0.17s) +// 只捞含 MKH 的订单,避免全表扫描(实测 1.18M 行 + 4.91M 行,合并查询约 1.25s) const PENDING_ORDER_SKU_LIKE = '%MKH%'; // 订单数据源异常时的降级缓存时长 const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000; +// 日销兜底(⑦-a):这些店铺的单上游不计入日销,兜底也必须排除,否则会误拉进预警 +const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']); // ── 配件不足 / 可组装数参数(见文件头 ⑪)──────────────────────── // 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id @@ -371,7 +405,7 @@ let replenishIndexCache = null; // 加工单在途量索引(见文件头 ⑧) let workOrderIndexCache = null; // 待发货订单索引(见文件头 ⑩) -let pendingOrderIndexCache = null; +let orderIndexCache = null; // 配件库存(按编码缓存,见文件头 ⑪) const componentStockCache = new Map(); @@ -866,7 +900,10 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( /** * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 * - * 口径(见文件头 ⑦ + ⑩ + ⑫): + * 口径(见文件头 ⑦ + ⑦-a + ⑩ + ⑫): + * ⓪ 日销取值:上游快照(⑦);**快照 ≤ 0 且近15天有真实发货时**改用订单实算值(⑦-a), + * 结果带 `dailySaleSource`('snapshot' / 'orders')与 `snapshotDailySale` 便于对照。 + * 下面 ① / ② 用的 `dailySale` 即此处的最终取值。 * ① 近15天日销 > 0 且 结余库存 < 3 天日销 → 预警 * 结余库存 = max(0, 现有库存 − 待发货订单),待发订单为 0 时即现有库存(见 ⑫) * 结余库存 = 0 → 缺货(订单都发不出去) @@ -876,7 +913,9 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 * * @param {object} row - * row.avgSale 近15天日均发货量(取自 rpt.pm_stockMonitor.fifteenAvgSale) + * row.avgSale 上游近15天日均发货量(rpt.pm_stockMonitor.fifteenAvgSale) + * row.recentShippedQty 近15天已发货件数(订单表实算,⑦-a;已排除样品/测试店) + * → 快照 ≤ 0 且该值 > 0 时,日销改用 recentShippedQty ÷ 15 * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) * row.monitorStock 监控快照库存(fallback) * row.safetyDays 源系统安全天数(仅展示) @@ -886,7 +925,17 @@ const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber( * row.pendingOrderQty 待发货订单量(未发货订单件数) */ function buildReplenishMetrics(row = {}) { - const dailySale = Math.max(0, toNumber(row.avgSale) ?? 0); + // 日销 = 上游快照(⑦) + 订单兜底(⑦-a): + // 上游 fifteenAvgSale 会漏算,**只在快照 ≤ 0 且近 SALE_WINDOW_DAYS 天有真实发货时** + // 才改用订单实算值(已发货件数 ÷ 天数);快照 > 0 一律以上游为准,不覆盖。 + const snapshotDailySale = Math.max(0, toNumber(row.avgSale) ?? 0); + const recentShippedQty = Math.max(0, toNumber(row.recentShippedQty) ?? 0); + const orderDailySale = recentShippedQty > 0 + ? Number((recentShippedQty / SALE_WINDOW_DAYS).toFixed(4)) + : 0; + const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0; + const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale; + const realtimeStock = toNumber(row.realtimeStock); const monitorStock = toNumber(row.monitorStock) ?? 0; const hasRealtime = realtimeStock !== null; @@ -908,6 +957,12 @@ function buildReplenishMetrics(row = {}) { const base = { dailySale, + // 日销来源(⑦-a):'snapshot' 上游快照 / 'orders' 订单表实算兜底 + dailySaleSource: useOrderDailySale ? 'orders' : 'snapshot', + // 上游快照原值(保留下来做对照,业务对不上源系统数字时能看出是兜底生效) + snapshotDailySale, + // 近 SALE_WINDOW_DAYS 天已发货件数(⑦-a,已排除样品/测试店) + recentShippedQty, stock, availableStock, pendingWorkOrderQty, @@ -1213,18 +1268,29 @@ const isMkhProductCode = (code) => { return Boolean(text) && /^MKH/i.test(text) && !/^MKH-/i.test(text); }; +/** 是否为「样品 / 测试专用店铺」—— 上游日销不计这些单,兜底也必须排除(见文件头 ⑦-a) */ +const isExcludedSaleShop = (shopName) => { + const text = toText(shopName); + if (!text) return false; + return SALE_EXCLUDED_SHOP_KEYWORDS.some((keyword) => text.includes(keyword)); +}; + /** - * 待发货订单索引 —— 进程内缓存 - * { bySku: Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }>, degraded }。 - * 口径见文件头 ⑩:未发货 = send_date 为空且状态非取消/合并,商品取自主表 skus 文本列。 + * 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a): + * pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩) + * shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店) + * 口径: + * · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩) + * · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a) + * · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计 * ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。 */ -async function loadPendingOrderIndex() { +async function loadOrderIndex() { const now = Date.now(); - if (pendingOrderIndexCache?.value && pendingOrderIndexCache.expiresAt > now) { - return pendingOrderIndexCache.value; + if (orderIndexCache?.value && orderIndexCache.expiresAt > now) { + return orderIndexCache.value; } - if (pendingOrderIndexCache?.pending) return pendingOrderIndexCache.pending; + if (orderIndexCache?.pending) return orderIndexCache.pending; const statusParams = {}; const statusPlaceholders = PENDING_ORDER_EXCLUDED_STATUS.map((status, index) => { @@ -1232,25 +1298,37 @@ async function loadPendingOrderIndex() { return `@pendingOrderStatus${index}`; }).join(', '); - // 两张订单表**并行**读取后合并(见文件头 ⑩:渠道互不重叠,只读一张会漏需求) - const pending = Promise.allSettled(PENDING_ORDER_TABLES.map((table) => cloudDatabase.query( + // 两张订单表**并行**读取后合并(见 ⑩:渠道互不重叠,只读一张会漏需求) + // ⚠️ 一条查询同时覆盖「待发货」(send_date IS NULL)与「近 N 天已发货」(send_date 在窗口内): + // 两者共用同一次表扫描,拆成两条查询会在现有开销之上再加一次全表扫描(实测约 +1.25s)。 + // 窗口取含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径。 + const pending = Promise.allSettled(ORDER_TABLES.map((table) => cloudDatabase.query( `SELECT o.o_id AS oId, o.order_date AS orderDate, + o.send_date AS sendDate, + o.shop_name AS shopName, o.skus AS skus FROM ${table} o WITH (NOLOCK) WHERE o.skus LIKE @pendingOrderSkuLike - AND o.send_date IS NULL - AND o.status NOT IN (${statusPlaceholders})`, + AND o.status NOT IN (${statusPlaceholders}) + AND ( + o.send_date IS NULL + OR ( + o.send_date >= DATEADD(DAY, -${SALE_WINDOW_DAYS - 1}, CAST(GETDATE() AS date)) + AND o.send_date < DATEADD(DAY, 1, CAST(GETDATE() AS date)) + ) + )`, { pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams }, { requestTimeout: 180000 } ))).then((results) => { - const bySku = new Map(); + const pendingBySku = new Map(); + const shippedBySku = new Map(); const seenOrderIds = new Set(); const failed = []; results.forEach((result, index) => { if (result.status !== 'fulfilled') { - failed.push(`${PENDING_ORDER_TABLES[index]}: ${result.reason?.message || result.reason}`); + failed.push(`${ORDER_TABLES[index]}: ${result.reason?.message || result.reason}`); return; } result.value.forEach((row) => { @@ -1261,12 +1339,20 @@ async function loadPendingOrderIndex() { if (seenOrderIds.has(orderId)) return; seenOrderIds.add(orderId); } + // 已发货(⑦-a):样品/测试专用店铺的单上游不计入日销,这里也必须排除 + const shipped = row.sendDate !== null && row.sendDate !== undefined; + if (shipped && isExcludedSaleShop(row.shopName)) return; + const orderDate = row.orderDate instanceof Date ? row.orderDate : null; parseOrderSkus(row.skus).forEach(({ code, qty }) => { if (!isMkhProductCode(code)) return; const key = normalizeCode(code); if (!key) return; - const entry = bySku.get(key) || { + if (shipped) { + shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty); + return; + } + const entry = pendingBySku.get(key) || { pendingOrderQty: 0, pendingOrderCount: 0, pendingOrderEarliest: null, @@ -1276,7 +1362,7 @@ async function loadPendingOrderIndex() { if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { entry.pendingOrderEarliest = orderDate; } - bySku.set(key, entry); + pendingBySku.set(key, entry); }); }); }); @@ -1284,19 +1370,25 @@ async function loadPendingOrderIndex() { if (failed.length) { // 部分源失败:仍用成功源的数据(比全空更接近真实),但标 degraded 让接口透出, // 并用短 TTL 尽快重试补齐。 - console.warn('[MKH-BOM] 待发货订单部分数据源读取失败,需求口径可能不完整:', failed.join(' | ')); - return { bySku, degraded: true, error: failed.join(' | '), ttl: PENDING_ORDER_DEGRADED_TTL_MS }; + console.warn('[MKH-BOM] 订单数据源部分读取失败,需求与日销口径可能不完整:', failed.join(' | ')); + return { + pendingBySku, + shippedBySku, + degraded: true, + error: failed.join(' | '), + ttl: PENDING_ORDER_DEGRADED_TTL_MS, + }; } - return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; + return { pendingBySku, shippedBySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; }); - pendingOrderIndexCache = { pending, value: null, expiresAt: 0 }; + orderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - pendingOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; return value; } catch (error) { - pendingOrderIndexCache = null; + orderIndexCache = null; throw error; } } @@ -1334,11 +1426,12 @@ async function loadReplenishIndex() { ), // 在途加工单(见文件头 ⑧) loadPendingWorkOrderIndex(), - // 待发货订单(见文件头 ⑩) - loadPendingOrderIndex(), - ]).then(([monitorRows, stockRows, workOrderIndex, pendingOrderIndex]) => { + // 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询 + loadOrderIndex(), + ]).then(([monitorRows, stockRows, workOrderIndex, orderIndex]) => { const workOrderBySku = workOrderIndex.bySku; - const pendingOrderBySku = pendingOrderIndex.bySku; + const pendingOrderBySku = orderIndex.pendingBySku; + const shippedBySku = orderIndex.shippedBySku; const latestStock = new Map(); stockRows.forEach((row) => { const key = normalizeCode(row.skuId); @@ -1356,6 +1449,8 @@ async function loadReplenishIndex() { bySku.set(key, buildReplenishMetrics({ // 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据) avgSale: row.fifteenAvgSale, + // 日销兜底见文件头 ⑦-a:快照漏算时用订单表实算的近15天发货量 + recentShippedQty: shippedBySku.get(key) || 0, // 实时库存缺失(而非为 0)时才回退监控快照 realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, monitorStock: row.monitorStock, @@ -1371,8 +1466,9 @@ async function loadReplenishIndex() { bySku, workOrderBySku, pendingOrderBySku, + shippedBySku, workOrderDegraded: workOrderIndex.degraded === true, - pendingOrderDegraded: pendingOrderIndex.degraded === true, + pendingOrderDegraded: orderIndex.degraded === true, monitorDate, }; }); @@ -1508,12 +1604,14 @@ async function buildMergedReplenishRows() { const key = normalizeCode(product.skuId); const metrics = replenishIndex.bySku.get(key); if (metrics) return { ...product, ...metrics }; - // 监控快照里没有该商品时,仍要带上在途加工单(⑧)与待发货订单(⑩)信息 + // 监控快照里没有该商品时,仍要带上在途加工单(⑧)、待发货订单(⑩)与 + // 近15天已发货(⑦-a)信息 —— 后者正是「快照里根本没这个编码」时唯一的日销来源 return { ...product, ...buildReplenishMetrics({ ...(replenishIndex.workOrderBySku?.get(key) || null), ...(replenishIndex.pendingOrderBySku?.get(key) || null), + recentShippedQty: replenishIndex.shippedBySku?.get(key) || 0, }), }; }); @@ -2035,7 +2133,7 @@ function clearMkhBomCache() { mkhProductCache = null; replenishIndexCache = null; workOrderIndexCache = null; - pendingOrderIndexCache = null; + orderIndexCache = null; const cleared = [ ['officialBomDetailCache', officialBomDetailCache.size], @@ -2073,11 +2171,13 @@ module.exports = { COMPONENT_LOCATION_CACHE_TTL_MS, buildReplenishMetrics, loadPendingWorkOrderIndex, - loadPendingOrderIndex, + loadOrderIndex, parseOrderSkus, isMkhProductCode, + isExcludedSaleShop, PENDING_ORDER_EXCLUDED_STATUS, - PENDING_ORDER_TABLES, + ORDER_TABLES, + SALE_EXCLUDED_SHOP_KEYWORDS, // ⑪ 配件不足 / 可组装数 buildComponentOccupation, buildComponentReadiness, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index c409030..3034c53 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -389,15 +389,22 @@ test('pending order: only MKH finished products count, combination codes are exc assert.equal(_private.isMkhProductCode('GBGJ-ZWJ024903AN1407V01'), false); assert.equal(_private.isMkhProductCode(''), false); assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']); - // 订单按渠道拆成互不重叠的两张表:只读 jst_Orders 会漏掉天猫/淘宝/1688 的待发货需求, - // 这两项是口径回归保护(2026-09-19,`MKHDNZ0409260700059` 漏报的根因) + // 订单按渠道拆成互不重叠的两张表:只读 jst_Orders 会漏掉天猫/淘宝/1688 的需求, + // 这两项是口径回归保护(2026-09-19,`MKHDNZ0409260700059` 漏报的根因)。 + // ⚠️ 待发货(⑩)与近15天已发货(⑦-a)共用这一组表。 assert.deepEqual( - [..._private.PENDING_ORDER_TABLES], + [..._private.ORDER_TABLES], ['rpt.jst_Orders', 'rpt.jst_qmcust_orderlist'] ); + // 日销兜底(⑦-a)排除样品/测试专用店铺:上游同样不计这些单,不排除会误拉进预警 + assert.deepEqual([..._private.SALE_EXCLUDED_SHOP_KEYWORDS], ['样品', '测试']); + assert.equal(_private.isExcludedSaleShop('样品专用店铺'), true); + assert.equal(_private.isExcludedSaleShop('某某测试专用店铺'), true); + assert.equal(_private.isExcludedSaleShop('天猫-HX旗舰店101'), false); + assert.equal(_private.isExcludedSaleShop(null), false); }); -test('pending order: reads both channel tables, merges and dedupes by o_id', async () => { +test('order index: reads both channel tables, merges and dedupes by o_id', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); const originalQuery = cloudDatabase.query; @@ -407,69 +414,162 @@ test('pending order: reads both channel tables, merges and dedupes by o_id', asy if (sql.includes('jst_qmcust_orderlist')) { queried.push('qmcust'); return [ - { oId: 9001, orderDate: new Date('2026-09-18T00:00:00Z'), skus: '1.MKHDNZ0409260700059' }, - { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260601228' }, + { oId: 9001, orderDate: new Date('2026-09-18T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059' }, + { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260601228' }, + // 已发货行(⑦-a):与待发货**同一次查询**返回,靠 send_date 是否有值区分 + { + oId: 9005, + sendDate: new Date('2026-09-09T00:00:00Z'), + shopName: '天猫-HX旗舰店101', + skus: '1.MKHDNZ0409260601548', + }, + // 样品专用店铺的已发货单必须排除(上游同样不计这些单) + { + oId: 9006, + sendDate: new Date('2026-09-10T00:00:00Z'), + shopName: '样品专用店铺', + skus: '2.MKHDNZ0409260609999', + }, ]; } queried.push('orders'); return [ // 与 qmcust 同 o_id → 必须只算一次(防御上游把同一单补录进另一张表) - { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260601228' }, - { oId: 9003, orderDate: new Date('2026-09-17T00:00:00Z'), skus: '1.MKHDNZ0409260700059*2' }, + { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260601228' }, + { oId: 9003, orderDate: new Date('2026-09-17T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059*2' }, // 组合装(MKH- 打头)不计入成品需求 { oId: 9004, orderDate: new Date('2026-09-16T00:00:00Z'), + sendDate: null, skus: '1.MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1', }, + // 已发货:真实店铺,同编码跨表累加 + { + oId: 9007, + sendDate: new Date('2026-09-11T00:00:00Z'), + shopName: '京东-HX旗舰店102', + skus: '1.MKHDNZ0409260601548*2', + }, + // 已发货:测试专用店铺 → 排除 + { + oId: 9008, + sendDate: new Date('2026-09-12T00:00:00Z'), + shopName: '某某测试专用店铺', + skus: '1.MKHDNZ0409260608888', + }, ]; }; try { svc.clearMkhBomCache(); - const index = await _private.loadPendingOrderIndex(); + const index = await _private.loadOrderIndex(); assert.deepEqual(queried.sort(), ['orders', 'qmcust'], '两张订单表都必须读到'); assert.equal(index.degraded, false); - const a = index.bySku.get('MKHDNZ0409260700059'); + const a = index.pendingBySku.get('MKHDNZ0409260700059'); assert.equal(a.pendingOrderQty, 3, '两表合并:1 + 2'); assert.equal(a.pendingOrderCount, 2); - const b = index.bySku.get('MKHDNZ0409260601228'); + const b = index.pendingBySku.get('MKHDNZ0409260601228'); assert.equal(b.pendingOrderQty, 1, '同 o_id 跨表重复只计一次'); assert.equal(b.pendingOrderCount, 1); - assert.equal(index.bySku.size, 2, '组合装不进入索引'); + assert.equal(index.pendingBySku.size, 2, '组合装不进入索引'); + + // 已发货(⑦-a):同一次查询拿到,真实店铺跨表累加、样品/测试店排除 + assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 3, '已发货件数 1 + 2 累加'); + assert.equal(index.shippedBySku.has('MKHDNZ0409260609999'), false, '样品专用店铺要排除'); + assert.equal(index.shippedBySku.has('MKHDNZ0409260608888'), false, '测试专用店铺要排除'); + assert.equal(index.shippedBySku.size, 1, '被排除的店铺不进入索引'); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); } }); -test('pending order: one failing channel degrades but still returns the other table', async () => { +test('order index: one failing channel degrades but still returns the other table', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); const originalQuery = cloudDatabase.query; cloudDatabase.query = async (sql) => { if (sql.includes('jst_qmcust_orderlist')) throw new Error('qmcust unavailable'); - return [{ oId: 9100, orderDate: new Date('2026-09-19T00:00:00Z'), skus: '1.MKHDNZ0409260700059' }]; + return [ + { oId: 9100, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059' }, + { + oId: 9101, + sendDate: new Date('2026-09-15T00:00:00Z'), + shopName: '京东-HX旗舰店102', + skus: '1.MKHDNZ0409260601548', + }, + ]; }; try { svc.clearMkhBomCache(); - const index = await _private.loadPendingOrderIndex(); + const index = await _private.loadOrderIndex(); assert.equal(index.degraded, true, '部分源失败必须透出 degraded'); assert.match(index.error, /qmcust unavailable/); - assert.equal(index.bySku.get('MKHDNZ0409260700059').pendingOrderQty, 1, '成功源的数据仍要保留'); + assert.equal(index.pendingBySku.get('MKHDNZ0409260700059').pendingOrderQty, 1, '成功源的数据仍要保留'); + assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 1, '成功源的已发货数据同样保留'); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); } }); +test('replenish: daily sale falls back to order-derived value when snapshot is 0', () => { + // 上游快照漏算(⑦-a):`MKHDNZ0409260601548` 近15天实发 2 件、快照日销 0、库存 0, + // 旧口径走「无销量」分支彻底隐身,连缺货都不报。 + const fallback = metrics({ avgSale: 0, recentShippedQty: 2, realtimeStock: 0, pendingOrderQty: 0 }); + assert.equal(fallback.dailySaleSource, 'orders', '快照为 0 且有发货 → 用订单实算兜底'); + assert.equal(fallback.snapshotDailySale, 0); + assert.equal(fallback.recentShippedQty, 2); + assert.equal(fallback.dailySale, Number((2 / 15).toFixed(4)), '日销 = 2 件 ÷ 15 天'); + assert.equal(fallback.warningLevel, '缺货', '兜底后库存 0 必须报缺货'); + assert.equal(fallback.hasWarning, true); + // 建议补货量 = ceil(3 × 0.1333 + 0 − 0) = 1 + assert.equal(fallback.replenishQty, 1); + + // 快照 > 0 → 一律以上游为准,不被订单实算值覆盖 + const snapshotWins = metrics({ avgSale: 0.5, recentShippedQty: 30, realtimeStock: 0 }); + assert.equal(snapshotWins.dailySaleSource, 'snapshot'); + assert.equal(snapshotWins.dailySale, 0.5, '不覆盖上游快照'); + + // 快照 0 且无发货 → 仍是「无销量」,不参与预警(兜底不能凭空造需求) + const noFallback = metrics({ avgSale: 0, recentShippedQty: 0, realtimeStock: 0 }); + assert.equal(noFallback.dailySale, 0); + assert.equal(noFallback.dailySaleSource, 'snapshot'); + assert.equal(noFallback.warningLevel, '无销量'); + assert.equal(noFallback.hasWarning, false); + + // 兜底日销很小但库存充足 → 正常,不制造噪声 + const enough = metrics({ avgSale: 0, recentShippedQty: 1, realtimeStock: 50, pendingOrderQty: 0 }); + assert.equal(enough.dailySaleSource, 'orders'); + assert.equal(enough.warningLevel, '正常'); + assert.equal(enough.hasWarning, false); + + // 兜底后又快又慢:兜底日销 > 0 时走日销驱动分支(①),不再走订单驱动(②)—— + // 因为此时已经有真实需求历史,用「结余库存 ÷ 日销」衡量比「库存 ÷ 代发件数」更准 + const both = metrics({ + avgSale: 0, recentShippedQty: 2, realtimeStock: 0, pendingOrderQty: 5, pendingOrderCount: 1, + }); + assert.equal(both.dailySaleSource, 'orders'); + assert.equal(both.orderDriven, false, '有兜底日销后不再是订单驱动'); + assert.equal(both.warningLevel, '缺货'); + // 建议补货量 = ceil(3 × 0.1333 + 5 − 0) = ceil(5.3999) = 6(先补订单缺口再补 3 天缓冲) + assert.equal(both.replenishQty, 6); + + // 订单源整体不可用 → recentShippedQty 缺省 0 → 退化成旧口径(不会误报) + const degraded = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); + assert.equal(degraded.recentShippedQty, 0); + assert.equal(degraded.dailySaleSource, 'snapshot'); + assert.equal(degraded.warningLevel, '无销量'); +}); + test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => { // 可用天数 = 现有库存 ÷ 代发订单件数;≤ 3 天即预警,= 0(无库存)为缺货。 // 完全没货:可用天数 0 → 缺货,补货量 = 代发件数 − 现有库存 diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index f5d9f8d..b74ac62 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -539,7 +539,23 @@ const MkhBomData = () => { dataIndex: 'dailySale', width: 106, align: 'right', - render: (value) => (Number(value) > 0 ? number(value, 2) : 0), + render: (value, row) => { + const text = Number(value) > 0 ? number(value, 2) : 0; + // 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。 + // 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。 + if (row.dailySaleSource !== 'orders') return text; + return ( + + {text} + + ); + }, }, { title: '现有库存', diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css index 55300ae..afb18b0 100644 --- a/frontend/src/pages/MkhBomData/style.css +++ b/frontend/src/pages/MkhBomData/style.css @@ -129,6 +129,13 @@ font-weight: 600; } +/* 日销兜底(上游快照漏算时按订单表实算,见服务文件头 ⑦-a): + 虚线表示该值不是上游快照,悬停有说明,避免与源系统数字对照时误判为数据错误 */ +.mkh-sale-fallback { + border-bottom: 1px dashed currentColor; + cursor: help; +} + /* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */ .mkh-tag--shortage { color: #722ed1; From dff2422b34a5d78ee3603bfe57c9539d34da67ac Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 19 Sep 2026 14:49:39 +0800 Subject: [PATCH 28/70] =?UTF-8?q?fix:=20MKH-BOM=20=E6=97=A5=E9=94=80?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E6=8E=92=E9=99=A4=200.01=20=E5=85=83?= =?UTF-8?q?=E7=89=B9=E6=AE=8A=E5=8D=95=EF=BC=8C=E4=BF=AE=E5=A4=8D=E3=80=8C?= =?UTF-8?q?=E6=B2=A1=E5=8F=91=E8=BF=87=E8=B4=A7=E5=8D=B4=E6=9C=89=E6=97=A5?= =?UTF-8?q?=E9=94=80=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 - 业务反馈 MKHDNZ0410260700005 从没发过货,页面却显示日销 0.6 并报缺货。 - 根因是上一版日销兜底(⑦-a)只看 send_date,把「0.01 元特殊单」当成了真实销售: · 拼多多-亿家达书房家具官方旗舰店在 2026-09-18 10:35–10:36 **一分钟内**批量生成 18 张 MKH 订单,实付合计仅 0.18 元、免额合计 16,344 元(单张:实付 0.01 元、 商品额 908.01、免额 908 全额免),备注统一为 SSDD,标签含「特殊单」「转x00005」; 同批还有 18 张商品码为占位码 1.X00005 的 0.01 元订单。 · 这批单 status=Sent、有物流单号,但 **rpt.jst_salesOutboundInfo 里查不到对应出库记录** (同店同时段其他订单有 6,467 行出库)—— 即系统标了「已发货」,货并没有真出。 · 后果:把 10 个 SKU 的日销从 0 抬到 0.6~1.8(共 162 件),凭空产出缺货预警。 改动 - loadOrderIndex 的**已发货分支**增加异常单过滤:命中任一即不计入日销 · pay_amount <= 0.01(0.01 元全免单) · labels 含「特殊单」 - 查询补充取出 o.pay_amount / o.labels(两张订单表均有这两列,已确认)。 - 金额取不到(null / 空串 / 非数字)时**不排除** —— 宁可少排也别误杀,取不到就退化成旧口径。 - ⚠️ **不使用 free_amount > 0 作为判据**:正常订单普遍带折扣免额(窗口内实测 569/1086 行 free_amount > 0),按它筛会把实付 356 / 458 / 1338 元的正常单一并误杀(第一版即踩此坑)。 - 本次只作用于「已发货」分支,待发货(⑩)口径未改动。 验证 - 语法检查:151 文件通过。 - 后端测试:338 项 337 通过。唯一失败为 resilientSecretStore 权限断言(期望 0o600、 Windows 实际 0o666),平台固有,与本改动无关。mkhBomService 单测 44/44。 - 新增回归断言:实付 0.01 / 0 元及字符串金额判为异常单;标签含「特殊单」判为异常单; **有免额的正常单不得被排除**;金额缺省/为 null/非数字时不排除。 - 订单索引用例补充:0.01 元单与「特殊单」标签行不进入 shippedBySku,而带折扣免额的 正常单照常计入(shippedBySku.size 由 1 调整为 2)。 - 真实数据端到端(业务库实时,窗口 15 天): · 应排除的 10 个异常单 SKU(MKHDNZ0410260700001/0002/0004/0005/0007/0008/0009/ 0010/0011/0012)**全部已排除**; · 应保留的 12 个真实单 SKU **全部保留**(含本问题之外的 MKHDNZ0409260601548 等); · 兜底生效 SKU 由 **22 → 12**;兜底日销最大值由 **1.8000 → 0.2000** (0.2 来自 MKHDNZ0409260700483 的 3 件真实发货,属正常)。 风险与回退 - 只影响「快照日销 ≤ 0 且近 15 天有发货」的商品;快照 > 0 的商品行为完全不变。 - 实付 ≤ 0.01 元的订单在任何业务口径下都不构成正常销售,排除不会误伤真实需求。 - 回退:本提交可整体 revert,无表结构变更、无数据迁移。 - 遗留(未在本次改动内):待发货(⑩)分支同样可能被 0.01 元单驱动, 是否一并排除需业务确认;「售后 / 补发」类订单是否计入亦有待定夺。 --- backend/src/services/mkhBomService.js | 58 +++++++++++++++++++++++++-- backend/test/mkhBomService.test.js | 52 +++++++++++++++++++++++- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 94c7428..bb740f2 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -118,6 +118,7 @@ // · **只在快照 ≤ 0 时兜底**:快照 > 0 一律以上游为准,不覆盖,避免两套口径打架 // · **排除样品 / 测试专用店铺**(shop_name 含「样品」或「测试」),与上游口径对齐; // 不排除的话那 16 个样品单编码会被误拉进预警 +// · **排除异常单**(2026-09-19 补充,见下):订单表里的「已发货」不等于真实销售 // · 窗口 = 含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径 // · 数据源与 ⑩ 的待发货订单**同一次查询、同一组表**:一次扫表同时拿到 // 「待发货」与「近15天已发货」两份数据。实测合并后总耗时 ≈ 单查待发货 @@ -125,10 +126,26 @@ // · 兜底生效的商品带 `dailySaleSource='orders'` 与 `recentShippedQty`, // 前端据此提示「该值来自订单实算、非上游快照」,避免与源系统数字对不上时误判 // · 订单源不可用时 shippedBySku 为空 → 兜底整体不生效,退化成旧口径(不会误报) +// +// 🔥 **必须排除「异常单」(2026-09-19 业务反馈后修正)** +// 业务报「MKHDNZ0410260700005 没发过货,为啥有日销」→ 查下来是兜底把 **0.01 元特殊单** +// 当成了销售。该单画像(拼多多-亿家达书房家具官方旗舰店): +// · 实付 **0.01 元**、商品额 908.01、**免额 908(全额免)**、备注 `SSDD`、标签含「特殊单」 +// · 该店在 **09-18 10:35–10:36 一分钟内**批量生成 18 张同样的 0.01 元 MKH 单 +// (实付合计 0.18 元、免额合计 16,344 元),另有 18 张占位码 `1.X00005` 的 0.01 元单 +// · **`rpt.jst_salesOutboundInfo` 里这些 o_id 查不到任何出库记录** +// (同店同时段其他单有 6,467 行)→ 系统标「已发货」,货其实没出 +// · 后果:把 10 个 SKU 的日销从 0 抬到 0.6~1.8(共 162 件),凭空产出缺货预警 +// 规则:已发货分支排除 **`pay_amount ≤ 0.01`** 或 **`labels` 含「特殊单」** 的单。 +// ⚠️ **别用 `free_amount > 0` 判**:正常单普遍有折扣免额(实测窗口内 569/1086 行), +// 按它筛会把 356 / 458 / 1338 元的正常单一起误杀(第一版就踩了这个坑)。 +// ⚠️ 只作用于**已发货**分支;待发货(⑩)口径本次未动。 +// // 实测(2026-09-19,窗口随当天滚动):近 15 天有发货 187 个编码,其中 // 165 个快照本就 > 0(兜底不生效),**22 个快照 = 0 → 兜底生效**, -// 含本问题的 MKHDNZ0409260601548,以及 09-18 才发货、快照尚未跟上的 -// MKHDNZ04102607000xx 一批。 +// 含本问题的 MKHDNZ0409260601548。这 22 个里:**12 个是真实单驱动**(实付 299~1338 元, +// 兜底正确),**10 个是上述 0.01 元异常单驱动**(`MKHDNZ0410260700001/0002/0004/0005/ +// 0007/0008/0009/0010/0011/0012`)—— 加过滤后这 10 个日销归零,另 12 个不受影响。 // ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」, // 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。 // @@ -344,6 +361,16 @@ const PENDING_ORDER_SKU_LIKE = '%MKH%'; const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000; // 日销兜底(⑦-a):这些店铺的单上游不计入日销,兜底也必须排除,否则会误拉进预警 const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']); +// 日销兜底(⑦-a):**异常单**同样不能计入「已发货」—— +// 2026-09-19 实测:店铺在 1 分钟内批量生成 18 张「实付 0.01 元 / 商品额 908 元全额免 / +// 备注 SSDD / 标签含「特殊单」」的单(同批还有占位码 X00005 的单), +// 在 `rpt.jst_salesOutboundInfo` 里**查不到任何出库记录**(同店其他单有 6467 行)—— +// 即系统标了「已发货」但货没真出。它们曾把 10 个 SKU 的日销从 0 抬到 0.6~1.8。 +// 判定:实付 ≤ 0.01 元,或标签含「特殊单」。 +// ⚠️ **不要用 `free_amount > 0` 判**:正常单普遍有折扣免额(实测窗口内 569/1086 行), +// 按它筛会把 356 / 458 / 1338 元的正常单一起误杀(第一版就踩了这个坑)。 +const SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX = 0.01; +const SALE_EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze(['特殊单']); // ── 配件不足 / 可组装数参数(见文件头 ⑪)──────────────────────── // 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id @@ -1275,14 +1302,30 @@ const isExcludedSaleShop = (shopName) => { return SALE_EXCLUDED_SHOP_KEYWORDS.some((keyword) => text.includes(keyword)); }; +/** + * 是否为「异常单」—— 标了已发货但货没真出的单,不能计入兜底日销(见文件头 ⑦-a)。 + * ① 标签含「特殊单」; + * ② 实付金额 ≤ 0.01 元(0.01 元全免单)。 + * ⚠️ 金额未知(null / 非数字)时**不排除** —— 宁可用数据也别误杀,取不到就退化成旧口径。 + */ +const isExcludedSaleOrder = (row = {}) => { + const labels = toText(row.labels); + if (labels && SALE_EXCLUDED_ORDER_LABEL_KEYWORDS.some((keyword) => labels.includes(keyword))) { + return true; + } + const paid = toNumber(row.payAmount); + return paid !== null && paid <= SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX; +}; + /** * 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a): * pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩) * shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店) * 口径: * · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩) - * · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a) + * · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a,已排除样品/测试店 + 0.01 元异常单) * · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计 + * · 异常单过滤只作用于**已发货**分支:0.01 元特殊单「标了发货但没真出库」,不算销售 * ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。 */ async function loadOrderIndex() { @@ -1307,6 +1350,8 @@ async function loadOrderIndex() { o.order_date AS orderDate, o.send_date AS sendDate, o.shop_name AS shopName, + o.pay_amount AS payAmount, + o.labels AS labels, o.skus AS skus FROM ${table} o WITH (NOLOCK) WHERE o.skus LIKE @pendingOrderSkuLike @@ -1342,6 +1387,8 @@ async function loadOrderIndex() { // 已发货(⑦-a):样品/测试专用店铺的单上游不计入日销,这里也必须排除 const shipped = row.sendDate !== null && row.sendDate !== undefined; if (shipped && isExcludedSaleShop(row.shopName)) return; + // 已发货(⑦-a):0.01 元特殊单「标了发货但没真出库」,同样不计入日销 + if (shipped && isExcludedSaleOrder(row)) return; const orderDate = row.orderDate instanceof Date ? row.orderDate : null; parseOrderSkus(row.skus).forEach(({ code, qty }) => { @@ -2175,9 +2222,12 @@ module.exports = { parseOrderSkus, isMkhProductCode, isExcludedSaleShop, + isExcludedSaleOrder, + SALE_EXCLUDED_SHOP_KEYWORDS, + SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX, + SALE_EXCLUDED_ORDER_LABEL_KEYWORDS, PENDING_ORDER_EXCLUDED_STATUS, ORDER_TABLES, - SALE_EXCLUDED_SHOP_KEYWORDS, // ⑪ 配件不足 / 可组装数 buildComponentOccupation, buildComponentReadiness, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 3034c53..52ccce1 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -402,6 +402,21 @@ test('pending order: only MKH finished products count, combination codes are exc assert.equal(_private.isExcludedSaleShop('某某测试专用店铺'), true); assert.equal(_private.isExcludedSaleShop('天猫-HX旗舰店101'), false); assert.equal(_private.isExcludedSaleShop(null), false); + // 日销兜底(⑦-a)排除异常单:0.01 元特殊单「标了发货但没真出库」,不是销售 + assert.equal(_private.SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX, 0.01); + assert.deepEqual([..._private.SALE_EXCLUDED_ORDER_LABEL_KEYWORDS], ['特殊单']); + assert.equal(_private.isExcludedSaleOrder({ payAmount: 0.01 }), true, '实付 0.01 元 → 异常单'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: 0 }), true, '实付 0 元 → 异常单'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: '0.01' }), true, '字符串金额同样识别'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: 908.01, labels: '模块化成品,特殊单,禁止拆分' }), true, + '标签含「特殊单」→ 异常单(即使实付正常)'); + // ⚠️ 反例:正常单普遍有折扣免额,**不能**按 free_amount 判异常,否则会误杀 + assert.equal(_private.isExcludedSaleOrder({ payAmount: 356, freeAmount: 50 }), false, '有免额的正常单不得排除'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: 1338.82, freeAmount: 236.26 }), false, '有免额的正常单不得排除'); + // 金额取不到时不排除(宁可用数据也别误杀) + assert.equal(_private.isExcludedSaleOrder({}), false, '金额缺省 → 不排除'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: null }), false, '金额为 null → 不排除'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: '未知' }), false, '金额非数字 → 不排除'); }); test('order index: reads both channel tables, merges and dedupes by o_id', async () => { @@ -458,6 +473,36 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async shopName: '某某测试专用店铺', skus: '1.MKHDNZ0409260608888', }, + // 异常单(⑦-a):0.01 元全免单,标了已发货但没真出库 → 排除 + // (复现 MKHDNZ0410260700005:实付 0.01 / 免额 908 / 标签含「特殊单」) + { + oId: 9009, + sendDate: new Date('2026-09-18T00:00:00Z'), + shopName: '拼多多-亿家达书房家具官方旗舰店', + payAmount: 0.01, + freeAmount: 908, + labels: '大单待确认,模块化成品,特殊单,禁止拆分,线上发货,转x00005', + skus: '9.MKHDNZ0410260700005*9', + }, + // 异常单(⑦-a):实付正常但标签含「特殊单」→ 同样排除 + { + oId: 9010, + sendDate: new Date('2026-09-18T00:00:00Z'), + shopName: '拼多多-亿家达书房家具官方旗舰店', + payAmount: 500, + labels: '模块化成品,特殊单', + skus: '1.MKHDNZ0410260700099', + }, + // 对照:有折扣免额的**正常单**必须照常计入(第一版按 free_amount 判会误杀它) + { + oId: 9011, + sendDate: new Date('2026-09-18T00:00:00Z'), + shopName: '拼多多-HX旗舰店103', + payAmount: 356, + freeAmount: 50, + labels: '店铺-拼多多,模块化成品', + skus: '1.MKHDNZ0410260700023', + }, ]; }; @@ -482,7 +527,12 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 3, '已发货件数 1 + 2 累加'); assert.equal(index.shippedBySku.has('MKHDNZ0409260609999'), false, '样品专用店铺要排除'); assert.equal(index.shippedBySku.has('MKHDNZ0409260608888'), false, '测试专用店铺要排除'); - assert.equal(index.shippedBySku.size, 1, '被排除的店铺不进入索引'); + // 异常单排除(⑦-a):0.01 元全免单、标签含「特殊单」都不计入已发货 + assert.equal(index.shippedBySku.has('MKHDNZ0410260700005'), false, '0.01 元异常单不得计入日销'); + assert.equal(index.shippedBySku.has('MKHDNZ0410260700099'), false, '标签含「特殊单」的不得计入日销'); + // 有免额的正常单必须照常计入 + assert.equal(index.shippedBySku.get('MKHDNZ0410260700023'), 1, '有免额的正常单不得被误排除'); + assert.equal(index.shippedBySku.size, 2, '被排除的店铺/异常单不进入索引'); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); From da08b03dcb6719f0597ae4cc56caf0623cf73d4a Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 19 Sep 2026 17:07:22 +0800 Subject: [PATCH 29/70] =?UTF-8?q?fix:=20MKH-BOM=20=E9=85=8D=E4=BB=B6?= =?UTF-8?q?=E4=BB=93=E4=BD=8D=E4=BB=93=E5=90=8D=E6=94=B9=E7=94=A8=20wms=5F?= =?UTF-8?q?co=5Fid=20=E5=85=B3=E8=81=94=EF=BC=8C=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=AF=AF=E6=98=BE=E7=A4=BA=E3=80=8C=E5=B8=B8=E5=B7=9E=E7=AE=80?= =?UTF-8?q?=E5=B0=9A=E5=AE=B6=E5=B1=85=E6=9C=89=E9=99=90=E5=85=AC=E5=8F=B8?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因 - rpt.jst_packInventoryInfo.wh_id **不是** rpt.jst_warehouseInfo.Id: 全表只有 6 个取值(1/2/3/4/6/7),90% 的行都是 wh_id=1, 按 `w.Id = p.wh_id` 关联后,727 个配件的仓位仓名被统一显示成「常州简尚家居有限公司」。 - 该表的仓 id 列是自带的 `wms_co_id`: 09-19 实测 30,236 行 **0 NULL**、14 个仓取值,按 `w.wms_co_id = p.wms_co_id` 关联即得到正确仓名「简尚-崔桥工厂半成品包装项目仓」。 - 旧结论「该表 wms_co_id 全表 NULL,必须用 wh_id」只对 09-17 的表状态成立 (当时 85,152 行、只有 3 个箱、bin 全空、与 MKH 配件零交集); 该表由上游**小时级增量刷新**(业务确认 09-19),09-18 重建后 wms_co_id 已全部有值。 改动 - backend/src/services/mkhBomService.js · loadComponentLocations() 的 JOIN 与 GROUP BY:p.wh_id → p.wms_co_id · 文件头 ⑤ 与 formatPackLocation() 注释更新为正确关联列,并记录该表的小时级 增量刷新特性(行级 updateTime 只反映「有变化的行」,不能当表的更新频率; 行数/箱数/命中数会随上游变化,不要写死)。 - backend/src/services/modularStockMonitorService.js · 补注:该页只取 bin、不 join 仓名,不受此关联列问题影响;命中数会随上游变化。 - backend/test/mkhBomService.test.js · 仓位格式化用例的 fixture 改回真实仓名,避免继续固化错误仓名。 影响与验证 - 仅影响 MKH-BOM 页「配件仓位」列与导出配件明细的仓名文本; A4 打印预览本就会去掉仓名前缀(stripWarehousePrefix),不受影响。 - 配件监控页不 join 仓名,无影响。 - 实测:修正后 15 个真实配件编码输出「简尚-崔桥工厂半成品包装项目仓 · BCP01-01-1」。 - 后端:check-syntax 151 文件通过;node --test 337/338 通过 (唯一失败为既有环境问题 resilientSecretStore 权限断言,与本改动无关)。 --- backend/src/services/mkhBomService.js | 9 +++++---- backend/src/services/modularStockMonitorService.js | 4 +++- backend/test/mkhBomService.test.js | 10 +++++----- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 94c7428..588c1b9 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -1695,7 +1695,8 @@ function matchesPackSku(componentCode, packSkuCode) { function formatPackLocation(row) { const warehouseName = toText(row.warehouseName); const warehouseId = toText(row.warehouseId); - // 仓库优先显示名称(wh_id → jst_warehouseInfo.Id → name),取不到时退回 id + // 仓库优先显示名称(wms_co_id → jst_warehouseInfo.wms_co_id → name),取不到时退回 id。 + // ⚠️ 关联列是 wms_co_id 不是 wh_id,见文件头 ⑤(09-19 修正) const warehouse = warehouseName || (warehouseId ? `仓库 ${warehouseId}` : ''); const binGroup = toText(row.binGroup); const bin = toText(row.bin); @@ -1772,15 +1773,15 @@ async function loadComponentLocations(codes = []) { const rows = await cloudDatabase.query( `SELECT p.sku_id AS skuId, p.pack_id AS packId, - p.wh_id AS warehouseId, + p.wms_co_id AS warehouseId, p.bin AS bin, p.bin_group AS binGroup, w.name AS warehouseName, SUM(p.qty) AS qty FROM rpt.jst_packInventoryInfo p WITH (NOLOCK) - LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.Id = p.wh_id + LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = p.wms_co_id WHERE ${conditions.join(' OR ')} - GROUP BY p.sku_id, p.pack_id, p.wh_id, p.bin, p.bin_group, w.name + GROUP BY p.sku_id, p.pack_id, p.wms_co_id, p.bin, p.bin_group, w.name HAVING SUM(p.qty) > 0`, params, { requestTimeout: 120000 } diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index c99abae..1718467 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -117,7 +117,9 @@ const buildWarningOrderSql = () => ( * 统一的「仓位」表达式(2026-09-18 改源)。 * 旧源 `jst_sku.bin / other_bin` 在目标仓 794 行**恒为空**(页面因此长期显示「未维护」); * 真货位在 `rpt.jst_packInventoryInfo.bin` —— 该表 2026-09-18 10:48 被上游刷新后, - * 目标仓 794 个 SKU **命中 727 个**(实测),如 ZT-8 / BCP01-01-1。 + * 目标仓 794 个 SKU **命中 727 个**(09-19 实测;该表小时级增量刷新,命中数会变)。 + * ⚠️ 只取 bin,**不 join 仓名**,所以不受该表 wh_id / wms_co_id 关联列问题影响(见 + * mkhBomService 文件头 ⑤;那个坑只影响 MKH-BOM 页配件仓位列的仓名文本)。 * · 只取 `qty > 0` 的箱,同 SKU 多箱时取库存最大的那个(列表页只展示主货位) * · 取不到才回退商品档案的 bin / other_bin(目前仍全空,保留兜底不影响结果) * 与 MKH-BOM 页 `mkhBomService.loadComponentLocations()` 同源,区别只在本页取单个主货位。 diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 3034c53..4dec850 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -789,8 +789,8 @@ test('pack inventory location prefers the warehouse name and keeps the box fallb const locations = _private.buildPackLocationMap([ { skuId: 'PART-1-1', - warehouseId: 1, - warehouseName: '常州简尚家居有限公司', + warehouseId: 15718196, + warehouseName: '简尚-崔桥工厂半成品包装项目仓', binGroup: null, bin: '', packId: 'BOX-1', @@ -798,8 +798,8 @@ test('pack inventory location prefers the warehouse name and keeps the box fallb }, { skuId: 'PART-1-2', - warehouseId: 1, - warehouseName: '常州简尚家居有限公司', + warehouseId: 15718196, + warehouseName: '简尚-崔桥工厂半成品包装项目仓', binGroup: '成品区', bin: 'A-01', packId: 'BOX-2', @@ -809,7 +809,7 @@ test('pack inventory location prefers the warehouse name and keeps the box fallb assert.equal( locations.get('PART-1'), - '常州简尚家居有限公司 · 箱号 BOX-1、常州简尚家居有限公司 · 成品区 / A-01' + '简尚-崔桥工厂半成品包装项目仓 · 箱号 BOX-1、简尚-崔桥工厂半成品包装项目仓 · 成品区 / A-01' ); }); From 0e0fcbb3782a553f2d5655b6bc798ca264c39d6d Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 19 Sep 2026 17:53:16 +0800 Subject: [PATCH 30/70] =?UTF-8?q?fix:=20MKH-BOM=20=E6=97=A5=E9=94=80?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E5=A2=9E=E5=8A=A0=E5=87=BA=E5=BA=93=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=EF=BC=8C=E6=A0=87=E4=BA=86=E3=80=8C=E5=B7=B2=E5=8F=91?= =?UTF-8?q?=E8=B4=A7=E3=80=8D=E4=BD=86=E6=97=A0=E5=AE=9E=E9=99=85=E5=87=BA?= =?UTF-8?q?=E5=BA=93=E7=9A=84=E5=8D=95=E4=B8=8D=E8=AE=A1=E9=94=80=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 业务口径:标了「已发货」但没有实际出库的 SKU,不需要出现缺货预警。 即在 ⑦-a 的金额/标签判据之上,再以出库表为准判定「有没有真出货」。 已发货分支计入销量需同时满足(待发货分支不变): 1. 非样品/测试店铺(⑦-a) 2. 非异常单:pay_amount <= 0.01 或 labels 含「特殊单」(⑦-a) 3. 出库表能查到该 o_id:rpt.jst_salesOutboundInfo / rpt.jst_qmcust_salesoutbound 两个必须豁免的情形(否则会大面积误杀真实单): · 拆分单(status='Split'):出库记录挂在子单上,父单必然查不到。 实测窗口内 40 张 Split 单 100% 无出库记录;不豁免会误杀 「京东-HX旗舰店104 09-19 实付 1194.14 元」这类真实单 · 今天发货的单:当天出库尚未同步完(09-19 全天出库表仅 1 行,09-18 有 14,690 行)。 豁免只到「昨天」为止——09-18 的 18 张无出库 Sent 单 100% 是特殊单, 且出库表 09-18 数据完整,豁免 09-18 会放过刷单 豁免只免「出库校验」,金额/标签判据照样生效。 真实数据验收(近 15 天窗口,1,086 张已发货 MKH 单): · 出库表命中 889 张 → 197 张无出库 = 40 张拆分单 + 157 张 Sent · 157 张里 131 张是 ≤0.01 元或「特殊单」(即 ⑦-a 已排除的刷单), 唯一 1 张正常金额的是 09-05 的 388 元单(标签「发货后取消/发货后售后/风控订单」) · 兜底生效 SKU = 12 个(与只按金额/标签判据一致),兜底日销最大值 = 0.2 · 10 个异常单 SKU 在预警列表中零出现;总预警 27 条(缺货 21 + 仅预警 6) 性能:1086 个订单号分 2 块并行 UNION 查询,冷缓存约 0.4~1.4s; 整窗口拉全量 DISTINCT o_id 实测要 8s,故不采用。 降级策略:出库数据取不到时整体跳过出库校验(退化成只按金额/标签判据)并标 degraded。 漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。 注意:jst_salesOutboundInfo.qty 全表 1,119,606 行恒为 0,只能判「有没有出库行」, 不能用 SUM(qty)(永远得 0)。 验证:check-syntax 151 文件通过;node --test 338/339(唯一失败是既有的 resilientSecretStore Windows 权限断言,与本改动无关);mkhBomService.test.js 45/45。 --- backend/src/services/mkhBomService.js | 213 +++++++++++++++++++++++--- backend/test/mkhBomService.test.js | 97 +++++++++++- 2 files changed, 284 insertions(+), 26 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index bb740f2..77b16e9 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -149,6 +149,33 @@ // ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」, // 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。 // +// ── ⑦-c 出库校验:**必须真出库才算销量**(2026-09-19 业务确认口径)──── +// 业务原话:「这些异常 SKU,系统标记了『已发货』,但没有实际出库的,不需要出现缺货预警」。 +// 即 ⑦-a 的金额/标签判据再往根上退一步——**以出库表为准**: +// 已发货分支要计入销量,必须同时满足 +// ① 不是异常单(`pay_amount ≤ 0.01` 或 `labels` 含「特殊单」,⑦-a) +// ② 在出库表里查得到该 `o_id`(`jst_salesOutboundInfo` / `jst_qmcust_salesoutbound`) +// 实测(2026-09-19,窗口 15 天、1,086 张已发货 MKH 单): +// · 出库表命中 889 张 → 197 张无出库,其中 **40 张是拆分单、157 张是 Sent** +// · 157 张里 **131 张是 ≤0.01 元或「特殊单」**(即 ⑦-a 已排除的那批刷单), +// 剩下唯一 1 张正常金额的是 09-05 的 388 元单(标签「发货后取消 / 发货后售后 / +// 风控订单」)—— ② 这条判据正是为它这类「标了发货其实没出库」的单兜底 +// · 与只按金额/标签判据相比,**当前窗口的兜底生效 SKU 集合完全一致(12 个)**, +// 属"同解但更根本",代价是每次冷缓存多约 0.5s 的出库查询 +// 🔥 **两个必须豁免的情形**(不豁免就会大面积误杀真实单,实测数据如下): +// ① **拆分单**(`status='Split'`):父单被拆成子单后出库挂在**子单**上, +// 父单必然查不到出库 —— 窗口内 40 张 Split 单 **100% 无出库记录**, +// 若不豁免会误杀「京东-HX旗舰店104 09-19 实付 1194.14 元」这类真实单 +// ② **今天发货的单**:当天出库数据尚未同步完(实测 09-19 全天出库表只有 1 行, +// 而 09-18 有 14,690 行)→ 09-19 的 7 张 Sent 无出库单里特殊单为 0, +// 说明无出库是**同步滞后**而非虚假发货。 +// 注意豁免只到「昨天」为止:09-18 的 18 张无出库 Sent 单 **100% 是特殊单**, +// 出库表 09-18 数据完整,所以 09-18 不能被豁免(否则会放过刷单) +// ⚠️ 只判**有没有出库行**:`jst_salesOutboundInfo.qty` 全表 1,119,606 行恒为 0 +// (`SUM(qty)` 永远得 0,近 15 天 180,433 行同样),别拿数量做判据。 +// ⚠️ 出库数据取不到时**整体跳过出库校验**(退化成只按金额/标签判据)并标 degraded: +// 漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。 +// // ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // (工厂已经在做了,不重复提示补货),改标为「已排产」。 @@ -372,6 +399,21 @@ const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']); const SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX = 0.01; const SALE_EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze(['特殊单']); +// ── 出库校验参数(见文件头 ⑦-c)────────────────────────────────── +// 出库表:判定「标了已发货的单到底有没有真出货」。两张表按渠道分开,**必须都查**—— +// 2026-09-19 实测近 15 天窗口内 1,086 张 MKH 已发货单:京东表命中 889、天猫表命中 198, +// jst_qmcust_orderlist 来源的 208 张单里 7 张只有京东表有记录、1 张只有天猫表有记录。 +const OUTBOUND_TABLES = Object.freeze([ + 'rpt.jst_salesOutboundInfo', // 京东 / 拼多多 / 抖音 等 + 'rpt.jst_qmcust_salesoutbound', // 天猫 / 淘宝 / 1688 等 +]); +// 单个查询里塞多少个 o_id:1000 个参数远低于 MSSQL 单语句 2100 上限, +// 实测 1086 个订单号分 2 块并行查询合计 ≈ 515ms(整窗口拉全量 DISTINCT 要 8s,别那么干) +const OUTBOUND_LOOKUP_CHUNK_SIZE = 1000; +// 「拆分单」状态:父单被拆成子单后,出库记录挂在**子单**上,父单必然查不到出库。 +// 2026-09-19 实测窗口内 40 张 Split 单 **100% 无出库记录** → 不能拿「无出库」判它造假。 +const SPLIT_ORDER_STATUSES = Object.freeze(['split']); + // ── 配件不足 / 可组装数参数(见文件头 ⑪)──────────────────────── // 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id const COMPONENT_STOCK_WMS_CO_ID = 15718196; @@ -1317,15 +1359,92 @@ const isExcludedSaleOrder = (row = {}) => { return paid !== null && paid <= SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX; }; +/** 是否为「拆分单」——父单的出库记录挂在子单上,父单查不到出库属正常(见 ⑦-c) */ +const isSplitOrder = (status) => { + const text = toText(status); + return Boolean(text) && SPLIT_ORDER_STATUSES.includes(text.toLowerCase()); +}; + +/** 发货日是否为**今天** —— 当天出库尚未同步完成,不能用出库判据(见 ⑦-c) */ +const isShippedToday = (sendDate) => { + const date = sendDate instanceof Date ? sendDate : new Date(sendDate); + if (Number.isNaN(date.getTime())) return false; + const today = new Date(); + return date.getFullYear() === today.getFullYear() + && date.getMonth() === today.getMonth() + && date.getDate() === today.getDate(); +}; + +/** + * 该已发货单能否用「出库记录」来判定真伪(见 ⑦-c)。 + * 不能判定的两种情形,退回只按 ⑦-a 的金额/标签判据,**不做无出库排除**,避免误杀: + * · 拆分单:出库挂在子单上,父单必然查不到 + * · 今天发货:当天出库数据尚未同步完(实测 09-19 全天出库表只有 1 行) + * 取不到订单号时同样豁免——宁可漏杀,不可错杀。 + */ +const isOutboundCheckSkipped = (row = {}) => ( + isSplitOrder(row.status) + || isShippedToday(row.sendDate) + || !toText(row.oId) +); + +/** + * 批量查「这些订单号在出库表里有没有实际出库记录」(见 ⑦-c)。 + * 只取 o_id 一列、分块并行查,失败时返回空集合 + 错误信息让调用方降级。 + * ⚠️ 只判**有没有行**:`qty` 列全表 1,119,606 行恒为 0,`SUM(qty)` 永远得 0,别用它。 + */ +async function loadOutboundOrderIds(orderIds) { + const ids = [...new Set((orderIds || []).map((id) => toText(id)).filter(Boolean))]; + if (!ids.length) return { ids: new Set(), error: null }; + + const chunks = []; + for (let index = 0; index < ids.length; index += OUTBOUND_LOOKUP_CHUNK_SIZE) { + chunks.push(ids.slice(index, index + OUTBOUND_LOOKUP_CHUNK_SIZE)); + } + + const settled = await Promise.allSettled(chunks.map((chunk) => { + const params = {}; + const placeholders = chunk + .map((value, index) => { params[`outboundOid${index}`] = value; return `@outboundOid${index}`; }) + .join(', '); + const union = OUTBOUND_TABLES + .map((table) => `SELECT o_id AS oId FROM ${table} WITH (NOLOCK) WHERE o_id IN (${placeholders})`) + .join('\n UNION\n '); + return cloudDatabase.query( + `SELECT DISTINCT outbound_rows.oId AS oId + FROM ( + ${union} + ) outbound_rows`, + params, + { requestTimeout: 180000 } + ); + })); + + const hits = new Set(); + const failed = []; + settled.forEach((result, index) => { + if (result.status !== 'fulfilled') { + failed.push(`出库表分块#${index + 1}: ${result.reason?.message || result.reason}`); + return; + } + result.value.forEach((row) => { + const text = toText(row.oId); + if (text) hits.add(text); + }); + }); + return { ids: hits, error: failed.length ? failed.join(' | ') : null }; +} + /** * 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a): * pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩) * shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店) * 口径: * · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩) - * · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a,已排除样品/测试店 + 0.01 元异常单) + * · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a),且 + * ①非样品/测试店铺、②非异常单(金额/标签)、③**有实际出库记录**(⑦-c) * · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计 - * · 异常单过滤只作用于**已发货**分支:0.01 元特殊单「标了发货但没真出库」,不算销售 + * · 三条过滤只作用于**已发货**分支:待发货(⑩)本来就是没出货的订单,不做出库校验 * ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。 */ async function loadOrderIndex() { @@ -1352,6 +1471,7 @@ async function loadOrderIndex() { o.shop_name AS shopName, o.pay_amount AS payAmount, o.labels AS labels, + o.status AS status, o.skus AS skus FROM ${table} o WITH (NOLOCK) WHERE o.skus LIKE @pendingOrderSkuLike @@ -1365,11 +1485,14 @@ async function loadOrderIndex() { )`, { pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams }, { requestTimeout: 180000 } - ))).then((results) => { + ))).then(async (results) => { const pendingBySku = new Map(); const shippedBySku = new Map(); const seenOrderIds = new Set(); const failed = []; + const shippedRows = []; + const pendingRows = []; + const outboundCheckIds = []; results.forEach((result, index) => { if (result.status !== 'fulfilled') { @@ -1390,27 +1513,61 @@ async function loadOrderIndex() { // 已发货(⑦-a):0.01 元特殊单「标了发货但没真出库」,同样不计入日销 if (shipped && isExcludedSaleOrder(row)) return; - const orderDate = row.orderDate instanceof Date ? row.orderDate : null; - parseOrderSkus(row.skus).forEach(({ code, qty }) => { - if (!isMkhProductCode(code)) return; - const key = normalizeCode(code); - if (!key) return; - if (shipped) { - shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty); - return; - } - const entry = pendingBySku.get(key) || { - pendingOrderQty: 0, - pendingOrderCount: 0, - pendingOrderEarliest: null, - }; - entry.pendingOrderQty += qty; - entry.pendingOrderCount += 1; - if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { - entry.pendingOrderEarliest = orderDate; - } - pendingBySku.set(key, entry); - }); + if (shipped) { + shippedRows.push(row); + // ⑦-c:可用于出库校验的单先记下订单号,第二遍统一过滤 + if (!isOutboundCheckSkipped(row)) outboundCheckIds.push(orderId); + return; + } + pendingRows.push(row); + }); + }); + + // ⑦-c:已发货单再过一道「有没有真出库」。 + // 出库数据取不到(或只取到一部分)时**整体不做出库排除**,退化成只按金额/标签判据—— + // 宁可漏杀不可错杀:漏杀只是多一条预警,错杀会让真实缺货彻底隐身。 + let outboundReady = false; + let outboundIds = new Set(); + if (outboundCheckIds.length) { + const outbound = await loadOutboundOrderIds(outboundCheckIds); + if (outbound.error) { + console.warn('[MKH-BOM] 出库数据源读取失败,本次跳过出库校验:', outbound.error); + failed.push(outbound.error); + } else { + outboundReady = true; + outboundIds = outbound.ids; + } + } + + shippedRows.forEach((row) => { + const orderId = row.oId === null || row.oId === undefined ? null : String(row.oId); + // ⑦-c:能判定、却没查到出库 → 系统标了「已发货」但货没真出,不计入销量 + if (outboundReady && !isOutboundCheckSkipped(row) && !outboundIds.has(orderId)) return; + parseOrderSkus(row.skus).forEach(({ code, qty }) => { + if (!isMkhProductCode(code)) return; + const key = normalizeCode(code); + if (!key) return; + shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty); + }); + }); + + pendingRows.forEach((row) => { + const orderDate = row.orderDate instanceof Date ? row.orderDate : null; + parseOrderSkus(row.skus).forEach(({ code, qty }) => { + if (!isMkhProductCode(code)) return; + const key = normalizeCode(code); + if (!key) return; + const entry = pendingBySku.get(key) || { + pendingOrderQty: 0, + pendingOrderCount: 0, + pendingOrderEarliest: null, + }; + entry.pendingOrderQty += qty; + entry.pendingOrderCount += 1; + if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { + entry.pendingOrderEarliest = orderDate; + } + pendingBySku.set(key, entry); }); }); @@ -2226,6 +2383,14 @@ module.exports = { SALE_EXCLUDED_SHOP_KEYWORDS, SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX, SALE_EXCLUDED_ORDER_LABEL_KEYWORDS, + // ⑦-c 出库校验 + isSplitOrder, + isShippedToday, + isOutboundCheckSkipped, + loadOutboundOrderIds, + OUTBOUND_TABLES, + OUTBOUND_LOOKUP_CHUNK_SIZE, + SPLIT_ORDER_STATUSES, PENDING_ORDER_EXCLUDED_STATUS, ORDER_TABLES, // ⑪ 配件不足 / 可组装数 diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 52ccce1..051922d 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -425,7 +425,14 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async const originalQuery = cloudDatabase.query; const queried = []; + // 出库表命中集合(⑦-c):9005 / 9007 / 9011 有真实出库记录; + // 9012(金额正常但无出库)、9009 / 9010 / 9015(异常单)刻意不在此列 + const outboundHits = ['9005', '9007', '9011']; cloudDatabase.query = async (sql) => { + if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { + queried.push('outbound'); + return outboundHits.map((oId) => ({ oId })); + } if (sql.includes('jst_qmcust_orderlist')) { queried.push('qmcust'); return [ @@ -503,6 +510,46 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async labels: '店铺-拼多多,模块化成品', skus: '1.MKHDNZ0410260700023', }, + // ⑦-c:金额正常、但出库表查不到 → 系统标了「已发货」货没真出 → 排除 + // (业务原话:标了已发货但没有实际出库的不需要出现缺货预警) + { + oId: 9012, + sendDate: new Date('2026-09-10T00:00:00Z'), + shopName: '拼多多-HX旗舰店105', + payAmount: 388, + labels: '发货后取消,发货后售后,风控订单', + skus: '1.MKHDNZ0409260601888', + }, + // ⑦-c 豁免①:拆分单的出库挂在子单上,父单查不到属正常 → 必须保留 + // (实测窗口内 40 张 Split 单 100% 无出库记录) + { + oId: 9013, + status: 'Split', + sendDate: new Date('2026-09-15T00:00:00Z'), + shopName: '京东-HX旗舰店104', + payAmount: 1194.14, + labels: '以旧换新,模块化成品', + skus: '1.MKHDNZ0409260601777', + }, + // ⑦-c 豁免②:今天发货的单,当天出库尚未同步完 → 必须保留 + { + oId: 9014, + sendDate: new Date(), + shopName: '京东-HX旗舰店104', + payAmount: 500, + labels: '模块化成品', + skus: '1.MKHDNZ0409260601666', + }, + // ⑦-c:拆分单/今天单虽然豁免**出库**校验,但金额/标签判据照样生效 + { + oId: 9015, + status: 'Split', + sendDate: new Date('2026-09-15T00:00:00Z'), + shopName: '拼多多-亿家达书房家具官方旗舰店', + payAmount: 0.01, + labels: '特殊单,模块化成品', + skus: '1.MKHDNZ0410260700777', + }, ]; }; @@ -510,7 +557,7 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async svc.clearMkhBomCache(); const index = await _private.loadOrderIndex(); - assert.deepEqual(queried.sort(), ['orders', 'qmcust'], '两张订单表都必须读到'); + assert.deepEqual(queried.sort(), ['orders', 'outbound', 'qmcust'], '两张订单表 + 出库表都必须读到'); assert.equal(index.degraded, false); const a = index.pendingBySku.get('MKHDNZ0409260700059'); @@ -532,7 +579,53 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async assert.equal(index.shippedBySku.has('MKHDNZ0410260700099'), false, '标签含「特殊单」的不得计入日销'); // 有免额的正常单必须照常计入 assert.equal(index.shippedBySku.get('MKHDNZ0410260700023'), 1, '有免额的正常单不得被误排除'); - assert.equal(index.shippedBySku.size, 2, '被排除的店铺/异常单不进入索引'); + // ⑦-c 出库校验:标了已发货但出库表查不到 → 不计入日销 + assert.equal(index.shippedBySku.has('MKHDNZ0409260601888'), false, '无实际出库记录的不得计入日销'); + // ⑦-c 豁免:拆分单 / 今天发货的单不能用出库判定,必须保留 + assert.equal(index.shippedBySku.get('MKHDNZ0409260601777'), 1, '拆分单无出库记录属正常,不得误杀'); + assert.equal(index.shippedBySku.get('MKHDNZ0409260601666'), 1, '今天发货的出库尚未同步,不得误杀'); + // ⑦-c 豁免只免「出库校验」,金额/标签判据照样生效 + assert.equal(index.shippedBySku.has('MKHDNZ0410260700777'), false, '拆分单的 0.01 元异常单仍要排除'); + assert.equal(index.shippedBySku.size, 4, '被排除的店铺/异常单/无出库单不进入索引'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('order index: outbound lookup failure skips the check instead of dropping sales', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + // 出库表整体不可用(例如视图未同步)——此时宁可漏杀不可错杀: + // 不做出库排除(否则真实销售会被整批砍掉),但要标 degraded 触发短 TTL 重试。 + cloudDatabase.query = async (sql) => { + if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { + throw new Error('出库视图不可用'); + } + if (sql.includes('jst_qmcust_orderlist')) return []; + return [ + { + oId: 9101, + sendDate: new Date('2026-09-10T00:00:00Z'), + shopName: '京东-HX旗舰店104', + payAmount: 388, + labels: '模块化成品', + skus: '1.MKHDNZ0409260601777', + }, + ]; + }; + + try { + svc.clearMkhBomCache(); + const index = await _private.loadOrderIndex(); + assert.equal(index.degraded, true, '出库源失败要标 degraded,让接口透出并尽快重试'); + assert.equal( + index.shippedBySku.get('MKHDNZ0409260601777'), + 1, + '出库数据取不到时不得据此砍掉销量(退化成只按金额/标签判据)' + ); } finally { cloudDatabase.query = originalQuery; svc.clearMkhBomCache(); From 5e029f831f27d0f6db1a71e986e536f0281a3d70 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Sat, 19 Sep 2026 18:11:38 +0800 Subject: [PATCH 31/70] =?UTF-8?q?fix:=20MKH-BOM=20=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=AE=B1=E8=A1=A8=E5=85=9C=E5=BA=95=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=8C=E5=BD=93=E6=97=A5=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E5=BD=93=E5=A4=A9=E6=98=BE=E7=A4=BA=E5=BA=93=E5=AD=98=E4=B8=BA?= =?UTF-8?q?=200=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:MKHDNZ0409260700491 当天已入库、实物有货,但页面上「现有库存」一直是 0; 点「同步最新数据」也不变。 根因:页面唯一的库存源 rpt.jst_inventoryInfo 是「有变化才写 updateTime」的同步表, 品牌仓尤其稀疏。该 SKU 在 inventoryInfo 里只有主仓 14138566 的一行 qty=0, 品牌仓连行都没有;而小时级的箱表 rpt.jst_packInventoryInfo 里, 品牌仓 bin='P-1' / qty=4 / createTime 当天 15:19 —— 当日入库的货只有箱表看得见。 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。 「同步最新数据」按钮只清服务端缓存,不触发上游同步,所以点多少次都是 0。 改法(只补 0,不覆盖有效值): ① inventoryInfo 品牌仓 qty > 0 → 用它(stockSource='realtime',旧口径不变) ② 否则箱表品牌仓 bin 非空行合计 > 0 → 用它(stockSource='pack') ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0 ④ 否则回退监控快照 jsStock → stockSource='monitor' 为什么不拿箱表当主源:两表都 > 0 且不等的 28 个成品里 25 个是 inventoryInfo 更大 (箱表只记已装箱的货,散货不计),拿它当主源会系统性低估库存、误报缺货。 另外必须过滤 bin 为空的行——那是装箱记录不是库存(品牌仓合计 21 件)。 影响面(实测):品牌仓 4,408 个成品里只有 3 个走箱表兜底 (MKHDNZ0409260601548=5 / MKHDNZ0409260700059=4 / MKHDNZ0409260700491=4, 同一箱 115870495000001201、货位 P-1、均为当天新建行), 预警列表 27 → 24 条(缺货 21 → 18),少的正是这 3 条「入库当天被误判缺货」。 前端:「现有库存」列 tooltip 增加 pack 来源说明(当天入库、实时库存表尚未同步)。 验证:check-syntax 151 文件通过;node --test 339/340(唯一失败是既有的 resilientSecretStore Windows 权限断言);mkhBomService.test.js 47/47; HTTP 实测该 SKU stock=4 / stockSource=pack,点「同步最新数据」后仍正确。 遗留(需业务/上游跟进):inventoryInfo 当天 06:41 后就没再更新过 (09-17 / 09-18 都是全天逐小时更新),疑似上游同步任务异常; 箱表兜底只解决「当日入库看不见」,存量库存仍依赖该表恢复。 --- backend/src/services/mkhBomService.js | 81 ++++++++++++++++++++++- backend/test/mkhBomService.test.js | 85 +++++++++++++++++++++++++ frontend/src/pages/MkhBomData/index.jsx | 15 ++++- 3 files changed, 175 insertions(+), 6 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 77b16e9..26c9528 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -176,6 +176,42 @@ // ⚠️ 出库数据取不到时**整体跳过出库校验**(退化成只按金额/标签判据)并标 degraded: // 漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。 // +// ── ⑦-d 库存源时效兜底:当日入库读箱表(2026-09-19 新增)────────── +// 业务原话:「MKHDNZ0409260700491 这个今日已经入库,现有库存是有…… +// 点击同步最新数据这个按钮,数据没有更新,还是显示库存为 0」。 +// 🔥 根因:**页面唯一的库存源 `jst_inventoryInfo` 是「有变化才写 updateTime」的同步表, +// 当天新入库的成品未必有对应行**(品牌仓尤其稀疏,一天只更新几十行)。 +// 2026-09-19 实测: +// · 该 SKU 在 inventoryInfo 全表**只有主仓 14138566 的一行 qty=0**, +// 品牌仓**连行都没有**;而箱表品牌仓 bin='P-1' / qty=4 / createTime 当日 15:19 +// · 横向对照:inv 当天最后更新 06:41(品牌仓最后 04:42,当天仅 3 行), +// 箱表当天最后更新 17:01(小时级)→ **当日入库的货只有箱表看得见** +// · ⚠️ 且 inv 当天 06:41 后**再没更新过**(09-17 / 09-18 都是全天逐小时更新) +// → 大概率上游同步任务当天异常,需业务侧跟进(见文末遗留) +// → 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。 +// ⚠️ 「同步最新数据」按钮只清服务端缓存(clearMkhBomCache),**不会触发上游同步**, +// 所以点上多少次都还是 0 —— 数据源当天就没有这条库存。 +// 兜底规则(**只补 0,不覆盖有效值**): +// ① inventoryInfo 品牌仓 qty > 0 → 用它(stockSource='realtime',与旧口径完全一致) +// ② 否则箱表品牌仓 `bin` 非空行 qty 合计 > 0 → 用它(stockSource='pack') +// ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0(stockSource='realtime') +// ④ 否则回退监控快照 jsStock → (stockSource='monitor') +// ⚠️ 为什么 inv 优先、pack 只补 0:两表都 > 0 且不等的 28 个成品里 **25 个是 inv 更大** +// (箱表只记装箱的货,散货不计)→ 若拿 pack 当主源会系统性低估库存、误报缺货。 +// ⚠️ 必须过滤 `bin` 为空的行:那是**装箱记录不是库存**(品牌仓 bin 空行合计 21 件), +// 不过滤会虚增。 +// 影响面(2026-09-19 实测):品牌仓 4,408 个成品里,走 ② 兜底的只有 **3 个** +// (MKHDNZ0409260601548=5 / MKHDNZ0409260700059=4 / MKHDNZ0409260700491=4, +// 同一箱 115870495000001201、货位 P-1,均为**当日新建**行); +// 预警列表随之 27 → 24 条(缺货 21 → 18),这 3 条都是「入库当天被误判缺货」。 +// 性能:箱表全量取数 **87ms**(brand 仓 + bin 非空 + GROUP BY),无额外负担。 +// +// ⚠️ 遗留(需业务 / 上游跟进):箱表兜底只解决「**当日入库看不见**」这一种情形。 +// inventoryInfo 若长期停更,**存量库存的时效性仍依赖该表恢复**——届时页面库存 +// 会停留在停更时点(本次即 09-19 06:41)的旧值,本项目无从感知。 +// 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的 +// MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。 +// // ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── // 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** // (工厂已经在做了,不重复提示补货),改标为「已排产」。 @@ -1005,10 +1041,30 @@ function buildReplenishMetrics(row = {}) { const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0; const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale; + // 现有库存来源优先级(见文件头 ⑦-d): + // inventoryInfo 品牌仓有货 → 用它(旧口径不变) + // 否则箱表(小时级)有货 → 用它(补「当日入库、日更库存表还没同步」的盲区) + // 否则 inventoryInfo 有行(qty≤0) → 用 0(该仓确实没货,别再退回更旧的快照) + // 否则回退监控快照 const realtimeStock = toNumber(row.realtimeStock); + const packStock = toNumber(row.packStock); const monitorStock = toNumber(row.monitorStock) ?? 0; const hasRealtime = realtimeStock !== null; - const stock = hasRealtime ? realtimeStock : monitorStock; + let stock; + let stockSource; + if (hasRealtime && realtimeStock > 0) { + stock = realtimeStock; + stockSource = 'realtime'; + } else if (packStock !== null && packStock > 0) { + stock = packStock; + stockSource = 'pack'; + } else if (hasRealtime) { + stock = realtimeStock; + stockSource = 'realtime'; + } else { + stock = monitorStock; + stockSource = 'monitor'; + } // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0); @@ -1044,7 +1100,7 @@ function buildReplenishMetrics(row = {}) { pendingOrderEarliest: toDateText(row.pendingOrderEarliest), orderDriven, thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)), - stockSource: hasRealtime ? 'realtime' : 'monitor', + stockSource, safetyDays: toNumber(row.safetyDays), suggestStock: toNumber(row.suggestStock) ?? 0, monitorDate: toDateText(row.monitorDate), @@ -1628,11 +1684,22 @@ async function loadReplenishIndex() { REPLENISH_PARAMS, { requestTimeout: 180000 } ), + // 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到, + // 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。 + cloudDatabase.query( + `SELECT p.sku_id AS skuId, SUM(p.qty) AS qty + FROM dbo.jst_packInventoryInfo p + WHERE p.wms_co_id = @brandWmsCoId AND p.sku_id LIKE N'MKH%' + AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL + GROUP BY p.sku_id`, + REPLENISH_PARAMS, + { requestTimeout: 180000 } + ), // 在途加工单(见文件头 ⑧) loadPendingWorkOrderIndex(), // 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询 loadOrderIndex(), - ]).then(([monitorRows, stockRows, workOrderIndex, orderIndex]) => { + ]).then(([monitorRows, stockRows, packStockRows, workOrderIndex, orderIndex]) => { const workOrderBySku = workOrderIndex.bySku; const pendingOrderBySku = orderIndex.pendingBySku; const shippedBySku = orderIndex.shippedBySku; @@ -1641,6 +1708,11 @@ async function loadReplenishIndex() { const key = normalizeCode(row.skuId); if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty)); }); + const packStock = new Map(); + packStockRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (key) packStock.set(key, toNumber(row.qty)); + }); const seenMonitor = new Set(); const bySku = new Map(); @@ -1657,6 +1729,8 @@ async function loadReplenishIndex() { recentShippedQty: shippedBySku.get(key) || 0, // 实时库存缺失(而非为 0)时才回退监控快照 realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, + // 箱/货位库存(⑦-d):inventoryInfo 日更,当日入库的货只有这张小时级表有 + packStock: packStock.has(key) ? packStock.get(key) : null, monitorStock: row.monitorStock, safetyDays: row.safetyDays, suggestStock: row.suggestStock, @@ -2376,6 +2450,7 @@ module.exports = { buildReplenishMetrics, loadPendingWorkOrderIndex, loadOrderIndex, + loadReplenishIndex, parseOrderSkus, isMkhProductCode, isExcludedSaleShop, diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 051922d..d7f0ad2 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -234,6 +234,33 @@ test('replenish: stock prefers the brand-warehouse realtime value, snapshot only assert.equal(zero.warningLevel, '缺货'); }); +test('replenish: box/pallet stock fills in when the daily inventory table is stale (⑦-d)', () => { + // 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货 + const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 }); + assert.equal(fillFromZero.stock, 4); + assert.equal(fillFromZero.stockSource, 'pack'); + assert.notEqual(fillFromZero.warningLevel, '缺货'); + + const fillFromMissing = metrics({ avgSale: 1, realtimeStock: null, packStock: 5, monitorStock: 0 }); + assert.equal(fillFromMissing.stock, 5); + assert.equal(fillFromMissing.stockSource, 'pack'); + + // 库存表有货时箱表不参与 —— 箱表只记装箱的货,拿它当主源会低估库存 + const realtimeWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 100, monitorStock: 0 }); + assert.equal(realtimeWins.stock, 4); + assert.equal(realtimeWins.stockSource, 'realtime'); + + // 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照 + const bothZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 0, monitorStock: 9 }); + assert.equal(bothZero.stock, 0); + assert.equal(bothZero.stockSource, 'realtime'); + + // 库存表整个品牌仓无记录、箱表也没货 → 才回退监控快照 + const snapshot = metrics({ avgSale: 1, realtimeStock: null, packStock: 0, monitorStock: 9 }); + assert.equal(snapshot.stock, 9); + assert.equal(snapshot.stockSource, 'monitor'); +}); + test('replenish: products without 7-day sales never warn', () => { const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 }); assert.equal(noSale.hasWarning, false); @@ -419,6 +446,64 @@ test('pending order: only MKH finished products count, combination codes are exc assert.equal(_private.isExcludedSaleOrder({ payAmount: '未知' }), false, '金额非数字 → 不排除'); }); +test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + const originalGetPool = cloudDatabase.getPool; + const queried = []; + + // 复现 MKHDNZ0409260700491:当日 15:19 入库,日更的 inventoryInfo 还没有这条, + // 只有小时级的箱表有;监控快照 jsStock = 0。 + cloudDatabase.query = async (sql) => { + if (sql.includes('jst_packInventoryInfo')) { + queried.push('pack'); + return [{ skuId: 'MKHDNZ0409260700491', qty: 4 }]; + } + if (sql.includes('jst_inventoryInfo')) { + queried.push('inventory'); + return []; + } + if (sql.includes('pm_stockMonitor')) { + queried.push('monitor'); + return [{ + skuId: 'MKHDNZ0409260700491', + fifteenAvgSale: 0, + monitorStock: 0, + safetyDays: 3, + suggestStock: 0, + monitorDate: new Date('2026-09-18T00:00:00Z'), + }]; + } + if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { + queried.push('outbound'); + return []; + } + if (sql.includes('jst_qmcust_orderlist')) { + queried.push('qmcust'); + return []; + } + queried.push('orders'); + return []; + }; + cloudDatabase.getPool = async () => ({ + request: () => ({ input() {}, query: async () => ({ recordset: [] }) }), + }); + + try { + const index = await svc._private.loadReplenishIndex(); + assert.ok(queried.includes('pack'), '箱表必须被读到'); + assert.ok(queried.includes('inventory'), '实时库存表仍要读'); + const row = index.bySku.get('MKHDNZ0409260700491'); + assert.equal(row.stock, 4, '当日入库的货必须显示出来,不能显示 0'); + assert.equal(row.stockSource, 'pack'); + } finally { + cloudDatabase.query = originalQuery; + cloudDatabase.getPool = originalGetPool; + svc.clearMkhBomCache(); + } +}); + test('order index: reads both channel tables, merges and dedupes by o_id', async () => { const cloudDatabase = require('../src/config/cloudDatabase'); const svc = require('../src/services/mkhBomService'); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index b74ac62..231dc8e 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -147,6 +147,17 @@ const ALERT_RECIPIENT_SOURCE_TEXT = { none: '未找到可用的收件人', }; +// 「现有库存」列的取值来源说明(后端 stockSource 字段): +// realtime = 品牌仓实时库存表(jst_inventoryInfo,日更) +// pack = 品牌仓箱/货位表(jst_packInventoryInfo,小时级)—— +// 实时库存表当天还没同步到这条货(当日入库)时用它兜底 +// monitor = 两个实时源都没有记录,退回监控快照库存 +const STOCK_SOURCE_TEXT = { + realtime: '品牌仓实时库存', + pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)', + monitor: '品牌仓实时库存缺失,取监控快照库存', +}; + // A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 // 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」), // 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。 @@ -564,9 +575,7 @@ const MkhBomData = () => { align: 'right', render: (value, row) => ( {number(value)} From add148192dbf9d7cb7a759e645ca3c4f3a702f45 Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Tue, 22 Sep 2026 18:39:09 +0800 Subject: [PATCH 32/70] =?UTF-8?q?fix:=20MKH-BOM=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=9C=A8=E9=80=94=E6=97=A7=E6=9F=A5=E8=AF=A2=E5=9B=9E=E5=86=99?= =?UTF-8?q?=E7=BC=93=E5=AD=98=EF=BC=8C=E5=AF=BC=E8=87=B4=E3=80=8C=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=9C=80=E6=96=B0=E6=95=B0=E6=8D=AE=E3=80=8D=E7=82=B9?= =?UTF-8?q?=E5=AE=8C=E4=BB=8D=E6=98=AF=E6=97=A7=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:点「同步最新数据」后,页面在 2~10 分钟内仍显示旧数据,用户表现为「像在用缓存」。 根因:本页 10 个进程内缓存都是「先挂 pending → await 结果 → 无条件写回」的写法。 若某个查询 in-flight 期间用户点了同步(clearMkhBomCache 清空全部缓存),那个**发起于 清缓存之前**的查询读到的仍是旧快照,返回时会无条件把旧值写回缓存,覆盖掉清缓存后新 请求填入的新值 —— 谁后完成谁生效。并发越高(多人同时使用、或先点刷新再点同步)越容 易撞上,且清缓存反而把旧数据「钉」在缓存里 2~10 分钟(各缓存 TTL),表现为按钮失灵。 修法:引入缓存代际 cacheEpoch。 - clearMkhBomCache() 先自增代际,再清各缓存; - 每个查询发起时记下当时的代际,写回缓存前比对:代际对不上说明中途被清过缓存, 本次结果只返回给发起它的那个请求,不写缓存。 覆盖 7 个变量型缓存(officialBomIndex / businessBom / productBom / mkhProduct / workOrderIndex / orderIndex / replenishIndex)与 3 个按 key 的 Map 缓存 (officialBomDetail / componentLocation / componentStock)。 POST /api/mkh-bom/sync 响应新增 cacheEpoch 字段,便于线上核对清缓存是否生效。 验证: - 新增 2 个用例:① 复现竞态——对照组(去掉写回保护)报 expected 1 / actual 9,正是 「点同步后拿到旧值」;加保护后通过。② 同代际内缓存照常命中的回归保护。 - 后端全量 node --test test/*.test.js:340 用例 / 339 通过,唯一失败为环境固有的 resilientSecretStore Windows 权限断言(与基线一致)。 - 本地端到端:预热 24.3s → 缓存 58ms;sync 返回 cacheEpoch=1;冷查 8.5s 确实重查; 再查 37ms 缓存重新生效;数据指纹前后一致。 --- backend/src/services/mkhBomService.js | 97 +++++++++++++++++++++------ backend/test/mkhBomService.test.js | 97 +++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 20 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 94c7428..f9f6a90 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -280,6 +280,21 @@ const officialBomDetailCache = new Map(); const componentLocationCache = new Map(); let productBomCache = null; +/** + * 缓存代际(epoch):`clearMkhBomCache()` 每次自增。 + * + * 为什么需要它 ——「同步最新数据」按钮点完反而看到旧数据: + * 所有缓存都是「先挂 pending,await 到结果后无条件写回」的写法。若在某个查询 + * in-flight 期间用户点了同步(清空全部缓存),那个**发起于清缓存之前**的查询 + * 读到的仍是旧快照,它返回时会无条件把旧值写回缓存,把清缓存后新请求填入的 + * 新值覆盖掉 —— 之后 2~10 分钟(各缓存 TTL)内页面持续显示旧数据。 + * 并发越高(多人同时用、或先点刷新再点同步)越容易撞上。 + * + * 修法:查询发起时记下当时的 epoch,写回前比对;epoch 变了说明中途被清过缓存, + * 这次结果已经过期,**只返回给发起它的那个请求,不写缓存**。 + */ +let cacheEpoch = 0; + // MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。 // 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。 const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓'; @@ -637,13 +652,18 @@ async function loadOfficialBomIndex() { return { bySku, bySpuPrefix: [], entries: [] }; }); + const epoch = cacheEpoch; officialBomIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - officialBomIndexCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + // epoch 变了 ⇒ 期间用户点过「同步最新数据」,本次结果来自清缓存前的旧快照, + // 只还给发起它的请求,不写回缓存(否则会把新值覆盖成旧值) + if (epoch === cacheEpoch) { + officialBomIndexCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + } return value; } catch (error) { - officialBomIndexCache = null; + if (epoch === cacheEpoch) officialBomIndexCache = null; throw error; } } @@ -660,6 +680,8 @@ async function loadOfficialBomForSkus(skuIds = []) { .filter(([key, value]) => key && value) ).entries()).slice(0, MAX_PAGE_SIZE); const now = Date.now(); + // 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存 + const epoch = cacheEpoch; const missing = requested.filter(([key]) => { const cached = officialBomDetailCache.get(key); return !cached || cached.expiresAt <= now; @@ -690,11 +712,14 @@ async function loadOfficialBomForSkus(skuIds = []) { ); const grouped = groupOfficialBomRows(rows); const expiresAt = Date.now() + OFFICIAL_BOM_DETAIL_CACHE_TTL_MS; + const writable = epoch === cacheEpoch; missing.forEach(([key]) => { - officialBomDetailCache.set(key, { - components: grouped.bySku.get(key) || [], - expiresAt, - }); + if (writable) { + officialBomDetailCache.set(key, { + components: grouped.bySku.get(key) || [], + expiresAt, + }); + } }); } @@ -751,13 +776,16 @@ async function loadBusinessBom() { return bom; }); + const epoch = cacheEpoch; businessBomCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - businessBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + if (epoch === cacheEpoch) { + businessBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + } return value; } catch (error) { - businessBomCache = null; + if (epoch === cacheEpoch) businessBomCache = null; throw error; } } @@ -834,13 +862,16 @@ async function loadPgBom() { return { bySku, bySpuPrefix, entries }; })(); + const epoch = cacheEpoch; productBomCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - productBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + if (epoch === cacheEpoch) { + productBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + } return value; } catch (error) { - productBomCache = null; + if (epoch === cacheEpoch) productBomCache = null; throw error; } } @@ -1158,13 +1189,16 @@ async function loadMkhProductIndex() { material: toText(row.material), })).filter((row) => row.skuId)); + const epoch = cacheEpoch; mkhProductCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + if (epoch === cacheEpoch) { + mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + } return value; } catch (error) { - mkhProductCache = null; + if (epoch === cacheEpoch) mkhProductCache = null; throw error; } } @@ -1225,13 +1259,16 @@ async function loadPendingWorkOrderIndex() { return { bySku: new Map(), degraded: true, error: error.message, ttl: WORK_ORDER_DEGRADED_TTL_MS }; }); + const epoch = cacheEpoch; workOrderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + if (epoch === cacheEpoch) { + workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + } return value; } catch (error) { - workOrderIndexCache = null; + if (epoch === cacheEpoch) workOrderIndexCache = null; throw error; } } @@ -1382,13 +1419,16 @@ async function loadOrderIndex() { return { pendingBySku, shippedBySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; }); + const epoch = cacheEpoch; orderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + if (epoch === cacheEpoch) { + orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + } return value; } catch (error) { - orderIndexCache = null; + if (epoch === cacheEpoch) orderIndexCache = null; throw error; } } @@ -1473,13 +1513,16 @@ async function loadReplenishIndex() { }; }); + const epoch = cacheEpoch; replenishIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + if (epoch === cacheEpoch) { + replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + } return value; } catch (error) { - replenishIndexCache = null; + if (epoch === cacheEpoch) replenishIndexCache = null; throw error; } } @@ -1494,6 +1537,8 @@ async function loadReplenishIndex() { */ async function loadComponentStock(codes = []) { const now = Date.now(); + // 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存 + const epoch = cacheEpoch; const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); const stockMap = new Map(); const missing = []; @@ -1528,9 +1573,11 @@ async function loadComponentStock(codes = []) { if (code) found.set(code, toNumber(row.qty) ?? 0); }); const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS; + const writable = epoch === cacheEpoch; chunk.forEach((code) => { const qty = found.get(code) ?? 0; - componentStockCache.set(code, { qty, expiresAt }); + // 本批结果仍要返回给调用方(stockMap),只是不再写缓存 + if (writable) componentStockCache.set(code, { qty, expiresAt }); stockMap.set(code, qty); }); } catch (error) { @@ -1741,6 +1788,8 @@ function buildPackLocationMap(rows, componentCodes) { */ async function loadComponentLocations(codes = []) { const now = Date.now(); + // 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存 + const epoch = cacheEpoch; const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); const locationMap = new Map(); const missing = []; @@ -1787,9 +1836,11 @@ async function loadComponentLocations(codes = []) { ); const byCode = buildPackLocationMap(rows, chunk); const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS; + const writable = epoch === cacheEpoch; chunk.forEach((code) => { const location = byCode.get(code) || null; - componentLocationCache.set(code, { location, expiresAt }); + // 本批结果仍要返回给调用方(locationMap),只是不再写缓存 + if (writable) componentLocationCache.set(code, { location, expiresAt }); if (location) locationMap.set(code, location); }); } catch (error) { @@ -2127,6 +2178,11 @@ async function listMkhBomExportRows(query = {}) { // 而不是 2~15 分钟前的缓存结果。 // 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。 function clearMkhBomCache() { + // 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询, + // 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值, + // 导致按钮点完反而 2~10 分钟一直显示旧数据)。 + cacheEpoch += 1; + businessBomCache = null; officialBomIndexCache = null; productBomCache = null; @@ -2146,6 +2202,7 @@ function clearMkhBomCache() { return { syncedAt: new Date().toISOString(), + cacheEpoch, cleared: cleared.map(([name, entries]) => ({ name, entries })), }; } diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 3034c53..ad3ead5 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -939,3 +939,100 @@ test('export: 配件不足 rows are flagged and carry no replenish target', () = // 可组装数一并导出,便于业务核对短板 assert.equal(rows[0].assemblableQty, 0); }); + +// ───────────────────────────────────────────────────────────────────────────── +// 缓存代际(cacheEpoch):修复「点同步最新数据,反而显示旧数据」 +// +// 症状:用户点「同步最新数据」后,页面 2~10 分钟内持续显示旧值,像在用缓存。 +// 成因:缓存是「先挂 pending → await 结果 → 无条件写回」。若某个查询在途时用户点了 +// 同步(清空缓存),那个发起于清缓存之前的查询读到的是旧快照,返回时会把旧值 +// 写回缓存,覆盖掉清缓存后新请求填入的新值 —— 谁后完成谁生效。 +// +// 下面第一个用例复现旧行为(修复前失败),第二个用例锁定正确语义。 +// ───────────────────────────────────────────────────────────────────────────── + +test('cache epoch: 清缓存后在途的旧查询结果不得写回缓存(否则同步按钮点完仍是旧数据)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + const CODE = 'DNZTESTCACHE01'; + const staleQty = 9; // 清缓存前那一刻库里/旧快照的值 + const freshQty = 1; // 清缓存后重新查到的值 + + let phase = 'old'; + let stockQueries = 0; + let releaseStale; + const staleGate = new Promise((resolve) => { releaseStale = resolve; }); + let markStaleStarted; + const staleStarted = new Promise((resolve) => { markStaleStarted = resolve; }); + + cloudDatabase.query = async (sql) => { + if (!sql.includes('jst_inventoryInfo')) return []; + stockQueries += 1; + if (phase === 'old') { + markStaleStarted(); + await staleGate; // 卡住旧查询:模拟「发起于此、期间用户点了同步」的慢查询 + return [{ code: CODE, qty: staleQty }]; + } + return [{ code: CODE, qty: freshQty }]; + }; + + try { + // 1) 旧查询发起并卡住 + const inFlight = svc._private.loadComponentStock([CODE]); + await staleStarted; + + // 2) 用户点「同步最新数据」→ 清缓存并推进代际 + const cleared = svc.clearMkhBomCache(); + assert.ok(cleared.cacheEpoch >= 1, '清缓存必须推进代际号'); + phase = 'new'; + + // 3) 清缓存后的新查询先返回,拿到新值并写缓存 + const fresh = await svc._private.loadComponentStock([CODE]); + assert.equal(fresh.stockMap.get(CODE), freshQty); + assert.equal(stockQueries, 2, '清缓存后必须真正重查数据源'); + + // 4) 旧查询这才返回(它读到的仍是旧快照) + releaseStale(); + const staleResult = await inFlight; + assert.equal(staleResult.stockMap.get(CODE), staleQty, + '过期结果仍应原样返回给发起它的那个请求,不能改变它自己的语义'); + + // 5) 🔥 关键断言:缓存里必须还是新值 + const after = await svc._private.loadComponentStock([CODE]); + assert.equal(after.stockMap.get(CODE), freshQty, + '清缓存后返回的过期结果写回了缓存 → 用户会看到「点了同步还是旧数据」'); + assert.equal(stockQueries, 2, + '第 5 步应直接命中缓存,不应再打数据库(说明缓存确实被写入了新值)'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + const CODE = 'DNZTESTCACHE02'; + let stockQueries = 0; + cloudDatabase.query = async (sql) => { + if (!sql.includes('jst_inventoryInfo')) return []; + stockQueries += 1; + return [{ code: CODE, qty: 3 }]; + }; + + try { + svc.clearMkhBomCache(); + const first = await svc._private.loadComponentStock([CODE]); + const second = await svc._private.loadComponentStock([CODE]); + assert.equal(first.stockMap.get(CODE), 3); + assert.equal(second.stockMap.get(CODE), 3); + assert.equal(stockQueries, 1, '同一次缓存代际内,第二次必须命中缓存不再查库'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); From 38fa3f21272f7d6977dae94235bc6978c42dd3bf Mon Sep 17 00:00:00 2001 From: yanhui <2353496031@qq.com> Date: Tue, 22 Sep 2026 19:32:40 +0800 Subject: [PATCH 33/70] =?UTF-8?q?fix:=20=E9=9A=A7=E9=81=93=E6=96=AD?= =?UTF-8?q?=E7=BA=BF=E8=87=AA=E6=84=88=20+=20=E4=BF=AE=E5=A4=8D=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E8=B4=9F=E7=BC=93=E5=AD=98=E6=B0=B8=E4=B9=85=E5=9B=BA?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 远端 RST 会把隧道反复踢掉(09-20 实测当天断 5 次,每次存活 15~25 分钟)。原先的 实现把「一次网络抖动」放大成「整个开发栈不可用」,根因有两处,本次一并修掉。 1) scripts/start-remote.mjs —— 隧道断了却拉着应用陪葬 隧道进程 exit 时直接 stopChildren(),把后端和前端一起杀掉,于是每一次 RST 都 升级成一次「网站打不开」,还得靠外部守护清残留、整树重启才能恢复。 改为「退避重连隧道本身,后端/前端进程不受影响」——这正是 autossh 的语义 (autossh 只负责重启 ssh,从不牵连被它服务的应用)。建立失败走 1s→30s 指数 退避,建立成功即重置退避。 顺带补齐保活参数:TCPKeepAlive=yes、ConnectTimeout=10,探活间隔 30s → 15s, 以便更快发现已被对端 RST 的死连接。 2) backend/src/config/cloudDatabase.js —— 配置负缓存被永久固化 系统库读取失败时把「空候选」写进缓存,而入口判断是 `if (cachedCloudConfigCandidates)` —— [] 在 JS 里是 truthy,于是这次失败被 永久固化:之后永不重试,直接抛「业务数据库尚未配置」500,隧道恢复了也没用, 必须重启后端。 改为「带 TTL 的负缓存」(默认 30s,可用 CLOUD_DB_CONFIG_NEGATIVE_TTL_MS 调整): 入口用 `!== null` 判断,失败态到期自动重新解析,使隧道恢复后无需人工干预。 缓存清理收敛到 invalidateCloudConfigCache() 单点,避免日后只清数组、忘了清 TTL 时间戳而退化回「永不过期」。同时让「测试连接」入口显式失效,保证用户改完 配置点测试能立即看到真实结果。 顺带把请求超时默认值 600000ms → 60000ms:10 分钟 × maxAttempts=2 最坏挂 20 分钟,用户体感就是「卡死」;冷查实测 16~42s,60s 仍有充足余量。 验证: - 新增 backend/test/cloudDatabaseConfigCache.test.js,锁定负缓存三段语义 (TTL 内不重复解析 / 到期必须重试 / 上游恢复后自动复原且成功态不被 TTL 淘汰); test/remote-access.test.mjs 补隧道保活参数、退避曲线,以及「自愈循环不得调用 stopChildren」的防腐化断言。 - 两处均做对照组实验:去掉保护后测试如实报错(负缓存 → 报 TTL 到期未重试; 隧道 → 把 stopChildren 加回去即被用例抓住),证明用例真能拦住回归。 - 后端全量 340 项 / 339 通过,唯一失败为 resilientSecretStore 的 Windows 权限 断言(环境固有,与本次无关);check-syntax 通过。 - 本地实测:kill 掉隧道进程后 1 秒内自动重建(PID 36268 → 15816),后端进程 PID 未变(29580)、/health 仍返回 200。 --- backend/src/config/cloudDatabase.js | 61 +++++++-- backend/test/cloudDatabaseConfigCache.test.js | 126 ++++++++++++++++++ scripts/start-remote.mjs | 115 +++++++++++++--- test/remote-access.test.mjs | 61 ++++++++- 4 files changed, 336 insertions(+), 27 deletions(-) create mode 100644 backend/test/cloudDatabaseConfigCache.test.js diff --git a/backend/src/config/cloudDatabase.js b/backend/src/config/cloudDatabase.js index 405346b..37c7d2f 100644 --- a/backend/src/config/cloudDatabase.js +++ b/backend/src/config/cloudDatabase.js @@ -19,6 +19,8 @@ const DEFAULT_REPORT_REPLICA_APP_SCHEMA = 'app'; let pool = null; let activeCloudConfig = null; let cachedCloudConfigCandidates = null; +// 负缓存(失败态)的过期时间戳。0 表示没有失败态缓存。 +let cachedCloudConfigExpiresAt = 0; function isPoolUsable(activePool) { return Boolean(activePool && activePool.connected && activePool.healthy !== false); @@ -30,7 +32,11 @@ const parsePositiveInt = (value, fallback) => { }; const DEFAULT_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_TIMEOUT_MS, 30000); -const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 600000); +// 原先默认 600000ms(10 分钟),配合 maxAttempts=2 最坏可挂 20 分钟,用户体感就是「卡死」。 +// 收敛到 60s:本页最重的冷查实测 16~42s,仍有充足余量。 +const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 60000); +// 解析「系统库配置」失败时的负缓存存活时间。到期后自动重试,使隧道恢复后无需重启后端。 +const NEGATIVE_CONFIG_CACHE_TTL_MS = parsePositiveInt(process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS, 30000); const CLOUD_DB_POOL_MAX = parsePositiveInt(process.env.CLOUD_DB_POOL_MAX, 10); const CLOUD_DB_POOL_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_POOL_IDLE_TIMEOUT_MS, 30000); const LOCAL_CONFIG_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000); @@ -673,7 +679,15 @@ async function loadCandidateConfig(localPool, profile) { } async function resolveCloudConfigCandidates() { - if (cachedCloudConfigCandidates) { + // 缓存有效判据:**非 null** 且在有效期内。 + // + // 旧实现是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy —— 一旦系统库 + // 读取失败一次(隧道抖动),就会把空数组当作合法缓存永久返回,之后永不重试, + // 直接抛「业务数据库尚未配置」500,直到重启后端。这是典型的「永久负缓存」反模式。 + // + // 现在:成功态 expiresAt = Infinity(靠 resetPool 显式失效);失败态带 30s TTL, + // 到期自动重新解析,隧道恢复后无需人工干预即可自愈。 + if (cachedCloudConfigCandidates !== null && Date.now() < cachedCloudConfigExpiresAt) { return cachedCloudConfigCandidates; } @@ -705,6 +719,7 @@ async function resolveCloudConfigCandidates() { } cachedCloudConfigCandidates = candidates; + cachedCloudConfigExpiresAt = Number.POSITIVE_INFINITY; return cachedCloudConfigCandidates; } catch (error) { console.warn('[CloudDB] 无法从数据库读取配置:', error.message); @@ -716,15 +731,23 @@ async function resolveCloudConfigCandidates() { } } - cachedCloudConfigCandidates = []; + // 失败态:按需回退到环境变量配置,并把结果记为「带 TTL 的负缓存」。 + const fallbacks = []; if (hasCompleteEnvConfig(process.env, 'default')) { console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_*)'); - cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'default')); + fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'default')); } if (hasCompleteEnvConfig(process.env, 'public')) { console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_PUBLIC_*)'); - cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'public')); + fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'public')); } + + cachedCloudConfigCandidates = fallbacks; + cachedCloudConfigExpiresAt = Date.now() + NEGATIVE_CONFIG_CACHE_TTL_MS; + console.warn( + `[CloudDB] 系统库配置暂不可用(${fallbacks.length > 0 ? '暂用环境变量兜底' : '且无环境变量兜底'}),` + + `${NEGATIVE_CONFIG_CACHE_TTL_MS}ms 后自动重试` + ); return cachedCloudConfigCandidates; } @@ -961,10 +984,21 @@ const getPool = async () => { throw lastError || new Error(NOT_CONFIGURED_MESSAGE); }; +/** + * 统一失效「云配置候选」缓存。 + * + * 抽成单点:数组与负缓存 TTL 时间戳必须同时清,只清数组会让陈旧的 expiresAt 残留, + * 下次读取时 `Date.now() < expiresAt` 仍可能成立 → 又退化回「负缓存不失效」。 + */ +function invalidateCloudConfigCache() { + cachedCloudConfigCandidates = null; + cachedCloudConfigExpiresAt = 0; +} + const resetPool = async () => { if (!pool) { activeCloudConfig = null; - cachedCloudConfigCandidates = null; + invalidateCloudConfigCache(); return; } @@ -975,7 +1009,7 @@ const resetPool = async () => { } finally { pool = null; activeCloudConfig = null; - cachedCloudConfigCandidates = null; + invalidateCloudConfigCache(); } }; @@ -1026,6 +1060,14 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const candidates = await resolveCloudConfigCandidates(); if (!Array.isArray(candidates) || candidates.length === 0) { + // 候选为空 = 本轮配置解析失败,且没有任何环境变量兜底。 + // + // 这里刻意**不**清缓存:失败态已由 resolveCloudConfigCandidates 写成「带 TTL 的负缓存」。 + // TTL 内的后续请求快速失败(不必每个请求都去撞系统库的慢超时),到期自动重新解析 —— + // 隧道恢复后无需重启后端。旧实现把这次失败永久固化,正是「必须重启后端」的根因。 + // + // 注:getPool() 里的同款分支行为与此保持一致,两条路径统一由 TTL 负责恢复; + // 「坏连接池」由 query() 的 catch → resetPool() 负责,各司其职。 throw new Error(NOT_CONFIGURED_MESSAGE); } @@ -1052,6 +1094,9 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => { */ const test = async () => { try { + // 「测试连接」是用户在系统设置里验证配置的入口,必须读最新配置: + // 先显式失效,避免 30s 内的失败态负缓存让用户看到「刚改好却连不上」。 + invalidateCloudConfigCache(); const candidates = await resolveCloudConfigCandidates(); if (!Array.isArray(candidates) || candidates.length === 0) { return { success: false, message: NOT_CONFIGURED_MESSAGE }; @@ -1080,7 +1125,7 @@ const close = async () => { } pool = null; activeCloudConfig = null; - cachedCloudConfigCandidates = null; + invalidateCloudConfigCache(); }; /** diff --git a/backend/test/cloudDatabaseConfigCache.test.js b/backend/test/cloudDatabaseConfigCache.test.js new file mode 100644 index 0000000..260b577 --- /dev/null +++ b/backend/test/cloudDatabaseConfigCache.test.js @@ -0,0 +1,126 @@ +/** + * 配置负缓存 TTL 回归测试。 + * + * 背景(线上真实故障): + * 隧道抖动 → 系统库(走 SSH 隧道)读取失败 → 旧实现把「空候选」永久缓存下来 + * (入口是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy), + * 此后永不重试,直接抛「业务数据库尚未配置」500,**必须重启后端**才能恢复。 + * + * 修复:失败态记为「带 TTL 的负缓存」,到期自动重新解析。 + * 本文件锁定三条语义: + * ① TTL 内不重复解析(不必每个请求都去撞系统库的慢超时) + * ② TTL 到期后必须重试 + * ③ 上游恢复后能自动回到正常态,且成功态不会被 TTL 反复淘汰 + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +// ── 必须在加载被测模块之前完成环境与替身准备 ───────────────────────────── +// 1) 先清掉业务库环境变量,稳定走「无环境变量兜底」分支(否则 fallbacks 非空,不会报未配置) +for (const key of Object.keys(process.env)) { + if (/^(CLOUD_DB|APP_DB|SQLSERVER)_/u.test(key)) { + delete process.env[key]; + } +} +// 2) 再缩短负缓存 TTL —— 必须在上面那次清理之后,否则会被同一前缀规则一并删掉 +process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS = '150'; + +// 3) 让「系统库读取」可控:替换 mysql2/promise 的 createPool +const MYSQL_PROMISE_PATH = require.resolve('mysql2/promise'); +const realMysqlPromise = require(MYSQL_PROMISE_PATH); + +let systemDbDialAttempts = 0; +let systemDbMode = 'down'; // down = 模拟隧道断开;recovered = 模拟隧道已恢复 + +const systemConfigRow = { + name: 'cloud_database', + host: '127.0.0.1', + port: 59999, // 故意指向无人监听的端口,避免测试真去连业务库 + database_name: 'JSReportReplica', + username: 'probe', + password: 'probe', + ssl: 0, + driver: 'mssql', + use_env_config: 0, + is_default: 1, +}; + +require.cache[MYSQL_PROMISE_PATH].exports = Object.assign({}, realMysqlPromise, { + createPool: () => { + systemDbDialAttempts += 1; + return { + query: async () => { + if (systemDbMode === 'down') { + throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:13307'), { + code: 'ECONNREFUSED', + }); + } + return [[systemConfigRow]]; + }, + end: async () => {}, + }; + }, +}); + +const cloudDatabase = require('../src/config/cloudDatabase'); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const NOT_CONFIGURED = /业务数据库尚未配置/u; + +test('负缓存带 TTL:TTL 内不重复解析,到期后必须重试', async () => { + await cloudDatabase.close(); + const base = systemDbDialAttempts; + + await assert.rejects( + () => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'), + NOT_CONFIGURED + ); + assert.equal(systemDbDialAttempts, base + 1, '首次查询应真正尝试解析配置'); + + await assert.rejects( + () => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'), + NOT_CONFIGURED + ); + assert.equal(systemDbDialAttempts, base + 1, 'TTL 内应命中负缓存,不重复解析'); + + await sleep(220); + await assert.rejects( + () => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'), + NOT_CONFIGURED + ); + assert.equal( + systemDbDialAttempts, + base + 2, + 'TTL 到期后必须重试 —— 这正是「隧道恢复后无需重启后端」的关键' + ); +}); + +test('隧道恢复后自动复原:TTL 到期重试能读回配置,且成功态不被 TTL 淘汰', async () => { + await sleep(220); // 越过上一轮的负缓存 TTL + systemDbMode = 'recovered'; + const base = systemDbDialAttempts; + + await assert.rejects( + () => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'), + (error) => { + // 关键断言:不应再是「尚未配置」——说明配置已成功读回, + // 失败只是发生在后面连业务库那一层(这里故意指向空端口)。 + assert.doesNotMatch( + error.message, + NOT_CONFIGURED, + '配置读取应已恢复,不该再报「业务数据库尚未配置」' + ); + return true; + } + ); + assert.ok(systemDbDialAttempts > base, '上游恢复后应重新解析配置'); + + // 成功态 expiresAt = Infinity:后续查询不得再反复解析系统库 + const afterRecovery = systemDbDialAttempts; + await assert.rejects(() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x')); + assert.equal( + systemDbDialAttempts, + afterRecovery, + '成功态不应被 TTL 反复淘汰(否则每个请求都要多打一次系统库)' + ); +}); diff --git a/scripts/start-remote.mjs b/scripts/start-remote.mjs index ad0c54f..b6ebf3a 100644 --- a/scripts/start-remote.mjs +++ b/scripts/start-remote.mjs @@ -34,13 +34,34 @@ function resolveBackendPort() { return 4001; } +/** + * 隧道重连退避:1s → 2s → 4s … 封顶 30s。 + * 首次失败也立即重试(等价 autossh 的 AUTOSSH_GATETIME=0),避免网络尚未就绪时白等。 + */ +const TUNNEL_RETRY_BASE_MS = 1000; +const TUNNEL_RETRY_MAX_MS = 30000; +const TUNNEL_RETRY_EXPONENT_CAP = 10; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function computeTunnelRetryDelay(attempt) { + const exponent = Math.min(attempt, TUNNEL_RETRY_EXPONENT_CAP); + return Math.min(TUNNEL_RETRY_BASE_MS * 2 ** exponent, TUNNEL_RETRY_MAX_MS); +} + export function buildSshArgs(config) { return [ '-N', '-T', '-o', 'BatchMode=yes', + // 远端仍可能主动 RST(中间设备/服务端空闲超时),本地探活只能更快发现,不能阻止; + // 真正的根治是下面的退避重连循环。 '-o', 'ExitOnForwardFailure=yes', - '-o', 'ServerAliveInterval=30', + '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3', + '-o', 'TCPKeepAlive=yes', + '-o', 'ConnectTimeout=10', '-o', 'StrictHostKeyChecking=yes', '-o', `UserKnownHostsFile=${path.join(ACCESS_DIR, 'known_hosts')}`, '-o', `CertificateFile=${path.join(ACCESS_DIR, 'id_ed25519-cert.pub')}`, @@ -112,32 +133,88 @@ export async function main(mode = process.argv[2] || 'tunnel') { process.once(signal, stopChildren); } + let tunnelRetryAttempt = 0; + let resolveTunnelReady = null; + const tunnelReady = new Promise((resolve) => { + resolveTunnelReady = resolve; + }); + + /** + * 启动一条 SSH 隧道并等它监听就绪。 + * 返回 { tunnel, exitPromise };隧道结束由 exitPromise 上报退出码,交给 + * superviseTunnel 决定重连 —— 这里**不再**牵连后端/前端。 + */ const startTunnel = async () => { const { value: config } = loadConnectionConfig(ROOT_DIR); const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' }); children.add(tunnel); - let ready = false; - const earlyExit = new Promise((resolve, reject) => { + + const exitPromise = new Promise((resolve) => { tunnel.once('exit', (code) => { children.delete(tunnel); - if (!ready) { - reject(new Error(`SSH 隧道在建立前退出(${code ?? 'unknown'})`)); - } else if (!shuttingDown) { - console.error(`SSH 隧道已退出(${code ?? 'unknown'})`); - stopChildren(); - process.exitCode = code || 1; - } + resolve(code); }); }); - try { - await Promise.race([waitForPort(Number(config.localDatabasePort)), earlyExit]); - ready = true; - } catch (error) { + + // 两个分支都 resolve,避免 Promise.race 落败方变成 unhandled rejection + const outcome = await Promise.race([ + waitForPort(Number(config.localDatabasePort)).then(() => ({ ready: true })), + exitPromise.then((code) => ({ ready: false, code })), + ]); + + if (!outcome.ready) { if (!tunnel.killed) tunnel.kill('SIGTERM'); - throw error; + throw new Error(`SSH 隧道在建立前退出(${outcome.code ?? 'unknown'})`); } + console.log(`系统数据库隧道已建立:127.0.0.1:${config.localDatabasePort}/${config.databaseName}`); - return tunnel; + return { tunnel, exitPromise }; + }; + + /** + * 隧道自愈循环(Windows 版 autossh 的语义)。 + * + * 隧道断开只重连隧道本身,后端/前端进程保持不动。这是原先缺失的容错: + * 旧实现在隧道 exit 时直接 stopChildren(),把整个开发栈一起杀掉,于是每一次 + * 远端 RST 都升级成一次「网站打不开」,还得靠外部守护清残留、整树重启。 + * + * 建立失败走指数退避(1s→30s);建立成功后重置退避,断开则固定 1s 后重连。 + */ + const superviseTunnel = async () => { + for (;;) { + if (shuttingDown) return; + + let session; + try { + session = await startTunnel(); + tunnelRetryAttempt = 0; + if (resolveTunnelReady) { + resolveTunnelReady(); + resolveTunnelReady = null; + } + } catch (error) { + if (shuttingDown) return; + const delay = computeTunnelRetryDelay(tunnelRetryAttempt); + tunnelRetryAttempt += 1; + console.error(`SSH 隧道建立失败(${error.message}),${delay}ms 后重试`); + await sleep(delay); + continue; + } + + const code = await session.exitPromise; + if (shuttingDown) return; + console.error(`SSH 隧道已退出(${code ?? 'unknown'}),正在重连…(后端/前端不受影响)`); + await sleep(TUNNEL_RETRY_BASE_MS); + } + }; + + // 守护循环本身理论上不抛错,兜底仍保留:真异常时按老路径收尾,避免留下半死进程 + const startTunnelSupervisor = () => { + superviseTunnel().catch((error) => { + console.error(`隧道守护异常终止:${error.message}`); + stopChildren(); + process.exitCode = 1; + }); }; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; @@ -172,7 +249,8 @@ export async function main(mode = process.argv[2] || 'tunnel') { const hasConnection = fs.existsSync(CONNECTION_PATH); if (hasConnection) { if (fs.existsSync(RESTART_MARKER_PATH)) fs.unlinkSync(RESTART_MARKER_PATH); - await startTunnel(); + startTunnelSupervisor(); + await tunnelReady; } else if (mode === 'tunnel') { throw new Error('尚未配置远程系统数据库,请先运行应用并在首次启动弹窗中输入请求地址和 API Key'); } @@ -195,7 +273,8 @@ export async function main(mode = process.argv[2] || 'tunnel') { await waitForExit(backend); restartingBackend = false; fs.unlinkSync(RESTART_MARKER_PATH); - await startTunnel(); + startTunnelSupervisor(); + await tunnelReady; backend = startApplication( ['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'], 'backend' diff --git a/test/remote-access.test.mjs b/test/remote-access.test.mjs index 380cde0..0e447f9 100644 --- a/test/remote-access.test.mjs +++ b/test/remote-access.test.mjs @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { getKnownHostsLine, mergeEnv, validateEnrollmentResponse } from '../scripts/remote-access-common.mjs'; -import { buildSshArgs } from '../scripts/start-remote.mjs'; +import { buildSshArgs, computeTunnelRetryDelay } from '../scripts/start-remote.mjs'; const enrollment = { installation_id: 'dev-123', @@ -69,3 +70,61 @@ test('pins standard and non-standard SSH host targets', () => { assert.equal(getKnownHostsLine('example.test', 22, 'ssh-ed25519 KEY'), 'example.test ssh-ed25519 KEY\n'); assert.equal(getKnownHostsLine('example.test', 2222, 'ssh-ed25519 KEY'), '[example.test]:2222 ssh-ed25519 KEY\n'); }); + +// ───────────────────────────────────────────────────────────────────────────── +// 隧道自愈(根治远端 RST 导致「网站打不开」) +// +// 旧行为:隧道进程 exit → stopChildren(),把后端 + 前端一起杀掉,于是每一次远端 +// RST 都升级成一次整栈不可用,还得靠外部守护清残留、整树重启。 +// 新行为:隧道 exit → 只重连隧道本身,应用进程不受影响。 +// ───────────────────────────────────────────────────────────────────────────── + +const tunnelConfig = { + localDatabasePort: 13307, + ssh: { + host: 'example.test', + port: 50232, + user: 'cangku-tunnel', + remoteDatabaseHost: '127.0.0.1', + remoteDatabasePort: 3307, + }, +}; + +test('tunnel keepalive: 更快发现死连接,且不会在 connect 阶段无限等待', () => { + const flags = new Set(buildSshArgs(tunnelConfig)); + // ExitOnForwardFailure 缺失会出现最阴险的故障:进程活着、端口却没绑上(「静默成功」) + assert.ok(flags.has('ExitOnForwardFailure=yes'), '必须保留 ExitOnForwardFailure=yes'); + assert.ok(flags.has('ServerAliveInterval=15'), '探活间隔应为 15s,以便更快发现被 RST 的连接'); + assert.ok(flags.has('ServerAliveCountMax=3')); + assert.ok(flags.has('TCPKeepAlive=yes'), '需要内核层保活兜底'); + assert.ok(flags.has('ConnectTimeout=10'), '连不上时应快速失败,交给退避重连而不是干等'); +}); + +test('tunnel retry backoff: 1s 起指数增长,封顶 30s 且不会溢出', () => { + assert.equal(computeTunnelRetryDelay(0), 1000); + assert.equal(computeTunnelRetryDelay(1), 2000); + assert.equal(computeTunnelRetryDelay(2), 4000); + assert.equal(computeTunnelRetryDelay(4), 16000); + assert.equal(computeTunnelRetryDelay(5), 30000); + // 长期不可用时 attempt 会一路增长,必须封顶而不是算成 Infinity / 0 + assert.equal(computeTunnelRetryDelay(200), 30000); + assert.ok(Number.isFinite(computeTunnelRetryDelay(2000))); +}); + +test('防腐化:隧道自愈循环不得停止后端/前端进程', async () => { + const source = await readFile(new URL('../scripts/start-remote.mjs', import.meta.url), 'utf8'); + const start = source.indexOf('const superviseTunnel = async () => {'); + const end = source.indexOf('const startTunnelSupervisor = () => {'); + assert.ok(start > 0 && end > start, '未定位到 superviseTunnel 实现'); + + const body = source.slice(start, end); + assert.equal( + body.includes('stopChildren()'), + false, + '隧道重连不得牵连后端/前端 —— 否则又回到「隧道一断,整个开发栈陪葬」' + ); + assert.ok( + body.includes('await sleep('), + '隧道退出后必须先等待再重连,避免 RST 时疯狂重连打爆对端' + ); +}); From fa91b9242c49e7b90e7b9e8b1f8770e1da78d8ff Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 23 Sep 2026 11:05:28 +0800 Subject: [PATCH 34/70] =?UTF-8?q?fix:=20=E6=A0=B9=E6=B2=BBMKH=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=90=8E=E6=97=A7=E8=AF=B7=E6=B1=82=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E6=9C=80=E6=96=B0=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/mkhBom.js | 13 ++++--- frontend/src/api/mkhBom.js | 13 ++++--- frontend/src/pages/MkhBomData/index.jsx | 46 ++++++++++++++++++++----- frontend/src/utils/requestGate.js | 17 +++++++++ frontend/test/requestGate.test.mjs | 24 +++++++++++++ 5 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 frontend/src/utils/requestGate.js create mode 100644 frontend/test/requestGate.test.mjs diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index 9614490..7cb8b0e 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -32,6 +32,7 @@ function sendError(res, error, fallback) { // MKH-BOM 资料列表(模块化组合商品 + 配件汇总) router.get('/list', async (req, res) => { try { + res.set('Cache-Control', 'no-store'); res.json({ success: true, data: await getMkhBomList(req.query || {}) }); } catch (error) { sendError(res, error, '加载 MKH-BOM 资料列表失败'); @@ -42,6 +43,7 @@ router.get('/list', async (req, res) => { router.get('/detail', async (req, res) => { try { const skuId = req.query?.skuId || req.query?.comboSkuId; + res.set('Cache-Control', 'no-store'); res.json({ success: true, data: await getMkhBomDetail(skuId) }); } catch (error) { sendError(res, error, '加载 MKH-BOM 明细失败'); @@ -102,11 +104,14 @@ router.put('/notification-center', async (req, res) => { } }); -// 手动同步:清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), -// 前端「同步」按钮调用后再拉一次列表即可直连副本库取最新快照 -router.post('/sync', async (_req, res) => { +// 手动同步:先清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), +// 再在同一次请求中直连副本库读取新快照。这样不会被同步前尚未完成的旧列表请求覆盖。 +router.post('/sync', async (req, res) => { try { - res.json({ success: true, data: clearMkhBomCache() }); + const cache = clearMkhBomCache(); + const snapshot = await getMkhBomList(req.body?.query || {}); + res.set('Cache-Control', 'no-store'); + res.json({ success: true, data: { cache, snapshot } }); } catch (error) { sendError(res, error, '同步 MKH-BOM 数据失败'); } diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js index 4d61416..dddc8cb 100644 --- a/frontend/src/api/mkhBom.js +++ b/frontend/src/api/mkhBom.js @@ -7,7 +7,11 @@ const EXPORT_TIMEOUT_MS = 5 * 60 * 1000; // MKH-BOM 资料列表(模块化组合商品 + 配件汇总) export const getMkhBomList = (params = {}) => request.get( '/api/mkh-bom/list', - { params, timeout: LIST_TIMEOUT_MS } + { + params, + timeout: LIST_TIMEOUT_MS, + headers: { 'Cache-Control': 'no-cache' }, + } ); // 单个 MKH 组合商品的 BOM 配件明细(列表展开 / 打印预览共用) @@ -46,10 +50,9 @@ export const saveMkhNotificationCenter = (payload = {}) => request.put( payload ); -// 手动同步:清空服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), -// 随后重新拉列表即直连副本库取最新快照,可随时点击 -export const syncMkhBom = () => request.post( +// 手动同步:服务端清空全部 MKH 缓存,并在同一次请求中返回当前筛选条件的数据库新快照 +export const syncMkhBom = (query = {}) => request.post( '/api/mkh-bom/sync', - null, + { query }, { timeout: LIST_TIMEOUT_MS } ); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 231dc8e..0670914 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -17,6 +17,7 @@ import { checkMkhReplenishAlert, syncMkhBom, } from '../../api/mkhBom'; +import { createRequestGate } from '../../utils/requestGate'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -273,6 +274,8 @@ const MkhBomData = () => { const [centerSaving, setCenterSaving] = useState(false); const tableWrapRef = useRef(null); + const listRequestGateRef = useRef(createRequestGate()); + const detailGenerationRef = useRef(0); const [bodyHeight, setBodyHeight] = useState(420); useEffect(() => { @@ -310,19 +313,22 @@ const MkhBomData = () => { }, [page, pageSize, sorter, filters]); const load = useCallback(async () => { + const requestGeneration = listRequestGateRef.current.begin(); setLoading(true); setError(''); try { - const result = await getMkhBomList(query); + const result = await getMkhBomList({ ...query, _refresh: Date.now() }); + if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; if (!result || result.success === false) { setError(result?.error || '加载 MKH-BOM 资料失败'); return; } setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 }); } catch (requestError) { + if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败'); } finally { - setLoading(false); + if (listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false); } }, [query]); @@ -482,18 +488,23 @@ const MkhBomData = () => { const loadDetail = useCallback(async (skuId) => { if (detailCache[skuId] || detailLoading[skuId]) return; + const detailGeneration = detailGenerationRef.current; setDetailLoading((old) => ({ ...old, [skuId]: true })); try { const result = await getMkhBomDetail(skuId); + if (detailGeneration !== detailGenerationRef.current) return; if (!result || result.success === false) { message.error(result?.error || '加载配件明细失败'); return; } setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } })); } catch (e) { + if (detailGeneration !== detailGenerationRef.current) return; message.error(e?.response?.data?.error || e?.message || '加载配件明细失败'); } finally { - setDetailLoading((old) => ({ ...old, [skuId]: false })); + if (detailGeneration === detailGenerationRef.current) { + setDetailLoading((old) => ({ ...old, [skuId]: false })); + } } }, [detailCache, detailLoading]); @@ -791,18 +802,35 @@ const MkhBomData = () => { ); }; - // 手动同步:清掉服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), - // 再拉一次列表即可直连副本库取最新快照;可随时点击,不必等凌晨的定时刷新 + // 手动同步:服务端清缓存后在同一次请求中直连副本库返回新快照。 + // 请求代际会阻止同步前尚未完成的旧请求覆盖这个新快照。 const handleSync = async () => { setSyncing(true); + setLoading(true); + // 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。 + listRequestGateRef.current.invalidate(); + detailGenerationRef.current += 1; + setDetailCache({}); + setDetailLoading({}); + setExpandedKeys({}); + const syncGeneration = listRequestGateRef.current.begin(); try { - await syncMkhBom(); - setDetailCache({}); - await load(); - message.success('已同步最新数据'); + const result = await syncMkhBom(query); + if (!listRequestGateRef.current.isCurrent(syncGeneration)) return; + if (!result || result.success === false || !result?.data?.snapshot) { + throw new Error(result?.error || '同步接口未返回数据库快照'); + } + setData(result.data.snapshot); + setError(''); + const monitorDate = result.data.snapshot.monitorDate; + message.success(monitorDate ? `已读取数据库最新快照(监控日 ${monitorDate})` : '已读取数据库最新快照'); } catch (syncError) { + if (listRequestGateRef.current.isCurrent(syncGeneration)) { + setError(syncError?.response?.data?.error || syncError?.message || '同步失败'); + } message.error(syncError?.response?.data?.error || syncError?.message || '同步失败'); } finally { + if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false); setSyncing(false); } }; diff --git a/frontend/src/utils/requestGate.js b/frontend/src/utils/requestGate.js new file mode 100644 index 0000000..a33ec9b --- /dev/null +++ b/frontend/src/utils/requestGate.js @@ -0,0 +1,17 @@ +export function createRequestGate() { + let generation = 0; + + return { + begin() { + generation += 1; + return generation; + }, + invalidate() { + generation += 1; + return generation; + }, + isCurrent(requestGeneration) { + return requestGeneration === generation; + }, + }; +} diff --git a/frontend/test/requestGate.test.mjs b/frontend/test/requestGate.test.mjs new file mode 100644 index 0000000..f4770b6 --- /dev/null +++ b/frontend/test/requestGate.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRequestGate } from '../src/utils/requestGate.js'; + +test('only the newest request may update the page', () => { + const gate = createRequestGate(); + const oldRequest = gate.begin(); + const newRequest = gate.begin(); + + assert.equal(gate.isCurrent(oldRequest), false); + assert.equal(gate.isCurrent(newRequest), true); +}); + +test('sync invalidates every request that started before cache clearing', () => { + const gate = createRequestGate(); + const beforeSync = gate.begin(); + + gate.invalidate(); + + assert.equal(gate.isCurrent(beforeSync), false); + const freshSnapshot = gate.begin(); + assert.equal(gate.isCurrent(freshSnapshot), true); +}); From b8e60a19d253882f01d87f34f44a7d385c85c1f7 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 23 Sep 2026 11:12:08 +0800 Subject: [PATCH 35/70] =?UTF-8?q?fix:=20MKH=E5=88=B7=E6=96=B0=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E5=AE=9E=E6=97=B6=E8=AE=A2=E5=8D=95=E5=BA=93=E5=AD=98?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/mkhBom.js | 2 +- backend/src/services/mkhBomService.js | 43 +++++++++++++++++++------ backend/test/mkhBomService.test.js | 16 +++++++++ frontend/src/api/mkhBom.js | 2 +- frontend/src/pages/MkhBomData/index.jsx | 35 +++++++++++--------- 5 files changed, 72 insertions(+), 26 deletions(-) diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index 7cb8b0e..252019d 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -109,7 +109,7 @@ router.put('/notification-center', async (req, res) => { router.post('/sync', async (req, res) => { try { const cache = clearMkhBomCache(); - const snapshot = await getMkhBomList(req.body?.query || {}); + const snapshot = await getMkhBomList({ ...(req.body?.query || {}), live: '1' }); res.set('Cache-Control', 'no-store'); res.json({ success: true, data: { cache, snapshot } }); } catch (error) { diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index eda345c..401e60e 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -1069,7 +1069,8 @@ function buildReplenishMetrics(row = {}) { const orderDailySale = recentShippedQty > 0 ? Number((recentShippedQty / SALE_WINDOW_DAYS).toFixed(4)) : 0; - const useOrderDailySale = snapshotDailySale <= 0 && orderDailySale > 0; + const preferLiveData = row.preferLiveData === true; + const useOrderDailySale = preferLiveData || (snapshotDailySale <= 0 && orderDailySale > 0); const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale; // 现有库存来源优先级(见文件头 ⑦-d): @@ -1092,9 +1093,13 @@ function buildReplenishMetrics(row = {}) { } else if (hasRealtime) { stock = realtimeStock; stockSource = 'realtime'; - } else { + } else if (!preferLiveData) { stock = monitorStock; stockSource = 'monitor'; + } else { + // 手动实时刷新不能再退回延迟快照;两个当前库存源均无记录时按 0 展示并明确标记来源。 + stock = 0; + stockSource = 'live-missing'; } // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 @@ -1114,7 +1119,7 @@ function buildReplenishMetrics(row = {}) { const base = { dailySale, // 日销来源(⑦-a):'snapshot' 上游快照 / 'orders' 订单表实算兜底 - dailySaleSource: useOrderDailySale ? 'orders' : 'snapshot', + dailySaleSource: preferLiveData ? 'orders-live' : (useOrderDailySale ? 'orders' : 'snapshot'), // 上游快照原值(保留下来做对照,业务对不上源系统数字时能看出是兜底生效) snapshotDailySale, // 近 SALE_WINDOW_DAYS 天已发货件数(⑦-a,已排除样品/测试店) @@ -1756,13 +1761,14 @@ async function loadReplenishIndex() { const seenMonitor = new Set(); const bySku = new Map(); + const liveBySku = new Map(); let monitorDate = null; monitorRows.forEach((row) => { const key = normalizeCode(row.skuId); if (!key || seenMonitor.has(key)) return; seenMonitor.add(key); if (!monitorDate) monitorDate = toDateText(row.monitorDate); - bySku.set(key, buildReplenishMetrics({ + const metricInput = { // 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据) avgSale: row.fifteenAvgSale, // 日销兜底见文件头 ⑦-a:快照漏算时用订单表实算的近15天发货量 @@ -1777,11 +1783,16 @@ async function loadReplenishIndex() { monitorDate: row.monitorDate, ...(workOrderBySku.get(key) || null), ...(pendingOrderBySku.get(key) || null), - })); + }; + bySku.set(key, buildReplenishMetrics(metricInput)); + liveBySku.set(key, buildReplenishMetrics({ ...metricInput, preferLiveData: true })); }); return { bySku, + liveBySku, + latestStock, + packStock, workOrderBySku, pendingOrderBySku, shippedBySku, @@ -1919,7 +1930,8 @@ async function loadMkhComponentReadiness(skuIds = []) { * MKH 商品 × 库存/日销指标 × 在途加工单(内存合并)。 * 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。 */ -async function buildMergedReplenishRows() { +async function buildMergedReplenishRows(options = {}) { + const preferLiveData = options.preferLiveData === true; const [products, replenishIndex] = await Promise.all([ loadMkhProductIndex(), loadReplenishIndex(), @@ -1927,13 +1939,20 @@ async function buildMergedReplenishRows() { const rows = products.map((product) => { const key = normalizeCode(product.skuId); - const metrics = replenishIndex.bySku.get(key); + const metrics = (preferLiveData ? replenishIndex.liveBySku : replenishIndex.bySku).get(key); if (metrics) return { ...product, ...metrics }; // 监控快照里没有该商品时,仍要带上在途加工单(⑧)、待发货订单(⑩)与 // 近15天已发货(⑦-a)信息 —— 后者正是「快照里根本没这个编码」时唯一的日销来源 return { ...product, ...buildReplenishMetrics({ + preferLiveData, + realtimeStock: replenishIndex.latestStock?.has(key) + ? replenishIndex.latestStock.get(key) + : null, + packStock: replenishIndex.packStock?.has(key) + ? replenishIndex.packStock.get(key) + : null, ...(replenishIndex.workOrderBySku?.get(key) || null), ...(replenishIndex.pendingOrderBySku?.get(key) || null), recentShippedQty: replenishIndex.shippedBySku?.get(key) || 0, @@ -2144,9 +2163,10 @@ async function getMkhBomList(query = {}) { // 默认按可用天数升序 —— 最紧急的排最前 const sortField = String(query.sortField || 'stockDays'); const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh); const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([ - buildMergedReplenishRows(), + buildMergedReplenishRows({ preferLiveData }), loadOfficialBomIndex(), loadBusinessBom(), loadPgBom(), @@ -2218,6 +2238,8 @@ async function getMkhBomList(query = {}) { ...stats, replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, saleWindowDays: SALE_WINDOW_DAYS, + dataMode: preferLiveData ? 'live' : 'snapshot', + refreshedAt: new Date().toISOString(), // 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理 workOrderDegraded: replenishIndex.workOrderDegraded === true, workOrderWarehouse: WORK_ORDER_WAREHOUSE, @@ -2393,9 +2415,10 @@ async function listMkhBomExportRows(query = {}) { const scope = resolveScope(query); const sortField = String(query.sortField || 'stockDays'); const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh); // 与列表接口完全相同的筛选 / 排序口径:先全量合并,再内存筛选排序(见文件头 ⑦) - const { rows: merged, replenishIndex } = await buildMergedReplenishRows(); + const { rows: merged, replenishIndex } = await buildMergedReplenishRows({ preferLiveData }); const sorted = sortRows(filterRows(merged, { keyword, scope }), sortField, sortOrder); const [officialBom, businessBom, productBom] = await Promise.all([ @@ -2448,6 +2471,8 @@ async function listMkhBomExportRows(query = {}) { monitorDate: replenishIndex.monitorDate, replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, saleWindowDays: SALE_WINDOW_DAYS, + dataMode: preferLiveData ? 'live' : 'snapshot', + refreshedAt: new Date().toISOString(), workOrderDegraded: replenishIndex.workOrderDegraded === true, }; } diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 5fe2b89..93cdded 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -234,6 +234,22 @@ test('replenish: stock prefers the brand-warehouse realtime value, snapshot only assert.equal(zero.warningLevel, '缺货'); }); +test('replenish: live refresh ignores delayed sales and stock snapshots', () => { + const live = metrics({ + preferLiveData: true, + avgSale: 9, + recentShippedQty: 30, + realtimeStock: null, + packStock: 0, + monitorStock: 99, + }); + + assert.equal(live.dailySale, 2, '实时日销必须按 30 件 ÷ 15 天计算'); + assert.equal(live.dailySaleSource, 'orders-live'); + assert.equal(live.stock, 0, '实时库存源无记录时不得回退延迟快照库存 99'); + assert.equal(live.stockSource, 'live-missing'); +}); + test('replenish: box/pallet stock fills in when the daily inventory table is stale (⑦-d)', () => { // 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货 const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 }); diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js index dddc8cb..41bc27c 100644 --- a/frontend/src/api/mkhBom.js +++ b/frontend/src/api/mkhBom.js @@ -25,7 +25,7 @@ export const getMkhBomDetail = (skuId) => request.get( // ⚠️ 「全部」范围下官方 BOM 明细要分批拉取,耗时明显高于列表接口,故单独放大超时 export const getMkhBomExportRows = (params = {}) => request.get( '/api/mkh-bom/export', - { params, timeout: EXPORT_TIMEOUT_MS } + { params: { ...params, live: 1 }, timeout: EXPORT_TIMEOUT_MS } ); // 补货预警推送设置(接收人 / 开关 / 推送范围) diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 0670914..9e434cb 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -157,6 +157,7 @@ const STOCK_SOURCE_TEXT = { realtime: '品牌仓实时库存', pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)', monitor: '品牌仓实时库存缺失,取监控快照库存', + 'live-missing': '实时库存表和箱/货位库存表均无记录,实时口径按 0 计算', }; // A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 @@ -317,7 +318,7 @@ const MkhBomData = () => { setLoading(true); setError(''); try { - const result = await getMkhBomList({ ...query, _refresh: Date.now() }); + const result = await getMkhBomList({ ...query, live: 1, _refresh: Date.now() }); if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; if (!result || result.success === false) { setError(result?.error || '加载 MKH-BOM 资料失败'); @@ -565,13 +566,18 @@ const MkhBomData = () => { const text = Number(value) > 0 ? number(value, 2) : 0; // 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。 // 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。 - if (row.dailySaleSource !== 'orders') return text; + if (!String(row.dailySaleSource || '').startsWith('orders')) return text; + const liveMode = row.dailySaleSource === 'orders-live'; return ( {text} @@ -822,8 +828,7 @@ const MkhBomData = () => { } setData(result.data.snapshot); setError(''); - const monitorDate = result.data.snapshot.monitorDate; - message.success(monitorDate ? `已读取数据库最新快照(监控日 ${monitorDate})` : '已读取数据库最新快照'); + message.success('已实时读取最新订单、库存、BOM 与仓位数据'); } catch (syncError) { if (listRequestGateRef.current.isCurrent(syncGeneration)) { setError(syncError?.response?.data?.error || syncError?.message || '同步失败'); @@ -849,8 +854,8 @@ const MkhBomData = () => { 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo, 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
- 补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、 - 该仓无记录时回退监控快照库存;结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0), + 补货预警:近15天日销按已出库订单实时计算,现有库存取品牌仓当前库存,并用箱/货位库存补充当日入库; + 实时查询不回退延迟监控快照。结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0), 可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货; 建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。 无日销且无订单的商品不参与预警。 @@ -891,8 +896,8 @@ const MkhBomData = () => { ]} /> - - + + { - {error ? 重试} /> : null} + {error ? 重试} /> : null} {!loading && !error && data.workOrderDegraded ? ( { type="warning" showIcon message="加工单数据源暂时不可用,在途加工量按 0 处理,预警结果可能偏多,请稍后点击「刷新数据」重试。" - action={} + action={} /> ) : null} @@ -924,7 +929,7 @@ const MkhBomData = () => { type="warning" showIcon message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近15天无日销但有订单」的商品,请稍后点击「刷新数据」重试。" - action={} + action={} /> ) : null} @@ -934,7 +939,7 @@ const MkhBomData = () => { type="warning" showIcon message="配件库存数据源暂时不可用,本次未做「配件不足」判定,可能存在配件凑不齐、补不出来的商品,请稍后点击「刷新数据」重试。" - action={} + action={} /> ) : null} @@ -946,7 +951,7 @@ const MkhBomData = () => { message={`补货预警:${data.warningCount} 个 MKH 商品需要补货`} description={( - 按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}): + 按近 {data.saleWindowDays || 15} 天已出库订单实时核算: 其中 {data.outOfStockCount || 0} 个已缺货, {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。 From d32494ca128ada60bce6495f24afd0d25a2be259 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 23 Sep 2026 11:35:47 +0800 Subject: [PATCH 36/70] =?UTF-8?q?fix:=20=E6=B6=88=E9=99=A4=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=BE=AA=E7=8E=AF=E5=88=86=E5=9D=97=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E9=AA=A8=E6=9E=B6=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/vite.config.js | 51 ++++++++++------------------------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8cdfea4..3f7dd9f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -11,15 +11,21 @@ export default defineConfig({ cssCodeSplit: false, modulePreload: false, rollupOptions: { + onwarn(warning, warn) { + // Manually splitting packages that depend on each other can create a circular chunk graph. + // That graph built successfully in the past but crashed before React mounted, leaving only + // the static skeleton on screen. Treat it as a release-blocking error from now on. + if (warning.code === 'CIRCULAR_CHUNK') { + throw new Error(`Circular production chunk: ${warning.message}`) + } + warn(warning) + }, output: { entryFileNames: 'assets/[name]-[hash].js', chunkFileNames(chunkInfo) { if (chunkInfo.name === 'app-shell') return 'assets/app-shell-[hash].js' if (chunkInfo.name === 'app-api') return 'assets/app-api-[hash].js' - if (chunkInfo.name === 'react-vendor') return 'assets/react-vendor-[hash].js' - if (chunkInfo.name === 'antd-vendor') return 'assets/antd-vendor-[hash].js' - if (chunkInfo.name === 'charts-vendor') return 'assets/charts-vendor-[hash].js' - if (chunkInfo.name === 'data-vendor') return 'assets/data-vendor-[hash].js' + if (chunkInfo.name === 'vendor') return 'assets/vendor-[hash].js' return 'assets/[name]-[hash].js' }, assetFileNames(assetInfo) { @@ -32,41 +38,8 @@ export default defineConfig({ const normalizedId = id.replace(/\\/g, '/') if (normalizedId.includes('node_modules') || normalizedId.includes('/.vite/deps/')) { - if ( - normalizedId.includes('/react/') || - normalizedId.includes('react-dom') || - normalizedId.includes('react-router') || - normalizedId.includes('@remix-run/router') || - normalizedId.includes('react_jsx-runtime') || - normalizedId.includes('scheduler') - ) { - return 'react-vendor' - } - - if ( - normalizedId.includes('antd') || - normalizedId.includes('@ant-design') || - normalizedId.includes('@ant-design/pro-components') || - normalizedId.includes('/rc-') - ) { - return 'antd-vendor' - } - - if ( - normalizedId.includes('echarts') || - normalizedId.includes('zrender') || - normalizedId.includes('recharts') - ) { - return 'charts-vendor' - } - - if ( - normalizedId.includes('axios') || - normalizedId.includes('@cubejs-client') - ) { - return 'data-vendor' - } - + // Keep mutually dependent third-party packages in one chunk. React, Ant Design, + // charts and Cube clients share runtime helpers and cannot be split safely by path. return 'vendor' } From b3b38d4aa258e756b205edaab7ee83b0643dd130 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 23 Sep 2026 11:43:29 +0800 Subject: [PATCH 37/70] =?UTF-8?q?docs:=20=E5=A2=9E=E5=8A=A0=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=83=A8=E7=BD=B2=E8=BF=90=E8=A1=8C=E6=97=B6=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 服务器部署规范.md | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/服务器部署规范.md b/服务器部署规范.md index 5c47b4d..352952a 100644 --- a/服务器部署规范.md +++ b/服务器部署规范.md @@ -7,7 +7,8 @@ - 生产服务器项目根目录:`/opt/cangku` - 服务器代码目录:`/opt/cangku/app` - 生产编排文件:`/opt/cangku/app/deploy/production/docker-compose.yml` -- 对外站点及注册接口:`https://ck.yydd.ai:8443` +- 对外主站:`https://ck.yydd.ai`(标准 HTTPS 端口 `443`) +- 兼容访问入口及注册接口:`https://ck.yydd.ai:8443` - 公网 TLS Redis:`ck.yydd.ai:6380` - 服务器 SSH 密钥目录位于开发者桌面:`~/Desktop/cangku-156.225.27.52-ssh-key` - 当前私钥文件:`~/Desktop/cangku-156.225.27.52-ssh-key/cangku-prod-root-ed25519` @@ -64,6 +65,18 @@ git diff --stat origin/main...HEAD 如果本次变更涉及数据表、索引、查询、数据映射或同步任务,部署者还必须确认 PR 已按《仓库提交与合并规范》记录索引与执行计划证据、分批/并发/超时边界、大表锁影响和回退方式。证据缺失、依赖全表扫描、可能对系统库或第三方云库造成不可控负载时,不得部署。 +### 3.1 前端发布必须阻断循环分块 + +本项目曾因 Vite/Rollup 把 React、Ant Design、图表库和数据客户端按包路径拆成相互依赖的多个 chunk,生产构建虽然成功,浏览器却在 React 挂载前报错,页面永久停留在静态骨架屏。`curl` 返回 `200`、后端 `/health` 正常和静态资源可下载都无法发现这一故障。 + +前端相关变更必须遵守以下要求: + +1. 保留 `frontend/vite.config.js` 中对 `CIRCULAR_CHUNK` 的构建阻断。不得为了让构建“通过”而删除、降级或忽略该检查。 +2. 第三方依赖默认保留在同一个 `vendor` chunk。不得仅按目录名把 React、Ant Design、图表库或 Cube/数据客户端强行拆成独立 vendor chunk;确需重新分包时,必须证明 chunk 依赖图无环,并完成本节和第 6 节的浏览器运行时验证。 +3. 出现 `Circular chunk`、`Cannot read properties of undefined`、`Cannot access ... before initialization` 等构建或浏览器错误时,停止发布并修正模块依赖或分包策略。禁止把它当成普通警告继续部署。 +4. 单个 vendor 文件较大的提示不等于可以直接按包名拆分。先使用路由级动态导入、页面级懒加载或经过验证的稳定边界减小首屏体积。 +5. 推送前至少执行 `npm --prefix frontend run check`。构建日志必须没有循环 chunk 警告;测试成功不能替代真实生产构建验证。 + 有冲突时,AI 必须阅读共同基线和双方改动,保留双方仍然有效的行为。无法确定业务取舍时,停止该冲突的处理并请负责人决定。冲突解决后的代码必须重新测试、重新审核,不能沿用冲突前的结果。 ## 4. 检查服务器是否正在更新 @@ -144,8 +157,10 @@ for attempt in $(seq 1 60); do sleep 2 done +curl -fsS https://ck.yydd.ai/ >/dev/null +curl -fsS https://ck.yydd.ai/health curl -fsS https://ck.yydd.ai:8443/ >/dev/null -curl -fsS https://ck.yydd.ai:8443/health +curl -fsS https://ck.yydd.ai:8443/health >/dev/null docker compose -f deploy/production/docker-compose.yml ps git status --short git rev-parse HEAD @@ -158,12 +173,25 @@ REMOTE ## 6. 部署完成标准 +### 6.1 必须验证浏览器真实运行,不能只检查 HTTP 200 + +前端构建和容器启动后,必须使用一个不复用旧站点缓存和旧登录状态的全新浏览器上下文访问主站 `https://ck.yydd.ai/`。可以使用临时 Chrome 用户目录、全新的无痕窗口,或在开发者工具中清空站点数据后硬刷新。验证过程必须同时满足: + +1. 页面离开启动骨架屏,显示完整登录页或登录后的业务页面。 +2. 浏览器 Console 没有未捕获的 `TypeError`、`ReferenceError`、模块初始化错误或资源加载错误。 +3. Network 中入口 HTML、当前 HTML 引用的 JS/CSS 和关键接口均成功;不得只验证首页 HTML 返回 `200`。 +4. 当前 HTML 引用的资源文件名来自本次构建,页面展示的版本信息或接口 `/health` 中的 `SOURCE_GIT_SHA` 与待部署 SHA 一致。 +5. 至少完成一个关键用户路径;涉及数据页面时,要确认加载能结束并显示数据、空状态或明确错误,不能无限显示 loading/skeleton。 + +如果全新浏览器上下文正常,而原有标签页仍停在旧骨架屏,先关闭旧标签并进行硬刷新或清除该站点缓存。不得通过回滚代码、固定旧资源文件名或长期关闭缓存来掩盖客户端旧资源。若全新上下文也失败,保留 Console、Network、容器日志和当前 SHA,在部署锁内停止报告完成并修复后重新发布。 + 部署者和 AI 只有在以下项目全部确认后才能报告完成: - 服务器 `HEAD` 等于预先记录且已合并的 `origin/main` SHA。 - `git status --short` 没有输出。 - MySQL、Redis、Redis TLS 网关、后端和注册服务容器处于正常运行状态。 -- `https://ck.yydd.ai:8443/` 返回成功,`/health` 显示系统数据库、第三方数据库和 Redis 连接成功。 +- `https://ck.yydd.ai/` 与 `https://ck.yydd.ai:8443/` 返回成功,`/health` 显示系统数据库、第三方数据库和 Redis 连接成功。 +- 使用全新浏览器上下文完成第 6.1 节检查;页面已离开骨架屏,Console 无致命运行时错误,关键数据页面不会无限加载。 - `ck.yydd.ai:6380` 的 TLS 证书校验通过;原始 Redis 端口没有对公网开放。 - 新版本的关键用户路径已验证,没有覆盖其他开发者已经上线的功能。 - 最终报告包含任务分支、PR、部署 SHA、实际检查结果和未解决问题。 From 783bb5182baac1d130c7c03b4218609ef79d5cee Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 23 Sep 2026 11:52:07 +0800 Subject: [PATCH 38/70] =?UTF-8?q?docs:=20=E6=98=8E=E7=A1=AE=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E9=A2=84=E6=9C=9F=E9=89=B4=E6=9D=83=E5=93=8D?= =?UTF-8?q?=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 服务器部署规范.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/服务器部署规范.md b/服务器部署规范.md index 352952a..8d55edb 100644 --- a/服务器部署规范.md +++ b/服务器部署规范.md @@ -178,8 +178,8 @@ REMOTE 前端构建和容器启动后,必须使用一个不复用旧站点缓存和旧登录状态的全新浏览器上下文访问主站 `https://ck.yydd.ai/`。可以使用临时 Chrome 用户目录、全新的无痕窗口,或在开发者工具中清空站点数据后硬刷新。验证过程必须同时满足: 1. 页面离开启动骨架屏,显示完整登录页或登录后的业务页面。 -2. 浏览器 Console 没有未捕获的 `TypeError`、`ReferenceError`、模块初始化错误或资源加载错误。 -3. Network 中入口 HTML、当前 HTML 引用的 JS/CSS 和关键接口均成功;不得只验证首页 HTML 返回 `200`。 +2. 浏览器 Console 没有未捕获的 `TypeError`、`ReferenceError`、模块初始化错误或意外的资源加载错误。登录页进行会话探测时,鉴权接口按设计返回 `401` 属于正常结果,但必须确认请求目标和响应含义确实是“尚未登录”。 +3. Network 中入口 HTML和当前 HTML 引用的 JS/CSS 必须成功,关键接口响应必须符合当前登录状态与业务预期;不得只验证首页 HTML 返回 `200`。 4. 当前 HTML 引用的资源文件名来自本次构建,页面展示的版本信息或接口 `/health` 中的 `SOURCE_GIT_SHA` 与待部署 SHA 一致。 5. 至少完成一个关键用户路径;涉及数据页面时,要确认加载能结束并显示数据、空状态或明确错误,不能无限显示 loading/skeleton。 From 181ea0237d0bd76db5252913e2e6386fb528e65a Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 23 Sep 2026 11:52:32 +0800 Subject: [PATCH 39/70] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 服务器部署规范.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/服务器部署规范.md b/服务器部署规范.md index 8d55edb..bca2759 100644 --- a/服务器部署规范.md +++ b/服务器部署规范.md @@ -179,7 +179,7 @@ REMOTE 1. 页面离开启动骨架屏,显示完整登录页或登录后的业务页面。 2. 浏览器 Console 没有未捕获的 `TypeError`、`ReferenceError`、模块初始化错误或意外的资源加载错误。登录页进行会话探测时,鉴权接口按设计返回 `401` 属于正常结果,但必须确认请求目标和响应含义确实是“尚未登录”。 -3. Network 中入口 HTML和当前 HTML 引用的 JS/CSS 必须成功,关键接口响应必须符合当前登录状态与业务预期;不得只验证首页 HTML 返回 `200`。 +3. Network 中入口 HTML 和当前 HTML 引用的 JS/CSS 必须成功,关键接口响应必须符合当前登录状态与业务预期;不得只验证首页 HTML 返回 `200`。 4. 当前 HTML 引用的资源文件名来自本次构建,页面展示的版本信息或接口 `/health` 中的 `SOURCE_GIT_SHA` 与待部署 SHA 一致。 5. 至少完成一个关键用户路径;涉及数据页面时,要确认加载能结束并显示数据、空状态或明确错误,不能无限显示 loading/skeleton。 From 2bc949099ba7fc6dba0db1dfa0b2bc47f6721f48 Mon Sep 17 00:00:00 2001 From: lunanting Date: Wed, 23 Sep 2026 13:29:17 +0800 Subject: [PATCH 40/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=A7?= =?UTF-8?q?=E8=B4=A7=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=8826=20?= =?UTF-8?q?=E4=BB=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按「库存监控」表格在侧边栏增加一级/二级菜单与共用页面 (路由 /planning/large-stock-monitor/:warehouseSlug)。 后端: - 新增 largeStockMonitorConfig.js:26 仓配置,含 wms_co_id / 快照仓名 / routeKey / slug 映射 - 新增 largeStockMonitor.js:config / overview / sku-list 三个接口 - 新增 largeStockMonitorFlow.js:采购进仓、采购退货、销售出仓、销售退货四类单据流水取数 - 新增 verify-large-stock-monitor.js:26 仓端到端校验脚本 - index.js:挂载 /api/large-stock-monitor 路由 口径说明: - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%,下沉到 SQL CASE WHEN 计算 - 四级分级(滞销预警/慢流/呆滞/僵尸)按库龄 + 周转率双维度判定 - 期末数量取聚水潭实际库存 jst_inventoryInfo(按 wms_co_id 单仓), 不用 pm_stockMonitor.jsStock —— 后者 warehouse 是「归属仓」口径, 会把已调拨到外仓的货仍计在本仓名下(崔桥工厂仓因此虚高约 2.2 万) - 截止时刻统一为快照日次日 01:00(跑批时点),库龄/分级均以此为基准,不再用 GETDATE() 前端: - 新增 LargeStockMonitor 页面、18 列定义、API 封装与样式 - 路由与侧边栏:26 条二级菜单 + 权限点 冲突处理: - 主仓库已下线 /api/dashboard(backend/src/api/dashboard.js 被移除), 相应删除 dashboardRoutes 的 require 与挂载,避免后端启动时 require 失败崩溃 - 保留主仓库新增的「模块化库存监控」菜单组及其子项 --- backend/scripts/verify-large-stock-monitor.js | 113 ++++ backend/src/api/largeStockMonitor.js | 514 ++++++++++++++++++ backend/src/config/largeStockMonitorConfig.js | 76 +++ backend/src/index.js | 2 + backend/src/services/largeStockMonitorFlow.js | 132 +++++ frontend/src/api/largeStockMonitor.js | 15 + frontend/src/config/sidebarNavigation.js | 15 + frontend/src/constants/largeStockMonitor.js | 165 ++++++ frontend/src/constants/sidebarPages.js | 1 + frontend/src/layouts/BasicLayout.jsx | 10 + .../src/pages/LargeStockMonitor/index.jsx | 502 +++++++++++++++++ .../src/pages/LargeStockMonitor/style.css | 149 +++++ frontend/src/router/index.jsx | 5 + 13 files changed, 1699 insertions(+) create mode 100644 backend/scripts/verify-large-stock-monitor.js create mode 100644 backend/src/api/largeStockMonitor.js create mode 100644 backend/src/config/largeStockMonitorConfig.js create mode 100644 backend/src/services/largeStockMonitorFlow.js create mode 100644 frontend/src/api/largeStockMonitor.js create mode 100644 frontend/src/constants/largeStockMonitor.js create mode 100644 frontend/src/pages/LargeStockMonitor/index.jsx create mode 100644 frontend/src/pages/LargeStockMonitor/style.css diff --git a/backend/scripts/verify-large-stock-monitor.js b/backend/scripts/verify-large-stock-monitor.js new file mode 100644 index 0000000..d1ca3a2 --- /dev/null +++ b/backend/scripts/verify-large-stock-monitor.js @@ -0,0 +1,113 @@ +// 端到端校验:大货库存监控 26 仓在「数据截止时刻(快照日次日 01:00)」口径下的取数 +// 用法:node scripts/verify-large-stock-monitor.js +process.env.NODE_ENV = process.env.NODE_ENV || 'development'; +require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') }); +const http = require('http'); + +const BASE = 'http://127.0.0.1:4001'; +const { LARGE_STOCK_MONITOR_WAREHOUSES } = require('../src/config/largeStockMonitorConfig'); + +function request(method, path, token, body) { + return new Promise((resolve, reject) => { + const payload = body ? JSON.stringify(body) : null; + const req = http.request( + `${BASE}${path}`, + { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), + }, + }, + (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + try { + resolve({ status: res.statusCode, json: JSON.parse(data) }); + } catch (e) { + reject(new Error(`响应解析失败 ${res.statusCode}: ${data.slice(0, 200)}`)); + } + }); + } + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +(async () => { + const login = await request('POST', '/api/auth/local-preview', null, {}); + const token = login.json?.token || login.json?.data?.token || login.json?.access_token; + if (!token) { + console.error('登录失败:', JSON.stringify(login.json).slice(0, 300)); + process.exit(1); + } + console.log('登录成功\n'); + + let ok = 0; + const problems = []; + let sampleAsOf = null; + + for (const w of LARGE_STOCK_MONITOR_WAREHOUSES) { + const url = `/api/large-stock-monitor/overview?warehouse=${encodeURIComponent(w.warehouse)}`; + try { + const res = await request('GET', url, token); + const d = res.json?.data; + if (!res.json?.success || !d) { + problems.push(`${w.warehouse}: 接口失败 ${res.status} ${res.json?.error || ''}`); + continue; + } + sampleAsOf = sampleAsOf || d.asOf; + const o = d.overview || {}; + const filled = Object.values(o).filter((v) => v !== '—' && v !== null && v !== undefined).length; + if (!d.hasData) { + problems.push(`${w.warehouse}: 无数据源 (source=${d.source})`); + continue; + } + ok += 1; + console.log( + `[${String(w.idx).padStart(2)}] ${w.warehouse}\n` + + ` 快照=${d.latestBizDate} 截止=${d.asOf} 区间=${d.period?.start} ~ ${d.period?.end}\n` + + ` SKU=${d.skuCount} 期末=${o.closingQty} 期初(估)=${o.periodStart}\n` + + ` 采购进=${o.purchaseIn} 采购退=${o.purchaseReturn} 销售出=${o.salesOut} 销售退=${o.salesReturn}\n` + + ` 周转=${o.turnoverDays} 预警=${o.warnLevel} 慢流=${o.slowLevel} 呆滞=${o.staleLevel} 僵尸=${o.zombieLevel}\n` + + ` 有值列 ${filled}/18 source=${d.source}` + ); + } catch (e) { + problems.push(`${w.warehouse}: ${e.message}`); + } + } + + // 明细接口校验:带 @asOf 的分级筛选 SQL(level 过滤会引用 @asOf) + console.log('\n===== 明细接口校验 ====='); + for (const w of LARGE_STOCK_MONITOR_WAREHOUSES.slice(0, 4)) { + for (const lv of ['', 'warn', 'slow', 'stale', 'zombie']) { + const qs = `warehouse=${encodeURIComponent(w.warehouse)}&page=1&pageSize=5${lv ? `&level=${lv}` : ''}`; + try { + const res = await request('GET', `/api/large-stock-monitor/sku-list?${qs}`, token); + const d = res.json?.data; + if (!res.json?.success) { + problems.push(`${w.warehouse} level=${lv || '全部'}: ${res.json?.error}`); + } else { + console.log(` ${w.warehouse} level=${lv || '全部'} -> total=${d?.total} 返回=${d?.items?.length} 截止=${d?.asOf}`); + } + } catch (e) { + problems.push(`${w.warehouse} level=${lv}: ${e.message}`); + } + } + } + + console.log(`\n===== 汇总 =====`); + console.log(`数据截止时刻(asOf): ${sampleAsOf}`); + console.log(`有数据仓库: ${ok}/${LARGE_STOCK_MONITOR_WAREHOUSES.length}`); + if (problems.length) { + console.log('异常:'); + problems.forEach((p) => console.log(' - ' + p)); + } else { + console.log('全部仓库取数正常,无异常'); + } + process.exit(0); +})(); diff --git a/backend/src/api/largeStockMonitor.js b/backend/src/api/largeStockMonitor.js new file mode 100644 index 0000000..ecea5a5 --- /dev/null +++ b/backend/src/api/largeStockMonitor.js @@ -0,0 +1,514 @@ +// 大货库存监控 - 后端路由 +// 业务数据源:rpt.pm_stockMonitor(MSSQL,业务库 JSReportReplica) +// rpt.jst_*(聚水潭单据/库存表,用于出入库流水) +// 配置源:C:/Users/.../Desktop/库存监控.xlsx 中的 26 行仓库配置 +// +// 计算口径(用户给定): +// 月度发货总件数 ≈ threeAvgSale × 30 (业务库无直接月度发货字段,按 3 天日均销量 × 30 估算) +// 月度日均库存件数 ≈ jsStock (业务库只有当前日快照,作为日均近似) +// 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100% +// 注:按公式实际为周转率(%),列名沿用 Excel 表头"库存周转天数" +// 库龄(月) = DATEDIFF(MONTH, created, GETDATE()) +// +// 分级口径(用户给定,按"库龄 + 周转天数(%)"两个维度): +// 滞销预警 warn 库龄 < 3 个月 且 30 ≤ 周转天数 ≤ 199 +// 商品慢流 slow 库龄 < 3 个月 且 周转天数 > 200 +// 或 库龄 > 3 个月 且 30 ≤ 周转天数 ≤ 99 +// 商品呆滞 stale 库龄 3~12 个月 且 周转天数 ≥ 100 +// 或 库龄 > 12 个月 且 100 ≤ 周转天数 ≤ 199 +// 商品僵尸 zombie 库龄 > 12 个月 且 周转天数 ≥ 200 +const express = require('express'); +const authMiddleware = require('../middleware/auth'); +const cloudDb = require('../config/cloudDatabase'); +const { LARGE_STOCK_MONITOR_WAREHOUSES } = require('../config/largeStockMonitorConfig'); +const { fetchFlowSummary, fetchInventoryQty } = require('../services/largeStockMonitorFlow'); + +const router = express.Router(); + +const LEVEL_LABELS = { + warn: '滞销预警', + slow: '商品慢流', + stale: '商品呆滞', + zombie: '商品僵尸', +}; + +// 18 列表头(与库存监控.xlsx 第 1 行的 D..U 列对应) +// available=false 表示业务库无对应数据源(加工/盘点/调拨/其它出库),前端显示「—」 +const COLUMN_HEADERS = [ + { key: 'periodStart', label: '期初数据', available: true, estimated: true }, + { key: 'processIn', label: '加工进仓', available: false }, + { key: 'purchaseReturn', label: '采购退货', available: true }, + { key: 'purchaseIn', label: '采购进仓', available: true }, + { key: 'salesOut', label: '销售出仓', available: true }, + { key: 'stocktake', label: '盘点', available: false }, + { key: 'processOut', label: '加工出仓', available: false }, + { key: 'transferOut', label: '调拨出', available: false }, + { key: 'salesReturn', label: '销售退货', available: true }, + { key: 'transferIn', label: '调拨入', available: false }, + { key: 'openingBalance', label: '期初', available: true, estimated: true }, + { key: 'otherOut', label: '其它出库', available: false }, + { key: 'closingQty', label: '期末数量', available: true }, + { key: 'turnoverDays', label: '库存周转天数', available: true }, + { key: 'warnLevel', label: '滞销预警级库存量', available: true }, + { key: 'slowLevel', label: '商品慢流级库存量', available: true }, + { key: 'staleLevel', label: '商品呆滞级库存量', available: true }, + { key: 'zombieLevel', label: '商品僵尸级库存量', available: true }, +]; + +// 在 SELECT/WHERE 中复用:库龄月数、月度发货、月度日均库存、周转天数(%)、分级 +// 关键:所有算式都做 NULL/0 兜底,避免分母为 0 导致 NULL 污染整列 +// 时间基准统一使用 @asOf(数据截止时刻,默认快照日次日 01:00),不再用 GETDATE(), +// 保证同一批报表内库龄/分级与流水口径完全一致。 +function buildDerivedColumns(alias = 'm') { + const a = alias ? `${alias}.` : ''; + const T = 'CONVERT(DATETIME, @asOf, 120)'; + return { + ageMonths: `CASE WHEN ${a}created IS NULL THEN NULL ELSE CAST(DATEDIFF(DAY, ${a}created, ${T}) AS DECIMAL(18, 4)) / 30.0 END`, + monthlyShipment: `(ISNULL(${a}threeAvgSale, 0) * 30.0)`, + avgDailyStock: `CAST(ISNULL(${a}jsStock, 0) AS DECIMAL(18, 4))`, + turnoverDays: `CASE WHEN ISNULL(${a}jsStock, 0) = 0 THEN NULL ELSE CAST(ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock AS DECIMAL(18, 4)) END`, + levelExpr: ` + CASE + WHEN ${a}created IS NULL OR ISNULL(${a}jsStock, 0) = 0 OR ISNULL(${a}threeAvgSale, 0) * 30.0 / NULLIF(${a}jsStock, 0) IS NULL THEN NULL + WHEN DATEDIFF(DAY, ${a}created, ${T}) < 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 30 AND 199 THEN 'warn' + WHEN DATEDIFF(DAY, ${a}created, ${T}) < 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 200 THEN 'slow' + WHEN DATEDIFF(DAY, ${a}created, ${T}) >= 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 30 AND 99 THEN 'slow' + WHEN DATEDIFF(DAY, ${a}created, ${T}) BETWEEN 90 AND 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 100 THEN 'stale' + WHEN DATEDIFF(DAY, ${a}created, ${T}) > 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 100 AND 199 THEN 'stale' + WHEN DATEDIFF(DAY, ${a}created, ${T}) > 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 200 THEN 'zombie' + ELSE NULL + END + `, + }; +} + +function resolveWarehouse(warehouse) { + const target = String(warehouse || '').trim(); + if (!target) return null; + return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.warehouse === target) || null; +} + +function buildPlaceholderRow() { + return COLUMN_HEADERS.reduce((acc, column) => { + acc[column.key] = '—'; + return acc; + }, {}); +} + +// 默认跟随业务库最新快照日期;可通过 ?date=YYYY-MM-DD 指定某一天 +const DEFAULT_TARGET_DATE = null; + +// 数据截止时刻:快照日次日的凌晨 01:00。 +// 业务库每天凌晨 1 点跑批生成快照,标注为前一日(T-1)的库存, +// 因此「9 月 23 日凌晨 1 点的数据」= 快照日 2026-09-22 + 截止时刻 2026-09-23 01:00。 +const AS_OF_TIME = '01:00'; + +// 流水统计区间:快照所在自然月的 1 日 00:00 ~ 快照日次日 01:00(左闭右开) +// 即完整覆盖快照日全天,并包含跑批当日凌晨 0 点~1 点的单据 +function buildPeriodRange(bizDate) { + if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null; + const [y, mo] = bizDate.split('-'); + const start = `${y}-${mo}-01 00:00`; + const d = new Date(`${bizDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + const endDay = d.toISOString().slice(0, 10); + const end = `${endDay} ${AS_OF_TIME}`; + return { start, end, endDay, asOf: end }; +} + +// 允许 ?asOf=YYYY-MM-DD HH:mm 手工覆盖截止时刻(排查/补数用) +function normalizeAsOf(value) { + const raw = String(value || '').trim(); + if (!raw) return null; + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return `${raw} ${AS_OF_TIME}`; + const m = raw.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/); + if (m) return `${m[1]} ${m[2]}`; + return null; +} + +const pad2 = (n) => String(n).padStart(2, '0'); + +function localDateString(d) { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +function localDateTimeString(d) { + return `${localDateString(d)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +} + +// 无库存快照的仓库(走实时库存兜底)没有「快照日次日」可用,截止时刻取今天凌晨 1 点; +// 若当前还没到凌晨 1 点(跑批尚未完成),则退化为当前时刻。 +function fallbackAsOf() { + const now = new Date(); + const hourFloor = Number(AS_OF_TIME.split(':')[0]); + if (now.getHours() >= hourFloor) return `${localDateString(now)} ${AS_OF_TIME}`; + return localDateTimeString(now); +} + +async function fetchTargetBizDate(dbWarehouse, preferredDate) { + if (!dbWarehouse) return null; + const rows = await cloudDb.query( + `SELECT TOP 1 CONVERT(VARCHAR(10), date, 23) AS d + FROM rpt.pm_stockMonitor + WHERE warehouse = @warehouse AND date IS NOT NULL + AND (@preferredDate IS NULL OR CONVERT(VARCHAR(10), date, 23) <= @preferredDate) + ORDER BY date DESC`, + { warehouse: dbWarehouse, preferredDate: preferredDate || null } + ); + return rows[0]?.d || null; +} + +// 期初倒推:期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货 +// 业务库 pm_stockMonitor 仅保留最新单日快照(无上月末历史),且加工/调拨/盘点/其它出库无数据源, +// 因此期初只能由已知流水倒推,属估算值(前端标注「估」)。 +// 倒推出现负数说明该仓库本期存在未采集到的出库(调拨/加工/其它出库),此时不再展示估算值, +// 避免出现「负库存」这类明显失真的数字。 +function estimateOpeningBalance(closingQty, flow) { + if (closingQty == null) return null; + const purchaseIn = flow?.purchaseIn ?? 0; + const purchaseReturn = flow?.purchaseReturn ?? 0; + const salesOut = flow?.salesOut ?? 0; + const salesReturn = flow?.salesReturn ?? 0; + const value = closingQty - purchaseIn + purchaseReturn + salesOut - salesReturn; + return value >= 0 ? value : null; +} + +router.get('/config', authMiddleware, async (_req, res) => { + try { + res.json({ + success: true, + data: { + columns: COLUMN_HEADERS, + levelLabels: LEVEL_LABELS, + warehouses: LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({ + warehouse: entry.warehouse, + group: entry.group, + routeKey: entry.routeKey, + hasData: Boolean(entry.dbWarehouse), + hasWms: Boolean(entry.wmsCoId), + })), + }, + }); + } catch (error) { + console.error('[LargeStockMonitor] 获取配置失败:', error); + res.status(500).json({ success: false, error: error.message || '获取配置失败' }); + } +}); + +router.get('/overview', authMiddleware, async (req, res) => { + try { + const config = resolveWarehouse(req.query.warehouse); + if (!config) { + return res.status(404).json({ success: false, error: '未知的仓库配置' }); + } + + const requestedDate = String(req.query.date || '').trim() || null; + if (requestedDate && !/^\d{4}-\d{2}-\d{2}$/.test(requestedDate)) { + return res.status(400).json({ success: false, error: 'date 参数格式应为 YYYY-MM-DD' }); + } + + const dbWarehouse = config.dbWarehouse; + const wmsCoId = config.wmsCoId; + const overview = buildPlaceholderRow(); + const baseResponse = { + warehouse: config.warehouse, + group: config.group, + routeKey: config.routeKey, + requestedDate, + latestBizDate: null, + fallbackDate: null, + asOf: null, + period: null, + source: null, + hasData: false, + skuCount: 0, + overview, + }; + + // 1) 快照日期:优先 pm_stockMonitor;无该仓库时退回聚水潭实时库存(无日期概念) + let latestBizDate = dbWarehouse + ? await fetchTargetBizDate(dbWarehouse, requestedDate).catch(() => null) + : null; + const fallbackDate = requestedDate && latestBizDate && latestBizDate !== requestedDate ? latestBizDate : null; + const period = buildPeriodRange(latestBizDate); + // 数据截止时刻:默认快照日次日 01:00(如 2026-09-23 01:00),可用 ?asOf= 覆盖 + const asOf = normalizeAsOf(req.query.asOf) || period?.asOf || fallbackAsOf(); + + // 2) 出入库流水(本月 1 日 ~ 截止时刻);pm_stockMonitor 无该仓库时用当天所在月 + const today = localDateString(new Date()); + const periodForFlow = period + ? { ...period, end: asOf || period.end } + : { start: buildPeriodRange(today)?.start, end: asOf }; + const flow = await fetchFlowSummary({ + wmsCoId, + dbWarehouse: dbWarehouse || config.warehouse.replace(/主仓$/, ''), + periodStart: periodForFlow?.start, + periodEnd: periodForFlow?.end, + }).catch(() => ({ purchaseIn: null, purchaseReturn: null, salesOut: null, salesReturn: null })); + + if (flow.purchaseReturn != null) overview.purchaseReturn = flow.purchaseReturn; + if (flow.purchaseIn != null) overview.purchaseIn = flow.purchaseIn; + if (flow.salesOut != null) overview.salesOut = flow.salesOut; + if (flow.salesReturn != null) overview.salesReturn = flow.salesReturn; + + // 3) 期末 / 分级 + // 期末数量统一取「聚水潭实际库存」rpt.jst_inventoryInfo(按 wms_co_id 单仓统计): + // pm_stockMonitor 的 warehouse 是「归属仓」而非实物所在仓,其 jsStock 会把 + // 已调拨到外仓(如横林二仓)的货仍计在本仓名下,与仓库实物库存对不上 + // (例:崔桥工厂仓 快照 jsStock 115135 vs 实物 92439)。 + // 库龄/周转率/四级库存量只能由 pm_stockMonitor 提供(实时库存表无 created、销量字段)。 + let skuCount = 0; + let source = null; + let closingSource = null; + let hasGrade = false; + let summary = {}; + + if (dbWarehouse && latestBizDate) { + const D = buildDerivedColumns(); + const summaryRows = await cloudDb.query( + `SELECT + COUNT(*) AS skuCount, + SUM(CAST(jsStock AS DECIMAL(18, 2))) AS totalStock, + AVG(${D.turnoverDays}) AS avgTurnover, + MAX(${D.turnoverDays}) AS maxTurnover, + SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel, + SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel, + SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel, + SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS zombieLevel + FROM rpt.pm_stockMonitor m + WHERE m.warehouse = @warehouse + AND CONVERT(VARCHAR(10), m.date, 23) = @bizDate`, + { warehouse: dbWarehouse, bizDate: latestBizDate, asOf } + ); + summary = summaryRows[0] || {}; + const snapSkuCount = Number(summary.skuCount || 0); + if (snapSkuCount > 0) { + hasGrade = true; + overview.turnoverDays = summary.avgTurnover == null ? null : Number(summary.avgTurnover); + overview.warnLevel = Number(summary.warnLevel || 0); + overview.slowLevel = Number(summary.slowLevel || 0); + overview.staleLevel = Number(summary.staleLevel || 0); + overview.zombieLevel = Number(summary.zombieLevel || 0); + } + } + + // 3.2 期末数量:聚水潭实际库存(单仓 wms_co_id);SKU 数同步跟随期末口径 + let closingTakenAt = null; + if (wmsCoId) { + const inv = await fetchInventoryQty(wmsCoId).catch(() => null); + if (inv && inv.qty != null) { + closingSource = 'jst_inventoryInfo'; + overview.closingQty = inv.qty; + skuCount = inv.skuCount || 0; + closingTakenAt = inv.takenAt || null; + } + } + + // 3.3 兜底:拿不到实时库存时退回快照归属库存(并在响应中标记来源) + if (!closingSource && summary.totalStock != null && Number(summary.skuCount || 0) > 0) { + closingSource = 'pm_stockMonitor'; + overview.closingQty = Number(summary.totalStock || 0); + skuCount = Number(summary.skuCount || 0); + } + + source = closingSource || (hasGrade ? 'pm_stockMonitor' : null); + // 四级库存量基于快照归属库存分级,与期末(实际库存)口径不同,供前端提示用 + const gradeBasisStock = Number(summary.totalStock || 0); + + // 4) 期初倒推(估算) + const opening = estimateOpeningBalance( + typeof overview.closingQty === 'number' ? overview.closingQty : null, + flow + ); + if (opening != null) { + overview.periodStart = opening; + overview.openingBalance = opening; + } + + res.json({ + success: true, + data: { + ...baseResponse, + latestBizDate: latestBizDate || null, + fallbackDate, + asOf, + period: periodForFlow, + source, + closingSource, + closingTakenAt, + hasGrade, + gradeBasisStock, + hasData: Boolean(source), + skuCount, + overview, + turnoverDaysAvg: summary.avgTurnover != null ? Number(summary.avgTurnover) : null, + turnoverDaysMax: summary.maxTurnover != null ? Number(summary.maxTurnover) : null, + }, + }); + } catch (error) { + console.error('[LargeStockMonitor] 获取概览失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取概览失败', + }); + } +}); + +router.get('/sku-list', authMiddleware, async (req, res) => { + try { + const config = resolveWarehouse(req.query.warehouse); + if (!config) { + return res.status(404).json({ success: false, error: '未知的仓库配置' }); + } + + const requestedDate = String(req.query.date || '').trim() || null; + if (requestedDate && !/^\d{4}-\d{2}-\d{2}$/.test(requestedDate)) { + return res.status(400).json({ success: false, error: 'date 参数格式应为 YYYY-MM-DD' }); + } + + const dbWarehouse = config.dbWarehouse; + const wmsCoId = config.wmsCoId; + const page = Math.max(parseInt(req.query.page, 10) || 1, 1); + const pageSize = Math.min(Math.max(parseInt(req.query.pageSize, 10) || 20, 1), 200); + const offset = (page - 1) * pageSize; + const level = String(req.query.level || '').trim(); + const keyword = String(req.query.keyword || '').trim(); + + const baseResponse = { + warehouse: config.warehouse, + group: config.group, + requestedDate, + latestBizDate: null, + fallbackDate: null, + asOf: null, + source: null, + page, + pageSize, + total: 0, + items: [], + }; + + const latestBizDate = dbWarehouse + ? await fetchTargetBizDate(dbWarehouse, requestedDate).catch(() => null) + : null; + const asOf = normalizeAsOf(req.query.asOf) || buildPeriodRange(latestBizDate)?.asOf || fallbackAsOf(); + + // pm_stockMonitor 有数据 → 走原有分级明细 + if (dbWarehouse && latestBizDate) { + const D = buildDerivedColumns(); + const filters = ['m.warehouse = @warehouse', "CONVERT(VARCHAR(10), m.date, 23) = @bizDate"]; + const params = { warehouse: dbWarehouse, bizDate: latestBizDate, asOf }; + + if (keyword) { + filters.push('(m.sku LIKE @kw OR m.skuName LIKE @kw)'); + params.kw = `%${keyword}%`; + } + if (['warn', 'slow', 'stale', 'zombie'].includes(level)) { + filters.push(`${D.levelExpr} = @level`); + params.level = level; + } + + const whereClause = filters.join(' AND '); + const totalRows = await cloudDb.query( + `SELECT COUNT(*) AS total FROM rpt.pm_stockMonitor m WHERE ${whereClause}`, + params + ); + const total = Number(totalRows[0]?.total || 0); + + params.pageSize = pageSize; + params.offset = offset; + const items = await cloudDb.query( + `SELECT + m.sku, m.spu, m.skuName, m.supplier, m.purchase, m.category, + m.jsStock, m.threeAvgSale, m.created, + ${D.ageMonths} AS ageMonths, + ${D.monthlyShipment} AS monthlyShipment, + ${D.avgDailyStock} AS avgDailyStock, + ${D.turnoverDays} AS turnoverDays, + ${D.levelExpr} AS level, + m.warehouse + FROM rpt.pm_stockMonitor m + WHERE ${whereClause} + ORDER BY m.jsStock DESC, m.sku ASC + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`, + params + ); + + return res.json({ + success: true, + data: { + ...baseResponse, + latestBizDate, + fallbackDate: requestedDate && latestBizDate !== requestedDate ? latestBizDate : null, + asOf, + source: 'pm_stockMonitor', + total, + items, + }, + }); + } + + // 兜底:聚水潭实时库存明细(无库龄/销量字段,level 与周转率留空) + // 该数据源算不出库龄与周转率,任何分级筛选都不可能命中,直接返回空列表, + // 避免点选「商品僵尸」等时把整仓库存原样列出造成误导。 + if (['warn', 'slow', 'stale', 'zombie'].includes(level)) { + return res.json({ + success: true, + data: { ...baseResponse, asOf, source: 'jst_inventoryInfo', total: 0, items: [] }, + }); + } + + if (!wmsCoId) { + return res.json({ success: true, data: baseResponse }); + } + + const invFilters = ['v.wms_co_id = @wmsCoId', 'ISNULL(v.qty, 0) <> 0']; + const invParams = { wmsCoId }; + if (keyword) { + invFilters.push('(v.sku_id LIKE @kw OR v.name LIKE @kw)'); + invParams.kw = `%${keyword}%`; + } + const invWhere = invFilters.join(' AND '); + + const invTotal = await cloudDb.query( + `SELECT COUNT(*) AS total FROM rpt.jst_inventoryInfo v WHERE ${invWhere}`, + invParams + ); + const total = Number(invTotal[0]?.total || 0); + + invParams.pageSize = pageSize; + invParams.offset = offset; + const items = await cloudDb.query( + `SELECT + v.sku_id AS sku, + v.i_id AS spu, + v.name AS skuName, + v.qty AS jsStock, + CAST(NULL AS DECIMAL(18, 4)) AS ageMonths, + CAST(NULL AS DECIMAL(18, 4)) AS monthlyShipment, + CAST(v.qty AS DECIMAL(18, 4)) AS avgDailyStock, + CAST(NULL AS DECIMAL(18, 4)) AS turnoverDays, + CAST(NULL AS VARCHAR(10)) AS level, + w.name AS warehouse + FROM rpt.jst_inventoryInfo v + LEFT JOIN rpt.jst_warehouseInfo w ON w.wms_co_id = v.wms_co_id + WHERE ${invWhere} + ORDER BY v.qty DESC, v.sku_id ASC + OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`, + invParams + ); + + res.json({ + success: true, + data: { ...baseResponse, asOf, source: 'jst_inventoryInfo', total, items }, + }); + } catch (error) { + console.error('[LargeStockMonitor] 获取 SKU 列表失败:', error); + res.status(error.statusCode || 500).json({ + success: false, + error: error.message || '获取 SKU 列表失败', + }); + } +}); + +module.exports = router; diff --git a/backend/src/config/largeStockMonitorConfig.js b/backend/src/config/largeStockMonitorConfig.js new file mode 100644 index 0000000..4d8de40 --- /dev/null +++ b/backend/src/config/largeStockMonitorConfig.js @@ -0,0 +1,76 @@ +// 大货库存监控配置 - 数据源:C:/Users/15018737611/Desktop/库存监控.xlsx +// 共 26 行,按 Excel 顺序保留:序号 / 二级功能按键 / 仓库全名 +// 业务库 rpt.pm_stockMonitor.warehouse 的命名规则与 Excel 不一致: +// Excel 通常带「主仓」后缀,业务库只保留「主」前那一段。 +// 已在 dbWarehouse 字段手工映射出业务库 warehouse 精确值;无法对应的留 null。 +// 菜单 label 显示 Excel 仓库全名,routeKey 用 slug 化的二级功能按键+序号保证唯一。 + +// wmsCoId 来自 rpt.jst_warehouseInfo(聚水潭仓库主数据,155 行,26 个仓库全部命中), +// 用于从出入库单据表(jst_purchase / jst_purchaseout / jst_salesOutboundInfo 等)取流水数量。 +// dbWarehouse 用于 rpt.pm_stockMonitor;该报表仅覆盖 16 个仓库,其余为 null。 +const RAW_WAREHOUSES = [ + { idx: 1, group: '半成品部件仓', warehouse: '简尚-崔桥工厂半成品包装项目仓主仓', dbWarehouse: '简尚-崔桥工厂半成品包装项目仓', wmsCoId: 15718196 }, + { idx: 2, group: '运营C', warehouse: '简尚-运营C崔桥工厂仓专用仓主仓', dbWarehouse: null, wmsCoId: 15301815 }, + { idx: 3, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂仓主仓', dbWarehouse: '简尚-崔桥工厂仓', wmsCoId: 15038426 }, + { idx: 4, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂京东仓主仓', dbWarehouse: null, wmsCoId: 15045345 }, + { idx: 5, group: '崔桥工厂品牌仓', warehouse: '简尚-崔桥工厂品牌仓主仓', dbWarehouse: '简尚-崔桥工厂品牌仓', wmsCoId: 15870495 }, + { idx: 6, group: '河南洛宁仓', warehouse: '简尚-河南洛宁仓主仓', dbWarehouse: '简尚-河南洛宁仓', wmsCoId: 15539084 }, + { idx: 7, group: '运营D', warehouse: '简尚-运营D专用仓库主仓', dbWarehouse: null, wmsCoId: 15051915 }, + { idx: 8, group: '运营C', warehouse: '简尚-运营C横林二仓专用仓主仓', dbWarehouse: null, wmsCoId: 15301814 }, + { idx: 9, group: '横林二仓', warehouse: '简尚-横林二仓主仓', dbWarehouse: '简尚-横林二仓', wmsCoId: 15033302 }, + { idx: 10, group: '横林三仓', warehouse: '简尚-横林三仓主仓', dbWarehouse: '简尚-横林三仓', wmsCoId: 15043561 }, + { idx: 11, group: '山东品牌仓', warehouse: '简尚-山东品牌仓主仓', dbWarehouse: '简尚-山东品牌仓', wmsCoId: 15182705 }, + { idx: 12, group: '山东青集岗仓', warehouse: '简尚-山东青岗集京东仓主仓', dbWarehouse: null, wmsCoId: 15406664 }, + { idx: 13, group: '山东青集岗仓', warehouse: '简尚-山东青岗集仓主仓', dbWarehouse: '简尚-山东青岗集仓', wmsCoId: 15137523 }, + { idx: 14, group: '运营D', warehouse: '简尚-运营D苏北专用仓主仓', dbWarehouse: null, wmsCoId: 15406876 }, + { idx: 15, group: '运营C', warehouse: '简尚-运营C新苏北专用仓主仓', dbWarehouse: null, wmsCoId: 15301812 }, + { idx: 16, group: '苏北仓', warehouse: '简尚-新苏北仓主仓', dbWarehouse: '简尚-新苏北仓', wmsCoId: 15038347 }, + { idx: 17, group: '分销仓', warehouse: '简尚-分销部苏北专用仓主仓', dbWarehouse: '简尚-分销部苏北专用仓', wmsCoId: 15045325 }, + { idx: 18, group: '运营D', warehouse: '简尚-运营D睢宁专用仓主仓', dbWarehouse: null, wmsCoId: 15051907 }, + { idx: 19, group: '运营C', warehouse: '简尚-运营C睢宁专用仓主仓', dbWarehouse: null, wmsCoId: 15301813 }, + { idx: 20, group: '睢宁仓', warehouse: '简尚-睢宁仓主仓', dbWarehouse: '简尚-睢宁仓', wmsCoId: 15032603 }, + { idx: 21, group: '沭阳金源涌动', warehouse: '简尚-沭阳金源涌动项目仓主仓', dbWarehouse: '简尚-沭阳金源涌动项目仓', wmsCoId: 15043585 }, + { idx: 22, group: '分销仓', warehouse: '简尚-分销部睢宁专用仓主仓', dbWarehouse: '简尚-分销部睢宁专用仓', wmsCoId: 15051960 }, + { idx: 23, group: '外贸仓', warehouse: '简尚-新外贸仓主仓', dbWarehouse: '简尚-新外贸仓', wmsCoId: 15045308 }, + { idx: 24, group: '外贸仓', warehouse: '简尚-济南外贸仓主仓', dbWarehouse: null, wmsCoId: 15051888 }, + { idx: 25, group: '分销仓', warehouse: '简尚-分销部专用仓主仓', dbWarehouse: '简尚-分销部专用仓', wmsCoId: 15045426 }, + { idx: 26, group: '分销仓', warehouse: '简尚-分销部崔桥专用仓主仓', dbWarehouse: '简尚-分销部崔桥专用仓', wmsCoId: 15404402 }, +]; + +function slugify(value) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +const LARGE_STOCK_MONITOR_WAREHOUSES = RAW_WAREHOUSES.map((entry) => ({ + ...entry, + routeKey: `stock-monitor-large-${entry.idx}`, + slug: slugify(entry.warehouse) || `warehouse-${entry.idx}`, + hasData: Boolean(entry.dbWarehouse), +})); + +// 无业务库数据源的列:业务库(JSReportReplica,220 张表)中不存在加工/盘点/调拨/其它出库 +// 任何单据表,这 6 列无真实数据可回填,保留占位符「—」并在前端说明。 +const UNAVAILABLE_COLUMN_KEYS = [ + 'processIn', + 'stocktake', + 'processOut', + 'transferOut', + 'transferIn', + 'otherOut', +]; + +const LARGE_STOCK_MONITOR_PAGE = { + pageKey: 'large_stock_monitor', + path: '/planning/large-stock-monitor/:warehouseSlug', + name: '大货库存监控', + defaultRoles: { admin: true, analyst: true, user: true, guest: false }, +}; + +module.exports = { + LARGE_STOCK_MONITOR_WAREHOUSES, + LARGE_STOCK_MONITOR_PAGE, + UNAVAILABLE_COLUMN_KEYS, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 88daa64..4b0007d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -35,6 +35,7 @@ const promotionModuleRoutes = require('./api/promotionModule'); const productMonitoringRoutes = require('./api/productMonitoring'); const modularStockMonitorRoutes = require('./api/modularStockMonitor'); const mkhBomRoutes = require('./api/mkhBom'); +const largeStockMonitorRoutes = require('./api/largeStockMonitor'); const database = require('./config/database'); const cloudDb = require('./config/cloudDatabase'); const jianSunDb = require('./config/jianSunDatabase'); @@ -654,6 +655,7 @@ async function startServer(options = {}) { cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes); cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes); cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes); + cubejsApp.use('/api/large-stock-monitor', authMiddleware, largeStockMonitorRoutes); cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); diff --git a/backend/src/services/largeStockMonitorFlow.js b/backend/src/services/largeStockMonitorFlow.js new file mode 100644 index 0000000..200cf5f --- /dev/null +++ b/backend/src/services/largeStockMonitorFlow.js @@ -0,0 +1,132 @@ +// 大货库存监控 - 出入库流水取数(本月 1 日 ~ 快照日) +// +// 业务库 JSReportReplica 里与「库存流水」相关的单据表: +// 采购进仓 rpt.jst_purchase (wms_co_id, po_date, status) + rpt.jst_purchaseItem (po_id, inQty) +// 采购退货 rpt.jst_purchaseout (wms_co_id, io_date, status) + rpt.jst_purchaseoutItem (io_id, qty) +// 销售出仓 rpt.jst_salesOutboundInfo(wms_co_id, io_date, status) + rpt.jst_OrderItem (o_id, qty) +// 销售退货 rpt.jst_returnAndRefundInfo(warehouse 名称) + rpt.jst_returnAndRefundItem(as_id, r_qty) +// +// 有效单据状态:只统计 Confirmed / Finished,排除 Cancelled / Delete / WaitConfirm。 +// 业务库中不存在加工、盘点、调拨、其它出库任何单据表,这 6 列无法取数。 +const cloudDb = require('../config/cloudDatabase'); + +// 单据有效状态(已确认/已完成),避免把取消单算进流水 +const PURCHASE_STATUS = "'Confirmed','Finished'"; +const PURCHASEOUT_STATUS = "'Confirmed'"; +const SALESOUT_STATUS = "'Confirmed'"; +const REFUND_STATUS = "'Confirmed'"; + +function toNumber(value) { + if (value == null) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** + * 采购进仓:采购单已入库数量(inQty) + */ +async function fetchPurchaseIn(wmsCoId, periodStart, periodEnd) { + const rows = await cloudDb.query( + `SELECT ISNULL(SUM(CAST(pi.inQty AS DECIMAL(18, 2))), 0) AS v + FROM rpt.jst_purchase p + JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id + WHERE p.wms_co_id = @wmsCoId + AND p.po_date >= @periodStart AND p.po_date < @periodEnd + AND p.status IN (${PURCHASE_STATUS})`, + { wmsCoId, periodStart, periodEnd } + ); + return toNumber(rows[0]?.v); +} + +/** + * 采购退货:采购退货出库单数量 + */ +async function fetchPurchaseReturn(wmsCoId, periodStart, periodEnd) { + const rows = await cloudDb.query( + `SELECT ISNULL(SUM(CAST(oi.qty AS DECIMAL(18, 2))), 0) AS v + FROM rpt.jst_purchaseout o + JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id + WHERE o.wms_co_id = @wmsCoId + AND o.io_date >= @periodStart AND o.io_date < @periodEnd + AND o.status IN (${PURCHASEOUT_STATUS})`, + { wmsCoId, periodStart, periodEnd } + ); + return toNumber(rows[0]?.v); +} + +/** + * 销售出仓:销售出库单关联的订单明细数量 + * 注意:jst_salesOutboundInfo.qty 是表头字段且恒为 0,真实件数在 jst_OrderItem.qty + */ +async function fetchSalesOut(wmsCoId, periodStart, periodEnd) { + const rows = await cloudDb.query( + `SELECT ISNULL(SUM(CAST(oi.qty AS DECIMAL(18, 2))), 0) AS v + FROM rpt.jst_salesOutboundInfo s + JOIN rpt.jst_OrderItem oi ON oi.o_id = s.o_id + WHERE s.wms_co_id = @wmsCoId + AND s.io_date >= @periodStart AND s.io_date < @periodEnd + AND s.status IN (${SALESOUT_STATUS})`, + { wmsCoId, periodStart, periodEnd } + ); + return toNumber(rows[0]?.v); +} + +/** + * 销售退货:退货退款单明细的实退数量(r_qty) + * 该表 wms_co_id 大量为空(9 月 92821 单中 75094 单为空),改用 warehouse 名称匹配: + * 名称形如「简尚-崔桥工厂仓销退仓」/「简尚-分销部睢宁专用仓主仓」,统一去掉「销退仓」「主仓」后比对。 + */ +async function fetchSalesReturn(dbWarehouse, periodStart, periodEnd) { + if (!dbWarehouse) return null; + const rows = await cloudDb.query( + `SELECT ISNULL(SUM(CAST(ri.r_qty AS DECIMAL(18, 2))), 0) AS v + FROM rpt.jst_returnAndRefundInfo r + JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id + WHERE REPLACE(REPLACE(ISNULL(r.warehouse, ''), N'销退仓', N''), N'主仓', N'') = @whName + AND r.created >= @periodStart AND r.created < @periodEnd + AND r.status IN (${REFUND_STATUS})`, + { whName: dbWarehouse, periodStart, periodEnd } + ); + return toNumber(rows[0]?.v); +} + +/** + * 仓库实物库存:聚水潭实时库存表 rpt.jst_inventoryInfo(按 wms_co_id 单仓统计) + * 这是报表「期末数量」的取数口径 —— pm_stockMonitor 的 warehouse 仅是归属仓, + * 其 jsStock 含已调拨到外仓的货,不能代表本仓实物库存。 + */ +async function fetchInventoryQty(wmsCoId) { + if (!wmsCoId) return null; + const rows = await cloudDb.query( + `SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS v, + COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount, + MAX(modified) AS takenAt + FROM rpt.jst_inventoryInfo + WHERE wms_co_id = @wmsCoId`, + { wmsCoId } + ); + return { + qty: toNumber(rows[0]?.v), + skuCount: toNumber(rows[0]?.skuCount) || 0, + takenAt: rows[0]?.takenAt ? new Date(rows[0].takenAt).toISOString() : null, + }; +} + +/** + * 一次性取全部流水;任一子查询失败不影响其它字段(返回 null 由调用方降级为「—」) + */ +async function fetchFlowSummary({ wmsCoId, dbWarehouse, periodStart, periodEnd }) { + const [purchaseIn, purchaseReturn, salesOut, salesReturn] = await Promise.all([ + wmsCoId ? fetchPurchaseIn(wmsCoId, periodStart, periodEnd).catch(() => null) : null, + wmsCoId ? fetchPurchaseReturn(wmsCoId, periodStart, periodEnd).catch(() => null) : null, + wmsCoId ? fetchSalesOut(wmsCoId, periodStart, periodEnd).catch(() => null) : null, + fetchSalesReturn(dbWarehouse, periodStart, periodEnd).catch(() => null), + ]); + + return { purchaseIn, purchaseReturn, salesOut, salesReturn }; +} + +module.exports = { + fetchFlowSummary, + fetchInventoryQty, +}; diff --git a/frontend/src/api/largeStockMonitor.js b/frontend/src/api/largeStockMonitor.js new file mode 100644 index 0000000..084edab --- /dev/null +++ b/frontend/src/api/largeStockMonitor.js @@ -0,0 +1,15 @@ +import request from './request'; + +const BASE = '/api/large-stock-monitor'; + +export function getLargeStockMonitorConfig() { + return request.get(`${BASE}/config`); +} + +export function getLargeStockMonitorOverview(warehouse) { + return request.get(`${BASE}/overview`, { params: { warehouse } }); +} + +export function getLargeStockMonitorSkuList(params = {}) { + return request.get(`${BASE}/sku-list`, { params }); +} diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 63a6520..d99a7b4 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -1,3 +1,14 @@ +import { LARGE_STOCK_MONITOR_WAREHOUSES } from '../constants/largeStockMonitor.js'; + +const buildLargeStockMonitorChildren = () => LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({ + key: `stock-monitor-large-${entry.idx}`, + label: entry.warehouse, + icon: 'appstore', + routeKey: `stock-monitor-large-${entry.idx}`, + visible: true, + order: entry.idx * 10, +})); + export const DEFAULT_SIDEBAR_CONFIG = { collapsedByDefault: false, darkModeByDefault: false, @@ -26,6 +37,10 @@ export const DEFAULT_SIDEBAR_CONFIG = { { key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 }, ], }, + { + key: 'stock-monitor-large', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: false, + children: buildLargeStockMonitorChildren(), + }, { key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true, children: [ diff --git a/frontend/src/constants/largeStockMonitor.js b/frontend/src/constants/largeStockMonitor.js new file mode 100644 index 0000000..38392f7 --- /dev/null +++ b/frontend/src/constants/largeStockMonitor.js @@ -0,0 +1,165 @@ +// 大货库存监控配置(前端镜像) +// 与 backend/src/config/largeStockMonitorConfig.js 同步 +// 修改需双端同步;运行期也可通过 /api/large-stock-monitor/config 获取 + +const RAW_WAREHOUSES = [ + { idx: 1, group: '半成品部件仓', warehouse: '简尚-崔桥工厂半成品包装项目仓主仓' }, + { idx: 2, group: '运营C', warehouse: '简尚-运营C崔桥工厂仓专用仓主仓' }, + { idx: 3, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂仓主仓' }, + { idx: 4, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂京东仓主仓' }, + { idx: 5, group: '崔桥工厂品牌仓', warehouse: '简尚-崔桥工厂品牌仓主仓' }, + { idx: 6, group: '河南洛宁仓', warehouse: '简尚-河南洛宁仓主仓' }, + { idx: 7, group: '运营D', warehouse: '简尚-运营D专用仓库主仓' }, + { idx: 8, group: '运营C', warehouse: '简尚-运营C横林二仓专用仓主仓' }, + { idx: 9, group: '横林二仓', warehouse: '简尚-横林二仓主仓' }, + { idx: 10, group: '横林三仓', warehouse: '简尚-横林三仓主仓' }, + { idx: 11, group: '山东品牌仓', warehouse: '简尚-山东品牌仓主仓' }, + { idx: 12, group: '山东青集岗仓', warehouse: '简尚-山东青岗集京东仓主仓' }, + { idx: 13, group: '山东青集岗仓', warehouse: '简尚-山东青岗集仓主仓' }, + { idx: 14, group: '运营D', warehouse: '简尚-运营D苏北专用仓主仓' }, + { idx: 15, group: '运营C', warehouse: '简尚-运营C新苏北专用仓主仓' }, + { idx: 16, group: '苏北仓', warehouse: '简尚-新苏北仓主仓' }, + { idx: 17, group: '分销仓', warehouse: '简尚-分销部苏北专用仓主仓' }, + { idx: 18, group: '运营D', warehouse: '简尚-运营D睢宁专用仓主仓' }, + { idx: 19, group: '运营C', warehouse: '简尚-运营C睢宁专用仓主仓' }, + { idx: 20, group: '睢宁仓', warehouse: '简尚-睢宁仓主仓' }, + { idx: 21, group: '沭阳金源涌动', warehouse: '简尚-沭阳金源涌动项目仓主仓' }, + { idx: 22, group: '分销仓', warehouse: '简尚-分销部睢宁专用仓主仓' }, + { idx: 23, group: '外贸仓', warehouse: '简尚-新外贸仓主仓' }, + { idx: 24, group: '外贸仓', warehouse: '简尚-济南外贸仓主仓' }, + { idx: 25, group: '分销仓', warehouse: '简尚-分销部专用仓主仓' }, + { idx: 26, group: '分销仓', warehouse: '简尚-分销部崔桥专用仓主仓' }, +]; + +function slugify(value) { + return String(value || '') + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export const LARGE_STOCK_MONITOR_WAREHOUSES = RAW_WAREHOUSES.map((entry) => ({ + ...entry, + routeKey: `stock-monitor-large-${entry.idx}`, + slug: slugify(entry.warehouse) || `warehouse-${entry.idx}`, +})); + +// 18 列表头(按 Excel 第 1 行 D..U 列) +// available=false:业务库(220 张表)中不存在该单据类型,无数据源,显示「—」 +// estimated=true:由已有字段推导(非原始字段),前端标「估」并在悬浮说明中写明推导式 +export const OVERVIEW_COLUMNS = [ + { + key: 'periodStart', + label: '期初数据', + available: true, + estimated: true, + tip: '业务库仅保留最新单日库存快照,无上月末历史。此值由「期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货」倒推,未包含加工/调拨/盘点/其它出库。倒推为负时显示「—」。', + }, + { key: 'processIn', label: '加工进仓', available: false, tip: '业务库无加工单据表,无数据源' }, + { + key: 'purchaseReturn', + label: '采购退货', + available: true, + tip: '本月采购退货出库单数量:rpt.jst_purchaseout + jst_purchaseoutItem(状态 Confirmed)', + }, + { + key: 'purchaseIn', + label: '采购进仓', + available: true, + tip: '本月采购单已入库数量:rpt.jst_purchase + jst_purchaseItem 的 inQty(状态 Confirmed/Finished)', + }, + { + key: 'salesOut', + label: '销售出仓', + available: true, + tip: '本月销售出库单件数:rpt.jst_salesOutboundInfo(状态 Confirmed)关联 jst_OrderItem.qty', + }, + { key: 'stocktake', label: '盘点', available: false, tip: '业务库无盘点单据表,无数据源' }, + { key: 'processOut', label: '加工出仓', available: false, tip: '业务库无加工单据表,无数据源' }, + { key: 'transferOut', label: '调拨出', available: false, tip: '业务库无调拨单据表,无数据源' }, + { + key: 'salesReturn', + label: '销售退货', + available: true, + tip: '本月退货退款单实退数量:rpt.jst_returnAndRefundInfo + jst_returnAndRefundItem 的 r_qty(状态 Confirmed,按仓库名称匹配)', + }, + { key: 'transferIn', label: '调拨入', available: false, tip: '业务库无调拨单据表,无数据源' }, + { + key: 'openingBalance', + label: '期初', + available: true, + estimated: true, + tip: '同期初数据(倒推估算值)', + }, + { key: 'otherOut', label: '其它出库', available: false, tip: '业务库无其它出库单据表,无数据源' }, + { + key: 'closingQty', + label: '期末数量', + available: true, + tip: '聚水潭实际库存 rpt.jst_inventoryInfo(按仓库 wms_co_id 单仓实物口径,实时值)。业务库无历史时点库存快照,此值随每次刷新滚动到最新;与某日「期末结存」的差额即该日之后的出入库与仓间调拨', + }, + { key: 'turnoverDays', label: '库存周转天数', available: true, tip: '月度发货总件数 / 月度日均库存件数 × 100%(按业务库口径,数值实为周转率 %)' }, + { key: 'warnLevel', label: '滞销预警级库存量', available: true, tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计(按库存快照分级,口径为归属库存,与期末实际库存不同)' }, + { key: 'slowLevel', label: '商品慢流级库存量', available: true, tip: '库龄 < 3 个月 且 周转率 ≥ 200%;或 库龄 > 3 个月 且 30% ≤ 周转率 ≤ 99%' }, + { key: 'staleLevel', label: '商品呆滞级库存量', available: true, tip: '库龄 3~12 个月 且 周转率 ≥ 100%;或 库龄 > 12 个月 且 100% ≤ 周转率 ≤ 199%' }, + { key: 'zombieLevel', label: '商品僵尸级库存量', available: true, tip: '库龄 > 12 个月 且 周转率 ≥ 200%' }, +]; + +// 分级标签(与后端 levelExpr 返回值一致) +export const LEVEL_LABEL = { + warn: '滞销预警', + slow: '商品慢流', + stale: '商品呆滞', + zombie: '商品僵尸', +}; + +export const LEVEL_FILTER_OPTIONS = [ + { label: '全部级别', value: '' }, + { label: '滞销预警', value: 'warn' }, + { label: '商品慢流', value: 'slow' }, + { label: '商品呆滞', value: 'stale' }, + { label: '商品僵尸', value: 'zombie' }, +]; + +export const LEVEL_COLOR = { + warn: 'gold', + slow: 'orange', + stale: 'volcano', + zombie: 'magenta', +}; + +export function findWarehouseBySlug(slug) { + return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.slug === slug) || null; +} + +export function findWarehouseByName(name) { + return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.warehouse === name) || null; +} + +export const PAGE_KEY = 'large_stock_monitor'; +export const PAGE_PATH = '/planning/large-stock-monitor/:warehouseSlug'; +export const PAGE_NAME = '大货库存监控'; + +// 顶部"算式说明"提示 +export const FORMULA_DESCRIPTION = [ + '库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%(按业务库约定列名沿用「周转天数」,数值实际为周转率,单位 %)', + '月度发货总件数 ≈ threeAvgSale × 30 (业务库仅有最近 3 天日均销量)', + '月度日均库存件数 ≈ jsStock (业务库仅保留最近一天库存快照,作为日均近似)', + '数据截止时刻:库存快照日次日的凌晨 01:00(业务库每天凌晨 1 点跑批,快照标注为前一日 T-1)', + '库龄(月) = (数据截止时刻 - SKU 入库时间) / 30', + '分级口径:', + '· 滞销预警:库龄 < 3 个月 且 30% ≤ 周转天数 ≤ 199%', + '· 商品慢流:库龄 < 3 个月 且 周转天数 ≥ 200%;或 库龄 > 3 个月 且 30% ≤ 周转天数 ≤ 99%', + '· 商品呆滞:库龄 3~12 个月 且 周转天数 ≥ 100%;或 库龄 > 12 个月 且 100% ≤ 周转天数 ≤ 199%', + '· 商品僵尸:库龄 > 12 个月 且 周转天数 ≥ 200%', + '流水类字段(本期 = 快照所在自然月 1 日 00:00 ~ 数据截止时刻,左闭右开):', + '· 采购进仓:jst_purchase + jst_purchaseItem.inQty(Confirmed/Finished)', + '· 采购退货:jst_purchaseout + jst_purchaseoutItem.qty(Confirmed)', + '· 销售出仓:jst_salesOutboundInfo 关联 jst_OrderItem.qty(Confirmed)', + '· 销售退货:jst_returnAndRefundInfo + jst_returnAndRefundItem.r_qty(Confirmed,按仓库名匹配)', + '· 期初数据:业务库无上月末快照,由「期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货」倒推(标「估」,倒推为负时显示「—」)', + '· 加工进仓 / 加工出仓 / 盘点 / 调拨出 / 调拨入 / 其它出库:业务库无对应单据表,无数据源', + '期末数量:聚水潭实际库存 jst_inventoryInfo(按 wms_co_id 单仓实物口径)', + '注意:pm_stockMonitor 的 warehouse 是「归属仓」,jsStock 含已调拨到外仓的货,不能代表本仓实物库存', + '四级库存量:只能由库存快照分级得出(实时库存表无入库时间与销量字段),口径为归属库存,与期末实际库存不同', +]; \ No newline at end of file diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js index 4545a75..6c12aef 100644 --- a/frontend/src/constants/sidebarPages.js +++ b/frontend/src/constants/sidebarPages.js @@ -4,6 +4,7 @@ const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: export const SIDEBAR_PAGES = [ { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, + { pageKey: 'large_stock_monitor', path: '/planning/large-stock-monitor/:warehouseSlug', name: '大货库存监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, ]; export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index 9347efe..8d60f31 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -36,6 +36,7 @@ import { markDesktopEmbedSession, requestDesktopSessionRenewal, } from '../utils/desktopEmbedSession'; +import { LARGE_STOCK_MONITOR_WAREHOUSES } from '../constants/largeStockMonitor.js'; const AUTH_USER_STORAGE_KEY = 'auth_user'; const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; @@ -97,6 +98,15 @@ const SIDEBAR_ROUTE_MAP = { 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, }; +const LARGE_STOCK_MONITOR_ROUTE_ENTRIES = LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => [ + `stock-monitor-large-${entry.idx}`, + { path: `/planning/large-stock-monitor/${entry.slug}`, matchMode: 'prefix' }, +]); + +LARGE_STOCK_MONITOR_ROUTE_ENTRIES.forEach(([key, value]) => { + SIDEBAR_ROUTE_MAP[key] = value; +}); + const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { if (entry.requiresAdmin && !canUseSettings) return []; if (entry.type === 'section') return [{ ...entry }]; diff --git a/frontend/src/pages/LargeStockMonitor/index.jsx b/frontend/src/pages/LargeStockMonitor/index.jsx new file mode 100644 index 0000000..171601a --- /dev/null +++ b/frontend/src/pages/LargeStockMonitor/index.jsx @@ -0,0 +1,502 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Alert, + Button, + Input, + Popover, + Segmented, + Skeleton, + Table, + Tag, + Tooltip, +} from 'antd'; +import { + InfoCircleOutlined, + QuestionCircleOutlined, + ReloadOutlined, + SearchOutlined, + WarningOutlined, +} from '@ant-design/icons'; +import { SycmBusinessPanel, SycmPage } from '../../components/Common'; +import { + findWarehouseBySlug, + FORMULA_DESCRIPTION, + LARGE_STOCK_MONITOR_WAREHOUSES, + LEVEL_COLOR, + LEVEL_FILTER_OPTIONS, + LEVEL_LABEL, + OVERVIEW_COLUMNS, +} from '../../constants/largeStockMonitor'; +import { + getLargeStockMonitorOverview, + getLargeStockMonitorSkuList, +} from '../../api/largeStockMonitor'; +import './style.css'; + +function formatNumber(value) { + if (value === null || value === undefined || Number.isNaN(Number(value))) return value; + const n = Number(value); + if (!Number.isFinite(n)) return value; + return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 }); +} + +function formatPercent(value) { + if (value === null || value === undefined || Number.isNaN(Number(value))) return value; + const n = Number(value); + if (!Number.isFinite(n)) return value; + return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })}%`; +} + +function formatMonths(value) { + if (value === null || value === undefined || Number.isNaN(Number(value))) return value; + const n = Number(value); + if (!Number.isFinite(n)) return value; + return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 月`; +} + +function formatStock(value) { + if (value === null || value === undefined || Number.isNaN(Number(value))) return value; + const n = Number(value); + if (!Number.isFinite(n)) return value; + return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 }); +} + +const OVERVIEW_RENDERERS = { + periodStart: formatStock, + openingBalance: formatStock, + purchaseReturn: formatStock, + purchaseIn: formatStock, + salesOut: formatStock, + salesReturn: formatStock, + closingQty: formatStock, + turnoverDays: formatPercent, + warnLevel: formatStock, + slowLevel: formatStock, + staleLevel: formatStock, + zombieLevel: formatStock, +}; + +const FormulaHelp = () => ( +
+
计算口径
+
    + {FORMULA_DESCRIPTION.map((line) => ( +
  • {line}
  • + ))} +
+
+); + +const LargeStockMonitor = () => { + const { warehouseSlug } = useParams(); + const navigate = useNavigate(); + + const warehouse = useMemo(() => { + if (!warehouseSlug) return null; + return findWarehouseBySlug(warehouseSlug) || LARGE_STOCK_MONITOR_WAREHOUSES[0] || null; + }, [warehouseSlug]); + + const [overview, setOverview] = useState(null); + const [overviewLoading, setOverviewLoading] = useState(true); + const [overviewError, setOverviewError] = useState(null); + + const [skuListState, setSkuListState] = useState({ + items: [], + total: 0, + page: 1, + pageSize: 20, + loading: false, + }); + const [filterLevel, setFilterLevel] = useState(''); + const [keyword, setKeyword] = useState(''); + + useEffect(() => { + if (!warehouse) { + setOverview(null); + setOverviewLoading(false); + return; + } + let cancelled = false; + setOverviewLoading(true); + setOverviewError(null); + getLargeStockMonitorOverview(warehouse.warehouse) + .then((res) => { + if (cancelled) return; + setOverview(res?.data || null); + }) + .catch((error) => { + if (cancelled) return; + setOverviewError(error?.message || '获取概览失败'); + setOverview(null); + }) + .finally(() => { + if (cancelled) return; + setOverviewLoading(false); + }); + return () => { + cancelled = true; + }; + }, [warehouse]); + + const reloadSkuList = (nextPage, nextPageSize, nextLevel, nextKeyword) => { + if (!warehouse) return; + setSkuListState((prev) => ({ ...prev, loading: true })); + getLargeStockMonitorSkuList({ + warehouse: warehouse.warehouse, + page: nextPage, + pageSize: nextPageSize, + level: nextLevel, + keyword: nextKeyword, + }) + .then((res) => { + setSkuListState({ + items: res?.data?.items || [], + total: res?.data?.total || 0, + page: nextPage, + pageSize: nextPageSize, + loading: false, + }); + }) + .catch(() => { + setSkuListState((prev) => ({ ...prev, loading: false })); + }); + }; + + useEffect(() => { + if (!warehouse) return; + reloadSkuList(1, skuListState.pageSize, filterLevel, keyword.trim()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [warehouse, filterLevel]); + + const handleTableChange = (pagination) => { + const nextPage = pagination.current || 1; + const nextPageSize = pagination.pageSize || 20; + reloadSkuList(nextPage, nextPageSize, filterLevel, keyword.trim()); + }; + + const handleSearch = () => { + reloadSkuList(1, skuListState.pageSize, filterLevel, keyword.trim()); + }; + + const overviewCells = useMemo(() => { + const map = overview?.overview || null; + return OVERVIEW_COLUMNS.map((column) => { + const value = map ? map[column.key] : '—'; + const rendered = column.available && value !== '—' && OVERVIEW_RENDERERS[column.key] + ? OVERVIEW_RENDERERS[column.key](value) + : value; + return { ...column, value, rendered }; + }); + }, [overview]); + + const skuColumns = useMemo(() => ([ + { + title: 'SKU', + dataIndex: 'sku', + key: 'sku', + width: 130, + fixed: 'left', + render: (value, record) => ( +
+ {value || '-'} + {record.spu ? SPU {record.spu} : null} +
+ ), + }, + { + title: '商品名称', + dataIndex: 'skuName', + key: 'skuName', + width: 260, + render: (value) => {value || '-'}, + }, + { + title: '分类', + dataIndex: 'category', + key: 'category', + width: 120, + render: (value) => value || '-', + }, + { + title: '供应商', + dataIndex: 'supplier', + key: 'supplier', + width: 140, + render: (value) => value || '-', + }, + { + title: '当前库存', + dataIndex: 'jsStock', + key: 'jsStock', + width: 100, + align: 'right', + render: formatStock, + }, + { + title: () => ( + + 库龄 + + + + + ), + dataIndex: 'ageMonths', + key: 'ageMonths', + width: 100, + align: 'right', + render: formatMonths, + }, + { + title: () => ( + + 月度发货 + + + + + ), + dataIndex: 'monthlyShipment', + key: 'monthlyShipment', + width: 110, + align: 'right', + render: formatNumber, + }, + { + title: () => ( + + 日均库存 + + + + + ), + dataIndex: 'avgDailyStock', + key: 'avgDailyStock', + width: 110, + align: 'right', + render: formatStock, + }, + { + title: () => ( + + 库存周转天数 + + + + + ), + dataIndex: 'turnoverDays', + key: 'turnoverDays', + width: 130, + align: 'right', + render: formatPercent, + }, + { + title: '级别', + dataIndex: 'level', + key: 'level', + width: 130, + fixed: 'right', + render: (value) => { + if (!value) return —; + return {LEVEL_LABEL[value] || value}; + }, + }, + ]), []); + + const siblingOptions = useMemo(() => LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({ + label: `${entry.group} · ${entry.warehouse.replace(/^简尚-/, '')}`, + value: entry.slug, + idx: entry.idx, + group: entry.group, + })), []); + + const fallbackNotice = warehouse && overview && !overview.hasData; + + return ( + + +
+
+

+ 大货库存监控 · {warehouse ? warehouse.warehouse : '请选择仓库'} + } + title="计算口径" + trigger="hover" + placement="rightTop" + > + + +

+

+ 二级分类:{warehouse ? warehouse.group : '-'} + 分布时间:{overview?.asOf || overview?.latestBizDate || '—'} + {overview?.latestBizDate ? 库存快照日:{overview.latestBizDate} : null} + {overview?.closingTakenAt ? ( + 库存取数时间:{new Date(overview.closingTakenAt).toLocaleString('zh-CN', { hour12: false })}(实时) + ) : null} + {overview?.period ? ( + + 流水区间:{overview.period.start} ~ {overview.period.end}(左闭右开) + + ) : null} + SKU 数:{overview?.skuCount ?? '—'} + 平均周转率:{overview?.turnoverDaysAvg != null ? formatPercent(overview.turnoverDaysAvg) : '—'} + 最大周转率:{overview?.turnoverDaysMax != null ? formatPercent(overview.turnoverDaysMax) : '—'} +

+
+
+ { + if (value) { + navigate(`/planning/large-stock-monitor/${value}`); + } + }} + options={siblingOptions.slice(0, 6)} + className="large-stock-monitor-quick-switch" + /> + +
+
+ + {overviewError ? ( + + ) : null} + + {fallbackNotice ? ( + } + message="业务库暂未同步该仓库的库存快照,周转率与四级库存量无数据源,显示「—」" + style={{ marginBottom: 12 }} + /> + ) : null} + + {overview && overview.hasGrade === false ? ( + + ) : null} + + {overview?.hasGrade && overview?.gradeBasisStock ? ( + } + message={`期末数量按聚水潭实际库存统计;四级库存量由库存快照分级得出,快照为「归属仓」口径(含已调拨到外仓的货,合计 ${formatStock(overview.gradeBasisStock)}),两者口径不同,不能直接相加核对`} + style={{ marginBottom: 12 }} + /> + ) : null} + + {overview?.requestedDate && overview?.fallbackDate ? ( + + ) : null} + + {overview?.requestedDate && !overview?.fallbackDate && !overview?.hasData ? ( + + ) : null} + +
+ {overviewLoading ? ( + + ) : ( +
+ {overviewCells.map((cell) => ( +
+ + {cell.label} + {cell.estimated ? 估 : null} + {cell.tip ? ( + + + + ) : null} + + {cell.rendered} +
+ ))} +
+ )} +
+
+ + +
+ + 明细按库存快照(归属仓口径)分级,用于识别滞销/慢流/呆滞/僵尸 SKU + + + setKeyword(event.target.value)} + onSearch={handleSearch} + enterButton={<> 查询} + style={{ maxWidth: 320 }} + /> +
+ +
`${record.sku}-${record.warehouse}`} + columns={skuColumns} + dataSource={skuListState.items} + loading={skuListState.loading} + scroll={{ x: 1280 }} + pagination={{ + current: skuListState.page, + pageSize: skuListState.pageSize, + total: skuListState.total, + showSizeChanger: true, + showTotal: (total) => `共 ${total} 条`, + pageSizeOptions: ['20', '50', '100'], + }} + onChange={handleTableChange} + locale={{ emptyText: '该仓库暂无 SKU 库存快照' }} + /> + + + ); +}; + +export default LargeStockMonitor; \ No newline at end of file diff --git a/frontend/src/pages/LargeStockMonitor/style.css b/frontend/src/pages/LargeStockMonitor/style.css new file mode 100644 index 0000000..535141b --- /dev/null +++ b/frontend/src/pages/LargeStockMonitor/style.css @@ -0,0 +1,149 @@ +.large-stock-monitor-page { + display: flex; + flex-direction: column; + gap: var(--sycm-space-md); + padding: var(--sycm-space-md); +} + +.large-stock-monitor-header__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.large-stock-monitor-header__top h2 { + margin: 0 0 4px; + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); +} + +.large-stock-monitor-header__meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 0; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.large-stock-monitor-header__actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.large-stock-monitor-quick-switch { + max-width: 480px; +} + +.large-stock-monitor-overview { + margin-top: 12px; +} + +.large-stock-monitor-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.large-stock-monitor-grid__cell { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border-radius: var(--sycm-radius-sm); + background: var(--sycm-bg-subtle); + border: 1px solid transparent; +} + +.large-stock-monitor-grid__cell.is-available { + background: var(--sycm-bg-elevated, #fff); + border-color: var(--sycm-color-primary-border); +} + +.large-stock-monitor-grid__cell.is-placeholder { + color: var(--sycm-text-tertiary); +} + +.large-stock-monitor-grid__label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--sycm-font-size-caption); + font-weight: var(--sycm-font-weight-medium); + color: var(--sycm-text-secondary); +} + +/* 估算值标记:由已有字段推导,非业务库原始字段 */ +.large-stock-monitor-grid__est { + padding: 0 4px; + border-radius: 3px; + background: var(--sycm-color-warning-bg, #fffbe6); + border: 1px solid var(--sycm-color-warning-border, #ffe58f); + color: var(--sycm-color-warning-text, #d46b08); + font-size: var(--sycm-font-size-micro); + font-style: normal; + line-height: 16px; +} + +.large-stock-monitor-grid__help { + color: var(--sycm-text-tertiary); + cursor: help; + font-size: var(--sycm-font-size-caption); +} + +.large-stock-monitor-grid__value { + font-size: var(--sycm-font-size-title); + font-weight: var(--sycm-font-weight-semibold); + color: var(--sycm-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.large-stock-monitor-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.large-stock-monitor-toolbar__note { + font-size: 12px; + color: #8c8c8c; + line-height: 1.5; +} + +.large-stock-monitor-data-table .large-stock-monitor-sku-cell { + display: flex; + flex-direction: column; + line-height: 1.3; +} + +.large-stock-monitor-data-table .large-stock-monitor-sku-cell__meta { + color: var(--sycm-text-tertiary); + font-size: var(--sycm-font-size-micro); +} + +.large-stock-monitor-formula { + max-width: 480px; + font-size: var(--sycm-font-size-caption); + color: var(--sycm-text-secondary); +} + +.large-stock-monitor-formula__title { + font-weight: var(--sycm-font-weight-semibold); + color: var(--sycm-text-primary); + margin-bottom: 8px; +} + +.large-stock-monitor-formula ul { + margin: 0; + padding-left: 18px; + line-height: 1.7; +} diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index 0c64a0c..78cd981 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -19,6 +19,7 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); const MkhBomData = lazy(() => import('../pages/MkhBomData')); +const LargeStockMonitor = lazy(() => import('../pages/LargeStockMonitor')); const lazyLoadingElement = ; @@ -131,6 +132,10 @@ const router = createBrowserRouter([ path: 'planning/dev-style-preview', element: renderLazyComponent(DevelopmentStylePreview), }, + { + path: 'planning/large-stock-monitor/:warehouseSlug', + element: renderLazyComponent(LargeStockMonitor), + }, { path: 'settings', element: renderLazyComponent(Settings), From 66e0ddf709f7d06843bc878cd5abaf35bbde8389 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 23 Sep 2026 15:42:46 +0800 Subject: [PATCH 41/70] =?UTF-8?q?fix:=20=E7=94=A8=E7=AE=B1=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E8=A1=A5=E9=BD=90=E6=BB=9E=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E9=85=8D=E4=BB=B6=E5=BA=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/modularStockMonitorService.js | 73 +++++++++++++++---- .../src/pages/ModularStockMonitor/index.jsx | 7 +- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js index 1718467..affb51f 100644 --- a/backend/src/services/modularStockMonitorService.js +++ b/backend/src/services/modularStockMonitorService.js @@ -13,6 +13,8 @@ // 目标仓库共 794 个 SKU // 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库 // 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得) +// 该表为 0/缺行而箱及仓位库存已有正库存时,用同仓 rpt.jst_packInventoryInfo 合计补齐; +// 只补 0,不覆盖 inventoryInfo 中的正库存,避免箱表未覆盖散货时低估库存。 // 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU: // fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下) // jsUseDays 库存可用天数 @@ -77,13 +79,39 @@ const SORTABLE_FIELDS = Object.freeze({ category: 's.category', realtimeStock: 'realtimeStock', avgShip15d: 'm.fifteenAvgSale', - stockDays: 'm.jsUseDays', + stockDays: 'stockDays', warningLevel: 'warningOrder', mkhStatus: 'mkhStatus', location: 'location', warehouse: 's.other_3', }); +// 实时库存优先使用库存表;库存表没有正库存时,用同仓「箱及仓位库存」补齐。 +// 聚水潭上游可能已经生成箱/仓位库存,但 jst_inventoryInfo 仍停留在旧值。 +// 只补 0/缺行,避免箱表只记录部分装箱库存时压低库存表中的有效库存。 +const buildRealtimeStockSql = () => ( + `CASE + WHEN inv.qty > 0 THEN inv.qty + WHEN ISNULL(ploc.packQty, 0) > 0 THEN ploc.packQty + WHEN inv.qty IS NOT NULL THEN inv.qty + ELSE ISNULL(m.jsStock, 0) + END` +); + +// 箱表兜底生效时,监控快照的 jsUseDays 仍基于旧库存,需要同步重算。 +// 无日销时返回 NULL,不能因为旧快照为 0 天而把已有库存误报为低库存。 +const buildStockDaysSql = () => ( + `CASE + WHEN ISNULL(inv.qty, 0) <= 0 AND ISNULL(ploc.packQty, 0) > 0 THEN + CASE + WHEN ISNULL(m.fifteenAvgSale, 0) > 0 + THEN CAST(ploc.packQty / NULLIF(m.fifteenAvgSale, 0) AS DECIMAL(18, 2)) + ELSE NULL + END + ELSE m.jsUseDays + END` +); + /** * 「已配置MKH」判定表达式(2026-09-17 晚修正口径): * 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护 @@ -107,8 +135,9 @@ const buildConfiguredExistsSql = () => ( const buildWarningOrderSql = () => ( `CASE WHEN cfg.configured = 0 THEN 2 - 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 + WHEN ${buildRealtimeStockSql()} <= 0 THEN 0 + WHEN ${buildStockDaysSql()} IS NOT NULL + AND ${buildStockDaysSql()} <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1 ELSE 2 END` ); @@ -171,15 +200,29 @@ OUTER APPLY ( 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 --- 仓位:主货位(真货位),见 buildLocationSql() 说明 -OUTER APPLY ( - SELECT TOP 1 NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin - FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK) - WHERE pk.sku_id = s.sku_id - AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL - AND ISNULL(pk.qty, 0) > 0 - ORDER BY pk.qty DESC -) ploc +-- 仓位及箱库存:先按 SKU/仓位一次性汇总,再关联目标仓,避免对 794 个 SKU +-- 分别扫描无独立索引的上游视图。rn=1 是库存最大的主货位,packQty 是该 SKU 全仓合计。 +LEFT JOIN ( + SELECT ranked.sku_id, ranked.wms_co_id, ranked.bin, ranked.packQty + FROM ( + SELECT + pk.sku_id, + pk.wms_co_id, + NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin, + SUM(ISNULL(pk.qty, 0)) AS binQty, + SUM(SUM(ISNULL(pk.qty, 0))) OVER (PARTITION BY pk.sku_id, pk.wms_co_id) AS packQty, + ROW_NUMBER() OVER ( + PARTITION BY pk.sku_id, pk.wms_co_id + ORDER BY SUM(ISNULL(pk.qty, 0)) DESC, NULLIF(LTRIM(RTRIM(pk.bin)), N'') ASC + ) AS rn + FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK) + WHERE pk.wms_co_id = @wmsCoId + AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL + GROUP BY pk.sku_id, pk.wms_co_id, NULLIF(LTRIM(RTRIM(pk.bin)), N'') + HAVING SUM(ISNULL(pk.qty, 0)) > 0 + ) ranked + WHERE ranked.rn = 1 +) ploc ON ploc.sku_id = s.sku_id AND ploc.wms_co_id = @wmsCoId -- 模块化配置状态:见文件头部「已配置MKH」口径说明 OUTER APPLY ( SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured @@ -362,13 +405,13 @@ const buildProjection = () => ` s.sku_id AS skuId, s.name AS productName, s.category AS category, - COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock, + ${buildRealtimeStockSql()} AS realtimeStock, ISNULL(ptr.transit, 0) AS purchaseTransit, ISNULL(pin.inQty, 0) AS inQtyToday, ISNULL(m.nearSecondSale, 0) AS outQtyToday, ${buildLocationSql()} AS location, m.fifteenAvgSale AS avgShip15d, - m.jsUseDays AS stockDays, + ${buildStockDaysSql()} AS stockDays, m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, s.other_3 AS warehouse, @@ -445,7 +488,7 @@ async function buildSummary(whereSql, params) { `SELECT COUNT_BIG(*) AS skuCount, SUM(CASE WHEN ${buildWarningOrderSql()} = 0 THEN 1 ELSE 0 END) AS outOfStockCount, - CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock, + CAST(ISNULL(SUM(${buildRealtimeStockSql()}), 0) AS BIGINT) AS totalStock, CAST(ISNULL(SUM(ISNULL(ptr.transit, 0)), 0) AS BIGINT) AS totalPurchaseTransit, SUM(CASE WHEN ${buildWarningOrderSql()} = 1 THEN 1 ELSE 0 END) AS lowStockDaysCount, SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount, diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx index ec08b52..dd3c9a2 100644 --- a/frontend/src/pages/ModularStockMonitor/index.jsx +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -271,7 +271,10 @@ const AccessoryStockPanel = () => { : int(value)), }, { - title: '实时库存', + title: , dataIndex: 'realtimeStock', width: 96, align: 'right', @@ -281,7 +284,7 @@ const AccessoryStockPanel = () => { { title: , dataIndex: 'purchaseTransit', width: 120, From 592c52f891dabca9be94f13d088af5f1c11d11b9 Mon Sep 17 00:00:00 2001 From: lunanting Date: Wed, 23 Sep 2026 15:55:36 +0800 Subject: [PATCH 42/70] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E5=A4=A7?= =?UTF-8?q?=E8=B4=A7=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8826=20=E4=BB=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 应用户要求下线该功能组,侧边栏不再显示「大货库存监控」及 26 个仓库二级菜单。 移除内容: - 前端页面 LargeStockMonitor、18 列定义、API 封装与样式 - 路由 /planning/large-stock-monitor/:warehouseSlug 与权限点 large_stock_monitor - 侧边栏菜单组 stock-monitor-large 与 SIDEBAR_ROUTE_MAP 26 条映射 - 后端 /api/large-stock-monitor 路由挂载、largeStockMonitor.js、 largeStockMonitorConfig.js、largeStockMonitorFlow.js 及端到端校验脚本 验证: - 全仓无 largeStockMonitor 残留引用;后端 346 用例 345 通过 (1 项失败为 resilientSecretStore.test.js,Windows 权限语义基线既有失败) - 前端 lint + 测试 + 构建通过,产物中 LargeStockMonitor chunk 已消失 - 运行时验证:/api/large-stock-monitor/* 返回 404,页面与登录不受影响 --- backend/scripts/verify-large-stock-monitor.js | 113 ---- backend/src/api/largeStockMonitor.js | 514 ------------------ backend/src/config/largeStockMonitorConfig.js | 76 --- backend/src/index.js | 2 - backend/src/services/largeStockMonitorFlow.js | 132 ----- frontend/src/api/largeStockMonitor.js | 15 - frontend/src/config/sidebarNavigation.js | 15 - frontend/src/constants/largeStockMonitor.js | 165 ------ frontend/src/constants/sidebarPages.js | 1 - frontend/src/layouts/BasicLayout.jsx | 9 - .../src/pages/LargeStockMonitor/index.jsx | 502 ----------------- .../src/pages/LargeStockMonitor/style.css | 149 ----- frontend/src/router/index.jsx | 5 - 13 files changed, 1698 deletions(-) delete mode 100644 backend/scripts/verify-large-stock-monitor.js delete mode 100644 backend/src/api/largeStockMonitor.js delete mode 100644 backend/src/config/largeStockMonitorConfig.js delete mode 100644 backend/src/services/largeStockMonitorFlow.js delete mode 100644 frontend/src/api/largeStockMonitor.js delete mode 100644 frontend/src/constants/largeStockMonitor.js delete mode 100644 frontend/src/pages/LargeStockMonitor/index.jsx delete mode 100644 frontend/src/pages/LargeStockMonitor/style.css diff --git a/backend/scripts/verify-large-stock-monitor.js b/backend/scripts/verify-large-stock-monitor.js deleted file mode 100644 index d1ca3a2..0000000 --- a/backend/scripts/verify-large-stock-monitor.js +++ /dev/null @@ -1,113 +0,0 @@ -// 端到端校验:大货库存监控 26 仓在「数据截止时刻(快照日次日 01:00)」口径下的取数 -// 用法:node scripts/verify-large-stock-monitor.js -process.env.NODE_ENV = process.env.NODE_ENV || 'development'; -require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') }); -const http = require('http'); - -const BASE = 'http://127.0.0.1:4001'; -const { LARGE_STOCK_MONITOR_WAREHOUSES } = require('../src/config/largeStockMonitorConfig'); - -function request(method, path, token, body) { - return new Promise((resolve, reject) => { - const payload = body ? JSON.stringify(body) : null; - const req = http.request( - `${BASE}${path}`, - { - method, - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), - }, - }, - (res) => { - let data = ''; - res.on('data', (c) => (data += c)); - res.on('end', () => { - try { - resolve({ status: res.statusCode, json: JSON.parse(data) }); - } catch (e) { - reject(new Error(`响应解析失败 ${res.statusCode}: ${data.slice(0, 200)}`)); - } - }); - } - ); - req.on('error', reject); - if (payload) req.write(payload); - req.end(); - }); -} - -(async () => { - const login = await request('POST', '/api/auth/local-preview', null, {}); - const token = login.json?.token || login.json?.data?.token || login.json?.access_token; - if (!token) { - console.error('登录失败:', JSON.stringify(login.json).slice(0, 300)); - process.exit(1); - } - console.log('登录成功\n'); - - let ok = 0; - const problems = []; - let sampleAsOf = null; - - for (const w of LARGE_STOCK_MONITOR_WAREHOUSES) { - const url = `/api/large-stock-monitor/overview?warehouse=${encodeURIComponent(w.warehouse)}`; - try { - const res = await request('GET', url, token); - const d = res.json?.data; - if (!res.json?.success || !d) { - problems.push(`${w.warehouse}: 接口失败 ${res.status} ${res.json?.error || ''}`); - continue; - } - sampleAsOf = sampleAsOf || d.asOf; - const o = d.overview || {}; - const filled = Object.values(o).filter((v) => v !== '—' && v !== null && v !== undefined).length; - if (!d.hasData) { - problems.push(`${w.warehouse}: 无数据源 (source=${d.source})`); - continue; - } - ok += 1; - console.log( - `[${String(w.idx).padStart(2)}] ${w.warehouse}\n` + - ` 快照=${d.latestBizDate} 截止=${d.asOf} 区间=${d.period?.start} ~ ${d.period?.end}\n` + - ` SKU=${d.skuCount} 期末=${o.closingQty} 期初(估)=${o.periodStart}\n` + - ` 采购进=${o.purchaseIn} 采购退=${o.purchaseReturn} 销售出=${o.salesOut} 销售退=${o.salesReturn}\n` + - ` 周转=${o.turnoverDays} 预警=${o.warnLevel} 慢流=${o.slowLevel} 呆滞=${o.staleLevel} 僵尸=${o.zombieLevel}\n` + - ` 有值列 ${filled}/18 source=${d.source}` - ); - } catch (e) { - problems.push(`${w.warehouse}: ${e.message}`); - } - } - - // 明细接口校验:带 @asOf 的分级筛选 SQL(level 过滤会引用 @asOf) - console.log('\n===== 明细接口校验 ====='); - for (const w of LARGE_STOCK_MONITOR_WAREHOUSES.slice(0, 4)) { - for (const lv of ['', 'warn', 'slow', 'stale', 'zombie']) { - const qs = `warehouse=${encodeURIComponent(w.warehouse)}&page=1&pageSize=5${lv ? `&level=${lv}` : ''}`; - try { - const res = await request('GET', `/api/large-stock-monitor/sku-list?${qs}`, token); - const d = res.json?.data; - if (!res.json?.success) { - problems.push(`${w.warehouse} level=${lv || '全部'}: ${res.json?.error}`); - } else { - console.log(` ${w.warehouse} level=${lv || '全部'} -> total=${d?.total} 返回=${d?.items?.length} 截止=${d?.asOf}`); - } - } catch (e) { - problems.push(`${w.warehouse} level=${lv}: ${e.message}`); - } - } - } - - console.log(`\n===== 汇总 =====`); - console.log(`数据截止时刻(asOf): ${sampleAsOf}`); - console.log(`有数据仓库: ${ok}/${LARGE_STOCK_MONITOR_WAREHOUSES.length}`); - if (problems.length) { - console.log('异常:'); - problems.forEach((p) => console.log(' - ' + p)); - } else { - console.log('全部仓库取数正常,无异常'); - } - process.exit(0); -})(); diff --git a/backend/src/api/largeStockMonitor.js b/backend/src/api/largeStockMonitor.js deleted file mode 100644 index ecea5a5..0000000 --- a/backend/src/api/largeStockMonitor.js +++ /dev/null @@ -1,514 +0,0 @@ -// 大货库存监控 - 后端路由 -// 业务数据源:rpt.pm_stockMonitor(MSSQL,业务库 JSReportReplica) -// rpt.jst_*(聚水潭单据/库存表,用于出入库流水) -// 配置源:C:/Users/.../Desktop/库存监控.xlsx 中的 26 行仓库配置 -// -// 计算口径(用户给定): -// 月度发货总件数 ≈ threeAvgSale × 30 (业务库无直接月度发货字段,按 3 天日均销量 × 30 估算) -// 月度日均库存件数 ≈ jsStock (业务库只有当前日快照,作为日均近似) -// 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100% -// 注:按公式实际为周转率(%),列名沿用 Excel 表头"库存周转天数" -// 库龄(月) = DATEDIFF(MONTH, created, GETDATE()) -// -// 分级口径(用户给定,按"库龄 + 周转天数(%)"两个维度): -// 滞销预警 warn 库龄 < 3 个月 且 30 ≤ 周转天数 ≤ 199 -// 商品慢流 slow 库龄 < 3 个月 且 周转天数 > 200 -// 或 库龄 > 3 个月 且 30 ≤ 周转天数 ≤ 99 -// 商品呆滞 stale 库龄 3~12 个月 且 周转天数 ≥ 100 -// 或 库龄 > 12 个月 且 100 ≤ 周转天数 ≤ 199 -// 商品僵尸 zombie 库龄 > 12 个月 且 周转天数 ≥ 200 -const express = require('express'); -const authMiddleware = require('../middleware/auth'); -const cloudDb = require('../config/cloudDatabase'); -const { LARGE_STOCK_MONITOR_WAREHOUSES } = require('../config/largeStockMonitorConfig'); -const { fetchFlowSummary, fetchInventoryQty } = require('../services/largeStockMonitorFlow'); - -const router = express.Router(); - -const LEVEL_LABELS = { - warn: '滞销预警', - slow: '商品慢流', - stale: '商品呆滞', - zombie: '商品僵尸', -}; - -// 18 列表头(与库存监控.xlsx 第 1 行的 D..U 列对应) -// available=false 表示业务库无对应数据源(加工/盘点/调拨/其它出库),前端显示「—」 -const COLUMN_HEADERS = [ - { key: 'periodStart', label: '期初数据', available: true, estimated: true }, - { key: 'processIn', label: '加工进仓', available: false }, - { key: 'purchaseReturn', label: '采购退货', available: true }, - { key: 'purchaseIn', label: '采购进仓', available: true }, - { key: 'salesOut', label: '销售出仓', available: true }, - { key: 'stocktake', label: '盘点', available: false }, - { key: 'processOut', label: '加工出仓', available: false }, - { key: 'transferOut', label: '调拨出', available: false }, - { key: 'salesReturn', label: '销售退货', available: true }, - { key: 'transferIn', label: '调拨入', available: false }, - { key: 'openingBalance', label: '期初', available: true, estimated: true }, - { key: 'otherOut', label: '其它出库', available: false }, - { key: 'closingQty', label: '期末数量', available: true }, - { key: 'turnoverDays', label: '库存周转天数', available: true }, - { key: 'warnLevel', label: '滞销预警级库存量', available: true }, - { key: 'slowLevel', label: '商品慢流级库存量', available: true }, - { key: 'staleLevel', label: '商品呆滞级库存量', available: true }, - { key: 'zombieLevel', label: '商品僵尸级库存量', available: true }, -]; - -// 在 SELECT/WHERE 中复用:库龄月数、月度发货、月度日均库存、周转天数(%)、分级 -// 关键:所有算式都做 NULL/0 兜底,避免分母为 0 导致 NULL 污染整列 -// 时间基准统一使用 @asOf(数据截止时刻,默认快照日次日 01:00),不再用 GETDATE(), -// 保证同一批报表内库龄/分级与流水口径完全一致。 -function buildDerivedColumns(alias = 'm') { - const a = alias ? `${alias}.` : ''; - const T = 'CONVERT(DATETIME, @asOf, 120)'; - return { - ageMonths: `CASE WHEN ${a}created IS NULL THEN NULL ELSE CAST(DATEDIFF(DAY, ${a}created, ${T}) AS DECIMAL(18, 4)) / 30.0 END`, - monthlyShipment: `(ISNULL(${a}threeAvgSale, 0) * 30.0)`, - avgDailyStock: `CAST(ISNULL(${a}jsStock, 0) AS DECIMAL(18, 4))`, - turnoverDays: `CASE WHEN ISNULL(${a}jsStock, 0) = 0 THEN NULL ELSE CAST(ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock AS DECIMAL(18, 4)) END`, - levelExpr: ` - CASE - WHEN ${a}created IS NULL OR ISNULL(${a}jsStock, 0) = 0 OR ISNULL(${a}threeAvgSale, 0) * 30.0 / NULLIF(${a}jsStock, 0) IS NULL THEN NULL - WHEN DATEDIFF(DAY, ${a}created, ${T}) < 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 30 AND 199 THEN 'warn' - WHEN DATEDIFF(DAY, ${a}created, ${T}) < 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 200 THEN 'slow' - WHEN DATEDIFF(DAY, ${a}created, ${T}) >= 90 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 30 AND 99 THEN 'slow' - WHEN DATEDIFF(DAY, ${a}created, ${T}) BETWEEN 90 AND 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 100 THEN 'stale' - WHEN DATEDIFF(DAY, ${a}created, ${T}) > 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) BETWEEN 100 AND 199 THEN 'stale' - WHEN DATEDIFF(DAY, ${a}created, ${T}) > 365 AND (ISNULL(${a}threeAvgSale, 0) * 30.0 * 100.0 / ${a}jsStock) >= 200 THEN 'zombie' - ELSE NULL - END - `, - }; -} - -function resolveWarehouse(warehouse) { - const target = String(warehouse || '').trim(); - if (!target) return null; - return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.warehouse === target) || null; -} - -function buildPlaceholderRow() { - return COLUMN_HEADERS.reduce((acc, column) => { - acc[column.key] = '—'; - return acc; - }, {}); -} - -// 默认跟随业务库最新快照日期;可通过 ?date=YYYY-MM-DD 指定某一天 -const DEFAULT_TARGET_DATE = null; - -// 数据截止时刻:快照日次日的凌晨 01:00。 -// 业务库每天凌晨 1 点跑批生成快照,标注为前一日(T-1)的库存, -// 因此「9 月 23 日凌晨 1 点的数据」= 快照日 2026-09-22 + 截止时刻 2026-09-23 01:00。 -const AS_OF_TIME = '01:00'; - -// 流水统计区间:快照所在自然月的 1 日 00:00 ~ 快照日次日 01:00(左闭右开) -// 即完整覆盖快照日全天,并包含跑批当日凌晨 0 点~1 点的单据 -function buildPeriodRange(bizDate) { - if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null; - const [y, mo] = bizDate.split('-'); - const start = `${y}-${mo}-01 00:00`; - const d = new Date(`${bizDate}T00:00:00Z`); - d.setUTCDate(d.getUTCDate() + 1); - const endDay = d.toISOString().slice(0, 10); - const end = `${endDay} ${AS_OF_TIME}`; - return { start, end, endDay, asOf: end }; -} - -// 允许 ?asOf=YYYY-MM-DD HH:mm 手工覆盖截止时刻(排查/补数用) -function normalizeAsOf(value) { - const raw = String(value || '').trim(); - if (!raw) return null; - if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return `${raw} ${AS_OF_TIME}`; - const m = raw.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/); - if (m) return `${m[1]} ${m[2]}`; - return null; -} - -const pad2 = (n) => String(n).padStart(2, '0'); - -function localDateString(d) { - return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; -} - -function localDateTimeString(d) { - return `${localDateString(d)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; -} - -// 无库存快照的仓库(走实时库存兜底)没有「快照日次日」可用,截止时刻取今天凌晨 1 点; -// 若当前还没到凌晨 1 点(跑批尚未完成),则退化为当前时刻。 -function fallbackAsOf() { - const now = new Date(); - const hourFloor = Number(AS_OF_TIME.split(':')[0]); - if (now.getHours() >= hourFloor) return `${localDateString(now)} ${AS_OF_TIME}`; - return localDateTimeString(now); -} - -async function fetchTargetBizDate(dbWarehouse, preferredDate) { - if (!dbWarehouse) return null; - const rows = await cloudDb.query( - `SELECT TOP 1 CONVERT(VARCHAR(10), date, 23) AS d - FROM rpt.pm_stockMonitor - WHERE warehouse = @warehouse AND date IS NOT NULL - AND (@preferredDate IS NULL OR CONVERT(VARCHAR(10), date, 23) <= @preferredDate) - ORDER BY date DESC`, - { warehouse: dbWarehouse, preferredDate: preferredDate || null } - ); - return rows[0]?.d || null; -} - -// 期初倒推:期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货 -// 业务库 pm_stockMonitor 仅保留最新单日快照(无上月末历史),且加工/调拨/盘点/其它出库无数据源, -// 因此期初只能由已知流水倒推,属估算值(前端标注「估」)。 -// 倒推出现负数说明该仓库本期存在未采集到的出库(调拨/加工/其它出库),此时不再展示估算值, -// 避免出现「负库存」这类明显失真的数字。 -function estimateOpeningBalance(closingQty, flow) { - if (closingQty == null) return null; - const purchaseIn = flow?.purchaseIn ?? 0; - const purchaseReturn = flow?.purchaseReturn ?? 0; - const salesOut = flow?.salesOut ?? 0; - const salesReturn = flow?.salesReturn ?? 0; - const value = closingQty - purchaseIn + purchaseReturn + salesOut - salesReturn; - return value >= 0 ? value : null; -} - -router.get('/config', authMiddleware, async (_req, res) => { - try { - res.json({ - success: true, - data: { - columns: COLUMN_HEADERS, - levelLabels: LEVEL_LABELS, - warehouses: LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({ - warehouse: entry.warehouse, - group: entry.group, - routeKey: entry.routeKey, - hasData: Boolean(entry.dbWarehouse), - hasWms: Boolean(entry.wmsCoId), - })), - }, - }); - } catch (error) { - console.error('[LargeStockMonitor] 获取配置失败:', error); - res.status(500).json({ success: false, error: error.message || '获取配置失败' }); - } -}); - -router.get('/overview', authMiddleware, async (req, res) => { - try { - const config = resolveWarehouse(req.query.warehouse); - if (!config) { - return res.status(404).json({ success: false, error: '未知的仓库配置' }); - } - - const requestedDate = String(req.query.date || '').trim() || null; - if (requestedDate && !/^\d{4}-\d{2}-\d{2}$/.test(requestedDate)) { - return res.status(400).json({ success: false, error: 'date 参数格式应为 YYYY-MM-DD' }); - } - - const dbWarehouse = config.dbWarehouse; - const wmsCoId = config.wmsCoId; - const overview = buildPlaceholderRow(); - const baseResponse = { - warehouse: config.warehouse, - group: config.group, - routeKey: config.routeKey, - requestedDate, - latestBizDate: null, - fallbackDate: null, - asOf: null, - period: null, - source: null, - hasData: false, - skuCount: 0, - overview, - }; - - // 1) 快照日期:优先 pm_stockMonitor;无该仓库时退回聚水潭实时库存(无日期概念) - let latestBizDate = dbWarehouse - ? await fetchTargetBizDate(dbWarehouse, requestedDate).catch(() => null) - : null; - const fallbackDate = requestedDate && latestBizDate && latestBizDate !== requestedDate ? latestBizDate : null; - const period = buildPeriodRange(latestBizDate); - // 数据截止时刻:默认快照日次日 01:00(如 2026-09-23 01:00),可用 ?asOf= 覆盖 - const asOf = normalizeAsOf(req.query.asOf) || period?.asOf || fallbackAsOf(); - - // 2) 出入库流水(本月 1 日 ~ 截止时刻);pm_stockMonitor 无该仓库时用当天所在月 - const today = localDateString(new Date()); - const periodForFlow = period - ? { ...period, end: asOf || period.end } - : { start: buildPeriodRange(today)?.start, end: asOf }; - const flow = await fetchFlowSummary({ - wmsCoId, - dbWarehouse: dbWarehouse || config.warehouse.replace(/主仓$/, ''), - periodStart: periodForFlow?.start, - periodEnd: periodForFlow?.end, - }).catch(() => ({ purchaseIn: null, purchaseReturn: null, salesOut: null, salesReturn: null })); - - if (flow.purchaseReturn != null) overview.purchaseReturn = flow.purchaseReturn; - if (flow.purchaseIn != null) overview.purchaseIn = flow.purchaseIn; - if (flow.salesOut != null) overview.salesOut = flow.salesOut; - if (flow.salesReturn != null) overview.salesReturn = flow.salesReturn; - - // 3) 期末 / 分级 - // 期末数量统一取「聚水潭实际库存」rpt.jst_inventoryInfo(按 wms_co_id 单仓统计): - // pm_stockMonitor 的 warehouse 是「归属仓」而非实物所在仓,其 jsStock 会把 - // 已调拨到外仓(如横林二仓)的货仍计在本仓名下,与仓库实物库存对不上 - // (例:崔桥工厂仓 快照 jsStock 115135 vs 实物 92439)。 - // 库龄/周转率/四级库存量只能由 pm_stockMonitor 提供(实时库存表无 created、销量字段)。 - let skuCount = 0; - let source = null; - let closingSource = null; - let hasGrade = false; - let summary = {}; - - if (dbWarehouse && latestBizDate) { - const D = buildDerivedColumns(); - const summaryRows = await cloudDb.query( - `SELECT - COUNT(*) AS skuCount, - SUM(CAST(jsStock AS DECIMAL(18, 2))) AS totalStock, - AVG(${D.turnoverDays}) AS avgTurnover, - MAX(${D.turnoverDays}) AS maxTurnover, - SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel, - SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel, - SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel, - SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(jsStock AS DECIMAL(18, 2)) ELSE 0 END) AS zombieLevel - FROM rpt.pm_stockMonitor m - WHERE m.warehouse = @warehouse - AND CONVERT(VARCHAR(10), m.date, 23) = @bizDate`, - { warehouse: dbWarehouse, bizDate: latestBizDate, asOf } - ); - summary = summaryRows[0] || {}; - const snapSkuCount = Number(summary.skuCount || 0); - if (snapSkuCount > 0) { - hasGrade = true; - overview.turnoverDays = summary.avgTurnover == null ? null : Number(summary.avgTurnover); - overview.warnLevel = Number(summary.warnLevel || 0); - overview.slowLevel = Number(summary.slowLevel || 0); - overview.staleLevel = Number(summary.staleLevel || 0); - overview.zombieLevel = Number(summary.zombieLevel || 0); - } - } - - // 3.2 期末数量:聚水潭实际库存(单仓 wms_co_id);SKU 数同步跟随期末口径 - let closingTakenAt = null; - if (wmsCoId) { - const inv = await fetchInventoryQty(wmsCoId).catch(() => null); - if (inv && inv.qty != null) { - closingSource = 'jst_inventoryInfo'; - overview.closingQty = inv.qty; - skuCount = inv.skuCount || 0; - closingTakenAt = inv.takenAt || null; - } - } - - // 3.3 兜底:拿不到实时库存时退回快照归属库存(并在响应中标记来源) - if (!closingSource && summary.totalStock != null && Number(summary.skuCount || 0) > 0) { - closingSource = 'pm_stockMonitor'; - overview.closingQty = Number(summary.totalStock || 0); - skuCount = Number(summary.skuCount || 0); - } - - source = closingSource || (hasGrade ? 'pm_stockMonitor' : null); - // 四级库存量基于快照归属库存分级,与期末(实际库存)口径不同,供前端提示用 - const gradeBasisStock = Number(summary.totalStock || 0); - - // 4) 期初倒推(估算) - const opening = estimateOpeningBalance( - typeof overview.closingQty === 'number' ? overview.closingQty : null, - flow - ); - if (opening != null) { - overview.periodStart = opening; - overview.openingBalance = opening; - } - - res.json({ - success: true, - data: { - ...baseResponse, - latestBizDate: latestBizDate || null, - fallbackDate, - asOf, - period: periodForFlow, - source, - closingSource, - closingTakenAt, - hasGrade, - gradeBasisStock, - hasData: Boolean(source), - skuCount, - overview, - turnoverDaysAvg: summary.avgTurnover != null ? Number(summary.avgTurnover) : null, - turnoverDaysMax: summary.maxTurnover != null ? Number(summary.maxTurnover) : null, - }, - }); - } catch (error) { - console.error('[LargeStockMonitor] 获取概览失败:', error); - res.status(error.statusCode || 500).json({ - success: false, - error: error.message || '获取概览失败', - }); - } -}); - -router.get('/sku-list', authMiddleware, async (req, res) => { - try { - const config = resolveWarehouse(req.query.warehouse); - if (!config) { - return res.status(404).json({ success: false, error: '未知的仓库配置' }); - } - - const requestedDate = String(req.query.date || '').trim() || null; - if (requestedDate && !/^\d{4}-\d{2}-\d{2}$/.test(requestedDate)) { - return res.status(400).json({ success: false, error: 'date 参数格式应为 YYYY-MM-DD' }); - } - - const dbWarehouse = config.dbWarehouse; - const wmsCoId = config.wmsCoId; - const page = Math.max(parseInt(req.query.page, 10) || 1, 1); - const pageSize = Math.min(Math.max(parseInt(req.query.pageSize, 10) || 20, 1), 200); - const offset = (page - 1) * pageSize; - const level = String(req.query.level || '').trim(); - const keyword = String(req.query.keyword || '').trim(); - - const baseResponse = { - warehouse: config.warehouse, - group: config.group, - requestedDate, - latestBizDate: null, - fallbackDate: null, - asOf: null, - source: null, - page, - pageSize, - total: 0, - items: [], - }; - - const latestBizDate = dbWarehouse - ? await fetchTargetBizDate(dbWarehouse, requestedDate).catch(() => null) - : null; - const asOf = normalizeAsOf(req.query.asOf) || buildPeriodRange(latestBizDate)?.asOf || fallbackAsOf(); - - // pm_stockMonitor 有数据 → 走原有分级明细 - if (dbWarehouse && latestBizDate) { - const D = buildDerivedColumns(); - const filters = ['m.warehouse = @warehouse', "CONVERT(VARCHAR(10), m.date, 23) = @bizDate"]; - const params = { warehouse: dbWarehouse, bizDate: latestBizDate, asOf }; - - if (keyword) { - filters.push('(m.sku LIKE @kw OR m.skuName LIKE @kw)'); - params.kw = `%${keyword}%`; - } - if (['warn', 'slow', 'stale', 'zombie'].includes(level)) { - filters.push(`${D.levelExpr} = @level`); - params.level = level; - } - - const whereClause = filters.join(' AND '); - const totalRows = await cloudDb.query( - `SELECT COUNT(*) AS total FROM rpt.pm_stockMonitor m WHERE ${whereClause}`, - params - ); - const total = Number(totalRows[0]?.total || 0); - - params.pageSize = pageSize; - params.offset = offset; - const items = await cloudDb.query( - `SELECT - m.sku, m.spu, m.skuName, m.supplier, m.purchase, m.category, - m.jsStock, m.threeAvgSale, m.created, - ${D.ageMonths} AS ageMonths, - ${D.monthlyShipment} AS monthlyShipment, - ${D.avgDailyStock} AS avgDailyStock, - ${D.turnoverDays} AS turnoverDays, - ${D.levelExpr} AS level, - m.warehouse - FROM rpt.pm_stockMonitor m - WHERE ${whereClause} - ORDER BY m.jsStock DESC, m.sku ASC - OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`, - params - ); - - return res.json({ - success: true, - data: { - ...baseResponse, - latestBizDate, - fallbackDate: requestedDate && latestBizDate !== requestedDate ? latestBizDate : null, - asOf, - source: 'pm_stockMonitor', - total, - items, - }, - }); - } - - // 兜底:聚水潭实时库存明细(无库龄/销量字段,level 与周转率留空) - // 该数据源算不出库龄与周转率,任何分级筛选都不可能命中,直接返回空列表, - // 避免点选「商品僵尸」等时把整仓库存原样列出造成误导。 - if (['warn', 'slow', 'stale', 'zombie'].includes(level)) { - return res.json({ - success: true, - data: { ...baseResponse, asOf, source: 'jst_inventoryInfo', total: 0, items: [] }, - }); - } - - if (!wmsCoId) { - return res.json({ success: true, data: baseResponse }); - } - - const invFilters = ['v.wms_co_id = @wmsCoId', 'ISNULL(v.qty, 0) <> 0']; - const invParams = { wmsCoId }; - if (keyword) { - invFilters.push('(v.sku_id LIKE @kw OR v.name LIKE @kw)'); - invParams.kw = `%${keyword}%`; - } - const invWhere = invFilters.join(' AND '); - - const invTotal = await cloudDb.query( - `SELECT COUNT(*) AS total FROM rpt.jst_inventoryInfo v WHERE ${invWhere}`, - invParams - ); - const total = Number(invTotal[0]?.total || 0); - - invParams.pageSize = pageSize; - invParams.offset = offset; - const items = await cloudDb.query( - `SELECT - v.sku_id AS sku, - v.i_id AS spu, - v.name AS skuName, - v.qty AS jsStock, - CAST(NULL AS DECIMAL(18, 4)) AS ageMonths, - CAST(NULL AS DECIMAL(18, 4)) AS monthlyShipment, - CAST(v.qty AS DECIMAL(18, 4)) AS avgDailyStock, - CAST(NULL AS DECIMAL(18, 4)) AS turnoverDays, - CAST(NULL AS VARCHAR(10)) AS level, - w.name AS warehouse - FROM rpt.jst_inventoryInfo v - LEFT JOIN rpt.jst_warehouseInfo w ON w.wms_co_id = v.wms_co_id - WHERE ${invWhere} - ORDER BY v.qty DESC, v.sku_id ASC - OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`, - invParams - ); - - res.json({ - success: true, - data: { ...baseResponse, asOf, source: 'jst_inventoryInfo', total, items }, - }); - } catch (error) { - console.error('[LargeStockMonitor] 获取 SKU 列表失败:', error); - res.status(error.statusCode || 500).json({ - success: false, - error: error.message || '获取 SKU 列表失败', - }); - } -}); - -module.exports = router; diff --git a/backend/src/config/largeStockMonitorConfig.js b/backend/src/config/largeStockMonitorConfig.js deleted file mode 100644 index 4d8de40..0000000 --- a/backend/src/config/largeStockMonitorConfig.js +++ /dev/null @@ -1,76 +0,0 @@ -// 大货库存监控配置 - 数据源:C:/Users/15018737611/Desktop/库存监控.xlsx -// 共 26 行,按 Excel 顺序保留:序号 / 二级功能按键 / 仓库全名 -// 业务库 rpt.pm_stockMonitor.warehouse 的命名规则与 Excel 不一致: -// Excel 通常带「主仓」后缀,业务库只保留「主」前那一段。 -// 已在 dbWarehouse 字段手工映射出业务库 warehouse 精确值;无法对应的留 null。 -// 菜单 label 显示 Excel 仓库全名,routeKey 用 slug 化的二级功能按键+序号保证唯一。 - -// wmsCoId 来自 rpt.jst_warehouseInfo(聚水潭仓库主数据,155 行,26 个仓库全部命中), -// 用于从出入库单据表(jst_purchase / jst_purchaseout / jst_salesOutboundInfo 等)取流水数量。 -// dbWarehouse 用于 rpt.pm_stockMonitor;该报表仅覆盖 16 个仓库,其余为 null。 -const RAW_WAREHOUSES = [ - { idx: 1, group: '半成品部件仓', warehouse: '简尚-崔桥工厂半成品包装项目仓主仓', dbWarehouse: '简尚-崔桥工厂半成品包装项目仓', wmsCoId: 15718196 }, - { idx: 2, group: '运营C', warehouse: '简尚-运营C崔桥工厂仓专用仓主仓', dbWarehouse: null, wmsCoId: 15301815 }, - { idx: 3, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂仓主仓', dbWarehouse: '简尚-崔桥工厂仓', wmsCoId: 15038426 }, - { idx: 4, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂京东仓主仓', dbWarehouse: null, wmsCoId: 15045345 }, - { idx: 5, group: '崔桥工厂品牌仓', warehouse: '简尚-崔桥工厂品牌仓主仓', dbWarehouse: '简尚-崔桥工厂品牌仓', wmsCoId: 15870495 }, - { idx: 6, group: '河南洛宁仓', warehouse: '简尚-河南洛宁仓主仓', dbWarehouse: '简尚-河南洛宁仓', wmsCoId: 15539084 }, - { idx: 7, group: '运营D', warehouse: '简尚-运营D专用仓库主仓', dbWarehouse: null, wmsCoId: 15051915 }, - { idx: 8, group: '运营C', warehouse: '简尚-运营C横林二仓专用仓主仓', dbWarehouse: null, wmsCoId: 15301814 }, - { idx: 9, group: '横林二仓', warehouse: '简尚-横林二仓主仓', dbWarehouse: '简尚-横林二仓', wmsCoId: 15033302 }, - { idx: 10, group: '横林三仓', warehouse: '简尚-横林三仓主仓', dbWarehouse: '简尚-横林三仓', wmsCoId: 15043561 }, - { idx: 11, group: '山东品牌仓', warehouse: '简尚-山东品牌仓主仓', dbWarehouse: '简尚-山东品牌仓', wmsCoId: 15182705 }, - { idx: 12, group: '山东青集岗仓', warehouse: '简尚-山东青岗集京东仓主仓', dbWarehouse: null, wmsCoId: 15406664 }, - { idx: 13, group: '山东青集岗仓', warehouse: '简尚-山东青岗集仓主仓', dbWarehouse: '简尚-山东青岗集仓', wmsCoId: 15137523 }, - { idx: 14, group: '运营D', warehouse: '简尚-运营D苏北专用仓主仓', dbWarehouse: null, wmsCoId: 15406876 }, - { idx: 15, group: '运营C', warehouse: '简尚-运营C新苏北专用仓主仓', dbWarehouse: null, wmsCoId: 15301812 }, - { idx: 16, group: '苏北仓', warehouse: '简尚-新苏北仓主仓', dbWarehouse: '简尚-新苏北仓', wmsCoId: 15038347 }, - { idx: 17, group: '分销仓', warehouse: '简尚-分销部苏北专用仓主仓', dbWarehouse: '简尚-分销部苏北专用仓', wmsCoId: 15045325 }, - { idx: 18, group: '运营D', warehouse: '简尚-运营D睢宁专用仓主仓', dbWarehouse: null, wmsCoId: 15051907 }, - { idx: 19, group: '运营C', warehouse: '简尚-运营C睢宁专用仓主仓', dbWarehouse: null, wmsCoId: 15301813 }, - { idx: 20, group: '睢宁仓', warehouse: '简尚-睢宁仓主仓', dbWarehouse: '简尚-睢宁仓', wmsCoId: 15032603 }, - { idx: 21, group: '沭阳金源涌动', warehouse: '简尚-沭阳金源涌动项目仓主仓', dbWarehouse: '简尚-沭阳金源涌动项目仓', wmsCoId: 15043585 }, - { idx: 22, group: '分销仓', warehouse: '简尚-分销部睢宁专用仓主仓', dbWarehouse: '简尚-分销部睢宁专用仓', wmsCoId: 15051960 }, - { idx: 23, group: '外贸仓', warehouse: '简尚-新外贸仓主仓', dbWarehouse: '简尚-新外贸仓', wmsCoId: 15045308 }, - { idx: 24, group: '外贸仓', warehouse: '简尚-济南外贸仓主仓', dbWarehouse: null, wmsCoId: 15051888 }, - { idx: 25, group: '分销仓', warehouse: '简尚-分销部专用仓主仓', dbWarehouse: '简尚-分销部专用仓', wmsCoId: 15045426 }, - { idx: 26, group: '分销仓', warehouse: '简尚-分销部崔桥专用仓主仓', dbWarehouse: '简尚-分销部崔桥专用仓', wmsCoId: 15404402 }, -]; - -function slugify(value) { - return String(value || '') - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -const LARGE_STOCK_MONITOR_WAREHOUSES = RAW_WAREHOUSES.map((entry) => ({ - ...entry, - routeKey: `stock-monitor-large-${entry.idx}`, - slug: slugify(entry.warehouse) || `warehouse-${entry.idx}`, - hasData: Boolean(entry.dbWarehouse), -})); - -// 无业务库数据源的列:业务库(JSReportReplica,220 张表)中不存在加工/盘点/调拨/其它出库 -// 任何单据表,这 6 列无真实数据可回填,保留占位符「—」并在前端说明。 -const UNAVAILABLE_COLUMN_KEYS = [ - 'processIn', - 'stocktake', - 'processOut', - 'transferOut', - 'transferIn', - 'otherOut', -]; - -const LARGE_STOCK_MONITOR_PAGE = { - pageKey: 'large_stock_monitor', - path: '/planning/large-stock-monitor/:warehouseSlug', - name: '大货库存监控', - defaultRoles: { admin: true, analyst: true, user: true, guest: false }, -}; - -module.exports = { - LARGE_STOCK_MONITOR_WAREHOUSES, - LARGE_STOCK_MONITOR_PAGE, - UNAVAILABLE_COLUMN_KEYS, -}; diff --git a/backend/src/index.js b/backend/src/index.js index 4b0007d..88daa64 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -35,7 +35,6 @@ const promotionModuleRoutes = require('./api/promotionModule'); const productMonitoringRoutes = require('./api/productMonitoring'); const modularStockMonitorRoutes = require('./api/modularStockMonitor'); const mkhBomRoutes = require('./api/mkhBom'); -const largeStockMonitorRoutes = require('./api/largeStockMonitor'); const database = require('./config/database'); const cloudDb = require('./config/cloudDatabase'); const jianSunDb = require('./config/jianSunDatabase'); @@ -655,7 +654,6 @@ async function startServer(options = {}) { cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes); cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes); cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes); - cubejsApp.use('/api/large-stock-monitor', authMiddleware, largeStockMonitorRoutes); cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); diff --git a/backend/src/services/largeStockMonitorFlow.js b/backend/src/services/largeStockMonitorFlow.js deleted file mode 100644 index 200cf5f..0000000 --- a/backend/src/services/largeStockMonitorFlow.js +++ /dev/null @@ -1,132 +0,0 @@ -// 大货库存监控 - 出入库流水取数(本月 1 日 ~ 快照日) -// -// 业务库 JSReportReplica 里与「库存流水」相关的单据表: -// 采购进仓 rpt.jst_purchase (wms_co_id, po_date, status) + rpt.jst_purchaseItem (po_id, inQty) -// 采购退货 rpt.jst_purchaseout (wms_co_id, io_date, status) + rpt.jst_purchaseoutItem (io_id, qty) -// 销售出仓 rpt.jst_salesOutboundInfo(wms_co_id, io_date, status) + rpt.jst_OrderItem (o_id, qty) -// 销售退货 rpt.jst_returnAndRefundInfo(warehouse 名称) + rpt.jst_returnAndRefundItem(as_id, r_qty) -// -// 有效单据状态:只统计 Confirmed / Finished,排除 Cancelled / Delete / WaitConfirm。 -// 业务库中不存在加工、盘点、调拨、其它出库任何单据表,这 6 列无法取数。 -const cloudDb = require('../config/cloudDatabase'); - -// 单据有效状态(已确认/已完成),避免把取消单算进流水 -const PURCHASE_STATUS = "'Confirmed','Finished'"; -const PURCHASEOUT_STATUS = "'Confirmed'"; -const SALESOUT_STATUS = "'Confirmed'"; -const REFUND_STATUS = "'Confirmed'"; - -function toNumber(value) { - if (value == null) return null; - const n = Number(value); - return Number.isFinite(n) ? n : null; -} - -/** - * 采购进仓:采购单已入库数量(inQty) - */ -async function fetchPurchaseIn(wmsCoId, periodStart, periodEnd) { - const rows = await cloudDb.query( - `SELECT ISNULL(SUM(CAST(pi.inQty AS DECIMAL(18, 2))), 0) AS v - FROM rpt.jst_purchase p - JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id - WHERE p.wms_co_id = @wmsCoId - AND p.po_date >= @periodStart AND p.po_date < @periodEnd - AND p.status IN (${PURCHASE_STATUS})`, - { wmsCoId, periodStart, periodEnd } - ); - return toNumber(rows[0]?.v); -} - -/** - * 采购退货:采购退货出库单数量 - */ -async function fetchPurchaseReturn(wmsCoId, periodStart, periodEnd) { - const rows = await cloudDb.query( - `SELECT ISNULL(SUM(CAST(oi.qty AS DECIMAL(18, 2))), 0) AS v - FROM rpt.jst_purchaseout o - JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id - WHERE o.wms_co_id = @wmsCoId - AND o.io_date >= @periodStart AND o.io_date < @periodEnd - AND o.status IN (${PURCHASEOUT_STATUS})`, - { wmsCoId, periodStart, periodEnd } - ); - return toNumber(rows[0]?.v); -} - -/** - * 销售出仓:销售出库单关联的订单明细数量 - * 注意:jst_salesOutboundInfo.qty 是表头字段且恒为 0,真实件数在 jst_OrderItem.qty - */ -async function fetchSalesOut(wmsCoId, periodStart, periodEnd) { - const rows = await cloudDb.query( - `SELECT ISNULL(SUM(CAST(oi.qty AS DECIMAL(18, 2))), 0) AS v - FROM rpt.jst_salesOutboundInfo s - JOIN rpt.jst_OrderItem oi ON oi.o_id = s.o_id - WHERE s.wms_co_id = @wmsCoId - AND s.io_date >= @periodStart AND s.io_date < @periodEnd - AND s.status IN (${SALESOUT_STATUS})`, - { wmsCoId, periodStart, periodEnd } - ); - return toNumber(rows[0]?.v); -} - -/** - * 销售退货:退货退款单明细的实退数量(r_qty) - * 该表 wms_co_id 大量为空(9 月 92821 单中 75094 单为空),改用 warehouse 名称匹配: - * 名称形如「简尚-崔桥工厂仓销退仓」/「简尚-分销部睢宁专用仓主仓」,统一去掉「销退仓」「主仓」后比对。 - */ -async function fetchSalesReturn(dbWarehouse, periodStart, periodEnd) { - if (!dbWarehouse) return null; - const rows = await cloudDb.query( - `SELECT ISNULL(SUM(CAST(ri.r_qty AS DECIMAL(18, 2))), 0) AS v - FROM rpt.jst_returnAndRefundInfo r - JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id - WHERE REPLACE(REPLACE(ISNULL(r.warehouse, ''), N'销退仓', N''), N'主仓', N'') = @whName - AND r.created >= @periodStart AND r.created < @periodEnd - AND r.status IN (${REFUND_STATUS})`, - { whName: dbWarehouse, periodStart, periodEnd } - ); - return toNumber(rows[0]?.v); -} - -/** - * 仓库实物库存:聚水潭实时库存表 rpt.jst_inventoryInfo(按 wms_co_id 单仓统计) - * 这是报表「期末数量」的取数口径 —— pm_stockMonitor 的 warehouse 仅是归属仓, - * 其 jsStock 含已调拨到外仓的货,不能代表本仓实物库存。 - */ -async function fetchInventoryQty(wmsCoId) { - if (!wmsCoId) return null; - const rows = await cloudDb.query( - `SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS v, - COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount, - MAX(modified) AS takenAt - FROM rpt.jst_inventoryInfo - WHERE wms_co_id = @wmsCoId`, - { wmsCoId } - ); - return { - qty: toNumber(rows[0]?.v), - skuCount: toNumber(rows[0]?.skuCount) || 0, - takenAt: rows[0]?.takenAt ? new Date(rows[0].takenAt).toISOString() : null, - }; -} - -/** - * 一次性取全部流水;任一子查询失败不影响其它字段(返回 null 由调用方降级为「—」) - */ -async function fetchFlowSummary({ wmsCoId, dbWarehouse, periodStart, periodEnd }) { - const [purchaseIn, purchaseReturn, salesOut, salesReturn] = await Promise.all([ - wmsCoId ? fetchPurchaseIn(wmsCoId, periodStart, periodEnd).catch(() => null) : null, - wmsCoId ? fetchPurchaseReturn(wmsCoId, periodStart, periodEnd).catch(() => null) : null, - wmsCoId ? fetchSalesOut(wmsCoId, periodStart, periodEnd).catch(() => null) : null, - fetchSalesReturn(dbWarehouse, periodStart, periodEnd).catch(() => null), - ]); - - return { purchaseIn, purchaseReturn, salesOut, salesReturn }; -} - -module.exports = { - fetchFlowSummary, - fetchInventoryQty, -}; diff --git a/frontend/src/api/largeStockMonitor.js b/frontend/src/api/largeStockMonitor.js deleted file mode 100644 index 084edab..0000000 --- a/frontend/src/api/largeStockMonitor.js +++ /dev/null @@ -1,15 +0,0 @@ -import request from './request'; - -const BASE = '/api/large-stock-monitor'; - -export function getLargeStockMonitorConfig() { - return request.get(`${BASE}/config`); -} - -export function getLargeStockMonitorOverview(warehouse) { - return request.get(`${BASE}/overview`, { params: { warehouse } }); -} - -export function getLargeStockMonitorSkuList(params = {}) { - return request.get(`${BASE}/sku-list`, { params }); -} diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index d99a7b4..63a6520 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -1,14 +1,3 @@ -import { LARGE_STOCK_MONITOR_WAREHOUSES } from '../constants/largeStockMonitor.js'; - -const buildLargeStockMonitorChildren = () => LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({ - key: `stock-monitor-large-${entry.idx}`, - label: entry.warehouse, - icon: 'appstore', - routeKey: `stock-monitor-large-${entry.idx}`, - visible: true, - order: entry.idx * 10, -})); - export const DEFAULT_SIDEBAR_CONFIG = { collapsedByDefault: false, darkModeByDefault: false, @@ -37,10 +26,6 @@ export const DEFAULT_SIDEBAR_CONFIG = { { key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 }, ], }, - { - key: 'stock-monitor-large', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: false, - children: buildLargeStockMonitorChildren(), - }, { key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true, children: [ diff --git a/frontend/src/constants/largeStockMonitor.js b/frontend/src/constants/largeStockMonitor.js deleted file mode 100644 index 38392f7..0000000 --- a/frontend/src/constants/largeStockMonitor.js +++ /dev/null @@ -1,165 +0,0 @@ -// 大货库存监控配置(前端镜像) -// 与 backend/src/config/largeStockMonitorConfig.js 同步 -// 修改需双端同步;运行期也可通过 /api/large-stock-monitor/config 获取 - -const RAW_WAREHOUSES = [ - { idx: 1, group: '半成品部件仓', warehouse: '简尚-崔桥工厂半成品包装项目仓主仓' }, - { idx: 2, group: '运营C', warehouse: '简尚-运营C崔桥工厂仓专用仓主仓' }, - { idx: 3, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂仓主仓' }, - { idx: 4, group: '崔桥工厂仓', warehouse: '简尚-崔桥工厂京东仓主仓' }, - { idx: 5, group: '崔桥工厂品牌仓', warehouse: '简尚-崔桥工厂品牌仓主仓' }, - { idx: 6, group: '河南洛宁仓', warehouse: '简尚-河南洛宁仓主仓' }, - { idx: 7, group: '运营D', warehouse: '简尚-运营D专用仓库主仓' }, - { idx: 8, group: '运营C', warehouse: '简尚-运营C横林二仓专用仓主仓' }, - { idx: 9, group: '横林二仓', warehouse: '简尚-横林二仓主仓' }, - { idx: 10, group: '横林三仓', warehouse: '简尚-横林三仓主仓' }, - { idx: 11, group: '山东品牌仓', warehouse: '简尚-山东品牌仓主仓' }, - { idx: 12, group: '山东青集岗仓', warehouse: '简尚-山东青岗集京东仓主仓' }, - { idx: 13, group: '山东青集岗仓', warehouse: '简尚-山东青岗集仓主仓' }, - { idx: 14, group: '运营D', warehouse: '简尚-运营D苏北专用仓主仓' }, - { idx: 15, group: '运营C', warehouse: '简尚-运营C新苏北专用仓主仓' }, - { idx: 16, group: '苏北仓', warehouse: '简尚-新苏北仓主仓' }, - { idx: 17, group: '分销仓', warehouse: '简尚-分销部苏北专用仓主仓' }, - { idx: 18, group: '运营D', warehouse: '简尚-运营D睢宁专用仓主仓' }, - { idx: 19, group: '运营C', warehouse: '简尚-运营C睢宁专用仓主仓' }, - { idx: 20, group: '睢宁仓', warehouse: '简尚-睢宁仓主仓' }, - { idx: 21, group: '沭阳金源涌动', warehouse: '简尚-沭阳金源涌动项目仓主仓' }, - { idx: 22, group: '分销仓', warehouse: '简尚-分销部睢宁专用仓主仓' }, - { idx: 23, group: '外贸仓', warehouse: '简尚-新外贸仓主仓' }, - { idx: 24, group: '外贸仓', warehouse: '简尚-济南外贸仓主仓' }, - { idx: 25, group: '分销仓', warehouse: '简尚-分销部专用仓主仓' }, - { idx: 26, group: '分销仓', warehouse: '简尚-分销部崔桥专用仓主仓' }, -]; - -function slugify(value) { - return String(value || '') - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -export const LARGE_STOCK_MONITOR_WAREHOUSES = RAW_WAREHOUSES.map((entry) => ({ - ...entry, - routeKey: `stock-monitor-large-${entry.idx}`, - slug: slugify(entry.warehouse) || `warehouse-${entry.idx}`, -})); - -// 18 列表头(按 Excel 第 1 行 D..U 列) -// available=false:业务库(220 张表)中不存在该单据类型,无数据源,显示「—」 -// estimated=true:由已有字段推导(非原始字段),前端标「估」并在悬浮说明中写明推导式 -export const OVERVIEW_COLUMNS = [ - { - key: 'periodStart', - label: '期初数据', - available: true, - estimated: true, - tip: '业务库仅保留最新单日库存快照,无上月末历史。此值由「期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货」倒推,未包含加工/调拨/盘点/其它出库。倒推为负时显示「—」。', - }, - { key: 'processIn', label: '加工进仓', available: false, tip: '业务库无加工单据表,无数据源' }, - { - key: 'purchaseReturn', - label: '采购退货', - available: true, - tip: '本月采购退货出库单数量:rpt.jst_purchaseout + jst_purchaseoutItem(状态 Confirmed)', - }, - { - key: 'purchaseIn', - label: '采购进仓', - available: true, - tip: '本月采购单已入库数量:rpt.jst_purchase + jst_purchaseItem 的 inQty(状态 Confirmed/Finished)', - }, - { - key: 'salesOut', - label: '销售出仓', - available: true, - tip: '本月销售出库单件数:rpt.jst_salesOutboundInfo(状态 Confirmed)关联 jst_OrderItem.qty', - }, - { key: 'stocktake', label: '盘点', available: false, tip: '业务库无盘点单据表,无数据源' }, - { key: 'processOut', label: '加工出仓', available: false, tip: '业务库无加工单据表,无数据源' }, - { key: 'transferOut', label: '调拨出', available: false, tip: '业务库无调拨单据表,无数据源' }, - { - key: 'salesReturn', - label: '销售退货', - available: true, - tip: '本月退货退款单实退数量:rpt.jst_returnAndRefundInfo + jst_returnAndRefundItem 的 r_qty(状态 Confirmed,按仓库名称匹配)', - }, - { key: 'transferIn', label: '调拨入', available: false, tip: '业务库无调拨单据表,无数据源' }, - { - key: 'openingBalance', - label: '期初', - available: true, - estimated: true, - tip: '同期初数据(倒推估算值)', - }, - { key: 'otherOut', label: '其它出库', available: false, tip: '业务库无其它出库单据表,无数据源' }, - { - key: 'closingQty', - label: '期末数量', - available: true, - tip: '聚水潭实际库存 rpt.jst_inventoryInfo(按仓库 wms_co_id 单仓实物口径,实时值)。业务库无历史时点库存快照,此值随每次刷新滚动到最新;与某日「期末结存」的差额即该日之后的出入库与仓间调拨', - }, - { key: 'turnoverDays', label: '库存周转天数', available: true, tip: '月度发货总件数 / 月度日均库存件数 × 100%(按业务库口径,数值实为周转率 %)' }, - { key: 'warnLevel', label: '滞销预警级库存量', available: true, tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计(按库存快照分级,口径为归属库存,与期末实际库存不同)' }, - { key: 'slowLevel', label: '商品慢流级库存量', available: true, tip: '库龄 < 3 个月 且 周转率 ≥ 200%;或 库龄 > 3 个月 且 30% ≤ 周转率 ≤ 99%' }, - { key: 'staleLevel', label: '商品呆滞级库存量', available: true, tip: '库龄 3~12 个月 且 周转率 ≥ 100%;或 库龄 > 12 个月 且 100% ≤ 周转率 ≤ 199%' }, - { key: 'zombieLevel', label: '商品僵尸级库存量', available: true, tip: '库龄 > 12 个月 且 周转率 ≥ 200%' }, -]; - -// 分级标签(与后端 levelExpr 返回值一致) -export const LEVEL_LABEL = { - warn: '滞销预警', - slow: '商品慢流', - stale: '商品呆滞', - zombie: '商品僵尸', -}; - -export const LEVEL_FILTER_OPTIONS = [ - { label: '全部级别', value: '' }, - { label: '滞销预警', value: 'warn' }, - { label: '商品慢流', value: 'slow' }, - { label: '商品呆滞', value: 'stale' }, - { label: '商品僵尸', value: 'zombie' }, -]; - -export const LEVEL_COLOR = { - warn: 'gold', - slow: 'orange', - stale: 'volcano', - zombie: 'magenta', -}; - -export function findWarehouseBySlug(slug) { - return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.slug === slug) || null; -} - -export function findWarehouseByName(name) { - return LARGE_STOCK_MONITOR_WAREHOUSES.find((entry) => entry.warehouse === name) || null; -} - -export const PAGE_KEY = 'large_stock_monitor'; -export const PAGE_PATH = '/planning/large-stock-monitor/:warehouseSlug'; -export const PAGE_NAME = '大货库存监控'; - -// 顶部"算式说明"提示 -export const FORMULA_DESCRIPTION = [ - '库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%(按业务库约定列名沿用「周转天数」,数值实际为周转率,单位 %)', - '月度发货总件数 ≈ threeAvgSale × 30 (业务库仅有最近 3 天日均销量)', - '月度日均库存件数 ≈ jsStock (业务库仅保留最近一天库存快照,作为日均近似)', - '数据截止时刻:库存快照日次日的凌晨 01:00(业务库每天凌晨 1 点跑批,快照标注为前一日 T-1)', - '库龄(月) = (数据截止时刻 - SKU 入库时间) / 30', - '分级口径:', - '· 滞销预警:库龄 < 3 个月 且 30% ≤ 周转天数 ≤ 199%', - '· 商品慢流:库龄 < 3 个月 且 周转天数 ≥ 200%;或 库龄 > 3 个月 且 30% ≤ 周转天数 ≤ 99%', - '· 商品呆滞:库龄 3~12 个月 且 周转天数 ≥ 100%;或 库龄 > 12 个月 且 100% ≤ 周转天数 ≤ 199%', - '· 商品僵尸:库龄 > 12 个月 且 周转天数 ≥ 200%', - '流水类字段(本期 = 快照所在自然月 1 日 00:00 ~ 数据截止时刻,左闭右开):', - '· 采购进仓:jst_purchase + jst_purchaseItem.inQty(Confirmed/Finished)', - '· 采购退货:jst_purchaseout + jst_purchaseoutItem.qty(Confirmed)', - '· 销售出仓:jst_salesOutboundInfo 关联 jst_OrderItem.qty(Confirmed)', - '· 销售退货:jst_returnAndRefundInfo + jst_returnAndRefundItem.r_qty(Confirmed,按仓库名匹配)', - '· 期初数据:业务库无上月末快照,由「期末 − 采购进仓 + 采购退货 + 销售出仓 − 销售退货」倒推(标「估」,倒推为负时显示「—」)', - '· 加工进仓 / 加工出仓 / 盘点 / 调拨出 / 调拨入 / 其它出库:业务库无对应单据表,无数据源', - '期末数量:聚水潭实际库存 jst_inventoryInfo(按 wms_co_id 单仓实物口径)', - '注意:pm_stockMonitor 的 warehouse 是「归属仓」,jsStock 含已调拨到外仓的货,不能代表本仓实物库存', - '四级库存量:只能由库存快照分级得出(实时库存表无入库时间与销量字段),口径为归属库存,与期末实际库存不同', -]; \ No newline at end of file diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js index 6c12aef..4545a75 100644 --- a/frontend/src/constants/sidebarPages.js +++ b/frontend/src/constants/sidebarPages.js @@ -4,7 +4,6 @@ const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: export const SIDEBAR_PAGES = [ { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, - { pageKey: 'large_stock_monitor', path: '/planning/large-stock-monitor/:warehouseSlug', name: '大货库存监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, ]; export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index 8d60f31..6cb7ddc 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -36,7 +36,6 @@ import { markDesktopEmbedSession, requestDesktopSessionRenewal, } from '../utils/desktopEmbedSession'; -import { LARGE_STOCK_MONITOR_WAREHOUSES } from '../constants/largeStockMonitor.js'; const AUTH_USER_STORAGE_KEY = 'auth_user'; const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; @@ -98,14 +97,6 @@ const SIDEBAR_ROUTE_MAP = { 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, }; -const LARGE_STOCK_MONITOR_ROUTE_ENTRIES = LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => [ - `stock-monitor-large-${entry.idx}`, - { path: `/planning/large-stock-monitor/${entry.slug}`, matchMode: 'prefix' }, -]); - -LARGE_STOCK_MONITOR_ROUTE_ENTRIES.forEach(([key, value]) => { - SIDEBAR_ROUTE_MAP[key] = value; -}); const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { if (entry.requiresAdmin && !canUseSettings) return []; diff --git a/frontend/src/pages/LargeStockMonitor/index.jsx b/frontend/src/pages/LargeStockMonitor/index.jsx deleted file mode 100644 index 171601a..0000000 --- a/frontend/src/pages/LargeStockMonitor/index.jsx +++ /dev/null @@ -1,502 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { - Alert, - Button, - Input, - Popover, - Segmented, - Skeleton, - Table, - Tag, - Tooltip, -} from 'antd'; -import { - InfoCircleOutlined, - QuestionCircleOutlined, - ReloadOutlined, - SearchOutlined, - WarningOutlined, -} from '@ant-design/icons'; -import { SycmBusinessPanel, SycmPage } from '../../components/Common'; -import { - findWarehouseBySlug, - FORMULA_DESCRIPTION, - LARGE_STOCK_MONITOR_WAREHOUSES, - LEVEL_COLOR, - LEVEL_FILTER_OPTIONS, - LEVEL_LABEL, - OVERVIEW_COLUMNS, -} from '../../constants/largeStockMonitor'; -import { - getLargeStockMonitorOverview, - getLargeStockMonitorSkuList, -} from '../../api/largeStockMonitor'; -import './style.css'; - -function formatNumber(value) { - if (value === null || value === undefined || Number.isNaN(Number(value))) return value; - const n = Number(value); - if (!Number.isFinite(n)) return value; - return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 }); -} - -function formatPercent(value) { - if (value === null || value === undefined || Number.isNaN(Number(value))) return value; - const n = Number(value); - if (!Number.isFinite(n)) return value; - return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })}%`; -} - -function formatMonths(value) { - if (value === null || value === undefined || Number.isNaN(Number(value))) return value; - const n = Number(value); - if (!Number.isFinite(n)) return value; - return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 月`; -} - -function formatStock(value) { - if (value === null || value === undefined || Number.isNaN(Number(value))) return value; - const n = Number(value); - if (!Number.isFinite(n)) return value; - return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 }); -} - -const OVERVIEW_RENDERERS = { - periodStart: formatStock, - openingBalance: formatStock, - purchaseReturn: formatStock, - purchaseIn: formatStock, - salesOut: formatStock, - salesReturn: formatStock, - closingQty: formatStock, - turnoverDays: formatPercent, - warnLevel: formatStock, - slowLevel: formatStock, - staleLevel: formatStock, - zombieLevel: formatStock, -}; - -const FormulaHelp = () => ( -
-
计算口径
-
    - {FORMULA_DESCRIPTION.map((line) => ( -
  • {line}
  • - ))} -
-
-); - -const LargeStockMonitor = () => { - const { warehouseSlug } = useParams(); - const navigate = useNavigate(); - - const warehouse = useMemo(() => { - if (!warehouseSlug) return null; - return findWarehouseBySlug(warehouseSlug) || LARGE_STOCK_MONITOR_WAREHOUSES[0] || null; - }, [warehouseSlug]); - - const [overview, setOverview] = useState(null); - const [overviewLoading, setOverviewLoading] = useState(true); - const [overviewError, setOverviewError] = useState(null); - - const [skuListState, setSkuListState] = useState({ - items: [], - total: 0, - page: 1, - pageSize: 20, - loading: false, - }); - const [filterLevel, setFilterLevel] = useState(''); - const [keyword, setKeyword] = useState(''); - - useEffect(() => { - if (!warehouse) { - setOverview(null); - setOverviewLoading(false); - return; - } - let cancelled = false; - setOverviewLoading(true); - setOverviewError(null); - getLargeStockMonitorOverview(warehouse.warehouse) - .then((res) => { - if (cancelled) return; - setOverview(res?.data || null); - }) - .catch((error) => { - if (cancelled) return; - setOverviewError(error?.message || '获取概览失败'); - setOverview(null); - }) - .finally(() => { - if (cancelled) return; - setOverviewLoading(false); - }); - return () => { - cancelled = true; - }; - }, [warehouse]); - - const reloadSkuList = (nextPage, nextPageSize, nextLevel, nextKeyword) => { - if (!warehouse) return; - setSkuListState((prev) => ({ ...prev, loading: true })); - getLargeStockMonitorSkuList({ - warehouse: warehouse.warehouse, - page: nextPage, - pageSize: nextPageSize, - level: nextLevel, - keyword: nextKeyword, - }) - .then((res) => { - setSkuListState({ - items: res?.data?.items || [], - total: res?.data?.total || 0, - page: nextPage, - pageSize: nextPageSize, - loading: false, - }); - }) - .catch(() => { - setSkuListState((prev) => ({ ...prev, loading: false })); - }); - }; - - useEffect(() => { - if (!warehouse) return; - reloadSkuList(1, skuListState.pageSize, filterLevel, keyword.trim()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [warehouse, filterLevel]); - - const handleTableChange = (pagination) => { - const nextPage = pagination.current || 1; - const nextPageSize = pagination.pageSize || 20; - reloadSkuList(nextPage, nextPageSize, filterLevel, keyword.trim()); - }; - - const handleSearch = () => { - reloadSkuList(1, skuListState.pageSize, filterLevel, keyword.trim()); - }; - - const overviewCells = useMemo(() => { - const map = overview?.overview || null; - return OVERVIEW_COLUMNS.map((column) => { - const value = map ? map[column.key] : '—'; - const rendered = column.available && value !== '—' && OVERVIEW_RENDERERS[column.key] - ? OVERVIEW_RENDERERS[column.key](value) - : value; - return { ...column, value, rendered }; - }); - }, [overview]); - - const skuColumns = useMemo(() => ([ - { - title: 'SKU', - dataIndex: 'sku', - key: 'sku', - width: 130, - fixed: 'left', - render: (value, record) => ( -
- {value || '-'} - {record.spu ? SPU {record.spu} : null} -
- ), - }, - { - title: '商品名称', - dataIndex: 'skuName', - key: 'skuName', - width: 260, - render: (value) => {value || '-'}, - }, - { - title: '分类', - dataIndex: 'category', - key: 'category', - width: 120, - render: (value) => value || '-', - }, - { - title: '供应商', - dataIndex: 'supplier', - key: 'supplier', - width: 140, - render: (value) => value || '-', - }, - { - title: '当前库存', - dataIndex: 'jsStock', - key: 'jsStock', - width: 100, - align: 'right', - render: formatStock, - }, - { - title: () => ( - - 库龄 - - - - - ), - dataIndex: 'ageMonths', - key: 'ageMonths', - width: 100, - align: 'right', - render: formatMonths, - }, - { - title: () => ( - - 月度发货 - - - - - ), - dataIndex: 'monthlyShipment', - key: 'monthlyShipment', - width: 110, - align: 'right', - render: formatNumber, - }, - { - title: () => ( - - 日均库存 - - - - - ), - dataIndex: 'avgDailyStock', - key: 'avgDailyStock', - width: 110, - align: 'right', - render: formatStock, - }, - { - title: () => ( - - 库存周转天数 - - - - - ), - dataIndex: 'turnoverDays', - key: 'turnoverDays', - width: 130, - align: 'right', - render: formatPercent, - }, - { - title: '级别', - dataIndex: 'level', - key: 'level', - width: 130, - fixed: 'right', - render: (value) => { - if (!value) return —; - return {LEVEL_LABEL[value] || value}; - }, - }, - ]), []); - - const siblingOptions = useMemo(() => LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({ - label: `${entry.group} · ${entry.warehouse.replace(/^简尚-/, '')}`, - value: entry.slug, - idx: entry.idx, - group: entry.group, - })), []); - - const fallbackNotice = warehouse && overview && !overview.hasData; - - return ( - - -
-
-

- 大货库存监控 · {warehouse ? warehouse.warehouse : '请选择仓库'} - } - title="计算口径" - trigger="hover" - placement="rightTop" - > - - -

-

- 二级分类:{warehouse ? warehouse.group : '-'} - 分布时间:{overview?.asOf || overview?.latestBizDate || '—'} - {overview?.latestBizDate ? 库存快照日:{overview.latestBizDate} : null} - {overview?.closingTakenAt ? ( - 库存取数时间:{new Date(overview.closingTakenAt).toLocaleString('zh-CN', { hour12: false })}(实时) - ) : null} - {overview?.period ? ( - - 流水区间:{overview.period.start} ~ {overview.period.end}(左闭右开) - - ) : null} - SKU 数:{overview?.skuCount ?? '—'} - 平均周转率:{overview?.turnoverDaysAvg != null ? formatPercent(overview.turnoverDaysAvg) : '—'} - 最大周转率:{overview?.turnoverDaysMax != null ? formatPercent(overview.turnoverDaysMax) : '—'} -

-
-
- { - if (value) { - navigate(`/planning/large-stock-monitor/${value}`); - } - }} - options={siblingOptions.slice(0, 6)} - className="large-stock-monitor-quick-switch" - /> - -
-
- - {overviewError ? ( - - ) : null} - - {fallbackNotice ? ( - } - message="业务库暂未同步该仓库的库存快照,周转率与四级库存量无数据源,显示「—」" - style={{ marginBottom: 12 }} - /> - ) : null} - - {overview && overview.hasGrade === false ? ( - - ) : null} - - {overview?.hasGrade && overview?.gradeBasisStock ? ( - } - message={`期末数量按聚水潭实际库存统计;四级库存量由库存快照分级得出,快照为「归属仓」口径(含已调拨到外仓的货,合计 ${formatStock(overview.gradeBasisStock)}),两者口径不同,不能直接相加核对`} - style={{ marginBottom: 12 }} - /> - ) : null} - - {overview?.requestedDate && overview?.fallbackDate ? ( - - ) : null} - - {overview?.requestedDate && !overview?.fallbackDate && !overview?.hasData ? ( - - ) : null} - -
- {overviewLoading ? ( - - ) : ( -
- {overviewCells.map((cell) => ( -
- - {cell.label} - {cell.estimated ? 估 : null} - {cell.tip ? ( - - - - ) : null} - - {cell.rendered} -
- ))} -
- )} -
-
- - -
- - 明细按库存快照(归属仓口径)分级,用于识别滞销/慢流/呆滞/僵尸 SKU - - - setKeyword(event.target.value)} - onSearch={handleSearch} - enterButton={<> 查询} - style={{ maxWidth: 320 }} - /> -
- -
`${record.sku}-${record.warehouse}`} - columns={skuColumns} - dataSource={skuListState.items} - loading={skuListState.loading} - scroll={{ x: 1280 }} - pagination={{ - current: skuListState.page, - pageSize: skuListState.pageSize, - total: skuListState.total, - showSizeChanger: true, - showTotal: (total) => `共 ${total} 条`, - pageSizeOptions: ['20', '50', '100'], - }} - onChange={handleTableChange} - locale={{ emptyText: '该仓库暂无 SKU 库存快照' }} - /> - - - ); -}; - -export default LargeStockMonitor; \ No newline at end of file diff --git a/frontend/src/pages/LargeStockMonitor/style.css b/frontend/src/pages/LargeStockMonitor/style.css deleted file mode 100644 index 535141b..0000000 --- a/frontend/src/pages/LargeStockMonitor/style.css +++ /dev/null @@ -1,149 +0,0 @@ -.large-stock-monitor-page { - display: flex; - flex-direction: column; - gap: var(--sycm-space-md); - padding: var(--sycm-space-md); -} - -.large-stock-monitor-header__top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; -} - -.large-stock-monitor-header__top h2 { - margin: 0 0 4px; - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); -} - -.large-stock-monitor-header__meta { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin: 0; - color: var(--sycm-text-secondary); - font-size: var(--sycm-font-size-caption); -} - -.large-stock-monitor-header__actions { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.large-stock-monitor-quick-switch { - max-width: 480px; -} - -.large-stock-monitor-overview { - margin-top: 12px; -} - -.large-stock-monitor-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 12px; -} - -.large-stock-monitor-grid__cell { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px; - border-radius: var(--sycm-radius-sm); - background: var(--sycm-bg-subtle); - border: 1px solid transparent; -} - -.large-stock-monitor-grid__cell.is-available { - background: var(--sycm-bg-elevated, #fff); - border-color: var(--sycm-color-primary-border); -} - -.large-stock-monitor-grid__cell.is-placeholder { - color: var(--sycm-text-tertiary); -} - -.large-stock-monitor-grid__label { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: var(--sycm-font-size-caption); - font-weight: var(--sycm-font-weight-medium); - color: var(--sycm-text-secondary); -} - -/* 估算值标记:由已有字段推导,非业务库原始字段 */ -.large-stock-monitor-grid__est { - padding: 0 4px; - border-radius: 3px; - background: var(--sycm-color-warning-bg, #fffbe6); - border: 1px solid var(--sycm-color-warning-border, #ffe58f); - color: var(--sycm-color-warning-text, #d46b08); - font-size: var(--sycm-font-size-micro); - font-style: normal; - line-height: 16px; -} - -.large-stock-monitor-grid__help { - color: var(--sycm-text-tertiary); - cursor: help; - font-size: var(--sycm-font-size-caption); -} - -.large-stock-monitor-grid__value { - font-size: var(--sycm-font-size-title); - font-weight: var(--sycm-font-weight-semibold); - color: var(--sycm-text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.large-stock-monitor-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} - -.large-stock-monitor-toolbar__note { - font-size: 12px; - color: #8c8c8c; - line-height: 1.5; -} - -.large-stock-monitor-data-table .large-stock-monitor-sku-cell { - display: flex; - flex-direction: column; - line-height: 1.3; -} - -.large-stock-monitor-data-table .large-stock-monitor-sku-cell__meta { - color: var(--sycm-text-tertiary); - font-size: var(--sycm-font-size-micro); -} - -.large-stock-monitor-formula { - max-width: 480px; - font-size: var(--sycm-font-size-caption); - color: var(--sycm-text-secondary); -} - -.large-stock-monitor-formula__title { - font-weight: var(--sycm-font-weight-semibold); - color: var(--sycm-text-primary); - margin-bottom: 8px; -} - -.large-stock-monitor-formula ul { - margin: 0; - padding-left: 18px; - line-height: 1.7; -} diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index 78cd981..0c64a0c 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -19,7 +19,6 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); const MkhBomData = lazy(() => import('../pages/MkhBomData')); -const LargeStockMonitor = lazy(() => import('../pages/LargeStockMonitor')); const lazyLoadingElement = ; @@ -132,10 +131,6 @@ const router = createBrowserRouter([ path: 'planning/dev-style-preview', element: renderLazyComponent(DevelopmentStylePreview), }, - { - path: 'planning/large-stock-monitor/:warehouseSlug', - element: renderLazyComponent(LargeStockMonitor), - }, { path: 'settings', element: renderLazyComponent(Settings), From 37c1adf4f25daed4df6c595957b2486b6f34a678 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:39 +0800 Subject: [PATCH 43/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/gen-daily-inventory-baseline.cjs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 backend/scripts/gen-daily-inventory-baseline.cjs diff --git a/backend/scripts/gen-daily-inventory-baseline.cjs b/backend/scripts/gen-daily-inventory-baseline.cjs new file mode 100644 index 0000000..8ed220f --- /dev/null +++ b/backend/scripts/gen-daily-inventory-baseline.cjs @@ -0,0 +1,106 @@ +process.env.CANGKU_SKIP_BOOTSTRAP = '1'; +const fs = require('fs'); +const path = require('path'); +const XLSX = require('xlsx'); +const cloudDb = require('../src/config/cloudDatabase'); + +// 表格的 12 个流水列 → 配置字段名 +const FLOW_FIELDS = [ + ['加工进仓', 'processIn'], + ['采购退货', 'purchaseReturn'], + ['采购进仓', 'purchaseIn'], + ['其它进仓', 'otherIn'], + ['其它退货', 'otherReturn'], + ['销售出仓', 'salesOut'], + ['盘点', 'stocktaking'], + ['加工出仓', 'processOut'], + ['调拨出', 'transferOut'], + ['销售退货', 'salesReturn'], + ['调拨入', 'transferIn'], + ['其它出库', 'otherOut'], +]; + +(async () => { + const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx'); + const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' }); + const h = rows[0]; + const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i])); + + const level1 = String(rows[1][idx['一级功能按键']]).trim(); + const level2 = String(rows[1][idx['二级功能按键']]).trim(); + console.log('一级功能:', level1, '| 二级功能:', level2); + + const whs = await cloudDb.query(`SELECT name, wms_co_id FROM rpt.jst_warehouseInfo`); + const byName = new Map(whs.map((w) => [String(w.name).trim(), w.wms_co_id])); + + const records = []; + const missing = []; + for (const r of rows.slice(1)) { + const sheetName = String(r[idx['仓库']] || '').trim(); + if (!sheetName) continue; + const base = sheetName.replace(/主仓$/, ''); + const wmsCoId = byName.get(base) ?? byName.get(sheetName) ?? null; + if (wmsCoId == null) missing.push(sheetName); + + const num = (k) => { + const v = r[idx[k]]; + return typeof v === 'number' ? v : Number(String(v || '').replace(/,/g, '')) || 0; + }; + + const flows = Object.fromEntries(FLOW_FIELDS.map(([cn, en]) => [en, num(cn)])); + const openExtra = r[idx['期初']]; // 第二个「期初」列,索引会与「期初数量」冲突,单独处理 + records.push({ + warehouse: sheetName, + dbWarehouse: base, + wmsCoId, + openingQty: num('期初数量'), + openingQtyExtra: typeof openExtra === 'number' ? openExtra : Number(openExtra) || 0, + ...flows, + }); + } + + console.log('记录数:', records.length, '| 缺 wms_co_id:', missing.length, missing.join(',') || '(无)'); + + const FLOW_COLUMNS_DEF = FLOW_FIELDS.map(([cn, en]) => ({ label: cn, field: en })); + const out = `/** + * 每日进销存 —— 2026-09-22 进销存基准流水 + * + * 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出) + * 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库 + * 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。 + * + * 口径约定: + * - openingQty 与 12 个流水列:22 号基准值(本文件静态存储) + * - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确) + * - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算 + */ + +/** 一级/二级菜单名(取自表格) */ +const DAILY_INVENTORY_LEVEL1 = '${level1}'; +const DAILY_INVENTORY_LEVEL2 = '${level2}'; + +/** 基准数据日期 */ +const BASELINE_DATE = '2026-09-22'; + +/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */ +const FLOW_COLUMNS = ${JSON.stringify(FLOW_COLUMNS_DEF, null, 2)}; + +/** 25 个仓库的 22 号基准流水 */ +const DAILY_INVENTORY_BASELINE = ${JSON.stringify(records, null, 2)}; + +module.exports = { + DAILY_INVENTORY_LEVEL1, + DAILY_INVENTORY_LEVEL2, + BASELINE_DATE, + FLOW_COLUMNS, + DAILY_INVENTORY_BASELINE, +}; +`; + + const target = path.join(__dirname, '../src/config/dailyInventoryBaseline.js'); + fs.writeFileSync(target, out, 'utf8'); + console.log('已生成:', target); + console.log('\n前 2 条预览:'); + console.log(JSON.stringify(records.slice(0, 2), null, 1)); + process.exit(0); +})().catch((e) => { console.log('ERR', e.message); process.exit(1); }); From 700d7ae96d9745924e0ba85cb82c58eef68362b0 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:40 +0800 Subject: [PATCH 44/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/scripts/verify-daily-inventory.js | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 backend/scripts/verify-daily-inventory.js diff --git a/backend/scripts/verify-daily-inventory.js b/backend/scripts/verify-daily-inventory.js new file mode 100644 index 0000000..38c1ec1 --- /dev/null +++ b/backend/scripts/verify-daily-inventory.js @@ -0,0 +1,96 @@ +process.env.CANGKU_SKIP_BOOTSTRAP = '1'; +const http = require('http'); +const XLSX = require('xlsx'); + +const FIELD_MAP = [ + ['期初数量', 'openingQty'], + ['加工进仓', 'processIn'], + ['采购退货', 'purchaseReturn'], + ['采购进仓', 'purchaseIn'], + ['其它进仓', 'otherIn'], + ['其它退货', 'otherReturn'], + ['销售出仓', 'salesOut'], + ['盘点', 'stocktaking'], + ['加工出仓', 'processOut'], + ['调拨出', 'transferOut'], + ['销售退货', 'salesReturn'], + ['调拨入', 'transferIn'], + ['期初', 'openingQtyExtra'], + ['其它出库', 'otherOut'], + ['期末数量', 'closingQty'], +]; + +function req(method, path, token, body) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const r = http.request({ + host: '127.0.0.1', port: 4001, path, method, + headers: { + 'Content-Type': 'application/json', + ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), + ...(token ? { Authorization: 'Bearer ' + token } : {}), + }, + }, (resp) => { let s = ''; resp.on('data', (d) => { s += d; }); resp.on('end', () => resolve({ code: resp.statusCode, body: s })); }); + r.on('error', reject); + if (data) r.write(data); + r.end(); + }); +} + +(async () => { + const login = JSON.parse((await req('POST', '/api/auth/local-preview', null, {})).body); + const token = login.access_token || login.token; + const res = await req('GET', '/api/daily-inventory', token); + if (res.code !== 200) { console.log('接口失败', res.code, res.body.slice(0, 300)); process.exit(1); } + const d = JSON.parse(res.body).data; + + const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx'); + const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' }); + const h = rows[0]; + const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i])); + const byName = new Map(d.items.map((it) => [it.warehouse, it])); + + console.log('接口: 基准日 %s | 快照日 %s | asOf %s | 仓数 %d', d.baselineDate, d.latestBizDate, d.asOf, d.items.length); + console.log(); + + const diffCounts = {}; + let totalCells = 0; + let diffCells = 0; + const samples = []; + + for (const r of rows.slice(1)) { + const name = String(r[idx['仓库']] || '').trim(); + if (!name) continue; + const item = byName.get(name); + if (!item) { console.log('接口缺少仓库:', name); continue; } + for (const [cn, field] of FIELD_MAP) { + const raw = r[idx[cn]]; + const expected = typeof raw === 'number' ? raw : Number(String(raw || '').replace(/,/g, '')) || 0; + const actual = Number(item[field] ?? 0); + totalCells += 1; + // 期末允许 1 件内的副本同步误差 + const tol = field === 'closingQty' ? 10 : 0; + if (Math.abs(expected - actual) > tol) { + diffCells += 1; + diffCounts[cn] = (diffCounts[cn] || 0) + 1; + if (samples.length < 12) samples.push(`${name} | ${cn} | 表格 ${expected} vs 接口 ${actual}`); + } + } + } + + console.log('比对单元格总数: %d,不一致: %d', totalCells, diffCells); + if (diffCells) { + console.log('\n不一致列统计:', JSON.stringify(diffCounts)); + console.log('样例:'); + samples.forEach((s) => console.log(' ' + s)); + } else { + console.log('✓ 全部单元格与表格一致'); + } + + const withGrade = d.items.filter((x) => x.hasGrade).length; + console.log('\n有分级数据仓: %d/%d', withGrade, d.items.length); + console.log('无分级的仓:', d.items.filter((x) => !x.hasGrade).map((x) => x.warehouse).join('、')); + console.log('\n合计: 期初 %s,期末 %s', d.totals.openingQty, d.totals.closingQty); + console.log('崔桥样例:', JSON.stringify(d.items[0], null, 1).replace(/\n\s*/g, ' ')); + process.exit(0); +})().catch((e) => { console.log('ERR', e.message); process.exit(1); }); From f6b755aac45f92cf05c614d38f618940139ba3cb Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:41 +0800 Subject: [PATCH 45/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/dailyInventory.js | 179 ++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 backend/src/api/dailyInventory.js diff --git a/backend/src/api/dailyInventory.js b/backend/src/api/dailyInventory.js new file mode 100644 index 0000000..7bdc9a1 --- /dev/null +++ b/backend/src/api/dailyInventory.js @@ -0,0 +1,179 @@ +const express = require('express'); +const cloudDb = require('../config/cloudDatabase'); +const { + DAILY_INVENTORY_BASELINE, + BASELINE_DATE, + FLOW_COLUMNS, +} = require('../config/dailyInventoryBaseline'); + +const router = express.Router(); + +/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */ +const AS_OF_TIME = '01:00'; + +const toNumber = (v) => { + if (v == null) return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +}; + +/** 快照日 + 次日 01:00 作为统一的库龄/分级基准 */ +function nextDayAsOf(bizDate) { + if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null; + const d = new Date(`${bizDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`; +} + +async function fetchLatestBizDate() { + const rows = await cloudDb.query( + `SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor` + ); + return rows[0]?.d || null; +} + +/** + * 派生列(SQL CASE WHEN 下沉计算) + * - 库龄(天) = DATEDIFF(asOf, created) + * - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100% + * 月度发货总件数 = threeAvgSale × 30;月度日均库存件数 = jsStock + * - 四级分级:库龄 + 周转率双维度 + */ +function buildDerivedColumns(asOf) { + const A = '@asOf'; + void A; + const ageDays = `DATEDIFF(DAY, m.created, @asOf)`; + const turnover = `(ISNULL(m.threeAvgSale, 0) * 30.0 * 100.0 / NULLIF(m.jsStock, 0))`; + return { + ageDays, + turnoverDays: `CAST(${turnover} AS DECIMAL(18, 4))`, + levelExpr: ` + CASE + WHEN m.created IS NULL OR ISNULL(m.jsStock, 0) = 0 OR ${turnover} IS NULL THEN NULL + WHEN ${ageDays} < 90 AND ${turnover} BETWEEN 30 AND 199 THEN 'warn' + WHEN ${ageDays} < 90 AND ${turnover} >= 200 THEN 'slow' + WHEN ${ageDays} >= 90 AND ${turnover} BETWEEN 30 AND 99 THEN 'slow' + WHEN ${ageDays} BETWEEN 90 AND 365 AND ${turnover} >= 100 THEN 'stale' + WHEN ${ageDays} > 365 AND ${turnover} BETWEEN 100 AND 199 THEN 'stale' + WHEN ${ageDays} > 365 AND ${turnover} >= 200 THEN 'zombie' + ELSE NULL + END`, + }; +} + +/** 该仓快照里的四级库存量与平均周转天数 */ +async function fetchGradeMetrics(dbWarehouse, bizDate, asOf) { + const D = buildDerivedColumns(asOf); + const rows = await cloudDb.query( + `SELECT COUNT(*) AS skuCount, + SUM(CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2))) AS gradeStock, + AVG(${D.turnoverDays}) AS avgTurnover, + SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel, + SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel, + SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel, + SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS zombieLevel + FROM rpt.pm_stockMonitor m + WHERE m.warehouse = @warehouse + AND CONVERT(VARCHAR(10), m.date, 23) = @bizDate`, + { warehouse: dbWarehouse, bizDate, asOf } + ); + const r = rows[0] || {}; + if (!Number(r.skuCount || 0)) return null; + return { + skuCount: toNumber(r.skuCount), + gradeStock: toNumber(r.gradeStock), + turnoverDays: toNumber(r.avgTurnover), + warnLevel: toNumber(r.warnLevel), + slowLevel: toNumber(r.slowLevel), + staleLevel: toNumber(r.staleLevel), + zombieLevel: toNumber(r.zombieLevel), + }; +} + +/** 实时期末库存(单仓 wms_co_id),并带回最近更新时间便于核对时效 */ +async function fetchClosingQty(wmsCoId) { + if (wmsCoId == null) return null; + const rows = await cloudDb.query( + `SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS qty, + COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount, + MAX(modified) AS takenAt + FROM rpt.jst_inventoryInfo + WHERE wms_co_id = @wmsCoId`, + { wmsCoId } + ); + const r = rows[0] || {}; + return { + closingQty: toNumber(r.qty), + closingSkuCount: toNumber(r.skuCount) || 0, + closingTakenAt: r.takenAt || null, + }; +} + +/** + * GET /api/daily-inventory + * 每日进销存:25 个仓库 × 进销存流水 + 期末 + 分级库存量 + * + * 数据源分工: + * - 期初数量 与 12 个流水列:22 号基准(dailyInventoryBaseline.js) + * - 期末数量:jst_inventoryInfo 实时库存 + * - 库存周转天数 / 四级库存量:pm_stockMonitor 快照 + */ +router.get('/', async (req, res) => { + try { + const latestBizDate = await fetchLatestBizDate().catch(() => null); + const asOf = nextDayAsOf(latestBizDate); + + const items = []; + for (const base of DAILY_INVENTORY_BASELINE) { + const flows = {}; + for (const col of FLOW_COLUMNS) flows[col.field] = base[col.field] ?? 0; + + const closing = await fetchClosingQty(base.wmsCoId).catch(() => null); + const grade = base.dbWarehouse && latestBizDate + ? await fetchGradeMetrics(base.dbWarehouse, latestBizDate, asOf).catch(() => null) + : null; + + items.push({ + warehouse: base.warehouse, + dbWarehouse: base.dbWarehouse, + openingQty: base.openingQty, + openingQtyExtra: base.openingQtyExtra, + ...flows, + closingQty: closing?.closingQty ?? null, + closingSkuCount: closing?.closingSkuCount ?? null, + closingTakenAt: closing?.closingTakenAt ?? null, + turnoverDays: grade?.turnoverDays ?? null, + gradeStock: grade?.gradeStock ?? null, + warnLevel: grade?.warnLevel ?? null, + slowLevel: grade?.slowLevel ?? null, + staleLevel: grade?.staleLevel ?? null, + zombieLevel: grade?.zombieLevel ?? null, + hasGrade: Boolean(grade), + }); + } + + // 合计行:便于与表格总额核对 + const sum = (k) => items.reduce((acc, it) => acc + (Number(it[k]) || 0), 0); + const totals = { warehouse: '合计', dbWarehouse: '', isTotal: true }; + for (const k of ['openingQty', 'openingQtyExtra', ...FLOW_COLUMNS.map((c) => c.field), 'closingQty', 'warnLevel', 'slowLevel', 'staleLevel', 'zombieLevel']) { + totals[k] = Number(sum(k).toFixed(2)); + } + + res.json({ + success: true, + data: { + baselineDate: BASELINE_DATE, + latestBizDate, + asOf, + flowColumns: FLOW_COLUMNS, + items, + totals, + }, + }); + } catch (error) { + console.error('[daily-inventory] 查询失败:', error.message); + res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message }); + } +}); + +module.exports = router; From 8655433e392e2d8804c6080b90787cafc4222bcb Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:42 +0800 Subject: [PATCH 46/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/config/dailyInventoryBaseline.js | 558 +++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 backend/src/config/dailyInventoryBaseline.js diff --git a/backend/src/config/dailyInventoryBaseline.js b/backend/src/config/dailyInventoryBaseline.js new file mode 100644 index 0000000..f8a9c5f --- /dev/null +++ b/backend/src/config/dailyInventoryBaseline.js @@ -0,0 +1,558 @@ +/** + * 每日进销存 —— 2026-09-22 进销存基准流水 + * + * 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出) + * 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库 + * 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。 + * + * 口径约定: + * - openingQty 与 12 个流水列:22 号基准值(本文件静态存储) + * - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确) + * - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算 + */ + +/** 一级/二级菜单名(取自表格) */ +const DAILY_INVENTORY_LEVEL1 = '大货库存监控'; +const DAILY_INVENTORY_LEVEL2 = '每日进销存'; + +/** 基准数据日期 */ +const BASELINE_DATE = '2026-09-22'; + +/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */ +const FLOW_COLUMNS = [ + { + "label": "加工进仓", + "field": "processIn" + }, + { + "label": "采购退货", + "field": "purchaseReturn" + }, + { + "label": "采购进仓", + "field": "purchaseIn" + }, + { + "label": "其它进仓", + "field": "otherIn" + }, + { + "label": "其它退货", + "field": "otherReturn" + }, + { + "label": "销售出仓", + "field": "salesOut" + }, + { + "label": "盘点", + "field": "stocktaking" + }, + { + "label": "加工出仓", + "field": "processOut" + }, + { + "label": "调拨出", + "field": "transferOut" + }, + { + "label": "销售退货", + "field": "salesReturn" + }, + { + "label": "调拨入", + "field": "transferIn" + }, + { + "label": "其它出库", + "field": "otherOut" + } +]; + +/** 25 个仓库的 22 号基准流水 */ +const DAILY_INVENTORY_BASELINE = [ + { + "warehouse": "简尚-崔桥工厂仓主仓", + "dbWarehouse": "简尚-崔桥工厂仓", + "wmsCoId": 15038426, + "openingQty": 92388, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -3924, + "stocktaking": -1, + "processOut": 0, + "transferOut": -1461, + "salesReturn": 0, + "transferIn": 5437, + "otherOut": 0 + }, + { + "warehouse": "简尚-山东青岗集仓主仓", + "dbWarehouse": "简尚-山东青岗集仓", + "wmsCoId": 15137523, + "openingQty": 88377, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 886, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -1058, + "stocktaking": -1, + "processOut": 0, + "transferOut": -11, + "salesReturn": 0, + "transferIn": 23, + "otherOut": 0 + }, + { + "warehouse": "简尚-河南洛宁仓主仓", + "dbWarehouse": "简尚-河南洛宁仓", + "wmsCoId": 15539084, + "openingQty": 83305, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -314, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 15, + "otherOut": 0 + }, + { + "warehouse": "简尚-新苏北仓主仓", + "dbWarehouse": "简尚-新苏北仓", + "wmsCoId": 15038347, + "openingQty": 77145, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -1744, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 649, + "otherOut": 0 + }, + { + "warehouse": "简尚-横林二仓主仓", + "dbWarehouse": "简尚-横林二仓", + "wmsCoId": 15033302, + "openingQty": 63780, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -982, + "stocktaking": 0, + "processOut": 0, + "transferOut": -17, + "salesReturn": 0, + "transferIn": 728, + "otherOut": 0 + }, + { + "warehouse": "简尚-山东品牌仓主仓", + "dbWarehouse": "简尚-山东品牌仓", + "wmsCoId": 15182705, + "openingQty": 41025, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 150, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -157, + "stocktaking": 0, + "processOut": 0, + "transferOut": -5, + "salesReturn": 0, + "transferIn": 4, + "otherOut": 0 + }, + { + "warehouse": "简尚-睢宁仓主仓", + "dbWarehouse": "简尚-睢宁仓", + "wmsCoId": 15032603, + "openingQty": 25761, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -922, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 333, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部专用仓主仓", + "dbWarehouse": "简尚-分销部专用仓", + "wmsCoId": 15045426, + "openingQty": 6380, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -264, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 200, + "otherOut": 0 + }, + { + "warehouse": "简尚-新外贸仓主仓", + "dbWarehouse": "简尚-新外贸仓", + "wmsCoId": 15045308, + "openingQty": 5165, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "京仓-横林二仓京东自营仓主仓", + "dbWarehouse": "京仓-横林二仓京东自营仓", + "wmsCoId": 15835893, + "openingQty": 4260, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部睢宁专用仓主仓", + "dbWarehouse": "简尚-分销部睢宁专用仓", + "wmsCoId": 15051960, + "openingQty": 3711, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-崔桥工厂品牌仓主仓", + "dbWarehouse": "简尚-崔桥工厂品牌仓", + "wmsCoId": 15870495, + "openingQty": 1831, + "openingQtyExtra": 0, + "processIn": 94, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -53, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部苏北专用仓主仓", + "dbWarehouse": "简尚-分销部苏北专用仓", + "wmsCoId": 15045325, + "openingQty": 1440, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C横林二仓专用仓主仓", + "dbWarehouse": "简尚-运营C横林二仓专用仓", + "wmsCoId": 15301814, + "openingQty": 2141, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -992, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-济南外贸仓主仓", + "dbWarehouse": "简尚-济南外贸仓", + "wmsCoId": 15051888, + "openingQty": 712, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部崔桥专用仓主仓", + "dbWarehouse": "简尚-分销部崔桥专用仓", + "wmsCoId": 15404402, + "openingQty": 620, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C崔桥工厂仓专用仓主仓", + "dbWarehouse": "简尚-运营C崔桥工厂仓专用仓", + "wmsCoId": 15301815, + "openingQty": 286, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营D苏北专用仓主仓", + "dbWarehouse": "简尚-运营D苏北专用仓", + "wmsCoId": 15406876, + "openingQty": 280, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C睢宁专用仓主仓", + "dbWarehouse": "简尚-运营C睢宁专用仓", + "wmsCoId": 15301813, + "openingQty": 193, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C新苏北专用仓主仓", + "dbWarehouse": "简尚-运营C新苏北专用仓", + "wmsCoId": 15301812, + "openingQty": 155, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营D睢宁专用仓主仓", + "dbWarehouse": "简尚-运营D睢宁专用仓", + "wmsCoId": 15051907, + "openingQty": 109, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -1, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-山东青岗集京东仓主仓", + "dbWarehouse": "简尚-山东青岗集京东仓", + "wmsCoId": 15406664, + "openingQty": 90, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营D专用仓库主仓", + "dbWarehouse": "简尚-运营D专用仓库", + "wmsCoId": 15051915, + "openingQty": 180, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -125, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-崔桥工厂京东仓主仓", + "dbWarehouse": "简尚-崔桥工厂京东仓", + "wmsCoId": 15045345, + "openingQty": 1, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "京仓-横林二仓京东云仓主仓", + "dbWarehouse": "京仓-横林二仓京东云仓", + "wmsCoId": 15197138, + "openingQty": -3082, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -48, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + } +]; + +module.exports = { + DAILY_INVENTORY_LEVEL1, + DAILY_INVENTORY_LEVEL2, + BASELINE_DATE, + FLOW_COLUMNS, + DAILY_INVENTORY_BASELINE, +}; From 855586d5dea6331fe274d1e66d86ef752f76e052 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:44 +0800 Subject: [PATCH 47/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/dailyInventory.js | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 frontend/src/api/dailyInventory.js diff --git a/frontend/src/api/dailyInventory.js b/frontend/src/api/dailyInventory.js new file mode 100644 index 0000000..e844361 --- /dev/null +++ b/frontend/src/api/dailyInventory.js @@ -0,0 +1,9 @@ +import request from './request'; + +const LIST_TIMEOUT_MS = 5 * 60 * 1000; + +// 每日进销存:25 仓 × 进销存流水 + 实时期末 + 分级库存量 +export const getDailyInventory = () => request.get( + '/api/daily-inventory', + { timeout: LIST_TIMEOUT_MS } +); From 819cd08de259a330ed5f59b2da8c9263fae23eba Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:45 +0800 Subject: [PATCH 48/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/dailyInventory.js | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 frontend/src/constants/dailyInventory.js diff --git a/frontend/src/constants/dailyInventory.js b/frontend/src/constants/dailyInventory.js new file mode 100644 index 0000000..c66257f --- /dev/null +++ b/frontend/src/constants/dailyInventory.js @@ -0,0 +1,61 @@ +/** + * 每日进销存 —— 列定义 + * + * 表头取自桌面《库存监控.xlsx》,顺序与表格一致。 + * source 标记该列数据来源,便于在页面上如实标注口径: + * - baseline:22 号基准值(业务库无对应单据表,无法还原) + * - runtime:运行时从业务库实时取数 + */ + +export const COLUMN_SOURCE = { + baseline: 'baseline', + runtime: 'runtime', +}; + +/** 表格存在两个名为「期初」的列,第二个加后缀区分 */ +export const DAILY_INVENTORY_COLUMNS = [ + { key: 'warehouse', label: '仓库', source: 'baseline', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' }, + { key: 'openingQty', label: '期初数量', source: 'baseline', tip: '22 号进销存基准值。业务库无按天的历史库存结存表,无法还原,取自 9-22 导出数据' }, + { key: 'processIn', label: '加工进仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' }, + { key: 'purchaseReturn', label: '采购退货', source: 'baseline', tip: '22 号基准值(业务库 jst_purchaseout 虽有单据,但数值与该进销存口径不一致)' }, + { key: 'purchaseIn', label: '采购进仓', source: 'baseline', tip: '22 号基准值(同上,业务库数值与进销存口径不一致)' }, + { key: 'otherIn', label: '其它进仓', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' }, + { key: 'otherReturn', label: '其它退货', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' }, + { key: 'salesOut', label: '销售出仓', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库 jst_salesOutboundInfo 数值与该进销存口径不一致' }, + { key: 'stocktaking', label: '盘点', source: 'baseline', tip: '22 号基准值。业务库无盘点单据表' }, + { key: 'processOut', label: '加工出仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' }, + { key: 'transferOut', label: '调拨出', source: 'baseline', tip: '22 号基准值(负数为调出)。业务库无调拨单据表' }, + { key: 'salesReturn', label: '销售退货', source: 'baseline', tip: '22 号基准值(业务库 jst_returnAndRefundInfo 数值与该进销存口径不一致)' }, + { key: 'transferIn', label: '调拨入', source: 'baseline', tip: '22 号基准值。业务库无调拨单据表' }, + { key: 'openingQtyExtra', label: '期初(2)', source: 'baseline', tip: '表格中的第二个「期初」列(9-22 导出值)' }, + { key: 'otherOut', label: '其它出库', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库无对应单据表' }, + { key: 'closingQty', label: '期末数量', source: 'runtime', tip: '聚水潭实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(与表格 22 号期末一致,24/25 仓精确匹配)' }, + { key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%(全员均值),数据来自 pm_stockMonitor' }, + { key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计' }, + { key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: '库龄 < 3 个月且周转率 ≥ 200%,或库龄 ≥ 3 个月且周转率 30%~99% 的 SKU 库存合计' }, + { key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: '库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的 SKU 库存合计' }, + { key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: '库龄 > 12 个月 且 周转率 ≥ 200% 的 SKU 库存合计' }, +]; + +/** 口径说明弹框文案 */ +export const FORMULA_DESCRIPTION = [ + '数据来源说明:', + '· 期初数量 / 加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库:', + ' 取自 2026-09-22 聚水潭进销存导出值。业务库 JSReportReplica 无历史库存结存表,', + ' 也缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据数值与该进销存口径不一致,故采用导出值。', + '· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。', + '· 库存周转天数 / 四级库存量:运行时由库存快照 rpt.pm_stockMonitor 计算,基准时刻为快照日次日 01:00。', + '', + '算式:', + '· 期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入', + ' − 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库', + '· 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%', + ' 其中 月度发货总件数 = threeAvgSale × 30,月度日均库存件数 = jsStock', + '· 库存分级(库龄 + 周转率双维度):', + ' 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警', + ' 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流', + ' 库龄 ≥ 3 个月 且 周转率 30%~99% → 商品慢流', + ' 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞', + ' 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞', + ' 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸', +]; From d3f36d53596906b171804eb02a7955dcdf3db1c1 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:47 +0800 Subject: [PATCH 49/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/DailyInventory/index.jsx | 177 ++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 frontend/src/pages/DailyInventory/index.jsx diff --git a/frontend/src/pages/DailyInventory/index.jsx b/frontend/src/pages/DailyInventory/index.jsx new file mode 100644 index 0000000..d22ac5a --- /dev/null +++ b/frontend/src/pages/DailyInventory/index.jsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Modal, Space, Spin, Table, Tooltip, Typography } from 'antd'; +import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { getDailyInventory } from '../../api/dailyInventory'; +import { + DAILY_INVENTORY_COLUMNS, + COLUMN_SOURCE, + FORMULA_DESCRIPTION, +} from '../../constants/dailyInventory.js'; +import './style.css'; + +const { Title, Text } = Typography; + +const TipTitle = ({ tip, children }) => ( + + {children} + +); + +const int = (value) => { + if (value === null || value === undefined || value === '') return '—'; + return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); +}; + +const decimal = (value, digits = 1) => { + if (value === null || value === undefined || value === '') return '—'; + return Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +}; + +/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */ +const renderSigned = (value) => { + if (value === null || value === undefined || value === '') return '—'; + const n = Number(value); + if (!Number.isFinite(n) || n === 0) return 0; + return {int(n)}; +}; + +const DailyInventory = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [data, setData] = useState(null); + const [helpOpen, setHelpOpen] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await getDailyInventory(); + setData(res?.data ?? null); + } catch (err) { + setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败'); + setData(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const items = useMemo(() => data?.items ?? [], [data]); + + const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => { + const isRuntime = col.source === COLUMN_SOURCE.runtime; + return { + title: {col.label}, + dataIndex: col.key, + key: col.key, + align: col.key === 'warehouse' ? 'left' : 'right', + width: col.key === 'warehouse' ? 240 : 130, + fixed: col.key === 'warehouse' ? 'left' : undefined, + className: isRuntime ? 'daily-inventory-col--runtime' : undefined, + render: (value, record) => { + if (record?.isTotal) { + // 合计行:周转天数不累加,显示「—」 + if (col.key === 'turnoverDays') return '—'; + return int(value); + } + if (col.key === 'warehouse') return {value}; + if (col.key === 'closingQty') return int(value); + if (col.key === 'turnoverDays') return decimal(value, 1); + return renderSigned(value); + }, + }; + }), []); + + const tableRows = useMemo(() => { + if (!data?.totals) return items; + return [...items, { ...data.totals, key: '__total__' }]; + }, [items, data]); + + const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]); + + return ( + +
+
+ 每日进销存 + +
+
+ 基准日期:{data?.baselineDate || '—'} + 库存快照日:{data?.latestBizDate || '—'} + 分级基准时刻:{data?.asOf || '—'} + {data?.items?.[0]?.closingTakenAt ? ( + + 库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })} + + ) : null} +
+ + + +
+ + + 期初数量与 12 个流水列取自 {data?.baselineDate || '2026-09-22'} 聚水潭进销存导出值(业务库缺少调拨/盘点/加工/其它出入库单据表,无法还原); + 期末数量与右侧 5 列为运行时实时取数。悬浮表头可看每列口径。 + {noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在库存快照中无数据,周转天数与四级库存量显示「—」。` : ''} + + } + style={{ marginBottom: 12 }} + /> + + {error ? : null} + + +
record.key || record.warehouse} + size="small" + bordered + pagination={false} + scroll={{ x: 'max-content', y: 'calc(100vh - 300px)' }} + rowClassName={(record) => (record?.isTotal ? 'daily-inventory-row--total' : '')} + /> + + + + 共 {items.length} 个仓库;蓝色底纹列为运行时取数,其余为基准值。 + + + setHelpOpen(false)} + footer={null} + width={720} + > +
+ {FORMULA_DESCRIPTION.map((line, i) => ( +

{line}

+ ))} +
+
+ + ); +}; + +export default DailyInventory; From 23e576d26e8e2f4a354517679440d8fed0cad774 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:37:48 +0800 Subject: [PATCH 50/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/DailyInventory/style.css | 91 +++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 frontend/src/pages/DailyInventory/style.css diff --git a/frontend/src/pages/DailyInventory/style.css b/frontend/src/pages/DailyInventory/style.css new file mode 100644 index 0000000..2019966 --- /dev/null +++ b/frontend/src/pages/DailyInventory/style.css @@ -0,0 +1,91 @@ +.daily-inventory-page { + padding: 16px 20px 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.daily-inventory-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.daily-inventory-header__title { + display: flex; + align-items: center; + gap: 4px; +} + +.daily-inventory-header__help { + font-size: 13px; +} + +.daily-inventory-header__meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + font-size: 13px; + color: rgba(0, 0, 0, 0.55); +} + +.daily-inventory-th-tip { + border-bottom: 1px dashed rgba(0, 0, 0, 0.35); + cursor: help; + white-space: nowrap; +} + +.daily-inventory-table .ant-table-thead > tr > th { + white-space: nowrap; + font-size: 12px; + padding: 6px 8px; +} + +.daily-inventory-table .ant-table-tbody > tr > td { + padding: 5px 8px; + font-size: 13px; +} + +/* 运行时取数的列:加淡蓝底纹,与基准值区分 */ +.daily-inventory-table .daily-inventory-col--runtime { + background: rgba(22, 119, 255, 0.04); +} + +.daily-inventory-warehouse { + white-space: nowrap; +} + +.daily-inventory-cell--out { + color: #cf1322; +} + +.daily-inventory-cell--in { + color: rgba(0, 0, 0, 0.85); +} + +.daily-inventory-cell--zero { + color: rgba(0, 0, 0, 0.3); +} + +.daily-inventory-row--total > td { + font-weight: 600; + background: rgba(0, 0, 0, 0.03); +} + +.daily-inventory-footer { + font-size: 12px; +} + +.daily-inventory-help p { + margin: 0 0 6px; + line-height: 1.7; + font-size: 13px; + white-space: pre-wrap; +} + +.daily-inventory-help p.is-indent { + padding-left: 16px; + color: rgba(0, 0, 0, 0.65); +} From fb1b433f9461be569922305f7f3fe268803c75e9 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:22 +0800 Subject: [PATCH 51/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/index.js | 1782 +++++++++++++++++++++--------------------- 1 file changed, 892 insertions(+), 890 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index 88daa64..f9802ee 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,890 +1,892 @@ -// 后端服务入口 -const path = require('path'); - -require('dotenv').config(); -require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); - -const express = require('express'); -const cors = require('cors'); -const { CubejsServer } = require('@cubejs-backend/server'); -const mysql = require('mysql2/promise'); - -const installRoutes = require('./api/install'); -const authMiddleware = require('./middleware/auth'); -const { createPageDataCache } = require('./middleware/pageDataCache'); -const adminRoutes = require('./api/admin'); -const planningUploadRoutes = require('./api/planningUpload'); -const planningMayProductPublicRoutes = require('./api/planningMayProductPublic'); -const planningSelectionFissionExternalRoutes = require('./api/planningSelectionFissionExternal'); -const presaleExternalRoutes = require('./api/presaleExternal'); -const delistExternalRoutes = require('./api/delistExternal'); -const campaignCloudRoutes = require('./api/campaignCloud'); -const desktopClientRoutes = require('./api/desktopClient'); -const desktopPresenceRoutes = require('./api/desktopPresence'); -const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks'); -const alibaba1688ProductExternalRoutes = require('./api/alibaba1688ProductExternal'); -const taobaoDetailPlusRoutes = require('./api/taobaoDetailPlus'); -const thirdPartySsoRoutes = require('./api/thirdPartySso'); -const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin'); -const channelRoutes = require('./api/channel'); -const analysisRoutes = require('./api/analysis'); -const productCodeBoardRoutes = require('./api/productCodeBoard'); -const productAnalysisRoutes = require('./api/productAnalysis'); -const marketingEventsRoutes = require('./api/marketingEvents'); -const promotionModuleRoutes = require('./api/promotionModule'); -const productMonitoringRoutes = require('./api/productMonitoring'); -const modularStockMonitorRoutes = require('./api/modularStockMonitor'); -const mkhBomRoutes = require('./api/mkhBom'); -const database = require('./config/database'); -const cloudDb = require('./config/cloudDatabase'); -const jianSunDb = require('./config/jianSunDatabase'); -const redis = require('./config/redis'); -const { startScheduler, manualRefresh } = require('./jobs/dailyRefresh'); -const { startStockSelectionSchedulers } = require('./jobs/stockSelectionSyncJob'); -const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductEmbeddingSyncJob'); -const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob'); -const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob'); -const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob'); -const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob'); -const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); -const { - startAlibaba1688ImageSearchExpiryScheduler, -} = require('./jobs/alibaba1688ImageSearchExpiryJob'); -const { - startMkhReplenishAlertScheduler, -} = require('./jobs/mkhReplenishAlertJob'); -const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); -const { ensureLocalSchema } = require('./services/localSchema'); -const { - prewarmChannelHotData, - prewarmChannelIdDefaultSnapshots, - prewarmChannelIdDashboardDefaultSnapshot, - prewarmChannelStockMonitorDashboardDefaultSnapshot, -} = require('./services/channelAnalysis'); -const { prewarmBrandAnalysisDefault } = require('./services/brandAnalysis'); -const { runHeavyDatabaseTask } = require('./services/databaseLoadGuard'); -const { warmSelectionFissionReadSchema } = require('./services/planningSelectionFissionService'); -const { - getPublicSystemConfigCache, - getStalePublicSystemConfigCache, - setPublicSystemConfigCache, -} = require('./services/publicSystemConfigCache'); -const { filterPublicSystemConfigs } = require('./services/systemConfigVisibility'); -const { - shouldRequireImageSearchCredentials, - verifyImageSearchCredentials, -} = require('./services/imageSearchCredentialGuard'); - -const PLANNING_PAGE_REDIS_TTL_SECONDS = Math.max( - Number(process.env.PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 23 * 60 * 60, - 60 -); -const BRAND_ANALYSIS_PREWARM_DELAY_MS = Math.max( - Number(process.env.BRAND_ANALYSIS_PREWARM_DELAY_MS) || 20 * 1000, - 5 * 1000 -); -const PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS = Math.max( - Number(process.env.PUBLIC_SYSTEM_CONFIG_CACHE_TTL_SECONDS) || 300, - 30 -) * 1000; -const PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS = Math.max( - Number(process.env.PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS) || 1800, - 500 -); -const CUBE_SCHEDULED_REFRESH_TIMER = String(process.env.CUBEJS_SCHEDULED_REFRESH_TIMER || '') - .trim() - .toLowerCase() === 'true'; - -function getBuildIdentity() { - return { - sourceGitSha: String(process.env.SOURCE_GIT_SHA || '').trim() || null, - sourceGitRef: String(process.env.SOURCE_GIT_REF || '').trim() || null, - }; -} -const CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS = Math.max( - Number(process.env.CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS) || 90000, - 10000 -); -const CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS = Math.max( - Number(process.env.CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS) || 30000, - 10000 -); - -function shouldCachePlanningUploadResponse(body = {}) { - if (!body || body.error || body.success === false) { - return false; - } - - const payload = body.data || body; - if (payload?.pending || payload?.cache?.backgroundRefresh) { - return false; - } - - return true; -} - -function shouldHandlePlanningUploadPageCache(req) { - // 企划页首页会聚合 MAY 快照统计;MAY 商品页本身也依赖最新成功批次。 - // MAY 渠道洞察也依赖最新店铺/覆盖快照,外层整页缓存会把旧列口径整天顶住。 - // 榜单数据分析现在也依赖实时机会/竞争诊断结果,外层整页缓存会把旧字段结构整天顶住。 - // 抖音/小红书市场页支持实时批次切换,整页缓存会把旧批次和旧字段结构顶住。 - // 这些接口必须直接读实体表/轻缓存,不能再被 23 小时整页 Redis 顶住。 - if ( - req.path === '/' - || req.path.startsWith('/table/may_product_data') - || req.path.startsWith('/analysis/may-channel-insight') - || req.path.startsWith('/analysis/ops-b-incentive') - || req.path.startsWith('/selection-fission') - ) { - return false; - } - - return true; -} - -const analysisPageCache = createPageDataCache({ - namespace: 'analysis', - redisTtlSeconds: 300, -}); -const channelPageCache = createPageDataCache({ - namespace: 'channel', - redisTtlSeconds: 300, - // ID 分析内部已有按口径版本控制的缓存;导出接口返回 CSV,不能走整页 JSON 缓存。 - shouldHandleRequest: (req) => !['/id-analysis', '/export'].includes(req.path) - && !req.path.startsWith('/export-jobs'), -}); -const planningUploadPageCache = createPageDataCache({ - namespace: 'planning-upload', - version: 'v27-bypass-may-snapshot-pages-20260423', - redisTtlSeconds: PLANNING_PAGE_REDIS_TTL_SECONDS, - shouldHandleRequest: shouldHandlePlanningUploadPageCache, - shouldCacheBody: shouldCachePlanningUploadResponse, -}); -let serverMode = 'install'; - -function isStartupPrewarmEnabled() { - return String(process.env.ENABLE_STARTUP_PREWARM || '').toLowerCase() === 'true'; -} - -function runGuardedPrewarm(label, handler) { - return runHeavyDatabaseTask(`prewarm:${label}`, handler); -} - -function withTimeout(promise, timeoutMs, fallback) { - let timer = null; - return Promise.race([ - promise.finally(() => { - if (timer) { - clearTimeout(timer); - } - }), - new Promise((resolve) => { - timer = setTimeout(() => resolve(fallback), timeoutMs); - if (typeof timer.unref === 'function') { - timer.unref(); - } - }), - ]); -} - -function hasCompleteCloudEnvConfig() { - return cloudDb.hasCompleteEnvConfig(); -} - -function getCloudEnvConfig() { - const config = cloudDb.getEnvBusinessDatabaseConfig(); - return { - driver: config.driver || 'mssql', - host: config.host, - port: config.port || 1433, - database: config.database, - user: config.user, - password: config.password, - ssl: Boolean(config.ssl), - }; -} - -function getBootstrapCloudConfig() { - return { - driver: 'mysql', - host: process.env.LOCAL_DB_HOST, - port: parseInt(process.env.LOCAL_DB_PORT || '3306', 10), - database: process.env.LOCAL_DB_NAME, - user: process.env.LOCAL_DB_USER, - password: process.env.LOCAL_DB_PASSWORD, - ssl: false, - bootstrap: true, - }; -} - -// ===================== -// 从数据库获取云数据库配置 -// ===================== -async function getCloudDbConfig() { - let localPool; - try { - // 先连接本地数据库读取配置 - localPool = mysql.createPool({ - host: process.env.LOCAL_DB_HOST, - port: parseInt(process.env.LOCAL_DB_PORT || '3306'), - user: process.env.LOCAL_DB_USER, - password: process.env.LOCAL_DB_PASSWORD, - database: process.env.LOCAL_DB_NAME, - charset: 'utf8mb4', - waitForConnections: true, - connectionLimit: 2, - maxIdle: 2, - idleTimeout: 60000, - queueLimit: 10, - connectTimeout: 5000, - enableKeepAlive: true, - keepAliveInitialDelay: 0, - }); - - const [rows] = await localPool.query( - 'SELECT host, port, database_name, username, password, `ssl`, driver, use_env_config FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1', - ['active'] - ); - - if (rows.length > 0) { - const config = rows[0]; - if (config.use_env_config === 1 || config.use_env_config === true) { - if (hasCompleteCloudEnvConfig()) { - console.log('📌 使用 .env 业务数据库配置(CLOUD_DB_*)'); - return getCloudEnvConfig(); - } - console.warn('⚠️ 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置'); - } - - console.log('📌 使用数据库配置(来自 database_configs 表)'); - return { - driver: config.driver || 'mysql', - host: config.host, - port: config.port || 3306, - database: config.database_name, - user: config.username, - password: config.password, - ssl: config.ssl === 1, - }; - } - } catch (error) { - console.warn('⚠️ 无法从数据库读取配置:', error.message); - } finally { - if (localPool) { - await localPool.end().catch((error) => { - console.warn('⚠️ 关闭系统库临时连接池失败:', error.message); - }); - } - } - - // 回退到环境变量配置 - if (hasCompleteCloudEnvConfig()) { - console.log('📌 使用环境变量配置(CLOUD_DB_*)'); - return getCloudEnvConfig(); - } - - console.log('⚙️ 业务数据库尚未配置,已进入系统初始化模式'); - return getBootstrapCloudConfig(); -} - -function applyCommonMiddleware(app) { - app.set('trust proxy', 1); - app.use(cors()); - app.use(express.json({ - limit: '15mb', - verify: (req, _res, buffer) => { - req.rawBody = buffer.toString('utf8'); - }, - })); -} - -function registerPublicSystemConfigRoute(app, fallbackConfig = DEFAULT_SITE_CONFIG) { - app.get('/api/admin/system-config', async (_req, res) => { - const now = Date.now(); - const cachedConfig = getPublicSystemConfigCache(now); - if (cachedConfig) { - return res.json(cachedConfig); - } - - try { - const configs = await Promise.race([ - database.local.query('SELECT config_key, config_value, config_type FROM system_config'), - new Promise((_, reject) => { - setTimeout(() => reject(new Error('public system config query timeout')), PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS); - }), - ]); - const result = {}; - filterPublicSystemConfigs(configs).forEach((config) => { - if (config.config_type === 'boolean') { - result[config.config_key] = config.config_value === 'true' || config.config_value === '1'; - } else if (config.config_type === 'number') { - result[config.config_key] = Number(config.config_value); - } else { - result[config.config_key] = config.config_value; - } - }); - setPublicSystemConfigCache(result, PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS, now); - res.json(result); - } catch (error) { - const staleConfig = getStalePublicSystemConfigCache(); - if (staleConfig) { - return res.json(staleConfig); - } - - if (serverMode === 'install' || serverMode === 'database_unavailable') { - return res.json({ - site_name: fallbackConfig.siteName, - site_description: fallbackConfig.siteDescription, - logo: '', - }); - } - - console.warn('获取系统配置超时或失败,返回兜底配置:', error.message); - return res.json({ - site_name: fallbackConfig.siteName, - site_description: fallbackConfig.siteDescription, - logo: '', - }); - } - }); -} - -function registerInstallModeRoutes(app, installStatus) { - const unavailableHandler = (_req, res) => { - res.status(503).json({ error: '系统尚未完成安装,请先访问 /install 完成初始化' }); - }; - - app.get('/health', async (_req, res) => { - const status = await getInstallStatus(); - res.json({ - status: 'install_required', - timestamp: new Date().toISOString(), - build: getBuildIdentity(), - install: status, - }); - }); - - app.use('/api/install', installRoutes); - registerPublicSystemConfigRoute(app); - - app.get('/api/auth/providers', (_req, res) => { - res.json({ - authBaseUrl: '', - dingtalkEnabled: false, - googleEnabled: false, - installRequired: true, - }); - }); - app.get('/api/auth/dingtalk/login', unavailableHandler); - app.get('/api/auth/google/login', unavailableHandler); - app.post('/api/auth/login', unavailableHandler); - app.post('/api/auth/callback', unavailableHandler); - app.get('/api/auth/verify', (_req, res) => { - res.status(401).json({ valid: false, installRequired: true }); - }); - - app.use('/api/admin', (_req, res) => { - res.status(503).json({ - error: '系统尚未完成安装,请先访问 /install 初始化系统数据库并创建管理员账号', - }); - }); - - console.log('='.repeat(50)); - console.log('🛠️ 系统已进入首次安装模式'); - console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); - console.log(`🧭 安装入口: http://localhost:${process.env.PORT || 4000}/install`); - console.log(`📦 安装状态: ${installStatus.systemDatabase.reason}`); - console.log('='.repeat(50)); -} - -async function startInstallServer(installStatus) { - serverMode = 'install'; - const app = express(); - applyCommonMiddleware(app); - registerInstallModeRoutes(app, installStatus); - - const port = parseInt(process.env.PORT || '4000', 10); - await new Promise((resolve) => { - app.listen(port, resolve); - }); -} - -function registerDatabaseUnavailableRoutes(app, installStatus) { - const unavailableHandler = (_req, res) => { - res.status(503).json({ - error: '系统数据库暂时无法连接,请检查 MySQL 网络、白名单或安全组后重启服务', - databaseUnavailable: true, - installRequired: false, - }); - }; - - app.get('/health', async (_req, res) => { - const status = await getInstallStatus(); - res.status(503).json({ - status: 'database_unavailable', - timestamp: new Date().toISOString(), - build: getBuildIdentity(), - install: status, - }); - }); - - app.use('/api/install', installRoutes); - registerPublicSystemConfigRoute(app); - - app.get('/api/auth/providers', (_req, res) => { - res.json({ - authBaseUrl: '', - dingtalkEnabled: false, - googleEnabled: false, - installRequired: false, - databaseUnavailable: true, - }); - }); - app.get('/api/auth/verify', (_req, res) => { - res.status(503).json({ valid: false, databaseUnavailable: true, installRequired: false }); - }); - app.post('/api/auth/login', unavailableHandler); - app.post('/api/auth/callback', unavailableHandler); - app.get('/api/auth/dingtalk/login', unavailableHandler); - app.get('/api/auth/google/login', unavailableHandler); - app.use('/api', unavailableHandler); - - console.log('='.repeat(50)); - console.log('🚧 系统数据库暂时无法连接,已进入降级模式'); - console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); - console.log(`📦 数据库状态: ${installStatus.systemDatabase.reason}`); - console.log('💡 这不是首次安装,请检查 MySQL 白名单/安全组/网络后重启服务'); - console.log('='.repeat(50)); -} - -async function startDatabaseUnavailableServer(installStatus) { - serverMode = 'database_unavailable'; - const app = express(); - applyCommonMiddleware(app); - registerDatabaseUnavailableRoutes(app, installStatus); - - const port = parseInt(process.env.PORT || '4000', 10); - await new Promise((resolve) => { - app.listen(port, resolve); - }); -} - -async function createFullModeApp(cloudConfig) { - const dbType = cloudConfig.driver === 'mssql' ? 'mssql' : 'mysql'; - console.log(`📊 数据库类型: ${dbType}`); - - const port = parseInt(process.env.PORT || '4000', 10); - - if (String(process.env.DISABLE_CUBEJS || '').trim().toLowerCase() === 'true') { - console.warn('⏸️ 已通过 DISABLE_CUBEJS=true 跳过 Cube.js,仅启动业务 API 服务'); - const app = express(); - await new Promise((resolve) => { - app.listen(port, resolve); - }); - - return { - app, - cubeMode: 'disabled', - cubeError: 'DISABLE_CUBEJS=true', - }; - } - - try { - const cubejsServer = new CubejsServer({ - schemaPath: 'model', - dbType: dbType, - logger: (msg, params) => { - console.log(`[Cube.js] ${msg}`, params !== undefined ? JSON.stringify(params) : ''); - }, - driverFactory: () => { - // 根据 driver 类型选择驱动 - if (cloudConfig.driver === 'mssql') { - const MSSQLDriver = require('@cubejs-backend/mssql-driver'); - return new MSSQLDriver({ - server: cloudConfig.host, - port: cloudConfig.port, - database: cloudConfig.database, - user: cloudConfig.user, - password: cloudConfig.password, - options: { - encrypt: cloudConfig.ssl, - trustServerCertificate: true, - }, - }); - } - - // 默认使用 MySQL - const MySQLDriver = require('@cubejs-backend/mysql-driver'); - return new MySQLDriver({ - database: cloudConfig.database, - host: cloudConfig.host, - port: cloudConfig.port, - user: cloudConfig.user, - password: cloudConfig.password, - ssl: cloudConfig.ssl, - }); - }, - apiSecret: process.env.CUBEJS_API_SECRET, - scheduledRefreshTimer: CUBE_SCHEDULED_REFRESH_TIMER, - jwt: { - key: process.env.CUBEJS_SECRET, - algorithms: ['HS256'], - }, - }); - - const { app } = await cubejsServer.listen(); - return { - app, - cubeMode: 'cube', - cubeError: null, - }; - } catch (error) { - console.warn('⚠️ Cube.js 启动失败,已降级为业务 API 服务:', error.message); - const app = express(); - await new Promise((resolve) => { - app.listen(port, resolve); - }); - - return { - app, - cubeMode: 'express-fallback', - cubeError: error.message, - }; - } -} - -// ===================== -// 启动服务 -// ===================== -async function startServer(options = {}) { - const envSkipLocalSchema = String(process.env.SKIP_LOCAL_SCHEMA_CHECK || '').trim().toLowerCase() === 'true'; - const { skipLocalSchema = envSkipLocalSchema } = options; - serverMode = 'full'; - if (skipLocalSchema) { - console.warn('⚠️ 系统数据库暂时不可用,已跳过本次本地 schema 检查,继续启动业务 API'); - } else { - await ensureLocalSchema(); - } - - if (shouldRequireImageSearchCredentials()) { - const credentialStatus = await verifyImageSearchCredentials(); - console.log(`✅ 图搜凭据守卫通过:公司=${credentialStatus.company.source}, 1688=${credentialStatus.alibaba1688.source}`); - } - - // 获取云数据库配置 - const cloudConfig = await getCloudDbConfig(); - if (cloudConfig.bootstrap) { - console.log(`📊 初始化模式占位数据源: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); - } else { - console.log(`📊 云数据库: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); - } - - // 启动 Cube.js 服务器;如 Cube Store 不可用,降级为普通业务 API 服务,避免整站不可用。 - const { app: cubejsApp, cubeMode, cubeError } = await createFullModeApp(cloudConfig); - - applyCommonMiddleware(cubejsApp); - - // ===================== - // 健康检查 (多数据源) - // ===================== - cubejsApp.get('/health', async (_req, res) => { - const [systemTest, businessTest, redisTest] = await Promise.all([ - withTimeout(database.local.test(), 5000, { success: false, message: '系统数据库检测超时' }), - withTimeout(cloudDb.test(), 5000, { success: false, message: '业务数据库检测超时' }), - withTimeout(redis.test(), 3000, { success: false, message: 'Redis 检测超时' }), - ]); - - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - build: getBuildIdentity(), - databases: { - system: systemTest, - local: systemTest, - cloud: businessTest, - }, - services: { - redis: redisTest, - cube: { - success: cubeMode === 'cube', - mode: cubeMode, - message: cubeMode === 'cube' ? 'Cube.js 服务正常' : `Cube.js 已降级: ${cubeError || '未知错误'}`, - }, - }, - }); - }); - - // ===================== - // 认证 API - // ===================== - cubejsApp.get('/api/auth/providers', require('./api/auth').getProviders); - cubejsApp.get('/api/auth/dingtalk/login', require('./api/auth').startDingTalkLogin); - cubejsApp.get('/api/auth/google/login', require('./api/auth').startGoogleLogin); - cubejsApp.post('/api/auth/login', require('./api/auth').login); - cubejsApp.post('/api/auth/local-preview', require('./api/auth').localPreview); - cubejsApp.post('/api/auth/callback', require('./api/auth').callback); - cubejsApp.get('/api/auth/verify', require('./api/auth').verify); - - // ===================== - // 公开访问的 API(无需认证) - // ===================== - cubejsApp.use('/api/install', installRoutes); - cubejsApp.use('/api/sso', thirdPartySsoRoutes); - cubejsApp.use('/api/desktop-client', desktopClientRoutes); - cubejsApp.use('/api/public/taobao-detail-plus', taobaoDetailPlusRoutes); - cubejsApp.use('/api/public/planning-may-product', planningMayProductPublicRoutes); - cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes); - cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes); - cubejsApp.use('/api/campaign', campaignCloudRoutes); - cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes); - cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes); - cubejsApp.use('/api/external/presale', presaleExternalRoutes); - - // 系统配置的 GET 接口公开访问(登录页需要获取网站名称和 Logo) - registerPublicSystemConfigRoute(cubejsApp); - - // ===================== - // 需要认证的 API - // ===================== - cubejsApp.use('/api/admin/sso-clients', authMiddleware, authMiddleware.requireRole('admin'), thirdPartySsoAdminRoutes); - cubejsApp.use('/api/admin', authMiddleware, adminRoutes); - cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes); - cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes); - cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes); - cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes); - cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes); - cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes); - cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); - cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); - cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); - cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes); - cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); - - // ===================== - // 数据分析 API(公开访问,可根据需要添加认证) - // ===================== - cubejsApp.use('/api/analysis', analysisPageCache, analysisRoutes); - - // ===================== - // 数据刷新 API(需要认证) - // ===================== - cubejsApp.post('/api/admin/refresh', authMiddleware, async (req, res) => { - try { - const { date } = req.body; - console.log(`[API] 手动触发数据刷新: ${date || '昨天'}`); - const result = await manualRefresh(date); - res.json({ success: true, ...result }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } - }); - - // ===================== - // 重新加载云数据库配置 API - // ===================== - cubejsApp.post('/api/admin/database/reload', authMiddleware, async (req, res) => { - try { - console.log('🔄 正在重新加载云数据库配置...'); - res.json({ - success: true, - message: '配置已保存,需要重启服务才能生效。请执行: pm2 restart maixiang' - }); - } catch (error) { - res.status(500).json({ success: false, message: error.message }); - } - }); - - console.log('='.repeat(50)); - console.log('🚀 后端服务已启动'); - console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); - console.log(cubeMode === 'cube' - ? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api` - : '📊 Cube.js API: 已降级不可用,业务 API 继续运行'); - - // 测试数据库连接 - console.log('\n📦 数据库连接测试:'); - const [systemTest, businessTest, redisTest] = await Promise.all([ - withTimeout(database.local.test(), 5000, { message: '系统数据库检测超时' }), - withTimeout(cloudDb.test(), 5000, { message: '业务数据库检测超时' }), - withTimeout(redis.test(), 3000, { message: 'Redis 检测超时' }), - ]); - console.log(` [系统] ${systemTest.message}`); - console.log(` [业务] ${businessTest.message}`); - console.log(` [缓存] ${redisTest.message}`); - console.log('='.repeat(50)); - - if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') { - console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理'); - return; - } - - startScheduler(); - startStockSelectionSchedulers(); - startTaobaoProductEmbeddingSchedulers(); - startPddSkuDimensionSnapshotSchedulers(); - startPlanningMayProductSnapshotSchedulers(); - startPlanningDingWorkflowSyncSchedulers(); - startOpsBIncentiveSnapshotSchedulers(); - startGoodsFirstSeenScheduler(); - startAlibaba1688ImageSearchExpiryScheduler(); - startMkhReplenishAlertScheduler(); - - // Keep the first selection-pool page request from paying the remote schema - // inspection round-trip over the planning database tunnel. - setTimeout(() => { - warmSelectionFissionReadSchema() - .then(() => console.log('[SelectionFission] 列表读取结构预热完成')) - .catch((error) => console.warn('[SelectionFission] 列表读取结构预热失败:', error.message)); - }, 1000); - - if (isStartupPrewarmEnabled()) { - setTimeout(() => { - if (typeof prewarmChannelHotData === 'function') { - runGuardedPrewarm('channel-hot-data', () => prewarmChannelHotData({ skipCoverage: true })).catch((error) => { - console.warn('⚠️ 渠道默认查询预热失败:', error.message); - }); - } - }, 8000); - - setTimeout(() => { - if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { - runGuardedPrewarm('analysis-hot-pages', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { - console.warn('⚠️ 分析页默认查询预热失败:', error.message); - }); - } - }, 10000); - - - - if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { - const analysisPrewarmTimer = setInterval(() => { - runGuardedPrewarm('analysis-hot-pages:interval', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { - console.warn('⚠️ 分析页默认查询定时预热失败:', error.message); - }); - }, 10 * 60 * 1000); - - if (typeof analysisPrewarmTimer.unref === 'function') { - analysisPrewarmTimer.unref(); - } - } - - - } else { - console.log('⏸️ 启动预热已关闭,可通过 ENABLE_STARTUP_PREWARM=true 开启'); - } - - if (!isStartupPrewarmEnabled() && typeof prewarmChannelStockMonitorDashboardDefaultSnapshot === 'function') { - if (typeof prewarmChannelIdDefaultSnapshots === 'function') { - setTimeout(() => { - runGuardedPrewarm( - 'channel-id-analysis-resilience', - () => prewarmChannelIdDefaultSnapshots({ skipCoverage: true }) - ).catch((error) => { - console.warn('⚠️ ID数据分析韧性快照预热失败:', error.message); - }); - }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS); - } - - if (typeof prewarmChannelIdDashboardDefaultSnapshot === 'function') { - setTimeout(() => { - runGuardedPrewarm( - 'channel-id-dashboard-resilience', - () => prewarmChannelIdDashboardDefaultSnapshot({ skipCoverage: true }) - ).catch((error) => { - console.warn('⚠️ ID数据分析仪表盘韧性快照预热失败:', error.message); - }); - }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS + 15000); - } - - setTimeout(() => { - runGuardedPrewarm( - 'channel-stock-monitor-resilience', - () => prewarmChannelStockMonitorDashboardDefaultSnapshot({ skipCoverage: true }) - ).catch((error) => { - console.warn('⚠️ 库存销售影响仪表盘韧性快照预热失败:', error.message); - }); - }, CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS); - } - - if (String(process.env.ENABLE_BRAND_ANALYSIS_PREWARM || 'true').toLowerCase() !== 'false' - && typeof prewarmBrandAnalysisDefault === 'function') { - setTimeout(() => { - runGuardedPrewarm('brand-analysis-default', () => prewarmBrandAnalysisDefault()) - .then((result) => { - console.log( - `[BrandAnalysis] default prewarm completed total=${result.total || 0} elapsedMs=${result.elapsedMs || 0}` - ); - }) - .catch((error) => { - console.warn('⚠️ 品牌维度默认真实数据预热失败:', error.message); - }); - }, BRAND_ANALYSIS_PREWARM_DELAY_MS); - } - - const cron = require('node-cron'); - - if (typeof prewarmChannelHotData === 'function') { - cron.schedule('0 1 * * *', async () => { - console.log('🧊 凌晨 1 点执行渠道 23 小时缓存预热...'); - await runGuardedPrewarm( - 'channel-hot-data:daily', - () => prewarmChannelHotData({ forceRefresh: true, skipCoverage: true }) - ); - }, { - timezone: 'Asia/Shanghai', - }); - console.log('[Scheduler] 渠道 23 小时缓存预热任务已注册: 每天01:00执行'); - } - - if (process.env.NODE_ENV === 'production') { - const alertChecker = require('./services/alertChecker'); - - cron.schedule('*/5 * * * *', async () => { - console.log('🔔 执行预警检查...'); - await alertChecker.checkAllRules(); - }); - } -} - -async function bootstrap() { - const installStatus = await getInstallStatus(); - if (installStatus.required) { - await startInstallServer(installStatus); - return; - } - - if (installStatus.databaseUnavailable) { - console.warn(`⚠️ 系统数据库暂时不可用,不进入降级拦截模式: ${installStatus.systemDatabase?.reason || 'unknown'}`); - await startServer({ skipLocalSchema: true }); - return; - } - - await startServer(); -} - -bootstrap().catch((error) => { - console.error('❌ 服务启动失败:', error); - process.exit(1); -}); - -async function shutdownGracefully() { - await Promise.allSettled([ - cloudDb.close(), - jianSunDb.close(), - database.close(), - redis.close(), - ]); -} - -// 优雅关闭 -process.on('SIGTERM', async () => { - console.log('正在关闭服务...'); - await shutdownGracefully(); - process.exit(0); -}); - -process.on('SIGINT', async () => { - console.log('正在关闭服务...'); - await shutdownGracefully(); - process.exit(0); -}); +// 后端服务入口 +const path = require('path'); + +require('dotenv').config(); +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const express = require('express'); +const cors = require('cors'); +const { CubejsServer } = require('@cubejs-backend/server'); +const mysql = require('mysql2/promise'); + +const installRoutes = require('./api/install'); +const authMiddleware = require('./middleware/auth'); +const { createPageDataCache } = require('./middleware/pageDataCache'); +const adminRoutes = require('./api/admin'); +const planningUploadRoutes = require('./api/planningUpload'); +const planningMayProductPublicRoutes = require('./api/planningMayProductPublic'); +const planningSelectionFissionExternalRoutes = require('./api/planningSelectionFissionExternal'); +const presaleExternalRoutes = require('./api/presaleExternal'); +const delistExternalRoutes = require('./api/delistExternal'); +const campaignCloudRoutes = require('./api/campaignCloud'); +const desktopClientRoutes = require('./api/desktopClient'); +const desktopPresenceRoutes = require('./api/desktopPresence'); +const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks'); +const alibaba1688ProductExternalRoutes = require('./api/alibaba1688ProductExternal'); +const taobaoDetailPlusRoutes = require('./api/taobaoDetailPlus'); +const thirdPartySsoRoutes = require('./api/thirdPartySso'); +const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin'); +const channelRoutes = require('./api/channel'); +const analysisRoutes = require('./api/analysis'); +const productCodeBoardRoutes = require('./api/productCodeBoard'); +const productAnalysisRoutes = require('./api/productAnalysis'); +const marketingEventsRoutes = require('./api/marketingEvents'); +const promotionModuleRoutes = require('./api/promotionModule'); +const productMonitoringRoutes = require('./api/productMonitoring'); +const modularStockMonitorRoutes = require('./api/modularStockMonitor'); +const mkhBomRoutes = require('./api/mkhBom'); +const dailyInventoryRoutes = require('./api/dailyInventory'); +const database = require('./config/database'); +const cloudDb = require('./config/cloudDatabase'); +const jianSunDb = require('./config/jianSunDatabase'); +const redis = require('./config/redis'); +const { startScheduler, manualRefresh } = require('./jobs/dailyRefresh'); +const { startStockSelectionSchedulers } = require('./jobs/stockSelectionSyncJob'); +const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductEmbeddingSyncJob'); +const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob'); +const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob'); +const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob'); +const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob'); +const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob'); +const { + startAlibaba1688ImageSearchExpiryScheduler, +} = require('./jobs/alibaba1688ImageSearchExpiryJob'); +const { + startMkhReplenishAlertScheduler, +} = require('./jobs/mkhReplenishAlertJob'); +const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); +const { ensureLocalSchema } = require('./services/localSchema'); +const { + prewarmChannelHotData, + prewarmChannelIdDefaultSnapshots, + prewarmChannelIdDashboardDefaultSnapshot, + prewarmChannelStockMonitorDashboardDefaultSnapshot, +} = require('./services/channelAnalysis'); +const { prewarmBrandAnalysisDefault } = require('./services/brandAnalysis'); +const { runHeavyDatabaseTask } = require('./services/databaseLoadGuard'); +const { warmSelectionFissionReadSchema } = require('./services/planningSelectionFissionService'); +const { + getPublicSystemConfigCache, + getStalePublicSystemConfigCache, + setPublicSystemConfigCache, +} = require('./services/publicSystemConfigCache'); +const { filterPublicSystemConfigs } = require('./services/systemConfigVisibility'); +const { + shouldRequireImageSearchCredentials, + verifyImageSearchCredentials, +} = require('./services/imageSearchCredentialGuard'); + +const PLANNING_PAGE_REDIS_TTL_SECONDS = Math.max( + Number(process.env.PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 23 * 60 * 60, + 60 +); +const BRAND_ANALYSIS_PREWARM_DELAY_MS = Math.max( + Number(process.env.BRAND_ANALYSIS_PREWARM_DELAY_MS) || 20 * 1000, + 5 * 1000 +); +const PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS = Math.max( + Number(process.env.PUBLIC_SYSTEM_CONFIG_CACHE_TTL_SECONDS) || 300, + 30 +) * 1000; +const PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS = Math.max( + Number(process.env.PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS) || 1800, + 500 +); +const CUBE_SCHEDULED_REFRESH_TIMER = String(process.env.CUBEJS_SCHEDULED_REFRESH_TIMER || '') + .trim() + .toLowerCase() === 'true'; + +function getBuildIdentity() { + return { + sourceGitSha: String(process.env.SOURCE_GIT_SHA || '').trim() || null, + sourceGitRef: String(process.env.SOURCE_GIT_REF || '').trim() || null, + }; +} +const CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS = Math.max( + Number(process.env.CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS) || 90000, + 10000 +); +const CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS = Math.max( + Number(process.env.CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS) || 30000, + 10000 +); + +function shouldCachePlanningUploadResponse(body = {}) { + if (!body || body.error || body.success === false) { + return false; + } + + const payload = body.data || body; + if (payload?.pending || payload?.cache?.backgroundRefresh) { + return false; + } + + return true; +} + +function shouldHandlePlanningUploadPageCache(req) { + // 企划页首页会聚合 MAY 快照统计;MAY 商品页本身也依赖最新成功批次。 + // MAY 渠道洞察也依赖最新店铺/覆盖快照,外层整页缓存会把旧列口径整天顶住。 + // 榜单数据分析现在也依赖实时机会/竞争诊断结果,外层整页缓存会把旧字段结构整天顶住。 + // 抖音/小红书市场页支持实时批次切换,整页缓存会把旧批次和旧字段结构顶住。 + // 这些接口必须直接读实体表/轻缓存,不能再被 23 小时整页 Redis 顶住。 + if ( + req.path === '/' + || req.path.startsWith('/table/may_product_data') + || req.path.startsWith('/analysis/may-channel-insight') + || req.path.startsWith('/analysis/ops-b-incentive') + || req.path.startsWith('/selection-fission') + ) { + return false; + } + + return true; +} + +const analysisPageCache = createPageDataCache({ + namespace: 'analysis', + redisTtlSeconds: 300, +}); +const channelPageCache = createPageDataCache({ + namespace: 'channel', + redisTtlSeconds: 300, + // ID 分析内部已有按口径版本控制的缓存;导出接口返回 CSV,不能走整页 JSON 缓存。 + shouldHandleRequest: (req) => !['/id-analysis', '/export'].includes(req.path) + && !req.path.startsWith('/export-jobs'), +}); +const planningUploadPageCache = createPageDataCache({ + namespace: 'planning-upload', + version: 'v27-bypass-may-snapshot-pages-20260423', + redisTtlSeconds: PLANNING_PAGE_REDIS_TTL_SECONDS, + shouldHandleRequest: shouldHandlePlanningUploadPageCache, + shouldCacheBody: shouldCachePlanningUploadResponse, +}); +let serverMode = 'install'; + +function isStartupPrewarmEnabled() { + return String(process.env.ENABLE_STARTUP_PREWARM || '').toLowerCase() === 'true'; +} + +function runGuardedPrewarm(label, handler) { + return runHeavyDatabaseTask(`prewarm:${label}`, handler); +} + +function withTimeout(promise, timeoutMs, fallback) { + let timer = null; + return Promise.race([ + promise.finally(() => { + if (timer) { + clearTimeout(timer); + } + }), + new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), timeoutMs); + if (typeof timer.unref === 'function') { + timer.unref(); + } + }), + ]); +} + +function hasCompleteCloudEnvConfig() { + return cloudDb.hasCompleteEnvConfig(); +} + +function getCloudEnvConfig() { + const config = cloudDb.getEnvBusinessDatabaseConfig(); + return { + driver: config.driver || 'mssql', + host: config.host, + port: config.port || 1433, + database: config.database, + user: config.user, + password: config.password, + ssl: Boolean(config.ssl), + }; +} + +function getBootstrapCloudConfig() { + return { + driver: 'mysql', + host: process.env.LOCAL_DB_HOST, + port: parseInt(process.env.LOCAL_DB_PORT || '3306', 10), + database: process.env.LOCAL_DB_NAME, + user: process.env.LOCAL_DB_USER, + password: process.env.LOCAL_DB_PASSWORD, + ssl: false, + bootstrap: true, + }; +} + +// ===================== +// 从数据库获取云数据库配置 +// ===================== +async function getCloudDbConfig() { + let localPool; + try { + // 先连接本地数据库读取配置 + localPool = mysql.createPool({ + host: process.env.LOCAL_DB_HOST, + port: parseInt(process.env.LOCAL_DB_PORT || '3306'), + user: process.env.LOCAL_DB_USER, + password: process.env.LOCAL_DB_PASSWORD, + database: process.env.LOCAL_DB_NAME, + charset: 'utf8mb4', + waitForConnections: true, + connectionLimit: 2, + maxIdle: 2, + idleTimeout: 60000, + queueLimit: 10, + connectTimeout: 5000, + enableKeepAlive: true, + keepAliveInitialDelay: 0, + }); + + const [rows] = await localPool.query( + 'SELECT host, port, database_name, username, password, `ssl`, driver, use_env_config FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1', + ['active'] + ); + + if (rows.length > 0) { + const config = rows[0]; + if (config.use_env_config === 1 || config.use_env_config === true) { + if (hasCompleteCloudEnvConfig()) { + console.log('📌 使用 .env 业务数据库配置(CLOUD_DB_*)'); + return getCloudEnvConfig(); + } + console.warn('⚠️ 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置'); + } + + console.log('📌 使用数据库配置(来自 database_configs 表)'); + return { + driver: config.driver || 'mysql', + host: config.host, + port: config.port || 3306, + database: config.database_name, + user: config.username, + password: config.password, + ssl: config.ssl === 1, + }; + } + } catch (error) { + console.warn('⚠️ 无法从数据库读取配置:', error.message); + } finally { + if (localPool) { + await localPool.end().catch((error) => { + console.warn('⚠️ 关闭系统库临时连接池失败:', error.message); + }); + } + } + + // 回退到环境变量配置 + if (hasCompleteCloudEnvConfig()) { + console.log('📌 使用环境变量配置(CLOUD_DB_*)'); + return getCloudEnvConfig(); + } + + console.log('⚙️ 业务数据库尚未配置,已进入系统初始化模式'); + return getBootstrapCloudConfig(); +} + +function applyCommonMiddleware(app) { + app.set('trust proxy', 1); + app.use(cors()); + app.use(express.json({ + limit: '15mb', + verify: (req, _res, buffer) => { + req.rawBody = buffer.toString('utf8'); + }, + })); +} + +function registerPublicSystemConfigRoute(app, fallbackConfig = DEFAULT_SITE_CONFIG) { + app.get('/api/admin/system-config', async (_req, res) => { + const now = Date.now(); + const cachedConfig = getPublicSystemConfigCache(now); + if (cachedConfig) { + return res.json(cachedConfig); + } + + try { + const configs = await Promise.race([ + database.local.query('SELECT config_key, config_value, config_type FROM system_config'), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('public system config query timeout')), PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS); + }), + ]); + const result = {}; + filterPublicSystemConfigs(configs).forEach((config) => { + if (config.config_type === 'boolean') { + result[config.config_key] = config.config_value === 'true' || config.config_value === '1'; + } else if (config.config_type === 'number') { + result[config.config_key] = Number(config.config_value); + } else { + result[config.config_key] = config.config_value; + } + }); + setPublicSystemConfigCache(result, PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS, now); + res.json(result); + } catch (error) { + const staleConfig = getStalePublicSystemConfigCache(); + if (staleConfig) { + return res.json(staleConfig); + } + + if (serverMode === 'install' || serverMode === 'database_unavailable') { + return res.json({ + site_name: fallbackConfig.siteName, + site_description: fallbackConfig.siteDescription, + logo: '', + }); + } + + console.warn('获取系统配置超时或失败,返回兜底配置:', error.message); + return res.json({ + site_name: fallbackConfig.siteName, + site_description: fallbackConfig.siteDescription, + logo: '', + }); + } + }); +} + +function registerInstallModeRoutes(app, installStatus) { + const unavailableHandler = (_req, res) => { + res.status(503).json({ error: '系统尚未完成安装,请先访问 /install 完成初始化' }); + }; + + app.get('/health', async (_req, res) => { + const status = await getInstallStatus(); + res.json({ + status: 'install_required', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + install: status, + }); + }); + + app.use('/api/install', installRoutes); + registerPublicSystemConfigRoute(app); + + app.get('/api/auth/providers', (_req, res) => { + res.json({ + authBaseUrl: '', + dingtalkEnabled: false, + googleEnabled: false, + installRequired: true, + }); + }); + app.get('/api/auth/dingtalk/login', unavailableHandler); + app.get('/api/auth/google/login', unavailableHandler); + app.post('/api/auth/login', unavailableHandler); + app.post('/api/auth/callback', unavailableHandler); + app.get('/api/auth/verify', (_req, res) => { + res.status(401).json({ valid: false, installRequired: true }); + }); + + app.use('/api/admin', (_req, res) => { + res.status(503).json({ + error: '系统尚未完成安装,请先访问 /install 初始化系统数据库并创建管理员账号', + }); + }); + + console.log('='.repeat(50)); + console.log('🛠️ 系统已进入首次安装模式'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(`🧭 安装入口: http://localhost:${process.env.PORT || 4000}/install`); + console.log(`📦 安装状态: ${installStatus.systemDatabase.reason}`); + console.log('='.repeat(50)); +} + +async function startInstallServer(installStatus) { + serverMode = 'install'; + const app = express(); + applyCommonMiddleware(app); + registerInstallModeRoutes(app, installStatus); + + const port = parseInt(process.env.PORT || '4000', 10); + await new Promise((resolve) => { + app.listen(port, resolve); + }); +} + +function registerDatabaseUnavailableRoutes(app, installStatus) { + const unavailableHandler = (_req, res) => { + res.status(503).json({ + error: '系统数据库暂时无法连接,请检查 MySQL 网络、白名单或安全组后重启服务', + databaseUnavailable: true, + installRequired: false, + }); + }; + + app.get('/health', async (_req, res) => { + const status = await getInstallStatus(); + res.status(503).json({ + status: 'database_unavailable', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + install: status, + }); + }); + + app.use('/api/install', installRoutes); + registerPublicSystemConfigRoute(app); + + app.get('/api/auth/providers', (_req, res) => { + res.json({ + authBaseUrl: '', + dingtalkEnabled: false, + googleEnabled: false, + installRequired: false, + databaseUnavailable: true, + }); + }); + app.get('/api/auth/verify', (_req, res) => { + res.status(503).json({ valid: false, databaseUnavailable: true, installRequired: false }); + }); + app.post('/api/auth/login', unavailableHandler); + app.post('/api/auth/callback', unavailableHandler); + app.get('/api/auth/dingtalk/login', unavailableHandler); + app.get('/api/auth/google/login', unavailableHandler); + app.use('/api', unavailableHandler); + + console.log('='.repeat(50)); + console.log('🚧 系统数据库暂时无法连接,已进入降级模式'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(`📦 数据库状态: ${installStatus.systemDatabase.reason}`); + console.log('💡 这不是首次安装,请检查 MySQL 白名单/安全组/网络后重启服务'); + console.log('='.repeat(50)); +} + +async function startDatabaseUnavailableServer(installStatus) { + serverMode = 'database_unavailable'; + const app = express(); + applyCommonMiddleware(app); + registerDatabaseUnavailableRoutes(app, installStatus); + + const port = parseInt(process.env.PORT || '4000', 10); + await new Promise((resolve) => { + app.listen(port, resolve); + }); +} + +async function createFullModeApp(cloudConfig) { + const dbType = cloudConfig.driver === 'mssql' ? 'mssql' : 'mysql'; + console.log(`📊 数据库类型: ${dbType}`); + + const port = parseInt(process.env.PORT || '4000', 10); + + if (String(process.env.DISABLE_CUBEJS || '').trim().toLowerCase() === 'true') { + console.warn('⏸️ 已通过 DISABLE_CUBEJS=true 跳过 Cube.js,仅启动业务 API 服务'); + const app = express(); + await new Promise((resolve) => { + app.listen(port, resolve); + }); + + return { + app, + cubeMode: 'disabled', + cubeError: 'DISABLE_CUBEJS=true', + }; + } + + try { + const cubejsServer = new CubejsServer({ + schemaPath: 'model', + dbType: dbType, + logger: (msg, params) => { + console.log(`[Cube.js] ${msg}`, params !== undefined ? JSON.stringify(params) : ''); + }, + driverFactory: () => { + // 根据 driver 类型选择驱动 + if (cloudConfig.driver === 'mssql') { + const MSSQLDriver = require('@cubejs-backend/mssql-driver'); + return new MSSQLDriver({ + server: cloudConfig.host, + port: cloudConfig.port, + database: cloudConfig.database, + user: cloudConfig.user, + password: cloudConfig.password, + options: { + encrypt: cloudConfig.ssl, + trustServerCertificate: true, + }, + }); + } + + // 默认使用 MySQL + const MySQLDriver = require('@cubejs-backend/mysql-driver'); + return new MySQLDriver({ + database: cloudConfig.database, + host: cloudConfig.host, + port: cloudConfig.port, + user: cloudConfig.user, + password: cloudConfig.password, + ssl: cloudConfig.ssl, + }); + }, + apiSecret: process.env.CUBEJS_API_SECRET, + scheduledRefreshTimer: CUBE_SCHEDULED_REFRESH_TIMER, + jwt: { + key: process.env.CUBEJS_SECRET, + algorithms: ['HS256'], + }, + }); + + const { app } = await cubejsServer.listen(); + return { + app, + cubeMode: 'cube', + cubeError: null, + }; + } catch (error) { + console.warn('⚠️ Cube.js 启动失败,已降级为业务 API 服务:', error.message); + const app = express(); + await new Promise((resolve) => { + app.listen(port, resolve); + }); + + return { + app, + cubeMode: 'express-fallback', + cubeError: error.message, + }; + } +} + +// ===================== +// 启动服务 +// ===================== +async function startServer(options = {}) { + const envSkipLocalSchema = String(process.env.SKIP_LOCAL_SCHEMA_CHECK || '').trim().toLowerCase() === 'true'; + const { skipLocalSchema = envSkipLocalSchema } = options; + serverMode = 'full'; + if (skipLocalSchema) { + console.warn('⚠️ 系统数据库暂时不可用,已跳过本次本地 schema 检查,继续启动业务 API'); + } else { + await ensureLocalSchema(); + } + + if (shouldRequireImageSearchCredentials()) { + const credentialStatus = await verifyImageSearchCredentials(); + console.log(`✅ 图搜凭据守卫通过:公司=${credentialStatus.company.source}, 1688=${credentialStatus.alibaba1688.source}`); + } + + // 获取云数据库配置 + const cloudConfig = await getCloudDbConfig(); + if (cloudConfig.bootstrap) { + console.log(`📊 初始化模式占位数据源: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); + } else { + console.log(`📊 云数据库: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); + } + + // 启动 Cube.js 服务器;如 Cube Store 不可用,降级为普通业务 API 服务,避免整站不可用。 + const { app: cubejsApp, cubeMode, cubeError } = await createFullModeApp(cloudConfig); + + applyCommonMiddleware(cubejsApp); + + // ===================== + // 健康检查 (多数据源) + // ===================== + cubejsApp.get('/health', async (_req, res) => { + const [systemTest, businessTest, redisTest] = await Promise.all([ + withTimeout(database.local.test(), 5000, { success: false, message: '系统数据库检测超时' }), + withTimeout(cloudDb.test(), 5000, { success: false, message: '业务数据库检测超时' }), + withTimeout(redis.test(), 3000, { success: false, message: 'Redis 检测超时' }), + ]); + + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + databases: { + system: systemTest, + local: systemTest, + cloud: businessTest, + }, + services: { + redis: redisTest, + cube: { + success: cubeMode === 'cube', + mode: cubeMode, + message: cubeMode === 'cube' ? 'Cube.js 服务正常' : `Cube.js 已降级: ${cubeError || '未知错误'}`, + }, + }, + }); + }); + + // ===================== + // 认证 API + // ===================== + cubejsApp.get('/api/auth/providers', require('./api/auth').getProviders); + cubejsApp.get('/api/auth/dingtalk/login', require('./api/auth').startDingTalkLogin); + cubejsApp.get('/api/auth/google/login', require('./api/auth').startGoogleLogin); + cubejsApp.post('/api/auth/login', require('./api/auth').login); + cubejsApp.post('/api/auth/local-preview', require('./api/auth').localPreview); + cubejsApp.post('/api/auth/callback', require('./api/auth').callback); + cubejsApp.get('/api/auth/verify', require('./api/auth').verify); + + // ===================== + // 公开访问的 API(无需认证) + // ===================== + cubejsApp.use('/api/install', installRoutes); + cubejsApp.use('/api/sso', thirdPartySsoRoutes); + cubejsApp.use('/api/desktop-client', desktopClientRoutes); + cubejsApp.use('/api/public/taobao-detail-plus', taobaoDetailPlusRoutes); + cubejsApp.use('/api/public/planning-may-product', planningMayProductPublicRoutes); + cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes); + cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes); + cubejsApp.use('/api/campaign', campaignCloudRoutes); + cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes); + cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes); + cubejsApp.use('/api/external/presale', presaleExternalRoutes); + + // 系统配置的 GET 接口公开访问(登录页需要获取网站名称和 Logo) + registerPublicSystemConfigRoute(cubejsApp); + + // ===================== + // 需要认证的 API + // ===================== + cubejsApp.use('/api/admin/sso-clients', authMiddleware, authMiddleware.requireRole('admin'), thirdPartySsoAdminRoutes); + cubejsApp.use('/api/admin', authMiddleware, adminRoutes); + cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes); + cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes); + cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes); + cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes); + cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes); + cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes); + cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); + cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); + cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); + cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes); + cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); + cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); + + // ===================== + // 数据分析 API(公开访问,可根据需要添加认证) + // ===================== + cubejsApp.use('/api/analysis', analysisPageCache, analysisRoutes); + + // ===================== + // 数据刷新 API(需要认证) + // ===================== + cubejsApp.post('/api/admin/refresh', authMiddleware, async (req, res) => { + try { + const { date } = req.body; + console.log(`[API] 手动触发数据刷新: ${date || '昨天'}`); + const result = await manualRefresh(date); + res.json({ success: true, ...result }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // ===================== + // 重新加载云数据库配置 API + // ===================== + cubejsApp.post('/api/admin/database/reload', authMiddleware, async (req, res) => { + try { + console.log('🔄 正在重新加载云数据库配置...'); + res.json({ + success: true, + message: '配置已保存,需要重启服务才能生效。请执行: pm2 restart maixiang' + }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } + }); + + console.log('='.repeat(50)); + console.log('🚀 后端服务已启动'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(cubeMode === 'cube' + ? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api` + : '📊 Cube.js API: 已降级不可用,业务 API 继续运行'); + + // 测试数据库连接 + console.log('\n📦 数据库连接测试:'); + const [systemTest, businessTest, redisTest] = await Promise.all([ + withTimeout(database.local.test(), 5000, { message: '系统数据库检测超时' }), + withTimeout(cloudDb.test(), 5000, { message: '业务数据库检测超时' }), + withTimeout(redis.test(), 3000, { message: 'Redis 检测超时' }), + ]); + console.log(` [系统] ${systemTest.message}`); + console.log(` [业务] ${businessTest.message}`); + console.log(` [缓存] ${redisTest.message}`); + console.log('='.repeat(50)); + + if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') { + console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理'); + return; + } + + startScheduler(); + startStockSelectionSchedulers(); + startTaobaoProductEmbeddingSchedulers(); + startPddSkuDimensionSnapshotSchedulers(); + startPlanningMayProductSnapshotSchedulers(); + startPlanningDingWorkflowSyncSchedulers(); + startOpsBIncentiveSnapshotSchedulers(); + startGoodsFirstSeenScheduler(); + startAlibaba1688ImageSearchExpiryScheduler(); + startMkhReplenishAlertScheduler(); + + // Keep the first selection-pool page request from paying the remote schema + // inspection round-trip over the planning database tunnel. + setTimeout(() => { + warmSelectionFissionReadSchema() + .then(() => console.log('[SelectionFission] 列表读取结构预热完成')) + .catch((error) => console.warn('[SelectionFission] 列表读取结构预热失败:', error.message)); + }, 1000); + + if (isStartupPrewarmEnabled()) { + setTimeout(() => { + if (typeof prewarmChannelHotData === 'function') { + runGuardedPrewarm('channel-hot-data', () => prewarmChannelHotData({ skipCoverage: true })).catch((error) => { + console.warn('⚠️ 渠道默认查询预热失败:', error.message); + }); + } + }, 8000); + + setTimeout(() => { + if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { + runGuardedPrewarm('analysis-hot-pages', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { + console.warn('⚠️ 分析页默认查询预热失败:', error.message); + }); + } + }, 10000); + + + + if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { + const analysisPrewarmTimer = setInterval(() => { + runGuardedPrewarm('analysis-hot-pages:interval', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { + console.warn('⚠️ 分析页默认查询定时预热失败:', error.message); + }); + }, 10 * 60 * 1000); + + if (typeof analysisPrewarmTimer.unref === 'function') { + analysisPrewarmTimer.unref(); + } + } + + + } else { + console.log('⏸️ 启动预热已关闭,可通过 ENABLE_STARTUP_PREWARM=true 开启'); + } + + if (!isStartupPrewarmEnabled() && typeof prewarmChannelStockMonitorDashboardDefaultSnapshot === 'function') { + if (typeof prewarmChannelIdDefaultSnapshots === 'function') { + setTimeout(() => { + runGuardedPrewarm( + 'channel-id-analysis-resilience', + () => prewarmChannelIdDefaultSnapshots({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ ID数据分析韧性快照预热失败:', error.message); + }); + }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS); + } + + if (typeof prewarmChannelIdDashboardDefaultSnapshot === 'function') { + setTimeout(() => { + runGuardedPrewarm( + 'channel-id-dashboard-resilience', + () => prewarmChannelIdDashboardDefaultSnapshot({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ ID数据分析仪表盘韧性快照预热失败:', error.message); + }); + }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS + 15000); + } + + setTimeout(() => { + runGuardedPrewarm( + 'channel-stock-monitor-resilience', + () => prewarmChannelStockMonitorDashboardDefaultSnapshot({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ 库存销售影响仪表盘韧性快照预热失败:', error.message); + }); + }, CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS); + } + + if (String(process.env.ENABLE_BRAND_ANALYSIS_PREWARM || 'true').toLowerCase() !== 'false' + && typeof prewarmBrandAnalysisDefault === 'function') { + setTimeout(() => { + runGuardedPrewarm('brand-analysis-default', () => prewarmBrandAnalysisDefault()) + .then((result) => { + console.log( + `[BrandAnalysis] default prewarm completed total=${result.total || 0} elapsedMs=${result.elapsedMs || 0}` + ); + }) + .catch((error) => { + console.warn('⚠️ 品牌维度默认真实数据预热失败:', error.message); + }); + }, BRAND_ANALYSIS_PREWARM_DELAY_MS); + } + + const cron = require('node-cron'); + + if (typeof prewarmChannelHotData === 'function') { + cron.schedule('0 1 * * *', async () => { + console.log('🧊 凌晨 1 点执行渠道 23 小时缓存预热...'); + await runGuardedPrewarm( + 'channel-hot-data:daily', + () => prewarmChannelHotData({ forceRefresh: true, skipCoverage: true }) + ); + }, { + timezone: 'Asia/Shanghai', + }); + console.log('[Scheduler] 渠道 23 小时缓存预热任务已注册: 每天01:00执行'); + } + + if (process.env.NODE_ENV === 'production') { + const alertChecker = require('./services/alertChecker'); + + cron.schedule('*/5 * * * *', async () => { + console.log('🔔 执行预警检查...'); + await alertChecker.checkAllRules(); + }); + } +} + +async function bootstrap() { + const installStatus = await getInstallStatus(); + if (installStatus.required) { + await startInstallServer(installStatus); + return; + } + + if (installStatus.databaseUnavailable) { + console.warn(`⚠️ 系统数据库暂时不可用,不进入降级拦截模式: ${installStatus.systemDatabase?.reason || 'unknown'}`); + await startServer({ skipLocalSchema: true }); + return; + } + + await startServer(); +} + +bootstrap().catch((error) => { + console.error('❌ 服务启动失败:', error); + process.exit(1); +}); + +async function shutdownGracefully() { + await Promise.allSettled([ + cloudDb.close(), + jianSunDb.close(), + database.close(), + redis.close(), + ]); +} + +// 优雅关闭 +process.on('SIGTERM', async () => { + console.log('正在关闭服务...'); + await shutdownGracefully(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('正在关闭服务...'); + await shutdownGracefully(); + process.exit(0); +}); From 570666f772d61c61081b29de47094a1571806195 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:50 +0800 Subject: [PATCH 52/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From e579f7356028a96997edff63f2b953ad6b163cd0 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:51 +0800 Subject: [PATCH 53/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a90c2fd4f0811258f6dba310f6758c4382e9b3f1 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:52 +0800 Subject: [PATCH 54/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From fce269828fe0d4e5d24d97132721ed8a4ee676d1 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:53 +0800 Subject: [PATCH 55/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 9a77d15448ec8e83aa2c899650b1c8629d542bfe Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:54 +0800 Subject: [PATCH 56/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 9fd0bcaf4bc785d123db418c04df089d91f65d04 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:55 +0800 Subject: [PATCH 57/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 2bd5dce3d7dd46505eb8c60860ed11a7576bdb2f Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:56 +0800 Subject: [PATCH 58/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/config/sidebarNavigation.js | 396 ++++++++++++----------- 1 file changed, 201 insertions(+), 195 deletions(-) diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 63a6520..5257bb9 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -1,195 +1,201 @@ -export const DEFAULT_SIDEBAR_CONFIG = { - collapsedByDefault: false, - darkModeByDefault: false, - entries: [ - { key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 }, - { key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 }, - { key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 }, - { - key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true, - children: [ - { key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 }, - { key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 }, - ], - }, - { - key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true, - children: [ - { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 }, - ], - }, - { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, - { - key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true, - children: [ - { key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 }, - { key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 }, - ], - }, - { - key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true, - children: [ - { key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true }, - { key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true }, - { key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true }, - ], - }, - ], -}; - -const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG)); - -const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [ - entry.key, - ...flattenEntryKeys(entry.children || []), -]).filter(Boolean); - -const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [ - entry.routeKey, - ...flattenRouteKeys(entry.children || []), -]).filter(Boolean); - -const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries)); -const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries)); - -// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置 -const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板'; -const OPS_B_INCENTIVE_LEGACY_ORDER = 6; -const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']); -// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并 -const extractPromotedTopLevelEntries = (entries = []) => { - const promoted = []; - const nextEntries = entries.map((entry) => { - if (!Array.isArray(entry?.children)) return entry; - const children = []; - entry.children.forEach((child) => { - if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) { - promoted.push(child); - } else { - children.push(child); - } - }); - return children.length === entry.children.length ? entry : { ...entry, children }; - }); - return { nextEntries, promoted }; -}; - -const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { - const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key)); - const configuredKeys = new Set(); - - const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => { - const merged = { ...defaultEntry, ...configuredEntry }; - if (defaultEntry.type) { - merged.type = defaultEntry.type; - } - if (defaultEntry.routeKey) { - merged.routeKey = defaultEntry.routeKey; - } - if (typeof defaultEntry.requiresAdmin !== 'undefined') { - merged.requiresAdmin = defaultEntry.requiresAdmin; - } - if ( - defaultEntry.key === 'ops-b-incentive' - && (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL) - ) { - merged.label = defaultEntry.label; - } - if ( - defaultEntry.key === 'ops-b-incentive' - && (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER) - ) { - merged.order = defaultEntry.order; - } - const hasDefaultChildren = Array.isArray(defaultEntry.children); - const hasConfiguredChildren = Array.isArray(configuredEntry.children); - - if (hasDefaultChildren || hasConfiguredChildren) { - if (hasDefaultChildren) { - merged.type = defaultEntry.type; - } else if (defaultEntry.routeKey) { - merged.type = defaultEntry.type || 'item'; - delete merged.children; - delete merged.defaultExpanded; - return merged; - } - merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []); - } - - return merged; - }; - - const configuredMerged = configuredEntries.map((entry) => { - if (entry?.key) { - configuredKeys.add(entry.key); - } - - const defaultEntry = defaultByKey.get(entry?.key); - return defaultEntry ? mergeEntry(defaultEntry, entry) : entry; - }); - - const missingDefaults = defaultEntries - .filter((entry) => entry?.key && !configuredKeys.has(entry.key)) - .map((entry) => mergeEntry(entry, {})); - - return [...configuredMerged, ...missingDefaults]; -}; - -const mergeSidebarConfigWithDefaults = (config) => { - const defaults = cloneDefaultConfig(); - const nestedEntries = (config?.entries || []); - const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries); - const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean)); - const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted; - - return { - ...defaults, - ...config, - entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]), - }; -}; - -const pruneSidebarEntry = (entry) => { - if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) { - return null; - } - - const children = Array.isArray(entry.children) - ? entry.children.map(pruneSidebarEntry).filter(Boolean) - : undefined; - const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false; - const hasAllowedChildren = Array.isArray(children) && children.length > 0; - const keepEntry = hasAllowedRoute || hasAllowedChildren; - - if (!keepEntry) { - return null; - } - - return { - ...entry, - ...(Array.isArray(entry.children) ? { children } : {}), - }; -}; - -const removeRetiredEntries = (config) => ({ - ...config, - entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean), -}); - -export const parseSidebarConfig = (value) => { - if (!value) return cloneDefaultConfig(); - - try { - const parsed = typeof value === 'string' ? JSON.parse(value) : value; - if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig(); - return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed)); - } catch { - return cloneDefaultConfig(); - } -}; - -export const sortSidebarEntries = (entries = []) => ( - [...entries] - .filter((entry) => entry?.visible !== false) - .sort((a, b) => Number(a.order || 0) - Number(b.order || 0)) -); +export const DEFAULT_SIDEBAR_CONFIG = { + collapsedByDefault: false, + darkModeByDefault: false, + entries: [ + { key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 }, + { key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 }, + { key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 }, + { + key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true, + children: [ + { key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 }, + { key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 }, + ], + }, + { + key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true, + children: [ + { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 }, + ], + }, + { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, + { + key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true, + children: [ + { key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 }, + { key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 }, + ], + }, + { + key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true, + children: [ + { key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 }, + ], + }, + { + key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true, + children: [ + { key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true }, + { key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true }, + { key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true }, + ], + }, + ], +}; + +const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG)); + +const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [ + entry.key, + ...flattenEntryKeys(entry.children || []), +]).filter(Boolean); + +const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [ + entry.routeKey, + ...flattenRouteKeys(entry.children || []), +]).filter(Boolean); + +const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries)); +const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries)); + +// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置 +const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板'; +const OPS_B_INCENTIVE_LEGACY_ORDER = 6; +const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']); +// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并 +const extractPromotedTopLevelEntries = (entries = []) => { + const promoted = []; + const nextEntries = entries.map((entry) => { + if (!Array.isArray(entry?.children)) return entry; + const children = []; + entry.children.forEach((child) => { + if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) { + promoted.push(child); + } else { + children.push(child); + } + }); + return children.length === entry.children.length ? entry : { ...entry, children }; + }); + return { nextEntries, promoted }; +}; + +const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { + const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key)); + const configuredKeys = new Set(); + + const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => { + const merged = { ...defaultEntry, ...configuredEntry }; + if (defaultEntry.type) { + merged.type = defaultEntry.type; + } + if (defaultEntry.routeKey) { + merged.routeKey = defaultEntry.routeKey; + } + if (typeof defaultEntry.requiresAdmin !== 'undefined') { + merged.requiresAdmin = defaultEntry.requiresAdmin; + } + if ( + defaultEntry.key === 'ops-b-incentive' + && (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL) + ) { + merged.label = defaultEntry.label; + } + if ( + defaultEntry.key === 'ops-b-incentive' + && (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER) + ) { + merged.order = defaultEntry.order; + } + const hasDefaultChildren = Array.isArray(defaultEntry.children); + const hasConfiguredChildren = Array.isArray(configuredEntry.children); + + if (hasDefaultChildren || hasConfiguredChildren) { + if (hasDefaultChildren) { + merged.type = defaultEntry.type; + } else if (defaultEntry.routeKey) { + merged.type = defaultEntry.type || 'item'; + delete merged.children; + delete merged.defaultExpanded; + return merged; + } + merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []); + } + + return merged; + }; + + const configuredMerged = configuredEntries.map((entry) => { + if (entry?.key) { + configuredKeys.add(entry.key); + } + + const defaultEntry = defaultByKey.get(entry?.key); + return defaultEntry ? mergeEntry(defaultEntry, entry) : entry; + }); + + const missingDefaults = defaultEntries + .filter((entry) => entry?.key && !configuredKeys.has(entry.key)) + .map((entry) => mergeEntry(entry, {})); + + return [...configuredMerged, ...missingDefaults]; +}; + +const mergeSidebarConfigWithDefaults = (config) => { + const defaults = cloneDefaultConfig(); + const nestedEntries = (config?.entries || []); + const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries); + const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean)); + const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted; + + return { + ...defaults, + ...config, + entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]), + }; +}; + +const pruneSidebarEntry = (entry) => { + if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) { + return null; + } + + const children = Array.isArray(entry.children) + ? entry.children.map(pruneSidebarEntry).filter(Boolean) + : undefined; + const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false; + const hasAllowedChildren = Array.isArray(children) && children.length > 0; + const keepEntry = hasAllowedRoute || hasAllowedChildren; + + if (!keepEntry) { + return null; + } + + return { + ...entry, + ...(Array.isArray(entry.children) ? { children } : {}), + }; +}; + +const removeRetiredEntries = (config) => ({ + ...config, + entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean), +}); + +export const parseSidebarConfig = (value) => { + if (!value) return cloneDefaultConfig(); + + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig(); + return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed)); + } catch { + return cloneDefaultConfig(); + } +}; + +export const sortSidebarEntries = (entries = []) => ( + [...entries] + .filter((entry) => entry?.visible !== false) + .sort((a, b) => Number(a.order || 0) - Number(b.order || 0)) +); From 39c7933d2dfc621d4d29328c554f55860757b52f Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:57 +0800 Subject: [PATCH 59/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 9e2f26b1c91bba2992328337cd269678ec55d3f7 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:40:58 +0800 Subject: [PATCH 60/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/sidebarPages.js | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js index 4545a75..3e72ea9 100644 --- a/frontend/src/constants/sidebarPages.js +++ b/frontend/src/constants/sidebarPages.js @@ -1,12 +1,13 @@ -const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false }; -const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false }; - -export const SIDEBAR_PAGES = [ - { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, - { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, -]; - -export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { - acc[item.pageKey] = item; - return acc; -}, {}); +const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false }; +const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false }; + +export const SIDEBAR_PAGES = [ + { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, + { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, + { pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, +]; + +export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { + acc[item.pageKey] = item; + return acc; +}, {}); From cd936f5e92d466862c8ca1cb43cc415f8cc68766 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:41:00 +0800 Subject: [PATCH 61/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/layouts/BasicLayout.jsx | 2651 +++++++++++++------------- 1 file changed, 1326 insertions(+), 1325 deletions(-) diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index 6cb7ddc..eabe42e 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -1,1325 +1,1326 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { - AppstoreOutlined, - CloudOutlined, - DashboardOutlined, - DesktopOutlined, - DownOutlined, - ExperimentOutlined, - FundProjectionScreenOutlined, - LineChartOutlined, - LogoutOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, - MessageOutlined, - MoonOutlined, - RightOutlined, - RobotOutlined, - SettingOutlined, - ShoppingOutlined, - TableOutlined, - TeamOutlined, - UserOutlined, - SunOutlined, -} from '@ant-design/icons'; -import { Dropdown, Switch } from 'antd'; -import { verifyToken } from '../api/auth'; -import { getSetupStatus, getSystemConfig } from '../api/admin'; -import { getInstallStatus } from '../api/install'; -import ErrorBoundary from '../components/ErrorBoundary'; -import { AppShellSkeleton } from '../components/AppLoadingSkeleton'; -import { DEFAULT_SIDEBAR_CONFIG, parseSidebarConfig, sortSidebarEntries } from '../config/sidebarNavigation'; -import { - canRestoreDesktopSessionImmediately, - isDesktopEmbedSession, - markDesktopEmbedSession, - requestDesktopSessionRenewal, -} from '../utils/desktopEmbedSession'; - -const AUTH_USER_STORAGE_KEY = 'auth_user'; -const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; -const BRAND_HOME_URL = 'https://yyd.jiansuntuzhi.cn/analysis/summary'; -const PLANNING_HOME_URL = '/planning/dev-style-preview'; -const FALLBACK_SITE_NAME = '拼多多自动化运营平台'; -const FALLBACK_LOGO = 'https://s3.jiansun.vip/gongxiangpan/logos/1775097467497-1d6a81b8a76a.png'; -const readSidebarDarkModePreference = () => { - try { - const value = localStorage.getItem(SIDEBAR_DARK_MODE_STORAGE_KEY); - if (value === 'dark') return true; - if (value === 'light') return false; - } catch { - // Local persistence is optional in restricted browser contexts. - } - return null; -}; - -const SIDEBAR_ICON_MAP = { - appstore: AppstoreOutlined, - cloud: CloudOutlined, - dashboard: DashboardOutlined, - desktop: DesktopOutlined, - experiment: ExperimentOutlined, - 'line-chart': LineChartOutlined, - message: MessageOutlined, - projection: FundProjectionScreenOutlined, - robot: RobotOutlined, - setting: SettingOutlined, - shopping: ShoppingOutlined, - table: TableOutlined, - team: TeamOutlined, -}; - -const SIDEBAR_ROUTE_MAP = { - 'dashboard-preview': { path: '/planning/dev-style-preview' }, - 'marketing-events': { - path: '/planning/operation/marketing-events', - activePath: '/planning/operation/marketing-events', - matchMode: 'prefix', - }, - 'promotion-module': { path: '/planning/operation/promotion-module' }, - 'product-monitoring': { path: '/planning/operation/product-monitoring' }, - 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, - 'product-analysis': { path: '/planning/operation/product-analysis' }, - 'work-orders': { path: '/planning/operation/work-orders' }, - 'modular-stock-monitor-accessory': { - path: '/planning/operation/modular-stock-monitor', - activePath: '/planning/operation/modular-stock-monitor', - matchMode: 'prefix', - }, - 'mkh-bom-data': { - path: '/planning/operation/mkh-bom-data', - activePath: '/planning/operation/mkh-bom-data', - matchMode: 'prefix', - }, - settings: { path: '/settings' }, - 'online-desktop-users': { path: '/settings/online-desktop-users' }, - 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, -}; - - -const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { - if (entry.requiresAdmin && !canUseSettings) return []; - if (entry.type === 'section') return [{ ...entry }]; - - const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {}; - const routeMatches = (itemRoute = {}, pathname = '') => { - const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : []; - if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { - return false; - } - - const exactPaths = Array.isArray(itemRoute.matchPaths) ? itemRoute.matchPaths : []; - if (exactPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { - return true; - } - - const matchPath = itemRoute.activePath || itemRoute.path; - if (!matchPath) return false; - return itemRoute.matchMode === 'prefix' - ? pathname === matchPath || pathname.startsWith(`${matchPath}/`) - : pathname === matchPath; - }; - const buildItem = (item) => { - if (item.requiresAdmin && !canUseSettings) return null; - const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {}; - return { - ...item, - icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined, - path: itemRoute.path, - external: Boolean(itemRoute.externalUrl), - onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined, - match: (path) => routeMatches(itemRoute, path), - }; - }; - - const normalized = { - ...entry, - icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined, - path: route.path, - external: Boolean(route.externalUrl), - onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined, - match: (path) => routeMatches(route, path), - }; - - if (entry.type === 'group') { - normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean); - } - - return [normalized]; -}); - -function parseJwtPayload(token) { - if (!token) { - return null; - } - - try { - const [, payload] = token.split('.'); - if (!payload) { - return null; - } - - const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '='); - return JSON.parse(window.atob(padded)); - } catch { - return null; - } -} - -function isExpiredJwtPayload(payload) { - const exp = Number(payload?.exp || 0); - return exp > 0 && Math.floor(Date.now() / 1000) >= exp; -} - -function readStoredAuthUser(tokenSub) { - try { - const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY); - if (!raw) { - return null; - } - - const storedUser = JSON.parse(raw); - const storedSub = storedUser?.sub ?? storedUser?.id; - return storedSub && String(storedSub) === String(tokenSub) ? storedUser : null; - } catch { - return null; - } -} - -function clearAuthSession() { - localStorage.removeItem('token'); - localStorage.removeItem(AUTH_USER_STORAGE_KEY); -} - -function looksLikeMojibake(text) { - return /[ÃÂ][\u0080-\u00ff]|[äåæçèé][\u0080-\u00ff]|[\u00c0-\u00ff][\u0080-\u00bf]/.test(text); -} - -function repairMojibake(text) { - if (!text || !looksLikeMojibake(text) || typeof TextDecoder === 'undefined') { - return text; - } - - try { - const bytes = Uint8Array.from([...text].map((char) => char.charCodeAt(0) & 0xff)); - const repaired = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim(); - return repaired || text; - } catch { - return text; - } -} - -function normalizeDisplayName(...candidates) { - const rawName = candidates.find((item) => typeof item === 'string' && item.trim()); - return repairMojibake(rawName?.trim() || '个人中心'); -} - -function buildFallbackUserFromToken(token) { - const payload = parseJwtPayload(token); - if (!payload?.sub || isExpiredJwtPayload(payload)) { - return null; - } - - const storedUser = readStoredAuthUser(payload.sub); - return { - sub: payload.sub, - username: payload.username || '', - name: payload.name || payload.username || '当前用户', - email: payload.email || '', - role: payload.role || storedUser?.role || 'user', - picture: payload.picture || storedUser?.picture || '', - authSource: payload.authSource || storedUser?.authSource || 'local', - departmentId: payload.departmentId ?? storedUser?.departmentId ?? null, - departmentName: payload.departmentName || storedUser?.departmentName || '', - allowedPageKeys: Array.isArray(payload.allowedPageKeys) - ? payload.allowedPageKeys - : Array.isArray(storedUser?.allowedPageKeys) - ? storedUser.allowedPageKeys - : [], - }; -} - -function canFallbackToJwtPayload(error, fallbackUser) { - if (!fallbackUser) { - return false; - } - - if (fallbackUser.authSource === 'local-preview') { - return true; - } - - const status = Number(error?.response?.status || 0); - return !status || status >= 500; -} - -const BasicLayout = () => { - const location = useLocation(); - const navigate = useNavigate(); - const [user, setUser] = useState(null); - const [authReady, setAuthReady] = useState(false); - const [siteConfig, setSiteConfig] = useState({ siteName: FALLBACK_SITE_NAME, logo: FALLBACK_LOGO }); - const [sidebarConfig, setSidebarConfig] = useState(DEFAULT_SIDEBAR_CONFIG); - const [sidebarCollapsed, setSidebarCollapsed] = useState( - () => DEFAULT_SIDEBAR_CONFIG.collapsedByDefault - ); - const [sidebarDarkMode, setSidebarDarkMode] = useState( - () => readSidebarDarkModePreference() ?? DEFAULT_SIDEBAR_CONFIG.darkModeByDefault - ); - const [expandedSidebarKeys, setExpandedSidebarKeys] = useState( - DEFAULT_SIDEBAR_CONFIG.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) - ); - - const pathname = location.pathname; - const isImmersivePlanningRoute = false; - const canUseSettings = user?.role === 'admin'; - const userDisplayName = normalizeDisplayName(user?.name, user?.username, user?.email); - const userAvatar = user?.picture || siteConfig.logo || FALLBACK_LOGO; - - const sidebarSections = useMemo( - () => buildSidebarSections(sidebarConfig, canUseSettings), - [canUseSettings, sidebarConfig] - ); - useEffect(() => { - const loadSiteConfig = async () => { - try { - const config = await getSystemConfig(); - const nextSiteName = config?.site_name || FALLBACK_SITE_NAME; - const nextLogo = config?.logo || FALLBACK_LOGO; - const nextSidebarConfig = parseSidebarConfig(config?.sidebar_navigation); - - setSiteConfig({ siteName: nextSiteName, logo: nextLogo }); - setSidebarConfig(nextSidebarConfig); - setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault)); - setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault)); - setExpandedSidebarKeys( - nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) - ); - document.title = nextSiteName; - - let link = document.querySelector("link[rel*='icon']"); - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - link.href = nextLogo; - } catch { - document.title = FALLBACK_SITE_NAME; - } - }; - - loadSiteConfig(); - }, []); - - useEffect(() => { - const handleSiteConfigUpdated = (event) => { - const nextSiteName = event.detail?.siteName || FALLBACK_SITE_NAME; - const nextLogo = event.detail?.logo; - - setSiteConfig((current) => ({ - siteName: nextSiteName, - logo: nextLogo || current.logo, - })); - document.title = nextSiteName; - - if (nextLogo) { - let link = document.querySelector("link[rel*='icon']"); - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - link.href = nextLogo; - } - }; - - window.addEventListener('site-config-updated', handleSiteConfigUpdated); - return () => window.removeEventListener('site-config-updated', handleSiteConfigUpdated); - }, []); - - useEffect(() => { - const checkAuth = async () => { - markDesktopEmbedSession(); - const token = localStorage.getItem('token'); - const fallbackUser = buildFallbackUserFromToken(token); - - if (canRestoreDesktopSessionImmediately(isDesktopEmbedSession(), fallbackUser)) { - setUser(fallbackUser); - setAuthReady(true); - - try { - const verification = await verifyToken(); - if (!verification?.valid || !verification.user) { - clearAuthSession(); - requestDesktopSessionRenewal('web-session-invalid'); - return; - } - - const resolvedUser = verification.user; - localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); - setUser(resolvedUser); - - if (resolvedUser.role === 'admin') { - const setup = await getSetupStatus().catch((error) => { - console.warn('Setup status unavailable, continue with current session:', error); - return null; - }); - if (setup?.shouldShowWizard) { - navigate('/setup', { replace: true }); - } - } - } catch (error) { - if (!canFallbackToJwtPayload(error, fallbackUser)) { - clearAuthSession(); - requestDesktopSessionRenewal('web-session-invalid'); - } - } - return; - } - - try { - const installStatusPromise = getInstallStatus().catch(() => ({ required: false })); - const verificationPromise = verifyToken() - .then((res) => ({ res, error: null })) - .catch((error) => ({ res: null, error })); - const setupStatusPromise = fallbackUser?.role === 'admin' - ? getSetupStatus().catch((error) => { - console.warn('Setup status unavailable, continue with current session:', error); - return null; - }) - : null; - const [installStatus, verification] = await Promise.all([ - installStatusPromise, - verificationPromise, - ]); - if (installStatus?.required && !fallbackUser) { - clearAuthSession(); - navigate('/install', { replace: true }); - return; - } - - let resolvedUser = null; - const verifyError = verification.error; - if (verification.res?.valid) { - resolvedUser = verification.res.user; - } else if (verifyError) { - console.warn('Token verification failed:', verifyError); - } - - if (!resolvedUser && canFallbackToJwtPayload(verifyError, fallbackUser)) { - resolvedUser = fallbackUser; - } - - if (resolvedUser) { - localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); - if (resolvedUser.role === 'admin') { - try { - const setup = setupStatusPromise - ? await setupStatusPromise - : await getSetupStatus(); - if (setup?.shouldShowWizard) { - navigate('/setup', { replace: true }); - return; - } - } catch (error) { - console.warn('Setup status unavailable, continue with current session:', error); - } - } - - setUser(resolvedUser); - } else if (installStatus?.required) { - clearAuthSession(); - navigate('/install', { replace: true }); - } else { - clearAuthSession(); - if (!requestDesktopSessionRenewal('web-session-missing')) { - navigate('/login'); - } - } - } catch (error) { - console.error(error); - if (fallbackUser) { - setUser(fallbackUser); - } else { - clearAuthSession(); - if (!requestDesktopSessionRenewal('web-session-invalid')) { - navigate('/login'); - } - } - } finally { - setAuthReady(true); - } - }; - - checkAuth(); - }, [navigate]); - - useEffect(() => { - if (!authReady || !user) { - return; - } - - if (pathname === '/' || pathname === '/dashboard') { - navigate(PLANNING_HOME_URL, { replace: true }); - return; - } - - if (pathname.startsWith('/settings') && !canUseSettings) { - navigate(PLANNING_HOME_URL, { replace: true }); - return; - } - - const isAllowedPath = ( - pathname === '/planning' - || pathname.startsWith('/planning/') - || pathname === '/settings' - || pathname.startsWith('/settings/') - ); - - if (!isAllowedPath) { - navigate(PLANNING_HOME_URL, { replace: true }); - } - }, [authReady, canUseSettings, navigate, pathname, user]); - - const handleLogout = () => { - clearAuthSession(); - navigate('/login'); - }; - - const userMenu = [ - canUseSettings - ? { - key: 'settings', - icon: , - label: '系统设置', - onClick: () => navigate('/settings'), - } - : null, - { - key: 'logout', - icon: , - label: '退出登录', - onClick: handleLogout, - }, - ].filter(Boolean); - - const isSidebarEntryActive = (entry) => { - if (!entry) return false; - if (entry.match?.(pathname)) return true; - return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child)); - }; - - const toggleSidebarGroup = (key) => { - setSidebarCollapsed(false); - setExpandedSidebarKeys((keys) => ( - keys.includes(key) - ? keys.filter((item) => item !== key) - : [...keys, key] - )); - }; - - const handleSidebarLeafClick = (item) => { - if (item.external) { - item.onClick?.(); - return; - } - if (item.path) { - navigate(item.path); - } - }; - - const handleSidebarDarkModeChange = (checked) => { - setSidebarDarkMode(checked); - try { - localStorage.setItem(SIDEBAR_DARK_MODE_STORAGE_KEY, checked ? 'dark' : 'light'); - } catch { - // Keep the current-session preference when persistence is unavailable. - } - }; - - if (!authReady) { - return ; - } - - return ( -
- - -
- - - -
- - -
- ); -}; - -export default BasicLayout; +import React, { useEffect, useMemo, useState } from 'react'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { + AppstoreOutlined, + CloudOutlined, + DashboardOutlined, + DesktopOutlined, + DownOutlined, + ExperimentOutlined, + FundProjectionScreenOutlined, + LineChartOutlined, + LogoutOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + MessageOutlined, + MoonOutlined, + RightOutlined, + RobotOutlined, + SettingOutlined, + ShoppingOutlined, + TableOutlined, + TeamOutlined, + UserOutlined, + SunOutlined, +} from '@ant-design/icons'; +import { Dropdown, Switch } from 'antd'; +import { verifyToken } from '../api/auth'; +import { getSetupStatus, getSystemConfig } from '../api/admin'; +import { getInstallStatus } from '../api/install'; +import ErrorBoundary from '../components/ErrorBoundary'; +import { AppShellSkeleton } from '../components/AppLoadingSkeleton'; +import { DEFAULT_SIDEBAR_CONFIG, parseSidebarConfig, sortSidebarEntries } from '../config/sidebarNavigation'; +import { + canRestoreDesktopSessionImmediately, + isDesktopEmbedSession, + markDesktopEmbedSession, + requestDesktopSessionRenewal, +} from '../utils/desktopEmbedSession'; + +const AUTH_USER_STORAGE_KEY = 'auth_user'; +const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; +const BRAND_HOME_URL = 'https://yyd.jiansuntuzhi.cn/analysis/summary'; +const PLANNING_HOME_URL = '/planning/dev-style-preview'; +const FALLBACK_SITE_NAME = '拼多多自动化运营平台'; +const FALLBACK_LOGO = 'https://s3.jiansun.vip/gongxiangpan/logos/1775097467497-1d6a81b8a76a.png'; +const readSidebarDarkModePreference = () => { + try { + const value = localStorage.getItem(SIDEBAR_DARK_MODE_STORAGE_KEY); + if (value === 'dark') return true; + if (value === 'light') return false; + } catch { + // Local persistence is optional in restricted browser contexts. + } + return null; +}; + +const SIDEBAR_ICON_MAP = { + appstore: AppstoreOutlined, + cloud: CloudOutlined, + dashboard: DashboardOutlined, + desktop: DesktopOutlined, + experiment: ExperimentOutlined, + 'line-chart': LineChartOutlined, + message: MessageOutlined, + projection: FundProjectionScreenOutlined, + robot: RobotOutlined, + setting: SettingOutlined, + shopping: ShoppingOutlined, + table: TableOutlined, + team: TeamOutlined, +}; + +const SIDEBAR_ROUTE_MAP = { + 'dashboard-preview': { path: '/planning/dev-style-preview' }, + 'marketing-events': { + path: '/planning/operation/marketing-events', + activePath: '/planning/operation/marketing-events', + matchMode: 'prefix', + }, + 'promotion-module': { path: '/planning/operation/promotion-module' }, + 'product-monitoring': { path: '/planning/operation/product-monitoring' }, + 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, + 'product-analysis': { path: '/planning/operation/product-analysis' }, + 'work-orders': { path: '/planning/operation/work-orders' }, + 'modular-stock-monitor-accessory': { + path: '/planning/operation/modular-stock-monitor', + activePath: '/planning/operation/modular-stock-monitor', + matchMode: 'prefix', + }, + 'mkh-bom-data': { + path: '/planning/operation/mkh-bom-data', + activePath: '/planning/operation/mkh-bom-data', + matchMode: 'prefix', + }, + 'daily-inventory': { path: '/planning/operation/daily-inventory' }, + settings: { path: '/settings' }, + 'online-desktop-users': { path: '/settings/online-desktop-users' }, + 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, +}; + + +const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { + if (entry.requiresAdmin && !canUseSettings) return []; + if (entry.type === 'section') return [{ ...entry }]; + + const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {}; + const routeMatches = (itemRoute = {}, pathname = '') => { + const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : []; + if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return false; + } + + const exactPaths = Array.isArray(itemRoute.matchPaths) ? itemRoute.matchPaths : []; + if (exactPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return true; + } + + const matchPath = itemRoute.activePath || itemRoute.path; + if (!matchPath) return false; + return itemRoute.matchMode === 'prefix' + ? pathname === matchPath || pathname.startsWith(`${matchPath}/`) + : pathname === matchPath; + }; + const buildItem = (item) => { + if (item.requiresAdmin && !canUseSettings) return null; + const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {}; + return { + ...item, + icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined, + path: itemRoute.path, + external: Boolean(itemRoute.externalUrl), + onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined, + match: (path) => routeMatches(itemRoute, path), + }; + }; + + const normalized = { + ...entry, + icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined, + path: route.path, + external: Boolean(route.externalUrl), + onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined, + match: (path) => routeMatches(route, path), + }; + + if (entry.type === 'group') { + normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean); + } + + return [normalized]; +}); + +function parseJwtPayload(token) { + if (!token) { + return null; + } + + try { + const [, payload] = token.split('.'); + if (!payload) { + return null; + } + + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '='); + return JSON.parse(window.atob(padded)); + } catch { + return null; + } +} + +function isExpiredJwtPayload(payload) { + const exp = Number(payload?.exp || 0); + return exp > 0 && Math.floor(Date.now() / 1000) >= exp; +} + +function readStoredAuthUser(tokenSub) { + try { + const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY); + if (!raw) { + return null; + } + + const storedUser = JSON.parse(raw); + const storedSub = storedUser?.sub ?? storedUser?.id; + return storedSub && String(storedSub) === String(tokenSub) ? storedUser : null; + } catch { + return null; + } +} + +function clearAuthSession() { + localStorage.removeItem('token'); + localStorage.removeItem(AUTH_USER_STORAGE_KEY); +} + +function looksLikeMojibake(text) { + return /[ÃÂ][\u0080-\u00ff]|[äåæçèé][\u0080-\u00ff]|[\u00c0-\u00ff][\u0080-\u00bf]/.test(text); +} + +function repairMojibake(text) { + if (!text || !looksLikeMojibake(text) || typeof TextDecoder === 'undefined') { + return text; + } + + try { + const bytes = Uint8Array.from([...text].map((char) => char.charCodeAt(0) & 0xff)); + const repaired = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim(); + return repaired || text; + } catch { + return text; + } +} + +function normalizeDisplayName(...candidates) { + const rawName = candidates.find((item) => typeof item === 'string' && item.trim()); + return repairMojibake(rawName?.trim() || '个人中心'); +} + +function buildFallbackUserFromToken(token) { + const payload = parseJwtPayload(token); + if (!payload?.sub || isExpiredJwtPayload(payload)) { + return null; + } + + const storedUser = readStoredAuthUser(payload.sub); + return { + sub: payload.sub, + username: payload.username || '', + name: payload.name || payload.username || '当前用户', + email: payload.email || '', + role: payload.role || storedUser?.role || 'user', + picture: payload.picture || storedUser?.picture || '', + authSource: payload.authSource || storedUser?.authSource || 'local', + departmentId: payload.departmentId ?? storedUser?.departmentId ?? null, + departmentName: payload.departmentName || storedUser?.departmentName || '', + allowedPageKeys: Array.isArray(payload.allowedPageKeys) + ? payload.allowedPageKeys + : Array.isArray(storedUser?.allowedPageKeys) + ? storedUser.allowedPageKeys + : [], + }; +} + +function canFallbackToJwtPayload(error, fallbackUser) { + if (!fallbackUser) { + return false; + } + + if (fallbackUser.authSource === 'local-preview') { + return true; + } + + const status = Number(error?.response?.status || 0); + return !status || status >= 500; +} + +const BasicLayout = () => { + const location = useLocation(); + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [authReady, setAuthReady] = useState(false); + const [siteConfig, setSiteConfig] = useState({ siteName: FALLBACK_SITE_NAME, logo: FALLBACK_LOGO }); + const [sidebarConfig, setSidebarConfig] = useState(DEFAULT_SIDEBAR_CONFIG); + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => DEFAULT_SIDEBAR_CONFIG.collapsedByDefault + ); + const [sidebarDarkMode, setSidebarDarkMode] = useState( + () => readSidebarDarkModePreference() ?? DEFAULT_SIDEBAR_CONFIG.darkModeByDefault + ); + const [expandedSidebarKeys, setExpandedSidebarKeys] = useState( + DEFAULT_SIDEBAR_CONFIG.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) + ); + + const pathname = location.pathname; + const isImmersivePlanningRoute = false; + const canUseSettings = user?.role === 'admin'; + const userDisplayName = normalizeDisplayName(user?.name, user?.username, user?.email); + const userAvatar = user?.picture || siteConfig.logo || FALLBACK_LOGO; + + const sidebarSections = useMemo( + () => buildSidebarSections(sidebarConfig, canUseSettings), + [canUseSettings, sidebarConfig] + ); + useEffect(() => { + const loadSiteConfig = async () => { + try { + const config = await getSystemConfig(); + const nextSiteName = config?.site_name || FALLBACK_SITE_NAME; + const nextLogo = config?.logo || FALLBACK_LOGO; + const nextSidebarConfig = parseSidebarConfig(config?.sidebar_navigation); + + setSiteConfig({ siteName: nextSiteName, logo: nextLogo }); + setSidebarConfig(nextSidebarConfig); + setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault)); + setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault)); + setExpandedSidebarKeys( + nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) + ); + document.title = nextSiteName; + + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = nextLogo; + } catch { + document.title = FALLBACK_SITE_NAME; + } + }; + + loadSiteConfig(); + }, []); + + useEffect(() => { + const handleSiteConfigUpdated = (event) => { + const nextSiteName = event.detail?.siteName || FALLBACK_SITE_NAME; + const nextLogo = event.detail?.logo; + + setSiteConfig((current) => ({ + siteName: nextSiteName, + logo: nextLogo || current.logo, + })); + document.title = nextSiteName; + + if (nextLogo) { + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = nextLogo; + } + }; + + window.addEventListener('site-config-updated', handleSiteConfigUpdated); + return () => window.removeEventListener('site-config-updated', handleSiteConfigUpdated); + }, []); + + useEffect(() => { + const checkAuth = async () => { + markDesktopEmbedSession(); + const token = localStorage.getItem('token'); + const fallbackUser = buildFallbackUserFromToken(token); + + if (canRestoreDesktopSessionImmediately(isDesktopEmbedSession(), fallbackUser)) { + setUser(fallbackUser); + setAuthReady(true); + + try { + const verification = await verifyToken(); + if (!verification?.valid || !verification.user) { + clearAuthSession(); + requestDesktopSessionRenewal('web-session-invalid'); + return; + } + + const resolvedUser = verification.user; + localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); + setUser(resolvedUser); + + if (resolvedUser.role === 'admin') { + const setup = await getSetupStatus().catch((error) => { + console.warn('Setup status unavailable, continue with current session:', error); + return null; + }); + if (setup?.shouldShowWizard) { + navigate('/setup', { replace: true }); + } + } + } catch (error) { + if (!canFallbackToJwtPayload(error, fallbackUser)) { + clearAuthSession(); + requestDesktopSessionRenewal('web-session-invalid'); + } + } + return; + } + + try { + const installStatusPromise = getInstallStatus().catch(() => ({ required: false })); + const verificationPromise = verifyToken() + .then((res) => ({ res, error: null })) + .catch((error) => ({ res: null, error })); + const setupStatusPromise = fallbackUser?.role === 'admin' + ? getSetupStatus().catch((error) => { + console.warn('Setup status unavailable, continue with current session:', error); + return null; + }) + : null; + const [installStatus, verification] = await Promise.all([ + installStatusPromise, + verificationPromise, + ]); + if (installStatus?.required && !fallbackUser) { + clearAuthSession(); + navigate('/install', { replace: true }); + return; + } + + let resolvedUser = null; + const verifyError = verification.error; + if (verification.res?.valid) { + resolvedUser = verification.res.user; + } else if (verifyError) { + console.warn('Token verification failed:', verifyError); + } + + if (!resolvedUser && canFallbackToJwtPayload(verifyError, fallbackUser)) { + resolvedUser = fallbackUser; + } + + if (resolvedUser) { + localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); + if (resolvedUser.role === 'admin') { + try { + const setup = setupStatusPromise + ? await setupStatusPromise + : await getSetupStatus(); + if (setup?.shouldShowWizard) { + navigate('/setup', { replace: true }); + return; + } + } catch (error) { + console.warn('Setup status unavailable, continue with current session:', error); + } + } + + setUser(resolvedUser); + } else if (installStatus?.required) { + clearAuthSession(); + navigate('/install', { replace: true }); + } else { + clearAuthSession(); + if (!requestDesktopSessionRenewal('web-session-missing')) { + navigate('/login'); + } + } + } catch (error) { + console.error(error); + if (fallbackUser) { + setUser(fallbackUser); + } else { + clearAuthSession(); + if (!requestDesktopSessionRenewal('web-session-invalid')) { + navigate('/login'); + } + } + } finally { + setAuthReady(true); + } + }; + + checkAuth(); + }, [navigate]); + + useEffect(() => { + if (!authReady || !user) { + return; + } + + if (pathname === '/' || pathname === '/dashboard') { + navigate(PLANNING_HOME_URL, { replace: true }); + return; + } + + if (pathname.startsWith('/settings') && !canUseSettings) { + navigate(PLANNING_HOME_URL, { replace: true }); + return; + } + + const isAllowedPath = ( + pathname === '/planning' + || pathname.startsWith('/planning/') + || pathname === '/settings' + || pathname.startsWith('/settings/') + ); + + if (!isAllowedPath) { + navigate(PLANNING_HOME_URL, { replace: true }); + } + }, [authReady, canUseSettings, navigate, pathname, user]); + + const handleLogout = () => { + clearAuthSession(); + navigate('/login'); + }; + + const userMenu = [ + canUseSettings + ? { + key: 'settings', + icon: , + label: '系统设置', + onClick: () => navigate('/settings'), + } + : null, + { + key: 'logout', + icon: , + label: '退出登录', + onClick: handleLogout, + }, + ].filter(Boolean); + + const isSidebarEntryActive = (entry) => { + if (!entry) return false; + if (entry.match?.(pathname)) return true; + return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child)); + }; + + const toggleSidebarGroup = (key) => { + setSidebarCollapsed(false); + setExpandedSidebarKeys((keys) => ( + keys.includes(key) + ? keys.filter((item) => item !== key) + : [...keys, key] + )); + }; + + const handleSidebarLeafClick = (item) => { + if (item.external) { + item.onClick?.(); + return; + } + if (item.path) { + navigate(item.path); + } + }; + + const handleSidebarDarkModeChange = (checked) => { + setSidebarDarkMode(checked); + try { + localStorage.setItem(SIDEBAR_DARK_MODE_STORAGE_KEY, checked ? 'dark' : 'light'); + } catch { + // Keep the current-session preference when persistence is unavailable. + } + }; + + if (!authReady) { + return ; + } + + return ( +
+ + +
+ + + +
+ + +
+ ); +}; + +export default BasicLayout; From b57fc1ad5bb0bdc3f97c6afe43e055835a3cf5dd Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:41:01 +0800 Subject: [PATCH 62/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From eaa8489600609924fa612a61197bd4d5e07d3f3e Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:41:02 +0800 Subject: [PATCH 63/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From e4b6bbcf315e002e328cb51fbe2aacadd2f29015 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:41:03 +0800 Subject: [PATCH 64/70] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/router/index.jsx | 313 +++++++++++++++++----------------- 1 file changed, 159 insertions(+), 154 deletions(-) diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index 0c64a0c..05a471b 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -1,154 +1,159 @@ -import React, { lazy, Suspense } from 'react'; -import { createBrowserRouter, Navigate } from 'react-router-dom'; -import BasicLayout from '../layouts/BasicLayout'; -import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton'; - -const Login = lazy(() => import('../pages/Login')); -const Install = lazy(() => import('../pages/Install')); -const Setup = lazy(() => import('../pages/Setup')); -const Settings = lazy(() => import('../pages/Settings')); -const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers')); -const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks')); -const MarketingEvents = lazy(() => import('../pages/MarketingEvents')); -const ProductInformation = lazy(() => import('../pages/ProductInformation')); -const PromotionModule = lazy(() => import('../pages/PromotionModule')); -const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder')); -const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview')); -const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis')); -const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard')); -const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); -const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); -const MkhBomData = lazy(() => import('../pages/MkhBomData')); - -const lazyLoadingElement = ; - -const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => { - const LazyView = lazyView; - - return ( - - - - ); -}; - -const HOME_URL = '/planning/dev-style-preview'; - -const router = createBrowserRouter([ - { - path: '/login', - element: renderLazyComponent(Login, {}, ), - }, - { - path: '/callback', - element: renderLazyComponent(Login, {}, ), - }, - { - path: '/setup', - element: renderLazyComponent(Setup), - }, - { - path: '/install', - element: renderLazyComponent(Install), - }, - { - path: '/', - element: , - children: [ - { - index: true, - element: , - }, - { - path: 'dashboard', - element: , - }, - { - path: 'channel', - element: , - }, - { - path: 'planning', - element: , - }, - { - path: 'planning/operation/marketing-events', - element: , - }, - { - path: 'planning/operation/marketing-events/activity-products', - element: renderLazyComponent(MarketingEvents), - }, - { - path: 'planning/operation/marketing-tools', - element: , - }, - { - path: 'planning/operation/product-information', - element: renderLazyComponent(ProductInformation), - }, - { - path: 'planning/operation/product-analysis', - element: renderLazyComponent(ProductAnalysis), - }, - { - path: 'planning/operation/promotion-module', - element: renderLazyComponent(PromotionModule), - }, - { - path: 'planning/operation/ops-b-incentive', - element: renderLazyComponent(OpsBIncentiveBoard), - }, - { - path: 'planning/operation/product-monitoring', - element: renderLazyComponent(ProductMonitoring), - }, - { - path: 'planning/operation/modular-stock-monitor', - element: renderLazyComponent(ModularStockMonitor), - }, - { - path: 'planning/operation/mkh-bom-data', - element: renderLazyComponent(MkhBomData), - }, - { path: 'planning/operation/store-checkup', element: }, - { path: 'planning/operation/review-analysis', element: }, - { path: 'planning/operation/customer-service-checkup', element: }, - { path: 'planning/operation/pricing-calculator', element: }, - { path: 'planning/operation/desktop-client-download', element: }, - { path: 'planning/operation/ai-buyer-show', element: }, - { path: 'planning/operation/ai-seeding-video', element: }, - { path: 'settings/desktop-client-releases', element: }, - { - path: 'planning/operation/:featureKey', - element: renderLazyComponent(OperationPlaceholder), - }, - { - path: 'planning/upload', - element: , - }, - { - path: 'planning/dev-style-preview', - element: renderLazyComponent(DevelopmentStylePreview), - }, - { - path: 'settings', - element: renderLazyComponent(Settings), - }, - { - path: 'settings/online-desktop-users', - element: renderLazyComponent(OnlineDesktopUsers), - }, - { - path: 'settings/desktop-execution-tasks', - element: renderLazyComponent(DesktopExecutionTasks), - }, - { - path: '*', - element: , - }, - ], - }, -]); - -export default router; +import React, { lazy, Suspense } from 'react'; +import { createBrowserRouter, Navigate } from 'react-router-dom'; +import BasicLayout from '../layouts/BasicLayout'; +import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton'; + +const Login = lazy(() => import('../pages/Login')); +const Install = lazy(() => import('../pages/Install')); +const Setup = lazy(() => import('../pages/Setup')); +const Settings = lazy(() => import('../pages/Settings')); +const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers')); +const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks')); +const MarketingEvents = lazy(() => import('../pages/MarketingEvents')); +const ProductInformation = lazy(() => import('../pages/ProductInformation')); +const PromotionModule = lazy(() => import('../pages/PromotionModule')); +const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder')); +const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview')); +const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis')); +const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard')); +const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); +const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); +const MkhBomData = lazy(() => import('../pages/MkhBomData')); +const DailyInventory = lazy(() => import('../pages/DailyInventory')); + +const lazyLoadingElement = ; + +const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => { + const LazyView = lazyView; + + return ( + + + + ); +}; + +const HOME_URL = '/planning/dev-style-preview'; + +const router = createBrowserRouter([ + { + path: '/login', + element: renderLazyComponent(Login, {}, ), + }, + { + path: '/callback', + element: renderLazyComponent(Login, {}, ), + }, + { + path: '/setup', + element: renderLazyComponent(Setup), + }, + { + path: '/install', + element: renderLazyComponent(Install), + }, + { + path: '/', + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'dashboard', + element: , + }, + { + path: 'channel', + element: , + }, + { + path: 'planning', + element: , + }, + { + path: 'planning/operation/marketing-events', + element: , + }, + { + path: 'planning/operation/marketing-events/activity-products', + element: renderLazyComponent(MarketingEvents), + }, + { + path: 'planning/operation/marketing-tools', + element: , + }, + { + path: 'planning/operation/product-information', + element: renderLazyComponent(ProductInformation), + }, + { + path: 'planning/operation/product-analysis', + element: renderLazyComponent(ProductAnalysis), + }, + { + path: 'planning/operation/promotion-module', + element: renderLazyComponent(PromotionModule), + }, + { + path: 'planning/operation/ops-b-incentive', + element: renderLazyComponent(OpsBIncentiveBoard), + }, + { + path: 'planning/operation/product-monitoring', + element: renderLazyComponent(ProductMonitoring), + }, + { + path: 'planning/operation/modular-stock-monitor', + element: renderLazyComponent(ModularStockMonitor), + }, + { + path: 'planning/operation/mkh-bom-data', + element: renderLazyComponent(MkhBomData), + }, + { + path: 'planning/operation/daily-inventory', + element: renderLazyComponent(DailyInventory), + }, + { path: 'planning/operation/store-checkup', element: }, + { path: 'planning/operation/review-analysis', element: }, + { path: 'planning/operation/customer-service-checkup', element: }, + { path: 'planning/operation/pricing-calculator', element: }, + { path: 'planning/operation/desktop-client-download', element: }, + { path: 'planning/operation/ai-buyer-show', element: }, + { path: 'planning/operation/ai-seeding-video', element: }, + { path: 'settings/desktop-client-releases', element: }, + { + path: 'planning/operation/:featureKey', + element: renderLazyComponent(OperationPlaceholder), + }, + { + path: 'planning/upload', + element: , + }, + { + path: 'planning/dev-style-preview', + element: renderLazyComponent(DevelopmentStylePreview), + }, + { + path: 'settings', + element: renderLazyComponent(Settings), + }, + { + path: 'settings/online-desktop-users', + element: renderLazyComponent(OnlineDesktopUsers), + }, + { + path: 'settings/desktop-execution-tasks', + element: renderLazyComponent(DesktopExecutionTasks), + }, + { + path: '*', + element: , + }, + ], + }, +]); + +export default router; From 4227d9001e7323a4797d0af8b18e5e668767dd15 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 09:19:21 +0800 Subject: [PATCH 65/70] fix: reconcile MKH received stock and work orders --- backend/src/services/mkhBomService.js | 65 ++++++++++++++++----------- backend/test/mkhBomService.test.js | 21 +++++---- 2 files changed, 53 insertions(+), 33 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 401e60e..c5fdbb0 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -191,13 +191,13 @@ // → 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。 // ⚠️ 「同步最新数据」按钮只清服务端缓存(clearMkhBomCache),**不会触发上游同步**, // 所以点上多少次都还是 0 —— 数据源当天就没有这条库存。 -// 兜底规则(**只补 0,不覆盖有效值**): -// ① inventoryInfo 品牌仓 qty > 0 → 用它(stockSource='realtime',与旧口径完全一致) -// ② 否则箱表品牌仓 `bin` 非空行 qty 合计 > 0 → 用它(stockSource='pack') -// ③ 否则 inventoryInfo 有品牌仓行(qty≤0) → 用 0(stockSource='realtime') -// ④ 否则回退监控快照 jsStock → (stockSource='monitor') -// ⚠️ 为什么 inv 优先、pack 只补 0:两表都 > 0 且不等的 28 个成品里 **25 个是 inv 更大** -// (箱表只记装箱的货,散货不计)→ 若拿 pack 当主源会系统性低估库存、误报缺货。 +// 兜底规则(2026-09-24 修正): +// ① inventoryInfo 与箱表品牌仓 `bin` 非空行都有值 → 取两者较大值 +// ② 只有其中一个当前库存源有值 → 用该源 +// ③ 两个当前库存源都无记录 → 回退监控快照 jsStock +// 取较大值的原因:inventoryInfo 包含散货,通常比箱表大;但新入库会先进入小时级 +// 箱表、inventoryInfo 仍保留旧正数。若一律让 inventoryInfo 优先,就会把新入库库存压住; +// 取 max 既不会丢散货,也能在日更快照滞后时立即看到箱表新货。 // ⚠️ 必须过滤 `bin` 为空的行:那是**装箱记录不是库存**(品牌仓 bin 空行合计 21 件), // 不过滤会虚增。 // 影响面(2026-09-19 实测):品牌仓 4,408 个成品里,走 ② 兜底的只有 **3 个** @@ -220,8 +220,9 @@ // (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表) // · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓' // AND 状态 <> '作废' ← 作废单据一律不核算 -// AND 收货状态 <> '全部入库' ← 未完成(全部入库 ⟺ 状态=完成) -// · 在途加工量 = Σ(数量 − 已入库数量),按 商品编码 归集 +// AND max(数量 − 已入库数量, 0) > 0 ← 只认真实剩余量 +// · 在途加工量 = Σ max(数量 − 已入库数量, 0),按 商品编码 归集。 +// 不再依赖「收货状态」文本:上游状态字段延迟时,已入库数量已追平的单据必须立即退出在途。 // ⚠️ 为什么走 getPool 而不是 cloudDatabase.query: // query() 会经 rewriteReportReplicaSql() 把 `dbo.xxx` 重写成 `rpt.xxx`, // 跨库四段名 `LS_JS_DMP.JSDataMiddlePlatform.dbo.t` 会被改坏成 @@ -1084,12 +1085,11 @@ function buildReplenishMetrics(row = {}) { const hasRealtime = realtimeStock !== null; let stock; let stockSource; - if (hasRealtime && realtimeStock > 0) { - stock = realtimeStock; - stockSource = 'realtime'; - } else if (packStock !== null && packStock > 0) { - stock = packStock; - stockSource = 'pack'; + if ((hasRealtime && realtimeStock > 0) || (packStock !== null && packStock > 0)) { + const realtimeValue = hasRealtime && realtimeStock > 0 ? realtimeStock : 0; + const packValue = packStock !== null && packStock > 0 ? packStock : 0; + stock = Math.max(realtimeValue, packValue); + stockSource = packValue > realtimeValue ? 'pack' : 'realtime'; } else if (hasRealtime) { stock = realtimeStock; stockSource = 'realtime'; @@ -1105,7 +1105,7 @@ function buildReplenishMetrics(row = {}) { // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0); const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0); - const hasPendingWorkOrder = pendingWorkOrderQty > 0 || pendingWorkOrderCount > 0; + const hasPendingWorkOrder = pendingWorkOrderQty > 0; // 待发货订单(见文件头 ⑩):当前未发货订单里的件数 const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0); @@ -1353,19 +1353,34 @@ async function loadPendingWorkOrderIndex() { const request = pool.request(); request.input('workOrderWarehouse', WORK_ORDER_WAREHOUSE); request.input('voidStatus', WORK_ORDER_VOID_STATUS); - request.input('receivedStatus', WORK_ORDER_RECEIVED_STATUS); - const result = await request.query(` SELECT [商品编码] AS skuId, - SUM(CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4))) AS pendingQty, - COUNT(DISTINCT [加工单号]) AS orderCount, - MIN(TRY_CONVERT(DATETIME, [预计加工完成时间], 111)) AS eta, - MIN(CAST([加工单号] AS NVARCHAR(40))) AS firstOrderNo + SUM(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4)) + ELSE 0 + END) AS pendingQty, + COUNT(DISTINCT CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN [加工单号] + END) AS orderCount, + MIN(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN TRY_CONVERT(DATETIME, [预计加工完成时间], 111) + END) AS eta, + MIN(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN CAST([加工单号] AS NVARCHAR(40)) + END) AS firstOrderNo FROM ${WORK_ORDER_TABLE} WITH (NOLOCK) WHERE [加工仓储方] = @workOrderWarehouse AND [状态] <> @voidStatus - AND [收货状态] <> @receivedStatus - GROUP BY [商品编码]`); + GROUP BY [商品编码] + HAVING SUM(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4)) + ELSE 0 + END) > 0`); const bySku = new Map(); (result.recordset || []).forEach((row) => { @@ -1373,7 +1388,7 @@ async function loadPendingWorkOrderIndex() { if (!key) return; const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0); const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0); - if (pendingQty <= 0 && orderCount <= 0) return; + if (pendingQty <= 0) return; bySku.set(key, { // 原始商品编码:配件占用展开(见文件头 ⑪)需要拿它回查 BOM,故一并保留 skuId: toText(row.skuId), diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 93cdded..460ffd1 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -250,7 +250,7 @@ test('replenish: live refresh ignores delayed sales and stock snapshots', () => assert.equal(live.stockSource, 'live-missing'); }); -test('replenish: box/pallet stock fills in when the daily inventory table is stale (⑦-d)', () => { +test('replenish: the larger current-stock source wins when the daily inventory table is stale (⑦-d)', () => { // 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货 const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 }); assert.equal(fillFromZero.stock, 4); @@ -261,9 +261,14 @@ test('replenish: box/pallet stock fills in when the daily inventory table is sta assert.equal(fillFromMissing.stock, 5); assert.equal(fillFromMissing.stockSource, 'pack'); - // 库存表有货时箱表不参与 —— 箱表只记装箱的货,拿它当主源会低估库存 - const realtimeWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 100, monitorStock: 0 }); - assert.equal(realtimeWins.stock, 4); + // 新入库先到箱表:库存快照仍为旧的正数 4 时,不能把箱表的 47 压住 + const packWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 47, monitorStock: 0 }); + assert.equal(packWins.stock, 47); + assert.equal(packWins.stockSource, 'pack'); + + // 散货只在 inventoryInfo 中:该表较大时仍保留完整库存 + const realtimeWins = metrics({ avgSale: 1, realtimeStock: 100, packStock: 47, monitorStock: 0 }); + assert.equal(realtimeWins.stock, 100); assert.equal(realtimeWins.stockSource, 'realtime'); // 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照 @@ -363,16 +368,16 @@ test('replenish: pending work order fields default to empty without any order', assert.equal(none.pendingWorkOrderEta, null); assert.equal(none.pendingWorkOrderNo, null); - // 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理 + // 已入库数量追平后,即使上游状态文本或单据计数延迟,也不再按「已排产」处理 const zeroQty = metrics({ avgSale: 1, realtimeStock: 0, pendingWorkOrderQty: 0, pendingWorkOrderCount: 2, }); - assert.equal(zeroQty.hasPendingWorkOrder, true); - assert.equal(zeroQty.warningLevel, '已排产'); - assert.equal(zeroQty.hasWarning, false); + assert.equal(zeroQty.hasPendingWorkOrder, false); + assert.equal(zeroQty.warningLevel, '缺货'); + assert.equal(zeroQty.hasWarning, true); }); test('replenish: products without sales stay "no sale" even with a pending work order', () => { From 30f5df220619ba5e0bc1289fc6f46893f5f37e96 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 13:15:53 +0800 Subject: [PATCH 66/70] fix: auto refresh MKH live data --- frontend/src/pages/MkhBomData/index.jsx | 35 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 9e434cb..184de96 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -33,6 +33,9 @@ const number = (value, digits = 0) => (value === null || value === undefined || const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); const EMPTY_FILTERS = { keyword: '', scope: 'warning' }; +// 页面长时打开时定期重读。后端共享 10 分钟索引缓存,因此多个页面的轮询 +// 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。 +const AUTO_REFRESH_MS = 2 * 60 * 1000; // 预警等级 → 标签配色 const WARNING_TONE = { @@ -277,6 +280,8 @@ const MkhBomData = () => { const tableWrapRef = useRef(null); const listRequestGateRef = useRef(createRequestGate()); const detailGenerationRef = useRef(0); + const lastListRefreshAtRef = useRef(0); + const autoRefreshPendingRef = useRef(false); const [bodyHeight, setBodyHeight] = useState(420); useEffect(() => { @@ -313,10 +318,10 @@ const MkhBomData = () => { return params; }, [page, pageSize, sorter, filters]); - const load = useCallback(async () => { + const load = useCallback(async ({ background = false } = {}) => { const requestGeneration = listRequestGateRef.current.begin(); - setLoading(true); - setError(''); + if (!background) setLoading(true); + if (!background) setError(''); try { const result = await getMkhBomList({ ...query, live: 1, _refresh: Date.now() }); if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; @@ -325,16 +330,38 @@ const MkhBomData = () => { return; } setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 }); + lastListRefreshAtRef.current = Date.now(); + setError(''); } catch (requestError) { if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败'); } finally { - if (listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false); + if (!background && listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false); } }, [query]); useEffect(() => { load(); }, [load]); + // 页面保持打开时自动更新;从后台切回且数据已超过轮询周期时立即重读。 + // 后台刷新保留当前表格,不显示全页加载态;请求门仍会阻止旧响应覆盖新数据。 + useEffect(() => { + const refreshIfStale = () => { + if (document.visibilityState !== 'visible' || syncing || autoRefreshPendingRef.current) return; + if (Date.now() - lastListRefreshAtRef.current < AUTO_REFRESH_MS) return; + autoRefreshPendingRef.current = true; + load({ background: true }).finally(() => { + autoRefreshPendingRef.current = false; + }); + }; + + const timer = window.setInterval(refreshIfStale, AUTO_REFRESH_MS); + document.addEventListener('visibilitychange', refreshIfStale); + return () => { + window.clearInterval(timer); + document.removeEventListener('visibilitychange', refreshIfStale); + }; + }, [load, syncing]); + const changeFilter = (key) => (value) => { setFilters((old) => ({ ...old, [key]: value || '' })); setPage(1); From ea312d90700280fcb671d35d0b87b18e670f7b14 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 13:30:09 +0800 Subject: [PATCH 67/70] fix: force fresh MKH operational data --- backend/src/services/mkhBomService.js | 87 +++++++++++++++++++++++-- backend/test/mkhBomService.test.js | 50 ++++++++++++++ frontend/src/pages/MkhBomData/index.jsx | 14 +++- 3 files changed, 142 insertions(+), 9 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index c5fdbb0..3ea8996 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -376,6 +376,14 @@ let productBomCache = null; */ let cacheEpoch = 0; +// 运营数据(库存 / 箱表 / 加工单 / 订单)使用独立代际。页面强制刷新时只淘汰 +// 这些变化频繁的缓存,不重查 BOM 与商品主数据。这样既能立即看到入库/完工, +// 又避免用户每刷新一次就把所有稳定表全量重扫。 +let operationalCacheEpoch = 0; +let operationalRefreshPending = null; +let operationalRefreshedAt = 0; +const OPERATIONAL_REFRESH_DEDUPE_MS = 15 * 1000; + // MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。 // 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。 const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓'; @@ -1405,15 +1413,16 @@ async function loadPendingWorkOrderIndex() { }); const epoch = cacheEpoch; + const operationalEpoch = operationalCacheEpoch; workOrderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - if (epoch === cacheEpoch) { + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) { workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; } return value; } catch (error) { - if (epoch === cacheEpoch) workOrderIndexCache = null; + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) workOrderIndexCache = null; throw error; } } @@ -1700,15 +1709,16 @@ async function loadOrderIndex() { }); const epoch = cacheEpoch; + const operationalEpoch = operationalCacheEpoch; orderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - if (epoch === cacheEpoch) { + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) { orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; } return value; } catch (error) { - if (epoch === cacheEpoch) orderIndexCache = null; + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) orderIndexCache = null; throw error; } } @@ -1818,15 +1828,16 @@ async function loadReplenishIndex() { }); const epoch = cacheEpoch; + const operationalEpoch = operationalCacheEpoch; replenishIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - if (epoch === cacheEpoch) { + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) { replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; } return value; } catch (error) { - if (epoch === cacheEpoch) replenishIndexCache = null; + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) replenishIndexCache = null; throw error; } } @@ -2178,7 +2189,12 @@ async function getMkhBomList(query = {}) { // 默认按可用天数升序 —— 最紧急的排最前 const sortField = String(query.sortField || 'stockDays'); const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh); + const forceFresh = parseFlag(query.fresh); + const preferLiveData = parseFlag(query.live) || forceFresh; + + // fresh=1 是「真的重读运营数据」,不只是改用实时口径。同时到达的多个用户 + // 共用一次刷新,15 秒内的重复请求直接复用刚刚查到的结果,保护业务云库。 + if (forceFresh) await refreshMkhOperationalData(); const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([ buildMergedReplenishRows({ preferLiveData }), @@ -2255,6 +2271,9 @@ async function getMkhBomList(query = {}) { saleWindowDays: SALE_WINDOW_DAYS, dataMode: preferLiveData ? 'live' : 'snapshot', refreshedAt: new Date().toISOString(), + operationalRefreshedAt: operationalRefreshedAt + ? new Date(operationalRefreshedAt).toISOString() + : null, // 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理 workOrderDegraded: replenishIndex.workOrderDegraded === true, workOrderWarehouse: WORK_ORDER_WAREHOUSE, @@ -2496,11 +2515,60 @@ async function listMkhBomExportRows(query = {}) { // 页面「同步」按钮调用——清掉后下一次查询直连副本库,拿到的是上游最新快照, // 而不是 2~15 分钟前的缓存结果。 // 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。 +function clearMkhOperationalCache() { + operationalCacheEpoch += 1; + operationalRefreshedAt = 0; + replenishIndexCache = null; + workOrderIndexCache = null; + orderIndexCache = null; + return { + operationalCacheEpoch, + clearedAt: new Date().toISOString(), + }; +} + +/** + * 强制重读会变的运营数据。 + * - single-flight:并发用户共用同一次云库查询; + * - 15 秒去重:页面初始加载、切回标签和用户连点刷新不会重复压库; + * - 只刷库存/箱表/加工单/订单,不清 BOM 与商品主数据。 + */ +async function refreshMkhOperationalData() { + const now = Date.now(); + if (operationalRefreshPending) { + const result = await operationalRefreshPending; + return { ...result, joined: true }; + } + if (operationalRefreshedAt && now - operationalRefreshedAt < OPERATIONAL_REFRESH_DEDUPE_MS) { + return { reused: true, refreshedAt: new Date(operationalRefreshedAt).toISOString() }; + } + + clearMkhOperationalCache(); + const refreshEpoch = operationalCacheEpoch; + const pending = (async () => { + await loadReplenishIndex(); + if (refreshEpoch === operationalCacheEpoch) operationalRefreshedAt = Date.now(); + return { + refreshed: true, + refreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null, + }; + })(); + operationalRefreshPending = pending; + try { + return await pending; + } finally { + if (operationalRefreshPending === pending) operationalRefreshPending = null; + } +} + function clearMkhBomCache() { // 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询, // 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值, // 导致按钮点完反而 2~10 分钟一直显示旧数据)。 cacheEpoch += 1; + operationalCacheEpoch += 1; + operationalRefreshPending = null; + operationalRefreshedAt = 0; businessBomCache = null; officialBomIndexCache = null; @@ -2522,6 +2590,7 @@ function clearMkhBomCache() { return { syncedAt: new Date().toISOString(), cacheEpoch, + operationalCacheEpoch, cleared: cleared.map(([name, entries]) => ({ name, entries })), }; } @@ -2529,6 +2598,7 @@ function clearMkhBomCache() { module.exports = { getMkhBomList, clearMkhBomCache, + refreshMkhOperationalData, getMkhBomDetail, listReplenishAlertRows, listMkhBomExportRows, @@ -2590,5 +2660,8 @@ module.exports = { EXPORT_MODES, buildProductExportRows, buildComponentExportRows, + clearMkhOperationalCache, + refreshMkhOperationalData, + OPERATIONAL_REFRESH_DEDUPE_MS, }, }; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 460ffd1..4f380e5 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -1285,3 +1285,53 @@ test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', asyn svc.clearMkhBomCache(); } }); + +test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + const originalGetPool = cloudDatabase.getPool; + + let queryCount = 0; + let releaseMonitor; + let markMonitorStarted; + const monitorGate = new Promise((resolve) => { releaseMonitor = resolve; }); + const monitorStarted = new Promise((resolve) => { markMonitorStarted = resolve; }); + + cloudDatabase.query = async (sql) => { + queryCount += 1; + if (sql.includes('pm_stockMonitor')) { + markMonitorStarted(); + await monitorGate; + } + return []; + }; + cloudDatabase.getPool = async () => ({ + request: () => ({ + input() { return this; }, + async query() { queryCount += 1; return { recordset: [] }; }, + }), + }); + + try { + svc.clearMkhBomCache(); + const first = svc.refreshMkhOperationalData(); + await monitorStarted; + const second = svc.refreshMkhOperationalData(); + releaseMonitor(); + const [firstResult, secondResult] = await Promise.all([first, second]); + assert.equal(firstResult.refreshed, true); + assert.equal(secondResult.joined, true); + assert.equal(secondResult.refreshedAt, firstResult.refreshedAt); + + const afterConcurrent = queryCount; + const reused = await svc.refreshMkhOperationalData(); + assert.equal(reused.reused, true); + assert.equal(queryCount, afterConcurrent, + '15 秒保护窗内不应重复访问业务云库'); + } finally { + cloudDatabase.query = originalQuery; + cloudDatabase.getPool = originalGetPool; + svc.clearMkhBomCache(); + } +}); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 184de96..e74f650 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -323,14 +323,24 @@ const MkhBomData = () => { if (!background) setLoading(true); if (!background) setError(''); try { - const result = await getMkhBomList({ ...query, live: 1, _refresh: Date.now() }); + const now = Date.now(); + const forceFresh = !lastListRefreshAtRef.current + || now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS; + // 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。 + // 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。 + const result = await getMkhBomList({ + ...query, + live: 1, + ...(forceFresh ? { fresh: 1 } : null), + _refresh: now, + }); if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; if (!result || result.success === false) { setError(result?.error || '加载 MKH-BOM 资料失败'); return; } setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 }); - lastListRefreshAtRef.current = Date.now(); + if (forceFresh) lastListRefreshAtRef.current = Date.now(); setError(''); } catch (requestError) { if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; From 22e7264438add37ba8494efbce0adc0f1ef24d5c Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 13:36:44 +0800 Subject: [PATCH 68/70] feat: show MKH refresh progress --- frontend/src/pages/MkhBomData/index.jsx | 69 ++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index e74f650..46ebc7a 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, Button, Card, Collapse, Dropdown, Input, Modal, Segmented, Select, Space, Spin, Switch, + Alert, Button, Card, Collapse, Dropdown, Input, Modal, Progress, Segmented, Select, Space, Spin, Switch, Table, Tooltip, Typography, message, } from 'antd'; import { @@ -37,6 +37,19 @@ const EMPTY_FILTERS = { keyword: '', scope: 'warning' }; // 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。 const AUTO_REFRESH_MS = 2 * 60 * 1000; +const getRefreshProgress = (elapsedSeconds) => { + if (elapsedSeconds < 3) { + return { percent: 12 + elapsedSeconds * 8, stage: '正在读取实时库存、箱表和加工单…' }; + } + if (elapsedSeconds < 15) { + return { percent: 36 + (elapsedSeconds - 3) * 3, stage: '正在核对待发货订单和近15天销量…' }; + } + return { + percent: Math.min(95, 72 + Math.floor((elapsedSeconds - 15) / 2)), + stage: '正在合并最新数据与 BOM 配件结果…', + }; +}; + // 预警等级 → 标签配色 const WARNING_TONE = { 缺货: 'danger', @@ -266,6 +279,7 @@ const MkhBomData = () => { // 导出(当前筛选条件下的全部数据) const [exporting, setExporting] = useState(false); const [syncing, setSyncing] = useState(false); + const [refreshProgress, setRefreshProgress] = useState({ open: false, elapsedSeconds: 0, title: '' }); // 补货预警推送设置 const [alertOpen, setAlertOpen] = useState(false); @@ -280,10 +294,35 @@ const MkhBomData = () => { const tableWrapRef = useRef(null); const listRequestGateRef = useRef(createRequestGate()); const detailGenerationRef = useRef(0); + const refreshProgressGenerationRef = useRef(0); const lastListRefreshAtRef = useRef(0); const autoRefreshPendingRef = useRef(false); const [bodyHeight, setBodyHeight] = useState(420); + const beginRefreshProgress = useCallback((title) => { + refreshProgressGenerationRef.current += 1; + const generation = refreshProgressGenerationRef.current; + setRefreshProgress({ open: true, elapsedSeconds: 0, title, generation }); + return generation; + }, []); + + const endRefreshProgress = useCallback((generation) => { + if (generation !== refreshProgressGenerationRef.current) return; + setRefreshProgress((current) => ({ ...current, open: false })); + }, []); + + useEffect(() => { + if (!refreshProgress.open) return undefined; + const startedAt = Date.now(); + const timer = window.setInterval(() => { + setRefreshProgress((current) => ({ + ...current, + elapsedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000)), + })); + }, 1000); + return () => window.clearInterval(timer); + }, [refreshProgress.open]); + useEffect(() => { const el = tableWrapRef.current; if (!el) return undefined; @@ -320,12 +359,16 @@ const MkhBomData = () => { const load = useCallback(async ({ background = false } = {}) => { const requestGeneration = listRequestGateRef.current.begin(); + let showProgress = false; + let progressGeneration = null; if (!background) setLoading(true); if (!background) setError(''); try { const now = Date.now(); const forceFresh = !lastListRefreshAtRef.current || now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS; + showProgress = forceFresh && !background; + if (showProgress) progressGeneration = beginRefreshProgress('正在更新最新业务数据'); // 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。 // 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。 const result = await getMkhBomList({ @@ -347,8 +390,9 @@ const MkhBomData = () => { setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败'); } finally { if (!background && listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false); + if (showProgress) endRefreshProgress(progressGeneration); } - }, [query]); + }, [beginRefreshProgress, endRefreshProgress, query]); useEffect(() => { load(); }, [load]); @@ -850,6 +894,7 @@ const MkhBomData = () => { const handleSync = async () => { setSyncing(true); setLoading(true); + const progressGeneration = beginRefreshProgress('正在强制同步全部最新数据'); // 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。 listRequestGateRef.current.invalidate(); detailGenerationRef.current += 1; @@ -874,11 +919,31 @@ const MkhBomData = () => { } finally { if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false); setSyncing(false); + endRefreshProgress(progressGeneration); } }; + const progressView = getRefreshProgress(refreshProgress.elapsedSeconds); + return ( + + + + {progressView.stage} + 已用时 {refreshProgress.elapsedSeconds} 秒,完成后页面会自动显示新数据。 + 进度为查询阶段估算,实际完成以服务端返回为准。 + +
MKH-BOM 资料 From ba6ef97a408974ae0ab98db4a8c0d40b001392c2 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 14:02:24 +0800 Subject: [PATCH 69/70] fix: reject stale MKH backend responses --- README.md | 2 ++ backend/src/services/mkhBomService.js | 4 ++++ frontend/src/pages/MkhBomData/index.jsx | 12 ++++++++++-- frontend/src/utils/mkhFreshContract.js | 15 +++++++++++++++ frontend/test/mkhFreshContract.test.mjs | 23 +++++++++++++++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 frontend/src/utils/mkhFreshContract.js create mode 100644 frontend/test/mkhFreshContract.test.mjs diff --git a/README.md b/README.md index c51db3c..c36d8af 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ npm run remote:dev 首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。 +拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。 + ## 验证与构建 ```bash diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 3ea8996..f585725 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -2271,6 +2271,10 @@ async function getMkhBomList(query = {}) { saleWindowDays: SALE_WINDOW_DAYS, dataMode: preferLiveData ? 'live' : 'snapshot', refreshedAt: new Date().toISOString(), + // 前后端实时刷新协议。本地前端如果代理到未重启的旧后端, + // 将拒绝展示其返回的旧库存,而不是把缺失协议字段当成刷新成功。 + freshDataContractVersion: 1, + freshDataApplied: forceFresh && operationalRefreshedAt > 0, operationalRefreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null, diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 46ebc7a..56e7043 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -18,6 +18,7 @@ import { syncMkhBom, } from '../../api/mkhBom'; import { createRequestGate } from '../../utils/requestGate'; +import { requireMkhFreshContract } from '../../utils/mkhFreshContract'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -382,7 +383,14 @@ const MkhBomData = () => { setError(result?.error || '加载 MKH-BOM 资料失败'); return; } - setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 }); + const snapshot = result?.data || null; + try { + requireMkhFreshContract(snapshot, { requireApplied: forceFresh }); + } catch (contractError) { + setData({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false }); + throw contractError; + } + setData(snapshot); if (forceFresh) lastListRefreshAtRef.current = Date.now(); setError(''); } catch (requestError) { @@ -908,7 +916,7 @@ const MkhBomData = () => { if (!result || result.success === false || !result?.data?.snapshot) { throw new Error(result?.error || '同步接口未返回数据库快照'); } - setData(result.data.snapshot); + setData(requireMkhFreshContract(result.data.snapshot)); setError(''); message.success('已实时读取最新订单、库存、BOM 与仓位数据'); } catch (syncError) { diff --git a/frontend/src/utils/mkhFreshContract.js b/frontend/src/utils/mkhFreshContract.js new file mode 100644 index 0000000..2ba828d --- /dev/null +++ b/frontend/src/utils/mkhFreshContract.js @@ -0,0 +1,15 @@ +export const MKH_FRESH_DATA_CONTRACT_VERSION = 1; + +const OUTDATED_BACKEND_MESSAGE = '本地后端版本过旧或更新后未重启,已停止展示可能过期的库存。' + + '请停止当前 npm run remote:dev,拉取最新 main 后重新启动。'; + +export function requireMkhFreshContract(snapshot, { requireApplied = false } = {}) { + const contractVersion = Number(snapshot?.freshDataContractVersion) || 0; + if (contractVersion < MKH_FRESH_DATA_CONTRACT_VERSION) { + throw new Error(OUTDATED_BACKEND_MESSAGE); + } + if (requireApplied && snapshot?.freshDataApplied !== true) { + throw new Error('后端未确认已重读最新运营数据,已停止展示可能过期的库存,请重试。'); + } + return snapshot; +} diff --git a/frontend/test/mkhFreshContract.test.mjs b/frontend/test/mkhFreshContract.test.mjs new file mode 100644 index 0000000..261f79d --- /dev/null +++ b/frontend/test/mkhFreshContract.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { requireMkhFreshContract } from '../src/utils/mkhFreshContract.js'; + +test('MKH fresh contract accepts a backend-confirmed live refresh', () => { + const snapshot = { freshDataContractVersion: 1, freshDataApplied: true }; + assert.equal(requireMkhFreshContract(snapshot, { requireApplied: true }), snapshot); +}); + +test('MKH fresh contract rejects an outdated local backend instead of showing stale stock', () => { + assert.throws( + () => requireMkhFreshContract({ rows: [{ stock: 4 }] }, { requireApplied: true }), + /本地后端版本过旧/ + ); +}); + +test('MKH fresh contract requires explicit refresh completion for fresh requests', () => { + assert.throws( + () => requireMkhFreshContract({ freshDataContractVersion: 1 }, { requireApplied: true }), + /未确认已重读/ + ); +}); From 345214daed50a017525c9284433dc4c0e302bf16 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 14:49:08 +0800 Subject: [PATCH 70/70] fix: force live refresh on MKH sync --- backend/src/api/mkhBom.js | 9 ++++++++- backend/test/mkhBomApi.test.js | 13 +++++++++++++ frontend/src/pages/MkhBomData/index.jsx | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 backend/test/mkhBomApi.test.js diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index 252019d..a4aa503 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -29,6 +29,10 @@ function sendError(res, error, fallback) { res.status(status).json({ success: false, error: error.message || fallback }); } +function buildSyncQuery(query = {}) { + return { ...query, live: '1', fresh: '1' }; +} + // MKH-BOM 资料列表(模块化组合商品 + 配件汇总) router.get('/list', async (req, res) => { try { @@ -109,7 +113,9 @@ router.put('/notification-center', async (req, res) => { router.post('/sync', async (req, res) => { try { const cache = clearMkhBomCache(); - const snapshot = await getMkhBomList({ ...(req.body?.query || {}), live: '1' }); + // fresh=1 是真正重读库存 / 加工单 / 待发货数据的协议。 + // 只清缓存再传 live=1 会落到旧快照,导致按钮显示“同步成功”却数值不变。 + const snapshot = await getMkhBomList(buildSyncQuery(req.body?.query)); res.set('Cache-Control', 'no-store'); res.json({ success: true, data: { cache, snapshot } }); } catch (error) { @@ -118,3 +124,4 @@ router.post('/sync', async (req, res) => { }); module.exports = router; +module.exports._private = { buildSyncQuery }; diff --git a/backend/test/mkhBomApi.test.js b/backend/test/mkhBomApi.test.js new file mode 100644 index 0000000..8d333f7 --- /dev/null +++ b/backend/test/mkhBomApi.test.js @@ -0,0 +1,13 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { _private } = require('../src/api/mkhBom'); + +test('manual MKH sync always forces a live operational refresh', () => { + assert.deepEqual( + _private.buildSyncQuery({ page: 2, fresh: 0, live: 0 }), + { page: 2, fresh: '1', live: '1' } + ); +}); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 56e7043..993ac5f 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -916,7 +916,8 @@ const MkhBomData = () => { if (!result || result.success === false || !result?.data?.snapshot) { throw new Error(result?.error || '同步接口未返回数据库快照'); } - setData(requireMkhFreshContract(result.data.snapshot)); + setData(requireMkhFreshContract(result.data.snapshot, { requireApplied: true })); + lastListRefreshAtRef.current = Date.now(); setError(''); message.success('已实时读取最新订单、库存、BOM 与仓位数据'); } catch (syncError) {