cangku/backend/src/services/pickingBinSalesAnalysis.js
youruo 31f184edd0 feat: 拣选库位动销分析导出增加商品编码,按仓位×商品编码拆行
- 新增 fetchPickingBinSkuRows:仓位 × 商品编码 粒度明细
  库存数量 = 该商品在此仓位的实际库存;销量 = 商品销量 ÷ 其占用同类仓位数(贡献)
- 导出列:仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / SKU数 / 销量 / 日销 / 动销等级 / 统计天数 / 统计基准日
- SKU数、动销等级仍为仓位粒度(同一仓位多行重复),列表页口径不变(仍用 bin 级聚合)
- 同一次取数比对:7055 个仓位,列表与导出库存/销量 0 处差异,库存合计一致 497721
- 单测 11 条通过
2026-10-08 09:35:26 +08:00

334 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
};