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