feat: 新增「缺货补货监控」看板(拣货位缺货 → 从存货位补货) #74
134
backend/src/api/binReplenish.js
Normal file
134
backend/src/api/binReplenish.js
Normal file
@ -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;
|
||||
@ -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(公开访问,可根据需要添加认证)
|
||||
|
||||
53
backend/src/services/binReplenishSchema.js
Normal file
53
backend/src/services/binReplenishSchema.js
Normal file
@ -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 };
|
||||
613
backend/src/services/binReplenishService.js
Normal file
613
backend/src/services/binReplenishService.js
Normal file
@ -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,
|
||||
};
|
||||
@ -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)后台预热,不阻塞启动
|
||||
|
||||
40
frontend/src/api/binReplenish.js
Normal file
40
frontend/src/api/binReplenish.js
Normal file
@ -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 } : {} }
|
||||
);
|
||||
@ -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: [
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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' },
|
||||
|
||||
517
frontend/src/pages/BinReplenishMonitor/index.jsx
Normal file
517
frontend/src/pages/BinReplenishMonitor/index.jsx
Normal file
@ -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 <span className="brp-muted">{emptyText}</span>;
|
||||
const visible = bins.slice(0, limit);
|
||||
const rest = bins.length - visible.length;
|
||||
return (
|
||||
<span className="brp-bins">
|
||||
{visible.map((item) => (
|
||||
<Tag key={`${item.bin}-${item.qty}`} className={`brp-bin${item.qty > 0 ? '' : ' is-zero'}`}>
|
||||
{item.bin}
|
||||
<b>{num(item.qty)}</b>
|
||||
</Tag>
|
||||
))}
|
||||
{rest > 0 ? (
|
||||
<Tooltip title={bins.slice(limit).map((item) => `${item.bin}(${num(item.qty)})`).join('、')}>
|
||||
<Tag className="brp-bin brp-bin--more">+{rest}</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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 color={tag.color} className="brp-status-tag">{tag.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '商品编码',
|
||||
dataIndex: 'skuId',
|
||||
width: 175,
|
||||
render: (value, row) => (
|
||||
<div className="brp-sku">
|
||||
<span className="brp-sku__code">{value}</span>
|
||||
{row.supplier ? <span className="brp-sku__supplier">{row.supplier}</span> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '品名', dataIndex: 'productName', width: 260, ellipsis: true },
|
||||
{
|
||||
title: '拣货位(货位号 / 数量)',
|
||||
dataIndex: 'pickBins',
|
||||
width: 300,
|
||||
render: (bins, row) => (row.hasPickSlot ? renderBinTags(bins) : <span className="brp-muted">未维护拣货位</span>),
|
||||
},
|
||||
{
|
||||
title: '拣货位库存',
|
||||
dataIndex: 'pickQty',
|
||||
width: 108,
|
||||
align: 'right',
|
||||
render: (value) => (
|
||||
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>{num(value)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '存货位库存',
|
||||
dataIndex: 'storageQty',
|
||||
width: 108,
|
||||
align: 'right',
|
||||
render: (value) => <span className="brp-qty">{num(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '建议补货',
|
||||
dataIndex: 'suggestQty',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (value) => (
|
||||
<span className={Number(value) > 0 ? 'brp-qty brp-qty--suggest' : 'brp-qty brp-qty--zero'}>
|
||||
{num(value)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '目标拣货位',
|
||||
dataIndex: 'targetQty',
|
||||
width: 108,
|
||||
align: 'right',
|
||||
render: (value) => <span className="brp-qty brp-qty--muted">{num(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '存货位(前 5 个 / 数量)',
|
||||
dataIndex: 'storageBins',
|
||||
width: 300,
|
||||
render: (bins) => renderBinTags(bins, '无存货', 3),
|
||||
},
|
||||
{
|
||||
title: '近15天日销',
|
||||
dataIndex: 'avgSale',
|
||||
width: 108,
|
||||
align: 'right',
|
||||
render: (value) => <span className="brp-qty brp-qty--muted">{num(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '现有库存',
|
||||
dataIndex: 'totalQty',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => <span className="brp-qty">{num(value)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SycmPage className="brp-page">
|
||||
<div className="brp-hero">
|
||||
<div className="brp-hero__main">
|
||||
<h1 className="brp-hero__title">缺货补货监控</h1>
|
||||
<p className="brp-hero__desc">
|
||||
盯的是<b>拣货位</b>:拣货位没货就拣不出来,需要从<b>存货位</b>把货补过去。
|
||||
状态、建议补货量都按当前库存实时算,数据来自聚水潭「箱及仓位库存」。
|
||||
</p>
|
||||
</div>
|
||||
<div className="brp-hero__meta">
|
||||
<div>
|
||||
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
|
||||
</div>
|
||||
<div>
|
||||
拣货位口径:
|
||||
<b className={usingFallback ? '' : 'brp-ok'}>
|
||||
{usingFallback ? '默认拣选位(Bin 且非存货位)' : `导入清单 ${num(pickBinMeta.count)} 个`}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b>
|
||||
{data.cacheHit ? <span className="brp-muted">(缓存)</span> : null}
|
||||
</div>
|
||||
<div>
|
||||
共 <b>{num(summary.skuTotal)}</b> 个 SKU,其中未设拣货位{' '}
|
||||
<b>{num(summary.nopick)}</b> 个(不参与待补货)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="brp-metrics">
|
||||
{metrics.map((item) => (
|
||||
<div key={item.key} className={`brp-metric brp-metric--${item.tone}`}>
|
||||
<Tooltip title={item.tip}>
|
||||
<div className="brp-metric__label">
|
||||
{item.label}
|
||||
<InfoCircleOutlined className="brp-metric__info" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="brp-metric__value">
|
||||
{num(item.value)}
|
||||
<span className="brp-metric__unit">{item.unit}</span>
|
||||
</div>
|
||||
<div className="brp-metric__foot">{item.foot}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="brp-toolbar">
|
||||
<span className="brp-toolbar__field">
|
||||
<span className="brp-toolbar__label">仓库</span>
|
||||
<Select
|
||||
value={warehouse}
|
||||
onChange={(value) => { setWarehouse(value); setPage(1); }}
|
||||
style={{ width: 210 }}
|
||||
options={(data.warehouses || []).map((item) => ({
|
||||
value: item.key,
|
||||
label: item.name,
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
<span className="brp-toolbar__field">
|
||||
<span className="brp-toolbar__label">范围</span>
|
||||
<Segmented
|
||||
value={status}
|
||||
onChange={(value) => { setStatus(value); setPage(1); }}
|
||||
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,
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
<span className="brp-toolbar__field">
|
||||
<span className="brp-toolbar__label">目标天数(天)</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={90}
|
||||
value={targetDays}
|
||||
onChange={(value) => { setTargetDays(Number(value) || DEFAULT_TARGET_DAYS); setPage(1); }}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</span>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜商品编码 / 品名 / 供应商"
|
||||
prefix={<SearchOutlined />}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<span className="brp-toolbar__spacer" />
|
||||
<Upload
|
||||
accept=".xlsx,.xls,.csv,.txt"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleImport}
|
||||
disabled={importing}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={importing}>导入货位清单</Button>
|
||||
</Upload>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={refreshing}
|
||||
onClick={() => load({ refresh: true })}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{usingFallback ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="brp-alert"
|
||||
message="拣货位口径:默认拣选位(pack_type = Bin 且非存货位),与「拣选库位动销分析」一致"
|
||||
description="这是系统当前能确定的最准口径。如果业务另有一份拣货位清单,可以上传覆盖(只放货位号,如 XA01-01-11),上传后按清单判定,其余货位都算存货位。"
|
||||
action={(
|
||||
<Upload accept=".xlsx,.xls,.csv,.txt" showUploadList={false} beforeUpload={handleImport}>
|
||||
<Button size="small" type="primary">上传清单</Button>
|
||||
</Upload>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
className="brp-alert"
|
||||
message={`拣货位口径:导入清单(${num(pickBinMeta.count)} 个货位)`}
|
||||
description={`最近导入:${pickBinMeta.lastImport?.fileName || '—'} · ${pickBinMeta.lastImport?.importedRows || 0} 个货位 · ${pickBinMeta.lastImport?.importedAt ? new Date(pickBinMeta.lastImport.importedAt).toLocaleString('zh-CN') : '—'}。清单变更后重新上传即可覆盖。`}
|
||||
action={(
|
||||
<Popconfirm
|
||||
title="清除后回到「货位散件记录」回退口径,确定吗?"
|
||||
onConfirm={handleClear}
|
||||
okText="清除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>清除清单</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="brp-alert"
|
||||
message={error}
|
||||
action={<Button size="small" onClick={() => load()}>重试</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Spin
|
||||
spinning={loading}
|
||||
tip="正在从仓位库存视图聚合,首次约 30~60 秒"
|
||||
wrapperClassName="brp-table-spin"
|
||||
>
|
||||
<Table
|
||||
rowKey="skuId"
|
||||
size="small"
|
||||
className="brp-table"
|
||||
columns={columns}
|
||||
dataSource={data.rows}
|
||||
scroll={{ x: 1900 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description={status === 'replenish'
|
||||
? '当前没有需要补货的拣货位'
|
||||
: '没有符合条件的商品'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
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);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
|
||||
<div className="brp-notes">
|
||||
<div className="brp-notes__title">数据来源与口径说明</div>
|
||||
<ul>
|
||||
<li>
|
||||
<b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码 + 货位汇总。
|
||||
「拣货位库存」= 拣货位上的数量合计;「存货位库存」= 其余货位(含整箱、未分箱)的数量合计。
|
||||
</li>
|
||||
<li>
|
||||
<b>拣货位</b>:副本没有货位类型字段,按导入的货位清单判定;未导入时回退为
|
||||
pack_type = Bin 的记录(页面顶部会标出当前口径)。
|
||||
</li>
|
||||
<li>
|
||||
<b>目标库存</b>:max(10, 近15天日销 × {targetDays});日销取 rpt.pm_stockMonitor 同仓最新快照的
|
||||
fifteenAvgSale,取不到的按 10 件兜底。
|
||||
</li>
|
||||
<li>
|
||||
<b>建议补货量</b>:min(存货位库存, 目标拣货位库存 − 当前拣货位库存),只在「拣货位缺货 / 偏低」时给数字;
|
||||
「无货可补」表示拣货位和存货位都没货,要走走采购,不是仓库内部调拨。
|
||||
</li>
|
||||
<li>
|
||||
<b>未设拣货位</b>:该 SKU 在拣货位清单里一个货位都没有(多为整箱直发、或还没维护拣货位)。
|
||||
这类不算「拣货位缺货」,不进待补货清单,可在「未设拣货位」页签单独核对。
|
||||
</li>
|
||||
<li>
|
||||
<b>时效</b>:仓位库存视图单次聚合约 35~45 秒,服务端缓存 10 分钟(启动时已预热);
|
||||
点「刷新数据」会强制回源,需要等半分钟左右。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default BinReplenishMonitor;
|
||||
303
frontend/src/pages/BinReplenishMonitor/style.css
Normal file
303
frontend/src/pages/BinReplenishMonitor/style.css
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 区分取哪个仓;
|
||||
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user