- 后端新增 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」
231 lines
7.7 KiB
JavaScript
231 lines
7.7 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 仓库库存动销分析 —— 业务库取数
|
||
*
|
||
* 一次查询覆盖全部仓库 × 全部动销等级:
|
||
* 库存件数:rpt.jst_inventoryInfo 实时库存(按 wms_co_id + sku_id 汇总)
|
||
* 销量: rpt.jst_OrderItem × rpt.jst_Orders,按 send_date,排除 Delete/Cancelled
|
||
* (与「每日进销存」的发货口径一致)
|
||
* 日销: 销量 / 统计窗口天数 → 分档得到动销等级
|
||
*
|
||
* 只统计「有库存或有销量」的 SKU:库存为 0 且窗口内无销量的 SKU 已下架,
|
||
* 计入「销售=0」会虚增分母,故整体剔除。
|
||
*/
|
||
|
||
const { SALES_GRADE_RULES } = require('../config/stockSalesAnalysis');
|
||
|
||
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
|
||
function buildInClause(values, prefix) {
|
||
const params = {};
|
||
const clause = values
|
||
.map((v, i) => {
|
||
const key = `${prefix}${i}`;
|
||
params[key] = v;
|
||
return `@${key}`;
|
||
})
|
||
.join(',');
|
||
return { clause, params };
|
||
}
|
||
|
||
/**
|
||
* 日销分档表达式:从高档往低档依次命中。
|
||
* 最后一档「销售=0」由 `salesQty <= 0` 兜底(销量为 NULL 视同 0)。
|
||
*/
|
||
function buildGradeCase(salesExpr = 'ISNULL(b.salesQty, 0)', daysParam = '@days') {
|
||
const branches = SALES_GRADE_RULES
|
||
.filter((rule) => rule.lower > 0)
|
||
.map((rule) => {
|
||
const daily = `(${salesExpr} / ${daysParam})`;
|
||
const lower = `${daily} >= ${rule.lower}`;
|
||
const upper = rule.upper == null ? null : `${daily} < ${rule.upper}`;
|
||
const cond = upper ? `${lower} AND ${upper}` : lower;
|
||
return `WHEN ${cond} THEN '${rule.key}'`;
|
||
});
|
||
|
||
return `
|
||
CASE
|
||
${branches.join('\n ')}
|
||
WHEN ${salesExpr} > 0 THEN 'slow2'
|
||
ELSE 'zero'
|
||
END`;
|
||
}
|
||
|
||
/**
|
||
* 每个仓库 × 每个动销等级的 SKU 数、库存件数、销量。
|
||
* @returns {Promise<Array<{wmsCoId:string, gradeKey:string, skuCount:number, stockQty:number, salesQty:number, dailySales:number}>>}
|
||
*/
|
||
async function fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
|
||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||
throw new TypeError('fetchGradeRows 的第一个参数必须是业务库实例');
|
||
}
|
||
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
|
||
if (!shipFrom || !shipTo || !days) return [];
|
||
|
||
const invIn = buildInClause(wmsCoIds, 'w');
|
||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||
const gradeCase = buildGradeCase();
|
||
|
||
const sqlText = `
|
||
WITH inv AS (
|
||
SELECT i.wms_co_id AS wmsCoId,
|
||
i.sku_id AS skuId,
|
||
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
|
||
FROM rpt.jst_inventoryInfo i
|
||
WHERE i.wms_co_id IN (${invIn.clause})
|
||
GROUP BY i.wms_co_id, i.sku_id
|
||
),
|
||
ship AS (
|
||
SELECT o.wms_co_id AS wmsCoId,
|
||
oi.sku_id AS skuId,
|
||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
|
||
FROM rpt.jst_OrderItem oi
|
||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||
AND o.send_date >= @shipFrom
|
||
AND o.send_date < @shipTo
|
||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||
GROUP BY o.wms_co_id, oi.sku_id
|
||
),
|
||
base AS (
|
||
SELECT u.wmsCoId,
|
||
u.skuId,
|
||
SUM(u.stockQty) AS stockQty,
|
||
SUM(u.salesQty) AS salesQty
|
||
FROM (
|
||
SELECT wmsCoId, skuId, stockQty, CAST(0 AS DECIMAL(18, 2)) AS salesQty FROM inv
|
||
UNION ALL
|
||
SELECT wmsCoId, skuId, CAST(0 AS DECIMAL(18, 2)) AS stockQty, salesQty FROM ship
|
||
) u
|
||
GROUP BY u.wmsCoId, u.skuId
|
||
),
|
||
graded AS (
|
||
SELECT b.wmsCoId,
|
||
b.stockQty,
|
||
b.salesQty,
|
||
${gradeCase} AS gradeKey
|
||
FROM base b
|
||
WHERE ABS(ISNULL(b.stockQty, 0)) > 0.0001
|
||
OR ABS(ISNULL(b.salesQty, 0)) > 0.0001
|
||
)
|
||
SELECT g.wmsCoId,
|
||
g.gradeKey,
|
||
COUNT(*) AS skuCount,
|
||
CAST(SUM(ISNULL(g.stockQty, 0)) AS DECIMAL(18, 2)) AS stockQty,
|
||
CAST(SUM(ISNULL(g.salesQty, 0)) AS DECIMAL(18, 2)) AS salesQty,
|
||
CAST(SUM(ISNULL(g.salesQty, 0) / @days) AS DECIMAL(18, 4)) AS dailySales
|
||
FROM graded g
|
||
GROUP BY g.wmsCoId, g.gradeKey`;
|
||
|
||
const rows = await cloudDb.query(sqlText, {
|
||
...invIn.params,
|
||
...shipIn.params,
|
||
shipFrom,
|
||
shipTo,
|
||
days,
|
||
}, { requestTimeout: 180000 });
|
||
|
||
return rows.map((r) => ({
|
||
wmsCoId: String(r.wmsCoId),
|
||
gradeKey: String(r.gradeKey),
|
||
skuCount: Number(r.skuCount) || 0,
|
||
stockQty: Number(r.stockQty) || 0,
|
||
salesQty: Number(r.salesQty) || 0,
|
||
dailySales: Number(r.dailySales) || 0,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 「销售=0」档的 SKU 级明细(导出用)。
|
||
* 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero',
|
||
* 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。
|
||
* @returns {Promise<Array<{wmsCoId:string, skuId:string, skuName:string, stockQty:number, salesQty:number}>>}
|
||
*/
|
||
async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
|
||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||
throw new TypeError('fetchZeroSaleSkuRows 的第一个参数必须是业务库实例');
|
||
}
|
||
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
|
||
if (!shipFrom || !shipTo || !days) return [];
|
||
|
||
const invIn = buildInClause(wmsCoIds, 'w');
|
||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||
const gradeCase = buildGradeCase();
|
||
|
||
const sqlText = `
|
||
WITH inv AS (
|
||
SELECT i.wms_co_id AS wmsCoId,
|
||
i.sku_id AS skuId,
|
||
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
|
||
FROM rpt.jst_inventoryInfo i
|
||
WHERE i.wms_co_id IN (${invIn.clause})
|
||
GROUP BY i.wms_co_id, i.sku_id
|
||
),
|
||
ship AS (
|
||
SELECT o.wms_co_id AS wmsCoId,
|
||
oi.sku_id AS skuId,
|
||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
|
||
FROM rpt.jst_OrderItem oi
|
||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||
AND o.send_date >= @shipFrom
|
||
AND o.send_date < @shipTo
|
||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||
GROUP BY o.wms_co_id, oi.sku_id
|
||
),
|
||
base AS (
|
||
SELECT u.wmsCoId,
|
||
u.skuId,
|
||
SUM(u.stockQty) AS stockQty,
|
||
SUM(u.salesQty) AS salesQty
|
||
FROM (
|
||
SELECT wmsCoId, skuId, stockQty, CAST(0 AS DECIMAL(18, 2)) AS salesQty FROM inv
|
||
UNION ALL
|
||
SELECT wmsCoId, skuId, CAST(0 AS DECIMAL(18, 2)) AS stockQty, salesQty FROM ship
|
||
) u
|
||
GROUP BY u.wmsCoId, u.skuId
|
||
),
|
||
graded AS (
|
||
SELECT b.wmsCoId,
|
||
b.skuId,
|
||
b.stockQty,
|
||
b.salesQty,
|
||
${gradeCase} AS gradeKey
|
||
FROM base b
|
||
WHERE ABS(ISNULL(b.stockQty, 0)) > 0.0001
|
||
OR ABS(ISNULL(b.salesQty, 0)) > 0.0001
|
||
)
|
||
SELECT g.wmsCoId,
|
||
g.skuId,
|
||
ISNULL(s.name, '') AS skuName,
|
||
CAST(g.stockQty AS DECIMAL(18, 2)) AS stockQty,
|
||
CAST(g.salesQty AS DECIMAL(18, 2)) AS salesQty
|
||
FROM graded g
|
||
LEFT JOIN rpt.jst_sku s ON s.sku_id = g.skuId
|
||
WHERE g.gradeKey = 'zero'
|
||
ORDER BY g.wmsCoId, g.stockQty DESC, g.skuId`;
|
||
|
||
const rows = await cloudDb.query(sqlText, {
|
||
...invIn.params,
|
||
...shipIn.params,
|
||
shipFrom,
|
||
shipTo,
|
||
days,
|
||
}, { requestTimeout: 180000 });
|
||
|
||
return rows.map((r) => ({
|
||
wmsCoId: String(r.wmsCoId),
|
||
skuId: String(r.skuId ?? ''),
|
||
skuName: r.skuName == null ? '' : String(r.skuName),
|
||
stockQty: Number(r.stockQty) || 0,
|
||
salesQty: Number(r.salesQty) || 0,
|
||
}));
|
||
}
|
||
|
||
module.exports = {
|
||
buildInClause,
|
||
buildGradeCase,
|
||
fetchGradeRows,
|
||
fetchZeroSaleSkuRows,
|
||
};
|