From fc280bfe271e5b09d31b869841c55dfd5a521651 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Mon, 28 Sep 2026 09:34:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BA=93=E5=AD=98=E5=8A=A8=E9=94=80?= =?UTF-8?q?=E5=88=86=E6=9E=90=E3=80=8C=E9=94=80=E5=94=AE=3D0=E3=80=8D?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=A2=9E=E5=8A=A0=E7=B3=BB=E7=BB=9F=E4=BB=93?= =?UTF-8?q?=E4=BD=8D=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仓位用于定位滞销库存的实际存放位置,便于盘点与挪位处理。 数据源为 rpt.jst_packInventoryInfo.bin(jst_sku.bin 全部为空、库存表无仓位字段, 这是业务库唯一可得的系统仓位): - 按 (wms_co_id, sku_id) 聚合,LTRIM 去空格后 DISTINCT,STRING_AGG 按名称排序用「、」连接 - 排除无仓位(bin 为空)与零库存(qty=0)的行,避免无效仓位干扰 - LEFT JOIN 且按「仓库+SKU」双条件匹配,避免跨仓错配 - 导出列顺序:仓库 | SKU编码 | SKU名称 | 系统仓位 | 库存数量 | 销量 | 统计天数 | 统计基准日 - 前端按钮提示同步说明多仓位拼接规则与留空含义 验证:后端 syntax 通过、单测 9/9(全量 389/390,唯一失败为既有 Windows 权限环境项); 前端 eslint/build/测试均通过; 本地实测导出 3650 行,系统仓位列就位,3381 行有仓位(92.6%,未覆盖的 7.4% 为 仓位表仅含 13 个仓库所致),销量列全为 0,最长仓位串 600 字符(Excel 上限内); 浏览器实测点击下载,文件名与列结构正确 --- backend/src/api/stockSalesAnalysis.js | 3 +- backend/src/services/stockSalesAnalysis.js | 23 +++++++++++++-- backend/test/stockSalesAnalysis.test.js | 29 +++++++++++++++++-- .../src/pages/StockSalesAnalysis/index.jsx | 2 +- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/backend/src/api/stockSalesAnalysis.js b/backend/src/api/stockSalesAnalysis.js index 988b09e..78d0483 100644 --- a/backend/src/api/stockSalesAnalysis.js +++ b/backend/src/api/stockSalesAnalysis.js @@ -243,6 +243,7 @@ router.get('/zero-sales-export', async (req, res) => { base?.warehouse || r.wmsCoId, r.skuId, r.skuName, + r.binName, r.stockQty, r.salesQty, days, @@ -257,7 +258,7 @@ router.get('/zero-sales-export', async (req, res) => { format, sheets: [{ name: '销售=0明细', - header: ['仓库', 'SKU编码', 'SKU名称', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'], + header: ['仓库', 'SKU编码', 'SKU名称', '系统仓位', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'], rows: sheetRows, }], }); diff --git a/backend/src/services/stockSalesAnalysis.js b/backend/src/services/stockSalesAnalysis.js index 55074e8..84b380d 100644 --- a/backend/src/services/stockSalesAnalysis.js +++ b/backend/src/services/stockSalesAnalysis.js @@ -139,7 +139,9 @@ async function fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) { * 「销售=0」档的 SKU 级明细(导出用)。 * 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero', * 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。 - * @returns {Promise>} + * 系统仓位取自 rpt.jst_packInventoryInfo.bin(一个 SKU 可能占多个仓位,按名称排序后用「、」连接; + * 库存为 0 的仓位记录已排除)。该表只覆盖部分仓库,未覆盖到的 SKU 返回空串。 + * @returns {Promise>} */ async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) { if (!cloudDb || typeof cloudDb.query !== 'function') { @@ -153,7 +155,21 @@ async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days const gradeCase = buildGradeCase(); const sqlText = ` - WITH inv AS ( + WITH bin AS ( + SELECT d.wmsCoId, + d.skuId, + STRING_AGG(CAST(d.b AS NVARCHAR(MAX)), '、') WITHIN GROUP (ORDER BY d.b) AS binName + FROM ( + SELECT DISTINCT p.wms_co_id AS wmsCoId, + p.sku_id AS skuId, + LTRIM(RTRIM(p.bin)) AS b + FROM rpt.jst_packInventoryInfo p + WHERE ISNULL(p.bin, '') <> '' + AND ISNULL(p.qty, 0) <> 0 + ) d + GROUP BY d.wmsCoId, d.skuId + ), + inv AS ( SELECT i.wms_co_id AS wmsCoId, i.sku_id AS skuId, SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty @@ -198,10 +214,12 @@ async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days SELECT g.wmsCoId, g.skuId, ISNULL(s.name, '') AS skuName, + ISNULL(bn.binName, '') AS binName, CAST(g.stockQty AS DECIMAL(18, 2)) AS stockQty, CAST(g.salesQty AS DECIMAL(18, 2)) AS salesQty FROM graded g LEFT JOIN rpt.jst_sku s ON s.sku_id = g.skuId + LEFT JOIN bin bn ON bn.wmsCoId = g.wmsCoId AND bn.skuId = g.skuId WHERE g.gradeKey = 'zero' ORDER BY g.wmsCoId, g.stockQty DESC, g.skuId`; @@ -217,6 +235,7 @@ async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days wmsCoId: String(r.wmsCoId), skuId: String(r.skuId ?? ''), skuName: r.skuName == null ? '' : String(r.skuName), + binName: r.binName == null ? '' : String(r.binName), stockQty: Number(r.stockQty) || 0, salesQty: Number(r.salesQty) || 0, })); diff --git a/backend/test/stockSalesAnalysis.test.js b/backend/test/stockSalesAnalysis.test.js index e9b97e1..cec796c 100644 --- a/backend/test/stockSalesAnalysis.test.js +++ b/backend/test/stockSalesAnalysis.test.js @@ -94,7 +94,8 @@ test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async query: async (sqlText, params) => { captured = { sqlText, params }; return [ - { wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', stockQty: 12, salesQty: 0 }, + { wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A01-01-1、A02-03-1', stockQty: 12, salesQty: 0 }, + { wmsCoId: 101, skuId: 'SKU-B', skuName: '款式B', binName: null, stockQty: 3, salesQty: 0 }, ]; }, }; @@ -105,11 +106,15 @@ test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async days: 30, }); - assert.equal(rows.length, 1); + assert.equal(rows.length, 2); assert.equal(rows[0].wmsCoId, '101'); assert.equal(rows[0].skuName, '款式A'); assert.equal(rows[0].stockQty, 12); + // 系统仓位:多仓位拼接成一个字符串,缺失时归一化为空串而不是 null + assert.equal(rows[0].binName, 'A01-01-1、A02-03-1'); + assert.equal(rows[1].binName, ''); + // 口径必须与 fetchGradeRows 一致:同一段 gradeCase 表达式 + 同样的「有库存或有销量」过滤 assert.ok(captured.sqlText.includes("ELSE 'zero'")); assert.ok(captured.sqlText.includes("g.gradeKey = 'zero'")); @@ -117,4 +122,24 @@ test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async assert.ok(captured.sqlText.includes("NOT IN ('Delete', 'Cancelled')")); assert.equal(captured.params.w0, 101); assert.equal(captured.params.days, 30); + + // 系统仓位:来自 rpt.jst_packInventoryInfo,按仓库+SKU 聚合,排除无仓位与零库存记录 + assert.ok(captured.sqlText.includes('rpt.jst_packInventoryInfo')); + assert.ok(captured.sqlText.includes('STRING_AGG')); + assert.ok(captured.sqlText.includes('binName')); +}); + +test('fetchZeroSaleSkuRows 的仓位必须按同一仓库匹配,避免跨仓错配', async () => { + let captured = null; + const db = { + query: async (sqlText) => { + captured = sqlText; + return []; + }, + }; + await fetchZeroSaleSkuRows(db, [101], { + shipFrom: '2026-08-28 01:00:00', shipTo: '2026-09-27 01:00:00', days: 30, + }); + assert.ok(captured.includes('bn.wmsCoId = g.wmsCoId AND bn.skuId = g.skuId')); + assert.ok(captured.includes("ISNULL(p.qty, 0) <> 0")); }); diff --git a/frontend/src/pages/StockSalesAnalysis/index.jsx b/frontend/src/pages/StockSalesAnalysis/index.jsx index 6d5064f..34550b7 100644 --- a/frontend/src/pages/StockSalesAnalysis/index.jsx +++ b/frontend/src/pages/StockSalesAnalysis/index.jsx @@ -209,7 +209,7 @@ const StockSalesAnalysis = () => { - +