cangku/backend/test/pickingBinSalesAnalysis.test.js
youruo 31f184edd0 feat: 拣选库位动销分析导出增加商品编码,按仓位×商品编码拆行
- 新增 fetchPickingBinSkuRows:仓位 × 商品编码 粒度明细
  库存数量 = 该商品在此仓位的实际库存;销量 = 商品销量 ÷ 其占用同类仓位数(贡献)
- 导出列:仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / SKU数 / 销量 / 日销 / 动销等级 / 统计天数 / 统计基准日
- SKU数、动销等级仍为仓位粒度(同一仓位多行重复),列表页口径不变(仍用 bin 级聚合)
- 同一次取数比对:7055 个仓位,列表与导出库存/销量 0 处差异,库存合计一致 497721
- 单测 11 条通过
2026-10-08 09:35:26 +08:00

172 lines
6.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,
fetchPickingBinRows,
fetchPickingBinSkuRows,
} = require('../src/services/pickingBinSalesAnalysis');
const {
PICKING_BIN_GRADE_RULES,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_DEFAULT_TYPES,
BIN_TYPES,
} = require('../src/config/pickingBinSalesAnalysis');
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();
PICKING_BIN_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('默认窗口为 30 天,默认只看拣选位', () => {
assert.equal(PICKING_BIN_DEFAULT_DAYS, 30);
assert.ok(PICKING_BIN_WINDOW_OPTIONS.includes(30));
assert.deepEqual(PICKING_BIN_DEFAULT_TYPES, ['拣选']);
assert.deepEqual(BIN_TYPES, ['拣选', '存货']);
});
test('fetchPickingBinRows 首参不是数据库实例时抛错', async () => {
await assert.rejects(
() => fetchPickingBinRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 15 }),
TypeError
);
});
test('fetchPickingBinRows 无仓库或缺失窗口时直接返回空结果,不打库', async () => {
let called = 0;
const db = { query: async () => { called += 1; return []; } };
assert.deepEqual(await fetchPickingBinRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 15 }), []);
assert.deepEqual(await fetchPickingBinRows(db, [101], { shipFrom: null, shipTo: 'b', days: 15 }), []);
assert.equal(called, 0);
});
test('fetchPickingBinRows 把仓库/类型/窗口参数一并传给业务库,并做分摊与分档', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => {
captured = { sqlText, params };
return [
{ wmsCoId: 101, binName: 'XA01-17-12', binType: '拣选', stockQty: 21, skuCount: 1, salesQty: 9, dailySales: 0.6, gradeKey: 'slow2' },
];
},
};
const rows = await fetchPickingBinRows(db, [101, 102], {
shipFrom: '2026-09-23 01:00:00',
shipTo: '2026-10-08 01:00:00',
days: 15,
binTypes: ['拣选'],
});
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
wmsCoId: '101',
binName: 'XA01-17-12',
binType: '拣选',
stockQty: 21,
skuCount: 1,
salesQty: 9,
dailySales: 0.6,
gradeKey: 'slow2',
});
// 参数必须带上两个仓库、发货窗口与天数,以及仓位类型过滤
assert.equal(captured.params.w0, 101);
assert.equal(captured.params.w1, 102);
assert.equal(captured.params.v0, 101);
assert.equal(captured.params.shipFrom, '2026-09-23 01:00:00');
assert.equal(captured.params.shipTo, '2026-10-08 01:00:00');
assert.equal(captured.params.days, 15);
assert.equal(captured.params.t0, '拣选');
// 分摊:SKU 销量除以其占用的同类型仓位数
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
// 仓位类型:bin_group 为空视为拣选位
assert.ok(captured.sqlText.includes("THEN N'拣选'"));
// 只统计有库存的仓位
assert.ok(captured.sqlText.includes('ISNULL(p.qty, 0) <> 0'));
});
test('fetchPickingBinRows 不传类型时不过滤 binType', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
};
await fetchPickingBinRows(db, [101], {
shipFrom: 'a', shipTo: 'b', days: 15, binTypes: [],
});
assert.ok(!captured.sqlText.includes('a.binType IN'));
assert.equal(captured.params.t0, undefined);
});
test('gradeKey 缺失时兜底为 zero', async () => {
const db = { query: async () => [{ wmsCoId: 101, binName: 'X', binType: '拣选', stockQty: 5 }] };
const rows = await fetchPickingBinRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
assert.equal(rows[0].gradeKey, 'zero');
assert.equal(rows[0].salesQty, 0);
assert.equal(rows[0].dailySales, 0);
});
test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () => {
await assert.rejects(
() => fetchPickingBinSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
TypeError
);
});
test('fetchPickingBinSkuRows 缺失必要窗口参数时返回空数组', async () => {
const db = { query: async () => [{ skuId: 'S1' }] };
assert.deepEqual(await fetchPickingBinSkuRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: null, shipTo: 'b', days: 30 }), []);
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 0 }), []);
});
test('fetchPickingBinSkuRows 按仓位×商品编码输出并保留仓位口径字段', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => {
captured = { sqlText, params };
return [
{
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU1',
stockQty: 12, skuCount: 2, salesQty: 30, dailySales: 1,
binSalesQty: 45, gradeKey: 'slow1',
},
{
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU2',
stockQty: 8, skuCount: 2, salesQty: 15, dailySales: 0.5,
binSalesQty: 45, gradeKey: 'slow1',
},
];
},
};
const rows = await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 30 });
assert.equal(rows.length, 2);
assert.deepEqual(rows.map((r) => r.skuId), ['SKU1', 'SKU2']);
// 一个仓位两个 SKU 拆成两行,库存为各商品在该仓位的实际库存
assert.deepEqual(rows.map((r) => r.stockQty), [12, 8]);
// 动销等级仍属仓位,同一仓位多行相同
assert.equal(rows[0].gradeKey, 'slow1');
assert.equal(rows[1].gradeKey, 'slow1');
assert.equal(rows[0].binSalesQty, 45);
assert.equal(rows[0].skuCount, 2);
// SQL 含商品编码列与倍数分摊
assert.ok(captured.sqlText.includes('b.skuId'));
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
assert.equal(captured.params.days, 30);
});