feat: 库存动销分析「销售=0」导出增加系统仓位列

仓位用于定位滞销库存的实际存放位置,便于盘点与挪位处理。
数据源为 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 上限内);
浏览器实测点击下载,文件名与列结构正确
This commit is contained in:
youruo 2026-09-28 09:34:55 +08:00
parent 4d419c5c7c
commit fc280bfe27
4 changed files with 51 additions and 6 deletions

View File

@ -243,6 +243,7 @@ router.get('/zero-sales-export', async (req, res) => {
base?.warehouse || r.wmsCoId, base?.warehouse || r.wmsCoId,
r.skuId, r.skuId,
r.skuName, r.skuName,
r.binName,
r.stockQty, r.stockQty,
r.salesQty, r.salesQty,
days, days,
@ -257,7 +258,7 @@ router.get('/zero-sales-export', async (req, res) => {
format, format,
sheets: [{ sheets: [{
name: '销售=0明细', name: '销售=0明细',
header: ['仓库', 'SKU编码', 'SKU名称', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'], header: ['仓库', 'SKU编码', 'SKU名称', '系统仓位', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'],
rows: sheetRows, rows: sheetRows,
}], }],
}); });

View File

@ -139,7 +139,9 @@ async function fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
* 「销售=0」档的 SKU 级明细(导出用)。 * 「销售=0」档的 SKU 级明细(导出用)。
* 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero', * 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero',
* 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。 * 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。
* @returns {Promise<Array<{wmsCoId:string, skuId:string, skuName:string, stockQty:number, salesQty:number}>>} * 系统仓位取自 rpt.jst_packInventoryInfo.bin(一个 SKU 可能占多个仓位,按名称排序后用「、」连接;
* 库存为 0 的仓位记录已排除)。该表只覆盖部分仓库,未覆盖到的 SKU 返回空串。
* @returns {Promise<Array<{wmsCoId:string, skuId:string, skuName:string, binName:string, stockQty:number, salesQty:number}>>}
*/ */
async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) { async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
if (!cloudDb || typeof cloudDb.query !== 'function') { if (!cloudDb || typeof cloudDb.query !== 'function') {
@ -153,7 +155,21 @@ async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days
const gradeCase = buildGradeCase(); const gradeCase = buildGradeCase();
const sqlText = ` 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, SELECT i.wms_co_id AS wmsCoId,
i.sku_id AS skuId, i.sku_id AS skuId,
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty 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, SELECT g.wmsCoId,
g.skuId, g.skuId,
ISNULL(s.name, '') AS skuName, ISNULL(s.name, '') AS skuName,
ISNULL(bn.binName, '') AS binName,
CAST(g.stockQty AS DECIMAL(18, 2)) AS stockQty, CAST(g.stockQty AS DECIMAL(18, 2)) AS stockQty,
CAST(g.salesQty AS DECIMAL(18, 2)) AS salesQty CAST(g.salesQty AS DECIMAL(18, 2)) AS salesQty
FROM graded g FROM graded g
LEFT JOIN rpt.jst_sku s ON s.sku_id = g.skuId 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' WHERE g.gradeKey = 'zero'
ORDER BY g.wmsCoId, g.stockQty DESC, g.skuId`; 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), wmsCoId: String(r.wmsCoId),
skuId: String(r.skuId ?? ''), skuId: String(r.skuId ?? ''),
skuName: r.skuName == null ? '' : String(r.skuName), skuName: r.skuName == null ? '' : String(r.skuName),
binName: r.binName == null ? '' : String(r.binName),
stockQty: Number(r.stockQty) || 0, stockQty: Number(r.stockQty) || 0,
salesQty: Number(r.salesQty) || 0, salesQty: Number(r.salesQty) || 0,
})); }));

View File

@ -94,7 +94,8 @@ test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async
query: async (sqlText, params) => { query: async (sqlText, params) => {
captured = { sqlText, params }; captured = { sqlText, params };
return [ 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, days: 30,
}); });
assert.equal(rows.length, 1); assert.equal(rows.length, 2);
assert.equal(rows[0].wmsCoId, '101'); assert.equal(rows[0].wmsCoId, '101');
assert.equal(rows[0].skuName, '款式A'); assert.equal(rows[0].skuName, '款式A');
assert.equal(rows[0].stockQty, 12); 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 表达式 + 同样的「有库存或有销量」过滤 // 口径必须与 fetchGradeRows 一致:同一段 gradeCase 表达式 + 同样的「有库存或有销量」过滤
assert.ok(captured.sqlText.includes("ELSE 'zero'")); assert.ok(captured.sqlText.includes("ELSE 'zero'"));
assert.ok(captured.sqlText.includes("g.gradeKey = '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.ok(captured.sqlText.includes("NOT IN ('Delete', 'Cancelled')"));
assert.equal(captured.params.w0, 101); assert.equal(captured.params.w0, 101);
assert.equal(captured.params.days, 30); 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"));
}); });

View File

@ -209,7 +209,7 @@ const StockSalesAnalysis = () => {
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细与库存明细重算(约 5~20 秒)"> <Tooltip title="跳过 5 分钟缓存,重新扫描订单明细与库存明细重算(约 5~20 秒)">
<Button onClick={() => load({ days, force: true })} loading={loading}>强制重算</Button> <Button onClick={() => load({ days, force: true })} loading={loading}>强制重算</Button>
</Tooltip> </Tooltip>
<Tooltip title="导出当前筛选下「销售=0」档的 SKU 明细(仓库 / SKU编码 / SKU名称 / 库存数量),跟随统计天数与仓库筛选"> <Tooltip title="导出当前筛选下「销售=0」档的 SKU 明细(仓库 / SKU编码 / SKU名称 / 系统仓位 / 库存数量),跟随统计天数与仓库筛选;一个 SKU 占多个仓位时用「、」连接,仓位数据未覆盖的 SKU 留空">
<Button icon={<DownloadOutlined />} onClick={handleExportZero} loading={exporting}>导出销售=0</Button> <Button icon={<DownloadOutlined />} onClick={handleExportZero} loading={exporting}>导出销售=0</Button>
</Tooltip> </Tooltip>
</Space> </Space>