- 后端新增 GET /api/stock-sales-analysis/zero-sales-export: 复用与页面完全一致的分档口径(同一段 gradeCase 表达式 + 「有库存或有销量」过滤), 输出仓库/SKU编码/SKU名称/库存数量/销量/统计天数/统计基准日的 SKU 级明细; 支持.days 统计窗口、wmsCoId 逗号分隔过滤(仅允许仓库清单内的 ID,防任意拼参)、 format=xlsx|csv,复用通用导出服务 exportWorkbook - services 新增 fetchZeroSaleSkuRows:SKU 级「销售=0」查询,LEFT JOIN rpt.jst_sku 取名称, 按仓库+库存倒序排列 - 前端工具栏新增「导出销售=0」按钮:跟随当前统计天数与仓库筛选, 文件名含业务日期与仓库范围,失败时解出错误体给出可读提示 - 测试补充 3 条:防呆抛错、空参短路、SQL 口径与参数传递断言 验证:后端 syntax 通过、单测 8/8(全量 389 条仅既有 Windows 环境项失败); 前端 eslint/build/test 21/21 通过; 本地实测:全仓导出 3641 行、26 仓、销量列全为 0; 单仓(崔桥半成品包装 694 行)与页面「销售=0」SKU 数一致; 浏览器实测点击下载真实文件名「库存动销分析-销售0明细-20260926.xlsx」
121 lines
4.3 KiB
JavaScript
121 lines
4.3 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', stockQty: 12, 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, 1);
|
|
assert.equal(rows[0].wmsCoId, '101');
|
|
assert.equal(rows[0].skuName, '款式A');
|
|
assert.equal(rows[0].stockQty, 12);
|
|
|
|
// 口径必须与 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);
|
|
});
|