From 2bc949099ba7fc6dba0db1dfa0b2bc47f6721f48 Mon Sep 17 00:00:00 2001 From: lunanting Date: Wed, 23 Sep 2026 13:29:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=A7=E8=B4=A7?= =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=8826=20=E4=BB=93?= =?UTF-8?q?=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),