feat: 新增二级页面「拣选库位动销分析」(大货库存监控)

- 后端:pickingBinSalesAnalysis 配置/服务/路由,仓位库存取 rpt.jst_packInventoryInfo
  (bin_group 空值=拣选位、存货=存货位),销量按「SKU 占用同类仓位数平均分摊」,
  恒等式「全部库位销量之和 = 有库位 SKU 销量之和」差异为 0
- 动销等级(近 N 天日销,默认 15):爆款>=20、热卖款 10-20、平销款 3-10、
  滞销款-1 1-3、滞销款-2 (0,1)、销售=0
- 前端:大货库存监控下新增二级菜单 + 页面(等级卡片、仓库/类型/天数/仓位筛选、
  分页表格、xlsx 导出),路由/菜单/页面权限四处注册
- 测试:后端 8 项单测通过;前端 lint/test/build 通过
This commit is contained in:
youruo 2026-10-07 16:59:39 +08:00
parent ada8d09c27
commit 70550b5499
14 changed files with 1135 additions and 0 deletions

View File

@ -0,0 +1,263 @@
const express = require('express');
const cloudDb = require('../config/cloudDatabase');
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
const {
PICKING_BIN_GRADE_RULES,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_TOTAL_LABEL,
BIN_TYPES,
PICKING_BIN_DEFAULT_TYPES,
} = require('../config/pickingBinSalesAnalysis');
const { fetchPickingBinRows } = require('../services/pickingBinSalesAnalysis');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */
const AS_OF_TIME = '01:00';
/** 结果缓存:一次要扫订单明细 + 仓位明细两张大表,避免连续刷新重复打库 */
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
const cacheByKey = new Map();
const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100;
const round4 = (v) => Math.round((Number(v) || 0) * 10000) / 10000;
/** 快照日 + 次日 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 fetchBinTakenAt() {
const rows = await cloudDb.query(
`SELECT MAX(updateTime) AS takenAt FROM rpt.jst_packInventoryInfo`
);
return rows[0]?.takenAt || null;
}
/** 解析并收敛统计窗口天数,只允许白名单内的取值 */
function resolveDays(raw) {
const n = Number.parseInt(String(raw ?? ''), 10);
return PICKING_BIN_WINDOW_OPTIONS.includes(n) ? n : PICKING_BIN_DEFAULT_DAYS;
}
/** 解析仓位类型,只保留已知类型;空 → 默认(拣选位) */
function resolveBinTypes(raw) {
if (raw === undefined || raw === null || String(raw).trim() === '') {
return [...PICKING_BIN_DEFAULT_TYPES];
}
const list = String(raw)
.split(',')
.map((v) => v.trim())
.filter((v) => BIN_TYPES.includes(v));
return list.length > 0 ? list : [...PICKING_BIN_DEFAULT_TYPES];
}
/** 解析仓库过滤,只允许出现在仓库清单里的 wmsCoId,防止任意拼参 */
function resolveWarehouses(raw) {
const known = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
const requested = String(raw || '')
.split(',')
.map((v) => v.trim())
.filter(Boolean);
const picked = requested.length > 0
? known.filter((b) => requested.includes(String(b.wmsCoId)))
: known;
return picked;
}
const GRADE_MAP = new Map(PICKING_BIN_GRADE_RULES.map((g) => [g.key, g]));
/**
* GET /api/picking-bin-sales-analysis
* 拣选库位动销分析:仓库 × 仓位 → 类型 / 库存数量 / SKU数 / 近N天销量 / 日销 / 动销等级
*/
router.get('/', async (req, res) => {
try {
const force = String(req.query?.refresh || '') === '1';
const days = resolveDays(req.query?.days);
const binTypes = resolveBinTypes(req.query?.binType);
const warehouses = resolveWarehouses(req.query?.wmsCoId);
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
const cacheKey = `${days}::${binTypes.join('|')}::${wmsCoIds.join('|')}`;
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const cached = cacheByKey.get(cacheKey);
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 [rows, takenAt] = await Promise.all([
fetchPickingBinRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes }),
fetchBinTakenAt().catch(() => null),
]);
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
const items = rows.map((r) => ({
...r,
warehouse: whMap.get(r.wmsCoId) || r.wmsCoId,
dailySales: round4(r.dailySales),
gradeLabel: GRADE_MAP.get(r.gradeKey)?.label || '',
}));
// 各动销等级汇总
const summary = PICKING_BIN_GRADE_RULES.map((grade) => {
const hit = items.filter((it) => it.gradeKey === grade.key);
const salesQty = round2(hit.reduce((acc, it) => acc + it.salesQty, 0));
return {
key: grade.key,
label: grade.label,
tip: grade.tip,
binCount: hit.length,
stockQty: round2(hit.reduce((acc, it) => acc + it.stockQty, 0)),
skuCount: hit.reduce((acc, it) => acc + it.skuCount, 0),
salesQty,
dailySales: round4(salesQty / days),
};
});
const totalSales = round2(summary.reduce((acc, s) => acc + s.salesQty, 0));
const total = {
key: '__total__',
label: PICKING_BIN_TOTAL_LABEL,
binCount: summary.reduce((acc, s) => acc + s.binCount, 0),
stockQty: round2(summary.reduce((acc, s) => acc + s.stockQty, 0)),
skuCount: summary.reduce((acc, s) => acc + s.skuCount, 0),
salesQty: totalSales,
dailySales: round4(totalSales / days),
};
const payload = {
days,
windowOptions: PICKING_BIN_WINDOW_OPTIONS,
binTypes,
binTypeOptions: BIN_TYPES,
latestBizDate,
asOf,
shipWindow: { shipFrom, shipTo, days },
takenAt,
grades: PICKING_BIN_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })),
totalLabel: PICKING_BIN_TOTAL_LABEL,
warehouses: warehouses.map((b) => ({ wmsCoId: String(b.wmsCoId), warehouse: b.warehouse })),
items,
summary,
total,
};
cacheByKey.set(cacheKey, { latestBizDate, at: Date.now(), payload });
return res.json({ success: true, data: payload });
} catch (error) {
console.error('[picking-bin-sales-analysis] 查询失败:', error.message);
return res.status(500).json({
success: false,
error: '拣选库位动销分析数据查询失败',
detail: error.message,
});
}
});
/**
* GET /api/picking-bin-sales-analysis/export
* 导出库位明细。query: days、binType、wmsCoId(逗号分隔)、grade(逗号分隔)、format(xlsx|csv)
*/
router.get('/export', async (req, res) => {
try {
const days = resolveDays(req.query?.days);
const binTypes = resolveBinTypes(req.query?.binType);
const warehouses = resolveWarehouses(req.query?.wmsCoId);
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const grades = String(req.query?.grade || '')
.split(',')
.map((v) => v.trim())
.filter((v) => GRADE_MAP.has(v));
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const asOf = nextDayAsOf(latestBizDate);
if (!asOf) {
return res.status(503).json({
success: false,
error: '业务库暂无库存监控数据,无法定位统计基准日',
});
}
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
const rows = await fetchPickingBinRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes });
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
const filtered = grades.length > 0
? rows.filter((r) => grades.includes(r.gradeKey))
: rows;
const sheetRows = filtered.map((r) => [
whMap.get(r.wmsCoId) || r.wmsCoId,
r.binName,
r.binType,
r.stockQty,
r.skuCount,
round2(r.salesQty),
round4(r.dailySales),
GRADE_MAP.get(r.gradeKey)?.label || r.gradeKey,
days,
latestBizDate || '',
]);
const fileName = `拣选库位动销分析-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`;
const { totalRows } = sendWorkbookDownload(res, {
fileName,
format,
sheets: [{
name: '库位动销明细',
header: [
'仓库', '仓位', '类型', '库存数量', 'SKU数',
`销量(近${days}天)`, '日销', '动销等级', '统计天数', '统计基准日',
],
rows: sheetRows,
}],
});
console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 类型 ${binTypes.join('/')}, 窗口 ${days} 天`);
return null;
} catch (error) {
console.error('[picking-bin-sales-analysis] 导出失败:', error.message);
if (!res.headersSent) {
return res.status(500).json({
success: false,
error: '拣选库位动销分析导出失败',
detail: error.message,
});
}
return null;
}
});
module.exports = router;

View File

@ -0,0 +1,66 @@
/**
* 拣选库位动销分析 —— 口径配置(纯规则,不含业务数值)
*
* 一级菜单:大货库存监控
* 二级菜单:拣选库位动销分析
*
* 表头(需求表原列为 所在仓库 | 仓位 | 数量 | 类型 | 动销等级,这里做了扩充与优化):
* 仓库 | 仓位 | 类型 | 库存数量 | SKU数 | 近N天销量 | 日销 | 动销等级
*
* 口径:
* 库存数量:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty
* 仓位类型:bin_group 为空 → 拣选位;bin_group = '存货' → 存货位
* 销量: rpt.jst_Orders × rpt.jst_OrderItem,按 send_date,排除 Delete/Cancelled
* 日销: 库位销量 / 窗口天数
*
* 【关键】同一 SKU 常常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个拣选 SKU 中
* 1431 个跨多个拣选位,最多 24 个)。若直接把 SKU 销量累加到每个仓位,
* 销量会被放大数倍。因此这里按「SKU 销量在其占用的同类型仓位数之间平均分摊」:
* 库位销量 = Σ(该库位内各 SKU 的窗口销量 ÷ 该 SKU 占用的同类型仓位数)
* 恒等式(已实测校验,差异 0):全部库位销量之和 = 有库位记录的那些 SKU 的销量之和。
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
*/
const PICKING_BIN_LEVEL1 = '大货库存监控';
const PICKING_BIN_LEVEL2 = '拣选库位动销分析';
/** 可选的统计窗口天数;默认 15 天(需求指定按近 15 天销量划分动销等级) */
const PICKING_BIN_WINDOW_OPTIONS = [7, 15, 30];
const PICKING_BIN_DEFAULT_DAYS = 15;
/** 仓位类型:业务库 bin_group 为空视为拣选位 */
const BIN_TYPE_PICKING = '拣选';
const BIN_TYPE_STORAGE = '存货';
const BIN_TYPES = [BIN_TYPE_PICKING, BIN_TYPE_STORAGE];
/** 默认只看拣选位(本功能的核心是拣货位动销) */
const PICKING_BIN_DEFAULT_TYPES = [BIN_TYPE_PICKING];
/**
* 动销等级(顺序即展示顺序)。
* lower 为日销下界(含),upper 为日销上界(不含);null 表示不限制。
* zero 档为「窗口内无销量」,由 salesQty <= 0 兜底。
*/
const PICKING_BIN_GRADE_RULES = [
{ key: 'hot', label: '爆款:日销≥20', lower: 20, upper: null, tip: '库位日均销量 ≥ 20 件' },
{ key: 'best', label: '热卖款:10≤日销<20', lower: 10, upper: 20, tip: '库位日均销量 10 ~ 20 件(含 10,不含 20)' },
{ key: 'normal', label: '平销款:3≤日销<10', lower: 3, upper: 10, tip: '库位日均销量 3 ~ 10 件(含 3,不含 10)' },
{ key: 'slow1', label: '滞销款-1:1≤日销<3', lower: 1, upper: 3, tip: '库位日均销量 1 ~ 3 件(含 1,不含 3)' },
{ key: 'slow2', label: '滞销款-2:0<日销<1', lower: 0, upper: 1, tip: '库位日均销量大于 0 且小于 1 件' },
{ key: 'zero', label: '销售=0', lower: 0, upper: 0, tip: '统计窗口内该库位所有 SKU 均无销量(仍占用库存)' },
];
const PICKING_BIN_TOTAL_LABEL = '汇总';
module.exports = {
PICKING_BIN_LEVEL1,
PICKING_BIN_LEVEL2,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
BIN_TYPE_PICKING,
BIN_TYPE_STORAGE,
BIN_TYPES,
PICKING_BIN_DEFAULT_TYPES,
PICKING_BIN_GRADE_RULES,
PICKING_BIN_TOTAL_LABEL,
};

View File

@ -45,6 +45,11 @@ const SIDEBAR_PAGES = [
pageName: '大货库存监控 · 库存动销分析', pageName: '大货库存监控 · 库存动销分析',
path: '/planning/operation/stock-sales-analysis', path: '/planning/operation/stock-sales-analysis',
}), }),
buildPlanningPage({
pageKey: 'picking_bin_sales_analysis',
pageName: '大货库存监控 · 拣选库位动销分析',
path: '/planning/operation/picking-bin-sales-analysis',
}),
{ {
pageKey: 'settings', pageKey: 'settings',
pageName: '系统设置', pageName: '系统设置',

View File

@ -38,6 +38,7 @@ const mkhBomRoutes = require('./api/mkhBom');
const dailyInventoryRoutes = require('./api/dailyInventory'); const dailyInventoryRoutes = require('./api/dailyInventory');
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification'); const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis'); const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis');
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning'); const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
const returnMonitorRoutes = require('./api/returnMonitor'); const returnMonitorRoutes = require('./api/returnMonitor');
const returnStockWarningRoutes = require('./api/returnStockWarning'); const returnStockWarningRoutes = require('./api/returnStockWarning');
@ -674,6 +675,7 @@ async function startServer(options = {}) {
cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes); cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes);
cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes); cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes);
cubejsApp.use('/api/picking-bin-sales-analysis', authMiddleware, pickingBinSalesAnalysisRoutes);
cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes); cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes);
// 崔桥工厂仓客退:退货入库核销 + 每日库存监控 // 崔桥工厂仓客退:退货入库核销 + 每日库存监控
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes); cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);

View File

@ -0,0 +1,181 @@
'use strict';
/**
* 拣选库位动销分析 —— 业务库取数
*
* 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty(仓位为空的行不参与)
* 类型:bin_group 为空 → 拣选位;否则取 bin_group(业务库目前只有「存货」)
* 销量:rpt.jst_Orders × rpt.jst_OrderItem,按 send_date,排除 Delete/Cancelled
* 分摊:SKU 销量 ÷ 该 SKU 占用的同类型仓位数,再按库位累加
* 保证「全部库位销量之和 = 全仓销量」
* 日销:库位销量 / 窗口天数 → 分档得到动销等级
*/
const { PICKING_BIN_GRADE_RULES } = require('../config/pickingBinSalesAnalysis');
/** 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 兜底。
*/
function buildGradeCase(salesExpr = 'ISNULL(x.salesQty, 0)', daysParam = '@days') {
const branches = PICKING_BIN_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`;
}
/**
* 拉取各仓库每个仓位的库存、销量、日销与动销等级。
*
* @param {object} cloudDb 业务库实例
* @param {string[]} wmsCoIds 仓库编码
* @param {{shipFrom:string, shipTo:string, days:number, binTypes?:string[]}} options
* @returns {Promise<Array<{wmsCoId:string, binName:string, binType:string,
* stockQty:number, skuCount:number, salesQty:number, dailySales:number, gradeKey:string}>>}
*/
async function fetchPickingBinRows(cloudDb, wmsCoIds, options = {}) {
if (!cloudDb || typeof cloudDb.query !== 'function') {
throw new TypeError('fetchPickingBinRows 的第一个参数必须是业务库实例');
}
const { shipFrom, shipTo, days, binTypes } = options;
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
if (!shipFrom || !shipTo || !days) return [];
const binIn = buildInClause(wmsCoIds, 'w');
const shipIn = buildInClause(wmsCoIds, 'v');
const gradeCase = buildGradeCase();
// 仓位类型过滤(不传 = 全部类型)
let typeFilter = '';
const typeParams = {};
if (Array.isArray(binTypes) && binTypes.length > 0) {
const t = buildInClause(binTypes, 't');
Object.assign(typeParams, t.params);
typeFilter = `AND a.binType IN (${t.clause})`;
}
const sqlText = `
WITH bin AS (
SELECT p.wms_co_id AS wmsCoId,
LTRIM(RTRIM(p.bin)) AS binName,
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
ELSE LTRIM(RTRIM(p.bin_group)) END AS binType,
p.sku_id AS skuId,
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binSkuQty
FROM rpt.jst_packInventoryInfo p
WHERE p.wms_co_id IN (${binIn.clause})
AND ISNULL(p.bin, '') <> ''
AND ISNULL(p.qty, 0) <> 0
GROUP BY p.wms_co_id,
LTRIM(RTRIM(p.bin)),
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
ELSE LTRIM(RTRIM(p.bin_group)) END,
p.sku_id
),
binSpan AS (
SELECT wmsCoId, skuId, binType, COUNT(DISTINCT binName) AS binCount
FROM bin
GROUP BY wmsCoId, skuId, binType
),
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
),
binSales AS (
SELECT b.wmsCoId,
b.binName,
b.binType,
SUM(CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 4)) / NULLIF(sp.binCount, 0)) AS salesQty
FROM bin b
JOIN binSpan sp
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
LEFT JOIN ship s
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
GROUP BY b.wmsCoId, b.binName, b.binType
),
binAgg AS (
SELECT wmsCoId,
binName,
binType,
SUM(binSkuQty) AS stockQty,
COUNT(DISTINCT skuId) AS skuCount
FROM bin
GROUP BY wmsCoId, binName, binType
)
SELECT a.wmsCoId,
a.binName,
a.binType,
CAST(a.stockQty AS DECIMAL(18, 2)) AS stockQty,
CAST(a.skuCount AS INT) AS skuCount,
CAST(ISNULL(x.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
CAST(ISNULL(x.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
${gradeCase} AS gradeKey
FROM binAgg a
LEFT JOIN binSales x
ON x.wmsCoId = a.wmsCoId AND x.binName = a.binName AND x.binType = a.binType
WHERE ABS(ISNULL(a.stockQty, 0)) > 0.0001
${typeFilter}
ORDER BY a.wmsCoId, a.binType, a.binName`;
const rows = await cloudDb.query(
sqlText,
{
...binIn.params,
...shipIn.params,
...typeParams,
shipFrom,
shipTo,
days,
},
{ requestTimeout: 120000 }
);
return rows.map((r) => ({
wmsCoId: String(r.wmsCoId),
binName: r.binName || '',
binType: r.binType || '',
stockQty: Number(r.stockQty) || 0,
skuCount: Number(r.skuCount) || 0,
salesQty: Number(r.salesQty) || 0,
dailySales: Number(r.dailySales) || 0,
gradeKey: r.gradeKey || 'zero',
}));
}
module.exports = {
buildGradeCase,
buildInClause,
fetchPickingBinRows,
};

