4 个销量来源 union all 后各分支各自 GROUP BY,同一个 仓库+商品 命中多个 分支时外层层 LEFT JOIN 没有二次聚合,一个仓位行被拆成多行、每行只带某一 个分支的销量(实测 SJ038411 同时出现 sales=0 与 sales=44 两行)。 新增 shipAgg CTE:按 仓库+商品 对各来源求和成一行,再与仓位行 join。 SalesQty/分摊销量/日销/等级随之统一。
244 lines
11 KiB
JavaScript
244 lines
11 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 拣选库位动销分析 —— 业务库取数
|
||
*
|
||
* 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty(仓位为空的行不参与)
|
||
* 范围:只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);
|
||
* 箱记录(pack_type=Pack)与存货位(bin_group=存货)不纳入
|
||
* 销量(4 个来源分支,均按 send_date 窗口):
|
||
* 1) rpt.jst_Orders × jst_OrderItem 普通行(sku_type<>combine),排除 Delete/Cancelled
|
||
* 2) rpt.jst_Orders 组合装行(sku_id 为 ZH-... 拼接串)→ JOIN jst_comboProductItem 展开为子件×数量
|
||
* 3) rpt.jst_qmcust_orderlist(全民客户 B2B 分销)普通行,排除 Delete/Cancelled/Split/Question
|
||
* (Split 是拆单父单,与拆出的子单重复,必须排除)
|
||
* 4) rpt.jst_qmcust_orderlist 组合装行 → 同样按映射表展开
|
||
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用
|
||
*
|
||
* 【日销与动销等级口径】按「商品日销」判定,不做仓位分摊:
|
||
* 商品日销 = 该商品在窗口内的整档销量 ÷ 窗口天数
|
||
* 同一件商品无论占用几个仓位,各行显示的销量 / 日销 / 动销等级完全一致 ——
|
||
* 不会因为货放得散就被算成滞销。
|
||
*
|
||
* 附带输出「分摊销量」= 商品销量 ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景:
|
||
* 恒等式(已实测校验,差异 0):全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。
|
||
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
|
||
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
|
||
*/
|
||
|
||
const { PICKING_BIN_GRADE_RULES, BIN_TYPE_PICKING } = 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 兜底。
|
||
* 传入的 salesExpr 是窗口销量(商品整档销量),除以天数得到日销。
|
||
*/
|
||
function buildGradeCase(salesExpr = 'ISNULL(s.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`;
|
||
}
|
||
|
||
/**
|
||
* 拉取「仓位 × 商品编码」粒度明细(列表与导出共用)。
|
||
*
|
||
* - stockQty:该商品在此仓位的实际库存
|
||
* - salesQty:该商品在此仓库的窗口销量(整档,不按仓位分摊),动销等级的判定依据
|
||
* - dailySales:salesQty / 窗口天数 = 商品日销
|
||
* - allocatedSalesQty:salesQty ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景
|
||
* - skuCount:该仓位内存放的商品数(仓位粒度,同一仓位多行重复)
|
||
*
|
||
* @param {object} cloudDb 业务库实例
|
||
* @param {string[]} wmsCoIds 仓库编码
|
||
* @param {{shipFrom:string, shipTo:string, days:number}} options
|
||
* @returns {Promise<Array<{wmsCoId:string, binName:string, binType:string, skuId:string,
|
||
* stockQty:number, skuCount:number, salesQty:number, allocatedSalesQty:number,
|
||
* dailySales:number, gradeKey:string}>>}
|
||
*/
|
||
async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||
throw new TypeError('fetchPickingBinSkuRows 的第一个参数必须是业务库实例');
|
||
}
|
||
const { shipFrom, shipTo, days } = options;
|
||
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
|
||
if (!shipFrom || !shipTo || !days) return [];
|
||
|
||
const binIn = buildInClause(wmsCoIds, 'w');
|
||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||
|
||
// 统计范围固定为真实拣选仓位:pack_type=Bin 且 bin_group 为空。
|
||
// 仅过滤 bin_group 会把同样为空的 Pack 箱记录误标为拣选位。
|
||
const sqlText = `
|
||
WITH bin AS (
|
||
SELECT p.wms_co_id AS wmsCoId,
|
||
LTRIM(RTRIM(p.bin)) AS binName,
|
||
N'${BIN_TYPE_PICKING}' 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
|
||
AND p.pack_type = N'Bin'
|
||
AND ISNULL(p.bin_group, '') = ''
|
||
GROUP BY p.wms_co_id,
|
||
LTRIM(RTRIM(p.bin)),
|
||
p.sku_id
|
||
),
|
||
binSpan AS (
|
||
SELECT wmsCoId, skuId, binType, COUNT(DISTINCT binName) AS binCount
|
||
FROM bin
|
||
GROUP BY wmsCoId, skuId, binType
|
||
),
|
||
ship AS (
|
||
-- 1) 线上店铺订单:普通商品行(sku_type<>combine,组合装行在下一分支展开)
|
||
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')
|
||
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
|
||
GROUP BY o.wms_co_id, oi.sku_id
|
||
UNION ALL
|
||
-- 2) 线上店铺订单:组合装行(sku_id 为 ZH-子件A-N-子件B-M 拼接串)→ 按映射表展开为子件
|
||
SELECT o.wms_co_id AS wmsCoId,
|
||
ci.src_sku_id AS skuId,
|
||
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.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
|
||
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_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')
|
||
AND oi.sku_type = 'combine'
|
||
GROUP BY o.wms_co_id, ci.src_sku_id
|
||
UNION ALL
|
||
-- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
|
||
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_qmcust_orderlist_item oi
|
||
JOIN rpt.jst_qmcust_orderlist 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', 'Split', 'Question')
|
||
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
|
||
GROUP BY o.wms_co_id, oi.sku_id
|
||
UNION ALL
|
||
-- 4) 全民客户订单:组合装行展开为子件
|
||
SELECT o.wms_co_id AS wmsCoId,
|
||
ci.src_sku_id AS skuId,
|
||
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18, 2))) AS salesQty
|
||
FROM rpt.jst_qmcust_orderlist_item oi
|
||
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
|
||
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_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', 'Split', 'Question')
|
||
AND oi.sku_type = 'combine'
|
||
GROUP BY o.wms_co_id, ci.src_sku_id
|
||
),
|
||
shipAgg AS (
|
||
-- 同一个 仓库+商品 可能同时命中多个来源分支(线上普通/线上组合装/全民客户普通/全民客户组合装),
|
||
-- 必须先按 仓库+商品 汇总成一行再 join,否则一个仓位行会被拆成多行、每行只带某分支的销量。
|
||
SELECT wmsCoId,
|
||
skuId,
|
||
SUM(CAST(salesQty AS DECIMAL(18, 2))) AS salesQty
|
||
FROM ship
|
||
GROUP BY wmsCoId, skuId
|
||
),
|
||
binAgg AS (
|
||
SELECT wmsCoId,
|
||
binName,
|
||
binType,
|
||
COUNT(DISTINCT skuId) AS skuCount
|
||
FROM bin
|
||
GROUP BY wmsCoId, binName, binType
|
||
)
|
||
SELECT b.wmsCoId,
|
||
b.binName,
|
||
b.binType,
|
||
b.skuId,
|
||
CAST(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2))) AS DECIMAL(18, 2)) AS stockQty,
|
||
CAST(a.skuCount AS INT) AS skuCount,
|
||
CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
|
||
CAST(ISNULL(s.salesQty, 0) / NULLIF(sp.binCount, 0) AS DECIMAL(18, 4)) AS allocatedSalesQty,
|
||
CAST(ISNULL(s.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
|
||
${buildGradeCase('ISNULL(s.salesQty, 0)')} AS gradeKey
|
||
FROM bin b
|
||
JOIN binSpan sp
|
||
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
|
||
JOIN binAgg a
|
||
ON a.wmsCoId = b.wmsCoId AND a.binName = b.binName AND a.binType = b.binType
|
||
LEFT JOIN shipAgg s
|
||
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
|
||
GROUP BY b.wmsCoId, b.binName, b.binType, b.skuId, a.skuCount, s.salesQty, sp.binCount
|
||
HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001
|
||
ORDER BY b.wmsCoId, b.binType, b.binName, b.skuId`;
|
||
|
||
// 该查询 union 了 4 个销量来源(含 qmcust 全渠道扫描),全仓窗口可能超过默认 60s;
|
||
// mssql 12.x 请求级 timeout 不生效,必须走池级放宽的慢查询入口。
|
||
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
|
||
const rows = await runQuery(
|
||
sqlText,
|
||
{
|
||
...binIn.params,
|
||
...shipIn.params,
|
||
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,
|
||
allocatedSalesQty: Number(r.allocatedSalesQty) || 0,
|
||
dailySales: Number(r.dailySales) || 0,
|
||
gradeKey: r.gradeKey || 'zero',
|
||
}));
|
||
}
|
||
|
||
module.exports = {
|
||
buildGradeCase,
|
||
buildInClause,
|
||
fetchPickingBinSkuRows,
|
||
};
|