Merge pull request 'feat: 拣选库位动销分析固定只统计拣选位,移除仓位类型切换' (#61) from codex/picking-bin-picking-only-1008 into main

This commit is contained in:
youruo 2026-10-08 13:10:48 +08:00
commit 214ede07a5
6 changed files with 46 additions and 102 deletions

View File

@ -6,8 +6,7 @@ const {
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_TOTAL_LABEL,
BIN_TYPES,
PICKING_BIN_DEFAULT_TYPES,
PICKING_BIN_BIN_TYPES,
} = require('../config/pickingBinSalesAnalysis');
const { fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
@ -60,18 +59,6 @@ function resolveDays(raw) {
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);
@ -101,11 +88,10 @@ 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 cacheKey = `${days}::${wmsCoIds.join('|')}`;
const latestBizDate = await fetchLatestBizDate().catch(() => null);
const cached = cacheByKey.get(cacheKey);
if (!force && cached && cached.latestBizDate === latestBizDate
@ -123,7 +109,7 @@ router.get('/', async (req, res) => {
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
const [rows, takenAt] = await Promise.all([
fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes }),
fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }),
fetchBinTakenAt().catch(() => null),
]);
@ -189,8 +175,8 @@ router.get('/', async (req, res) => {
const payload = {
days,
windowOptions: PICKING_BIN_WINDOW_OPTIONS,
binTypes,
binTypeOptions: BIN_TYPES,
binTypes: [...PICKING_BIN_BIN_TYPES],
binTypeOptions: [...PICKING_BIN_BIN_TYPES],
latestBizDate,
asOf,
shipWindow: { shipFrom, shipTo, days },
@ -217,12 +203,11 @@ router.get('/', async (req, res) => {
/**
* GET /api/picking-bin-sales-analysis/export
* 导出库位明细。query: days、binType、wmsCoId(逗号分隔)、grade(逗号分隔)、format(xlsx|csv)
* 导出库位明细(只含拣选位)。query: days、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';
@ -243,7 +228,7 @@ router.get('/export', async (req, res) => {
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
// 导出按「仓位 × 商品编码」拆行:一个仓位有 N 个商品就输出 N 行
const rows = await fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes });
const rows = await fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days });
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
const filtered = grades.length > 0
@ -253,7 +238,6 @@ router.get('/export', async (req, res) => {
const sheetRows = filtered.map((r) => [
whMap.get(r.wmsCoId) || r.wmsCoId,
r.binName,
r.binType,
r.skuId,
r.stockQty,
r.skuCount,
@ -272,14 +256,14 @@ router.get('/export', async (req, res) => {
sheets: [{
name: '库位动销明细',
header: [
'仓库', '仓位', '类型', '商品编码', '库存数量', '仓位SKU数',
'仓库', '仓位', '商品编码', '库存数量', '仓位SKU数',
`商品销量(近${days}天)`, `分摊销量(近${days}天)`, '商品日销',
'动销等级', '统计天数', '统计基准日',
],
rows: sheetRows,
}],
});
console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 类型 ${binTypes.join('/')}, 窗口 ${days} 天`);
console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 仅拣选位, 窗口 ${days} 天`);
return null;
} catch (error) {
console.error('[picking-bin-sales-analysis] 导出失败:', error.message);

View File

@ -11,7 +11,7 @@
*
* 口径:
* 库存数量:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty
* 仓位类型:bin_group 为空 → 拣选位;bin_group = '存货' → 存货位
* 统计范围:只统计拣选位(bin_group 为空);存货位(bin_group=存货)不纳入
* 销量: rpt.jst_Orders × rpt.jst_OrderItem,按 send_date,排除 Delete/Cancelled
* 日销: 商品窗口销量 / 窗口天数(整档,不按仓位分摊)
* 等级: 严格按商品日销判定,同一商品占多少个仓位都是同一等级
@ -35,12 +35,14 @@ const PICKING_BIN_LEVEL2 = '拣选库位动销分析';
const PICKING_BIN_WINDOW_OPTIONS = [7, 15, 30, 60];
const PICKING_BIN_DEFAULT_DAYS = 30;
/** 仓位类型:业务库 bin_group 为空视为拣选位 */
/**
* 仓位类型:本功能只统计拣选位(业务库 bin_group 为空即拣选位)。
* 存货位(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];
/** 固定统计范围:仅拣选位(保持数组形式,便于接口字段与后续扩展) */
const PICKING_BIN_BIN_TYPES = [BIN_TYPE_PICKING];
/**
* 动销等级(顺序即展示顺序)。判定依据为「商品日销」= 商品窗口整档销量 ÷ 天数,
@ -65,9 +67,7 @@ module.exports = {
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_DAYS,
BIN_TYPE_PICKING,
BIN_TYPE_STORAGE,
BIN_TYPES,
PICKING_BIN_DEFAULT_TYPES,
PICKING_BIN_BIN_TYPES,
PICKING_BIN_GRADE_RULES,
PICKING_BIN_TOTAL_LABEL,
};

View File

@ -4,7 +4,7 @@
* 拣选库位动销分析 —— 业务库取数
*
* 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty(仓位为空的行不参与)
* 类型:bin_group 为空 → 拣选位;否则取 bin_group(业务库目前只有「存货」)
* 范围:只统计拣选位(bin_group 为空);存货位(bin_group=存货)不纳入
* 销量:rpt.jst_Orders × rpt.jst_OrderItem,按 send_date,排除 Delete/Cancelled
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用
*
@ -19,7 +19,7 @@
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
*/
const { PICKING_BIN_GRADE_RULES, BIN_TYPES } = require('../config/pickingBinSalesAnalysis');
const { PICKING_BIN_GRADE_RULES, BIN_TYPE_PICKING } = require('../config/pickingBinSalesAnalysis');
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
function buildInClause(values, prefix) {
@ -68,7 +68,7 @@ function buildGradeCase(salesExpr = 'ISNULL(s.salesQty, 0)', daysParam = '@days'
*
* @param {object} cloudDb 业务库实例
* @param {string[]} wmsCoIds 仓库编码
* @param {{shipFrom:string, shipTo:string, days:number, binTypes?:string[]}} options
* @param {{shipFrom:string, shipTo:string, days:number}} options
* @returns {Promise<Array<{wmsCoId:string, binName:string, binType:string, skuId:string,
* stockQty:number, skuCount:number, salesQty:number, allocatedSalesQty:number,
* dailySales:number, gradeKey:string}>>}
@ -77,39 +77,29 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
if (!cloudDb || typeof cloudDb.query !== 'function') {
throw new TypeError('fetchPickingBinSkuRows 的第一个参数必须是业务库实例');
}
const { shipFrom, shipTo, days, binTypes } = options;
const { shipFrom, shipTo, days } = options;
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
if (!shipFrom || !shipTo || !days) return [];
const binIn = buildInClause(wmsCoIds, 'w');
const shipIn = buildInClause(wmsCoIds, 'v');
// 仓位类型过滤(不传 = 全部类型;选满全部类型时也不过滤,避免参数嗅探把查询计划带偏)
let typeFilter = '';
const typeParams = {};
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 b.binType IN (${t.clause})`;
}
// 统计范围固定为拣选位:直接在仓位库存表上过滤 bin_group 为空,
// 存货位(bin_group=存货)不参与动销等级统计。
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,
N'${BIN_TYPE_PICKING}' 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
AND ISNULL(p.bin_group, '') = ''
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 (
@ -154,8 +144,6 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
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
WHERE 1 = 1
${typeFilter}
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`;
@ -165,7 +153,6 @@ async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
{
...binIn.params,
...shipIn.params,
...typeParams,
shipFrom,
shipTo,
days,

View File

@ -12,8 +12,7 @@ const {
PICKING_BIN_GRADE_RULES,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_TYPES,
BIN_TYPES,
PICKING_BIN_BIN_TYPES,
} = require('../src/config/pickingBinSalesAnalysis');
test('buildInClause 按位展开参数', () => {
@ -37,11 +36,10 @@ test('等级按商品销量判定(默认表达式取 s.salesQty,不含仓位
assert.ok(!expr.includes('x.salesQty'));
});
test('默认窗口为 30 天,默认只看拣选位', () => {
test('默认窗口为 30 天,统计范围固定为拣选位', () => {
assert.equal(PICKING_BIN_DEFAULT_DAYS, 30);
assert.ok(PICKING_BIN_WINDOW_OPTIONS.includes(30));
assert.deepEqual(PICKING_BIN_DEFAULT_TYPES, ['拣选']);
assert.deepEqual(BIN_TYPES, ['拣选', '存货']);
assert.deepEqual(PICKING_BIN_BIN_TYPES, ['拣选']);
});
test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () => {
@ -84,7 +82,6 @@ test('fetchPickingBinSkuRows 按仓位×商品输出,等级与日销来自商
shipFrom: '2026-09-08 01:00:00',
shipTo: '2026-10-08 01:00:00',
days: 30,
binTypes: ['拣选'],
});
assert.equal(rows.length, 2);
@ -108,34 +105,34 @@ test('fetchPickingBinSkuRows 按仓位×商品输出,等级与日销来自商
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'拣选'"));
// 统计范围固定为拣选位:bin_group 为空;只统计有库存的行
assert.ok(captured.sqlText.includes("ISNULL(p.bin_group, '') = ''"));
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 () => {
test('fetchPickingBinSkuRows 只统计拣选位,不带类型参数', 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: [],
});
await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
// 存货位(bin_group=存货)被排除,且不再有 binType IN (...) 的可变过滤
assert.ok(captured.sqlText.includes("ISNULL(p.bin_group, '') = ''"));
assert.ok(!captured.sqlText.includes('b.binType IN'));
assert.equal(captured.params.t0, undefined);
assert.equal(captured.params.binTypes, undefined);
});
test('gradeKey 缺失时兜底为 zero', async () => {

View File

@ -11,11 +11,6 @@ export const PICKING_BIN_COLUMNS = [
label: '仓位',
tip: '系统仓位编码(jst_packInventoryInfo.bin);同一仓位有多个商品时会出现多行',
},
{
key: 'binType',
label: '类型',
tip: '拣选 = 拣货位(bin_group 为空);存货 = 存货位(bin_group=存货)',
},
{
key: 'skuId',
label: '商品编码',
@ -52,13 +47,6 @@ export const PICKING_BIN_WINDOW_OPTIONS = [
export const PICKING_BIN_DEFAULT_DAYS = 30;
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',
@ -70,6 +58,8 @@ export const PICKING_BIN_GRADE_COLORS = {
};
export const PICKING_BIN_DESCRIPTION = [
'【统计范围】只统计拣选位(bin_group 为空的仓位);存货位不纳入动销分析。',
'',
'【粒度】一行 = 一个「仓位 × 商品编码」组合。一个仓位放了 N 个商品就显示 N 行,列表与导出一致。',
' 库存数量 = 该商品在这个仓位上的实际库存(同一仓位多行相加 = 仓位总库存)。',
'',
@ -91,5 +81,5 @@ export const PICKING_BIN_DESCRIPTION = [
'',
'数据来源:库存取 rpt.jst_packInventoryInfo(按仓库 + 仓位 + 商品汇总);',
'销量取 rpt.jst_Orders × rpt.jst_OrderItem(按 send_date,排除 Delete/Cancelled),与「每日进销存」发货口径一致。',
'仓位类型:bin_group 为空视为拣选位,bin_group=存货 为存货位。',
'统计范围:只取 bin_group 为空的拣选位,存货位(bin_group=存货)不参与统计。',
];

View File

@ -13,8 +13,6 @@ 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';
@ -47,7 +45,6 @@ const PickingBinSalesAnalysis = () => {
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('');
@ -55,9 +52,8 @@ const PickingBinSalesAnalysis = () => {
const params = useMemo(() => ({
days,
binType: binTypes.join(','),
...(warehouse ? { wmsCoId: warehouse } : {}),
}), [days, binTypes, warehouse]);
}), [days, warehouse]);
const load = useCallback(async (options = {}) => {
setLoading(true);
@ -96,7 +92,7 @@ const PickingBinSalesAnalysis = () => {
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
key: col.key,
dataIndex: col.key,
align: ['warehouse', 'binName', 'binType', 'skuId', 'gradeKey'].includes(col.key) ? 'left' : 'right',
align: ['warehouse', 'binName', '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)
@ -107,7 +103,7 @@ const PickingBinSalesAnalysis = () => {
const g = gradeMap.get(value);
return <Tag color={PICKING_BIN_GRADE_COLORS[value] || 'default'}>{g?.label || value}</Tag>;
}
if (col.key === 'binType' || col.key === 'skuId') return <Text>{value || '—'}</Text>;
if (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') return int(value);
@ -174,16 +170,6 @@ const PickingBinSalesAnalysis = () => {
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}
@ -205,7 +191,7 @@ const PickingBinSalesAnalysis = () => {
<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>
@ -263,7 +249,7 @@ const PickingBinSalesAnalysis = () => {
className="picking-bin-table"
columns={columns}
dataSource={filteredRows}
rowKey={(r) => `${r.wmsCoId}::${r.binType}::${r.binName}::${r.skuId}`}
rowKey={(r) => `${r.wmsCoId}::${r.binName}::${r.skuId}`}
size="small"
bordered
pagination={{
@ -279,7 +265,7 @@ const PickingBinSalesAnalysis = () => {
<Text type="secondary" className="picking-bin-footer">
当前筛选出 {filteredRows.length} 条明细
{items.length !== filteredRows.length ? `(共 ${items.length} 条)` : ''};
去重商品 {int(data?.total?.skuCount ?? 0)} 个;已选类型:{(data?.binTypes ?? []).join('、') || '—'}。
去重商品 {int(data?.total?.skuCount ?? 0)} 个;统计范围:仅拣选位。
</Text>
<Modal