forked from lunanting/cangku
上一版把同一 SKU 的多个仓位用「、」拼在一格里,库存数量仍是 SKU 总量, 无法直接按仓位盘点。现改为每个仓位独立一行: - bin CTE 改为按 (wms_co_id, sku_id, LTRIM(RTRIM(bin))) 分组, SUM(qty) 得到仓位级库存(各仓位行之和 = SKU 总库存,实测 240=60+69+35+76) - 导出行 = graded(SKU 级「销售=0」判定)LEFT JOIN 仓位级明细: 有仓位数据的 SKU 按仓位展开多行、库存数量取该仓位实际 qty(qty=0 的仓位行剔除); 仓位表未覆盖的 SKU 保留一行(仓位为空、库存取总库存),不丢数据 - 排序:仓库 → SKU 总库存倒序 → SKU → 仓位名称,同一 SKU 的各行保持相邻 - 前端按钮提示同步更新拆行与兜底规则说明 验证:后端 syntax 通过、单测 9/9(全量 389/390,唯一失败为既有 Windows 环境项); 前端 eslint/build 通过; 本地实测:单仓(睢宁 196 行,原 128 SKU 行)样例 SKU 正确拆为 4 行且合计 240; 全仓导出 6798 行(原 3650),有仓位行 6529、仓位为空兜底行 269,覆盖 26 仓
150 lines
5.7 KiB
JavaScript
150 lines
5.7 KiB
JavaScript
'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', stockQty: 7, salesQty: 0 },
|
||
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A02-03-1', stockQty: 5, 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,
|
||
});
|
||
|
||
// 每个仓位独立一行;无仓位的 SKU 保留一行(仓位为空、库存取总库存)
|
||
assert.equal(rows.length, 3);
|
||
assert.equal(rows[0].wmsCoId, '101');
|
||
assert.equal(rows[0].skuName, '款式A');
|
||
assert.equal(rows[0].binName, 'A01-01-1');
|
||
assert.equal(rows[0].stockQty, 7);
|
||
assert.equal(rows[1].binName, 'A02-03-1');
|
||
assert.equal(rows[1].stockQty, 5);
|
||
assert.equal(rows[2].binName, '');
|
||
assert.equal(rows[2].stockQty, 3);
|
||
|
||
// 口径必须与 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+仓位」聚合出仓位级库存,
|
||
// 每仓位一行(COALESCE 兜底无仓位 SKU 取总库存),排除无仓位与零库存记录
|
||
assert.ok(captured.sqlText.includes('rpt.jst_packInventoryInfo'));
|
||
assert.ok(captured.sqlText.includes('COALESCE(bn.binQty, g.stockQty)'));
|
||
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"));
|
||
});
|