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:
youruo 2026-10-08 10:58:17 +08:00
parent 195351deae
commit 88a39975d3
7 changed files with 255 additions and 361 deletions

View File

@ -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,
}],

View File

@ -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 = '汇总';

View File

@ -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,
};

View File

@ -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.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');
});

View File

@ -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=存货 为存货位。',
];

View File

@ -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

View File

@ -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 {