View File

@ -0,0 +1,119 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildGradeCase,
buildInClause,
fetchPickingBinRows,
} = require('../src/services/pickingBinSalesAnalysis');
const {
PICKING_BIN_GRADE_RULES,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_DEFAULT_TYPES,
BIN_TYPES,
} = require('../src/config/pickingBinSalesAnalysis');
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();
PICKING_BIN_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('默认窗口为 15 天,默认只看拣选位', () => {
assert.equal(PICKING_BIN_DEFAULT_DAYS, 15);
assert.deepEqual(PICKING_BIN_DEFAULT_TYPES, ['拣选']);
assert.deepEqual(BIN_TYPES, ['拣选', '存货']);
});
test('fetchPickingBinRows 首参不是数据库实例时抛错', async () => {
await assert.rejects(
() => fetchPickingBinRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 15 }),
TypeError
);
});
test('fetchPickingBinRows 无仓库或缺失窗口时直接返回空结果,不打库', async () => {
let called = 0;
const db = { query: async () => { called += 1; return []; } };
assert.deepEqual(await fetchPickingBinRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 15 }), []);
assert.deepEqual(await fetchPickingBinRows(db, [101], { shipFrom: null, shipTo: 'b', days: 15 }), []);
assert.equal(called, 0);
});
test('fetchPickingBinRows 把仓库/类型/窗口参数一并传给业务库,并做分摊与分档', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => {
captured = { sqlText, params };
return [
{ wmsCoId: 101, binName: 'XA01-17-12', binType: '拣选', stockQty: 21, skuCount: 1, salesQty: 9, dailySales: 0.6, gradeKey: 'slow2' },
];
},
};
const rows = await fetchPickingBinRows(db, [101, 102], {
shipFrom: '2026-09-23 01:00:00',
shipTo: '2026-10-08 01:00:00',
days: 15,
binTypes: ['拣选'],
});
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
wmsCoId: '101',
binName: 'XA01-17-12',
binType: '拣选',
stockQty: 21,
skuCount: 1,
salesQty: 9,
dailySales: 0.6,
gradeKey: 'slow2',
});
// 参数必须带上两个仓库、发货窗口与天数,以及仓位类型过滤
assert.equal(captured.params.w0, 101);
assert.equal(captured.params.w1, 102);
assert.equal(captured.params.v0, 101);
assert.equal(captured.params.shipFrom, '2026-09-23 01:00:00');
assert.equal(captured.params.shipTo, '2026-10-08 01:00:00');
assert.equal(captured.params.days, 15);
assert.equal(captured.params.t0, '拣选');
// 分摊:SKU 销量除以其占用的同类型仓位数
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
// 仓位类型:bin_group 为空视为拣选位
assert.ok(captured.sqlText.includes("THEN N'拣选'"));
// 只统计有库存的仓位
assert.ok(captured.sqlText.includes('ISNULL(p.qty, 0) <> 0'));
});
test('fetchPickingBinRows 不传类型时不过滤 binType', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
};
await fetchPickingBinRows(db, [101], {
shipFrom: 'a', shipTo: 'b', days: 15, binTypes: [],
});
assert.ok(!captured.sqlText.includes('a.binType IN'));
assert.equal(captured.params.t0, undefined);
});
test('gradeKey 缺失时兜底为 zero', async () => {
const db = { query: async () => [{ wmsCoId: 101, binName: 'X', binType: '拣选', stockQty: 5 }] };
const rows = await fetchPickingBinRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
assert.equal(rows[0].gradeKey, 'zero');
assert.equal(rows[0].salesQty, 0);
assert.equal(rows[0].dailySales, 0);
});

