From 55b755c1d9bf4c62768f4f0fff7356b6b96c42c0 Mon Sep 17 00:00:00 2001 From: lunanting Date: Thu, 22 Oct 2026 10:53:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E3=80=8C=E7=BC=BA?= =?UTF-8?q?=E8=B4=A7=E8=A1=A5=E8=B4=A7=E7=9B=91=E6=8E=A7=E3=80=8D=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=EF=BC=88=E6=8B=A3=E8=B4=A7=E4=BD=8D=E7=BC=BA=E8=B4=A7?= =?UTF-8?q?=20=E2=86=92=20=E4=BB=8E=E5=AD=98=E8=B4=A7=E4=BD=8D=E8=A1=A5?= =?UTF-8?q?=E8=B4=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据源:聚水潭「箱及仓位库存」rpt.jst_packInventoryInfo(副本里是视图, 按 SKU+货位聚合单次 35~45s,因此服务端缓存 10 分钟 + 启动预热)。 口径: - 拣货位:按业务导入的货位清单判定(本地表 bin_replenish_pick_bin,按仓库隔离); 副本没有货位类型主数据,未导入时回退为 pack_type='Bin' 的记录,页面明确标注。 - 拣货位库存 / 存货位库存 / 现有库存合计; - 目标库存 = max(10, 近15天日销 × 目标天数(默认7)); - 建议补货量 = min(存货位库存, 目标 − 拣货位库存); - 状态:拣货位缺货 / 无货可补 / 拣货位偏低 / 未设拣货位 / 正常。 「未设拣货位」单列,避免把整箱直发商品误报成缺货。 后端:binReplenishService + binReplenishSchema + /api/bin-replenish (list / export / pick-bins GET-POST-DELETE / ping),建表与预热挂在 localSchema。 前端:/planning/operation/bin-replenish-monitor 页面、侧栏入口、页面权限注册, 含 KPI 卡、状态分段筛选、关键字搜索、目标天数、货位清单导入/清除、服务端导出。 --- backend/src/api/binReplenish.js | 134 ++++ backend/src/index.js | 2 + backend/src/services/binReplenishSchema.js | 53 ++ backend/src/services/binReplenishService.js | 613 ++++++++++++++++++ backend/src/services/localSchema.js | 5 + frontend/src/api/binReplenish.js | 40 ++ frontend/src/config/sidebarNavigation.js | 1 + frontend/src/constants/sidebarPages.js | 1 + frontend/src/layouts/BasicLayout.jsx | 5 + .../src/pages/BinReplenishMonitor/index.jsx | 517 +++++++++++++++ .../src/pages/BinReplenishMonitor/style.css | 303 +++++++++ frontend/src/router/index.jsx | 5 + 12 files changed, 1679 insertions(+) create mode 100644 backend/src/api/binReplenish.js create mode 100644 backend/src/services/binReplenishSchema.js create mode 100644 backend/src/services/binReplenishService.js create mode 100644 frontend/src/api/binReplenish.js create mode 100644 frontend/src/pages/BinReplenishMonitor/index.jsx create mode 100644 frontend/src/pages/BinReplenishMonitor/style.css diff --git a/backend/src/api/binReplenish.js b/backend/src/api/binReplenish.js new file mode 100644 index 0000000..9d00a36 --- /dev/null +++ b/backend/src/api/binReplenish.js @@ -0,0 +1,134 @@ +'use strict'; + +const express = require('express'); +const multer = require('multer'); +const { + clearPickBins, + getPickBinMeta, + getReplenishExportRows, + getReplenishList, + importPickBins, + resolveWarehouseKey, +} = require('../services/binReplenishService'); +const { sendWorkbookDownload } = require('../services/exportWorkbook'); + +const router = express.Router(); +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 20 * 1024 * 1024 }, +}); + +const EXPORT_COLUMNS = [ + { key: 'skuId', title: '商品编码' }, + { key: 'productName', title: '品名' }, + { key: 'supplier', title: '供应商' }, + { key: 'statusLabel', title: '状态' }, + { key: 'pickQty', title: '拣货位库存' }, + { key: 'storageQty', title: '存货位库存' }, + { key: 'totalQty', title: '现有库存合计' }, + { key: 'targetQty', title: '目标拣货位库存' }, + { key: 'suggestQty', title: '建议补货量' }, + { key: 'avgSale', title: '近15天日销' }, + { key: 'pickBinText', title: '拣货位(货位号/数量)' }, + { key: 'storageBinText', title: '存货位(前5个/数量)' }, +]; + +/** GET /api/bin-replenish/list —— 缺货补货监控列表 */ +router.get('/list', async (req, res, next) => { + try { + const payload = await getReplenishList({ + warehouse: req.query.warehouse, + keyword: req.query.keyword, + status: req.query.status, + page: req.query.page, + pageSize: req.query.pageSize, + targetDays: req.query.targetDays, + refresh: req.query.refresh, + }); + res.json({ success: true, data: payload }); + } catch (error) { + next(error); + } +}); + +/** GET /api/bin-replenish/export —— 服务端全量导出 */ +router.get('/export', async (req, res, next) => { + try { + const format = String(req.query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx'; + const payload = await getReplenishExportRows({ + warehouse: req.query.warehouse, + keyword: req.query.keyword, + status: req.query.status, + targetDays: req.query.targetDays, + }); + const rows = payload.rows.map((row) => ({ + ...row, + pickBinText: (row.pickBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'), + storageBinText: (row.storageBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'), + })); + + sendWorkbookDownload(res, { + fileName: `${payload.warehouse.shortName}缺货补货监控_${new Date().toISOString().slice(0, 10)}`, + format, + sheets: [{ + name: '缺货补货监控', + header: EXPORT_COLUMNS.map((column) => column.title), + rows: rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])), + }], + }); + } catch (error) { + next(error); + } +}); + +/** GET /api/bin-replenish/pick-bins —— 当前仓库的拣货位清单概况 */ +router.get('/pick-bins', async (req, res, next) => { + try { + const meta = await getPickBinMeta(resolveWarehouseKey(req.query.warehouse)); + res.json({ success: true, data: meta }); + } catch (error) { + next(error); + } +}); + +/** POST /api/bin-replenish/pick-bins —— 导入拣货位清单(xlsx / xls / csv / txt) */ +router.post('/pick-bins', upload.single('file'), async (req, res, next) => { + try { + if (!req.file || !req.file.buffer) { + res.status(400).json({ success: false, message: '请选择要导入的货位清单文件(xlsx / xls / csv)' }); + return; + } + // multer 按 latin1 解析 multipart 文件名,中文会乱码,这里转回 UTF-8 + const rawName = req.file.originalname || ''; + const fileName = /[\u00c0-\u00ff]/.test(rawName) + ? Buffer.from(rawName, 'latin1').toString('utf8') + : rawName; + + const result = await importPickBins({ + buffer: req.file.buffer, + fileName, + warehouseKey: req.body?.warehouse || req.query.warehouse, + importedBy: req.user?.username || req.user?.name || null, + }); + res.json({ success: true, data: result }); + } catch (error) { + next(error); + } +}); + +/** DELETE /api/bin-replenish/pick-bins —— 清除货位清单,回到回退口径 */ +router.delete('/pick-bins', async (req, res, next) => { + try { + const result = await clearPickBins(req.query.warehouse); + res.json({ success: true, data: result }); + } catch (error) { + next(error); + } +}); + +/** GET /api/bin-replenish/ping —— 路由存活探针(401 也代表已注册) */ +router.get('/ping', (req, res) => { + res.json({ success: true, data: { ok: true } }); +}); + +module.exports = router; diff --git a/backend/src/index.js b/backend/src/index.js index 6f4c4e6..335c47c 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -690,6 +690,8 @@ async function startServer(options = {}) { cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes); // 崔桥工厂仓客退:退货库存预警(箱及仓位库存 · 崔桥工厂客退临时上架仓) cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes); + // 缺货补货监控(拣货位缺货 → 从存货位补货) + cubejsApp.use('/api/bin-replenish', authMiddleware, binReplenishRoutes); // ===================== // 数据分析 API(公开访问,可根据需要添加认证) diff --git a/backend/src/services/binReplenishSchema.js b/backend/src/services/binReplenishSchema.js new file mode 100644 index 0000000..a37e3cf --- /dev/null +++ b/backend/src/services/binReplenishSchema.js @@ -0,0 +1,53 @@ +'use strict'; + +/** + * 缺货补货监控(拣货位 → 存货位)本地表 + * -------------------------------------------------------------------------- + * 为什么需要本地表: + * 聚水潭「箱及仓位库存」在副本里是**视图** rpt.jst_packInventoryInfo, + * 只有货位号(bin)与数量,**没有货位类型**(拣货位 / 存货位)这层主数据 + * (全库 5,496 个字段里没有 bin_type / location_type 一类的列,见 2026-10-22 复核)。 + * 而业务判断「拣货位缺货、要从存货位补」必须先知道哪些 bin 是拣货位, + * 所以把业务给的货位清单导入本地库,作为判定基准。 + * + * 两张表: + * bin_replenish_pick_bin —— 拣货位清单(按仓库隔离,可重复导入覆盖) + * bin_replenish_import_batch —— 导入批次日志(谁、什么时候、导入多少个) + * + * 未导入清单时,服务层回退到「pack_type = 'Bin' 的记录」作为拣货位口径, + * 页面会明确提示当前用的是回退口径。 + */ + +const database = require('../config/database'); + +const SCHEMA_SQL = [ + `CREATE TABLE IF NOT EXISTS bin_replenish_pick_bin ( + warehouse_key VARCHAR(32) NOT NULL COMMENT '仓库标识(changzhou/henglin3/subei/suining/henglin2/wh7)', + bin_code VARCHAR(64) NOT NULL COMMENT '货位号(大写去空格)', + batch_id VARCHAR(64) NOT NULL COMMENT '导入批次', + imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间', + PRIMARY KEY (warehouse_key, bin_code), + KEY idx_brpb_batch (batch_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缺货补货监控-拣货位清单'`, + + `CREATE TABLE IF NOT EXISTS bin_replenish_import_batch ( + batch_id VARCHAR(64) NOT NULL COMMENT '导入批次', + warehouse_key VARCHAR(32) NOT NULL COMMENT '仓库标识', + file_name VARCHAR(255) NULL COMMENT '文件名', + total_cells INT NOT NULL DEFAULT 0 COMMENT '扫描到的候选单元格数', + imported_rows INT NOT NULL DEFAULT 0 COMMENT '写入的货位数', + imported_by VARCHAR(100) NULL COMMENT '导入人', + imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间', + PRIMARY KEY (batch_id), + KEY idx_brpib_wh (warehouse_key, imported_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缺货补货监控-拣货位导入批次'`, +]; + +async function ensureBinReplenishSchema() { + for (const sql of SCHEMA_SQL) { + // eslint-disable-next-line no-await-in-loop + await database.local.query(sql); + } +} + +module.exports = { ensureBinReplenishSchema }; diff --git a/backend/src/services/binReplenishService.js b/backend/src/services/binReplenishService.js new file mode 100644 index 0000000..ad94972 --- /dev/null +++ b/backend/src/services/binReplenishService.js @@ -0,0 +1,613 @@ +'use strict'; + +/** + * 缺货补货监控 —— 拣货位缺货 / 从存货位补货 + * ========================================================================== + * 业务需求(2026-10-22 用户确认): + * 监控现有库存 → 看哪些「拣货库位」缺货 → 需要从「存货仓位」补货过去。 + * + * ── 数据源 ─────────────────────────────────────────────────────────── + * rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) + * · 列:sku_id / bin(货位号)/ pack_type / bin_group / qty / wms_co_id + * · 一行 = 某货位上某商品的库存;同一货位可能多行(不同箱) + * · pack_type:Bin = 货位散件库存;Pack = 整箱;DefaultPack = 未分箱(仓库级) + * ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1), + * 崔桥/山东/横林二/苏北/河南全部挂在 wh_id=1 下,按 wh_id 会混成一个桶。 + * ⚠️ 该对象在副本里是**视图**(sys.objects.type_desc = VIEW), + * 没有暴露底层表;单次按 SKU+货位聚合实测 **10~45 秒**(随缓存/负载波动), + * 直接取原始行更慢(25,488 行约 80 秒)。因此必须缓存 + 启动预热, + * 不要把它改回「每次请求都回源」。 + * + * ── 拣货位怎么认定(关键)───────────────────────────────────────────── + * 副本**没有独立的货位类型主数据**,但「拣选库位动销分析」已确认口径: + * 真实拣选位 = `pack_type = 'Bin'` 且 `bin_group` 为空 + * (`bin_group='存货'`、`pack_type='Pack'/'DefaultPack'` 都算存货位) + * 本页默认就用这个口径;如果业务另有一份拣货位清单, + * 页面导入后以清单为准(`bin ∈ 清单` → 拣货位),其余一律算存货位。 + * + * ── 指标口径 ───────────────────────────────────────────────────────── + * 拣货位库存 pickQty = 该 SKU 在所有拣货位上的 qty 合计 + * 存货位库存 storageQty = 该 SKU 在其余货位(含整箱/未分箱)上的 qty 合计 + * 现有库存 totalQty = pickQty + storageQty + * 目标拣货位库存 target = max(10, ceil(近15天日销 × targetDays)),targetDays 默认 7 + * 建议补货量 = min(storageQty, max(0, target − pickQty)),向上取整 + * + * 状态: + * pickout 拣货位缺货(pickQty ≤ 0,且存货位有货 → 可以补) + * empty 无货可补(pickQty ≤ 0,存货位也没货 → 需要采购) + * low 拣货位偏低(0 < pickQty < target,存货位有货) + * ok 正常 + * + * 近15天日销取自 rpt.pm_stockMonitor(同仓最新快照的 fifteenAvgSale), + * 只用于算目标和排序;取不到时目标按 10 件兜底。 + */ + +const crypto = require('crypto'); +const XLSX = require('xlsx'); +const cloudDb = require('../config/cloudDatabase'); +const database = require('../config/database'); + +/** + * 仓库清单:**按 wms_co_id 取数,不能用 wh_id** + * --------------------------------------------------------------------------- + * 2026-10-22 实测:jst_packInventoryInfo.wh_id 是「货主」(简尚=1), + * 崔桥/山东/横林二/苏北/河南的仓位记录**全部挂在 wh_id=1 底下**, + * 按 wh_id 汇总会把 10 个仓混成一个桶。真实仓库是 wms_co_id。 + * 这里只列「有真实拣选位」的仓(拣选位 = pack_type='Bin' 且 bin_group 为空), + * 口径与「拣选库位动销分析」保持一致。 + */ +const WAREHOUSES = Object.freeze({ + cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 }, + shandong: { key: 'shandong', wmsCoId: 15137523, name: '简尚-山东青岗集仓', shortName: '山东青岗集仓', order: 20 }, + henglin2: { key: 'henglin2', wmsCoId: 15033302, name: '简尚-横林二仓', shortName: '横林二仓', order: 30 }, + subei: { key: 'subei', wmsCoId: 15038347, name: '简尚-新苏北仓', shortName: '新苏北仓', order: 40 }, + henan: { key: 'henan', wmsCoId: 15539084, name: '简尚-河南洛宁仓', shortName: '河南洛宁仓', order: 50 }, + shandongBrand: { key: 'shandongBrand', wmsCoId: 15182705, name: '简尚-山东品牌仓', shortName: '山东品牌仓', order: 60 }, + cuiqiaoBcp: { key: 'cuiqiaoBcp', wmsCoId: 15718196, name: '简尚-崔桥工厂半成品包装项目仓', shortName: '崔桥半成品包装仓', order: 70 }, + suining: { key: 'suining', wmsCoId: 15032603, name: '简尚-睢宁仓', shortName: '睢宁仓', order: 80 }, + cuiqiaoBrand: { key: 'cuiqiaoBrand', wmsCoId: 15870495, name: '简尚-崔桥工厂品牌仓', shortName: '崔桥品牌仓', order: 90 }, + henglin3: { key: 'henglin3', wmsCoId: 15043561, name: '简尚-横林三仓', shortName: '横林三仓', order: 100 }, +}); + +const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order); + +const DEFAULT_WAREHOUSE_KEY = 'cuiqiao'; +const DEFAULT_TARGET_DAYS = 7; +const MIN_TARGET_QTY = 10; +const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐 +const MAX_PAGE_SIZE = 5000; + +const STATUS = Object.freeze({ + PICK_OUT: 'pickout', + EMPTY: 'empty', + LOW: 'low', + NO_PICK: 'nopick', + OK: 'ok', +}); + +const STATUS_LABELS = Object.freeze({ + pickout: '拣货位缺货', + empty: '无货可补', + low: '拣货位偏低', + nopick: '未设拣货位', + ok: '正常', +}); + +const STATUS_RANK = Object.freeze({ pickout: 4, empty: 3, low: 2, nopick: 1, ok: 0 }); + +const cacheStore = new Map(); +let cacheEpoch = 0; + +function toText(value, maxLength = 200) { + return String(value ?? '').trim().slice(0, maxLength); +} + +function toNumber(value, fallback = 0) { + const num = Number(value); + return Number.isFinite(num) ? num : fallback; +} + +function round2(value) { + return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100; +} + +function normalizeBin(value) { + return toText(value, 64).toUpperCase().replace(/\s+/g, ''); +} + +function resolveWarehouseKey(value) { + const key = toText(value, 32).toLowerCase(); + return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY; +} + +function resolveWarehouse(value) { + return WAREHOUSES[resolveWarehouseKey(value)]; +} + +function resolveCacheTtlMs() { + const raw = process.env.BIN_REPLENISH_CACHE_TTL_MS; + if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_CACHE_TTL_MS; + const num = Number(raw); + if (!Number.isFinite(num) || num < 0) return DEFAULT_CACHE_TTL_MS; + return num; +} + +function resolveTargetDays(value) { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) return DEFAULT_TARGET_DAYS; + return Math.min(Math.round(num), 90); +} + +// ── 拣货位清单(本地库)──────────────────────────────────────────── +async function loadPickBinRows(warehouseKey) { + const rows = await database.local.query( + 'SELECT bin_code AS binCode, imported_at AS importedAt FROM bin_replenish_pick_bin WHERE warehouse_key = ?', + [warehouseKey] + ); + return rows || []; +} + +async function loadLatestPickBinBatch(warehouseKey) { + const rows = await database.local.query( + `SELECT batch_id AS batchId, file_name AS fileName, imported_rows AS importedRows, + imported_by AS importedBy, imported_at AS importedAt + FROM bin_replenish_import_batch WHERE warehouse_key = ? + ORDER BY imported_at DESC LIMIT 1`, + [warehouseKey] + ); + return rows?.[0] || null; +} + +async function getPickBinMeta(warehouseKey) { + const [bins, batch] = await Promise.all([ + loadPickBinRows(warehouseKey), + loadLatestPickBinBatch(warehouseKey), + ]); + return { + count: bins.length, + sample: bins.slice(0, 20).map((row) => row.binCode), + lastImport: batch, + source: bins.length > 0 ? 'import' : 'fallback', + }; +} + +/** 从任意表格/文本里抽取货位号:扫描所有单元格,按逗号/分号/换行拆开,保留 ASCII 货位形态 */ +const BIN_TOKEN_PATTERN = /^[0-9A-Z]+(?:-[0-9A-Z]+){0,5}$/; +// 表头 / 常见非货位词,避免把标题行当成货位导进来 +const BIN_TOKEN_BLOCKLIST = new Set(['BIN', 'CODE', 'SKU', 'SNO', 'NO', 'ID', 'LOCATION', 'LOC', 'POSITION']); + +function extractBinCodes(matrix = []) { + const codes = new Set(); + let candidates = 0; + matrix.forEach((row) => { + (Array.isArray(row) ? row : [row]).forEach((cell) => { + if (cell === null || cell === undefined) return; + if (cell instanceof Date) return; + const text = String(cell).trim(); + if (!text) return; + const tokens = text.split(/[,,;;\s\r\n\t|、]+/u).filter(Boolean); + tokens.forEach((token) => { + const code = normalizeBin(token); + if (!code) return; + candidates += 1; + if (code.length < 3 || code.length > 64) return; + if (BIN_TOKEN_BLOCKLIST.has(code)) return; + if (!BIN_TOKEN_PATTERN.test(code)) return; + // 纯数字/纯字母也放过(有些仓用 A01、1234 这种),但单个字符的不要 + if (/^\d+$/.test(code) && code.length < 2) return; + codes.add(code); + }); + }); + }); + return { codes: [...codes], candidates }; +} + +async function importPickBins({ buffer, fileName, warehouseKey, importedBy }) { + if (!buffer || !buffer.length) { + const error = new Error('文件内容为空,请重新选择'); + error.statusCode = 400; + throw error; + } + const key = resolveWarehouseKey(warehouseKey); + let workbook; + try { + workbook = XLSX.read(buffer, { type: 'buffer', cellDates: false }); + } catch (error) { + const err = new Error(`文件解析失败:${error.message}`); + err.statusCode = 400; + throw err; + } + + const matrix = []; + workbook.SheetNames.forEach((sheetName) => { + const sheet = workbook.Sheets[sheetName]; + if (!sheet) return; + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false }); + rows.forEach((row) => matrix.push(row)); + }); + + const { codes, candidates } = extractBinCodes(matrix); + if (!codes.length) { + const error = new Error('没有识别到货位号。清单里请只放货位号(如 XA01-01-11),或把它放在单独一列。'); + error.statusCode = 400; + throw error; + } + + const batchId = crypto.randomBytes(8).toString('hex'); + await database.local.query('DELETE FROM bin_replenish_pick_bin WHERE warehouse_key = ?', [key]); + + const CHUNK = 500; + for (let i = 0; i < codes.length; i += CHUNK) { + const chunk = codes.slice(i, i + CHUNK); + const placeholders = chunk.map(() => '(?,?,?,NOW())').join(','); + const params = []; + chunk.forEach((code) => { + params.push(key, code, batchId); + }); + // eslint-disable-next-line no-await-in-loop + await database.local.query( + `INSERT INTO bin_replenish_pick_bin (warehouse_key, bin_code, batch_id, imported_at) VALUES ${placeholders}`, + params + ); + } + + await database.local.query( + `INSERT INTO bin_replenish_import_batch + (batch_id, warehouse_key, file_name, total_cells, imported_rows, imported_by, imported_at) + VALUES (?,?,?,?,?,?,NOW())`, + [batchId, key, toText(fileName, 255) || null, candidates, codes.length, toText(importedBy, 100) || null] + ); + + invalidateCache(key); + + return { + batchId, + warehouse: WAREHOUSES[key], + importedRows: codes.length, + totalCells: candidates, + fileName: toText(fileName, 255) || null, + sample: codes.slice(0, 20), + }; +} + +async function clearPickBins(warehouseKey) { + const key = resolveWarehouseKey(warehouseKey); + const rows = await database.local.query( + 'SELECT COUNT(*) AS cnt FROM bin_replenish_pick_bin WHERE warehouse_key = ?', + [key] + ); + await database.local.query('DELETE FROM bin_replenish_pick_bin WHERE warehouse_key = ?', [key]); + invalidateCache(key); + return { warehouse: WAREHOUSES[key], removed: Number(rows?.[0]?.cnt || 0) }; +} + +// ── 副本取数 ──────────────────────────────────────────────────────── +/** + * 按 SKU + 货位 + 记录类型聚合。 + * 必须用 GROUP BY(不要把原始行拉回进程):视图原始行 25,488 行约 80s, + * 聚合后 23,751 行约 43s,是当前可用的最快形态。 + */ +async function loadReplicaRows(warehouse) { + const sql = ` + SELECT p.sku_id AS skuId, + p.bin AS bin, + p.pack_type AS packType, + p.bin_group AS binGroup, + SUM(p.qty) AS qty, + MAX(s.name) AS productName, + MAX(s.supplier_name) AS supplier + FROM rpt.jst_packInventoryInfo p + LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id + WHERE p.wms_co_id = @wmsCoId + GROUP BY p.sku_id, p.bin, p.pack_type, p.bin_group`; + return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 }); +} + +/** 近15天日销(同仓最新快照),只用于算目标库存与排序 */ +async function loadDailySaleMap(warehouse) { + const rows = await cloudDb.query( + `SELECT sku_id AS skuId, fifteenAvgSale AS avgSale + FROM rpt.pm_stockMonitor + WHERE warehouse = @warehouseName + AND CONVERT(varchar(10), date, 23) = ( + SELECT CONVERT(varchar(10), MAX(date), 23) + FROM rpt.pm_stockMonitor WHERE warehouse = @warehouseName + )`, + { warehouseName: warehouse.name }, + { requestTimeout: 300000 } + ); + const map = new Map(); + (rows || []).forEach((row) => { + const skuId = toText(row.skuId, 120); + if (skuId) map.set(skuId, toNumber(row.avgSale)); + }); + return map; +} + +// ── 组装 ──────────────────────────────────────────────────────────── +function buildSkuRows({ replicaRows, pickBinSet, dailySaleMap, targetDays, useFallback }) { + const skuMap = new Map(); + + (replicaRows || []).forEach((row) => { + const skuId = toText(row.skuId, 120); + if (!skuId) return; + const bin = normalizeBin(row.bin); + const qty = toNumber(row.qty); + const isPick = useFallback + // 默认口径与「拣选库位动销分析」一致:真实拣选位 = pack_type='Bin' 且 bin_group 为空 + // (bin_group='存货' 与 pack_type='Pack' 都是存货,不算拣选位) + ? (toText(row.packType) === 'Bin' && !toText(row.binGroup)) + : (bin ? pickBinSet.has(bin) : false); + + let entry = skuMap.get(skuId); + if (!entry) { + entry = { + skuId, + productName: toText(row.productName, 300) || '', + supplier: toText(row.supplier, 120) || '', + pickQty: 0, + storageQty: 0, + pickBins: [], + storageBins: [], + }; + skuMap.set(skuId, entry); + } + if (!entry.productName) entry.productName = toText(row.productName, 300) || ''; + if (!entry.supplier) entry.supplier = toText(row.supplier, 120) || ''; + + if (isPick) { + entry.pickQty += qty; + entry.pickBins.push({ bin: bin || '未维护', qty }); + } else { + entry.storageQty += qty; + entry.storageBins.push({ bin: bin || '未维护', qty }); + } + }); + + const rows = []; + skuMap.forEach((entry) => { + const avgSale = toNumber(dailySaleMap.get(entry.skuId), 0); + const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(avgSale * targetDays)); + const pickQty = round2(entry.pickQty); + const storageQty = round2(entry.storageQty); + + let status = STATUS.OK; + if (entry.pickBins.length === 0) { + // 这个 SKU 压根没有拣货位(整箱直发 / 未维护)——不是「拣货位缺货」, + // 不能混进待补货清单,否则会刷出几千条误报。 + status = STATUS.NO_PICK; + } else if (pickQty <= 0) { + status = storageQty > 0 ? STATUS.PICK_OUT : STATUS.EMPTY; + } else if (pickQty < targetQty && storageQty > 0) { + status = STATUS.LOW; + } + + const suggestQty = status === STATUS.PICK_OUT || status === STATUS.LOW + ? Math.max(0, Math.min(Math.ceil(storageQty), Math.ceil(targetQty - pickQty))) + : 0; + + const pickBins = entry.pickBins + .slice() + .sort((a, b) => b.qty - a.qty || a.bin.localeCompare(b.bin)); + const storageBins = entry.storageBins + .filter((item) => item.qty > 0) + .sort((a, b) => b.qty - a.qty) + .slice(0, 5); + + rows.push({ + skuId: entry.skuId, + productName: entry.productName, + supplier: entry.supplier, + status, + statusLabel: STATUS_LABELS[status], + pickQty, + storageQty, + totalQty: round2(pickQty + storageQty), + targetQty, + suggestQty, + avgSale: round2(avgSale), + pickBinCount: entry.pickBins.length, + pickBins, + storageBins, + hasPickSlot: entry.pickBins.length > 0, + }); + }); + + return rows; +} + +/** 按页面传入的目标天数重算目标库存与建议补货量(缓存里存的是默认 7 天口径) */ +function applyTargetDays(rows, targetDays) { + if (targetDays === DEFAULT_TARGET_DAYS) return rows; + return rows.map((row) => { + const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(row.avgSale * targetDays)); + const suggestQty = (row.status === STATUS.PICK_OUT || row.status === STATUS.LOW) + ? Math.max(0, Math.min(Math.ceil(row.storageQty), Math.ceil(targetQty - row.pickQty))) + : 0; + return { ...row, targetQty, suggestQty }; + }); +} + +function buildSummary(rows, meta) { + const summary = { + skuTotal: rows.length, + pickout: 0, + empty: 0, + low: 0, + nopick: 0, + ok: 0, + suggestQtyTotal: 0, + pickoutQtyTotal: 0, + storageQtyTotal: 0, + withPickSlot: 0, + source: meta.source, + pickBinCount: meta.pickBinCount, + }; + rows.forEach((row) => { + if (summary[row.status] !== undefined) summary[row.status] += 1; + summary.suggestQtyTotal += row.suggestQty || 0; + summary.storageQtyTotal += row.storageQty || 0; + if (row.status === STATUS.PICK_OUT) summary.pickoutQtyTotal += row.suggestQty || 0; + if (row.hasPickSlot) summary.withPickSlot += 1; + }); + summary.suggestQtyTotal = round2(summary.suggestQtyTotal); + summary.pickoutQtyTotal = round2(summary.pickoutQtyTotal); + summary.storageQtyTotal = round2(summary.storageQtyTotal); + return summary; +} + +async function loadDataset(warehouse, { refresh = false } = {}) { + const cacheKey = warehouse.key; + const cached = cacheStore.get(cacheKey); + const ttl = resolveCacheTtlMs(); + const now = Date.now(); + if (!refresh && cached && ttl > 0 && now - cached.at < ttl) { + return { ...cached, cacheHit: true }; + } + + const epoch = cacheEpoch; + const [replicaRows, pickBins, dailySaleMap] = await Promise.all([ + loadReplicaRows(warehouse), + loadPickBinRows(warehouse.key), + loadDailySaleMap(warehouse).catch(() => new Map()), + ]); + + const pickBinSet = new Set(pickBins.map((row) => normalizeBin(row.binCode)).filter(Boolean)); + const useFallback = pickBinSet.size === 0; + const rows = buildSkuRows({ + replicaRows, + pickBinSet, + dailySaleMap, + targetDays: DEFAULT_TARGET_DAYS, + useFallback, + }); + + const dataset = { + at: now, + rows, + warehouse, + meta: { + source: useFallback ? 'fallback' : 'import', + pickBinCount: pickBinSet.size, + dailySaleSkus: dailySaleMap.size, + rowCount: rows.length, + }, + }; + // 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据 + if (epoch === cacheEpoch) cacheStore.set(cacheKey, dataset); + return { ...dataset, cacheHit: false }; +} + +function invalidateCache(warehouseKey) { + cacheEpoch += 1; + if (warehouseKey) cacheStore.delete(resolveWarehouseKey(warehouseKey)); + else cacheStore.clear(); +} + +function matchesStatus(row, status) { + const key = toText(status, 20).toLowerCase(); + if (!key || key === 'all') return true; + if (key === 'action' || key === 'todo') { + return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW || row.status === STATUS.EMPTY; + } + if (key === 'replenish') return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW; + return row.status === key; +} + +function sortRows(rows) { + return rows.slice().sort((a, b) => ( + (STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0) + || (b.suggestQty || 0) - (a.suggestQty || 0) + || String(a.skuId).localeCompare(String(b.skuId)) + )); +} + +function filterRows(rows, { keyword, status }) { + const kw = toText(keyword, 120).toLowerCase(); + return rows.filter((row) => { + if (!matchesStatus(row, status)) return false; + if (!kw) return true; + return row.skuId.toLowerCase().includes(kw) + || String(row.productName || '').toLowerCase().includes(kw) + || String(row.supplier || '').toLowerCase().includes(kw); + }); +} + +async function getReplenishList(options = {}) { + const warehouse = resolveWarehouse(options.warehouse); + const targetDays = resolveTargetDays(options.targetDays); + const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' }); + + // targetDays 是页面参数,不进缓存:缓存的是原始聚合,这里按参数重算目标/建议量 + const rows = applyTargetDays(dataset.rows, targetDays); + + const filtered = sortRows(filterRows(rows, options)); + const pickBinMeta = await getPickBinMeta(warehouse.key); + const summary = buildSummary(rows, { + source: pickBinMeta.source, + pickBinCount: pickBinMeta.count, + }); + const page = Math.max(1, Number.parseInt(options.page, 10) || 1); + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(options.pageSize, 10) || 50)); + const start = (page - 1) * pageSize; + + return { + rows: filtered.slice(start, start + pageSize), + total: filtered.length, + page, + pageSize, + summary, + warehouse, + warehouses: Object.values(WAREHOUSES), + targetDays, + cacheHit: dataset.cacheHit, + updatedAt: new Date(dataset.at).toISOString(), + cacheTtlMs: resolveCacheTtlMs(), + pickBinMeta, + dailySaleSkus: dataset.meta.dailySaleSkus, + }; +} + +async function getReplenishExportRows(options = {}) { + const warehouse = resolveWarehouse(options.warehouse); + const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' }); + const targetDays = resolveTargetDays(options.targetDays); + const rows = applyTargetDays(dataset.rows, targetDays); + return { + warehouse, + rows: sortRows(filterRows(rows, options)), + summary: buildSummary(rows, { + source: dataset.meta.source, + pickBinCount: dataset.meta.pickBinCount, + }), + }; +} + +/** 启动预热:视图查询 35~45s,不能让首个用户请求等 */ +async function warmBinReplenishCache() { + try { + const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY]; + const t0 = Date.now(); + await loadDataset(warehouse, { refresh: true }); + console.log(`[BinReplenish] 预热完成 ${warehouse.name},耗时 ${Date.now() - t0}ms`); + } catch (error) { + console.warn('[BinReplenish] 预热失败:', error.message); + } +} + +module.exports = { + DEFAULT_WAREHOUSE_KEY, + STATUS, + STATUS_LABELS, + WAREHOUSES, + clearPickBins, + getPickBinMeta, + getReplenishExportRows, + getReplenishList, + importPickBins, + loadDataset, + normalizeBin, + resolveWarehouse, + resolveWarehouseKey, + warmBinReplenishCache, +}; diff --git a/backend/src/services/localSchema.js b/backend/src/services/localSchema.js index 4074beb..f7ec44d 100644 --- a/backend/src/services/localSchema.js +++ b/backend/src/services/localSchema.js @@ -16,6 +16,8 @@ const { ensureReturnMonitorSchema } = require('./returnMonitorSchema'); const { ensureReturnStockWarningSchema, } = require('./returnStockWarningSchema'); +const { ensureBinReplenishSchema } = require('./binReplenishSchema'); +const { warmBinReplenishCache } = require('./binReplenishService'); const { warmReturnStockWarningCache } = require('./returnStockWarningService'); const USER_COLUMNS = [ @@ -959,8 +961,11 @@ async function ensureLocalSchema() { await ensureProductMonitoringSchema(); await ensureReturnMonitorSchema(); await ensureReturnStockWarningSchema(); + await ensureBinReplenishSchema(); // 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动 void warmReturnStockWarningCache(); + // 缺货补货监控(仓位库存视图单次查询 35~45s)后台预热,不阻塞启动 + void warmBinReplenishCache(); await ensureSidebarRolePermissions(); // 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动 diff --git a/frontend/src/api/binReplenish.js b/frontend/src/api/binReplenish.js new file mode 100644 index 0000000..0c2f050 --- /dev/null +++ b/frontend/src/api/binReplenish.js @@ -0,0 +1,40 @@ +import request from './request'; + +const LONG_TIMEOUT_MS = 10 * 60 * 1000; + +// 缺货补货监控(拣货位缺货 → 从存货位补货) +// params.warehouse: changzhou / wh7 / suining / henglin2 / subei / henglin3(缺省 = 常州主仓) +export const getBinReplenishList = (params = {}) => request.get( + '/api/bin-replenish/list', + { params, timeout: LONG_TIMEOUT_MS } +); + +export const exportBinReplenish = (params = {}) => request.get( + '/api/bin-replenish/export', + { + params, + responseType: 'blob', + timeout: LONG_TIMEOUT_MS, + silentError: true, + } +); + +export const getPickBinMeta = (warehouse) => request.get( + '/api/bin-replenish/pick-bins', + { params: warehouse ? { warehouse } : {} } +); + +export const importPickBins = (file, warehouse) => { + const formData = new FormData(); + formData.append('file', file); + if (warehouse) formData.append('warehouse', warehouse); + return request.post('/api/bin-replenish/pick-bins', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: LONG_TIMEOUT_MS, + }); +}; + +export const clearPickBins = (warehouse) => request.delete( + '/api/bin-replenish/pick-bins', + { params: warehouse ? { warehouse } : {} } +); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 0fd54f9..37033a0 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -29,6 +29,7 @@ export const DEFAULT_SIDEBAR_CONFIG = { ], }, { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, + { key: 'bin-replenish-monitor', type: 'item', label: '缺货补货监控', icon: 'inbox', routeKey: 'bin-replenish-monitor', visible: true, order: 90 }, { key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true, children: [ diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js index 0b70943..f33d1f4 100644 --- a/frontend/src/constants/sidebarPages.js +++ b/frontend/src/constants/sidebarPages.js @@ -12,6 +12,7 @@ export const SIDEBAR_PAGES = [ { pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_shandong', path: '/planning/operation/returns-inventory/shandong', name: '山东客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_henan', path: '/planning/operation/returns-inventory/henan', name: '河南客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, + { pageKey: 'bin_replenish_monitor', path: '/planning/operation/bin-replenish-monitor', name: '缺货补货监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, ]; export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index 26960f6..b51732e 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -134,6 +134,11 @@ 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' }, + 'bin-replenish-monitor': { + path: '/planning/operation/bin-replenish-monitor', + activePath: '/planning/operation/bin-replenish-monitor', + matchMode: 'prefix', + }, 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/BinReplenishMonitor/index.jsx b/frontend/src/pages/BinReplenishMonitor/index.jsx new file mode 100644 index 0000000..292c3fc --- /dev/null +++ b/frontend/src/pages/BinReplenishMonitor/index.jsx @@ -0,0 +1,517 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, Button, Empty, Input, InputNumber, Popconfirm, Segmented, Select, Spin, Table, Tag, Tooltip, Upload, + message, +} from 'antd'; +import { + DeleteOutlined, DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UploadOutlined, +} from '@ant-design/icons'; +import { + clearPickBins, exportBinReplenish, getBinReplenishList, importPickBins, +} from '../../api/binReplenish'; +import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport'; +import { SycmPage } from '../../components/Common'; +import './style.css'; + +const num = (value) => (value === null || value === undefined || value === '' + ? '--' + : Number(value).toLocaleString('zh-CN')); + +const STATUS_TAGS = { + pickout: { color: 'red', label: '拣货位缺货' }, + empty: { color: 'default', label: '无货可补' }, + low: { color: 'orange', label: '拣货位偏低' }, + nopick: { color: 'blue', label: '未设拣货位' }, + ok: { color: 'green', label: '正常' }, +}; + +const STATUS_OPTIONS = [ + { label: '待补货', value: 'replenish' }, + { label: '无货可补', value: 'empty' }, + { label: '未设拣货位', value: 'nopick' }, + { label: '全部', value: 'all' }, +]; + +const DEFAULT_TARGET_DAYS = 7; + +const renderBinTags = (bins = [], emptyText = '未维护', limit = 3) => { + if (!bins.length) return {emptyText}; + const visible = bins.slice(0, limit); + const rest = bins.length - visible.length; + return ( + + {visible.map((item) => ( + 0 ? '' : ' is-zero'}`}> + {item.bin} + {num(item.qty)} + + ))} + {rest > 0 ? ( + `${item.bin}(${num(item.qty)})`).join('、')}> + +{rest} + + ) : null} + + ); +}; + +const BinReplenishMonitor = () => { + const [warehouse, setWarehouse] = useState('cuiqiao'); + const [status, setStatus] = useState('replenish'); + const [keyword, setKeyword] = useState(''); + const [targetDays, setTargetDays] = useState(DEFAULT_TARGET_DAYS); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(''); + const [data, setData] = useState({ + rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], pickBinMeta: null, updatedAt: '', cacheHit: false, + }); + const [importing, setImporting] = useState(false); + const keywordTimer = useRef(null); + + const load = useCallback(async (options = {}) => { + const isRefresh = options.refresh === true; + if (isRefresh) setRefreshing(true); + else setLoading(true); + setError(''); + try { + const response = await getBinReplenishList({ + warehouse, + status, + keyword, + targetDays, + page: options.page || page, + pageSize: options.pageSize || pageSize, + refresh: isRefresh ? 'true' : undefined, + }); + const payload = response?.data?.data || response?.data || {}; + setData({ + rows: payload.rows || [], + total: payload.total || 0, + summary: payload.summary || {}, + warehouse: payload.warehouse || null, + warehouses: payload.warehouses || [], + pickBinMeta: payload.pickBinMeta || null, + updatedAt: payload.updatedAt || '', + cacheHit: Boolean(payload.cacheHit), + }); + if (isRefresh) message.success('已重新回源取数'); + } catch (err) { + setError(err?.message || '加载失败'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [warehouse, status, keyword, targetDays, page, pageSize]); + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [warehouse, status, page, pageSize, targetDays]); + + useEffect(() => { + if (keywordTimer.current) clearTimeout(keywordTimer.current); + keywordTimer.current = setTimeout(() => { + setPage(1); + load({ page: 1 }); + }, 400); + return () => { if (keywordTimer.current) clearTimeout(keywordTimer.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [keyword]); + + const summary = data.summary; + const pickBinMeta = data.pickBinMeta || {}; + const usingFallback = pickBinMeta.source !== 'import'; + + const metrics = useMemo(() => ([ + { + key: 'pickout', + tone: 'red', + label: '拣货位缺货', + value: summary.pickout || 0, + unit: '个 SKU', + foot: `可立即从存货位补 ${num(summary.suggestQtyTotal)} 件`, + tip: '拣货位库存为 0、存货位还有货:需要把存货位的库存补到拣货位', + }, + { + key: 'empty', + tone: 'grey', + label: '无货可补', + value: summary.empty || 0, + unit: '个 SKU', + foot: '拣货位与存货位都为 0,需采购', + tip: '拣货位库存为 0,且仓库里已经没有该商品的存货,补货要走走采购', + }, + { + key: 'low', + tone: 'orange', + label: '拣货位偏低', + value: summary.low || 0, + unit: '个 SKU', + foot: `低于 ${targetDays} 天销量目标`, + tip: `拣货位还有货,但低于目标库存(= 近15天日销 × ${targetDays},最少 ${10} 件)`, + }, + { + key: 'qty', + tone: 'blue', + label: '建议补货合计', + value: summary.suggestQtyTotal || 0, + unit: '件', + foot: `覆盖 ${num(summary.withPickSlot)} 个有拣货位的 SKU`, + tip: '建议补货量 = min(存货位库存, 目标拣货位库存 − 当前拣货位库存)', + }, + ]), [summary, targetDays]); + + const handleExport = async () => { + try { + const response = await exportBinReplenish({ warehouse, status, keyword, targetDays }); + downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控.xlsx`); + } catch (error_) { + const text = await readBlobErrorMessage(error_); + message.error(text || '导出失败'); + } + }; + + const handleImport = async (file) => { + setImporting(true); + try { + const response = await importPickBins(file, warehouse); + const result = response?.data?.data || {}; + message.success(`已导入 ${result.importedRows || 0} 个拣货位,正在重新取数…`); + await load({ refresh: true, page: 1 }); + setPage(1); + } catch (error_) { + message.error(error_?.message || '导入失败'); + } finally { + setImporting(false); + } + return false; + }; + + const handleClear = async () => { + try { + await clearPickBins(warehouse); + message.success('已清除货位清单,回到「货位散件记录」回退口径'); + setPage(1); + await load({ refresh: true, page: 1 }); + } catch (error_) { + message.error(error_?.message || '清除失败'); + } + }; + + const columns = [ + { + title: '状态', + dataIndex: 'status', + width: 112, + fixed: 'left', + render: (value) => { + const tag = STATUS_TAGS[value] || STATUS_TAGS.ok; + return {tag.label}; + }, + }, + { + title: '商品编码', + dataIndex: 'skuId', + width: 175, + render: (value, row) => ( +
+ {value} + {row.supplier ? {row.supplier} : null} +
+ ), + }, + { title: '品名', dataIndex: 'productName', width: 260, ellipsis: true }, + { + title: '拣货位(货位号 / 数量)', + dataIndex: 'pickBins', + width: 300, + render: (bins, row) => (row.hasPickSlot ? renderBinTags(bins) : 未维护拣货位), + }, + { + title: '拣货位库存', + dataIndex: 'pickQty', + width: 108, + align: 'right', + render: (value) => ( + {num(value)} + ), + }, + { + title: '存货位库存', + dataIndex: 'storageQty', + width: 108, + align: 'right', + render: (value) => {num(value)}, + }, + { + title: '建议补货', + dataIndex: 'suggestQty', + width: 110, + align: 'right', + render: (value) => ( + 0 ? 'brp-qty brp-qty--suggest' : 'brp-qty brp-qty--zero'}> + {num(value)} + + ), + }, + { + title: '目标拣货位', + dataIndex: 'targetQty', + width: 108, + align: 'right', + render: (value) => {num(value)}, + }, + { + title: '存货位(前 5 个 / 数量)', + dataIndex: 'storageBins', + width: 300, + render: (bins) => renderBinTags(bins, '无存货', 3), + }, + { + title: '近15天日销', + dataIndex: 'avgSale', + width: 108, + align: 'right', + render: (value) => {num(value)}, + }, + { + title: '现有库存', + dataIndex: 'totalQty', + width: 100, + align: 'right', + render: (value) => {num(value)}, + }, + ]; + + return ( + +
+
+

缺货补货监控

+

+ 盯的是拣货位:拣货位没货就拣不出来,需要从存货位把货补过去。 + 状态、建议补货量都按当前库存实时算,数据来自聚水潭「箱及仓位库存」。 +

+
+
+
+ 仓库:{(data.warehouse || {}).name || '简尚-崔桥工厂仓'} +
+
+ 拣货位口径: + + {usingFallback ? '默认拣选位(Bin 且非存货位)' : `导入清单 ${num(pickBinMeta.count)} 个`} + +
+
+ 取数时间:{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'} + {data.cacheHit ? (缓存) : null} +
+
+ 共 {num(summary.skuTotal)} 个 SKU,其中未设拣货位{' '} + {num(summary.nopick)} 个(不参与待补货) +
+
+
+ +
+ {metrics.map((item) => ( +
+ +
+ {item.label} + +
+
+
+ {num(item.value)} + {item.unit} +
+
{item.foot}
+
+ ))} +
+ +
+ + 仓库 + setKeyword(event.target.value)} + placeholder="搜商品编码 / 品名 / 供应商" + prefix={} + style={{ width: 260 }} + /> + + + + + + +
+ + {usingFallback ? ( + + + + )} + /> + ) : ( + + + + )} + /> + )} + + {error ? ( + load()}>重试} + /> + ) : null} + + + + ), + }} + pagination={{ + current: page, + pageSize, + total: data.total, + showSizeChanger: true, + pageSizeOptions: ['20', '50', '100', '200'], + showTotal: (total) => `共 ${total} 个 SKU`, + onChange: (nextPage, nextSize) => { + setPage(nextPage); + setPageSize(nextSize); + }, + }} + /> + + +
+
数据来源与口径说明
+
    +
  • + 库存:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码 + 货位汇总。 + 「拣货位库存」= 拣货位上的数量合计;「存货位库存」= 其余货位(含整箱、未分箱)的数量合计。 +
  • +
  • + 拣货位:副本没有货位类型字段,按导入的货位清单判定;未导入时回退为 + pack_type = Bin 的记录(页面顶部会标出当前口径)。 +
  • +
  • + 目标库存:max(10, 近15天日销 × {targetDays});日销取 rpt.pm_stockMonitor 同仓最新快照的 + fifteenAvgSale,取不到的按 10 件兜底。 +
  • +
  • + 建议补货量:min(存货位库存, 目标拣货位库存 − 当前拣货位库存),只在「拣货位缺货 / 偏低」时给数字; + 「无货可补」表示拣货位和存货位都没货,要走走采购,不是仓库内部调拨。 +
  • +
  • + 未设拣货位:该 SKU 在拣货位清单里一个货位都没有(多为整箱直发、或还没维护拣货位)。 + 这类不算「拣货位缺货」,不进待补货清单,可在「未设拣货位」页签单独核对。 +
  • +
  • + 时效:仓位库存视图单次聚合约 35~45 秒,服务端缓存 10 分钟(启动时已预热); + 点「刷新数据」会强制回源,需要等半分钟左右。 +
  • +
