Merge pull request 'feat: 库存动销分析「销售=0」导出按仓位拆行,库存数量按实际仓位统计' (#51) from codex/ssa-zero-export-bin-0928 into main

This commit is contained in:
youruo 2026-09-28 09:57:18 +08:00
commit aeac4bcc81
3 changed files with 30 additions and 27 deletions

View File

@ -139,8 +139,11 @@ async function fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
* 「销售=0」档的 SKU 级明细(导出用)。
* 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero',
* 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。
* 系统仓位取自 rpt.jst_packInventoryInfo.bin(一个 SKU 可能占多个仓位,按名称排序后用「、」连接;
* 库存为 0 的仓位记录已排除)。该表只覆盖部分仓库,未覆盖到的 SKU 返回空串。
*
* 系统仓位取自 rpt.jst_packInventoryInfo.bin(jst_sku.bin 全为空、库存表无仓位字段)。
* 每个仓位独立一行,库存数量按该仓位的实际 qty 统计(各仓位行之和 = SKU 总库存):
* - 有仓位数据的 SKU:按 (wms_co_id, sku_id, bin) 展开,qty=0 的仓位行剔除;
* - 无仓位数据的 SKU(仓位表未覆盖的仓库):保留一行,仓位为空、库存取 SKU 总库存,避免丢数。
* @returns {Promise<Array<{wmsCoId:string, skuId:string, skuName:string, binName:string, stockQty:number, salesQty:number}>>}
*/
async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
@ -156,18 +159,14 @@ async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days
const sqlText = `
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,
SELECT p.wms_co_id AS wmsCoId,
p.sku_id AS skuId,
LTRIM(RTRIM(p.bin)) AS b
LTRIM(RTRIM(p.bin)) AS b,
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binQty
FROM rpt.jst_packInventoryInfo p
WHERE ISNULL(p.bin, '') <> ''
AND ISNULL(p.qty, 0) <> 0
) d
GROUP BY d.wmsCoId, d.skuId
GROUP BY p.wms_co_id, p.sku_id, LTRIM(RTRIM(p.bin))
),
inv AS (
SELECT i.wms_co_id AS wmsCoId,
@ -214,14 +213,14 @@ 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,
ISNULL(bn.b, '') AS binName,
CAST(COALESCE(bn.binQty, 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`;
ORDER BY g.wmsCoId, g.stockQty DESC, g.skuId, bn.b`;
const rows = await cloudDb.query(sqlText, {
...invIn.params,

View File

@ -94,7 +94,8 @@ test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async
query: async (sqlText, params) => {
captured = { sqlText, params };
return [
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A01-01-1、A02-03-1', stockQty: 12, salesQty: 0 },
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A01-01-1', stockQty: 7, salesQty: 0 },
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A02-03-1', stockQty: 5, salesQty: 0 },
{ wmsCoId: 101, skuId: 'SKU-B', skuName: '款式B', binName: null, stockQty: 3, salesQty: 0 },
];
},
@ -106,14 +107,16 @@ test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async
days: 30,
});
assert.equal(rows.length, 2);
// 每个仓位独立一行;无仓位的 SKU 保留一行(仓位为空、库存取总库存)
assert.equal(rows.length, 3);
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, '');
assert.equal(rows[0].binName, 'A01-01-1');
assert.equal(rows[0].stockQty, 7);
assert.equal(rows[1].binName, 'A02-03-1');
assert.equal(rows[1].stockQty, 5);
assert.equal(rows[2].binName, '');
assert.equal(rows[2].stockQty, 3);
// 口径必须与 fetchGradeRows 一致:同一段 gradeCase 表达式 + 同样的「有库存或有销量」过滤
assert.ok(captured.sqlText.includes("ELSE 'zero'"));
@ -123,9 +126,10 @@ test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async
assert.equal(captured.params.w0, 101);
assert.equal(captured.params.days, 30);
// 系统仓位:来自 rpt.jst_packInventoryInfo,按仓库+SKU 聚合,排除无仓位与零库存记录
// 系统仓位:来自 rpt.jst_packInventoryInfo,按「仓库+SKU+仓位」聚合出仓位级库存,
// 每仓位一行(COALESCE 兜底无仓位 SKU 取总库存),排除无仓位与零库存记录
assert.ok(captured.sqlText.includes('rpt.jst_packInventoryInfo'));
assert.ok(captured.sqlText.includes('STRING_AGG'));
assert.ok(captured.sqlText.includes('COALESCE(bn.binQty, g.stockQty)'));
assert.ok(captured.sqlText.includes('binName'));
});

View File

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