cangku/backend/test/pickingBinSalesAnalysis.test.js
youruo 2bdc4b5713 fix: 动销分析销量补齐组合装子件与全民客户(B2B)渠道
线上订单里组合装以 ZH-子件A-N-子件B-M 拼接编码存在,按 sku_id 精确
匹配永远查不到子件销量;全民客户(jst_qmcust_orderlist)渠道此前完全
未纳入。两处缺失导致大量 SKU(尤其组合装子件)窗口销量被算成 0。

销量 ship CTE 拆为 4 个 UNION ALL 分支:
1) jst_Orders 普通行(sku_type<>combine)
2) jst_Orders 组合装行 JOIN jst_comboProductItem 展开为子件x数量
3) jst_qmcust_orderlist 普通行(排除拆单父单 Split/Question)
4) qmcust 组合装行同样展开
均按 send_date 窗口,仓库过滤不变。等级/日销/分摊销量随数据源自动重算。
2026-10-14 11:05:13 +08:00

168 lines
7.1 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,
fetchPickingBinSkuRows,
} = require('../src/services/pickingBinSalesAnalysis');
const {
PICKING_BIN_GRADE_RULES,
PICKING_BIN_DEFAULT_DAYS,
PICKING_BIN_WINDOW_OPTIONS,
PICKING_BIN_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('等级按商品销量判定(默认表达式取 s.salesQty,不含仓位汇总列)', () => {
const expr = buildGradeCase();
assert.ok(expr.includes('ISNULL(s.salesQty, 0) / @days'));
assert.ok(!expr.includes('x.salesQty'));
});
test('默认窗口为 30 天,统计范围固定为拣选位', () => {
assert.equal(PICKING_BIN_DEFAULT_DAYS, 30);
assert.ok(PICKING_BIN_WINDOW_OPTIONS.includes(30));
assert.deepEqual(PICKING_BIN_BIN_TYPES, ['拣选']);
});
test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () => {
await assert.rejects(
() => fetchPickingBinSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
TypeError
);
});
test('fetchPickingBinSkuRows 缺失必要窗口参数时返回空数组,不打库', async () => {
let called = 0;
const db = { query: async () => { called += 1; return [{ 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 }), []);
assert.equal(called, 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: 600, allocatedSalesQty: 300,
dailySales: 20, gradeKey: 'hot',
},
{
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU2',
stockQty: 8, skuCount: 2, salesQty: 45, allocatedSalesQty: 22.5,
dailySales: 1.5, gradeKey: 'slow1',
},
];
},
};
const rows = await fetchPickingBinSkuRows(db, [101, 102], {
shipFrom: '2026-09-08 01:00:00',
shipTo: '2026-10-08 01:00:00',
days: 30,
});
assert.equal(rows.length, 2);
assert.deepEqual(rows.map((r) => r.skuId), ['SKU1', 'SKU2']);
// 同一仓位两个商品拆两行,库存是各商品在该仓位的实际库存
assert.deepEqual(rows.map((r) => r.stockQty), [12, 8]);
// 等级按商品自身日销,同一仓位的多行可以不同
assert.deepEqual(rows.map((r) => r.gradeKey), ['hot', 'slow1']);
// 分摊销量 = 商品销量 ÷ 占用的同类型仓位数(用于按行求和)
assert.deepEqual(rows.map((r) => r.allocatedSalesQty), [300, 22.5]);
assert.deepEqual(rows[0], {
wmsCoId: '101',
binName: 'XA01-01-01',
binType: '拣选',
skuId: 'SKU1',
stockQty: 12,
skuCount: 2,
salesQty: 600,
allocatedSalesQty: 300,
dailySales: 20,
gradeKey: 'hot',
});
// 参数必须带上两个仓库、发货窗口与天数
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-08 01:00:00');
assert.equal(captured.params.shipTo, '2026-10-08 01:00:00');
assert.equal(captured.params.days, 30);
// 商品销量按占用仓位数分摊(供求和),但等级依据的是未分摊的商品销量
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
assert.ok(captured.sqlText.includes('ISNULL(s.salesQty, 0) / @days AS DECIMAL(18, 4)'));
// 统计范围固定为真实拣选仓位:pack_type=Bin 且 bin_group 为空;只统计有库存的行
assert.ok(captured.sqlText.includes("p.pack_type = N'Bin'"));
assert.ok(captured.sqlText.includes("ISNULL(p.bin_group, '') = ''"));
assert.ok(captured.sqlText.includes('ISNULL(p.qty, 0) <> 0'));
assert.ok(captured.sqlText.includes('HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001'));
});
test('fetchPickingBinSkuRows 只统计真实拣选仓位,排除 Pack 箱记录且不带类型参数', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
};
await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
// Pack 箱记录和存货位被排除,且不再有 binType IN (...) 的可变过滤
assert.ok(captured.sqlText.includes("p.pack_type = N'Bin'"));
assert.ok(captured.sqlText.includes("ISNULL(p.bin_group, '') = ''"));
assert.ok(!captured.sqlText.includes('b.binType IN'));
assert.equal(captured.params.t0, undefined);
assert.equal(captured.params.binTypes, undefined);
});
test('销量覆盖 4 个来源:线上普通行 + 组合装展开 + 全民客户普通行 + 全民客户组合装展开', async () => {
let captured = null;
const db = {
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
};
await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 30 });
// ① 线上店铺订单普通行
assert.ok(captured.sqlText.includes('FROM rpt.jst_OrderItem'), '缺少 jst_OrderItem 普通行分支');
// ② 线上订单组合装行按映射表展开为子件
assert.ok(captured.sqlText.includes('JOIN rpt.jst_comboProductItem'), '缺少组合装展开分支');
assert.ok(captured.sqlText.includes("oi.sku_type = 'combine'"), '缺少组合装行识别条件');
// ③ 全民客户(B2B 分销)渠道普通行,排除拆单父单 Split 与问题单
assert.ok(captured.sqlText.includes('FROM rpt.jst_qmcust_orderlist_item'), '缺少 qmcust 分支');
assert.ok(captured.sqlText.includes("'Split', 'Question'"), 'qmcust 未排除拆单父单/问题单');
// 四个分支共用同一发货窗口
assert.equal(captured.sqlText.split('o.send_date >= @shipFrom').length, 5);
});
test('gradeKey 缺失时兜底为 zero', async () => {
const db = { query: async () => [{ wmsCoId: 101, binName: 'X', binType: '拣选', skuId: 'S1', stockQty: 5 }] };
const rows = await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
assert.equal(rows[0].gradeKey, 'zero');
assert.equal(rows[0].salesQty, 0);
assert.equal(rows[0].allocatedSalesQty, 0);
assert.equal(rows[0].dailySales, 0);
assert.equal(rows[0].skuId, 'S1');
});