Merge pull request 'feat: 新增二级页面「拣选库位动销分析」(大货库存监控)' (#56) from codex/picking-bin-sales-analysis-1007 into main
This commit is contained in:
commit
7505a04e37
263
backend/src/api/pickingBinSalesAnalysis.js
Normal file
263
backend/src/api/pickingBinSalesAnalysis.js
Normal 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;
|
||||
66
backend/src/config/pickingBinSalesAnalysis.js
Normal file
66
backend/src/config/pickingBinSalesAnalysis.js
Normal 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,
|
||||
};
|
||||
@ -45,6 +45,11 @@ const SIDEBAR_PAGES = [
|
||||
pageName: '大货库存监控 · 库存动销分析',
|
||||
path: '/planning/operation/stock-sales-analysis',
|
||||
}),
|
||||
buildPlanningPage({
|
||||
pageKey: 'picking_bin_sales_analysis',
|
||||
pageName: '大货库存监控 · 拣选库位动销分析',
|
||||
path: '/planning/operation/picking-bin-sales-analysis',
|
||||
}),
|
||||
{
|
||||
pageKey: 'settings',
|
||||
pageName: '系统设置',
|
||||
|
||||
@ -38,6 +38,7 @@ const mkhBomRoutes = require('./api/mkhBom');
|
||||
const dailyInventoryRoutes = require('./api/dailyInventory');
|
||||
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
|
||||
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
|
||||
const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis');
|
||||
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
|
||||
const returnMonitorRoutes = require('./api/returnMonitor');
|
||||
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-notification', authMiddleware, dailyInventoryNotificationRoutes);
|
||||
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-monitor', authMiddleware, returnMonitorRoutes);
|
||||
|
||||
181
backend/src/services/pickingBinSalesAnalysis.js
Normal file
181
backend/src/services/pickingBinSalesAnalysis.js
Normal 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,
|
||||
};
|
||||
119
backend/test/pickingBinSalesAnalysis.test.js
Normal file
119
backend/test/pickingBinSalesAnalysis.test.js
Normal 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);
|
||||
});
|
||||
16
frontend/src/api/pickingBinSalesAnalysis.js
Normal file
16
frontend/src/api/pickingBinSalesAnalysis.js
Normal 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 }
|
||||
);
|
||||
@ -41,6 +41,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
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 },
|
||||
{ key: 'picking-bin-sales-analysis', label: '拣选库位动销分析', icon: 'inbox', routeKey: 'picking-bin-sales-analysis', visible: true, order: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
87
frontend/src/constants/pickingBinSalesAnalysis.js
Normal file
87
frontend/src/constants/pickingBinSalesAnalysis.js
Normal 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=存货 为存货位。',
|
||||
];
|
||||
@ -6,6 +6,7 @@ export const SIDEBAR_PAGES = [
|
||||
{ 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: '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_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 },
|
||||
|
||||
@ -121,6 +121,7 @@ const SIDEBAR_ROUTE_MAP = {
|
||||
},
|
||||
'daily-inventory': { path: '/planning/operation/daily-inventory' },
|
||||
'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' },
|
||||
'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' },
|
||||
settings: { path: '/settings' },
|
||||
'online-desktop-users': { path: '/settings/online-desktop-users' },
|
||||
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
|
||||
|
||||
295
frontend/src/pages/PickingBinSalesAnalysis/index.jsx
Normal file
295
frontend/src/pages/PickingBinSalesAnalysis/index.jsx
Normal 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;
|
||||
93
frontend/src/pages/PickingBinSalesAnalysis/style.css
Normal file
93
frontend/src/pages/PickingBinSalesAnalysis/style.css
Normal 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);
|
||||
}
|
||||
@ -21,6 +21,7 @@ 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 PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
|
||||
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
|
||||
|
||||
const lazyLoadingElement = <PageLoadingFallback />;
|
||||
@ -122,6 +123,10 @@ const router = createBrowserRouter([
|
||||
path: 'planning/operation/stock-sales-analysis',
|
||||
element: renderLazyComponent(StockSalesAnalysis),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/picking-bin-sales-analysis',
|
||||
element: renderLazyComponent(PickingBinSalesAnalysis),
|
||||
},
|
||||
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
|
||||
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user