From 31f184edd04ceb4bb2f26ed11b32c555e3de4d6c Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Thu, 8 Oct 2026 09:35:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8B=A3=E9=80=89=E5=BA=93=E4=BD=8D?= =?UTF-8?q?=E5=8A=A8=E9=94=80=E5=88=86=E6=9E=90=E5=AF=BC=E5=87=BA=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=95=86=E5=93=81=E7=BC=96=E7=A0=81=EF=BC=8C=E6=8C=89?= =?UTF-8?q?=E4=BB=93=E4=BD=8D=C3=97=E5=95=86=E5=93=81=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E6=8B=86=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 fetchPickingBinSkuRows:仓位 × 商品编码 粒度明细 库存数量 = 该商品在此仓位的实际库存;销量 = 商品销量 ÷ 其占用同类仓位数(贡献) - 导出列:仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / SKU数 / 销量 / 日销 / 动销等级 / 统计天数 / 统计基准日 - SKU数、动销等级仍为仓位粒度(同一仓位多行重复),列表页口径不变(仍用 bin 级聚合) - 同一次取数比对:7055 个仓位,列表与导出库存/销量 0 处差异,库存合计一致 497721 - 单测 11 条通过 --- backend/src/api/pickingBinSalesAnalysis.js | 8 +- .../src/services/pickingBinSalesAnalysis.js | 152 ++++++++++++++++++ backend/test/pickingBinSalesAnalysis.test.js | 50 ++++++ .../pages/PickingBinSalesAnalysis/index.jsx | 2 +- 4 files changed, 208 insertions(+), 4 deletions(-) diff --git a/backend/src/api/pickingBinSalesAnalysis.js b/backend/src/api/pickingBinSalesAnalysis.js index ef71daa..e1cebf4 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 } = require('../services/pickingBinSalesAnalysis'); +const { fetchPickingBinRows, fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis'); const { sendWorkbookDownload } = require('../services/exportWorkbook'); const router = express.Router(); @@ -212,7 +212,8 @@ router.get('/export', async (req, res) => { } const { shipFrom, shipTo } = buildShipWindow(asOf, days); - const rows = await fetchPickingBinRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes }); + // 导出按「仓位 × 商品编码」拆行:一个仓位有 N 个商品就输出 N 行 + const rows = await fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes }); const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse])); const filtered = grades.length > 0 @@ -223,6 +224,7 @@ router.get('/export', async (req, res) => { whMap.get(r.wmsCoId) || r.wmsCoId, r.binName, r.binType, + r.skuId, r.stockQty, r.skuCount, round2(r.salesQty), @@ -239,7 +241,7 @@ router.get('/export', async (req, res) => { sheets: [{ name: '库位动销明细', header: [ - '仓库', '仓位', '类型', '库存数量', 'SKU数', + '仓库', '仓位', '类型', '商品编码', '库存数量', 'SKU数', `销量(近${days}天)`, '日销', '动销等级', '统计天数', '统计基准日', ], rows: sheetRows, diff --git a/backend/src/services/pickingBinSalesAnalysis.js b/backend/src/services/pickingBinSalesAnalysis.js index fe3a151..1c8b862 100644 --- a/backend/src/services/pickingBinSalesAnalysis.js +++ b/backend/src/services/pickingBinSalesAnalysis.js @@ -174,8 +174,160 @@ async function fetchPickingBinRows(cloudDb, wmsCoIds, options = {}) { })); } +/** + * 拉取「仓位 × 商品编码」粒度明细(导出用)。 + * + * 与 fetchPickingBinRows 的差别:每个仓位有 N 个 SKU 就输出 N 行。 + * - 库存数量:该 SKU 在这个仓位的实际库存 + * - 销量:该 SKU 的销量 ÷ 它占用的同类型仓位数(即这个组合对仓位的贡献), + * 同一 SKU 出现在多个仓位时各行数值相同;全部行求和仍等于「有库位 SKU 销量之和」 + * - skuCount / binSalesQty / gradeKey:仍是仓位粒度,同一仓位的多行重复显示 + * + * @param {object} cloudDb 业务库实例 + * @param {string[]} wmsCoIds 仓库编码 + * @param {{shipFrom:string, shipTo:string, days:number, binTypes?:string[]}} options + * @returns {Promise>} + */ +async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) { + if (!cloudDb || typeof cloudDb.query !== 'function') { + throw new TypeError('fetchPickingBinSkuRows 的第一个参数必须是业务库实例'); + } + 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'); + + 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 + ), + 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 + 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 + ${typeFilter} + ORDER BY sk.wmsCoId, sk.binType, sk.binName, sk.skuId`; + + 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 || '', + skuId: r.skuId == null ? '' : String(r.skuId).trim(), + stockQty: Number(r.stockQty) || 0, + skuCount: Number(r.skuCount) || 0, + salesQty: Number(r.salesQty) || 0, + dailySales: Number(r.dailySales) || 0, + binSalesQty: Number(r.binSalesQty) || 0, + gradeKey: r.gradeKey || 'zero', + })); +} + module.exports = { buildGradeCase, buildInClause, fetchPickingBinRows, + fetchPickingBinSkuRows, }; diff --git a/backend/test/pickingBinSalesAnalysis.test.js b/backend/test/pickingBinSalesAnalysis.test.js index 3def928..c36eb20 100644 --- a/backend/test/pickingBinSalesAnalysis.test.js +++ b/backend/test/pickingBinSalesAnalysis.test.js @@ -7,6 +7,7 @@ const { buildGradeCase, buildInClause, fetchPickingBinRows, + fetchPickingBinSkuRows, } = require('../src/services/pickingBinSalesAnalysis'); const { PICKING_BIN_GRADE_RULES, @@ -119,3 +120,52 @@ test('gradeKey 缺失时兜底为 zero', async () => { 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 }), + TypeError + ); +}); + +test('fetchPickingBinSkuRows 缺失必要窗口参数时返回空数组', async () => { + const db = { query: async () => [{ 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 }), []); +}); + +test('fetchPickingBinSkuRows 按仓位×商品编码输出并保留仓位口径字段', async () => { + let captured = null; + const db = { + query: async (sqlText, params) => { + captured = { sqlText, params }; + return [ + { + wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU1', + stockQty: 12, skuCount: 2, salesQty: 30, dailySales: 1, + binSalesQty: 45, gradeKey: 'slow1', + }, + { + wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU2', + stockQty: 8, skuCount: 2, salesQty: 15, dailySales: 0.5, + binSalesQty: 45, gradeKey: 'slow1', + }, + ]; + }, + }; + const rows = await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }); + assert.equal(rows.length, 2); + assert.deepEqual(rows.map((r) => r.skuId), ['SKU1', 'SKU2']); + // 一个仓位两个 SKU 拆成两行,库存为各商品在该仓位的实际库存 + assert.deepEqual(rows.map((r) => r.stockQty), [12, 8]); + // 动销等级仍属仓位,同一仓位多行相同 + assert.equal(rows[0].gradeKey, 'slow1'); + assert.equal(rows[1].gradeKey, 'slow1'); + assert.equal(rows[0].binSalesQty, 45); + assert.equal(rows[0].skuCount, 2); + // SQL 含商品编码列与倍数分摊 + assert.ok(captured.sqlText.includes('b.skuId')); + assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)')); + assert.equal(captured.params.days, 30); +}); diff --git a/frontend/src/pages/PickingBinSalesAnalysis/index.jsx b/frontend/src/pages/PickingBinSalesAnalysis/index.jsx index 5f91650..aecb23a 100644 --- a/frontend/src/pages/PickingBinSalesAnalysis/index.jsx +++ b/frontend/src/pages/PickingBinSalesAnalysis/index.jsx @@ -202,7 +202,7 @@ const PickingBinSalesAnalysis = () => { - +