diff --git a/backend/src/api/pickingBinSalesAnalysis.js b/backend/src/api/pickingBinSalesAnalysis.js index e1cebf4..c8b7c14 100644 --- a/backend/src/api/pickingBinSalesAnalysis.js +++ b/backend/src/api/pickingBinSalesAnalysis.js @@ -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, }], diff --git a/backend/src/config/pickingBinSalesAnalysis.js b/backend/src/config/pickingBinSalesAnalysis.js index 5aa8942..60f51ba 100644 --- a/backend/src/config/pickingBinSalesAnalysis.js +++ b/backend/src/config/pickingBinSalesAnalysis.js @@ -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 = '汇总'; diff --git a/backend/src/services/pickingBinSalesAnalysis.js b/backend/src/services/pickingBinSalesAnalysis.js index 1c8b862..34e3509 100644 --- a/backend/src/services/pickingBinSalesAnalysis.js +++ b/backend/src/services/pickingBinSalesAnalysis.js @@ -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>} - */ -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>} + * 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 - 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, 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 + SELECT b.wmsCoId, + b.binName, + b.binType, + b.skuId, + 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 = 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 + 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} - 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, }; diff --git a/backend/test/pickingBinSalesAnalysis.test.js b/backend/test/pickingBinSalesAnalysis.test.js index c36eb20..df9bdad 100644 --- a/backend/test/pickingBinSalesAnalysis.test.js +++ b/backend/test/pickingBinSalesAnalysis.test.js @@ -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'); }); diff --git a/frontend/src/constants/pickingBinSalesAnalysis.js b/frontend/src/constants/pickingBinSalesAnalysis.js index dd64d09..e59bdf8 100644 --- a/frontend/src/constants/pickingBinSalesAnalysis.js +++ b/frontend/src/constants/pickingBinSalesAnalysis.js @@ -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=存货 为存货位。', ]; diff --git a/frontend/src/pages/PickingBinSalesAnalysis/index.jsx b/frontend/src/pages/PickingBinSalesAnalysis/index.jsx index aecb23a..6be6bbf 100644 --- a/frontend/src/pages/PickingBinSalesAnalysis/index.jsx +++ b/frontend/src/pages/PickingBinSalesAnalysis/index.jsx @@ -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: {col.label}, 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 {g?.label || value}; } - if (col.key === 'binType') return {value || '—'}; + if (col.key === 'binType' || 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' || 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 }} /> - + @@ -211,8 +214,8 @@ const PickingBinSalesAnalysis = () => { @@ -228,14 +231,16 @@ const PickingBinSalesAnalysis = () => { onClick={() => toggleGrade(s.key)} className={grades.includes(s.key) ? 'picking-bin-card is-active' : 'picking-bin-card'} > - + + +
- 库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)} + 明细 {int(s.rowCount)} 行 · 库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)}
@@ -243,9 +248,11 @@ const PickingBinSalesAnalysis = () => { {total ? ( - + + +
- 库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)} + 明细 {int(total.rowCount)} 行 · 库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)}
@@ -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)' }} /> - 当前筛选出 {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('、') || '—'}。 tr > th {