feat: 侧边栏新增二级「库存动销分析」页面
按《仓库库存动销分析.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 行渲染与截图均正常
This commit is contained in:
parent
9e0612b7f3
commit
f20bd3a487
205
backend/src/api/stockSalesAnalysis.js
Normal file
205
backend/src/api/stockSalesAnalysis.js
Normal file
@ -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;
|
||||
47
backend/src/config/stockSalesAnalysis.js
Normal file
47
backend/src/config/stockSalesAnalysis.js
Normal file
@ -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,
|
||||
};
|
||||
@ -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: '系统设置',
|
||||
|
||||
@ -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);
|
||||
|
||||
142
backend/src/services/stockSalesAnalysis.js
Normal file
142
backend/src/services/stockSalesAnalysis.js
Normal file
@ -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<Array<{wmsCoId:string, gradeKey:string, skuCount:number, stockQty:number, salesQty:number, dailySales:number}>>}
|
||||
*/
|
||||
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,
|
||||
};
|
||||
73
backend/test/stockSalesAnalysis.test.js
Normal file
73
backend/test/stockSalesAnalysis.test.js
Normal file
@ -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'));
|
||||
});
|
||||
10
frontend/src/api/stockSalesAnalysis.js
Normal file
10
frontend/src/api/stockSalesAnalysis.js
Normal file
@ -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 }
|
||||
);
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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 },
|
||||
|
||||
57
frontend/src/constants/stockSalesAnalysis.js
Normal file
57
frontend/src/constants/stockSalesAnalysis.js
Normal file
@ -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),与「每日进销存」发货口径一致。',
|
||||
];
|
||||
@ -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' },
|
||||
|
||||
241
frontend/src/pages/StockSalesAnalysis/index.jsx
Normal file
241
frontend/src/pages/StockSalesAnalysis/index.jsx
Normal file
@ -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 }) => (
|
||||
<Tooltip title={tip}>
|
||||
<span className="stock-sales-th-tip">{children}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
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: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
|
||||
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 <span className="stock-sales-warehouse">{value}</span>;
|
||||
}
|
||||
if (col.key === 'grade') {
|
||||
return <span className={record.isSubtotal ? 'stock-sales-grade is-total' : 'stock-sales-grade'}>{value}</span>;
|
||||
}
|
||||
if (col.key === 'skuRatio' || col.key === 'stockRatio') return percent(value);
|
||||
return int(value);
|
||||
},
|
||||
})), []);
|
||||
|
||||
const gradeCount = data?.grades?.length ?? 0;
|
||||
|
||||
return (
|
||||
<SycmPage className="stock-sales-page">
|
||||
<div className="stock-sales-header">
|
||||
<div className="stock-sales-header__title">
|
||||
<Title level={4} style={{ margin: 0 }}>库存动销分析</Title>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => setHelpOpen(true)}
|
||||
className="stock-sales-header__help"
|
||||
>
|
||||
口径说明
|
||||
</Button>
|
||||
</div>
|
||||
<div className="stock-sales-header__meta">
|
||||
<span>统计基准日:{data?.latestBizDate || '—'}</span>
|
||||
{data?.shipWindow ? (
|
||||
<span>
|
||||
销量窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天)
|
||||
</span>
|
||||
) : null}
|
||||
{data?.takenAt ? (
|
||||
<span>库存取数时间:{new Date(data.takenAt).toLocaleString('zh-CN', { hour12: false })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Space size={4}>
|
||||
<Text type="secondary">统计天数</Text>
|
||||
<Select
|
||||
value={days}
|
||||
options={STOCK_SALES_WINDOW_OPTIONS}
|
||||
onChange={handleDaysChange}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Space>
|
||||
<Select
|
||||
value={warehouse}
|
||||
options={warehouseOptions}
|
||||
onChange={setWarehouse}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ minWidth: 240 }}
|
||||
placeholder="选择仓库"
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load({ days })} loading={loading}>刷新</Button>
|
||||
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细与库存明细重算(约 5~20 秒)">
|
||||
<Button onClick={() => load({ days, force: true })} loading={loading}>强制重算</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`按「单 SKU 日均销量」把库存拆成 ${gradeCount} 个动销等级,全部数值来自业务库实时取数`}
|
||||
description="库存取实时库存表,销量取销售出库单;占比为「该等级 ÷ 该仓库合计」。切换统计天数会按新窗口重算日销与分档。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table
|
||||
className="stock-sales-table"
|
||||
columns={columns}
|
||||
dataSource={tableRows}
|
||||
rowKey="key"
|
||||
size="small"
|
||||
bordered
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content', y: 'calc(100vh - 320px)' }}
|
||||
rowClassName={(record) => [
|
||||
record?.isGrand ? 'stock-sales-row--grand' : '',
|
||||
record?.isSubtotal ? 'stock-sales-row--subtotal' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
/>
|
||||
</Spin>
|
||||
|
||||
<Text type="secondary" className="stock-sales-footer">
|
||||
共 {visibleItems.length} 个仓库;每个仓库 {gradeCount} 个动销等级 + 1 行汇总
|
||||
{data?.emptyWarehouseCount > 0 ? `;其中 ${data.emptyWarehouseCount} 个仓在业务库无有效库存或销量` : ''}。
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
title="计算口径说明"
|
||||
open={helpOpen}
|
||||
onCancel={() => setHelpOpen(false)}
|
||||
footer={null}
|
||||
width={720}
|
||||
>
|
||||
<div className="stock-sales-help">
|
||||
{STOCK_SALES_DESCRIPTION.map((line, i) => (
|
||||
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockSalesAnalysis;
|
||||
90
frontend/src/pages/StockSalesAnalysis/style.css
Normal file
90
frontend/src/pages/StockSalesAnalysis/style.css
Normal file
@ -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);
|
||||
}
|
||||
@ -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 = <PageLoadingFallback />;
|
||||
@ -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 区分取哪个仓;
|
||||
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user