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