forked from lunanting/cangku
仓位用于定位滞销库存的实际存放位置,便于盘点与挪位处理。 数据源为 rpt.jst_packInventoryInfo.bin(jst_sku.bin 全部为空、库存表无仓位字段, 这是业务库唯一可得的系统仓位): - 按 (wms_co_id, sku_id) 聚合,LTRIM 去空格后 DISTINCT,STRING_AGG 按名称排序用「、」连接 - 排除无仓位(bin 为空)与零库存(qty=0)的行,避免无效仓位干扰 - LEFT JOIN 且按「仓库+SKU」双条件匹配,避免跨仓错配 - 导出列顺序:仓库 | SKU编码 | SKU名称 | 系统仓位 | 库存数量 | 销量 | 统计天数 | 统计基准日 - 前端按钮提示同步说明多仓位拼接规则与留空含义 验证:后端 syntax 通过、单测 9/9(全量 389/390,唯一失败为既有 Windows 权限环境项); 前端 eslint/build/测试均通过; 本地实测导出 3650 行,系统仓位列就位,3381 行有仓位(92.6%,未覆盖的 7.4% 为 仓位表仅含 13 个仓库所致),销量列全为 0,最长仓位串 600 字符(Excel 上限内); 浏览器实测点击下载,文件名与列结构正确
250 lines
8.6 KiB
JavaScript
250 lines
8.6 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。
|
||
* 系统仓位取自 rpt.jst_packInventoryInfo.bin(一个 SKU 可能占多个仓位,按名称排序后用「、」连接;
|
||
* 库存为 0 的仓位记录已排除)。该表只覆盖部分仓库,未覆盖到的 SKU 返回空串。
|
||
* @returns {Promise<Array<{wmsCoId:string, skuId:string, skuName:string, binName: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 bin AS (
|
||
SELECT d.wmsCoId,
|
||
d.skuId,
|
||
STRING_AGG(CAST(d.b AS NVARCHAR(MAX)), '、') WITHIN GROUP (ORDER BY d.b) AS binName
|
||
FROM (
|
||
SELECT DISTINCT p.wms_co_id AS wmsCoId,
|
||
p.sku_id AS skuId,
|
||
LTRIM(RTRIM(p.bin)) AS b
|
||
FROM rpt.jst_packInventoryInfo p
|
||
WHERE ISNULL(p.bin, '') <> ''
|
||
AND ISNULL(p.qty, 0) <> 0
|
||
) d
|
||
GROUP BY d.wmsCoId, d.skuId
|
||
),
|
||
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,
|
||
ISNULL(bn.binName, '') AS binName,
|
||
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
|
||
LEFT JOIN bin bn ON bn.wmsCoId = g.wmsCoId AND bn.skuId = 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),
|
||
binName: r.binName == null ? '' : String(r.binName),
|
||
stockQty: Number(r.stockQty) || 0,
|
||
salesQty: Number(r.salesQty) || 0,
|
||
}));
|
||
}
|
||
|
||
module.exports = {
|
||
buildInClause,
|
||
buildGradeCase,
|
||
fetchGradeRows,
|
||
fetchZeroSaleSkuRows,
|
||
};
|