From 70550b5499d4597ffaf7bed83ae46a6665296979 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 7 Oct 2026 16:59:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BA=8C=E7=BA=A7?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E3=80=8C=E6=8B=A3=E9=80=89=E5=BA=93=E4=BD=8D?= =?UTF-8?q?=E5=8A=A8=E9=94=80=E5=88=86=E6=9E=90=E3=80=8D=EF=BC=88=E5=A4=A7?= =?UTF-8?q?=E8=B4=A7=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: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 通过 --- backend/src/api/pickingBinSalesAnalysis.js | 263 ++++++++++++++++ backend/src/config/pickingBinSalesAnalysis.js | 66 ++++ backend/src/constants/sidebarPages.js | 5 + backend/src/index.js | 2 + .../src/services/pickingBinSalesAnalysis.js | 181 +++++++++++ backend/test/pickingBinSalesAnalysis.test.js | 119 +++++++ frontend/src/api/pickingBinSalesAnalysis.js | 16 + frontend/src/config/sidebarNavigation.js | 1 + .../src/constants/pickingBinSalesAnalysis.js | 87 ++++++ frontend/src/constants/sidebarPages.js | 1 + frontend/src/layouts/BasicLayout.jsx | 1 + .../pages/PickingBinSalesAnalysis/index.jsx | 295 ++++++++++++++++++ .../pages/PickingBinSalesAnalysis/style.css | 93 ++++++ frontend/src/router/index.jsx | 5 + 14 files changed, 1135 insertions(+) create mode 100644 backend/src/api/pickingBinSalesAnalysis.js create mode 100644 backend/src/config/pickingBinSalesAnalysis.js create mode 100644 backend/src/services/pickingBinSalesAnalysis.js create mode 100644 backend/test/pickingBinSalesAnalysis.test.js create mode 100644 frontend/src/api/pickingBinSalesAnalysis.js create mode 100644 frontend/src/constants/pickingBinSalesAnalysis.js create mode 100644 frontend/src/pages/PickingBinSalesAnalysis/index.jsx create mode 100644 frontend/src/pages/PickingBinSalesAnalysis/style.css diff --git a/backend/src/api/pickingBinSalesAnalysis.js b/backend/src/api/pickingBinSalesAnalysis.js new file mode 100644 index 0000000..ef71daa --- /dev/null +++ b/backend/src/api/pickingBinSalesAnalysis.js @@ -0,0 +1,263 @@ +const express = require('express'); +const cloudDb = require('../config/cloudDatabase'); +const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline'); +const { + PICKING_BIN_GRADE_RULES, + PICKING_BIN_WINDOW_OPTIONS, + PICKING_BIN_DEFAULT_DAYS, + PICKING_BIN_TOTAL_LABEL, + BIN_TYPES, + PICKING_BIN_DEFAULT_TYPES, +} = require('../config/pickingBinSalesAnalysis'); +const { fetchPickingBinRows } = require('../services/pickingBinSalesAnalysis'); +const { sendWorkbookDownload } = require('../services/exportWorkbook'); + +const router = express.Router(); + +/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */ +const AS_OF_TIME = '01:00'; + +/** 结果缓存:一次要扫订单明细 + 仓位明细两张大表,避免连续刷新重复打库 */ +const RESULT_CACHE_TTL_MS = 5 * 60 * 1000; +const cacheByKey = new Map(); + +const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100; +const round4 = (v) => Math.round((Number(v) || 0) * 10000) / 10000; + +/** 快照日 + 次日 01:00 作为统一的统计基准时刻 */ +function nextDayAsOf(bizDate) { + if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null; + const d = new Date(`${bizDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`; +} + +/** 发货窗口:[asOf - N 天, asOf) */ +function buildShipWindow(asOf, days) { + const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`); + const from = new Date(to.getTime() - days * 86400000); + const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' '); + return { shipFrom: fmt(from), shipTo: fmt(to) }; +} + +async function fetchLatestBizDate() { + const rows = await cloudDb.query( + `SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor` + ); + return rows[0]?.d || null; +} + +async function fetchBinTakenAt() { + const rows = await cloudDb.query( + `SELECT MAX(updateTime) AS takenAt FROM rpt.jst_packInventoryInfo` + ); + return rows[0]?.takenAt || null; +} + +/** 解析并收敛统计窗口天数,只允许白名单内的取值 */ +function resolveDays(raw) { + const n = Number.parseInt(String(raw ?? ''), 10); + return PICKING_BIN_WINDOW_OPTIONS.includes(n) ? n : PICKING_BIN_DEFAULT_DAYS; +} + +/** 解析仓位类型,只保留已知类型;空 → 默认(拣选位) */ +function resolveBinTypes(raw) { + if (raw === undefined || raw === null || String(raw).trim() === '') { + return [...PICKING_BIN_DEFAULT_TYPES]; + } + const list = String(raw) + .split(',') + .map((v) => v.trim()) + .filter((v) => BIN_TYPES.includes(v)); + return list.length > 0 ? list : [...PICKING_BIN_DEFAULT_TYPES]; +} + +/** 解析仓库过滤,只允许出现在仓库清单里的 wmsCoId,防止任意拼参 */ +function resolveWarehouses(raw) { + const known = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null); + const requested = String(raw || '') + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + const picked = requested.length > 0 + ? known.filter((b) => requested.includes(String(b.wmsCoId))) + : known; + return picked; +} + +const GRADE_MAP = new Map(PICKING_BIN_GRADE_RULES.map((g) => [g.key, g])); + +/** + * GET /api/picking-bin-sales-analysis + * 拣选库位动销分析:仓库 × 仓位 → 类型 / 库存数量 / SKU数 / 近N天销量 / 日销 / 动销等级 + */ +router.get('/', async (req, res) => { + try { + const force = String(req.query?.refresh || '') === '1'; + const days = resolveDays(req.query?.days); + const binTypes = resolveBinTypes(req.query?.binType); + const warehouses = resolveWarehouses(req.query?.wmsCoId); + const wmsCoIds = warehouses.map((b) => String(b.wmsCoId)); + + const cacheKey = `${days}::${binTypes.join('|')}::${wmsCoIds.join('|')}`; + const latestBizDate = await fetchLatestBizDate().catch(() => null); + const cached = cacheByKey.get(cacheKey); + if (!force && cached && cached.latestBizDate === latestBizDate + && Date.now() - cached.at < RESULT_CACHE_TTL_MS) { + return res.json({ success: true, data: cached.payload, cached: true }); + } + + const asOf = nextDayAsOf(latestBizDate); + if (!asOf) { + return res.status(503).json({ + success: false, + error: '业务库暂无库存监控数据,无法定位统计基准日', + }); + } + + const { shipFrom, shipTo } = buildShipWindow(asOf, days); + const [rows, takenAt] = await Promise.all([ + fetchPickingBinRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes }), + fetchBinTakenAt().catch(() => null), + ]); + + const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse])); + const items = rows.map((r) => ({ + ...r, + warehouse: whMap.get(r.wmsCoId) || r.wmsCoId, + dailySales: round4(r.dailySales), + gradeLabel: GRADE_MAP.get(r.gradeKey)?.label || '', + })); + + // 各动销等级汇总 + const summary = PICKING_BIN_GRADE_RULES.map((grade) => { + const hit = items.filter((it) => it.gradeKey === grade.key); + const salesQty = round2(hit.reduce((acc, it) => acc + it.salesQty, 0)); + return { + key: grade.key, + label: grade.label, + tip: grade.tip, + binCount: hit.length, + stockQty: round2(hit.reduce((acc, it) => acc + it.stockQty, 0)), + skuCount: hit.reduce((acc, it) => acc + it.skuCount, 0), + salesQty, + dailySales: round4(salesQty / days), + }; + }); + + const totalSales = round2(summary.reduce((acc, s) => acc + s.salesQty, 0)); + const total = { + key: '__total__', + label: PICKING_BIN_TOTAL_LABEL, + binCount: summary.reduce((acc, s) => acc + s.binCount, 0), + stockQty: round2(summary.reduce((acc, s) => acc + s.stockQty, 0)), + skuCount: summary.reduce((acc, s) => acc + s.skuCount, 0), + salesQty: totalSales, + dailySales: round4(totalSales / days), + }; + + const payload = { + days, + windowOptions: PICKING_BIN_WINDOW_OPTIONS, + binTypes, + binTypeOptions: BIN_TYPES, + latestBizDate, + asOf, + shipWindow: { shipFrom, shipTo, days }, + takenAt, + grades: PICKING_BIN_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })), + totalLabel: PICKING_BIN_TOTAL_LABEL, + warehouses: warehouses.map((b) => ({ wmsCoId: String(b.wmsCoId), warehouse: b.warehouse })), + items, + summary, + total, + }; + + cacheByKey.set(cacheKey, { latestBizDate, at: Date.now(), payload }); + return res.json({ success: true, data: payload }); + } catch (error) { + console.error('[picking-bin-sales-analysis] 查询失败:', error.message); + return res.status(500).json({ + success: false, + error: '拣选库位动销分析数据查询失败', + detail: error.message, + }); + } +}); + +/** + * GET /api/picking-bin-sales-analysis/export + * 导出库位明细。query: days、binType、wmsCoId(逗号分隔)、grade(逗号分隔)、format(xlsx|csv) + */ +router.get('/export', async (req, res) => { + try { + const days = resolveDays(req.query?.days); + const binTypes = resolveBinTypes(req.query?.binType); + const warehouses = resolveWarehouses(req.query?.wmsCoId); + const wmsCoIds = warehouses.map((b) => String(b.wmsCoId)); + const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx'; + + const grades = String(req.query?.grade || '') + .split(',') + .map((v) => v.trim()) + .filter((v) => GRADE_MAP.has(v)); + + const latestBizDate = await fetchLatestBizDate().catch(() => null); + const asOf = nextDayAsOf(latestBizDate); + if (!asOf) { + return res.status(503).json({ + success: false, + error: '业务库暂无库存监控数据,无法定位统计基准日', + }); + } + + const { shipFrom, shipTo } = buildShipWindow(asOf, days); + const rows = await fetchPickingBinRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days, binTypes }); + + const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse])); + const filtered = grades.length > 0 + ? rows.filter((r) => grades.includes(r.gradeKey)) + : rows; + + const sheetRows = filtered.map((r) => [ + whMap.get(r.wmsCoId) || r.wmsCoId, + r.binName, + r.binType, + r.stockQty, + r.skuCount, + round2(r.salesQty), + round4(r.dailySales), + GRADE_MAP.get(r.gradeKey)?.label || r.gradeKey, + days, + latestBizDate || '', + ]); + + const fileName = `拣选库位动销分析-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`; + const { totalRows } = sendWorkbookDownload(res, { + fileName, + format, + sheets: [{ + name: '库位动销明细', + header: [ + '仓库', '仓位', '类型', '库存数量', 'SKU数', + `销量(近${days}天)`, '日销', '动销等级', '统计天数', '统计基准日', + ], + rows: sheetRows, + }], + }); + console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 类型 ${binTypes.join('/')}, 窗口 ${days} 天`); + return null; + } catch (error) { + console.error('[picking-bin-sales-analysis] 导出失败:', error.message); + if (!res.headersSent) { + return res.status(500).json({ + success: false, + error: '拣选库位动销分析导出失败', + detail: error.message, + }); + } + return null; + } +}); + +module.exports = router; diff --git a/backend/src/config/pickingBinSalesAnalysis.js b/backend/src/config/pickingBinSalesAnalysis.js new file mode 100644 index 0000000..b574d5f --- /dev/null +++ b/backend/src/config/pickingBinSalesAnalysis.js @@ -0,0 +1,66 @@ +/** + * 拣选库位动销分析 —— 口径配置(纯规则,不含业务数值) + * + * 一级菜单:大货库存监控 + * 二级菜单:拣选库位动销分析 + * + * 表头(需求表原列为 所在仓库 | 仓位 | 数量 | 类型 | 动销等级,这里做了扩充与优化): + * 仓库 | 仓位 | 类型 | 库存数量 | SKU数 | 近N天销量 | 日销 | 动销等级 + * + * 口径: + * 库存数量:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty + * 仓位类型:bin_group 为空 → 拣选位;bin_group = '存货' → 存货位 + * 销量: rpt.jst_Orders × rpt.jst_OrderItem,按 send_date,排除 Delete/Cancelled + * 日销: 库位销量 / 窗口天数 + * + * 【关键】同一 SKU 常常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个拣选 SKU 中 + * 1431 个跨多个拣选位,最多 24 个)。若直接把 SKU 销量累加到每个仓位, + * 销量会被放大数倍。因此这里按「SKU 销量在其占用的同类型仓位数之间平均分摊」: + * 库位销量 = Σ(该库位内各 SKU 的窗口销量 ÷ 该 SKU 占用的同类型仓位数) + * 恒等式(已实测校验,差异 0):全部库位销量之和 = 有库位记录的那些 SKU 的销量之和。 + * 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据, + * 没有仓位记录的 SKU 的销量不会体现在任何库位上。 + */ + +const PICKING_BIN_LEVEL1 = '大货库存监控'; +const PICKING_BIN_LEVEL2 = '拣选库位动销分析'; + +/** 可选的统计窗口天数;默认 15 天(需求指定按近 15 天销量划分动销等级) */ +const PICKING_BIN_WINDOW_OPTIONS = [7, 15, 30]; +const PICKING_BIN_DEFAULT_DAYS = 15; + +/** 仓位类型:业务库 bin_group 为空视为拣选位 */ +const BIN_TYPE_PICKING = '拣选'; +const BIN_TYPE_STORAGE = '存货'; +const BIN_TYPES = [BIN_TYPE_PICKING, BIN_TYPE_STORAGE]; +/** 默认只看拣选位(本功能的核心是拣货位动销) */ +const PICKING_BIN_DEFAULT_TYPES = [BIN_TYPE_PICKING]; + +/** + * 动销等级(顺序即展示顺序)。 + * lower 为日销下界(含),upper 为日销上界(不含);null 表示不限制。 + * zero 档为「窗口内无销量」,由 salesQty <= 0 兜底。 + */ +const PICKING_BIN_GRADE_RULES = [ + { key: 'hot', label: '爆款:日销≥20', lower: 20, upper: null, tip: '库位日均销量 ≥ 20 件' }, + { key: 'best', label: '热卖款:10≤日销<20', lower: 10, upper: 20, tip: '库位日均销量 10 ~ 20 件(含 10,不含 20)' }, + { key: 'normal', label: '平销款:3≤日销<10', lower: 3, upper: 10, tip: '库位日均销量 3 ~ 10 件(含 3,不含 10)' }, + { key: 'slow1', label: '滞销款-1:1≤日销<3', lower: 1, upper: 3, tip: '库位日均销量 1 ~ 3 件(含 1,不含 3)' }, + { key: 'slow2', label: '滞销款-2:0<日销<1', lower: 0, upper: 1, tip: '库位日均销量大于 0 且小于 1 件' }, + { key: 'zero', label: '销售=0', lower: 0, upper: 0, tip: '统计窗口内该库位所有 SKU 均无销量(仍占用库存)' }, +]; + +const PICKING_BIN_TOTAL_LABEL = '汇总'; + +module.exports = { + PICKING_BIN_LEVEL1, + PICKING_BIN_LEVEL2, + PICKING_BIN_WINDOW_OPTIONS, + PICKING_BIN_DEFAULT_DAYS, + BIN_TYPE_PICKING, + BIN_TYPE_STORAGE, + BIN_TYPES, + PICKING_BIN_DEFAULT_TYPES, + PICKING_BIN_GRADE_RULES, + PICKING_BIN_TOTAL_LABEL, +}; diff --git a/backend/src/constants/sidebarPages.js b/backend/src/constants/sidebarPages.js index 35b47c5..06d62cb 100644 --- a/backend/src/constants/sidebarPages.js +++ b/backend/src/constants/sidebarPages.js @@ -45,6 +45,11 @@ const SIDEBAR_PAGES = [ pageName: '大货库存监控 · 库存动销分析', path: '/planning/operation/stock-sales-analysis', }), + buildPlanningPage({ + pageKey: 'picking_bin_sales_analysis', + pageName: '大货库存监控 · 拣选库位动销分析', + path: '/planning/operation/picking-bin-sales-analysis', + }), { pageKey: 'settings', pageName: '系统设置', diff --git a/backend/src/index.js b/backend/src/index.js index e6baf43..1af090e 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -38,6 +38,7 @@ const mkhBomRoutes = require('./api/mkhBom'); const dailyInventoryRoutes = require('./api/dailyInventory'); const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification'); const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis'); +const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis'); const returnInventoryWarningRoutes = require('./api/returnInventoryWarning'); const returnMonitorRoutes = require('./api/returnMonitor'); const returnStockWarningRoutes = require('./api/returnStockWarning'); @@ -674,6 +675,7 @@ async function startServer(options = {}) { cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes); cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes); + cubejsApp.use('/api/picking-bin-sales-analysis', authMiddleware, pickingBinSalesAnalysisRoutes); cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes); // 崔桥工厂仓客退:退货入库核销 + 每日库存监控 cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes); diff --git a/backend/src/services/pickingBinSalesAnalysis.js b/backend/src/services/pickingBinSalesAnalysis.js new file mode 100644 index 0000000..fe3a151 --- /dev/null +++ b/backend/src/services/pickingBinSalesAnalysis.js @@ -0,0 +1,181 @@ +'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', + })); +} + +module.exports = { + buildGradeCase, + buildInClause, + fetchPickingBinRows, +}; diff --git a/backend/test/pickingBinSalesAnalysis.test.js b/backend/test/pickingBinSalesAnalysis.test.js new file mode 100644 index 0000000..151ccb4 --- /dev/null +++ b/backend/test/pickingBinSalesAnalysis.test.js @@ -0,0 +1,119 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildGradeCase, + buildInClause, + fetchPickingBinRows, +} = require('../src/services/pickingBinSalesAnalysis'); +const { + PICKING_BIN_GRADE_RULES, + PICKING_BIN_DEFAULT_DAYS, + PICKING_BIN_DEFAULT_TYPES, + BIN_TYPES, +} = require('../src/config/pickingBinSalesAnalysis'); + +test('buildInClause 按位展开参数', () => { + const { clause, params } = buildInClause([101, 102], 'w'); + assert.equal(clause, '@w0,@w1'); + assert.deepEqual(params, { w0: 101, w1: 102 }); +}); + +test('buildGradeCase 覆盖全部正向等级,并以销售=0 兜底', () => { + const expr = buildGradeCase(); + PICKING_BIN_GRADE_RULES.filter((rule) => rule.lower > 0).forEach((rule) => { + assert.ok(expr.includes(`THEN '${rule.key}'`), `${rule.key} 未出现在分档表达式中`); + }); + assert.ok(expr.includes("THEN 'slow2'")); + assert.ok(expr.includes("ELSE 'zero'")); +}); + +test('默认窗口为 15 天,默认只看拣选位', () => { + assert.equal(PICKING_BIN_DEFAULT_DAYS, 15); + assert.deepEqual(PICKING_BIN_DEFAULT_TYPES, ['拣选']); + assert.deepEqual(BIN_TYPES, ['拣选', '存货']); +}); + +test('fetchPickingBinRows 首参不是数据库实例时抛错', async () => { + await assert.rejects( + () => fetchPickingBinRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 15 }), + TypeError + ); +}); + +test('fetchPickingBinRows 无仓库或缺失窗口时直接返回空结果,不打库', async () => { + let called = 0; + const db = { query: async () => { called += 1; return []; } }; + assert.deepEqual(await fetchPickingBinRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 15 }), []); + assert.deepEqual(await fetchPickingBinRows(db, [101], { shipFrom: null, shipTo: 'b', days: 15 }), []); + assert.equal(called, 0); +}); + +test('fetchPickingBinRows 把仓库/类型/窗口参数一并传给业务库,并做分摊与分档', async () => { + let captured = null; + const db = { + query: async (sqlText, params) => { + captured = { sqlText, params }; + return [ + { wmsCoId: 101, binName: 'XA01-17-12', binType: '拣选', stockQty: 21, skuCount: 1, salesQty: 9, dailySales: 0.6, gradeKey: 'slow2' }, + ]; + }, + }; + + const rows = await fetchPickingBinRows(db, [101, 102], { + shipFrom: '2026-09-23 01:00:00', + shipTo: '2026-10-08 01:00:00', + days: 15, + binTypes: ['拣选'], + }); + + assert.equal(rows.length, 1); + assert.deepEqual(rows[0], { + wmsCoId: '101', + binName: 'XA01-17-12', + binType: '拣选', + stockQty: 21, + skuCount: 1, + salesQty: 9, + dailySales: 0.6, + gradeKey: 'slow2', + }); + + // 参数必须带上两个仓库、发货窗口与天数,以及仓位类型过滤 + assert.equal(captured.params.w0, 101); + assert.equal(captured.params.w1, 102); + assert.equal(captured.params.v0, 101); + assert.equal(captured.params.shipFrom, '2026-09-23 01:00:00'); + assert.equal(captured.params.shipTo, '2026-10-08 01:00:00'); + assert.equal(captured.params.days, 15); + assert.equal(captured.params.t0, '拣选'); + + // 分摊:SKU 销量除以其占用的同类型仓位数 + assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)')); + // 仓位类型:bin_group 为空视为拣选位 + assert.ok(captured.sqlText.includes("THEN N'拣选'")); + // 只统计有库存的仓位 + assert.ok(captured.sqlText.includes('ISNULL(p.qty, 0) <> 0')); +}); + +test('fetchPickingBinRows 不传类型时不过滤 binType', async () => { + let captured = null; + const db = { + query: async (sqlText, params) => { captured = { sqlText, params }; return []; }, + }; + await fetchPickingBinRows(db, [101], { + shipFrom: 'a', shipTo: 'b', days: 15, binTypes: [], + }); + assert.ok(!captured.sqlText.includes('a.binType IN')); + assert.equal(captured.params.t0, undefined); +}); + +test('gradeKey 缺失时兜底为 zero', async () => { + const db = { query: async () => [{ wmsCoId: 101, binName: 'X', binType: '拣选', stockQty: 5 }] }; + const rows = await fetchPickingBinRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 }); + assert.equal(rows[0].gradeKey, 'zero'); + assert.equal(rows[0].salesQty, 0); + assert.equal(rows[0].dailySales, 0); +}); diff --git a/frontend/src/api/pickingBinSalesAnalysis.js b/frontend/src/api/pickingBinSalesAnalysis.js new file mode 100644 index 0000000..42db3a2 --- /dev/null +++ b/frontend/src/api/pickingBinSalesAnalysis.js @@ -0,0 +1,16 @@ +import request from './request'; + +const LIST_TIMEOUT_MS = 5 * 60 * 1000; + +// 拣选库位动销分析:仓库 × 仓位 → 类型 / 库存数量 / SKU数 / 近N天销量 / 日销 / 动销等级 +// refresh=1 跳过后端 5 分钟缓存,强制重算 +export const getPickingBinSalesAnalysis = (params = {}) => request.get( + '/api/picking-bin-sales-analysis', + { params, timeout: LIST_TIMEOUT_MS } +); + +// 导出库位明细(xlsx/csv,服务端直接返回文件流) +export const exportPickingBinSalesAnalysis = (params = {}) => request.get( + '/api/picking-bin-sales-analysis/export', + { params, responseType: 'blob', timeout: LIST_TIMEOUT_MS } +); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 4df05bb..b899808 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -41,6 +41,7 @@ export const DEFAULT_SIDEBAR_CONFIG = { children: [ { key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 }, { key: 'stock-sales-analysis', label: '库存动销分析', icon: 'line-chart', routeKey: 'stock-sales-analysis', visible: true, order: 20 }, + { key: 'picking-bin-sales-analysis', label: '拣选库位动销分析', icon: 'inbox', routeKey: 'picking-bin-sales-analysis', visible: true, order: 30 }, ], }, { diff --git a/frontend/src/constants/pickingBinSalesAnalysis.js b/frontend/src/constants/pickingBinSalesAnalysis.js new file mode 100644 index 0000000..5f5d9d9 --- /dev/null +++ b/frontend/src/constants/pickingBinSalesAnalysis.js @@ -0,0 +1,87 @@ +/** 拣选库位动销分析 —— 表头与口径说明 */ + +export const PICKING_BIN_COLUMNS = [ + { + key: 'warehouse', + label: '仓库', + tip: '聚水潭主仓(按 wms_co_id 单仓独立统计,不合并分仓)', + }, + { + key: 'binName', + label: '仓位', + tip: '系统仓位编码(jst_packInventoryInfo.bin)', + }, + { + key: 'binType', + label: '类型', + tip: '拣选 = 拣货位(bin_group 为空);存货 = 存货位(bin_group=存货)', + }, + { + key: 'stockQty', + label: '库存数量', + tip: '该仓位当前库存件数(jst_packInventoryInfo 按 wms_co_id + 仓位 汇总 qty)', + }, + { + key: 'skuCount', + label: 'SKU数', + tip: '该仓位内存放且有库存的 SKU 个数', + }, + { + key: 'salesQty', + label: '销量', + tip: '近 N 天该仓位的分摊销量:Σ(仓位内各 SKU 销量 ÷ 该 SKU 占用的同类型仓位数)', + }, + { + key: 'dailySales', + label: '日销', + tip: '分摊销量 ÷ 统计天数,动销等级按此值划分', + }, + { + key: 'gradeKey', + label: '动销等级', + tip: '爆款≥20 / 热卖款 10~20 / 平销款 3~10 / 滞销款-1 1~3 / 滞销款-2 0~1 / 销售=0', + }, +]; + +export const PICKING_BIN_WINDOW_OPTIONS = [ + { label: '近 7 天', value: 7 }, + { label: '近 15 天', value: 15 }, + { label: '近 30 天', value: 30 }, +]; + +export const PICKING_BIN_DEFAULT_DAYS = 15; + +export const PICKING_BIN_TYPE_OPTIONS = [ + { label: '拣选位', value: '拣选' }, + { label: '存货位', value: '存货' }, +]; + +export const PICKING_BIN_DEFAULT_TYPES = ['拣选']; + +/** 动销等级展示色(与后端 key 对应) */ +export const PICKING_BIN_GRADE_COLORS = { + hot: 'red', + best: 'volcano', + normal: 'blue', + slow1: 'gold', + slow2: 'default', + zero: 'default', +}; + +export const PICKING_BIN_DESCRIPTION = [ + '动销等级口径:按「单个仓位的日均销量(日销)」分档,日销 = 该仓位近 N 天销量 ÷ N。', + ' 爆款:日销 ≥ 20;热卖款:10 ≤ 日销 < 20;平销款:3 ≤ 日销 < 10;', + ' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内该仓位所有 SKU 均无销量。', + '默认统计窗口为近 15 天,可切换 7 / 15 / 30 天。', + '', + '【销量为什么要分摊】', + ' 同一 SKU 常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。', + ' 若直接把 SKU 销量累加到每个仓位,销量会被放大数倍。', + ' 因此按「SKU 销量在其占用的同类型仓位数之间平均分摊」计算库位销量,', + ' 恒等式已实测校验:全部库位销量之和 = 有库位记录的那些 SKU 的销量之和(差异 0)。', + ' 注意该合计小于全仓销量:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,无仓位记录的 SKU 销量不计入任何库位。', + '', + '数据来源:库存取 rpt.jst_packInventoryInfo(按仓库 + 仓位汇总);', + '销量取 rpt.jst_Orders × rpt.jst_OrderItem(按 send_date,排除 Delete/Cancelled),与「每日进销存」发货口径一致。', + '仓位类型:bin_group 为空视为拣选位,bin_group=存货 为存货位。', +]; diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js index 5794422..0b70943 100644 --- a/frontend/src/constants/sidebarPages.js +++ b/frontend/src/constants/sidebarPages.js @@ -6,6 +6,7 @@ export const SIDEBAR_PAGES = [ { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, { pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'stock_sales_analysis', path: '/planning/operation/stock-sales-analysis', name: '库存动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, + { pageKey: 'picking_bin_sales_analysis', path: '/planning/operation/picking-bin-sales-analysis', name: '拣选库位动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_cuiqiao', path: '/planning/operation/returns-inventory/cuiqiao', name: '崔桥工厂仓客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_subei', path: '/planning/operation/returns-inventory/subei', name: '苏北客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index bfa9e29..1159ad5 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -121,6 +121,7 @@ const SIDEBAR_ROUTE_MAP = { }, 'daily-inventory': { path: '/planning/operation/daily-inventory' }, 'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' }, + 'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' }, settings: { path: '/settings' }, 'online-desktop-users': { path: '/settings/online-desktop-users' }, 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, diff --git a/frontend/src/pages/PickingBinSalesAnalysis/index.jsx b/frontend/src/pages/PickingBinSalesAnalysis/index.jsx new file mode 100644 index 0000000..5f91650 --- /dev/null +++ b/frontend/src/pages/PickingBinSalesAnalysis/index.jsx @@ -0,0 +1,295 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, Button, Card, Col, Input, Modal, Row, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message, +} from 'antd'; +import { DownloadOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { + exportPickingBinSalesAnalysis, + getPickingBinSalesAnalysis, +} from '../../api/pickingBinSalesAnalysis'; +import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport'; +import { + PICKING_BIN_COLUMNS, + PICKING_BIN_WINDOW_OPTIONS, + PICKING_BIN_DEFAULT_DAYS, + PICKING_BIN_TYPE_OPTIONS, + PICKING_BIN_DEFAULT_TYPES, + PICKING_BIN_GRADE_COLORS, + PICKING_BIN_DESCRIPTION, +} from '../../constants/pickingBinSalesAnalysis'; +import './style.css'; + +const { Title, Text } = Typography; + +const int = (value) => { + if (value === null || value === undefined || value === '') return '—'; + return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); +}; + +const num = (value, digits = 2) => { + if (value === null || value === undefined || value === '') return '—'; + return Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +}; + +const TipTitle = ({ tip, children }) => ( + + {children} + +); + +const PickingBinSalesAnalysis = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [data, setData] = useState(null); + const [helpOpen, setHelpOpen] = useState(false); + const [days, setDays] = useState(PICKING_BIN_DEFAULT_DAYS); + const [binTypes, setBinTypes] = useState(PICKING_BIN_DEFAULT_TYPES); + const [warehouse, setWarehouse] = useState(''); + const [grades, setGrades] = useState([]); + const [keyword, setKeyword] = useState(''); + const [exporting, setExporting] = useState(false); + + const params = useMemo(() => ({ + days, + binType: binTypes.join(','), + ...(warehouse ? { wmsCoId: warehouse } : {}), + }), [days, binTypes, warehouse]); + + const load = useCallback(async (options = {}) => { + setLoading(true); + setError(''); + try { + const res = await getPickingBinSalesAnalysis({ + ...(options.params ?? params), + ...(options.force ? { refresh: '1' } : {}), + }); + setData(res?.data ?? null); + } catch (err) { + setError(err?.response?.data?.error || err?.message || '拣选库位动销分析数据加载失败'); + setData(null); + } finally { + setLoading(false); + } + }, [params]); + + useEffect(() => { load(); }, [load]); + + const items = useMemo(() => data?.items ?? [], [data]); + const gradeMap = useMemo(() => new Map((data?.grades ?? []).map((g) => [g.key, g])), [data]); + + const filteredRows = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + return items.filter((it) => { + if (grades.length > 0 && !grades.includes(it.gradeKey)) return false; + if (kw && !String(it.binName).toLowerCase().includes(kw)) return false; + return true; + }); + }, [items, grades, keyword]); + + const columns = useMemo(() => PICKING_BIN_COLUMNS.map((col) => ({ + title: {col.label}, + key: col.key, + dataIndex: col.key, + align: ['warehouse', 'binName', 'binType', 'gradeKey'].includes(col.key) ? 'left' : 'right', + width: col.key === 'warehouse' ? 240 : (col.key === 'gradeKey' ? 170 : 110), + sorter: ['stockQty', 'skuCount', 'salesQty', 'dailySales'].includes(col.key) + ? (a, b) => (a[col.key] ?? 0) - (b[col.key] ?? 0) + : undefined, + render: (value) => { + if (col.key === 'gradeKey') { + const g = gradeMap.get(value); + return {g?.label || value}; + } + if (col.key === 'binType') return {value || '—'}; + if (col.key === 'dailySales') return num(value, 2); + if (col.key === 'salesQty') return num(value, 2); + if (col.key === 'stockQty' || col.key === 'skuCount') return int(value); + return value || '—'; + }, + })), [gradeMap]); + + const handleExport = useCallback(async () => { + setExporting(true); + try { + const blob = await exportPickingBinSalesAnalysis({ + ...params, + ...(grades.length > 0 ? { grade: grades.join(',') } : {}), + format: 'xlsx', + }); + const dateKey = (data?.latestBizDate || '').replace(/-/g, '') || '未知日期'; + const fileName = `拣选库位动销分析-${dateKey}.xlsx`; + await downloadBlobAsFile(blob, fileName); + message.success(`已开始下载:${fileName}`); + } catch (exportError) { + message.error(await readBlobErrorMessage(exportError, '库位动销明细导出失败')); + } finally { + setExporting(false); + } + }, [params, grades, data?.latestBizDate]); + + const toggleGrade = useCallback((key) => { + setGrades((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); + }, []); + + const summary = data?.summary ?? []; + const total = data?.total ?? null; + + return ( + +
+
+ 拣选库位动销分析 + +
+
+ 统计基准日:{data?.latestBizDate || '—'} + {data?.shipWindow ? ( + 销量窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天) + ) : null} + {data?.takenAt ? ( + 仓位数据时间:{new Date(data.takenAt).toLocaleString('zh-CN', { hour12: false })} + ) : null} +
+ + + 统计天数 + setBinTypes(v && v.length > 0 ? v : PICKING_BIN_DEFAULT_TYPES)} + style={{ minWidth: 170 }} + placeholder="仓位类型" + maxTagCount={2} + /> +