View File

@ -0,0 +1,16 @@
import request from './request';
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
// 拣选库位动销分析:仓库 × 仓位 → 类型 / 库存数量 / SKU数 / 近N天销量 / 日销 / 动销等级
// refresh=1 跳过后端 5 分钟缓存,强制重算
export const getPickingBinSalesAnalysis = (params = {}) => request.get(
'/api/picking-bin-sales-analysis',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 导出库位明细(xlsx/csv,服务端直接返回文件流)
export const exportPickingBinSalesAnalysis = (params = {}) => request.get(
'/api/picking-bin-sales-analysis/export',
{ params, responseType: 'blob', timeout: LIST_TIMEOUT_MS }
);

View File

@ -41,6 +41,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
children: [ children: [
{ key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 }, { 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 }, { key: 'stock-sales-analysis', label: '库存动销分析', icon: 'line-chart', routeKey: 'stock-sales-analysis', visible: true, order: 20 },
{ key: 'picking-bin-sales-analysis', label: '拣选库位动销分析', icon: 'inbox', routeKey: 'picking-bin-sales-analysis', visible: true, order: 30 },
], ],
}, },
{ {

View File

@ -0,0 +1,87 @@
/** 拣选库位动销分析 —— 表头与口径说明 */
export const PICKING_BIN_COLUMNS = [
{
key: 'warehouse',
label: '仓库',
tip: '聚水潭主仓(按 wms_co_id 单仓独立统计,不合并分仓)',
},
{
key: 'binName',
label: '仓位',
tip: '系统仓位编码(jst_packInventoryInfo.bin)',
},
{
key: 'binType',
label: '类型',
tip: '拣选 = 拣货位(bin_group 为空);存货 = 存货位(bin_group=存货)',
},
{
key: 'stockQty',
label: '库存数量',
tip: '该仓位当前库存件数(jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty)',
},
{
key: 'skuCount',
label: 'SKU数',
tip: '该仓位内存放且有库存的 SKU 个数',
},
{
key: 'salesQty',
label: '销量',
tip: '近 N 天该仓位的分摊销量:Σ(仓位内各 SKU 销量 ÷ 该 SKU 占用的同类型仓位数)',
},
{
key: 'dailySales',
label: '日销',
tip: '分摊销量 ÷ 统计天数,动销等级按此值划分',
},
{
key: 'gradeKey',
label: '动销等级',
tip: '爆款≥20 / 热卖款 10~20 / 平销款 3~10 / 滞销款-1 1~3 / 滞销款-2 0~1 / 销售=0',
},
];
export const PICKING_BIN_WINDOW_OPTIONS = [
{ label: '近 7 天', value: 7 },
{ label: '近 15 天', value: 15 },
{ label: '近 30 天', value: 30 },
];
export const PICKING_BIN_DEFAULT_DAYS = 15;
export const PICKING_BIN_TYPE_OPTIONS = [
{ label: '拣选位', value: '拣选' },
{ label: '存货位', value: '存货' },
];
export const PICKING_BIN_DEFAULT_TYPES = ['拣选'];
/** 动销等级展示色(与后端 key 对应) */
export const PICKING_BIN_GRADE_COLORS = {
hot: 'red',
best: 'volcano',
normal: 'blue',
slow1: 'gold',
slow2: 'default',
zero: 'default',
};
export const PICKING_BIN_DESCRIPTION = [
'动销等级口径:按「单个仓位的日均销量(日销)」分档,日销 = 该仓位近 N 天销量 ÷ N。',
' 爆款:日销 ≥ 20;热卖款:10 ≤ 日销 < 20;平销款:3 ≤ 日销 < 10;',
' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内该仓位所有 SKU 均无销量。',
'默认统计窗口为近 15 天,可切换 7 / 15 / 30 天。',
'',
'【销量为什么要分摊】',
' 同一 SKU 常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。',
' 若直接把 SKU 销量累加到每个仓位,销量会被放大数倍。',
' 因此按「SKU 销量在其占用的同类型仓位数之间平均分摊」计算库位销量,',
' 恒等式已实测校验:全部库位销量之和 = 有库位记录的那些 SKU 的销量之和(差异 0)。',
' 注意该合计小于全仓销量:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,无仓位记录的 SKU 销量不计入任何库位。',
'',
'数据来源:库存取 rpt.jst_packInventoryInfo(按仓库 + 仓位汇总);',
'销量取 rpt.jst_Orders × rpt.jst_OrderItem(按 send_date,排除 Delete/Cancelled),与「每日进销存」发货口径一致。',
'仓位类型:bin_group 为空视为拣选位,bin_group=存货 为存货位。',
];

View File

@ -6,6 +6,7 @@ export const SIDEBAR_PAGES = [
{ pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_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: '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: 'stock_sales_analysis', path: '/planning/operation/stock-sales-analysis', name: '库存动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
{ pageKey: 'picking_bin_sales_analysis', path: '/planning/operation/picking-bin-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_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_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 }, { pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },

View File

@ -121,6 +121,7 @@ const SIDEBAR_ROUTE_MAP = {
}, },
'daily-inventory': { path: '/planning/operation/daily-inventory' }, 'daily-inventory': { path: '/planning/operation/daily-inventory' },
'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' }, 'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' },
'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' },
settings: { path: '/settings' }, settings: { path: '/settings' },
'online-desktop-users': { path: '/settings/online-desktop-users' }, 'online-desktop-users': { path: '/settings/online-desktop-users' },
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },

