From f20bd3a487f9d87450962ea4c3f1a554a4805ebb Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Sun, 27 Sep 2026 16:29:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BE=A7=E8=BE=B9=E6=A0=8F=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E4=BA=8C=E7=BA=A7=E3=80=8C=E5=BA=93=E5=AD=98=E5=8A=A8?= =?UTF-8?q?=E9=94=80=E5=88=86=E6=9E=90=E3=80=8D=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按《仓库库存动销分析.xlsx》: - 一级菜单「大货库存监控」下新增二级「库存动销分析」(路由 /planning/operation/stock-sales-analysis) - 表头与表格结构按需求表:仓库 | 动销等级 | SKU数量 | SKU占比 | 库存数量 | 库存占比 | 销量, 每仓 6 个动销等级(爆款/热卖款/平销款/滞销款-1/滞销款-2/销售=0)+ 1 行汇总,仓库列跨行合并 - 分档口径:日销 = 统计窗口内已发货件数 ÷ 窗口天数(7/14/30 天可选,默认 30), 库存取 rpt.jst_inventoryInfo 实时库存,销量取 jst_Orders×jst_OrderItem 按 send_date (排除 Delete/Cancelled,与每日进销存发货口径一致),只统计有库存或有销量的 SKU - 后端 /api/stock-sales-analysis:单条 SQL 一次覆盖全部仓库×等级,5 分钟缓存 + refresh=1 强制重算; 返回逐仓 + 全仓合计、占比、无数据仓数量 - 前端页面:统计天数/仓库筛选、口径说明弹窗、合计行高亮、无数据仓计数提示 - 页面权限:前后端 sidebarPages 注册 pageKey=stock_sales_analysis,默认 planning 角色可见 验证:后端 syntax + 单测 5/5;前端 eslint、build、npm test 21/21; 本地接口 30 天窗口实测 27 仓(崔桥汇总 2080 SKU / 90822 件 / 54076 件销量), 占比逐仓闭合为 100%;浏览器实测侧边栏入口、表头、197 行渲染与截图均正常 --- backend/src/api/stockSalesAnalysis.js | 205 +++++++++++++++ backend/src/config/stockSalesAnalysis.js | 47 ++++ backend/src/constants/sidebarPages.js | 5 + backend/src/index.js | 2 + backend/src/services/stockSalesAnalysis.js | 142 +++++++++++ backend/test/stockSalesAnalysis.test.js | 73 ++++++ frontend/src/api/stockSalesAnalysis.js | 10 + frontend/src/config/sidebarNavigation.js | 1 + frontend/src/constants/sidebarPages.js | 1 + frontend/src/constants/stockSalesAnalysis.js | 57 +++++ frontend/src/layouts/BasicLayout.jsx | 1 + .../src/pages/StockSalesAnalysis/index.jsx | 241 ++++++++++++++++++ .../src/pages/StockSalesAnalysis/style.css | 90 +++++++ frontend/src/router/index.jsx | 5 + 14 files changed, 880 insertions(+) create mode 100644 backend/src/api/stockSalesAnalysis.js create mode 100644 backend/src/config/stockSalesAnalysis.js create mode 100644 backend/src/services/stockSalesAnalysis.js create mode 100644 backend/test/stockSalesAnalysis.test.js create mode 100644 frontend/src/api/stockSalesAnalysis.js create mode 100644 frontend/src/constants/stockSalesAnalysis.js create mode 100644 frontend/src/pages/StockSalesAnalysis/index.jsx create mode 100644 frontend/src/pages/StockSalesAnalysis/style.css diff --git a/backend/src/api/stockSalesAnalysis.js b/backend/src/api/stockSalesAnalysis.js new file mode 100644 index 0000000..8eb4ed8 --- /dev/null +++ b/backend/src/api/stockSalesAnalysis.js @@ -0,0 +1,205 @@ +const express = require('express'); +const cloudDb = require('../config/cloudDatabase'); +const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline'); +const { + SALES_GRADE_RULES, + SALES_TOTAL_LABEL, + STOCK_SALES_WINDOW_OPTIONS, + STOCK_SALES_DEFAULT_DAYS, +} = require('../config/stockSalesAnalysis'); +const { fetchGradeRows } = require('../services/stockSalesAnalysis'); + +const router = express.Router(); + +/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */ +const AS_OF_TIME = '01:00'; + +/** 结果缓存:一次要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */ +const RESULT_CACHE_TTL_MS = 5 * 60 * 1000; +const cacheByDays = new Map(); + +const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100; + +/** 快照日 + 次日 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}`; +} + +/** 发货窗口:[asOf - N 天, asOf) */ +function buildShipWindow(asOf, days) { + const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`); + const from = new Date(to.getTime() - days * 86400000); + const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' '); + return { shipFrom: fmt(from), shipTo: fmt(to) }; +} + +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; +} + +async function fetchInventoryTakenAt() { + const rows = await cloudDb.query( + `SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo` + ); + return rows[0]?.takenAt || null; +} + +/** 解析并收敛统计窗口天数,只允许白名单内的取值 */ +function resolveDays(raw) { + const n = Number.parseInt(String(raw ?? ''), 10); + return STOCK_SALES_WINDOW_OPTIONS.includes(n) ? n : STOCK_SALES_DEFAULT_DAYS; +} + +/** + * GET /api/stock-sales-analysis + * 仓库库存动销分析:仓库 × 动销等级 → SKU数量 / SKU占比 / 库存数量 / 库存占比 / 销量 + */ +router.get('/', async (req, res) => { + try { + const force = String(req.query?.refresh || '') === '1'; + const days = resolveDays(req.query?.days); + const latestBizDate = await fetchLatestBizDate().catch(() => null); + + const cached = cacheByDays.get(days); + if (!force && cached && cached.latestBizDate === latestBizDate + && Date.now() - cached.at < RESULT_CACHE_TTL_MS) { + return res.json({ success: true, data: cached.payload, cached: true }); + } + + const asOf = nextDayAsOf(latestBizDate); + if (!asOf) { + return res.status(503).json({ + success: false, + error: '业务库暂无库存监控数据,无法定位统计基准日', + }); + } + + const { shipFrom, shipTo } = buildShipWindow(asOf, days); + const warehouses = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null); + const wmsCoIds = warehouses.map((b) => b.wmsCoId); + + const [rows, takenAt] = await Promise.all([ + fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }), + fetchInventoryTakenAt().catch(() => null), + ]); + + // (仓库, 等级) → 指标 + const matrix = new Map(); + rows.forEach((r) => { + matrix.set(`${r.wmsCoId}::${r.gradeKey}`, r); + }); + + const buildBucket = (grade, cell) => ({ + key: grade.key, + label: grade.label, + tip: grade.tip, + skuCount: cell?.skuCount ?? 0, + stockQty: round2(cell?.stockQty ?? 0), + salesQty: round2(cell?.salesQty ?? 0), + dailySales: Number((cell?.dailySales ?? 0).toFixed(2)), + }); + + const items = warehouses.map((base) => { + const id = String(base.wmsCoId); + const buckets = SALES_GRADE_RULES.map((grade) => ( + buildBucket(grade, matrix.get(`${id}::${grade.key}`)) + )); + + const totalSku = buckets.reduce((acc, b) => acc + b.skuCount, 0); + const totalStock = buckets.reduce((acc, b) => acc + b.stockQty, 0); + const totalSales = round2(buckets.reduce((acc, b) => acc + b.salesQty, 0)); + + return { + warehouse: base.warehouse, + dbWarehouse: base.dbWarehouse, + wmsCoId: base.wmsCoId, + hasData: totalSku > 0 || totalStock !== 0 || totalSales !== 0, + buckets: buckets.map((b) => ({ + ...b, + skuRatio: totalSku > 0 ? b.skuCount / totalSku : null, + stockRatio: totalStock !== 0 ? b.stockQty / totalStock : null, + })), + total: { + key: '__total__', + label: SALES_TOTAL_LABEL, + skuCount: totalSku, + skuRatio: totalSku > 0 ? 1 : null, + stockQty: totalStock, + stockRatio: totalStock !== 0 ? 1 : null, + salesQty: totalSales, + dailySales: Number((totalSales / days).toFixed(2)), + }, + }; + }); + + // 全仓合计:各等级跨仓累加 + 汇总 + const grandBuckets = SALES_GRADE_RULES.map((grade) => { + const skuCount = items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.skuCount || 0), 0); + const stockQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.stockQty || 0), 0)); + const salesQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.salesQty || 0), 0)); + return { + key: grade.key, + label: grade.label, + tip: grade.tip, + skuCount, + stockQty, + salesQty, + dailySales: Number((salesQty / days).toFixed(2)), + }; + }); + const grandSku = grandBuckets.reduce((acc, b) => acc + b.skuCount, 0); + const grandStock = round2(grandBuckets.reduce((acc, b) => acc + b.stockQty, 0)); + const grandSales = round2(grandBuckets.reduce((acc, b) => acc + b.salesQty, 0)); + const grandTotal = { + key: '__total__', + label: SALES_TOTAL_LABEL, + skuCount: grandSku, + skuRatio: grandSku > 0 ? 1 : null, + stockQty: grandStock, + stockRatio: grandStock !== 0 ? 1 : null, + salesQty: grandSales, + dailySales: Number((grandSales / days).toFixed(2)), + }; + + const payload = { + days, + windowOptions: STOCK_SALES_WINDOW_OPTIONS, + latestBizDate, + asOf, + shipWindow: { shipFrom, shipTo, days }, + takenAt, + grades: SALES_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })), + totalLabel: SALES_TOTAL_LABEL, + items, + grand: { + warehouse: '全部仓库合计', + hasData: grandSku > 0, + buckets: grandBuckets.map((b) => ({ + ...b, + skuRatio: grandSku > 0 ? b.skuCount / grandSku : null, + stockRatio: grandStock !== 0 ? b.stockQty / grandStock : null, + })), + total: grandTotal, + }, + emptyWarehouseCount: items.filter((it) => !it.hasData).length, + }; + + cacheByDays.set(days, { latestBizDate, at: Date.now(), payload }); + return res.json({ success: true, data: payload }); + } catch (error) { + console.error('[stock-sales-analysis] 查询失败:', error.message); + return res.status(500).json({ + success: false, + error: '仓库库存动销分析数据查询失败', + detail: error.message, + }); + } +}); + +module.exports = router; diff --git a/backend/src/config/stockSalesAnalysis.js b/backend/src/config/stockSalesAnalysis.js new file mode 100644 index 0000000..9e584f6 --- /dev/null +++ b/backend/src/config/stockSalesAnalysis.js @@ -0,0 +1,47 @@ +/** + * 仓库库存动销分析 —— 口径配置(纯规则,不含业务数值) + * + * 一级菜单:大货库存监控 + * 二级菜单:库存动销分析 + * + * 表头(与需求表一致):仓库 | 动销等级 | SKU数量 | SKU占比 | 库存数量 | 库存占比 | 销量 + * + * 口径: + * 日销(日均销量)= 统计窗口内已发货件数 / 窗口天数 + * 动销等级按「单个 SKU 的日销」分档,落在哪一档就计入哪一档 + * 仓库内汇总:SKU占比 = 该等级 SKU 数 / 该仓库 SKU 总数 + * 库存占比 = 该等级库存件数 / 该仓库库存总件数 + * 仓库清单与「每日进销存」保持同一份(DAILY_INVENTORY_BASELINE) + */ + +const STOCK_SALES_LEVEL1 = '大货库存监控'; +const STOCK_SALES_LEVEL2 = '库存动销分析'; + +/** 可选的统计窗口天数;默认 30 天 */ +const STOCK_SALES_WINDOW_OPTIONS = [7, 14, 30]; +const STOCK_SALES_DEFAULT_DAYS = 30; + +/** + * 动销等级(顺序即表格行顺序,汇总行由 total 单独给出)。 + * lower 为日销下界(含),upper 为日销上界(不含);null 表示不限制。 + */ +const SALES_GRADE_RULES = [ + { key: 'hot', label: '爆款:日销≥20', lower: 20, upper: null, tip: '单个 SKU 日均销量 ≥ 20 件' }, + { key: 'best', label: '热卖款:10≤日销<20', lower: 10, upper: 20, tip: '单个 SKU 日均销量 10 ~ 20 件(含 10,不含 20)' }, + { key: 'normal', label: '平销款:3≤日销<10', lower: 3, upper: 10, tip: '单个 SKU 日均销量 3 ~ 10 件(含 3,不含 10)' }, + { key: 'slow1', label: '滞销款-1:1≤日销<3', lower: 1, upper: 3, tip: '单个 SKU 日均销量 1 ~ 3 件(含 1,不含 3)' }, + { key: 'slow2', label: '滞销款-2:0<日销<1', lower: 0, upper: 1, tip: '单个 SKU 日均销量大于 0 且小于 1 件' }, + { key: 'zero', label: '销售=0', lower: 0, upper: 0, tip: '统计窗口内无销量的 SKU(仍占用库存)' }, +]; + +/** 汇总行的等级名(表内展示用) */ +const SALES_TOTAL_LABEL = '汇总'; + +module.exports = { + STOCK_SALES_LEVEL1, + STOCK_SALES_LEVEL2, + STOCK_SALES_WINDOW_OPTIONS, + STOCK_SALES_DEFAULT_DAYS, + SALES_GRADE_RULES, + SALES_TOTAL_LABEL, +}; diff --git a/backend/src/constants/sidebarPages.js b/backend/src/constants/sidebarPages.js index b9294ef..35b47c5 100644 --- a/backend/src/constants/sidebarPages.js +++ b/backend/src/constants/sidebarPages.js @@ -40,6 +40,11 @@ const SIDEBAR_PAGES = [ path: '/planning/operation/mkh-bom-data', }), buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }), + buildPlanningPage({ + pageKey: 'stock_sales_analysis', + pageName: '大货库存监控 · 库存动销分析', + path: '/planning/operation/stock-sales-analysis', + }), { pageKey: 'settings', pageName: '系统设置', diff --git a/backend/src/index.js b/backend/src/index.js index 6c1e98c..e6baf43 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -37,6 +37,7 @@ const modularStockMonitorRoutes = require('./api/modularStockMonitor'); const mkhBomRoutes = require('./api/mkhBom'); const dailyInventoryRoutes = require('./api/dailyInventory'); const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification'); +const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis'); const returnInventoryWarningRoutes = require('./api/returnInventoryWarning'); const returnMonitorRoutes = require('./api/returnMonitor'); const returnStockWarningRoutes = require('./api/returnStockWarning'); @@ -672,6 +673,7 @@ async function startServer(options = {}) { cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes); + cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes); cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes); // 崔桥工厂仓客退:退货入库核销 + 每日库存监控 cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes); diff --git a/backend/src/services/stockSalesAnalysis.js b/backend/src/services/stockSalesAnalysis.js new file mode 100644 index 0000000..0f188ac --- /dev/null +++ b/backend/src/services/stockSalesAnalysis.js @@ -0,0 +1,142 @@ +'use strict'; + +/** + * 仓库库存动销分析 —— 业务库取数 + * + * 一次查询覆盖全部仓库 × 全部动销等级: + * 库存件数:rpt.jst_inventoryInfo 实时库存(按 wms_co_id + sku_id 汇总) + * 销量: rpt.jst_OrderItem × rpt.jst_Orders,按 send_date,排除 Delete/Cancelled + * (与「每日进销存」的发货口径一致) + * 日销: 销量 / 统计窗口天数 → 分档得到动销等级 + * + * 只统计「有库存或有销量」的 SKU:库存为 0 且窗口内无销量的 SKU 已下架, + * 计入「销售=0」会虚增分母,故整体剔除。 + */ + +const { SALES_GRADE_RULES } = require('../config/stockSalesAnalysis'); + +/** mssql 不支持数组参数,按位展开成 @p0,@p1... */ +function buildInClause(values, prefix) { + const params = {}; + const clause = values + .map((v, i) => { + const key = `${prefix}${i}`; + params[key] = v; + return `@${key}`; + }) + .join(','); + return { clause, params }; +} + +/** + * 日销分档表达式:从高档往低档依次命中。 + * 最后一档「销售=0」由 `salesQty <= 0` 兜底(销量为 NULL 视同 0)。 + */ +function buildGradeCase(salesExpr = 'ISNULL(b.salesQty, 0)', daysParam = '@days') { + const branches = SALES_GRADE_RULES + .filter((rule) => rule.lower > 0) + .map((rule) => { + const daily = `(${salesExpr} / ${daysParam})`; + const lower = `${daily} >= ${rule.lower}`; + const upper = rule.upper == null ? null : `${daily} < ${rule.upper}`; + const cond = upper ? `${lower} AND ${upper}` : lower; + return `WHEN ${cond} THEN '${rule.key}'`; + }); + + return ` + CASE + ${branches.join('\n ')} + WHEN ${salesExpr} > 0 THEN 'slow2' + ELSE 'zero' + END`; +} + +/** + * 每个仓库 × 每个动销等级的 SKU 数、库存件数、销量。 + * @returns {Promise>} + */ +async function fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) { + if (!cloudDb || typeof cloudDb.query !== 'function') { + throw new TypeError('fetchGradeRows 的第一个参数必须是业务库实例'); + } + if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return []; + if (!shipFrom || !shipTo || !days) return []; + + const invIn = buildInClause(wmsCoIds, 'w'); + const shipIn = buildInClause(wmsCoIds, 'v'); + const gradeCase = buildGradeCase(); + + const sqlText = ` + WITH inv AS ( + SELECT i.wms_co_id AS wmsCoId, + i.sku_id AS skuId, + SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty + FROM rpt.jst_inventoryInfo i + WHERE i.wms_co_id IN (${invIn.clause}) + GROUP BY i.wms_co_id, i.sku_id + ), + ship AS ( + SELECT o.wms_co_id AS wmsCoId, + oi.sku_id AS skuId, + SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty + FROM rpt.jst_OrderItem oi + JOIN rpt.jst_Orders o ON o.o_id = oi.o_id + WHERE o.wms_co_id IN (${shipIn.clause}) + AND o.send_date >= @shipFrom + AND o.send_date < @shipTo + AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled') + GROUP BY o.wms_co_id, oi.sku_id + ), + base AS ( + SELECT u.wmsCoId, + u.skuId, + SUM(u.stockQty) AS stockQty, + SUM(u.salesQty) AS salesQty + FROM ( + SELECT wmsCoId, skuId, stockQty, CAST(0 AS DECIMAL(18, 2)) AS salesQty FROM inv + UNION ALL + SELECT wmsCoId, skuId, CAST(0 AS DECIMAL(18, 2)) AS stockQty, salesQty FROM ship + ) u + GROUP BY u.wmsCoId, u.skuId + ), + graded AS ( + SELECT b.wmsCoId, + b.stockQty, + b.salesQty, + ${gradeCase} AS gradeKey + FROM base b + WHERE ABS(ISNULL(b.stockQty, 0)) > 0.0001 + OR ABS(ISNULL(b.salesQty, 0)) > 0.0001 + ) + SELECT g.wmsCoId, + g.gradeKey, + COUNT(*) AS skuCount, + CAST(SUM(ISNULL(g.stockQty, 0)) AS DECIMAL(18, 2)) AS stockQty, + CAST(SUM(ISNULL(g.salesQty, 0)) AS DECIMAL(18, 2)) AS salesQty, + CAST(SUM(ISNULL(g.salesQty, 0) / @days) AS DECIMAL(18, 4)) AS dailySales + FROM graded g + GROUP BY g.wmsCoId, g.gradeKey`; + + const rows = await cloudDb.query(sqlText, { + ...invIn.params, + ...shipIn.params, + shipFrom, + shipTo, + days, + }, { requestTimeout: 180000 }); + + return rows.map((r) => ({ + wmsCoId: String(r.wmsCoId), + gradeKey: String(r.gradeKey), + skuCount: Number(r.skuCount) || 0, + stockQty: Number(r.stockQty) || 0, + salesQty: Number(r.salesQty) || 0, + dailySales: Number(r.dailySales) || 0, + })); +} + +module.exports = { + buildInClause, + buildGradeCase, + fetchGradeRows, +}; diff --git a/backend/test/stockSalesAnalysis.test.js b/backend/test/stockSalesAnalysis.test.js new file mode 100644 index 0000000..ab56347 --- /dev/null +++ b/backend/test/stockSalesAnalysis.test.js @@ -0,0 +1,73 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildGradeCase, + buildInClause, + fetchGradeRows, +} = require('../src/services/stockSalesAnalysis'); +const { SALES_GRADE_RULES } = require('../src/config/stockSalesAnalysis'); + +test('buildInClause 按位展开仓库参数', () => { + const { clause, params } = buildInClause([101, 102], 'w'); + assert.equal(clause, '@w0,@w1'); + assert.deepEqual(params, { w0: 101, w1: 102 }); +}); + +test('buildGradeCase 覆盖全部正向等级,并以销售=0 兜底', () => { + const expr = buildGradeCase(); + SALES_GRADE_RULES.filter((rule) => rule.lower > 0).forEach((rule) => { + assert.ok(expr.includes(`THEN '${rule.key}'`), `${rule.key} 未出现在分档表达式中`); + }); + assert.ok(expr.includes("THEN 'slow2'")); + assert.ok(expr.includes("ELSE 'zero'")); +}); + +test('fetchGradeRows 首参不是数据库实例时抛错,避免静默返回空数组', async () => { + await assert.rejects( + () => fetchGradeRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }), + TypeError + ); +}); + +test('fetchGradeRows 无仓库或缺失窗口时直接返回空结果', async () => { + let called = 0; + const db = { query: async () => { called += 1; return []; } }; + assert.deepEqual(await fetchGradeRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []); + assert.deepEqual(await fetchGradeRows(db, [101], { shipFrom: null, shipTo: 'b', days: 30 }), []); + assert.equal(called, 0); +}); + +test('fetchGradeRows 把仓库参数与窗口参数一并传给业务库', async () => { + let captured = null; + const db = { + query: async (sqlText, params) => { + captured = { sqlText, params }; + return [ + { wmsCoId: 101, gradeKey: 'hot', skuCount: 2, stockQty: 438, salesQty: 1136, dailySales: 37.87 }, + ]; + }, + }; + + const rows = await fetchGradeRows(db, [101, 102], { + shipFrom: '2026-08-28 01:00:00', + shipTo: '2026-09-27 01:00:00', + days: 30, + }); + + assert.equal(rows.length, 1); + assert.equal(rows[0].wmsCoId, '101'); + assert.equal(rows[0].stockQty, 438); + + assert.equal(captured.params.w0, 101); + assert.equal(captured.params.w1, 102); + assert.equal(captured.params.v0, 101); + assert.equal(captured.params.days, 30); + assert.equal(captured.params.shipFrom, '2026-08-28 01:00:00'); + assert.equal(captured.params.shipTo, '2026-09-27 01:00:00'); + assert.ok(captured.sqlText.includes('@days')); + assert.ok(captured.sqlText.includes('rpt.jst_inventoryInfo')); + assert.ok(captured.sqlText.includes('rpt.jst_OrderItem')); +}); diff --git a/frontend/src/api/stockSalesAnalysis.js b/frontend/src/api/stockSalesAnalysis.js new file mode 100644 index 0000000..af3fdec --- /dev/null +++ b/frontend/src/api/stockSalesAnalysis.js @@ -0,0 +1,10 @@ +import request from './request'; + +const LIST_TIMEOUT_MS = 5 * 60 * 1000; + +// 仓库库存动销分析:仓库 × 动销等级 → SKU数量 / SKU占比 / 库存数量 / 库存占比 / 销量 +// refresh=1 跳过后端 5 分钟缓存,强制重算 +export const getStockSalesAnalysis = (params = {}) => request.get( + '/api/stock-sales-analysis', + { params, timeout: LIST_TIMEOUT_MS } +); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 52e71ee..4df05bb 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -40,6 +40,7 @@ export const DEFAULT_SIDEBAR_CONFIG = { 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: 'stock-sales-analysis', label: '库存动销分析', icon: 'line-chart', routeKey: 'stock-sales-analysis', visible: true, order: 20 }, ], }, { diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js index 30a8803..5794422 100644 --- a/frontend/src/constants/sidebarPages.js +++ b/frontend/src/constants/sidebarPages.js @@ -5,6 +5,7 @@ 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 }, + { pageKey: 'stock_sales_analysis', path: '/planning/operation/stock-sales-analysis', name: '库存动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_cuiqiao', path: '/planning/operation/returns-inventory/cuiqiao', name: '崔桥工厂仓客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_subei', path: '/planning/operation/returns-inventory/subei', name: '苏北客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, diff --git a/frontend/src/constants/stockSalesAnalysis.js b/frontend/src/constants/stockSalesAnalysis.js new file mode 100644 index 0000000..1dd7962 --- /dev/null +++ b/frontend/src/constants/stockSalesAnalysis.js @@ -0,0 +1,57 @@ +/** 仓库库存动销分析 —— 表头与口径说明(与需求表一致) */ + +export const STOCK_SALES_COLUMNS = [ + { + key: 'warehouse', + label: '仓库', + tip: '聚水潭主仓(按 wms_co_id 单仓独立统计,不合并分仓)', + }, + { + key: 'grade', + label: '动销等级', + tip: '按单个 SKU 的日均销量分档:日销 = 统计窗口内已发货件数 / 窗口天数', + }, + { + key: 'skuCount', + label: 'SKU数量', + tip: '该动销等级下「有库存或有销量」的 SKU 个数', + }, + { + key: 'skuRatio', + label: 'SKU占比', + tip: '该等级 SKU 数量 ÷ 该仓库 SKU 总数', + }, + { + key: 'stockQty', + label: '库存数量', + tip: '该等级 SKU 的实时库存件数(jst_inventoryInfo,按 wms_co_id + sku_id 汇总)', + }, + { + key: 'stockRatio', + label: '库存占比', + tip: '该等级库存数量 ÷ 该仓库库存总件数', + }, + { + key: 'salesQty', + label: '销量', + tip: '统计窗口内该等级 SKU 的已发货件数(jst_Orders × jst_OrderItem,按 send_date,排除 Delete/Cancelled)', + }, +]; + +export const STOCK_SALES_WINDOW_OPTIONS = [ + { label: '近 7 天', value: 7 }, + { label: '近 14 天', value: 14 }, + { label: '近 30 天', value: 30 }, +]; + +export const STOCK_SALES_DEFAULT_DAYS = 30; + +export const STOCK_SALES_DESCRIPTION = [ + '动销等级口径:按「单个 SKU 的日均销量(日销)」分档,日销 = 统计窗口内已发货件数 ÷ 窗口天数。', + ' 爆款:日销 ≥ 20;热卖款:10 ≤ 日销 < 20;平销款:3 ≤ 日销 < 10;', + ' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内无销量的 SKU。', + 'SKU数量 / 库存数量 / 销量:仓库内按等级汇总;「汇总」行为该仓库六个等级的合计。', + 'SKU占比 = 该等级 SKU 数量 ÷ 该仓库 SKU 总数;库存占比 = 该等级库存数量 ÷ 该仓库库存总件数。', + '统计范围:只统计「有库存或有销量」的 SKU;库存为 0 且窗口内无销量的 SKU 已下架,不计入分母。', + '库存取业务库实时库存表;销量取销售出库单(jst_Orders × jst_OrderItem,按 send_date,排除 Delete/Cancelled),与「每日进销存」发货口径一致。', +]; diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index 25f86b9..bfa9e29 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -120,6 +120,7 @@ const SIDEBAR_ROUTE_MAP = { matchMode: 'prefix', }, 'daily-inventory': { path: '/planning/operation/daily-inventory' }, + 'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' }, settings: { path: '/settings' }, 'online-desktop-users': { path: '/settings/online-desktop-users' }, 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, diff --git a/frontend/src/pages/StockSalesAnalysis/index.jsx b/frontend/src/pages/StockSalesAnalysis/index.jsx new file mode 100644 index 0000000..0551105 --- /dev/null +++ b/frontend/src/pages/StockSalesAnalysis/index.jsx @@ -0,0 +1,241 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, Button, Modal, Select, Space, Spin, Table, Tooltip, Typography, +} from 'antd'; +import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { getStockSalesAnalysis } from '../../api/stockSalesAnalysis'; +import { + STOCK_SALES_COLUMNS, + STOCK_SALES_WINDOW_OPTIONS, + STOCK_SALES_DEFAULT_DAYS, + STOCK_SALES_DESCRIPTION, +} from '../../constants/stockSalesAnalysis'; +import './style.css'; + +const { Title, Text } = Typography; + +const ALL_WAREHOUSES = '__all__'; + +const int = (value) => { + if (value === null || value === undefined || value === '') return '—'; + return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); +}; + +const percent = (value) => { + if (value === null || value === undefined || value === '') return '—'; + return `${(Number(value) * 100).toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}%`; +}; + +const TipTitle = ({ tip, children }) => ( + + {children} + +); + +const StockSalesAnalysis = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [data, setData] = useState(null); + const [helpOpen, setHelpOpen] = useState(false); + const [days, setDays] = useState(STOCK_SALES_DEFAULT_DAYS); + const [warehouse, setWarehouse] = useState(ALL_WAREHOUSES); + + const load = useCallback(async (options = {}) => { + setLoading(true); + setError(''); + try { + const res = await getStockSalesAnalysis({ + days: options.days ?? STOCK_SALES_DEFAULT_DAYS, + ...(options.force ? { refresh: '1' } : {}), + }); + setData(res?.data ?? null); + } catch (err) { + setError(err?.response?.data?.error || err?.message || '库存动销分析数据加载失败'); + setData(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load({ days: STOCK_SALES_DEFAULT_DAYS }); }, [load]); + + const handleDaysChange = useCallback((value) => { + setDays(value); + load({ days: value }); + }, [load]); + + const items = useMemo(() => data?.items ?? [], [data]); + const grand = data?.grand ?? null; + + const warehouseOptions = useMemo(() => ([ + { label: '全部仓库', value: ALL_WAREHOUSES }, + ...items.map((it) => ({ label: it.warehouse, value: it.warehouse })), + ]), [items]); + + const visibleItems = useMemo(() => ( + warehouse === ALL_WAREHOUSES ? items : items.filter((it) => it.warehouse === warehouse) + ), [items, warehouse]); + + /** 表格行:每个仓库 6 个等级 + 1 行汇总;仓库列跨行合并 */ + const tableRows = useMemo(() => { + const rows = []; + const pushGroup = (warehouseName, buckets, total, extra = {}) => { + // 注意:bucket / total 自带 key(等级 key),必须放在展开之后覆盖,否则跨仓行 key 会重复 + buckets.forEach((bucket, index) => { + rows.push({ + ...bucket, + ...extra, + key: `${warehouseName}::${bucket.key}`, + warehouse: warehouseName, + warehouseRowSpan: index === 0 ? buckets.length + 1 : 0, + grade: bucket.label, + }); + }); + rows.push({ + ...total, + ...extra, + key: `${warehouseName}::__total__`, + warehouse: warehouseName, + warehouseRowSpan: 0, + grade: total.label, + isSubtotal: true, + }); + }; + + visibleItems.forEach((item) => pushGroup(item.warehouse, item.buckets, item.total)); + if (grand && (warehouse === ALL_WAREHOUSES || visibleItems.length === 0)) { + pushGroup(grand.warehouse, grand.buckets, grand.total, { isGrand: true }); + } + return rows; + }, [visibleItems, grand, warehouse]); + + const columns = useMemo(() => STOCK_SALES_COLUMNS.map((col) => ({ + title: {col.label}, + key: col.key, + dataIndex: col.key, + align: ['warehouse', 'grade'].includes(col.key) ? 'left' : 'right', + width: col.key === 'warehouse' ? 260 : (col.key === 'grade' ? 180 : 130), + fixed: col.key === 'warehouse' ? 'left' : undefined, + onCell: col.key === 'warehouse' + ? (record) => ({ rowSpan: record.warehouseRowSpan }) + : undefined, + render: (value, record) => { + if (col.key === 'warehouse') { + if (record.warehouseRowSpan === 0) return null; + return {value}; + } + if (col.key === 'grade') { + return {value}; + } + if (col.key === 'skuRatio' || col.key === 'stockRatio') return percent(value); + return int(value); + }, + })), []); + + const gradeCount = data?.grades?.length ?? 0; + + return ( + +
+
+ 库存动销分析 + +
+
+ 统计基准日:{data?.latestBizDate || '—'} + {data?.shipWindow ? ( + + 销量窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天) + + ) : null} + {data?.takenAt ? ( + 库存取数时间:{new Date(data.takenAt).toLocaleString('zh-CN', { hour12: false })} + ) : null} +
+ + + 统计天数 + + + + + + +
+ + + + {error ? : null} + + + [ + record?.isGrand ? 'stock-sales-row--grand' : '', + record?.isSubtotal ? 'stock-sales-row--subtotal' : '', + ].filter(Boolean).join(' ')} + /> + + + + 共 {visibleItems.length} 个仓库;每个仓库 {gradeCount} 个动销等级 + 1 行汇总 + {data?.emptyWarehouseCount > 0 ? `;其中 ${data.emptyWarehouseCount} 个仓在业务库无有效库存或销量` : ''}。 + + + setHelpOpen(false)} + footer={null} + width={720} + > +
+ {STOCK_SALES_DESCRIPTION.map((line, i) => ( +

{line}

+ ))} +
+
+ + ); +}; + +export default StockSalesAnalysis; diff --git a/frontend/src/pages/StockSalesAnalysis/style.css b/frontend/src/pages/StockSalesAnalysis/style.css new file mode 100644 index 0000000..d82b3bf --- /dev/null +++ b/frontend/src/pages/StockSalesAnalysis/style.css @@ -0,0 +1,90 @@ +.stock-sales-page { + padding: 16px 20px 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.stock-sales-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.stock-sales-header__title { + display: flex; + align-items: center; + gap: 4px; +} + +.stock-sales-header__help { + font-size: 13px; +} + +.stock-sales-header__meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + font-size: 13px; + color: rgba(0, 0, 0, 0.55); +} + +.stock-sales-th-tip { + border-bottom: 1px dashed rgba(0, 0, 0, 0.35); + cursor: help; + white-space: nowrap; +} + +.stock-sales-table .ant-table-thead > tr > th { + white-space: nowrap; + font-size: 12px; + padding: 6px 8px; +} + +.stock-sales-table .ant-table-tbody > tr > td { + padding: 5px 8px; + font-size: 13px; + vertical-align: middle; +} + +.stock-sales-warehouse { + white-space: nowrap; +} + +.stock-sales-grade { + white-space: nowrap; +} + +.stock-sales-grade.is-total { + font-weight: 600; +} + +/* 每个仓库的汇总行 */ +.stock-sales-table .stock-sales-row--subtotal > td { + background: rgba(0, 0, 0, 0.03); + font-weight: 600; +} + +/* 全仓合计行 */ +.stock-sales-table .stock-sales-row--grand > td { + background: rgba(22, 119, 255, 0.06); + font-weight: 600; +} + +.stock-sales-footer { + font-size: 12px; +} + +.stock-sales-help p { + margin: 0 0 6px; + line-height: 1.7; + font-size: 13px; + white-space: pre-wrap; +} + +.stock-sales-help p.is-indent { + padding-left: 16px; + color: rgba(0, 0, 0, 0.65); +} diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index c20985e..b738e5f 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -20,6 +20,7 @@ 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 StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis')); const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor')); const lazyLoadingElement = ; @@ -117,6 +118,10 @@ const router = createBrowserRouter([ path: 'planning/operation/daily-inventory', element: renderLazyComponent(DailyInventory), }, + { + path: 'planning/operation/stock-sales-analysis', + element: renderLazyComponent(StockSalesAnalysis), + }, // 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓; // 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线) {