From 2bc949099ba7fc6dba0db1dfa0b2bc47f6721f48 Mon Sep 17 00:00:00 2001 From: lunanting Date: Wed, 23 Sep 2026 13:29:17 +0800 Subject: [PATCH 01/31] =?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 = () => ( +
+
计算口径
+ +
+); + +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 02/31] =?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 03/31] =?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 04/31] =?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 05/31] =?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 06/31] =?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 07/31] =?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 08/31] =?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 09/31] =?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 10/31] =?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 11/31] =?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 12/31] =?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 13/31] =?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 14/31] =?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 15/31] =?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 16/31] =?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 17/31] =?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 18/31] =?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 19/31] =?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 20/31] =?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 21/31] =?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 22/31] =?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 23/31] =?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 24/31] =?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 25/31] =?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 26/31] 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 27/31] 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 28/31] 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 29/31] 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 30/31] 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 31/31] 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) {