cangku/backend/test/stockSalesAnalysis.test.js
youruo fc280bfe27 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 上限内);
浏览器实测点击下载,文件名与列结构正确
2026-09-28 09:34:55 +08:00

146 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildGradeCase,
buildInClause,
fetchGradeRows,
fetchZeroSaleSkuRows,
} = require('../src/services/stockSalesAnalysis');
const { SALES_GRADE_RULES } = require('../src/config/stockSalesAnalysis');
test('buildInClause 按位展开仓库参数', () => {
const { clause, params } = buildInClause([101, 102], 'w');
assert.equal(clause, '@w0,@w1');
assert.deepEqual(params, { w0: 101, w1: 102 });
});
test('buildGradeCase 覆盖全部正向等级,并以销售=0 兜底', () => {
const expr = buildGradeCase();
SALES_GRADE_RULES.filter((rule) => rule.lower > 0).forEach((rule) => {
assert.ok(expr.includes(`THEN '${rule.key}'`), `${rule.key} 未出现在分档表达式中`);
});
assert.ok(expr.includes("THEN 'slow2'"));
assert.ok(expr.includes("ELSE 'zero'"));
});
test('fetchGradeRows 首参不是数据库实例时抛错,避免静默返回空数组', async () => {
await assert.rejects(
() => fetchGradeRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
TypeError
);
});
test('fetchGradeRows 无仓库或缺失窗口时直接返回空结果', async () => {
let called = 0;
const db = { query: async () => { called += 1; return []; } };
assert.deepEqual(await fetchGradeRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
assert.deepEqual(await fetchGradeRows(db, [101], { shipFrom: null, shipTo: 'b', days: 30 }), []);
assert.equal(called, 0);
});
test('fetchGradeRows 把仓库参数与窗口参数一并传给业务库', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => {
captured = { sqlText, params };
return [
{ wmsCoId: 101, gradeKey: 'hot', skuCount: 2, stockQty: 438, salesQty: 1136, dailySales: 37.87 },
];
},
};
const rows = await fetchGradeRows(db, [101, 102], {
shipFrom: '2026-08-28 01:00:00',
shipTo: '2026-09-27 01:00:00',
days: 30,
});
assert.equal(rows.length, 1);
assert.equal(rows[0].wmsCoId, '101');
assert.equal(rows[0].stockQty, 438);
assert.equal(captured.params.w0, 101);
assert.equal(captured.params.w1, 102);
assert.equal(captured.params.v0, 101);
assert.equal(captured.params.days, 30);
assert.equal(captured.params.shipFrom, '2026-08-28 01:00:00');
assert.equal(captured.params.shipTo, '2026-09-27 01:00:00');
assert.ok(captured.sqlText.includes('@days'));
assert.ok(captured.sqlText.includes('rpt.jst_inventoryInfo'));
assert.ok(captured.sqlText.includes('rpt.jst_OrderItem'));
});
test('fetchZeroSaleSkuRows 首参不是数据库实例时抛错,避免静默返回空数组', async () => {
await assert.rejects(
() => fetchZeroSaleSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
TypeError
);
});
test('fetchZeroSaleSkuRows 无仓库或缺失窗口时直接返回空结果', async () => {
let called = 0;
const db = { query: async () => { called += 1; return []; } };
assert.deepEqual(await fetchZeroSaleSkuRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
assert.deepEqual(await fetchZeroSaleSkuRows(db, [101], { shipFrom: 'a', shipTo: null, days: 30 }), []);
assert.equal(called, 0);
});
test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async () => {
let captured = null;
const db = {
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-B', skuName: '款式B', binName: null, stockQty: 3, salesQty: 0 },
];
},
};
const rows = await fetchZeroSaleSkuRows(db, [101, 102], {
shipFrom: '2026-08-28 01:00:00',
shipTo: '2026-09-27 01:00:00',
days: 30,
});
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'"));
assert.ok(captured.sqlText.includes('rpt.jst_sku'));
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"));
});