'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); });