forked from lunanting/cangku
- 新增 fetchPickingBinSkuRows:仓位 × 商品编码 粒度明细 库存数量 = 该商品在此仓位的实际库存;销量 = 商品销量 ÷ 其占用同类仓位数(贡献) - 导出列:仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / SKU数 / 销量 / 日销 / 动销等级 / 统计天数 / 统计基准日 - SKU数、动销等级仍为仓位粒度(同一仓位多行重复),列表页口径不变(仍用 bin 级聚合) - 同一次取数比对:7055 个仓位,列表与导出库存/销量 0 处差异,库存合计一致 497721 - 单测 11 条通过
334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 拣选库位动销分析 —— 业务库取数
|
||
*
|
||
* 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty(仓位为空的行不参与)
|
||
* 类型:bin_group 为空 → 拣选位;否则取 bin_group(业务库目前只有「存货」)
|
||
* 销量:rpt.jst_Orders × rpt.jst_OrderItem,按 send_date,排除 Delete/Cancelled
|
||
* 分摊:SKU 销量 ÷ 该 SKU 占用的同类型仓位数,再按库位累加
|
||
* 保证「全部库位销量之和 = 全仓销量」
|
||
* 日销:库位销量 / 窗口天数 → 分档得到动销等级
|
||
*/
|
||
|
||
const { PICKING_BIN_GRADE_RULES } = require('../config/pickingBinSalesAnalysis');
|
||
|
||
/** 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 兜底。
|
||
*/
|
||
function buildGradeCase(salesExpr = 'ISNULL(x.salesQty, 0)', daysParam = '@days') {
|
||
const branches = PICKING_BIN_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`;
|
||
}
|
||
|
||
/**
|
||
* 拉取各仓库每个仓位的库存、销量、日销与动销等级。
|
||
*
|
||
* @param {object} cloudDb 业务库实例
|
||
* @param {string[]} wmsCoIds 仓库编码
|
||
* @param {{shipFrom:string, shipTo:string, days:number, binTypes?:string[]}} options
|
||
* @returns {Promise<Array<{wmsCoId:string, binName:string, binType:string,
|
||
* stockQty:number, skuCount:number, salesQty:number, dailySales:number, gradeKey:string}>>}
|
||
*/
|
||
async function fetchPickingBinRows(cloudDb, wmsCoIds, options = {}) {
|
||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||
throw new TypeError('fetchPickingBinRows 的第一个参数必须是业务库实例');
|
||
}
|
||
const { shipFrom, shipTo, days, binTypes } = options;
|
||
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
|
||
if (!shipFrom || !shipTo || !days) return [];
|
||
|
||
const binIn = buildInClause(wmsCoIds, 'w');
|
||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||
const gradeCase = buildGradeCase();
|
||
|
||
// 仓位类型过滤(不传 = 全部类型)
|
||
let typeFilter = '';
|
||
const typeParams = {};
|
||
if (Array.isArray(binTypes) && binTypes.length > 0) {
|
||
const t = buildInClause(binTypes, 't');
|
||
Object.assign(typeParams, t.params);
|
||
typeFilter = `AND a.binType IN (${t.clause})`;
|
||
}
|
||
|
||
const sqlText = `
|
||
WITH bin AS (
|
||
SELECT p.wms_co_id AS wmsCoId,
|
||
LTRIM(RTRIM(p.bin)) AS binName,
|
||
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
|
||
ELSE LTRIM(RTRIM(p.bin_group)) END AS binType,
|
||
p.sku_id AS skuId,
|
||
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binSkuQty
|
||
FROM rpt.jst_packInventoryInfo p
|
||
WHERE p.wms_co_id IN (${binIn.clause})
|
||
AND ISNULL(p.bin, '') <> ''
|
||
AND ISNULL(p.qty, 0) <> 0
|
||
GROUP BY p.wms_co_id,
|
||
LTRIM(RTRIM(p.bin)),
|
||
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
|
||
ELSE LTRIM(RTRIM(p.bin_group)) END,
|
||
p.sku_id
|
||
),
|
||
binSpan AS (
|
||
SELECT wmsCoId, skuId, binType, COUNT(DISTINCT binName) AS binCount
|
||
FROM bin
|
||
GROUP BY wmsCoId, skuId, binType
|
||
),
|
||
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
|
||
),
|
||
binSales AS (
|
||
SELECT b.wmsCoId,
|
||
b.binName,
|
||
b.binType,
|
||
SUM(CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 4)) / NULLIF(sp.binCount, 0)) AS salesQty
|
||
FROM bin b
|
||
JOIN binSpan sp
|
||
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
|
||
LEFT JOIN ship s
|
||
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
|
||
GROUP BY b.wmsCoId, b.binName, b.binType
|
||
),
|
||
binAgg AS (
|
||
SELECT wmsCoId,
|
||
binName,
|
||
binType,
|
||
SUM(binSkuQty) AS stockQty,
|
||
COUNT(DISTINCT skuId) AS skuCount
|
||
FROM bin
|
||
GROUP BY wmsCoId, binName, binType
|
||
)
|
||
SELECT a.wmsCoId,
|
||
a.binName,
|
||
a.binType,
|
||
CAST(a.stockQty AS DECIMAL(18, 2)) AS stockQty,
|
||
CAST(a.skuCount AS INT) AS skuCount,
|
||
CAST(ISNULL(x.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
|
||
CAST(ISNULL(x.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
|
||
${gradeCase} AS gradeKey
|
||
FROM binAgg a
|
||
LEFT JOIN binSales x
|
||
ON x.wmsCoId = a.wmsCoId AND x.binName = a.binName AND x.binType = a.binType
|
||
WHERE ABS(ISNULL(a.stockQty, 0)) > 0.0001
|
||
${typeFilter}
|
||
ORDER BY a.wmsCoId, a.binType, a.binName`;
|
||
|
||
const rows = await cloudDb.query(
|
||
sqlText,
|
||
{
|
||
...binIn.params,
|
||
...shipIn.params,
|
||
...typeParams,
|
||
shipFrom,
|
||
shipTo,
|
||
days,
|
||
},
|
||
{ requestTimeout: 120000 }
|
||
);
|
||
|
||
return rows.map((r) => ({
|
||
wmsCoId: String(r.wmsCoId),
|
||
binName: r.binName || '',
|
||
binType: r.binType || '',
|
||
stockQty: Number(r.stockQty) || 0,
|
||
skuCount: Number(r.skuCount) || 0,
|
||
salesQty: Number(r.salesQty) || 0,
|
||
dailySales: Number(r.dailySales) || 0,
|
||
gradeKey: r.gradeKey || 'zero',
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 拉取「仓位 × 商品编码」粒度明细(导出用)。
|
||
*
|
||
* 与 fetchPickingBinRows 的差别:每个仓位有 N 个 SKU 就输出 N 行。
|
||
* - 库存数量:该 SKU 在这个仓位的实际库存
|
||
* - 销量:该 SKU 的销量 ÷ 它占用的同类型仓位数(即这个组合对仓位的贡献),
|
||
* 同一 SKU 出现在多个仓位时各行数值相同;全部行求和仍等于「有库位 SKU 销量之和」
|
||
* - skuCount / binSalesQty / gradeKey:仍是仓位粒度,同一仓位的多行重复显示
|
||
*
|
||
* @param {object} cloudDb 业务库实例
|
||
* @param {string[]} wmsCoIds 仓库编码
|
||
* @param {{shipFrom:string, shipTo:string, days:number, binTypes?:string[]}} options
|
||
* @returns {Promise<Array<{wmsCoId:string, binName:string, binType:string, skuId:string,
|
||
* stockQty:number, skuCount:number, salesQty:number, dailySales:number,
|
||
* binSalesQty:number, gradeKey:string}>>}
|
||
*/
|
||
async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||
throw new TypeError('fetchPickingBinSkuRows 的第一个参数必须是业务库实例');
|
||
}
|
||
const { shipFrom, shipTo, days, binTypes } = options;
|
||
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
|
||
if (!shipFrom || !shipTo || !days) return [];
|
||
|
||
const binIn = buildInClause(wmsCoIds, 'w');
|
||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||
|
||
let typeFilter = '';
|
||
const typeParams = {};
|
||
if (Array.isArray(binTypes) && binTypes.length > 0) {
|
||
const t = buildInClause(binTypes, 't');
|
||
Object.assign(typeParams, t.params);
|
||
typeFilter = `AND a.binType IN (${t.clause})`;
|
||
}
|
||
|
||
const sqlText = `
|
||
WITH bin AS (
|
||
SELECT p.wms_co_id AS wmsCoId,
|
||
LTRIM(RTRIM(p.bin)) AS binName,
|
||
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
|
||
ELSE LTRIM(RTRIM(p.bin_group)) END AS binType,
|
||
p.sku_id AS skuId,
|
||
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binSkuQty
|
||
FROM rpt.jst_packInventoryInfo p
|
||
WHERE p.wms_co_id IN (${binIn.clause})
|
||
AND ISNULL(p.bin, '') <> ''
|
||
AND ISNULL(p.qty, 0) <> 0
|
||
GROUP BY p.wms_co_id,
|
||
LTRIM(RTRIM(p.bin)),
|
||
CASE WHEN ISNULL(p.bin_group, '') = '' THEN N'${'拣选'}'
|
||
ELSE LTRIM(RTRIM(p.bin_group)) END,
|
||
p.sku_id
|
||
),
|
||
binSpan AS (
|
||
SELECT wmsCoId, skuId, binType, COUNT(DISTINCT binName) AS binCount
|
||
FROM bin
|
||
GROUP BY wmsCoId, skuId, binType
|
||
),
|
||
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
|
||
),
|
||
binSales AS (
|
||
SELECT b.wmsCoId,
|
||
b.binName,
|
||
b.binType,
|
||
SUM(CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 4)) / NULLIF(sp.binCount, 0)) AS salesQty
|
||
FROM bin b
|
||
JOIN binSpan sp
|
||
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
|
||
LEFT JOIN ship s
|
||
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
|
||
GROUP BY b.wmsCoId, b.binName, b.binType
|
||
),
|
||
binAgg AS (
|
||
SELECT wmsCoId,
|
||
binName,
|
||
binType,
|
||
SUM(binSkuQty) AS stockQty,
|
||
COUNT(DISTINCT skuId) AS skuCount
|
||
FROM bin
|
||
GROUP BY wmsCoId, binName, binType
|
||
),
|
||
skuRows AS (
|
||
SELECT b.wmsCoId,
|
||
b.binName,
|
||
b.binType,
|
||
b.skuId,
|
||
SUM(CAST(b.binSkuQty AS DECIMAL(18, 2))) AS stockQty,
|
||
SUM(CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 4)) / NULLIF(sp.binCount, 0)) AS salesQty
|
||
FROM bin b
|
||
JOIN binSpan sp
|
||
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
|
||
LEFT JOIN ship s
|
||
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
|
||
GROUP BY b.wmsCoId, b.binName, b.binType, b.skuId
|
||
)
|
||
SELECT sk.wmsCoId,
|
||
sk.binName,
|
||
sk.binType,
|
||
sk.skuId,
|
||
CAST(sk.stockQty AS DECIMAL(18, 2)) AS stockQty,
|
||
CAST(a.skuCount AS INT) AS skuCount,
|
||
CAST(ISNULL(sk.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
|
||
CAST(ISNULL(sk.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
|
||
CAST(ISNULL(x.salesQty, 0) AS DECIMAL(18, 2)) AS binSalesQty,
|
||
${buildGradeCase('ISNULL(x.salesQty, 0)')} AS gradeKey
|
||
FROM skuRows sk
|
||
JOIN binAgg a
|
||
ON a.wmsCoId = sk.wmsCoId AND a.binName = sk.binName AND a.binType = sk.binType
|
||
LEFT JOIN binSales x
|
||
ON x.wmsCoId = sk.wmsCoId AND x.binName = sk.binName AND x.binType = sk.binType
|
||
WHERE ABS(ISNULL(sk.stockQty, 0)) > 0.0001
|
||
${typeFilter}
|
||
ORDER BY sk.wmsCoId, sk.binType, sk.binName, sk.skuId`;
|
||
|
||
const rows = await cloudDb.query(
|
||
sqlText,
|
||
{
|
||
...binIn.params,
|
||
...shipIn.params,
|
||
...typeParams,
|
||
shipFrom,
|
||
shipTo,
|
||
days,
|
||
},
|
||
{ requestTimeout: 120000 }
|
||
);
|
||
|
||
return rows.map((r) => ({
|
||
wmsCoId: String(r.wmsCoId),
|
||
binName: r.binName || '',
|
||
binType: r.binType || '',
|
||
skuId: r.skuId == null ? '' : String(r.skuId).trim(),
|
||
stockQty: Number(r.stockQty) || 0,
|
||
skuCount: Number(r.skuCount) || 0,
|
||
salesQty: Number(r.salesQty) || 0,
|
||
dailySales: Number(r.dailySales) || 0,
|
||
binSalesQty: Number(r.binSalesQty) || 0,
|
||
gradeKey: r.gradeKey || 'zero',
|
||
}));
|
||
}
|
||
|
||
module.exports = {
|
||
buildGradeCase,
|
||
buildInClause,
|
||
fetchPickingBinRows,
|
||
fetchPickingBinSkuRows,
|
||
};
|