feat: 动销等级改为按商品日销统计,列表与导出统一到仓位×商品粒度
- 取数统一为 fetchPickingBinSkuRows:一行 = 一个「仓位 × 商品编码」,列表与导出共用 商品日销 = 商品窗口整档销量 ÷ 天数(不做仓位分摊);同一商品占几个仓位都是同一等级 另输出分摊销量 = 商品销量 ÷ 占用同类型仓位数,供按行求和 - 列表页同步拆到商品粒度:新增商品编码列,去掉 SKU数列,搜索支持商品编码 - 汇总卡片主数值改为去重商品数,另显示明细行数;销量按去重商品口径统计 - 导出新增「分摊销量」「商品日销」列,表头口径标注更明确 - 性能:仓位类型选满时不拼 IN 过滤(避免参数嗅探走坏计划),全类型查询由超时降到约 30 秒 验证(基准日 2026-10-08、30 天、拣选位): - 明细 11871 行 / 去重商品 6821 个 / 库存合计 499141(与旧口径一致) - 跨多仓位的 2028 个商品,销量与等级不一致 0 个 - 导出行求和:分摊销量合计 96847 = 去重商品销量合计 96847(差异 -0.0017,浮点舍入) - 单测 9/9 通过;语法检查 182 文件通过;前端 eslint 无报错、vite build 成功
This commit is contained in:
parent
195351deae
commit
88a39975d3
@ -9,7 +9,7 @@ const {
|
||||
BIN_TYPES,
|
||||
PICKING_BIN_DEFAULT_TYPES,
|
||||
} = require('../config/pickingBinSalesAnalysis');
|
||||
const { fetchPickingBinRows, fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis');
|
||||
const { fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
@ -87,9 +87,15 @@ function resolveWarehouses(raw) {
|
||||
|
||||
const GRADE_MAP = new Map(PICKING_BIN_GRADE_RULES.map((g) => [g.key, g]));
|
||||
|
||||
/** 明细行按「仓库 × 商品编码」去重后的商品个数 */
|
||||
function dedupeCount(list) {
|
||||
return new Set(list.map((it) => `${it.wmsCoId}::${it.skuId}`)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/picking-bin-sales-analysis
|
||||
* 拣选库位动销分析:仓库 × 仓位 → 类型 / 库存数量 / SKU数 / 近N天销量 / 日销 / 动销等级
|
||||
* 拣选库位动销分析:仓库 × 仓位 × 商品编码 → 库存数量 / 商品销量 / 商品日销 / 动销等级
|
||||
* 动销等级按「商品日销」判定(商品整档销量 ÷ 天数),不做仓位分摊。
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
@ -117,41 +123,65 @@ router.get('/', async (req, res) => {
|
||||
|
||||
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
|
||||
const [rows, takenAt] = await Promise.all([
|
||||
fetchPickingBinRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes }),
|
||||
fetchPickingBinSkuRows(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,
|
||||
wmsCoId: r.wmsCoId,
|
||||
binName: r.binName,
|
||||
binType: r.binType,
|
||||
skuId: r.skuId,
|
||||
warehouse: whMap.get(r.wmsCoId) || r.wmsCoId,
|
||||
stockQty: r.stockQty,
|
||||
skuCount: r.skuCount,
|
||||
salesQty: round2(r.salesQty),
|
||||
dailySales: round4(r.dailySales),
|
||||
gradeKey: r.gradeKey,
|
||||
gradeLabel: GRADE_MAP.get(r.gradeKey)?.label || '',
|
||||
}));
|
||||
|
||||
// 各动销等级汇总
|
||||
// 每个「仓库 × 商品」只计一次销量,避免同一商品跨多个仓位被重复累加
|
||||
const skuSalesMap = new Map();
|
||||
rows.forEach((r) => {
|
||||
const key = `${r.wmsCoId}::${r.skuId}`;
|
||||
if (!skuSalesMap.has(key)) skuSalesMap.set(key, r.salesQty);
|
||||
});
|
||||
const dedupeSales = (list) => {
|
||||
const seen = new Set();
|
||||
return list.reduce((acc, it) => {
|
||||
const key = `${it.wmsCoId}::${it.skuId}`;
|
||||
if (seen.has(key)) return acc;
|
||||
seen.add(key);
|
||||
return acc + (skuSalesMap.get(key) || 0);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 各动销等级汇总:商品数按「仓库 × 商品」去重,库存按明细行求和(无重复)
|
||||
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));
|
||||
const salesQty = round2(dedupeSales(hit));
|
||||
return {
|
||||
key: grade.key,
|
||||
label: grade.label,
|
||||
tip: grade.tip,
|
||||
binCount: hit.length,
|
||||
rowCount: hit.length,
|
||||
skuCount: dedupeCount(hit),
|
||||
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 dedupeAll = new Set(items.map((it) => `${it.wmsCoId}::${it.skuId}`));
|
||||
const totalSales = round2(dedupeSales(items));
|
||||
const total = {
|
||||
key: '__total__',
|
||||
label: PICKING_BIN_TOTAL_LABEL,
|
||||
binCount: summary.reduce((acc, s) => acc + s.binCount, 0),
|
||||
rowCount: items.length,
|
||||
skuCount: dedupeAll.size,
|
||||
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),
|
||||
};
|
||||
@ -228,6 +258,7 @@ router.get('/export', async (req, res) => {
|
||||
r.stockQty,
|
||||
r.skuCount,
|
||||
round2(r.salesQty),
|
||||
round4(r.allocatedSalesQty),
|
||||
round4(r.dailySales),
|
||||
GRADE_MAP.get(r.gradeKey)?.label || r.gradeKey,
|
||||
days,
|
||||
@ -241,8 +272,9 @@ router.get('/export', async (req, res) => {
|
||||
sheets: [{
|
||||
name: '库位动销明细',
|
||||
header: [
|
||||
'仓库', '仓位', '类型', '商品编码', '库存数量', 'SKU数',
|
||||
`销量(近${days}天)`, '日销', '动销等级', '统计天数', '统计基准日',
|
||||
'仓库', '仓位', '类型', '商品编码', '库存数量', '仓位SKU数',
|
||||
`商品销量(近${days}天)`, `分摊销量(近${days}天)`, '商品日销',
|
||||
'动销等级', '统计天数', '统计基准日',
|
||||
],
|
||||
rows: sheetRows,
|
||||
}],
|
||||
|
||||
@ -5,19 +5,25 @@
|
||||
* 二级菜单:拣选库位动销分析
|
||||
*
|
||||
* 表头(需求表原列为 所在仓库 | 仓位 | 数量 | 类型 | 动销等级,这里做了扩充与优化):
|
||||
* 仓库 | 仓位 | 类型 | 库存数量 | SKU数 | 近N天销量 | 日销 | 动销等级
|
||||
* 仓库 | 仓位 | 类型 | 商品编码 | 库存数量 | 近N天销量 | 日销 | 动销等级
|
||||
*
|
||||
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用。
|
||||
*
|
||||
* 口径:
|
||||
* 库存数量:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty
|
||||
* 库存数量:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_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 的销量之和。
|
||||
* 【为什么不分摊来定等级】同一 SKU 常常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个
|
||||
* 拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。若把销量摊到仓位再定等级,
|
||||
* 一件爆款商品放在 10 个仓位就会被算成日销 1/10 的滞销品 —— 判断的是「摆法」而不是「商品」。
|
||||
* 因此等级一律以商品自身的销售速度为准。
|
||||
*
|
||||
* 【分摊销量列】导出仍附带「分摊销量」= 商品销量 ÷ 其占用的同类型仓位数,
|
||||
* 供需要按行求和的场景使用:恒等式(已实测校验,差异 0)
|
||||
* 全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。
|
||||
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
|
||||
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
|
||||
*/
|
||||
@ -37,17 +43,18 @@ 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 均无销量(仍占用库存)' },
|
||||
{ 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: '统计窗口内该商品无销量(仍占用库存)' },
|
||||
];
|
||||
|
||||
const PICKING_BIN_TOTAL_LABEL = '汇总';
|
||||
|
||||
@ -3,15 +3,23 @@
|
||||
/**
|
||||
* 拣选库位动销分析 —— 业务库取数
|
||||
*
|
||||
* 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty(仓位为空的行不参与)
|
||||
* 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty(仓位为空的行不参与)
|
||||
* 类型:bin_group 为空 → 拣选位;否则取 bin_group(业务库目前只有「存货」)
|
||||
* 销量:rpt.jst_Orders × rpt.jst_OrderItem,按 send_date,排除 Delete/Cancelled
|
||||
* 分摊:SKU 销量 ÷ 该 SKU 占用的同类型仓位数,再按库位累加
|
||||
* 保证「全部库位销量之和 = 全仓销量」
|
||||
* 日销:库位销量 / 窗口天数 → 分档得到动销等级
|
||||
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用
|
||||
*
|
||||
* 【日销与动销等级口径】按「商品日销」判定,不做仓位分摊:
|
||||
* 商品日销 = 该商品在窗口内的整档销量 ÷ 窗口天数
|
||||
* 同一件商品无论占用几个仓位,各行显示的销量 / 日销 / 动销等级完全一致 ——
|
||||
* 不会因为货放得散就被算成滞销。
|
||||
*
|
||||
* 附带输出「分摊销量」= 商品销量 ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景:
|
||||
* 恒等式(已实测校验,差异 0):全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。
|
||||
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
|
||||
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
|
||||
*/
|
||||
|
||||
const { PICKING_BIN_GRADE_RULES } = require('../config/pickingBinSalesAnalysis');
|
||||
const { PICKING_BIN_GRADE_RULES, BIN_TYPES } = require('../config/pickingBinSalesAnalysis');
|
||||
|
||||
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
|
||||
function buildInClause(values, prefix) {
|
||||
@ -28,8 +36,9 @@ function buildInClause(values, prefix) {
|
||||
|
||||
/**
|
||||
* 日销分档表达式:从高档往低档依次命中,最后一档「销售=0」由 salesQty <= 0 兜底。
|
||||
* 传入的 salesExpr 是窗口销量(商品整档销量),除以天数得到日销。
|
||||
*/
|
||||
function buildGradeCase(salesExpr = 'ISNULL(x.salesQty, 0)', daysParam = '@days') {
|
||||
function buildGradeCase(salesExpr = 'ISNULL(s.salesQty, 0)', daysParam = '@days') {
|
||||
const branches = PICKING_BIN_GRADE_RULES
|
||||
.filter((rule) => rule.lower > 0)
|
||||
.map((rule) => {
|
||||
@ -49,146 +58,20 @@ function buildGradeCase(salesExpr = 'ISNULL(x.salesQty, 0)', daysParam = '@days'
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取各仓库每个仓位的库存、销量、日销与动销等级。
|
||||
* 拉取「仓位 × 商品编码」粒度明细(列表与导出共用)。
|
||||
*
|
||||
* @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',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取「仓位 × 商品编码」粒度明细(导出用)。
|
||||
*
|
||||
* 与 fetchPickingBinRows 的差别:每个仓位有 N 个 SKU 就输出 N 行。
|
||||
* - 库存数量:该 SKU 在这个仓位的实际库存
|
||||
* - 销量:该 SKU 的销量 ÷ 它占用的同类型仓位数(即这个组合对仓位的贡献),
|
||||
* 同一 SKU 出现在多个仓位时各行数值相同;全部行求和仍等于「有库位 SKU 销量之和」
|
||||
* - skuCount / binSalesQty / gradeKey:仍是仓位粒度,同一仓位的多行重复显示
|
||||
* - stockQty:该商品在此仓位的实际库存
|
||||
* - salesQty:该商品在此仓库的窗口销量(整档,不按仓位分摊),动销等级的判定依据
|
||||
* - dailySales:salesQty / 窗口天数 = 商品日销
|
||||
* - allocatedSalesQty:salesQty ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景
|
||||
* - skuCount:该仓位内存放的商品数(仓位粒度,同一仓位多行重复)
|
||||
*
|
||||
* @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, skuId:string,
|
||||
* stockQty:number, skuCount:number, salesQty:number, dailySales:number,
|
||||
* binSalesQty:number, gradeKey:string}>>}
|
||||
* stockQty:number, skuCount:number, salesQty:number, allocatedSalesQty:number,
|
||||
* dailySales:number, gradeKey:string}>>}
|
||||
*/
|
||||
async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
||||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||||
@ -201,12 +84,14 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
||||
const binIn = buildInClause(wmsCoIds, 'w');
|
||||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||||
|
||||
// 仓位类型过滤(不传 = 全部类型;选满全部类型时也不过滤,避免参数嗅探把查询计划带偏)
|
||||
let typeFilter = '';
|
||||
const typeParams = {};
|
||||
if (Array.isArray(binTypes) && binTypes.length > 0) {
|
||||
const wantAllTypes = BIN_TYPES.every((t) => (binTypes || []).includes(t));
|
||||
if (Array.isArray(binTypes) && binTypes.length > 0 && !wantAllTypes) {
|
||||
const t = buildInClause(binTypes, 't');
|
||||
Object.assign(typeParams, t.params);
|
||||
typeFilter = `AND a.binType IN (${t.clause})`;
|
||||
typeFilter = `AND b.binType IN (${t.clause})`;
|
||||
}
|
||||
|
||||
const sqlText = `
|
||||
@ -244,59 +129,36 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
||||
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
|
||||
),
|
||||
skuRows AS (
|
||||
)
|
||||
SELECT b.wmsCoId,
|
||||
b.binName,
|
||||
b.binType,
|
||||
b.skuId,
|
||||
SUM(CAST(b.binSkuQty AS DECIMAL(18, 2))) AS stockQty,
|
||||
SUM(CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 4)) / NULLIF(sp.binCount, 0)) AS salesQty
|
||||
CAST(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2))) AS DECIMAL(18, 2)) AS stockQty,
|
||||
CAST(a.skuCount AS INT) AS skuCount,
|
||||
CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
|
||||
CAST(ISNULL(s.salesQty, 0) / NULLIF(sp.binCount, 0) AS DECIMAL(18, 4)) AS allocatedSalesQty,
|
||||
CAST(ISNULL(s.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
|
||||
${buildGradeCase('ISNULL(s.salesQty, 0)')} AS gradeKey
|
||||
FROM bin b
|
||||
JOIN binSpan sp
|
||||
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
|
||||
JOIN binAgg a
|
||||
ON a.wmsCoId = b.wmsCoId AND a.binName = b.binName AND a.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, b.skuId
|
||||
)
|
||||
SELECT sk.wmsCoId,
|
||||
sk.binName,
|
||||
sk.binType,
|
||||
sk.skuId,
|
||||
CAST(sk.stockQty AS DECIMAL(18, 2)) AS stockQty,
|
||||
CAST(a.skuCount AS INT) AS skuCount,
|
||||
CAST(ISNULL(sk.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
|
||||
CAST(ISNULL(sk.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
|
||||
CAST(ISNULL(x.salesQty, 0) AS DECIMAL(18, 2)) AS binSalesQty,
|
||||
${buildGradeCase('ISNULL(x.salesQty, 0)')} AS gradeKey
|
||||
FROM skuRows sk
|
||||
JOIN binAgg a
|
||||
ON a.wmsCoId = sk.wmsCoId AND a.binName = sk.binName AND a.binType = sk.binType
|
||||
LEFT JOIN binSales x
|
||||
ON x.wmsCoId = sk.wmsCoId AND x.binName = sk.binName AND x.binType = sk.binType
|
||||
WHERE ABS(ISNULL(sk.stockQty, 0)) > 0.0001
|
||||
WHERE 1 = 1
|
||||
${typeFilter}
|
||||
ORDER BY sk.wmsCoId, sk.binType, sk.binName, sk.skuId`;
|
||||
GROUP BY b.wmsCoId, b.binName, b.binType, b.skuId, a.skuCount, s.salesQty, sp.binCount
|
||||
HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001
|
||||
ORDER BY b.wmsCoId, b.binType, b.binName, b.skuId`;
|
||||
|
||||
const rows = await cloudDb.query(
|
||||
sqlText,
|
||||
@ -319,8 +181,8 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
||||
stockQty: Number(r.stockQty) || 0,
|
||||
skuCount: Number(r.skuCount) || 0,
|
||||
salesQty: Number(r.salesQty) || 0,
|
||||
allocatedSalesQty: Number(r.allocatedSalesQty) || 0,
|
||||
dailySales: Number(r.dailySales) || 0,
|
||||
binSalesQty: Number(r.binSalesQty) || 0,
|
||||
gradeKey: r.gradeKey || 'zero',
|
||||
}));
|
||||
}
|
||||
@ -328,6 +190,5 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
||||
module.exports = {
|
||||
buildGradeCase,
|
||||
buildInClause,
|
||||
fetchPickingBinRows,
|
||||
fetchPickingBinSkuRows,
|
||||
};
|
||||
|
||||
@ -6,7 +6,6 @@ const assert = require('node:assert/strict');
|
||||
const {
|
||||
buildGradeCase,
|
||||
buildInClause,
|
||||
fetchPickingBinRows,
|
||||
fetchPickingBinSkuRows,
|
||||
} = require('../src/services/pickingBinSalesAnalysis');
|
||||
const {
|
||||
@ -32,6 +31,12 @@ test('buildGradeCase 覆盖全部正向等级,并以销售=0 兜底', () => {
|
||||
assert.ok(expr.includes("ELSE 'zero'"));
|
||||
});
|
||||
|
||||
test('等级按商品销量判定(默认表达式取 s.salesQty,不含仓位汇总列)', () => {
|
||||
const expr = buildGradeCase();
|
||||
assert.ok(expr.includes('ISNULL(s.salesQty, 0) / @days'));
|
||||
assert.ok(!expr.includes('x.salesQty'));
|
||||
});
|
||||
|
||||
test('默认窗口为 30 天,默认只看拣选位', () => {
|
||||
assert.equal(PICKING_BIN_DEFAULT_DAYS, 30);
|
||||
assert.ok(PICKING_BIN_WINDOW_OPTIONS.includes(30));
|
||||
@ -39,88 +44,6 @@ test('默认窗口为 30 天,默认只看拣选位', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchPickingBinSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
|
||||
@ -128,14 +51,16 @@ test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 缺失必要窗口参数时返回空数组', async () => {
|
||||
const db = { query: async () => [{ skuId: 'S1' }] };
|
||||
test('fetchPickingBinSkuRows 缺失必要窗口参数时返回空数组,不打库', async () => {
|
||||
let called = 0;
|
||||
const db = { query: async () => { called += 1; return [{ skuId: 'S1' }]; } };
|
||||
assert.deepEqual(await fetchPickingBinSkuRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
|
||||
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: null, shipTo: 'b', days: 30 }), []);
|
||||
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 0 }), []);
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 按仓位×商品编码输出并保留仓位口径字段', async () => {
|
||||
test('fetchPickingBinSkuRows 按仓位×商品输出,等级与日销来自商品整档销量', async () => {
|
||||
let captured = null;
|
||||
const db = {
|
||||
query: async (sqlText, params) => {
|
||||
@ -143,29 +68,82 @@ test('fetchPickingBinSkuRows 按仓位×商品编码输出并保留仓位口径
|
||||
return [
|
||||
{
|
||||
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU1',
|
||||
stockQty: 12, skuCount: 2, salesQty: 30, dailySales: 1,
|
||||
binSalesQty: 45, gradeKey: 'slow1',
|
||||
stockQty: 12, skuCount: 2, salesQty: 600, allocatedSalesQty: 300,
|
||||
dailySales: 20, gradeKey: 'hot',
|
||||
},
|
||||
{
|
||||
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU2',
|
||||
stockQty: 8, skuCount: 2, salesQty: 15, dailySales: 0.5,
|
||||
binSalesQty: 45, gradeKey: 'slow1',
|
||||
stockQty: 8, skuCount: 2, salesQty: 45, allocatedSalesQty: 22.5,
|
||||
dailySales: 1.5, gradeKey: 'slow1',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
const rows = await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 30 });
|
||||
|
||||
const rows = await fetchPickingBinSkuRows(db, [101, 102], {
|
||||
shipFrom: '2026-09-08 01:00:00',
|
||||
shipTo: '2026-10-08 01:00:00',
|
||||
days: 30,
|
||||
binTypes: ['拣选'],
|
||||
});
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows.map((r) => r.skuId), ['SKU1', 'SKU2']);
|
||||
// 一个仓位两个 SKU 拆成两行,库存为各商品在该仓位的实际库存
|
||||
// 同一仓位两个商品拆两行,库存是各商品在该仓位的实际库存
|
||||
assert.deepEqual(rows.map((r) => r.stockQty), [12, 8]);
|
||||
// 动销等级仍属仓位,同一仓位多行相同
|
||||
assert.equal(rows[0].gradeKey, 'slow1');
|
||||
assert.equal(rows[1].gradeKey, 'slow1');
|
||||
assert.equal(rows[0].binSalesQty, 45);
|
||||
assert.equal(rows[0].skuCount, 2);
|
||||
// SQL 含商品编码列与倍数分摊
|
||||
assert.ok(captured.sqlText.includes('b.skuId'));
|
||||
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
|
||||
assert.equal(captured.params.days, 30);
|
||||
// 等级按商品自身日销,同一仓位的多行可以不同
|
||||
assert.deepEqual(rows.map((r) => r.gradeKey), ['hot', 'slow1']);
|
||||
// 分摊销量 = 商品销量 ÷ 占用的同类型仓位数(用于按行求和)
|
||||
assert.deepEqual(rows.map((r) => r.allocatedSalesQty), [300, 22.5]);
|
||||
assert.deepEqual(rows[0], {
|
||||
wmsCoId: '101',
|
||||
binName: 'XA01-01-01',
|
||||
binType: '拣选',
|
||||
skuId: 'SKU1',
|
||||
stockQty: 12,
|
||||
skuCount: 2,
|
||||
salesQty: 600,
|
||||
allocatedSalesQty: 300,
|
||||
dailySales: 20,
|
||||
gradeKey: 'hot',
|
||||
});
|
||||
|
||||
// 参数必须带上两个仓库、发货窗口与天数,以及仓位类型过滤
|
||||
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-08 01:00:00');
|
||||
assert.equal(captured.params.shipTo, '2026-10-08 01:00:00');
|
||||
assert.equal(captured.params.days, 30);
|
||||
assert.equal(captured.params.t0, '拣选');
|
||||
|
||||
// 商品销量按占用仓位数分摊(供求和),但等级依据的是未分摊的商品销量
|
||||
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
|
||||
assert.ok(captured.sqlText.includes('ISNULL(s.salesQty, 0) / @days AS DECIMAL(18, 4)'));
|
||||
// 仓位类型:bin_group 为空视为拣选位;只统计有库存的行
|
||||
assert.ok(captured.sqlText.includes("THEN N'拣选'"));
|
||||
assert.ok(captured.sqlText.includes('ISNULL(p.qty, 0) <> 0'));
|
||||
assert.ok(captured.sqlText.includes('HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001'));
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 不传类型时不过滤 binType', async () => {
|
||||
let captured = null;
|
||||
const db = {
|
||||
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
|
||||
};
|
||||
await fetchPickingBinSkuRows(db, [101], {
|
||||
shipFrom: 'a', shipTo: 'b', days: 15, binTypes: [],
|
||||
});
|
||||
assert.ok(!captured.sqlText.includes('b.binType IN'));
|
||||
assert.equal(captured.params.t0, undefined);
|
||||
});
|
||||
|
||||
test('gradeKey 缺失时兜底为 zero', async () => {
|
||||
const db = { query: async () => [{ wmsCoId: 101, binName: 'X', binType: '拣选', skuId: 'S1', stockQty: 5 }] };
|
||||
const rows = await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
|
||||
assert.equal(rows[0].gradeKey, 'zero');
|
||||
assert.equal(rows[0].salesQty, 0);
|
||||
assert.equal(rows[0].allocatedSalesQty, 0);
|
||||
assert.equal(rows[0].dailySales, 0);
|
||||
assert.equal(rows[0].skuId, 'S1');
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/** 拣选库位动销分析 —— 表头与口径说明 */
|
||||
/** 拣选库位动销分析 —— 表头与口径说明(粒度:仓位 × 商品编码) */
|
||||
|
||||
export const PICKING_BIN_COLUMNS = [
|
||||
{
|
||||
@ -9,7 +9,7 @@ export const PICKING_BIN_COLUMNS = [
|
||||
{
|
||||
key: 'binName',
|
||||
label: '仓位',
|
||||
tip: '系统仓位编码(jst_packInventoryInfo.bin)',
|
||||
tip: '系统仓位编码(jst_packInventoryInfo.bin);同一仓位有多个商品时会出现多行',
|
||||
},
|
||||
{
|
||||
key: 'binType',
|
||||
@ -17,29 +17,29 @@ export const PICKING_BIN_COLUMNS = [
|
||||
tip: '拣选 = 拣货位(bin_group 为空);存货 = 存货位(bin_group=存货)',
|
||||
},
|
||||
{
|
||||
key: 'stockQty',
|
||||
label: '库存数量',
|
||||
tip: '该仓位当前库存件数(jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty)',
|
||||
key: 'skuId',
|
||||
label: '商品编码',
|
||||
tip: '商品编码(jst_packInventoryInfo.sku_id),一行就是一个「仓位 × 商品」组合',
|
||||
},
|
||||
{
|
||||
key: 'skuCount',
|
||||
label: 'SKU数',
|
||||
tip: '该仓位内存放且有库存的 SKU 个数',
|
||||
key: 'stockQty',
|
||||
label: '库存数量',
|
||||
tip: '该商品在这个仓位上的当前库存件数(同一仓位多行的库存相加 = 仓位库存)',
|
||||
},
|
||||
{
|
||||
key: 'salesQty',
|
||||
label: '销量',
|
||||
tip: '近 N 天该仓位的分摊销量:Σ(仓位内各 SKU 销量 ÷ 该 SKU 占用的同类型仓位数)',
|
||||
label: '商品销量',
|
||||
tip: '近 N 天该商品的销量(整档,不按仓位分摊):同一商品占几个仓位,这几行显示的销量都一样,所以这一列不能直接按行求和',
|
||||
},
|
||||
{
|
||||
key: 'dailySales',
|
||||
label: '日销',
|
||||
tip: '分摊销量 ÷ 统计天数,动销等级按此值划分',
|
||||
label: '商品日销',
|
||||
tip: '商品销量 ÷ 统计天数,动销等级严格按此值划分',
|
||||
},
|
||||
{
|
||||
key: 'gradeKey',
|
||||
label: '动销等级',
|
||||
tip: '爆款≥20 / 热卖款 10~20 / 平销款 3~10 / 滞销款-1 1~3 / 滞销款-2 0~1 / 销售=0',
|
||||
tip: '按商品日销划分:爆款≥20 / 热卖款 10~20 / 平销款 3~10 / 滞销款-1 1~3 / 滞销款-2 0~1 / 销售=0',
|
||||
},
|
||||
];
|
||||
|
||||
@ -70,19 +70,26 @@ export const PICKING_BIN_GRADE_COLORS = {
|
||||
};
|
||||
|
||||
export const PICKING_BIN_DESCRIPTION = [
|
||||
'动销等级口径:按「单个仓位的日均销量(日销)」分档,日销 = 该仓位近 N 天销量 ÷ N。',
|
||||
'【粒度】一行 = 一个「仓位 × 商品编码」组合。一个仓位放了 N 个商品就显示 N 行,列表与导出一致。',
|
||||
' 库存数量 = 该商品在这个仓位上的实际库存(同一仓位多行相加 = 仓位总库存)。',
|
||||
'',
|
||||
'【动销等级口径】按「商品日销」分档:商品日销 = 该商品近 N 天销量 ÷ N。',
|
||||
' 爆款:日销 ≥ 20;热卖款:10 ≤ 日销 < 20;平销款:3 ≤ 日销 < 10;',
|
||||
' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内该仓位所有 SKU 均无销量。',
|
||||
' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内该商品无销量。',
|
||||
' 等级判断的是商品本身的销售速度,与其摆放位置无关 —— 同一件商品占几个仓位,等级都相同。',
|
||||
'默认统计窗口为近 30 天,可切换 7 / 15 / 30 / 60 天。',
|
||||
'',
|
||||
'【销量为什么要分摊】',
|
||||
'【为什么不用分摊销量来定等级】',
|
||||
' 同一 SKU 常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。',
|
||||
' 若直接把 SKU 销量累加到每个仓位,销量会被放大数倍。',
|
||||
' 因此按「SKU 销量在其占用的同类型仓位数之间平均分摊」计算库位销量,',
|
||||
' 恒等式已实测校验:全部库位销量之和 = 有库位记录的那些 SKU 的销量之和(差异 0)。',
|
||||
' 注意该合计小于全仓销量:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,无仓位记录的 SKU 销量不计入任何库位。',
|
||||
' 若把销量先摊到每个仓位再定等级,一件日销 100 的爆款放在 10 个仓位就会被算成日销 10 —— 判断的是「摆法」而不是商品。',
|
||||
'',
|
||||
'数据来源:库存取 rpt.jst_packInventoryInfo(按仓库 + 仓位汇总);',
|
||||
'【商品销量为什么不能直接按行求和】',
|
||||
' 商品销量取的是整档销量(不除以占用仓位数),同一件商品跨仓位会重复出现,所以该列相加无意义。',
|
||||
' 需要按行求和请用导出文件里的「分摊销量」列:商品销量 ÷ 其占用的同类型仓位数。',
|
||||
' 恒等式已实测校验(差异 0):全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。',
|
||||
' 注意它小于全仓销量:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,无仓位记录的 SKU 销量不计入任何库位。',
|
||||
'',
|
||||
'数据来源:库存取 rpt.jst_packInventoryInfo(按仓库 + 仓位 + 商品汇总);',
|
||||
'销量取 rpt.jst_Orders × rpt.jst_OrderItem(按 send_date,排除 Delete/Cancelled),与「每日进销存」发货口径一致。',
|
||||
'仓位类型:bin_group 为空视为拣选位,bin_group=存货 为存货位。',
|
||||
];
|
||||
|
||||
@ -85,7 +85,9 @@ const PickingBinSalesAnalysis = () => {
|
||||
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;
|
||||
if (kw
|
||||
&& !String(it.binName).toLowerCase().includes(kw)
|
||||
&& !String(it.skuId).toLowerCase().includes(kw)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [items, grades, keyword]);
|
||||
@ -94,9 +96,10 @@ const PickingBinSalesAnalysis = () => {
|
||||
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)
|
||||
align: ['warehouse', 'binName', 'binType', 'skuId', 'gradeKey'].includes(col.key) ? 'left' : 'right',
|
||||
width: col.key === 'warehouse' ? 240
|
||||
: (col.key === 'gradeKey' ? 170 : (col.key === 'skuId' ? 150 : 110)),
|
||||
sorter: ['stockQty', 'salesQty', 'dailySales'].includes(col.key)
|
||||
? (a, b) => (a[col.key] ?? 0) - (b[col.key] ?? 0)
|
||||
: undefined,
|
||||
render: (value) => {
|
||||
@ -104,10 +107,10 @@ const PickingBinSalesAnalysis = () => {
|
||||
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 === 'binType' || col.key === 'skuId') 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);
|
||||
if (col.key === 'stockQty') return int(value);
|
||||
return value || '—';
|
||||
},
|
||||
})), [gradeMap]);
|
||||
@ -195,14 +198,14 @@ const PickingBinSalesAnalysis = () => {
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索仓位"
|
||||
style={{ width: 160 }}
|
||||
placeholder="搜索仓位 / 商品编码"
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<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数 / 销量 / 日销 / 动销等级)。按「仓位 × 商品编码」拆行:同一仓位有多个商品时拆成多行,库存数量为该商品在此仓位的实际库存;销量为该商品按其占用仓位数分摊后的贡献,动销等级仍属仓位(同一仓位多行相同)">
|
||||
<Tooltip title="导出当前筛选下的明细(仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / 仓位SKU数 / 商品销量 / 分摊销量 / 商品日销 / 动销等级)。按「仓位 × 商品编码」拆行;商品销量为整档销量(同一商品跨仓位重复,不可直接求和),需要求和请用「分摊销量」列;动销等级按商品日销判定">
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出明细</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
@ -211,8 +214,8 @@ const PickingBinSalesAnalysis = () => {
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`按「单个仓位的日均销量」把库位分成 ${summary.length} 个动销等级,全部数值来自业务库实时取数`}
|
||||
description="库存取仓位库存表,销量取销售出库单并在同类仓位间平均分摊(避免同一 SKU 占用多个仓位导致销量被放大)。点击下方等级卡片可筛选。"
|
||||
message={`按「商品日均销量(商品日销)」划分 ${summary.length} 个动销等级,一行 = 一个「仓位 × 商品编码」组合`}
|
||||
description="商品日销 = 该商品近 N 天销量 ÷ N,不做仓位分摊,因此同一件商品放在几个仓位都是同一等级。商品销量跨仓位重复显示,不能直接按行求和(需要求和请导出用「分摊销量」列)。点击下方等级卡片可筛选。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
@ -228,14 +231,16 @@ const PickingBinSalesAnalysis = () => {
|
||||
onClick={() => toggleGrade(s.key)}
|
||||
className={grades.includes(s.key) ? 'picking-bin-card is-active' : 'picking-bin-card'}
|
||||
>
|
||||
<Tooltip title={`${s.tip};明细 ${int(s.rowCount)} 行,库存合计 ${int(s.stockQty)} 件,日销合计 ${num(s.dailySales, 1)}(销量按去重商品计)`}>
|
||||
<Statistic
|
||||
title={s.label}
|
||||
value={s.binCount}
|
||||
suffix="位"
|
||||
value={s.skuCount}
|
||||
suffix="品"
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className="picking-bin-card__meta">
|
||||
库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)}
|
||||
明细 {int(s.rowCount)} 行 · 库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
@ -243,9 +248,11 @@ const PickingBinSalesAnalysis = () => {
|
||||
{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 }} />
|
||||
<Tooltip title={`去重商品 ${int(total.skuCount)} 个,涉及「仓位 × 商品」组合 ${int(total.rowCount)} 行`}>
|
||||
<Statistic title={total.label} value={total.skuCount} suffix="品" valueStyle={{ fontSize: 18 }} />
|
||||
</Tooltip>
|
||||
<div className="picking-bin-card__meta">
|
||||
库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)}
|
||||
明细 {int(total.rowCount)} 行 · 库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
@ -256,23 +263,23 @@ const PickingBinSalesAnalysis = () => {
|
||||
className="picking-bin-table"
|
||||
columns={columns}
|
||||
dataSource={filteredRows}
|
||||
rowKey={(r) => `${r.wmsCoId}::${r.binType}::${r.binName}`}
|
||||
rowKey={(r) => `${r.wmsCoId}::${r.binType}::${r.binName}::${r.skuId}`}
|
||||
size="small"
|
||||
bordered
|
||||
pagination={{
|
||||
pageSize: 50,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [50, 100, 200],
|
||||
showTotal: (t) => `共 ${t} 个库位`,
|
||||
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('、') || '—'}。
|
||||
当前筛选出 {filteredRows.length} 条明细
|
||||
{items.length !== filteredRows.length ? `(共 ${items.length} 条)` : ''};
|
||||
去重商品 {int(data?.total?.skuCount ?? 0)} 个;已选类型:{(data?.binTypes ?? []).join('、') || '—'}。
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
|
||||
@ -62,6 +62,8 @@
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.picking-bin-table .ant-table-thead > tr > th {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user