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:
parent
4d419c5c7c
commit
fc280bfe27
@ -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,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -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"));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user