diff --git a/backend/src/api/binReplenish.js b/backend/src/api/binReplenish.js index 9d00a36..a9afc4b 100644 --- a/backend/src/api/binReplenish.js +++ b/backend/src/api/binReplenish.js @@ -1,22 +1,13 @@ '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: '商品编码' }, @@ -24,16 +15,15 @@ const EXPORT_COLUMNS = [ { key: 'supplier', title: '供应商' }, { key: 'statusLabel', title: '状态' }, { key: 'pickQty', title: '拣货位库存' }, - { key: 'storageQty', title: '存货位库存' }, + { key: 'storageQty', title: '存储位库存' }, { key: 'totalQty', title: '现有库存合计' }, - { key: 'targetQty', title: '目标拣货位库存' }, + { key: 'orderQty', title: '当日订单' }, + { key: 'excess', title: '超出订单数' }, { key: 'suggestQty', title: '建议补货量' }, - { key: 'avgSale', title: '近15天日销' }, - { key: 'pickBinText', title: '拣货位(货位号/数量)' }, - { key: 'storageBinText', title: '存货位(前5个/数量)' }, + { key: 'shortfall', title: '存储缺口' }, ]; -/** GET /api/bin-replenish/list —— 缺货补货监控列表 */ +/** GET /api/bin-replenish/list —— 缺货补货监控列表(订单驱动) */ router.get('/list', async (req, res, next) => { try { const payload = await getReplenishList({ @@ -42,7 +32,6 @@ router.get('/list', async (req, res, next) => { 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 }); @@ -59,21 +48,16 @@ router.get('/export', async (req, res, next) => { 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('、'), - })); + const fileName = `${payload.warehouse.shortName}缺货补货监控_${payload.orderDate || ''}`; sendWorkbookDownload(res, { - fileName: `${payload.warehouse.shortName}缺货补货监控_${new Date().toISOString().slice(0, 10)}`, + fileName, format, sheets: [{ name: '缺货补货监控', header: EXPORT_COLUMNS.map((column) => column.title), - rows: rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])), + rows: payload.rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])), }], }); } catch (error) { @@ -81,51 +65,6 @@ router.get('/export', async (req, res, next) => { } }); -/** 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 } }); diff --git a/backend/src/services/binReplenishService.js b/backend/src/services/binReplenishService.js index ad94972..e13c3b8 100644 --- a/backend/src/services/binReplenishService.js +++ b/backend/src/services/binReplenishService.js @@ -1,60 +1,54 @@ 'use strict'; /** - * 缺货补货监控 —— 拣货位缺货 / 从存货位补货 + * 缺货补货监控(订单驱动版,2026-10-22 重写) * ========================================================================== - * 业务需求(2026-10-22 用户确认): - * 监控现有库存 → 看哪些「拣货库位」缺货 → 需要从「存货仓位」补货过去。 + * 业务需求(用户确认): + * 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够今天的订单拣, + * 需要从「存储库位」把货补过去。 + * + * ── 货位口径(前缀规则,用户指定)────────────────────────────────────── + * 拣货库位(拣货仓位):以 XA / XD / XE 开头 + * 存储库位(存储仓位):以 XX / X9 / X5 / X4 开头 + * 其余前缀(DB / Y* / Z* / YC 等,崔桥占比极小)归为「未分类」,不计入拣货/存储。 * * ── 数据源 ─────────────────────────────────────────────────────────── - * 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 秒)。因此必须缓存 + 启动预热, - * 不要把它改回「每次请求都回源」。 + * 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储) + * 订单:rpt.jst_Orders × jst_OrderItem(线上店铺) + * + rpt.jst_qmcust_orderlist × jst_qmcust_orderlist_item(全民客户 B2B) + * + 组合装(sku_type='combine')→ jst_comboProductItem 展开为子件 + * ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1), + * 按 wh_id 会把多仓混成一个桶。 + * 订单日期:取该仓订单表 send_date 的最大日期(即「最近一个有订单的营业日」, + * 副本 ETL 有延迟时不会为空,页面会明确标注统计的是哪一天)。 * - * ── 拣货位怎么认定(关键)───────────────────────────────────────────── - * 副本**没有独立的货位类型主数据**,但「拣选库位动销分析」已确认口径: - * 真实拣选位 = `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)),向上取整 + * ── 判断逻辑(核心)────────────────────────────────────────────────── + * 对每一个 SKU(在拣货/存储位上有库存): + * 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计 + * 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计 + * 当日订单 orderQty = 该 SKU 在订单日期当天的订单量(含组合装展开) + * 超出订单数 excess = max(0, orderQty − pickQty) + * —— 拣货位不够今天的订单拣,超出的这部分要从存储位补到拣货位 + * 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量) + * 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调) * * 状态: - * pickout 拣货位缺货(pickQty ≤ 0,且存货位有货 → 可以补) - * empty 无货可补(pickQty ≤ 0,存货位也没货 → 需要采购) - * low 拣货位偏低(0 < pickQty < target,存货位有货) - * ok 正常 + * short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急 + * need 需补货 (orderQty > pickQty 且 storageQty ≥ excess) + * enough 拣货位充足(0 < orderQty ≤ pickQty) + * no_order 今日无单(orderQty = 0) + * (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调) * - * 近15天日销取自 rpt.pm_stockMonitor(同仓最新快照的 fifteenAvgSale), - * 只用于算目标和排序;取不到时目标按 10 件兜底。 + * ⚠️ 性能:库存视图聚合 + 当日订单 4 分支 union 各自都要 30s~1min, + * 必须缓存(默认 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 为空), - * 口径与「拣选库位动销分析」保持一致。 + * 仓库清单:**按 wms_co_id 取数**。 + * 前缀货位口径(XA/XD/XE 拣货、XX/X9/X5/X4 存储)是崔桥工厂仓的约定; + * 其他仓若用不同前缀,页面会标注,默认聚焦崔桥工厂仓。 */ const WAREHOUSES = Object.freeze({ cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 }, @@ -72,28 +66,31 @@ const WAREHOUSES = Object.freeze({ 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 PICK_PREFIXES = Object.freeze(['XA', 'XD', 'XE']); +const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']); +const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(','); +const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(','); + const STATUS = Object.freeze({ - PICK_OUT: 'pickout', - EMPTY: 'empty', - LOW: 'low', - NO_PICK: 'nopick', - OK: 'ok', + SHORT: 'short', + NEED: 'need', + ENOUGH: 'enough', + NO_ORDER: 'no_order', }); const STATUS_LABELS = Object.freeze({ - pickout: '拣货位缺货', - empty: '无货可补', - low: '拣货位偏低', - nopick: '未设拣货位', - ok: '正常', + short: '存储不足', + need: '需补货', + enough: '拣货位充足', + no_order: '今日无单', }); -const STATUS_RANK = Object.freeze({ pickout: 4, empty: 3, low: 2, nopick: 1, ok: 0 }); +// 排序权重:最紧急的排最前 +const STATUS_RANK = Object.freeze({ short: 4, need: 3, no_order: 2, enough: 1 }); const cacheStore = new Map(); let cacheEpoch = 0; @@ -111,10 +108,6 @@ 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; @@ -132,332 +125,213 @@ function resolveCacheTtlMs() { 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) }; +/** 订单日期的窗口边界(仅用日期字符串,避免时区问题;副本 send_date 按中国营业日存储) */ +function orderWindow(orderDate) { + const d = new Date(`${orderDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + const next = d.toISOString().slice(0, 10); + return { shipFrom: `${orderDate} 00:00:00`, shipTo: `${next} 00:00:00` }; } // ── 副本取数 ──────────────────────────────────────────────────────── /** - * 按 SKU + 货位 + 记录类型聚合。 - * 必须用 GROUP BY(不要把原始行拉回进程):视图原始行 25,488 行约 80s, - * 聚合后 23,751 行约 43s,是当前可用的最快形态。 + * 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。 + * 只统计用户指定的前缀宇宙(XA/XD/XE/XX/X9/X5/X4),其余货位不计入。 */ -async function loadReplicaRows(warehouse) { +async function loadReplicaInventory(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, + SELECT p.sku_id AS skuId, + SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}) + THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS pickQty, + SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${STORE_PREFIX_IN}) + THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS storageQty, + 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`; + AND ISNULL(p.bin, '') <> '' + AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN}) + GROUP BY p.sku_id`; return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 }); } -/** 近15天日销(同仓最新快照),只用于算目标库存与排序 */ -async function loadDailySaleMap(warehouse) { +/** 该仓最近的订单日期(线上 + 全民客户 取最大 send_date 日期) */ +async function loadOrderDate(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 }, + `SELECT MAX(d) AS d FROM ( + SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d + FROM rpt.jst_Orders WHERE wms_co_id = @wmsCoId + UNION ALL + SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d + FROM rpt.jst_qmcust_orderlist WHERE wms_co_id = @wmsCoId + ) t`, + { wmsCoId: warehouse.wmsCoId }, + { requestTimeout: 120000 } + ); + return (rows && rows[0] && rows[0].d) || null; +} + +/** 当日(订单日期)各 SKU 的订单量:4 个来源分支,组合装展开为子件。 */ +async function loadTodayOrderMap(warehouse, orderDate) { + if (!orderDate) return new Map(); + const { shipFrom, shipTo } = orderWindow(orderDate); + const sql = ` + WITH ship AS ( + -- 1) 线上店铺订单:普通商品行 + SELECT oi.sku_id AS skuId, + SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty + FROM rpt.jst_OrderItem oi + JOIN rpt.jst_Orders o ON o.o_id = oi.o_id + WHERE o.wms_co_id = @wmsCoId + AND o.send_date >= @shipFrom AND o.send_date < @shipTo + AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled') + AND ISNULL(oi.sku_type, 'normal') <> 'combine' + GROUP BY oi.sku_id + UNION ALL + -- 2) 线上店铺订单:组合装行 → 展开为子件 + SELECT ci.src_sku_id AS skuId, + SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty + FROM rpt.jst_OrderItem oi + JOIN rpt.jst_Orders o ON o.o_id = oi.o_id + JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id + WHERE o.wms_co_id = @wmsCoId + AND o.send_date >= @shipFrom AND o.send_date < @shipTo + AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled') + AND oi.sku_type = 'combine' + GROUP BY ci.src_sku_id + UNION ALL + -- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除) + SELECT oi.sku_id AS skuId, + SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty + FROM rpt.jst_qmcust_orderlist_item oi + JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id + WHERE o.wms_co_id = @wmsCoId + AND o.send_date >= @shipFrom AND o.send_date < @shipTo + AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question') + AND ISNULL(oi.sku_type, 'normal') <> 'combine' + GROUP BY oi.sku_id + UNION ALL + -- 4) 全民客户订单:组合装行 → 展开为子件 + SELECT ci.src_sku_id AS skuId, + SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty + FROM rpt.jst_qmcust_orderlist_item oi + JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id + JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id + WHERE o.wms_co_id = @wmsCoId + AND o.send_date >= @shipFrom AND o.send_date < @shipTo + AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question') + AND oi.sku_type = 'combine' + GROUP BY ci.src_sku_id + ) + SELECT skuId, SUM(orderQty) AS orderQty + FROM ship + GROUP BY skuId`; + + const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query; + const rows = await runQuery( + sql, + { wmsCoId: warehouse.wmsCoId, shipFrom, shipTo }, { requestTimeout: 300000 } ); const map = new Map(); (rows || []).forEach((row) => { const skuId = toText(row.skuId, 120); - if (skuId) map.set(skuId, toNumber(row.avgSale)); + if (skuId) map.set(skuId, toNumber(row.orderQty)); }); return map; } // ── 组装 ──────────────────────────────────────────────────────────── -function buildSkuRows({ replicaRows, pickBinSet, dailySaleMap, targetDays, useFallback }) { - const skuMap = new Map(); - - (replicaRows || []).forEach((row) => { +function buildSkuRows({ invRows, orderMap, orderDate }) { + const rows = []; + (invRows || []).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); + const pickQty = round2(row.pickQty); + const storageQty = round2(row.storageQty); + const orderQty = round2(toNumber(orderMap.get(skuId), 0)); + const excess = Math.max(0, round2(orderQty - pickQty)); - 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); + let status = STATUS.NO_ORDER; + let suggestQty = 0; + let shortfall = 0; + let pickEmpty = false; + + if (orderQty > 0) { + if (pickQty <= 0) pickEmpty = true; + if (excess > 0) { + const can = Math.min(excess, storageQty); + suggestQty = round2(can); + if (storageQty >= excess) { + status = STATUS.NEED; + } else { + status = STATUS.SHORT; + shortfall = round2(excess - storageQty); + } + } else { + status = STATUS.ENOUGH; + } } - 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, + skuId, + productName: toText(row.productName, 300) || '', + supplier: toText(row.supplier, 120) || '', status, statusLabel: STATUS_LABELS[status], pickQty, storageQty, totalQty: round2(pickQty + storageQty), - targetQty, + orderQty, + excess, suggestQty, - avgSale: round2(avgSale), - pickBinCount: entry.pickBins.length, - pickBins, - storageBins, - hasPickSlot: entry.pickBins.length > 0, + shortfall, + pickEmpty, }); }); - 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) { +function buildSummary(rows, orderSkuCount) { const summary = { skuTotal: rows.length, - pickout: 0, - empty: 0, - low: 0, - nopick: 0, - ok: 0, + need: 0, + short: 0, + enough: 0, + noOrder: 0, + pickEmpty: 0, suggestQtyTotal: 0, - pickoutQtyTotal: 0, - storageQtyTotal: 0, - withPickSlot: 0, - source: meta.source, - pickBinCount: meta.pickBinCount, + orderSkuCount: orderSkuCount || 0, + orderQtyTotal: 0, + exceedTotal: 0, + shortfallTotal: 0, + pickTotal: 0, + storageTotal: 0, }; 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.orderQtyTotal += row.orderQty || 0; + summary.exceedTotal += row.excess || 0; + summary.shortfallTotal += row.shortfall || 0; + summary.pickTotal += row.pickQty || 0; + summary.storageTotal += row.storageQty || 0; + if (row.pickEmpty) summary.pickEmpty += 1; }); summary.suggestQtyTotal = round2(summary.suggestQtyTotal); - summary.pickoutQtyTotal = round2(summary.pickoutQtyTotal); - summary.storageQtyTotal = round2(summary.storageQtyTotal); + summary.orderQtyTotal = round2(summary.orderQtyTotal); + summary.exceedTotal = round2(summary.exceedTotal); + summary.shortfallTotal = round2(summary.shortfallTotal); + summary.pickTotal = round2(summary.pickTotal); + summary.storageTotal = round2(summary.storageTotal); return summary; } async function loadDataset(warehouse, { refresh = false } = {}) { - const cacheKey = warehouse.key; + const orderDate = await loadOrderDate(warehouse); + // 把订单日期纳入缓存键:跨天后自动失效,无需等 TTL + const cacheKey = `${warehouse.key}::${orderDate || 'none'}`; const cached = cacheStore.get(cacheKey); const ttl = resolveCacheTtlMs(); const now = Date.now(); @@ -466,31 +340,21 @@ async function loadDataset(warehouse, { refresh = false } = {}) { } const epoch = cacheEpoch; - const [replicaRows, pickBins, dailySaleMap] = await Promise.all([ - loadReplicaRows(warehouse), - loadPickBinRows(warehouse.key), - loadDailySaleMap(warehouse).catch(() => new Map()), + const [invRows, orderMap] = await Promise.all([ + loadReplicaInventory(warehouse), + loadTodayOrderMap(warehouse, orderDate).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 rows = buildSkuRows({ invRows, orderMap, orderDate }); const dataset = { at: now, rows, warehouse, + orderDate, meta: { - source: useFallback ? 'fallback' : 'import', - pickBinCount: pickBinSet.size, - dailySaleSkus: dailySaleMap.size, rowCount: rows.length, + orderSkuCount: orderMap.size, }, }; // 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据 @@ -507,10 +371,9 @@ function invalidateCache(warehouseKey) { 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 === 'action' || key === 'todo' || key === 'replenish') { + return row.status === STATUS.NEED || row.status === STATUS.SHORT; } - if (key === 'replenish') return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW; return row.status === key; } @@ -518,6 +381,7 @@ 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) + || (b.excess || 0) - (a.excess || 0) || String(a.skuId).localeCompare(String(b.skuId)) )); } @@ -535,18 +399,9 @@ function filterRows(rows, { keyword, status }) { 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 filtered = sortRows(filterRows(dataset.rows, options)); + const summary = buildSummary(dataset.rows, dataset.meta.orderSkuCount); 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; @@ -559,31 +414,25 @@ async function getReplenishList(options = {}) { summary, warehouse, warehouses: Object.values(WAREHOUSES), - targetDays, + orderDate: dataset.orderDate, 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, - }), + orderDate: dataset.orderDate, + rows: sortRows(filterRows(dataset.rows, options)), + summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount), }; } -/** 启动预热:视图查询 35~45s,不能让首个用户请求等 */ +/** 启动预热:库存视图 + 当日订单 union 各自 30s~1min,不能让首个用户请求等 */ async function warmBinReplenishCache() { try { const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY]; @@ -597,16 +446,15 @@ async function warmBinReplenishCache() { module.exports = { DEFAULT_WAREHOUSE_KEY, + PICK_PREFIXES, + STORE_PREFIXES, STATUS, STATUS_LABELS, WAREHOUSES, - clearPickBins, - getPickBinMeta, + clearPickBins: async () => ({ removed: 0 }), getReplenishExportRows, getReplenishList, - importPickBins, loadDataset, - normalizeBin, resolveWarehouse, resolveWarehouseKey, warmBinReplenishCache, diff --git a/frontend/src/api/binReplenish.js b/frontend/src/api/binReplenish.js index 0c2f050..dd33343 100644 --- a/frontend/src/api/binReplenish.js +++ b/frontend/src/api/binReplenish.js @@ -2,8 +2,8 @@ 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 } @@ -18,23 +18,3 @@ export const exportBinReplenish = (params = {}) => request.get( 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/pages/BinReplenishMonitor/index.jsx b/frontend/src/pages/BinReplenishMonitor/index.jsx index 292c3fc..aadd4a8 100644 --- a/frontend/src/pages/BinReplenishMonitor/index.jsx +++ b/frontend/src/pages/BinReplenishMonitor/index.jsx @@ -1,14 +1,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, Button, Empty, Input, InputNumber, Popconfirm, Segmented, Select, Spin, Table, Tag, Tooltip, Upload, + Alert, Button, Empty, Input, Segmented, Select, Spin, Table, Tag, Tooltip, message, } from 'antd'; import { - DeleteOutlined, DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UploadOutlined, + DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, } from '@ant-design/icons'; -import { - clearPickBins, exportBinReplenish, getBinReplenishList, importPickBins, -} from '../../api/binReplenish'; +import { exportBinReplenish, getBinReplenishList } from '../../api/binReplenish'; import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport'; import { SycmPage } from '../../components/Common'; import './style.css'; @@ -18,48 +16,24 @@ 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: '正常' }, + short: { color: 'red', label: '存储不足' }, + need: { color: 'gold', label: '需补货' }, + enough: { color: 'green', label: '拣货位充足' }, + no_order: { color: 'default', label: '今日无单' }, }; const STATUS_OPTIONS = [ { label: '待补货', value: 'replenish' }, - { label: '无货可补', value: 'empty' }, - { label: '未设拣货位', value: 'nopick' }, + { label: '存储不足', value: 'short' }, + { label: '拣货位充足', value: 'enough' }, + { label: '今日无单', value: 'no_order' }, { 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); @@ -67,9 +41,8 @@ const BinReplenishMonitor = () => { 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, + rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderDate: '', updatedAt: '', cacheHit: false, }); - const [importing, setImporting] = useState(false); const keywordTimer = useRef(null); const load = useCallback(async (options = {}) => { @@ -82,7 +55,6 @@ const BinReplenishMonitor = () => { warehouse, status, keyword, - targetDays, page: options.page || page, pageSize: options.pageSize || pageSize, refresh: isRefresh ? 'true' : undefined, @@ -94,7 +66,7 @@ const BinReplenishMonitor = () => { summary: payload.summary || {}, warehouse: payload.warehouse || null, warehouses: payload.warehouses || [], - pickBinMeta: payload.pickBinMeta || null, + orderDate: payload.orderDate || '', updatedAt: payload.updatedAt || '', cacheHit: Boolean(payload.cacheHit), }); @@ -105,12 +77,12 @@ const BinReplenishMonitor = () => { setLoading(false); setRefreshing(false); } - }, [warehouse, status, keyword, targetDays, page, pageSize]); + }, [warehouse, status, keyword, page, pageSize]); useEffect(() => { load(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [warehouse, status, page, pageSize, targetDays]); + }, [warehouse, status, page, pageSize]); useEffect(() => { if (keywordTimer.current) clearTimeout(keywordTimer.current); @@ -123,93 +95,65 @@ const BinReplenishMonitor = () => { }, [keyword]); const summary = data.summary; - const pickBinMeta = data.pickBinMeta || {}; - const usingFallback = pickBinMeta.source !== 'import'; + const needCount = (summary.need || 0) + (summary.short || 0); const metrics = useMemo(() => ([ { - key: 'pickout', + key: 'replenish', tone: 'red', - label: '拣货位缺货', - value: summary.pickout || 0, - unit: '个 SKU', - foot: `可立即从存货位补 ${num(summary.suggestQtyTotal)} 件`, - tip: '拣货位库存为 0、存货位还有货:需要把存货位的库存补到拣货位', + label: '待补货 SKU', + value: needCount, + unit: '个', + foot: `建议补货合计 ${num(summary.suggestQtyTotal)} 件`, + tip: '当日订单量 > 拣货位库存:拣货位不够今天的订单拣,需要从存储位把货补过来', }, { - key: 'empty', + key: 'short', tone: 'grey', - label: '无货可补', - value: summary.empty || 0, - unit: '个 SKU', - foot: '拣货位与存货位都为 0,需采购', - tip: '拣货位库存为 0,且仓库里已经没有该商品的存货,补货要走走采购', + label: '其中存储不足', + value: summary.short || 0, + unit: '个', + foot: `存储缺口 ${num(summary.shortfallTotal)} 件`, + tip: '拣货位缺货,且存储位也不够补(存储缺口 > 0):光靠库内调拨补不齐,需要采购 / 紧急调拨', }, { - key: 'low', + key: 'exceed', tone: 'orange', - label: '拣货位偏低', - value: summary.low || 0, - unit: '个 SKU', - foot: `低于 ${targetDays} 天销量目标`, - tip: `拣货位还有货,但低于目标库存(= 近15天日销 × ${targetDays},最少 ${10} 件)`, + label: '超出订单数合计', + value: summary.exceedTotal || 0, + unit: '件', + foot: '今日拣货位缺口订单量', + tip: '所有待补货 SKU 的「订单量 − 拣货位库存」之和:这是今天需要从存储位搬到拣货位的总量', }, { - key: 'qty', + key: 'order', tone: 'blue', - label: '建议补货合计', - value: summary.suggestQtyTotal || 0, - unit: '件', - foot: `覆盖 ${num(summary.withPickSlot)} 个有拣货位的 SKU`, - tip: '建议补货量 = min(存货位库存, 目标拣货位库存 − 当前拣货位库存)', + label: '当日有单 SKU', + value: summary.orderSkuCount || 0, + unit: '个', + foot: `当日订单量 ${num(summary.orderQtyTotal)} 件`, + tip: '统计日当天下过单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件)', }, - ]), [summary, targetDays]); + ]), [summary, needCount]); const handleExport = async () => { try { - const response = await exportBinReplenish({ warehouse, status, keyword, targetDays }); - downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控.xlsx`); + const response = await exportBinReplenish({ warehouse, status, keyword }); + downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_${data.orderDate || ''}.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, + width: 100, fixed: 'left', render: (value) => { - const tag = STATUS_TAGS[value] || STATUS_TAGS.ok; + const tag = STATUS_TAGS[value] || STATUS_TAGS.no_order; return {tag.label}; }, }, @@ -225,30 +169,42 @@ const BinReplenishMonitor = () => { ), }, { title: '品名', dataIndex: 'productName', width: 260, ellipsis: true }, - { - title: '拣货位(货位号 / 数量)', - dataIndex: 'pickBins', - width: 300, - render: (bins, row) => (row.hasPickSlot ? renderBinTags(bins) : 未维护拣货位), - }, { title: '拣货位库存', dataIndex: 'pickQty', - width: 108, + width: 100, align: 'right', - render: (value) => ( - {num(value)} + render: (value, row) => ( + + {num(value)}{row.pickEmpty ? ' ⚠' : ''} + ), }, { - title: '存货位库存', + title: '存储位库存', dataIndex: 'storageQty', - width: 108, + width: 100, align: 'right', render: (value) => {num(value)}, }, { - title: '建议补货', + title: '当日订单', + dataIndex: 'orderQty', + width: 100, + align: 'right', + render: (value) => {num(value)}, + }, + { + title: '超出订单数', + dataIndex: 'excess', + width: 100, + align: 'right', + render: (value) => ( + 0 ? 'brp-qty brp-qty--warn' : 'brp-qty brp-qty--zero'}>{num(value)} + ), + }, + { + title: '建议补货量', dataIndex: 'suggestQty', width: 110, align: 'right', @@ -259,24 +215,13 @@ const BinReplenishMonitor = () => { ), }, { - title: '目标拣货位', - dataIndex: 'targetQty', - width: 108, + title: '存储缺口', + dataIndex: 'shortfall', + width: 100, 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)}, + render: (value) => ( + 0 ? 'brp-qty brp-qty--danger' : 'brp-qty brp-qty--zero'}>{num(value)} + ), }, { title: '现有库存', @@ -293,8 +238,8 @@ const BinReplenishMonitor = () => {

