cangku/backend/src/services/pickingBinSalesAnalysis.js
youruo 70550b5499 feat: 新增二级页面「拣选库位动销分析」(大货库存监控)
- 后端:pickingBinSalesAnalysis 配置/服务/路由,仓位库存取 rpt.jst_packInventoryInfo
  (bin_group 空值=拣选位、存货=存货位),销量按「SKU 占用同类仓位数平均分摊」,
  恒等式「全部库位销量之和 = 有库位 SKU 销量之和」差异为 0
- 动销等级(近 N 天日销,默认 15):爆款>=20、热卖款 10-20、平销款 3-10、
  滞销款-1 1-3、滞销款-2 (0,1)、销售=0
- 前端:大货库存监控下新增二级菜单 + 页面(等级卡片、仓库/类型/天数/仓位筛选、
  分页表格、xlsx 导出),路由/菜单/页面权限四处注册
- 测试:后端 8 项单测通过;前端 lint/test/build 通过
2026-10-07 16:59:39 +08:00

182 lines
6.2 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',
}));
}
module.exports = {
buildGradeCase,
buildInClause,
fetchPickingBinRows,
};