diff --git a/backend/src/api/pickingBinSalesAnalysis.js b/backend/src/api/pickingBinSalesAnalysis.js index c8b7c14..93c624f 100644 --- a/backend/src/api/pickingBinSalesAnalysis.js +++ b/backend/src/api/pickingBinSalesAnalysis.js @@ -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); diff --git a/backend/src/config/pickingBinSalesAnalysis.js b/backend/src/config/pickingBinSalesAnalysis.js index 60f51ba..aac70a9 100644 --- a/backend/src/config/pickingBinSalesAnalysis.js +++ b/backend/src/config/pickingBinSalesAnalysis.js @@ -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, }; diff --git a/backend/src/services/pickingBinSalesAnalysis.js b/backend/src/services/pickingBinSalesAnalysis.js index 34e3509..0c9f946 100644 --- a/backend/src/services/pickingBinSalesAnalysis.js +++ b/backend/src/services/pickingBinSalesAnalysis.js @@ -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>} @@ -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, diff --git a/backend/test/pickingBinSalesAnalysis.test.js b/backend/test/pickingBinSalesAnalysis.test.js index df9bdad..ccffa0a 100644 --- a/backend/test/pickingBinSalesAnalysis.test.js +++ b/backend/test/pickingBinSalesAnalysis.test.js @@ -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 () => { diff --git a/frontend/src/constants/pickingBinSalesAnalysis.js b/frontend/src/constants/pickingBinSalesAnalysis.js index e59bdf8..012128c 100644 --- a/frontend/src/constants/pickingBinSalesAnalysis.js +++ b/frontend/src/constants/pickingBinSalesAnalysis.js @@ -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=存货)不参与统计。', ]; diff --git a/frontend/src/pages/PickingBinSalesAnalysis/index.jsx b/frontend/src/pages/PickingBinSalesAnalysis/index.jsx index 6be6bbf..1352f1e 100644 --- a/frontend/src/pages/PickingBinSalesAnalysis/index.jsx +++ b/frontend/src/pages/PickingBinSalesAnalysis/index.jsx @@ -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: {col.label}, 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 {g?.label || value}; } - if (col.key === 'binType' || col.key === 'skuId') return {value || '—'}; + if (col.key === 'skuId') return {value || '—'}; 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 }} /> - { - + @@ -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 = () => { 当前筛选出 {filteredRows.length} 条明细 {items.length !== filteredRows.length ? `(共 ${items.length} 条)` : ''}; - 去重商品 {int(data?.total?.skuCount ?? 0)} 个;已选类型:{(data?.binTypes ?? []).join('、') || '—'}。 + 去重商品 {int(data?.total?.skuCount ?? 0)} 个;统计范围:仅拣选位。