缺货补货监控

- 盯的是拣货位:拣货位没货就拣不出来,需要从存货位把货补过去。 - 状态、建议补货量都按当前库存实时算,数据来自聚水潭「箱及仓位库存」。 + 盯的是拣货位:今天下的订单,如果拣货位库存不够拣,超出的那部分就要从存储位补到拣货位。 + 库存取自聚水潭「箱及仓位库存」,订单取自崔桥工厂仓当日订单。

@@ -302,18 +247,15 @@ const BinReplenishMonitor = () => { 仓库:{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}
- 拣货位口径: - - {usingFallback ? '默认拣选位(Bin 且非存货位)' : `导入清单 ${num(pickBinMeta.count)} 个`} - + 订单日期:{data.orderDate || '--'} + (取该仓最近一个有订单的营业日)
取数时间:{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'} {data.cacheHit ? (缓存) : null}
- 共 {num(summary.skuTotal)} 个 SKU,其中未设拣货位{' '} - {num(summary.nopick)} 个(不参与待补货) + 共 {num(summary.skuTotal)} 个 SKU,其中今日无单 {num(summary.noOrder)} 个
@@ -357,23 +299,15 @@ const BinReplenishMonitor = () => { options={STATUS_OPTIONS.map((item) => ({ ...item, label: item.value === 'replenish' - ? `${item.label} ${num((summary.pickout || 0) + (summary.low || 0))}` - : item.value === 'empty' - ? `${item.label} ${num(summary.empty || 0)}` - : item.label, + ? `${item.label} ${num(needCount)}` + : item.value === 'short' + ? `${item.label} ${num(summary.short || 0)}` + : item.value === 'no_order' + ? `${item.label} ${num(summary.noOrder || 0)}` + : item.label, }))} /> - - 目标天数(天) - { setTargetDays(Number(value) || DEFAULT_TARGET_DAYS); setPage(1); }} - style={{ width: 90 }} - /> - { style={{ width: 260 }} /> - - - - {usingFallback ? ( - - - - )} - /> - ) : ( - - - - )} - /> - )} + + 拣货库位以 XA / XD / XE 开头,存储库位以 XX / X9 / X5 / X4 开头;其余货位(DB / Y* / Z* 等,占比极小)不计入。 + 判断规则:当日订单量 > 拣货位库存 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。 + + )} + /> {error ? ( { { className="brp-table" columns={columns} dataSource={data.rows} - scroll={{ x: 1900 }} + scroll={{ x: 1500 }} locale={{ emptyText: ( {
数据来源与口径说明
  • - 库存:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码 + 货位汇总。 - 「拣货位库存」= 拣货位上的数量合计;「存货位库存」= 其余货位(含整箱、未分箱)的数量合计。 + 库存:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码(sku_id)聚合。 + 「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
  • - 拣货位:副本没有货位类型字段,按导入的货位清单判定;未导入时回退为 - pack_type = Bin 的记录(页面顶部会标出当前口径)。 + 订单:崔桥工厂仓当日订单 = rpt.jst_Orders(线上店铺)+ rpt.jst_qmcust_orderlist(全民客户 B2B), + 均按 send_date 落在「订单日期」当天;组合装(sku_type=combine)已按 jst_comboProductItem 展开为子件。
  • - 目标库存:max(10, 近15天日销 × {targetDays});日销取 rpt.pm_stockMonitor 同仓最新快照的 - fifteenAvgSale,取不到的按 10 件兜底。 + 判断规则:对每个 SKU,若 当日订单量 > 拣货位库存,超出的订单数即「需要从存储位补到拣货位」的量; + 「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。
  • - 建议补货量:min(存货位库存, 目标拣货位库存 − 当前拣货位库存),只在「拣货位缺货 / 偏低」时给数字; - 「无货可补」表示拣货位和存货位都没货,要走走采购,不是仓库内部调拨。 + 订单日期:取该仓订单表 send_date 的最大日期(即最近一个有订单的营业日),副本 ETL 有延迟时不会为空; + 页面顶部明确标注统计的是哪一天。
  • - 未设拣货位:该 SKU 在拣货位清单里一个货位都没有(多为整箱直发、或还没维护拣货位)。 - 这类不算「拣货位缺货」,不进待补货清单,可在「未设拣货位」页签单独核对。 -
  • -
  • - 时效:仓位库存视图单次聚合约 35~45 秒,服务端缓存 10 分钟(启动时已预热); - 点「刷新数据」会强制回源,需要等半分钟左右。 + 时效:仓位库存视图聚合 + 当日订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热); + 点「刷新数据」会强制回源,需要等 1 分钟左右。
diff --git a/frontend/src/pages/BinReplenishMonitor/style.css b/frontend/src/pages/BinReplenishMonitor/style.css index 1ef578f..6013667 100644 --- a/frontend/src/pages/BinReplenishMonitor/style.css +++ b/frontend/src/pages/BinReplenishMonitor/style.css @@ -250,6 +250,16 @@ font-weight: 600; } +.brp-qty--warn { + color: var(--brp-orange); + font-weight: 600; +} + +.brp-qty--danger { + color: var(--brp-red); + font-weight: 700; +} + .brp-qty--muted { color: var(--sycm-text-tertiary, #8c8c8c); font-weight: 400;