View File

@ -0,0 +1,295 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, Col, Input, Modal, Row, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import {
exportPickingBinSalesAnalysis,
getPickingBinSalesAnalysis,
} from '../../api/pickingBinSalesAnalysis';
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
import {
PICKING_BIN_COLUMNS,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_TYPE_OPTIONS,
PICKING_BIN_DEFAULT_TYPES,
PICKING_BIN_GRADE_COLORS,
PICKING_BIN_DESCRIPTION,
} from '../../constants/pickingBinSalesAnalysis';
import './style.css';
const { Title, Text } = Typography;
const int = (value) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
const num = (value, digits = 2) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
const TipTitle = ({ tip, children }) => (
<Tooltip title={tip}>
<span className="picking-bin-th-tip">{children}</span>
</Tooltip>
);
const PickingBinSalesAnalysis = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
const [days, setDays] = useState(PICKING_BIN_DEFAULT_DAYS);
const [binTypes, setBinTypes] = useState(PICKING_BIN_DEFAULT_TYPES);
const [warehouse, setWarehouse] = useState('');
const [grades, setGrades] = useState([]);
const [keyword, setKeyword] = useState('');
const [exporting, setExporting] = useState(false);
const params = useMemo(() => ({
days,
binType: binTypes.join(','),
...(warehouse ? { wmsCoId: warehouse } : {}),
}), [days, binTypes, warehouse]);
const load = useCallback(async (options = {}) => {
setLoading(true);
setError('');
try {
const res = await getPickingBinSalesAnalysis({
...(options.params ?? params),
...(options.force ? { refresh: '1' } : {}),
});
setData(res?.data ?? null);
} catch (err) {
setError(err?.response?.data?.error || err?.message || '拣选库位动销分析数据加载失败');
setData(null);
} finally {
setLoading(false);
}
}, [params]);
useEffect(() => { load(); }, [load]);
const items = useMemo(() => data?.items ?? [], [data]);
const gradeMap = useMemo(() => new Map((data?.grades ?? []).map((g) => [g.key, g])), [data]);
const filteredRows = useMemo(() => {
const kw = keyword.trim().toLowerCase();
return items.filter((it) => {
if (grades.length > 0 && !grades.includes(it.gradeKey)) return false;
if (kw && !String(it.binName).toLowerCase().includes(kw)) return false;
return true;
});
}, [items, grades, keyword]);
const columns = useMemo(() => PICKING_BIN_COLUMNS.map((col) => ({
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
key: col.key,
dataIndex: col.key,
align: ['warehouse', 'binName', 'binType', 'gradeKey'].includes(col.key) ? 'left' : 'right',
width: col.key === 'warehouse' ? 240 : (col.key === 'gradeKey' ? 170 : 110),
sorter: ['stockQty', 'skuCount', 'salesQty', 'dailySales'].includes(col.key)
? (a, b) => (a[col.key] ?? 0) - (b[col.key] ?? 0)
: undefined,
render: (value) => {
if (col.key === 'gradeKey') {
const g = gradeMap.get(value);
return <Tag color={PICKING_BIN_GRADE_COLORS[value] || 'default'}>{g?.label || value}</Tag>;
}
if (col.key === 'binType') return <Text>{value || '—'}</Text>;
if (col.key === 'dailySales') return num(value, 2);
if (col.key === 'salesQty') return num(value, 2);
if (col.key === 'stockQty' || col.key === 'skuCount') return int(value);
return value || '—';
},
})), [gradeMap]);
const handleExport = useCallback(async () => {
setExporting(true);
try {
const blob = await exportPickingBinSalesAnalysis({
...params,
...(grades.length > 0 ? { grade: grades.join(',') } : {}),
format: 'xlsx',
});
const dateKey = (data?.latestBizDate || '').replace(/-/g, '') || '未知日期';
const fileName = `拣选库位动销分析-${dateKey}.xlsx`;
await downloadBlobAsFile(blob, fileName);
message.success(`已开始下载:${fileName}`);
} catch (exportError) {
message.error(await readBlobErrorMessage(exportError, '库位动销明细导出失败'));
} finally {
setExporting(false);
}
}, [params, grades, data?.latestBizDate]);
const toggleGrade = useCallback((key) => {
setGrades((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
}, []);
const summary = data?.summary ?? [];
const total = data?.total ?? null;
return (
<SycmPage className="picking-bin-page">
<div className="picking-bin-header">
<div className="picking-bin-header__title">
<Title level={4} style={{ margin: 0 }}>拣选库位动销分析</Title>
<Button
type="text"
icon={<InfoCircleOutlined />}
onClick={() => setHelpOpen(true)}
className="picking-bin-header__help"
>
口径说明
</Button>
</div>
<div className="picking-bin-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={PICKING_BIN_WINDOW_OPTIONS}
onChange={setDays}
style={{ width: 110 }}
/>
</Space>
<Select
mode="multiple"
allowClear
value={binTypes}
options={PICKING_BIN_TYPE_OPTIONS}
onChange={(v) => setBinTypes(v && v.length > 0 ? v : PICKING_BIN_DEFAULT_TYPES)}
style={{ minWidth: 170 }}
placeholder="仓位类型"
maxTagCount={2}
/>
<Select
allowClear
value={warehouse || undefined}
options={(data?.warehouses ?? []).map((w) => ({ label: w.warehouse, value: w.wmsCoId }))}
onChange={(v) => setWarehouse(v || '')}
showSearch
optionFilterProp="label"
style={{ minWidth: 240 }}
placeholder="全部仓库"
/>
<Input.Search
allowClear
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索仓位"
style={{ width: 160 }}
/>
<Button icon={<ReloadOutlined />} onClick={() => load()} loading={loading}>刷新</Button>
<Tooltip title="跳过 5 分钟缓存,重新扫描订单与仓位明细重算(约 5~20 秒)">
<Button onClick={() => load({ force: true })} loading={loading}>强制重算</Button>
</Tooltip>
<Tooltip title="导出当前筛选下的库位明细(仓库 / 仓位 / 类型 / 库存数量 / SKU数 / 销量 / 日销 / 动销等级),每个仓位独立一行">
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出明细</Button>
</Tooltip>
</Space>
</div>
<Alert
type="info"
showIcon
message={`按「单个仓位的日均销量」把库位分成 ${summary.length} 个动销等级,全部数值来自业务库实时取数`}
description="库存取仓位库存表,销量取销售出库单并在同类仓位间平均分摊(避免同一 SKU 占用多个仓位导致销量被放大)。点击下方等级卡片可筛选。"
style={{ marginBottom: 12 }}
/>
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
<Spin spinning={loading}>
<Row gutter={[8, 8]} className="picking-bin-summary">
{summary.map((s) => (
<Col key={s.key} flex="1 1 150px">
<Card
size="small"
hoverable
onClick={() => toggleGrade(s.key)}
className={grades.includes(s.key) ? 'picking-bin-card is-active' : 'picking-bin-card'}
>
<Statistic
title={s.label}
value={s.binCount}
suffix="位"
valueStyle={{ fontSize: 18 }}
/>
<div className="picking-bin-card__meta">
库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)}
</div>
</Card>
</Col>
))}
{total ? (
<Col flex="1 1 150px">
<Card size="small" className="picking-bin-card is-total">
<Statistic title={total.label} value={total.binCount} suffix="位" valueStyle={{ fontSize: 18 }} />
<div className="picking-bin-card__meta">
库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)}
</div>
</Card>
</Col>
) : null}
</Row>
<Table
className="picking-bin-table"
columns={columns}
dataSource={filteredRows}
rowKey={(r) => `${r.wmsCoId}::${r.binType}::${r.binName}`}
size="small"
bordered
pagination={{
pageSize: 50,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
showTotal: (t) => `共 ${t} 个库位`,
}}
scroll={{ x: 'max-content', y: 'calc(100vh - 420px)' }}
/>
</Spin>
<Text type="secondary" className="picking-bin-footer">
当前筛选出 {filteredRows.length} 个库位
{items.length !== filteredRows.length ? `(共 ${items.length} 个)` : ''};
已选类型:{(data?.binTypes ?? []).join('、') || '—'}。
</Text>
<Modal
title="计算口径说明"
open={helpOpen}
onCancel={() => setHelpOpen(false)}
footer={null}
width={760}
>
<div className="picking-bin-help">
{PICKING_BIN_DESCRIPTION.map((line, i) => (
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
))}
</div>
</Modal>
</SycmPage>
);
};
export default PickingBinSalesAnalysis;

