'use strict'; /** * 拣选库位动销分析 —— 业务库取数 * * 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty(仓位为空的行不参与) * 范围:只统计拣选位(bin_group 为空);存货位(bin_group=存货)不纳入 * 销量:rpt.jst_Orders × rpt.jst_OrderItem,按 send_date,排除 Delete/Cancelled * 粒度:仓位 × 商品编码(一个仓位有 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>} */ 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'); // 统计范围固定为拣选位:直接在仓位库存表上过滤 bin_group 为空, // 存货位(bin_group=存货)不参与动销等级统计。 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 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 ( 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 ), 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 ship 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`; const rows = await cloudDb.query( 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, };