+
+ + ); +}; + +export default BinReplenishMonitor; diff --git a/frontend/src/pages/BinReplenishMonitor/style.css b/frontend/src/pages/BinReplenishMonitor/style.css new file mode 100644 index 0000000..1ef578f --- /dev/null +++ b/frontend/src/pages/BinReplenishMonitor/style.css @@ -0,0 +1,303 @@ +/* 缺货补货监控 —— 页头 / 指标卡 / 工具栏 / 明细表 */ + +.brp-page { + --brp-gap: 12px; + --brp-border: var(--sycm-border-card, #e6e6e6); + --brp-radius: var(--sycm-radius-xl, 12px); + --brp-shadow: var(--sycm-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.035)); + --brp-blue: #2062e6; + --brp-red: #e5484d; + --brp-orange: #e8890c; + --brp-grey: #6b7280; + --brp-green: #1bb574; +} + +/* ── 页头 ───────────────────────────────────────────────── */ +.brp-hero { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 12px 24px; + margin-bottom: var(--brp-gap); +} + +.brp-hero__main { + min-width: 320px; +} + +.brp-hero__title { + margin: 0; + color: var(--sycm-text-strong, #262626); + font-size: 20px; + font-weight: 600; + line-height: 1.3; +} + +.brp-hero__desc { + margin: 4px 0 0; + max-width: 820px; + color: var(--sycm-text-secondary, #595959); + font-size: 12px; + line-height: 1.6; +} + +.brp-hero__meta { + display: grid; + gap: 3px; + color: var(--sycm-text-secondary, #595959); + font-size: 12px; + line-height: 1.6; + text-align: right; +} + +.brp-warn { + color: var(--brp-orange); +} + +.brp-ok { + color: var(--brp-green); +} + +/* ── 指标卡 ─────────────────────────────────────────────── */ +.brp-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--brp-gap); + margin-bottom: var(--brp-gap); +} + +.brp-metric { + position: relative; + overflow: hidden; + padding: 12px 14px 11px; + border: 1px solid var(--brp-border); + border-radius: var(--brp-radius); + background: var(--sycm-bg-container, #fff); + box-shadow: var(--brp-shadow); +} + +.brp-metric::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 3px; + background: var(--brp-tone, var(--brp-blue)); +} + +.brp-metric--red { --brp-tone: var(--brp-red); } +.brp-metric--orange { --brp-tone: var(--brp-orange); } +.brp-metric--blue { --brp-tone: var(--brp-blue); } +.brp-metric--grey { --brp-tone: var(--brp-grey); } + +.brp-metric__label { + display: flex; + align-items: center; + gap: 4px; + color: var(--sycm-text-tertiary, #8c8c8c); + font-size: 12px; + cursor: help; +} + +.brp-metric__info { + font-size: 11px; +} + +.brp-metric__value { + margin-top: 6px; + color: var(--sycm-text-strong, #262626); + font-size: 24px; + font-variant-numeric: tabular-nums; + font-weight: 600; + line-height: 1.15; +} + +.brp-metric__unit { + margin-left: 4px; + color: var(--sycm-text-tertiary, #8c8c8c); + font-size: 12px; + font-weight: 400; +} + +.brp-metric__foot { + margin-top: 6px; + color: var(--sycm-text-secondary, #595959); + font-size: 12px; + line-height: 1.4; +} + +/* ── 工具栏 ─────────────────────────────────────────────── */ +.brp-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-bottom: 10px; + padding: 10px 12px; + border: 1px solid var(--brp-border); + border-radius: var(--brp-radius); + background: var(--sycm-bg-container, #fff); + box-shadow: var(--brp-shadow); +} + +.brp-toolbar__field { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.brp-toolbar__label { + color: var(--sycm-text-tertiary, #8c8c8c); + font-size: 12px; + white-space: nowrap; +} + +.brp-toolbar__spacer { + flex: 1 1 auto; +} + +.brp-alert { + margin-bottom: 10px; +} + +/* ── 表格 ───────────────────────────────────────────────── */ +.brp-table-spin { + display: block; +} + +.brp-table .ant-table-thead > tr > th { + background: #fafafa; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.brp-table .ant-table-tbody > tr > td { + font-size: 12px; +} + +.brp-status-tag { + margin-inline-end: 0; +} + +.brp-sku { + display: flex; + flex-direction: column; + gap: 2px; +} + +.brp-sku__code { + color: var(--sycm-text-strong, #262626); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +.brp-sku__supplier { + overflow: hidden; + color: var(--sycm-text-tertiary, #8c8c8c); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.brp-bins { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.brp-bin { + margin: 0; + padding: 0 6px; + border-color: #d9e2f2; + background: #f5f8ff; + color: #2b4a80; + font-size: 11px; + line-height: 18px; +} + +.brp-bin b { + margin-left: 4px; + font-variant-numeric: tabular-nums; +} + +.brp-bin.is-zero { + border-color: #f3d2d2; + background: #fff6f6; + color: #a33a3a; +} + +.brp-bin--more { + border-color: #eee; + background: #fafafa; + color: #8c8c8c; +} + +.brp-qty { + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +.brp-qty--zero { + color: var(--brp-red); + font-weight: 600; +} + +.brp-qty--suggest { + color: var(--brp-blue); + font-weight: 600; +} + +.brp-qty--muted { + color: var(--sycm-text-tertiary, #8c8c8c); + font-weight: 400; +} + +.brp-muted { + color: var(--sycm-text-tertiary, #8c8c8c); +} + +/* ── 口径说明 ───────────────────────────────────────────── */ +.brp-notes { + margin-top: 14px; + padding: 12px 14px; + border: 1px dashed var(--brp-border); + border-radius: var(--brp-radius); + background: var(--sycm-bg-container, #fff); + color: var(--sycm-text-secondary, #595959); + font-size: 12px; + line-height: 1.7; +} + +.brp-notes__title { + margin-bottom: 6px; + color: var(--sycm-text-strong, #262626); + font-weight: 600; +} + +.brp-notes ul { + margin: 0; + padding-left: 18px; +} + +.brp-notes li + li { + margin-top: 4px; +} + +@media (max-width: 1200px) { + .brp-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .brp-hero__meta { + text-align: left; + } +} + +@media (max-width: 640px) { + .brp-metrics { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index 82f03c3..a6ecd7d 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -137,6 +137,11 @@ const router = createBrowserRouter([ path: 'planning/operation/picking-bin-sales-analysis', element: renderLazyComponent(PickingBinSalesAnalysis), }, + // 缺货补货监控:拣货位缺货 → 从存货位补货 + { + path: 'planning/operation/bin-replenish-monitor', + element: renderLazyComponent(BinReplenishMonitor), + }, // 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓; // 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线) { -- 2.47.2