View File

@ -0,0 +1,93 @@
.picking-bin-page {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.picking-bin-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.picking-bin-header__title {
display: flex;
align-items: center;
gap: 4px;
}
.picking-bin-header__help {
font-size: 13px;
}
.picking-bin-header__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.55);
}
.picking-bin-th-tip {
border-bottom: 1px dashed rgba(0, 0, 0, 0.35);
cursor: help;
white-space: nowrap;
}
.picking-bin-summary {
margin-bottom: 12px;
}
.picking-bin-card .ant-statistic-title {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picking-bin-card.is-active {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
}
.picking-bin-card.is-total {
background: rgba(22, 119, 255, 0.06);
}
.picking-bin-card__meta {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.55);
white-space: nowrap;
}
.picking-bin-table .ant-table-thead > tr > th {
white-space: nowrap;
font-size: 12px;
padding: 6px 8px;
}
.picking-bin-table .ant-table-tbody > tr > td {
padding: 5px 8px;
font-size: 13px;
vertical-align: middle;
}
.picking-bin-footer {
font-size: 12px;
}
.picking-bin-help p {
margin: 0 0 6px;
line-height: 1.7;
font-size: 13px;
white-space: pre-wrap;
}
.picking-bin-help p.is-indent {
padding-left: 16px;
color: rgba(0, 0, 0, 0.65);
}

View File

@ -21,6 +21,7 @@ const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
const MkhBomData = lazy(() => import('../pages/MkhBomData')); const MkhBomData = lazy(() => import('../pages/MkhBomData'));
const DailyInventory = lazy(() => import('../pages/DailyInventory')); const DailyInventory = lazy(() => import('../pages/DailyInventory'));
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis')); const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor')); const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
const lazyLoadingElement = <PageLoadingFallback />; const lazyLoadingElement = <PageLoadingFallback />;
@ -122,6 +123,10 @@ const router = createBrowserRouter([
path: 'planning/operation/stock-sales-analysis', path: 'planning/operation/stock-sales-analysis',
element: renderLazyComponent(StockSalesAnalysis), element: renderLazyComponent(StockSalesAnalysis),
}, },
{
path: 'planning/operation/picking-bin-sales-analysis',
element: renderLazyComponent(PickingBinSalesAnalysis),
},
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓; // 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线) // 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
{ {