Merge pull request 'feat: 缺货补货监控改为订单驱动(崔桥当日订单>拣货位库存即需从存储位补货)' (#78) from codex/lunanting-bin-replenish-order-002 into main

This commit is contained in:
Xkh5201314 2026-10-22 14:02:19 +08:00
commit 1957330ebd
5 changed files with 353 additions and 674 deletions

View File

@ -1,22 +1,13 @@
'use strict'; 'use strict';
const express = require('express'); const express = require('express');
const multer = require('multer');
const { const {
clearPickBins,
getPickBinMeta,
getReplenishExportRows, getReplenishExportRows,
getReplenishList, getReplenishList,
importPickBins,
resolveWarehouseKey,
} = require('../services/binReplenishService'); } = require('../services/binReplenishService');
const { sendWorkbookDownload } = require('../services/exportWorkbook'); const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router(); const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024 },
});
const EXPORT_COLUMNS = [ const EXPORT_COLUMNS = [
{ key: 'skuId', title: '商品编码' }, { key: 'skuId', title: '商品编码' },
@ -24,16 +15,15 @@ const EXPORT_COLUMNS = [
{ key: 'supplier', title: '供应商' }, { key: 'supplier', title: '供应商' },
{ key: 'statusLabel', title: '状态' }, { key: 'statusLabel', title: '状态' },
{ key: 'pickQty', title: '拣货位库存' }, { key: 'pickQty', title: '拣货位库存' },
{ key: 'storageQty', title: '存货位库存' }, { key: 'storageQty', title: '存储位库存' },
{ key: 'totalQty', title: '现有库存合计' }, { key: 'totalQty', title: '现有库存合计' },
{ key: 'targetQty', title: '目标拣货位库存' }, { key: 'orderQty', title: '当日订单' },
{ key: 'excess', title: '超出订单数' },
{ key: 'suggestQty', title: '建议补货量' }, { key: 'suggestQty', title: '建议补货量' },
{ key: 'avgSale', title: '近15天日销' }, { key: 'shortfall', title: '存储缺口' },
{ key: 'pickBinText', title: '拣货位(货位号/数量)' },
{ key: 'storageBinText', title: '存货位(前5个/数量)' },
]; ];
/** GET /api/bin-replenish/list —— 缺货补货监控列表 */ /** GET /api/bin-replenish/list —— 缺货补货监控列表(订单驱动) */
router.get('/list', async (req, res, next) => { router.get('/list', async (req, res, next) => {
try { try {
const payload = await getReplenishList({ const payload = await getReplenishList({
@ -42,7 +32,6 @@ router.get('/list', async (req, res, next) => {
status: req.query.status, status: req.query.status,
page: req.query.page, page: req.query.page,
pageSize: req.query.pageSize, pageSize: req.query.pageSize,
targetDays: req.query.targetDays,
refresh: req.query.refresh, refresh: req.query.refresh,
}); });
res.json({ success: true, data: payload }); res.json({ success: true, data: payload });
@ -59,21 +48,16 @@ router.get('/export', async (req, res, next) => {
warehouse: req.query.warehouse, warehouse: req.query.warehouse,
keyword: req.query.keyword, keyword: req.query.keyword,
status: req.query.status, status: req.query.status,
targetDays: req.query.targetDays,
}); });
const rows = payload.rows.map((row) => ({ const fileName = `${payload.warehouse.shortName}缺货补货监控_${payload.orderDate || ''}`;
...row,
pickBinText: (row.pickBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'),
storageBinText: (row.storageBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'),
}));
sendWorkbookDownload(res, { sendWorkbookDownload(res, {
fileName: `${payload.warehouse.shortName}缺货补货监控_${new Date().toISOString().slice(0, 10)}`, fileName,
format, format,
sheets: [{ sheets: [{
name: '缺货补货监控', name: '缺货补货监控',
header: EXPORT_COLUMNS.map((column) => column.title), header: EXPORT_COLUMNS.map((column) => column.title),
rows: rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])), rows: payload.rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
}], }],
}); });
} catch (error) { } catch (error) {
@ -81,51 +65,6 @@ router.get('/export', async (req, res, next) => {
} }
}); });
/** GET /api/bin-replenish/pick-bins —— 当前仓库的拣货位清单概况 */
router.get('/pick-bins', async (req, res, next) => {
try {
const meta = await getPickBinMeta(resolveWarehouseKey(req.query.warehouse));
res.json({ success: true, data: meta });
} catch (error) {
next(error);
}
});
/** POST /api/bin-replenish/pick-bins —— 导入拣货位清单(xlsx / xls / csv / txt) */
router.post('/pick-bins', upload.single('file'), async (req, res, next) => {
try {
if (!req.file || !req.file.buffer) {
res.status(400).json({ success: false, message: '请选择要导入的货位清单文件(xlsx / xls / csv)' });
return;
}
// multer 按 latin1 解析 multipart 文件名,中文会乱码,这里转回 UTF-8
const rawName = req.file.originalname || '';
const fileName = /[\u00c0-\u00ff]/.test(rawName)
? Buffer.from(rawName, 'latin1').toString('utf8')
: rawName;
const result = await importPickBins({
buffer: req.file.buffer,
fileName,
warehouseKey: req.body?.warehouse || req.query.warehouse,
importedBy: req.user?.username || req.user?.name || null,
});
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});
/** DELETE /api/bin-replenish/pick-bins —— 清除货位清单,回到回退口径 */
router.delete('/pick-bins', async (req, res, next) => {
try {
const result = await clearPickBins(req.query.warehouse);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});
/** GET /api/bin-replenish/ping —— 路由存活探针(401 也代表已注册) */ /** GET /api/bin-replenish/ping —— 路由存活探针(401 也代表已注册) */
router.get('/ping', (req, res) => { router.get('/ping', (req, res) => {
res.json({ success: true, data: { ok: true } }); res.json({ success: true, data: { ok: true } });

View File

@ -1,60 +1,54 @@
'use strict'; 'use strict';
/** /**
* 缺货补货监控 —— 拣货位缺货 / 从存货位补货 * 缺货补货监控(订单驱动版,2026-10-22 重写)
* ========================================================================== * ==========================================================================
* 业务需求(2026-10-22 用户确认): * 业务需求(用户确认):
* 监控现有库存 → 看哪些「拣货库位」缺货 → 需要从「存货仓位」补货过去。 * 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够今天的订单拣,
* 需要从「存储库位」把货补过去。
*
* ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
* 拣货库位(拣货仓位):以 XA / XD / XE 开头
* 存储库位(存储仓位):以 XX / X9 / X5 / X4 开头
* 其余前缀(DB / Y* / Z* / YC 等,崔桥占比极小)归为「未分类」,不计入拣货/存储。
* *
* ── 数据源 ─────────────────────────────────────────────────────────── * ── 数据源 ───────────────────────────────────────────────────────────
* rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) * 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储)
* · 列:sku_id / bin(货位号)/ pack_type / bin_group / qty / wms_co_id * 订单:rpt.jst_Orders × jst_OrderItem(线上店铺)
* · 一行 = 某货位上某商品的库存;同一货位可能多行(不同箱) * + rpt.jst_qmcust_orderlist × jst_qmcust_orderlist_item(全民客户 B2B)
* · pack_type:Bin = 货位散件库存;Pack = 整箱;DefaultPack = 未分箱(仓库级) * + 组合装(sku_type='combine')→ jst_comboProductItem 展开为子件
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1), * ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
* 崔桥/山东/横林二/苏北/河南全部挂在 wh_id=1 下,按 wh_id 会混成一个桶。 * 按 wh_id 会把多仓混成一个桶。
* ⚠️ 该对象在副本里是**视图**(sys.objects.type_desc = VIEW), * 订单日期:取该仓订单表 send_date 的最大日期(即「最近一个有订单的营业日」,
* 没有暴露底层表;单次按 SKU+货位聚合实测 **10~45 秒**(随缓存/负载波动), * 副本 ETL 有延迟时不会为空,页面会明确标注统计的是哪一天)。
* 直接取原始行更慢(25,488 行约 80 秒)。因此必须缓存 + 启动预热,
* 不要把它改回「每次请求都回源」。
* *
* ── 拣货位怎么认定(关键)───────────────────────────────────────────── * ── 判断逻辑(核心)──────────────────────────────────────────────────
* 副本**没有独立的货位类型主数据**,但「拣选库位动销分析」已确认口径: * 对每一个 SKU(在拣货/存储位上有库存):
* 真实拣选位 = `pack_type = 'Bin'` 且 `bin_group` 为空 * 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计
* (`bin_group='存货'`、`pack_type='Pack'/'DefaultPack'` 都算存货位) * 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计
* 本页默认就用这个口径;如果业务另有一份拣货位清单, * 当日订单 orderQty = 该 SKU 在订单日期当天的订单量(含组合装展开)
* 页面导入后以清单为准(`bin ∈ 清单` → 拣货位),其余一律算存货位。 * 超出订单数 excess = max(0, orderQty − pickQty)
* * —— 拣货位不够今天的订单拣,超出的这部分要从存储位补到拣货位
* ── 指标口径 ───────────────────────────────────────────────────────── * 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
* 拣货位库存 pickQty = 该 SKU 在所有拣货位上的 qty 合计 * 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
* 存货位库存 storageQty = 该 SKU 在其余货位(含整箱/未分箱)上的 qty 合计
* 现有库存 totalQty = pickQty + storageQty
* 目标拣货位库存 target = max(10, ceil(近15天日销 × targetDays)),targetDays 默认 7
* 建议补货量 = min(storageQty, max(0, target − pickQty)),向上取整
* *
* 状态: * 状态:
* pickout 拣货位缺货(pickQty ≤ 0,且存货位有货 → 可以补) * short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
* empty 无货可补(pickQty ≤ 0,存货位也没货 → 需要采购) * need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
* low 拣货位偏低(0 < pickQty < target,存货位有货) * enough 拣货位充足(0 < orderQty ≤ pickQty)
* ok 正常 * no_order 今日无单(orderQty = 0)
* (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
* *
* 近15天日销取自 rpt.pm_stockMonitor(同仓最新快照的 fifteenAvgSale), * ⚠️ 性能:库存视图聚合 + 当日订单 4 分支 union 各自都要 30s~1min,
* 只用于算目标和排序;取不到时目标按 10 件兜底。 * 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
*/ */
const crypto = require('crypto');
const XLSX = require('xlsx');
const cloudDb = require('../config/cloudDatabase'); const cloudDb = require('../config/cloudDatabase');
const database = require('../config/database');
/** /**
* 仓库清单:**按 wms_co_id 取数,不能用 wh_id** * 仓库清单:**按 wms_co_id 取数**。
* --------------------------------------------------------------------------- * 前缀货位口径(XA/XD/XE 拣货、XX/X9/X5/X4 存储)是崔桥工厂仓的约定;
* 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({ const WAREHOUSES = Object.freeze({
cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 }, cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 },
@ -72,28 +66,31 @@ const WAREHOUSES = Object.freeze({
const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order); const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order);
const DEFAULT_WAREHOUSE_KEY = 'cuiqiao'; 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 DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐
const MAX_PAGE_SIZE = 5000; const MAX_PAGE_SIZE = 5000;
// ── 货位前缀规则(用户指定)────────────────────────────────────────
const PICK_PREFIXES = Object.freeze(['XA', 'XD', 'XE']);
const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']);
const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(',');
const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(',');
const STATUS = Object.freeze({ const STATUS = Object.freeze({
PICK_OUT: 'pickout', SHORT: 'short',
EMPTY: 'empty', NEED: 'need',
LOW: 'low', ENOUGH: 'enough',
NO_PICK: 'nopick', NO_ORDER: 'no_order',
OK: 'ok',
}); });
const STATUS_LABELS = Object.freeze({ const STATUS_LABELS = Object.freeze({
pickout: '拣货位缺货', short: '存储不足',
empty: '无货可补', need: '需补货',
low: '拣货位偏低', enough: '拣货位充足',
nopick: '未设拣货位', no_order: '今日无单',
ok: '正常',
}); });
const STATUS_RANK = Object.freeze({ pickout: 4, empty: 3, low: 2, nopick: 1, ok: 0 }); // 排序权重:最紧急的排最前
const STATUS_RANK = Object.freeze({ short: 4, need: 3, no_order: 2, enough: 1 });
const cacheStore = new Map(); const cacheStore = new Map();
let cacheEpoch = 0; let cacheEpoch = 0;
@ -111,10 +108,6 @@ function round2(value) {
return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100; return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100;
} }
function normalizeBin(value) {
return toText(value, 64).toUpperCase().replace(/\s+/g, '');
}
function resolveWarehouseKey(value) { function resolveWarehouseKey(value) {
const key = toText(value, 32).toLowerCase(); const key = toText(value, 32).toLowerCase();
return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY; return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY;
@ -132,332 +125,213 @@ function resolveCacheTtlMs() {
return num; return num;
} }
function resolveTargetDays(value) { /** 订单日期的窗口边界(仅用日期字符串,避免时区问题;副本 send_date 按中国营业日存储) */
const num = Number(value); function orderWindow(orderDate) {
if (!Number.isFinite(num) || num <= 0) return DEFAULT_TARGET_DAYS; const d = new Date(`${orderDate}T00:00:00Z`);
return Math.min(Math.round(num), 90); d.setUTCDate(d.getUTCDate() + 1);
} const next = d.toISOString().slice(0, 10);
return { shipFrom: `${orderDate} 00:00:00`, shipTo: `${next} 00:00:00` };
// ── 拣货位清单(本地库)────────────────────────────────────────────
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 + 货位 + 记录类型聚合。 * 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
* 必须用 GROUP BY(不要把原始行拉回进程):视图原始行 25,488 行约 80s, * 只统计用户指定的前缀宇宙(XA/XD/XE/XX/X9/X5/X4),其余货位不计入。
* 聚合后 23,751 行约 43s,是当前可用的最快形态。
*/ */
async function loadReplicaRows(warehouse) { async function loadReplicaInventory(warehouse) {
const sql = ` const sql = `
SELECT p.sku_id AS skuId, SELECT p.sku_id AS skuId,
p.bin AS bin, SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN})
p.pack_type AS packType, THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS pickQty,
p.bin_group AS binGroup, SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${STORE_PREFIX_IN})
SUM(p.qty) AS qty, THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS storageQty,
MAX(s.name) AS productName, MAX(s.name) AS productName,
MAX(s.supplier_name) AS supplier MAX(s.supplier_name) AS supplier
FROM rpt.jst_packInventoryInfo p FROM rpt.jst_packInventoryInfo p
LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id
WHERE p.wms_co_id = @wmsCoId WHERE p.wms_co_id = @wmsCoId
GROUP BY p.sku_id, p.bin, p.pack_type, p.bin_group`; AND ISNULL(p.bin, '') <> ''
AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
GROUP BY p.sku_id`;
return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 }); return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 });
} }
/** 近15天日销(同仓最新快照),只用于算目标库存与排序 */ /** 该仓最近的订单日期(线上 + 全民客户 取最大 send_date 日期) */
async function loadDailySaleMap(warehouse) { async function loadOrderDate(warehouse) {
const rows = await cloudDb.query( const rows = await cloudDb.query(
`SELECT sku_id AS skuId, fifteenAvgSale AS avgSale `SELECT MAX(d) AS d FROM (
FROM rpt.pm_stockMonitor SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d
WHERE warehouse = @warehouseName FROM rpt.jst_Orders WHERE wms_co_id = @wmsCoId
AND CONVERT(varchar(10), date, 23) = ( UNION ALL
SELECT CONVERT(varchar(10), MAX(date), 23) SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d
FROM rpt.pm_stockMonitor WHERE warehouse = @warehouseName FROM rpt.jst_qmcust_orderlist WHERE wms_co_id = @wmsCoId
)`, ) t`,
{ warehouseName: warehouse.name }, { wmsCoId: warehouse.wmsCoId },
{ requestTimeout: 120000 }
);
return (rows && rows[0] && rows[0].d) || null;
}
/** 当日(订单日期)各 SKU 的订单量:4 个来源分支,组合装展开为子件。 */
async function loadTodayOrderMap(warehouse, orderDate) {
if (!orderDate) return new Map();
const { shipFrom, shipTo } = orderWindow(orderDate);
const sql = `
WITH ship AS (
-- 1) 线上店铺订单:普通商品行
SELECT oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
WHERE o.wms_co_id = @wmsCoId
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
GROUP BY oi.sku_id
UNION ALL
-- 2) 线上店铺订单:组合装行 → 展开为子件
SELECT ci.src_sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.jst_OrderItem oi
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
WHERE o.wms_co_id = @wmsCoId
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
AND oi.sku_type = 'combine'
GROUP BY ci.src_sku_id
UNION ALL
-- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
SELECT oi.sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.jst_qmcust_orderlist_item oi
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
WHERE o.wms_co_id = @wmsCoId
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
GROUP BY oi.sku_id
UNION ALL
-- 4) 全民客户订单:组合装行 → 展开为子件
SELECT ci.src_sku_id AS skuId,
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
FROM rpt.jst_qmcust_orderlist_item oi
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
WHERE o.wms_co_id = @wmsCoId
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
AND oi.sku_type = 'combine'
GROUP BY ci.src_sku_id
)
SELECT skuId, SUM(orderQty) AS orderQty
FROM ship
GROUP BY skuId`;
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
const rows = await runQuery(
sql,
{ wmsCoId: warehouse.wmsCoId, shipFrom, shipTo },
{ requestTimeout: 300000 } { requestTimeout: 300000 }
); );
const map = new Map(); const map = new Map();
(rows || []).forEach((row) => { (rows || []).forEach((row) => {
const skuId = toText(row.skuId, 120); const skuId = toText(row.skuId, 120);
if (skuId) map.set(skuId, toNumber(row.avgSale)); if (skuId) map.set(skuId, toNumber(row.orderQty));
}); });
return map; return map;
} }
// ── 组装 ──────────────────────────────────────────────────────────── // ── 组装 ────────────────────────────────────────────────────────────
function buildSkuRows({ replicaRows, pickBinSet, dailySaleMap, targetDays, useFallback }) { function buildSkuRows({ invRows, orderMap, orderDate }) {
const skuMap = new Map(); const rows = [];
(invRows || []).forEach((row) => {
(replicaRows || []).forEach((row) => {
const skuId = toText(row.skuId, 120); const skuId = toText(row.skuId, 120);
if (!skuId) return; if (!skuId) return;
const bin = normalizeBin(row.bin); const pickQty = round2(row.pickQty);
const qty = toNumber(row.qty); const storageQty = round2(row.storageQty);
const isPick = useFallback const orderQty = round2(toNumber(orderMap.get(skuId), 0));
// 默认口径与「拣选库位动销分析」一致:真实拣选位 = pack_type='Bin' 且 bin_group 为空 const excess = Math.max(0, round2(orderQty - pickQty));
// (bin_group='存货' 与 pack_type='Pack' 都是存货,不算拣选位)
? (toText(row.packType) === 'Bin' && !toText(row.binGroup))
: (bin ? pickBinSet.has(bin) : false);
let entry = skuMap.get(skuId); let status = STATUS.NO_ORDER;
if (!entry) { let suggestQty = 0;
entry = { let shortfall = 0;
skuId, let pickEmpty = false;
productName: toText(row.productName, 300) || '',
supplier: toText(row.supplier, 120) || '', if (orderQty > 0) {
pickQty: 0, if (pickQty <= 0) pickEmpty = true;
storageQty: 0, if (excess > 0) {
pickBins: [], const can = Math.min(excess, storageQty);
storageBins: [], suggestQty = round2(can);
}; if (storageQty >= excess) {
skuMap.set(skuId, entry); status = STATUS.NEED;
} else {
status = STATUS.SHORT;
shortfall = round2(excess - storageQty);
}
} else {
status = STATUS.ENOUGH;
}
} }
if (!entry.productName) entry.productName = toText(row.productName, 300) || '';
if (!entry.supplier) entry.supplier = toText(row.supplier, 120) || '';
if (isPick) {
entry.pickQty += qty;
entry.pickBins.push({ bin: bin || '未维护', qty });
} else {
entry.storageQty += qty;
entry.storageBins.push({ bin: bin || '未维护', qty });
}
});
const rows = [];
skuMap.forEach((entry) => {
const avgSale = toNumber(dailySaleMap.get(entry.skuId), 0);
const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(avgSale * targetDays));
const pickQty = round2(entry.pickQty);
const storageQty = round2(entry.storageQty);
let status = STATUS.OK;
if (entry.pickBins.length === 0) {
// 这个 SKU 压根没有拣货位(整箱直发 / 未维护)——不是「拣货位缺货」,
// 不能混进待补货清单,否则会刷出几千条误报。
status = STATUS.NO_PICK;
} else if (pickQty <= 0) {
status = storageQty > 0 ? STATUS.PICK_OUT : STATUS.EMPTY;
} else if (pickQty < targetQty && storageQty > 0) {
status = STATUS.LOW;
}
const suggestQty = status === STATUS.PICK_OUT || status === STATUS.LOW
? Math.max(0, Math.min(Math.ceil(storageQty), Math.ceil(targetQty - pickQty)))
: 0;
const pickBins = entry.pickBins
.slice()
.sort((a, b) => b.qty - a.qty || a.bin.localeCompare(b.bin));
const storageBins = entry.storageBins
.filter((item) => item.qty > 0)
.sort((a, b) => b.qty - a.qty)
.slice(0, 5);
rows.push({ rows.push({
skuId: entry.skuId, skuId,
productName: entry.productName, productName: toText(row.productName, 300) || '',
supplier: entry.supplier, supplier: toText(row.supplier, 120) || '',
status, status,
statusLabel: STATUS_LABELS[status], statusLabel: STATUS_LABELS[status],
pickQty, pickQty,
storageQty, storageQty,
totalQty: round2(pickQty + storageQty), totalQty: round2(pickQty + storageQty),
targetQty, orderQty,
excess,
suggestQty, suggestQty,
avgSale: round2(avgSale), shortfall,
pickBinCount: entry.pickBins.length, pickEmpty,
pickBins,
storageBins,
hasPickSlot: entry.pickBins.length > 0,
}); });
}); });
return rows; return rows;
} }
/** 按页面传入的目标天数重算目标库存与建议补货量(缓存里存的是默认 7 天口径) */ function buildSummary(rows, orderSkuCount) {
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 = { const summary = {
skuTotal: rows.length, skuTotal: rows.length,
pickout: 0, need: 0,
empty: 0, short: 0,
low: 0, enough: 0,
nopick: 0, noOrder: 0,
ok: 0, pickEmpty: 0,
suggestQtyTotal: 0, suggestQtyTotal: 0,
pickoutQtyTotal: 0, orderSkuCount: orderSkuCount || 0,
storageQtyTotal: 0, orderQtyTotal: 0,
withPickSlot: 0, exceedTotal: 0,
source: meta.source, shortfallTotal: 0,
pickBinCount: meta.pickBinCount, pickTotal: 0,
storageTotal: 0,
}; };
rows.forEach((row) => { rows.forEach((row) => {
if (summary[row.status] !== undefined) summary[row.status] += 1; if (summary[row.status] !== undefined) summary[row.status] += 1;
summary.suggestQtyTotal += row.suggestQty || 0; summary.suggestQtyTotal += row.suggestQty || 0;
summary.storageQtyTotal += row.storageQty || 0; summary.orderQtyTotal += row.orderQty || 0;
if (row.status === STATUS.PICK_OUT) summary.pickoutQtyTotal += row.suggestQty || 0; summary.exceedTotal += row.excess || 0;
if (row.hasPickSlot) summary.withPickSlot += 1; summary.shortfallTotal += row.shortfall || 0;
summary.pickTotal += row.pickQty || 0;
summary.storageTotal += row.storageQty || 0;
if (row.pickEmpty) summary.pickEmpty += 1;
}); });
summary.suggestQtyTotal = round2(summary.suggestQtyTotal); summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
summary.pickoutQtyTotal = round2(summary.pickoutQtyTotal); summary.orderQtyTotal = round2(summary.orderQtyTotal);
summary.storageQtyTotal = round2(summary.storageQtyTotal); summary.exceedTotal = round2(summary.exceedTotal);
summary.shortfallTotal = round2(summary.shortfallTotal);
summary.pickTotal = round2(summary.pickTotal);
summary.storageTotal = round2(summary.storageTotal);
return summary; return summary;
} }
async function loadDataset(warehouse, { refresh = false } = {}) { async function loadDataset(warehouse, { refresh = false } = {}) {
const cacheKey = warehouse.key; const orderDate = await loadOrderDate(warehouse);
// 把订单日期纳入缓存键:跨天后自动失效,无需等 TTL
const cacheKey = `${warehouse.key}::${orderDate || 'none'}`;
const cached = cacheStore.get(cacheKey); const cached = cacheStore.get(cacheKey);
const ttl = resolveCacheTtlMs(); const ttl = resolveCacheTtlMs();
const now = Date.now(); const now = Date.now();
@ -466,31 +340,21 @@ async function loadDataset(warehouse, { refresh = false } = {}) {
} }
const epoch = cacheEpoch; const epoch = cacheEpoch;
const [replicaRows, pickBins, dailySaleMap] = await Promise.all([ const [invRows, orderMap] = await Promise.all([
loadReplicaRows(warehouse), loadReplicaInventory(warehouse),
loadPickBinRows(warehouse.key), loadTodayOrderMap(warehouse, orderDate).catch(() => new Map()),
loadDailySaleMap(warehouse).catch(() => new Map()),
]); ]);
const pickBinSet = new Set(pickBins.map((row) => normalizeBin(row.binCode)).filter(Boolean)); const rows = buildSkuRows({ invRows, orderMap, orderDate });
const useFallback = pickBinSet.size === 0;
const rows = buildSkuRows({
replicaRows,
pickBinSet,
dailySaleMap,
targetDays: DEFAULT_TARGET_DAYS,
useFallback,
});
const dataset = { const dataset = {
at: now, at: now,
rows, rows,
warehouse, warehouse,
orderDate,
meta: { meta: {
source: useFallback ? 'fallback' : 'import',
pickBinCount: pickBinSet.size,
dailySaleSkus: dailySaleMap.size,
rowCount: rows.length, rowCount: rows.length,
orderSkuCount: orderMap.size,
}, },
}; };
// 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据 // 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据
@ -507,10 +371,9 @@ function invalidateCache(warehouseKey) {
function matchesStatus(row, status) { function matchesStatus(row, status) {
const key = toText(status, 20).toLowerCase(); const key = toText(status, 20).toLowerCase();
if (!key || key === 'all') return true; if (!key || key === 'all') return true;
if (key === 'action' || key === 'todo') { if (key === 'action' || key === 'todo' || key === 'replenish') {
return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW || row.status === STATUS.EMPTY; return row.status === STATUS.NEED || row.status === STATUS.SHORT;
} }
if (key === 'replenish') return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW;
return row.status === key; return row.status === key;
} }
@ -518,6 +381,7 @@ function sortRows(rows) {
return rows.slice().sort((a, b) => ( return rows.slice().sort((a, b) => (
(STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0) (STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0)
|| (b.suggestQty || 0) - (a.suggestQty || 0) || (b.suggestQty || 0) - (a.suggestQty || 0)
|| (b.excess || 0) - (a.excess || 0)
|| String(a.skuId).localeCompare(String(b.skuId)) || String(a.skuId).localeCompare(String(b.skuId))
)); ));
} }
@ -535,18 +399,9 @@ function filterRows(rows, { keyword, status }) {
async function getReplenishList(options = {}) { async function getReplenishList(options = {}) {
const warehouse = resolveWarehouse(options.warehouse); const warehouse = resolveWarehouse(options.warehouse);
const targetDays = resolveTargetDays(options.targetDays);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' }); const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
const filtered = sortRows(filterRows(dataset.rows, options));
// targetDays 是页面参数,不进缓存:缓存的是原始聚合,这里按参数重算目标/建议量 const summary = buildSummary(dataset.rows, dataset.meta.orderSkuCount);
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 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 pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(options.pageSize, 10) || 50));
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
@ -559,31 +414,25 @@ async function getReplenishList(options = {}) {
summary, summary,
warehouse, warehouse,
warehouses: Object.values(WAREHOUSES), warehouses: Object.values(WAREHOUSES),
targetDays, orderDate: dataset.orderDate,
cacheHit: dataset.cacheHit, cacheHit: dataset.cacheHit,
updatedAt: new Date(dataset.at).toISOString(), updatedAt: new Date(dataset.at).toISOString(),
cacheTtlMs: resolveCacheTtlMs(), cacheTtlMs: resolveCacheTtlMs(),
pickBinMeta,
dailySaleSkus: dataset.meta.dailySaleSkus,
}; };
} }
async function getReplenishExportRows(options = {}) { async function getReplenishExportRows(options = {}) {
const warehouse = resolveWarehouse(options.warehouse); const warehouse = resolveWarehouse(options.warehouse);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' }); const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
const targetDays = resolveTargetDays(options.targetDays);
const rows = applyTargetDays(dataset.rows, targetDays);
return { return {
warehouse, warehouse,
rows: sortRows(filterRows(rows, options)), orderDate: dataset.orderDate,
summary: buildSummary(rows, { rows: sortRows(filterRows(dataset.rows, options)),
source: dataset.meta.source, summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount),
pickBinCount: dataset.meta.pickBinCount,
}),
}; };
} }
/** 启动预热:视图查询 35~45s,不能让首个用户请求等 */ /** 启动预热:库存视图 + 当日订单 union 各自 30s~1min,不能让首个用户请求等 */
async function warmBinReplenishCache() { async function warmBinReplenishCache() {
try { try {
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY]; const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];
@ -597,16 +446,15 @@ async function warmBinReplenishCache() {
module.exports = { module.exports = {
DEFAULT_WAREHOUSE_KEY, DEFAULT_WAREHOUSE_KEY,
PICK_PREFIXES,
STORE_PREFIXES,
STATUS, STATUS,
STATUS_LABELS, STATUS_LABELS,
WAREHOUSES, WAREHOUSES,
clearPickBins, clearPickBins: async () => ({ removed: 0 }),
getPickBinMeta,
getReplenishExportRows, getReplenishExportRows,
getReplenishList, getReplenishList,
importPickBins,
loadDataset, loadDataset,
normalizeBin,
resolveWarehouse, resolveWarehouse,
resolveWarehouseKey, resolveWarehouseKey,
warmBinReplenishCache, warmBinReplenishCache,

View File

@ -2,8 +2,8 @@ import request from './request';
const LONG_TIMEOUT_MS = 10 * 60 * 1000; const LONG_TIMEOUT_MS = 10 * 60 * 1000;
// 缺货补货监控(拣货位缺货 → 从存货位补货) // 缺货补货监控(订单驱动版)
// params.warehouse: changzhou / wh7 / suining / henglin2 / subei / henglin3(缺省 = 常州主仓) // 判断口径:当日订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
export const getBinReplenishList = (params = {}) => request.get( export const getBinReplenishList = (params = {}) => request.get(
'/api/bin-replenish/list', '/api/bin-replenish/list',
{ params, timeout: LONG_TIMEOUT_MS } { params, timeout: LONG_TIMEOUT_MS }
@ -18,23 +18,3 @@ export const exportBinReplenish = (params = {}) => request.get(
silentError: true, 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 } : {} }
);

View File

@ -1,14 +1,12 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Alert, Button, Empty, Input, InputNumber, Popconfirm, Segmented, Select, Spin, Table, Tag, Tooltip, Upload, Alert, Button, Empty, Input, Segmented, Select, Spin, Table, Tag, Tooltip,
message, message,
} from 'antd'; } from 'antd';
import { import {
DeleteOutlined, DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UploadOutlined, DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import { exportBinReplenish, getBinReplenishList } from '../../api/binReplenish';
clearPickBins, exportBinReplenish, getBinReplenishList, importPickBins,
} from '../../api/binReplenish';
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport'; import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
import { SycmPage } from '../../components/Common'; import { SycmPage } from '../../components/Common';
import './style.css'; import './style.css';
@ -18,48 +16,24 @@ const num = (value) => (value === null || value === undefined || value === ''
: Number(value).toLocaleString('zh-CN')); : Number(value).toLocaleString('zh-CN'));
const STATUS_TAGS = { const STATUS_TAGS = {
pickout: { color: 'red', label: '拣货位缺货' }, short: { color: 'red', label: '存储不足' },
empty: { color: 'default', label: '无货可补' }, need: { color: 'gold', label: '需补货' },
low: { color: 'orange', label: '拣货位偏低' }, enough: { color: 'green', label: '拣货位充足' },
nopick: { color: 'blue', label: '未设拣货位' }, no_order: { color: 'default', label: '今日无单' },
ok: { color: 'green', label: '正常' },
}; };
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ label: '待补货', value: 'replenish' }, { label: '待补货', value: 'replenish' },
{ label: '无货可补', value: 'empty' }, { label: '存储不足', value: 'short' },
{ label: '未设拣货位', value: 'nopick' }, { label: '拣货位充足', value: 'enough' },
{ label: '今日无单', value: 'no_order' },
{ label: '全部', value: 'all' }, { 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 BinReplenishMonitor = () => {
const [warehouse, setWarehouse] = useState('cuiqiao'); const [warehouse, setWarehouse] = useState('cuiqiao');
const [status, setStatus] = useState('replenish'); const [status, setStatus] = useState('replenish');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [targetDays, setTargetDays] = useState(DEFAULT_TARGET_DAYS);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(50);
@ -67,9 +41,8 @@ const BinReplenishMonitor = () => {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [data, setData] = useState({ const [data, setData] = useState({
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], pickBinMeta: null, updatedAt: '', cacheHit: false, rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderDate: '', updatedAt: '', cacheHit: false,
}); });
const [importing, setImporting] = useState(false);
const keywordTimer = useRef(null); const keywordTimer = useRef(null);
const load = useCallback(async (options = {}) => { const load = useCallback(async (options = {}) => {
@ -82,7 +55,6 @@ const BinReplenishMonitor = () => {
warehouse, warehouse,
status, status,
keyword, keyword,
targetDays,
page: options.page || page, page: options.page || page,
pageSize: options.pageSize || pageSize, pageSize: options.pageSize || pageSize,
refresh: isRefresh ? 'true' : undefined, refresh: isRefresh ? 'true' : undefined,
@ -94,7 +66,7 @@ const BinReplenishMonitor = () => {
summary: payload.summary || {}, summary: payload.summary || {},
warehouse: payload.warehouse || null, warehouse: payload.warehouse || null,
warehouses: payload.warehouses || [], warehouses: payload.warehouses || [],
pickBinMeta: payload.pickBinMeta || null, orderDate: payload.orderDate || '',
updatedAt: payload.updatedAt || '', updatedAt: payload.updatedAt || '',
cacheHit: Boolean(payload.cacheHit), cacheHit: Boolean(payload.cacheHit),
}); });
@ -105,12 +77,12 @@ const BinReplenishMonitor = () => {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
} }
}, [warehouse, status, keyword, targetDays, page, pageSize]); }, [warehouse, status, keyword, page, pageSize]);
useEffect(() => { useEffect(() => {
load(); load();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [warehouse, status, page, pageSize, targetDays]); }, [warehouse, status, page, pageSize]);
useEffect(() => { useEffect(() => {
if (keywordTimer.current) clearTimeout(keywordTimer.current); if (keywordTimer.current) clearTimeout(keywordTimer.current);
@ -123,93 +95,65 @@ const BinReplenishMonitor = () => {
}, [keyword]); }, [keyword]);
const summary = data.summary; const summary = data.summary;
const pickBinMeta = data.pickBinMeta || {}; const needCount = (summary.need || 0) + (summary.short || 0);
const usingFallback = pickBinMeta.source !== 'import';
const metrics = useMemo(() => ([ const metrics = useMemo(() => ([
{ {
key: 'pickout', key: 'replenish',
tone: 'red', tone: 'red',
label: '拣货位缺货', label: '待补货 SKU',
value: summary.pickout || 0, value: needCount,
unit: '个 SKU', unit: '个',
foot: `可立即从存货位补 ${num(summary.suggestQtyTotal)} 件`, foot: `建议补货合计 ${num(summary.suggestQtyTotal)} 件`,
tip: '拣货位库存为 0、存货位还有货:需要把存货位的库存补到拣货位', tip: '当日订单量 > 拣货位库存:拣货位不够今天的订单拣,需要从存储位把货补过来',
}, },
{ {
key: 'empty', key: 'short',
tone: 'grey', tone: 'grey',
label: '无货可补', label: '其中存储不足',
value: summary.empty || 0, value: summary.short || 0,
unit: '个 SKU', unit: '个',
foot: '拣货位与存货位都为 0,需采购', foot: `存储缺口 ${num(summary.shortfallTotal)} 件`,
tip: '拣货位库存为 0,且仓库里已经没有该商品的存货,补货要走走采购', tip: '拣货位缺货,且存储位也不够补(存储缺口 > 0):光靠库内调拨补不齐,需要采购 / 紧急调拨',
}, },
{ {
key: 'low', key: 'exceed',
tone: 'orange', tone: 'orange',
label: '拣货位偏低', label: '超出订单数合计',
value: summary.low || 0, value: summary.exceedTotal || 0,
unit: '个 SKU', unit: '件',
foot: `低于 ${targetDays} 天销量目标`, foot: '今日拣货位缺口订单量',
tip: `拣货位还有货,但低于目标库存(= 近15天日销 × ${targetDays},最少 ${10} 件)`, tip: '所有待补货 SKU 的「订单量 − 拣货位库存」之和:这是今天需要从存储位搬到拣货位的总量',
}, },
{ {
key: 'qty', key: 'order',
tone: 'blue', tone: 'blue',
label: '建议补货合计', label: '当日有单 SKU',
value: summary.suggestQtyTotal || 0, value: summary.orderSkuCount || 0,
unit: '件', unit: '个',
foot: `覆盖 ${num(summary.withPickSlot)} 个有拣货位的 SKU`, foot: `当日订单量 ${num(summary.orderQtyTotal)} 件`,
tip: '建议补货量 = min(存货位库存, 目标拣货位库存 − 当前拣货位库存)', tip: '统计日当天下过单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件)',
}, },
]), [summary, targetDays]); ]), [summary, needCount]);
const handleExport = async () => { const handleExport = async () => {
try { try {
const response = await exportBinReplenish({ warehouse, status, keyword, targetDays }); const response = await exportBinReplenish({ warehouse, status, keyword });
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控.xlsx`); downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_${data.orderDate || ''}.xlsx`);
} catch (error_) { } catch (error_) {
const text = await readBlobErrorMessage(error_); const text = await readBlobErrorMessage(error_);
message.error(text || '导出失败'); 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 = [ const columns = [
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 112, width: 100,
fixed: 'left', fixed: 'left',
render: (value) => { render: (value) => {
const tag = STATUS_TAGS[value] || STATUS_TAGS.ok; const tag = STATUS_TAGS[value] || STATUS_TAGS.no_order;
return <Tag color={tag.color} className="brp-status-tag">{tag.label}</Tag>; return <Tag color={tag.color} className="brp-status-tag">{tag.label}</Tag>;
}, },
}, },
@ -225,30 +169,42 @@ const BinReplenishMonitor = () => {
), ),
}, },
{ title: '品名', dataIndex: 'productName', width: 260, ellipsis: true }, { 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: '拣货位库存', title: '拣货位库存',
dataIndex: 'pickQty', dataIndex: 'pickQty',
width: 108, width: 100,
align: 'right', align: 'right',
render: (value) => ( render: (value, row) => (
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>{num(value)}</span> <span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>
{num(value)}{row.pickEmpty ? ' ⚠' : ''}
</span>
), ),
}, },
{ {
title: '存货位库存', title: '存储位库存',
dataIndex: 'storageQty', dataIndex: 'storageQty',
width: 108, width: 100,
align: 'right', align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>, render: (value) => <span className="brp-qty">{num(value)}</span>,
}, },
{ {
title: '建议补货', title: '当日订单',
dataIndex: 'orderQty',
width: 100,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
{
title: '超出订单数',
dataIndex: 'excess',
width: 100,
align: 'right',
render: (value) => (
<span className={Number(value) > 0 ? 'brp-qty brp-qty--warn' : 'brp-qty brp-qty--zero'}>{num(value)}</span>
),
},
{
title: '建议补货量',
dataIndex: 'suggestQty', dataIndex: 'suggestQty',
width: 110, width: 110,
align: 'right', align: 'right',
@ -259,24 +215,13 @@ const BinReplenishMonitor = () => {
), ),
}, },
{ {
title: '目标拣货位', title: '存储缺口',
dataIndex: 'targetQty', dataIndex: 'shortfall',
width: 108, width: 100,
align: 'right', align: 'right',
render: (value) => <span className="brp-qty brp-qty--muted">{num(value)}</span>, render: (value) => (
}, <span className={Number(value) > 0 ? 'brp-qty brp-qty--danger' : 'brp-qty brp-qty--zero'}>{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: '现有库存', title: '现有库存',
@ -293,8 +238,8 @@ const BinReplenishMonitor = () => {
<div className="brp-hero__main"> <div className="brp-hero__main">
<h1 className="brp-hero__title">缺货补货监控</h1> <h1 className="brp-hero__title">缺货补货监控</h1>
<p className="brp-hero__desc"> <p className="brp-hero__desc">
盯的是<b>拣货位</b>:拣货位没货就拣不出来,需要从<b>存货位</b>把货补过去。 盯的是<b>拣货位</b>:今天下的订单,如果拣货位库存不够拣,超出的那部分就要从<b>存储位</b>补到拣货位。
状态、建议补货量都按当前库存实时算,数据来自聚水潭「箱及仓位库存」。 库存取自聚水潭「箱及仓位库存」,订单取自崔桥工厂仓当日订单。
</p> </p>
</div> </div>
<div className="brp-hero__meta"> <div className="brp-hero__meta">
@ -302,18 +247,15 @@ const BinReplenishMonitor = () => {
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b> 仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
</div> </div>
<div> <div>
拣货位口径: 订单日期:<b>{data.orderDate || '--'}</b>
<b className={usingFallback ? '' : 'brp-ok'}> <span className="brp-muted">(取该仓最近一个有订单的营业日)</span>
{usingFallback ? '默认拣选位(Bin 且非存货位)' : `导入清单 ${num(pickBinMeta.count)} 个`}
</b>
</div> </div>
<div> <div>
取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b> 取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b>
{data.cacheHit ? <span className="brp-muted">(缓存)</span> : null} {data.cacheHit ? <span className="brp-muted">(缓存)</span> : null}
</div> </div>
<div> <div>
共 <b>{num(summary.skuTotal)}</b> 个 SKU,其中未设拣货位{' '} 共 <b>{num(summary.skuTotal)}</b> 个 SKU,其中今日无单 <b>{num(summary.noOrder)}</b> 个
<b>{num(summary.nopick)}</b> 个(不参与待补货)
</div> </div>
</div> </div>
</div> </div>
@ -357,23 +299,15 @@ const BinReplenishMonitor = () => {
options={STATUS_OPTIONS.map((item) => ({ options={STATUS_OPTIONS.map((item) => ({
...item, ...item,
label: item.value === 'replenish' label: item.value === 'replenish'
? `${item.label} ${num((summary.pickout || 0) + (summary.low || 0))}` ? `${item.label} ${num(needCount)}`
: item.value === 'empty' : item.value === 'short'
? `${item.label} ${num(summary.empty || 0)}` ? `${item.label} ${num(summary.short || 0)}`
: item.label, : item.value === 'no_order'
? `${item.label} ${num(summary.noOrder || 0)}`
: item.label,
}))} }))}
/> />
</span> </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 <Input
allowClear allowClear
value={keyword} value={keyword}
@ -383,14 +317,6 @@ const BinReplenishMonitor = () => {
style={{ width: 260 }} style={{ width: 260 }}
/> />
<span className="brp-toolbar__spacer" /> <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 icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
<Button <Button
type="primary" type="primary"
@ -402,38 +328,18 @@ const BinReplenishMonitor = () => {
</Button> </Button>
</div> </div>
{usingFallback ? ( <Alert
<Alert type="info"
type="info" showIcon
showIcon className="brp-alert"
className="brp-alert" message="货位口径(崔桥工厂仓约定)"
message="拣货位口径:默认拣选位(pack_type = Bin 且非存货位),与「拣选库位动销分析」一致" description={(
description="这是系统当前能确定的最准口径。如果业务另有一份拣货位清单,可以上传覆盖(只放货位号,如 XA01-01-11),上传后按清单判定,其余货位都算存货位。" <span>
action={( 拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;其余货位(DB / Y* / Z* 等,占比极小)不计入。
<Upload accept=".xlsx,.xls,.csv,.txt" showUploadList={false} beforeUpload={handleImport}> 判断规则:<b>当日订单量 &gt; 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
<Button size="small" type="primary">上传清单</Button> </span>
</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 ? ( {error ? (
<Alert <Alert
@ -447,7 +353,7 @@ const BinReplenishMonitor = () => {
<Spin <Spin
spinning={loading} spinning={loading}
tip="正在从仓位库存视图聚合,首次约 30~60 秒" tip="正在聚合仓位库存 + 当日订单,首次约 30~90 秒"
wrapperClassName="brp-table-spin" wrapperClassName="brp-table-spin"
> >
<Table <Table
@ -456,7 +362,7 @@ const BinReplenishMonitor = () => {
className="brp-table" className="brp-table"
columns={columns} columns={columns}
dataSource={data.rows} dataSource={data.rows}
scroll={{ x: 1900 }} scroll={{ x: 1500 }}
locale={{ locale={{
emptyText: ( emptyText: (
<Empty <Empty
@ -485,28 +391,24 @@ const BinReplenishMonitor = () => {
<div className="brp-notes__title">数据来源与口径说明</div> <div className="brp-notes__title">数据来源与口径说明</div>
<ul> <ul>
<li> <li>
<b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码 + 货位汇总。 <b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码(sku_id)聚合。
「拣货位库存」= 拣货位上的数量合计;「存货位库存」= 其余货位(含整箱、未分箱)的数量合计。 「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
</li> </li>
<li> <li>
<b>拣货位</b>:副本没有货位类型字段,按导入的货位清单判定;未导入时回退为 <b>订单</b>:崔桥工厂仓当日订单 = rpt.jst_Orders(线上店铺)+ rpt.jst_qmcust_orderlist(全民客户 B2B),
pack_type = Bin 的记录(页面顶部会标出当前口径)。 均按 send_date 落在「订单日期」当天;组合装(sku_type=combine)已按 jst_comboProductItem 展开为子件。
</li> </li>
<li> <li>
<b>目标库存</b>:max(10, 近15天日销 × {targetDays});日销取 rpt.pm_stockMonitor 同仓最新快照的 <b>判断规则</b>:对每个 SKU,若 <b>当日订单量 &gt; 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量;
fifteenAvgSale,取不到的按 10 件兜底。 「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。
</li> </li>
<li> <li>
<b>建议补货量</b>:min(存货位库存, 目标拣货位库存 − 当前拣货位库存),只在「拣货位缺货 / 偏低」时给数字; <b>订单日期</b>:取该仓订单表 send_date 的最大日期(即最近一个有订单的营业日),副本 ETL 有延迟时不会为空;
「无货可补」表示拣货位和存货位都没货,要走走采购,不是仓库内部调拨。 页面顶部明确标注统计的是哪一天。
</li> </li>
<li> <li>
<b>未设拣货位</b>:该 SKU 在拣货位清单里一个货位都没有(多为整箱直发、或还没维护拣货位)。 <b>时效</b>:仓位库存视图聚合 + 当日订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热);
这类不算「拣货位缺货」,不进待补货清单,可在「未设拣货位」页签单独核对。 点「刷新数据」会强制回源,需要等 1 分钟左右。
</li>
<li>
<b>时效</b>:仓位库存视图单次聚合约 35~45 秒,服务端缓存 10 分钟(启动时已预热);
点「刷新数据」会强制回源,需要等半分钟左右。
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -250,6 +250,16 @@
font-weight: 600; font-weight: 600;
} }
.brp-qty--warn {
color: var(--brp-orange);
font-weight: 600;
}
.brp-qty--danger {
color: var(--brp-red);
font-weight: 700;
}
.brp-qty--muted { .brp-qty--muted {
color: var(--sycm-text-tertiary, #8c8c8c); color: var(--sycm-text-tertiary, #8c8c8c);
font-weight: 400; font-weight: 400;