diff --git a/backend/src/api/binReplenish.js b/backend/src/api/binReplenish.js
index 9d00a36..a9afc4b 100644
--- a/backend/src/api/binReplenish.js
+++ b/backend/src/api/binReplenish.js
@@ -1,22 +1,13 @@
'use strict';
const express = require('express');
-const multer = require('multer');
const {
- clearPickBins,
- getPickBinMeta,
getReplenishExportRows,
getReplenishList,
- importPickBins,
- resolveWarehouseKey,
} = require('../services/binReplenishService');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
-const upload = multer({
- storage: multer.memoryStorage(),
- limits: { fileSize: 20 * 1024 * 1024 },
-});
const EXPORT_COLUMNS = [
{ key: 'skuId', title: '商品编码' },
@@ -24,16 +15,15 @@ const EXPORT_COLUMNS = [
{ key: 'supplier', title: '供应商' },
{ key: 'statusLabel', title: '状态' },
{ key: 'pickQty', title: '拣货位库存' },
- { key: 'storageQty', title: '存货位库存' },
+ { key: 'storageQty', title: '存储位库存' },
{ key: 'totalQty', title: '现有库存合计' },
- { key: 'targetQty', title: '目标拣货位库存' },
+ { key: 'orderQty', title: '当日订单' },
+ { key: 'excess', title: '超出订单数' },
{ key: 'suggestQty', title: '建议补货量' },
- { key: 'avgSale', title: '近15天日销' },
- { key: 'pickBinText', title: '拣货位(货位号/数量)' },
- { key: 'storageBinText', title: '存货位(前5个/数量)' },
+ { key: 'shortfall', title: '存储缺口' },
];
-/** GET /api/bin-replenish/list —— 缺货补货监控列表 */
+/** GET /api/bin-replenish/list —— 缺货补货监控列表(订单驱动) */
router.get('/list', async (req, res, next) => {
try {
const payload = await getReplenishList({
@@ -42,7 +32,6 @@ router.get('/list', async (req, res, next) => {
status: req.query.status,
page: req.query.page,
pageSize: req.query.pageSize,
- targetDays: req.query.targetDays,
refresh: req.query.refresh,
});
res.json({ success: true, data: payload });
@@ -59,21 +48,16 @@ router.get('/export', async (req, res, next) => {
warehouse: req.query.warehouse,
keyword: req.query.keyword,
status: req.query.status,
- targetDays: req.query.targetDays,
});
- const rows = payload.rows.map((row) => ({
- ...row,
- pickBinText: (row.pickBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'),
- storageBinText: (row.storageBins || []).map((item) => `${item.bin}(${item.qty})`).join('、'),
- }));
+ const fileName = `${payload.warehouse.shortName}缺货补货监控_${payload.orderDate || ''}`;
sendWorkbookDownload(res, {
- fileName: `${payload.warehouse.shortName}缺货补货监控_${new Date().toISOString().slice(0, 10)}`,
+ fileName,
format,
sheets: [{
name: '缺货补货监控',
header: EXPORT_COLUMNS.map((column) => column.title),
- rows: rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
+ rows: payload.rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
}],
});
} catch (error) {
@@ -81,51 +65,6 @@ router.get('/export', async (req, res, next) => {
}
});
-/** GET /api/bin-replenish/pick-bins —— 当前仓库的拣货位清单概况 */
-router.get('/pick-bins', async (req, res, next) => {
- try {
- const meta = await getPickBinMeta(resolveWarehouseKey(req.query.warehouse));
- res.json({ success: true, data: meta });
- } catch (error) {
- next(error);
- }
-});
-
-/** POST /api/bin-replenish/pick-bins —— 导入拣货位清单(xlsx / xls / csv / txt) */
-router.post('/pick-bins', upload.single('file'), async (req, res, next) => {
- try {
- if (!req.file || !req.file.buffer) {
- res.status(400).json({ success: false, message: '请选择要导入的货位清单文件(xlsx / xls / csv)' });
- return;
- }
- // multer 按 latin1 解析 multipart 文件名,中文会乱码,这里转回 UTF-8
- const rawName = req.file.originalname || '';
- const fileName = /[\u00c0-\u00ff]/.test(rawName)
- ? Buffer.from(rawName, 'latin1').toString('utf8')
- : rawName;
-
- const result = await importPickBins({
- buffer: req.file.buffer,
- fileName,
- warehouseKey: req.body?.warehouse || req.query.warehouse,
- importedBy: req.user?.username || req.user?.name || null,
- });
- res.json({ success: true, data: result });
- } catch (error) {
- next(error);
- }
-});
-
-/** DELETE /api/bin-replenish/pick-bins —— 清除货位清单,回到回退口径 */
-router.delete('/pick-bins', async (req, res, next) => {
- try {
- const result = await clearPickBins(req.query.warehouse);
- res.json({ success: true, data: result });
- } catch (error) {
- next(error);
- }
-});
-
/** GET /api/bin-replenish/ping —— 路由存活探针(401 也代表已注册) */
router.get('/ping', (req, res) => {
res.json({ success: true, data: { ok: true } });
diff --git a/backend/src/services/binReplenishService.js b/backend/src/services/binReplenishService.js
index ad94972..e13c3b8 100644
--- a/backend/src/services/binReplenishService.js
+++ b/backend/src/services/binReplenishService.js
@@ -1,60 +1,54 @@
'use strict';
/**
- * 缺货补货监控 —— 拣货位缺货 / 从存货位补货
+ * 缺货补货监控(订单驱动版,2026-10-22 重写)
* ==========================================================================
- * 业务需求(2026-10-22 用户确认):
- * 监控现有库存 → 看哪些「拣货库位」缺货 → 需要从「存货仓位」补货过去。
+ * 业务需求(用户确认):
+ * 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够今天的订单拣,
+ * 需要从「存储库位」把货补过去。
+ *
+ * ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
+ * 拣货库位(拣货仓位):以 XA / XD / XE 开头
+ * 存储库位(存储仓位):以 XX / X9 / X5 / X4 开头
+ * 其余前缀(DB / Y* / Z* / YC 等,崔桥占比极小)归为「未分类」,不计入拣货/存储。
*
* ── 数据源 ───────────────────────────────────────────────────────────
- * rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」)
- * · 列:sku_id / bin(货位号)/ pack_type / bin_group / qty / wms_co_id
- * · 一行 = 某货位上某商品的库存;同一货位可能多行(不同箱)
- * · pack_type:Bin = 货位散件库存;Pack = 整箱;DefaultPack = 未分箱(仓库级)
- * ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
- * 崔桥/山东/横林二/苏北/河南全部挂在 wh_id=1 下,按 wh_id 会混成一个桶。
- * ⚠️ 该对象在副本里是**视图**(sys.objects.type_desc = VIEW),
- * 没有暴露底层表;单次按 SKU+货位聚合实测 **10~45 秒**(随缓存/负载波动),
- * 直接取原始行更慢(25,488 行约 80 秒)。因此必须缓存 + 启动预热,
- * 不要把它改回「每次请求都回源」。
+ * 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储)
+ * 订单:rpt.jst_Orders × jst_OrderItem(线上店铺)
+ * + rpt.jst_qmcust_orderlist × jst_qmcust_orderlist_item(全民客户 B2B)
+ * + 组合装(sku_type='combine')→ jst_comboProductItem 展开为子件
+ * ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
+ * 按 wh_id 会把多仓混成一个桶。
+ * 订单日期:取该仓订单表 send_date 的最大日期(即「最近一个有订单的营业日」,
+ * 副本 ETL 有延迟时不会为空,页面会明确标注统计的是哪一天)。
*
- * ── 拣货位怎么认定(关键)─────────────────────────────────────────────
- * 副本**没有独立的货位类型主数据**,但「拣选库位动销分析」已确认口径:
- * 真实拣选位 = `pack_type = 'Bin'` 且 `bin_group` 为空
- * (`bin_group='存货'`、`pack_type='Pack'/'DefaultPack'` 都算存货位)
- * 本页默认就用这个口径;如果业务另有一份拣货位清单,
- * 页面导入后以清单为准(`bin ∈ 清单` → 拣货位),其余一律算存货位。
- *
- * ── 指标口径 ─────────────────────────────────────────────────────────
- * 拣货位库存 pickQty = 该 SKU 在所有拣货位上的 qty 合计
- * 存货位库存 storageQty = 该 SKU 在其余货位(含整箱/未分箱)上的 qty 合计
- * 现有库存 totalQty = pickQty + storageQty
- * 目标拣货位库存 target = max(10, ceil(近15天日销 × targetDays)),targetDays 默认 7
- * 建议补货量 = min(storageQty, max(0, target − pickQty)),向上取整
+ * ── 判断逻辑(核心)──────────────────────────────────────────────────
+ * 对每一个 SKU(在拣货/存储位上有库存):
+ * 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计
+ * 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计
+ * 当日订单 orderQty = 该 SKU 在订单日期当天的订单量(含组合装展开)
+ * 超出订单数 excess = max(0, orderQty − pickQty)
+ * —— 拣货位不够今天的订单拣,超出的这部分要从存储位补到拣货位
+ * 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
+ * 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
*
* 状态:
- * pickout 拣货位缺货(pickQty ≤ 0,且存货位有货 → 可以补)
- * empty 无货可补(pickQty ≤ 0,存货位也没货 → 需要采购)
- * low 拣货位偏低(0 < pickQty < target,存货位有货)
- * ok 正常
+ * short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
+ * need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
+ * enough 拣货位充足(0 < orderQty ≤ pickQty)
+ * no_order 今日无单(orderQty = 0)
+ * (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
*
- * 近15天日销取自 rpt.pm_stockMonitor(同仓最新快照的 fifteenAvgSale),
- * 只用于算目标和排序;取不到时目标按 10 件兜底。
+ * ⚠️ 性能:库存视图聚合 + 当日订单 4 分支 union 各自都要 30s~1min,
+ * 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
*/
-const crypto = require('crypto');
-const XLSX = require('xlsx');
const cloudDb = require('../config/cloudDatabase');
-const database = require('../config/database');
/**
- * 仓库清单:**按 wms_co_id 取数,不能用 wh_id**
- * ---------------------------------------------------------------------------
- * 2026-10-22 实测:jst_packInventoryInfo.wh_id 是「货主」(简尚=1),
- * 崔桥/山东/横林二/苏北/河南的仓位记录**全部挂在 wh_id=1 底下**,
- * 按 wh_id 汇总会把 10 个仓混成一个桶。真实仓库是 wms_co_id。
- * 这里只列「有真实拣选位」的仓(拣选位 = pack_type='Bin' 且 bin_group 为空),
- * 口径与「拣选库位动销分析」保持一致。
+ * 仓库清单:**按 wms_co_id 取数**。
+ * 前缀货位口径(XA/XD/XE 拣货、XX/X9/X5/X4 存储)是崔桥工厂仓的约定;
+ * 其他仓若用不同前缀,页面会标注,默认聚焦崔桥工厂仓。
*/
const WAREHOUSES = Object.freeze({
cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 },
@@ -72,28 +66,31 @@ const WAREHOUSES = Object.freeze({
const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order);
const DEFAULT_WAREHOUSE_KEY = 'cuiqiao';
-const DEFAULT_TARGET_DAYS = 7;
-const MIN_TARGET_QTY = 10;
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐
const MAX_PAGE_SIZE = 5000;
+// ── 货位前缀规则(用户指定)────────────────────────────────────────
+const PICK_PREFIXES = Object.freeze(['XA', 'XD', 'XE']);
+const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']);
+const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(',');
+const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(',');
+
const STATUS = Object.freeze({
- PICK_OUT: 'pickout',
- EMPTY: 'empty',
- LOW: 'low',
- NO_PICK: 'nopick',
- OK: 'ok',
+ SHORT: 'short',
+ NEED: 'need',
+ ENOUGH: 'enough',
+ NO_ORDER: 'no_order',
});
const STATUS_LABELS = Object.freeze({
- pickout: '拣货位缺货',
- empty: '无货可补',
- low: '拣货位偏低',
- nopick: '未设拣货位',
- ok: '正常',
+ short: '存储不足',
+ need: '需补货',
+ enough: '拣货位充足',
+ no_order: '今日无单',
});
-const STATUS_RANK = Object.freeze({ pickout: 4, empty: 3, low: 2, nopick: 1, ok: 0 });
+// 排序权重:最紧急的排最前
+const STATUS_RANK = Object.freeze({ short: 4, need: 3, no_order: 2, enough: 1 });
const cacheStore = new Map();
let cacheEpoch = 0;
@@ -111,10 +108,6 @@ function round2(value) {
return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100;
}
-function normalizeBin(value) {
- return toText(value, 64).toUpperCase().replace(/\s+/g, '');
-}
-
function resolveWarehouseKey(value) {
const key = toText(value, 32).toLowerCase();
return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY;
@@ -132,332 +125,213 @@ function resolveCacheTtlMs() {
return num;
}
-function resolveTargetDays(value) {
- const num = Number(value);
- if (!Number.isFinite(num) || num <= 0) return DEFAULT_TARGET_DAYS;
- return Math.min(Math.round(num), 90);
-}
-
-// ── 拣货位清单(本地库)────────────────────────────────────────────
-async function loadPickBinRows(warehouseKey) {
- const rows = await database.local.query(
- 'SELECT bin_code AS binCode, imported_at AS importedAt FROM bin_replenish_pick_bin WHERE warehouse_key = ?',
- [warehouseKey]
- );
- return rows || [];
-}
-
-async function loadLatestPickBinBatch(warehouseKey) {
- const rows = await database.local.query(
- `SELECT batch_id AS batchId, file_name AS fileName, imported_rows AS importedRows,
- imported_by AS importedBy, imported_at AS importedAt
- FROM bin_replenish_import_batch WHERE warehouse_key = ?
- ORDER BY imported_at DESC LIMIT 1`,
- [warehouseKey]
- );
- return rows?.[0] || null;
-}
-
-async function getPickBinMeta(warehouseKey) {
- const [bins, batch] = await Promise.all([
- loadPickBinRows(warehouseKey),
- loadLatestPickBinBatch(warehouseKey),
- ]);
- return {
- count: bins.length,
- sample: bins.slice(0, 20).map((row) => row.binCode),
- lastImport: batch,
- source: bins.length > 0 ? 'import' : 'fallback',
- };
-}
-
-/** 从任意表格/文本里抽取货位号:扫描所有单元格,按逗号/分号/换行拆开,保留 ASCII 货位形态 */
-const BIN_TOKEN_PATTERN = /^[0-9A-Z]+(?:-[0-9A-Z]+){0,5}$/;
-// 表头 / 常见非货位词,避免把标题行当成货位导进来
-const BIN_TOKEN_BLOCKLIST = new Set(['BIN', 'CODE', 'SKU', 'SNO', 'NO', 'ID', 'LOCATION', 'LOC', 'POSITION']);
-
-function extractBinCodes(matrix = []) {
- const codes = new Set();
- let candidates = 0;
- matrix.forEach((row) => {
- (Array.isArray(row) ? row : [row]).forEach((cell) => {
- if (cell === null || cell === undefined) return;
- if (cell instanceof Date) return;
- const text = String(cell).trim();
- if (!text) return;
- const tokens = text.split(/[,,;;\s\r\n\t|、]+/u).filter(Boolean);
- tokens.forEach((token) => {
- const code = normalizeBin(token);
- if (!code) return;
- candidates += 1;
- if (code.length < 3 || code.length > 64) return;
- if (BIN_TOKEN_BLOCKLIST.has(code)) return;
- if (!BIN_TOKEN_PATTERN.test(code)) return;
- // 纯数字/纯字母也放过(有些仓用 A01、1234 这种),但单个字符的不要
- if (/^\d+$/.test(code) && code.length < 2) return;
- codes.add(code);
- });
- });
- });
- return { codes: [...codes], candidates };
-}
-
-async function importPickBins({ buffer, fileName, warehouseKey, importedBy }) {
- if (!buffer || !buffer.length) {
- const error = new Error('文件内容为空,请重新选择');
- error.statusCode = 400;
- throw error;
- }
- const key = resolveWarehouseKey(warehouseKey);
- let workbook;
- try {
- workbook = XLSX.read(buffer, { type: 'buffer', cellDates: false });
- } catch (error) {
- const err = new Error(`文件解析失败:${error.message}`);
- err.statusCode = 400;
- throw err;
- }
-
- const matrix = [];
- workbook.SheetNames.forEach((sheetName) => {
- const sheet = workbook.Sheets[sheetName];
- if (!sheet) return;
- const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false });
- rows.forEach((row) => matrix.push(row));
- });
-
- const { codes, candidates } = extractBinCodes(matrix);
- if (!codes.length) {
- const error = new Error('没有识别到货位号。清单里请只放货位号(如 XA01-01-11),或把它放在单独一列。');
- error.statusCode = 400;
- throw error;
- }
-
- const batchId = crypto.randomBytes(8).toString('hex');
- await database.local.query('DELETE FROM bin_replenish_pick_bin WHERE warehouse_key = ?', [key]);
-
- const CHUNK = 500;
- for (let i = 0; i < codes.length; i += CHUNK) {
- const chunk = codes.slice(i, i + CHUNK);
- const placeholders = chunk.map(() => '(?,?,?,NOW())').join(',');
- const params = [];
- chunk.forEach((code) => {
- params.push(key, code, batchId);
- });
- // eslint-disable-next-line no-await-in-loop
- await database.local.query(
- `INSERT INTO bin_replenish_pick_bin (warehouse_key, bin_code, batch_id, imported_at) VALUES ${placeholders}`,
- params
- );
- }
-
- await database.local.query(
- `INSERT INTO bin_replenish_import_batch
- (batch_id, warehouse_key, file_name, total_cells, imported_rows, imported_by, imported_at)
- VALUES (?,?,?,?,?,?,NOW())`,
- [batchId, key, toText(fileName, 255) || null, candidates, codes.length, toText(importedBy, 100) || null]
- );
-
- invalidateCache(key);
-
- return {
- batchId,
- warehouse: WAREHOUSES[key],
- importedRows: codes.length,
- totalCells: candidates,
- fileName: toText(fileName, 255) || null,
- sample: codes.slice(0, 20),
- };
-}
-
-async function clearPickBins(warehouseKey) {
- const key = resolveWarehouseKey(warehouseKey);
- const rows = await database.local.query(
- 'SELECT COUNT(*) AS cnt FROM bin_replenish_pick_bin WHERE warehouse_key = ?',
- [key]
- );
- await database.local.query('DELETE FROM bin_replenish_pick_bin WHERE warehouse_key = ?', [key]);
- invalidateCache(key);
- return { warehouse: WAREHOUSES[key], removed: Number(rows?.[0]?.cnt || 0) };
+/** 订单日期的窗口边界(仅用日期字符串,避免时区问题;副本 send_date 按中国营业日存储) */
+function orderWindow(orderDate) {
+ const d = new Date(`${orderDate}T00:00:00Z`);
+ d.setUTCDate(d.getUTCDate() + 1);
+ const next = d.toISOString().slice(0, 10);
+ return { shipFrom: `${orderDate} 00:00:00`, shipTo: `${next} 00:00:00` };
}
// ── 副本取数 ────────────────────────────────────────────────────────
/**
- * 按 SKU + 货位 + 记录类型聚合。
- * 必须用 GROUP BY(不要把原始行拉回进程):视图原始行 25,488 行约 80s,
- * 聚合后 23,751 行约 43s,是当前可用的最快形态。
+ * 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
+ * 只统计用户指定的前缀宇宙(XA/XD/XE/XX/X9/X5/X4),其余货位不计入。
*/
-async function loadReplicaRows(warehouse) {
+async function loadReplicaInventory(warehouse) {
const sql = `
- SELECT p.sku_id AS skuId,
- p.bin AS bin,
- p.pack_type AS packType,
- p.bin_group AS binGroup,
- SUM(p.qty) AS qty,
- MAX(s.name) AS productName,
+ SELECT p.sku_id AS skuId,
+ SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN})
+ THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS pickQty,
+ SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${STORE_PREFIX_IN})
+ THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS storageQty,
+ MAX(s.name) AS productName,
MAX(s.supplier_name) AS supplier
FROM rpt.jst_packInventoryInfo p
LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id
WHERE p.wms_co_id = @wmsCoId
- GROUP BY p.sku_id, p.bin, p.pack_type, p.bin_group`;
+ AND ISNULL(p.bin, '') <> ''
+ AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
+ GROUP BY p.sku_id`;
return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 });
}
-/** 近15天日销(同仓最新快照),只用于算目标库存与排序 */
-async function loadDailySaleMap(warehouse) {
+/** 该仓最近的订单日期(线上 + 全民客户 取最大 send_date 日期) */
+async function loadOrderDate(warehouse) {
const rows = await cloudDb.query(
- `SELECT sku_id AS skuId, fifteenAvgSale AS avgSale
- FROM rpt.pm_stockMonitor
- WHERE warehouse = @warehouseName
- AND CONVERT(varchar(10), date, 23) = (
- SELECT CONVERT(varchar(10), MAX(date), 23)
- FROM rpt.pm_stockMonitor WHERE warehouse = @warehouseName
- )`,
- { warehouseName: warehouse.name },
+ `SELECT MAX(d) AS d FROM (
+ SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d
+ FROM rpt.jst_Orders WHERE wms_co_id = @wmsCoId
+ UNION ALL
+ SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d
+ FROM rpt.jst_qmcust_orderlist WHERE wms_co_id = @wmsCoId
+ ) t`,
+ { wmsCoId: warehouse.wmsCoId },
+ { requestTimeout: 120000 }
+ );
+ return (rows && rows[0] && rows[0].d) || null;
+}
+
+/** 当日(订单日期)各 SKU 的订单量:4 个来源分支,组合装展开为子件。 */
+async function loadTodayOrderMap(warehouse, orderDate) {
+ if (!orderDate) return new Map();
+ const { shipFrom, shipTo } = orderWindow(orderDate);
+ const sql = `
+ WITH ship AS (
+ -- 1) 线上店铺订单:普通商品行
+ SELECT oi.sku_id AS skuId,
+ SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
+ FROM rpt.jst_OrderItem oi
+ JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
+ WHERE o.wms_co_id = @wmsCoId
+ AND o.send_date >= @shipFrom AND o.send_date < @shipTo
+ AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
+ AND ISNULL(oi.sku_type, 'normal') <> 'combine'
+ GROUP BY oi.sku_id
+ UNION ALL
+ -- 2) 线上店铺订单:组合装行 → 展开为子件
+ SELECT ci.src_sku_id AS skuId,
+ SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
+ FROM rpt.jst_OrderItem oi
+ JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
+ JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
+ WHERE o.wms_co_id = @wmsCoId
+ AND o.send_date >= @shipFrom AND o.send_date < @shipTo
+ AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
+ AND oi.sku_type = 'combine'
+ GROUP BY ci.src_sku_id
+ UNION ALL
+ -- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
+ SELECT oi.sku_id AS skuId,
+ SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
+ FROM rpt.jst_qmcust_orderlist_item oi
+ JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
+ WHERE o.wms_co_id = @wmsCoId
+ AND o.send_date >= @shipFrom AND o.send_date < @shipTo
+ AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
+ AND ISNULL(oi.sku_type, 'normal') <> 'combine'
+ GROUP BY oi.sku_id
+ UNION ALL
+ -- 4) 全民客户订单:组合装行 → 展开为子件
+ SELECT ci.src_sku_id AS skuId,
+ SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
+ FROM rpt.jst_qmcust_orderlist_item oi
+ JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
+ JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
+ WHERE o.wms_co_id = @wmsCoId
+ AND o.send_date >= @shipFrom AND o.send_date < @shipTo
+ AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
+ AND oi.sku_type = 'combine'
+ GROUP BY ci.src_sku_id
+ )
+ SELECT skuId, SUM(orderQty) AS orderQty
+ FROM ship
+ GROUP BY skuId`;
+
+ const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
+ const rows = await runQuery(
+ sql,
+ { wmsCoId: warehouse.wmsCoId, shipFrom, shipTo },
{ requestTimeout: 300000 }
);
const map = new Map();
(rows || []).forEach((row) => {
const skuId = toText(row.skuId, 120);
- if (skuId) map.set(skuId, toNumber(row.avgSale));
+ if (skuId) map.set(skuId, toNumber(row.orderQty));
});
return map;
}
// ── 组装 ────────────────────────────────────────────────────────────
-function buildSkuRows({ replicaRows, pickBinSet, dailySaleMap, targetDays, useFallback }) {
- const skuMap = new Map();
-
- (replicaRows || []).forEach((row) => {
+function buildSkuRows({ invRows, orderMap, orderDate }) {
+ const rows = [];
+ (invRows || []).forEach((row) => {
const skuId = toText(row.skuId, 120);
if (!skuId) return;
- const bin = normalizeBin(row.bin);
- const qty = toNumber(row.qty);
- const isPick = useFallback
- // 默认口径与「拣选库位动销分析」一致:真实拣选位 = pack_type='Bin' 且 bin_group 为空
- // (bin_group='存货' 与 pack_type='Pack' 都是存货,不算拣选位)
- ? (toText(row.packType) === 'Bin' && !toText(row.binGroup))
- : (bin ? pickBinSet.has(bin) : false);
+ const pickQty = round2(row.pickQty);
+ const storageQty = round2(row.storageQty);
+ const orderQty = round2(toNumber(orderMap.get(skuId), 0));
+ const excess = Math.max(0, round2(orderQty - pickQty));
- let entry = skuMap.get(skuId);
- if (!entry) {
- entry = {
- skuId,
- productName: toText(row.productName, 300) || '',
- supplier: toText(row.supplier, 120) || '',
- pickQty: 0,
- storageQty: 0,
- pickBins: [],
- storageBins: [],
- };
- skuMap.set(skuId, entry);
+ let status = STATUS.NO_ORDER;
+ let suggestQty = 0;
+ let shortfall = 0;
+ let pickEmpty = false;
+
+ if (orderQty > 0) {
+ if (pickQty <= 0) pickEmpty = true;
+ if (excess > 0) {
+ const can = Math.min(excess, storageQty);
+ suggestQty = round2(can);
+ if (storageQty >= excess) {
+ status = STATUS.NEED;
+ } else {
+ status = STATUS.SHORT;
+ shortfall = round2(excess - storageQty);
+ }
+ } else {
+ status = STATUS.ENOUGH;
+ }
}
- if (!entry.productName) entry.productName = toText(row.productName, 300) || '';
- if (!entry.supplier) entry.supplier = toText(row.supplier, 120) || '';
-
- if (isPick) {
- entry.pickQty += qty;
- entry.pickBins.push({ bin: bin || '未维护', qty });
- } else {
- entry.storageQty += qty;
- entry.storageBins.push({ bin: bin || '未维护', qty });
- }
- });
-
- const rows = [];
- skuMap.forEach((entry) => {
- const avgSale = toNumber(dailySaleMap.get(entry.skuId), 0);
- const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(avgSale * targetDays));
- const pickQty = round2(entry.pickQty);
- const storageQty = round2(entry.storageQty);
-
- let status = STATUS.OK;
- if (entry.pickBins.length === 0) {
- // 这个 SKU 压根没有拣货位(整箱直发 / 未维护)——不是「拣货位缺货」,
- // 不能混进待补货清单,否则会刷出几千条误报。
- status = STATUS.NO_PICK;
- } else if (pickQty <= 0) {
- status = storageQty > 0 ? STATUS.PICK_OUT : STATUS.EMPTY;
- } else if (pickQty < targetQty && storageQty > 0) {
- status = STATUS.LOW;
- }
-
- const suggestQty = status === STATUS.PICK_OUT || status === STATUS.LOW
- ? Math.max(0, Math.min(Math.ceil(storageQty), Math.ceil(targetQty - pickQty)))
- : 0;
-
- const pickBins = entry.pickBins
- .slice()
- .sort((a, b) => b.qty - a.qty || a.bin.localeCompare(b.bin));
- const storageBins = entry.storageBins
- .filter((item) => item.qty > 0)
- .sort((a, b) => b.qty - a.qty)
- .slice(0, 5);
rows.push({
- skuId: entry.skuId,
- productName: entry.productName,
- supplier: entry.supplier,
+ skuId,
+ productName: toText(row.productName, 300) || '',
+ supplier: toText(row.supplier, 120) || '',
status,
statusLabel: STATUS_LABELS[status],
pickQty,
storageQty,
totalQty: round2(pickQty + storageQty),
- targetQty,
+ orderQty,
+ excess,
suggestQty,
- avgSale: round2(avgSale),
- pickBinCount: entry.pickBins.length,
- pickBins,
- storageBins,
- hasPickSlot: entry.pickBins.length > 0,
+ shortfall,
+ pickEmpty,
});
});
-
return rows;
}
-/** 按页面传入的目标天数重算目标库存与建议补货量(缓存里存的是默认 7 天口径) */
-function applyTargetDays(rows, targetDays) {
- if (targetDays === DEFAULT_TARGET_DAYS) return rows;
- return rows.map((row) => {
- const targetQty = Math.max(MIN_TARGET_QTY, Math.ceil(row.avgSale * targetDays));
- const suggestQty = (row.status === STATUS.PICK_OUT || row.status === STATUS.LOW)
- ? Math.max(0, Math.min(Math.ceil(row.storageQty), Math.ceil(targetQty - row.pickQty)))
- : 0;
- return { ...row, targetQty, suggestQty };
- });
-}
-
-function buildSummary(rows, meta) {
+function buildSummary(rows, orderSkuCount) {
const summary = {
skuTotal: rows.length,
- pickout: 0,
- empty: 0,
- low: 0,
- nopick: 0,
- ok: 0,
+ need: 0,
+ short: 0,
+ enough: 0,
+ noOrder: 0,
+ pickEmpty: 0,
suggestQtyTotal: 0,
- pickoutQtyTotal: 0,
- storageQtyTotal: 0,
- withPickSlot: 0,
- source: meta.source,
- pickBinCount: meta.pickBinCount,
+ orderSkuCount: orderSkuCount || 0,
+ orderQtyTotal: 0,
+ exceedTotal: 0,
+ shortfallTotal: 0,
+ pickTotal: 0,
+ storageTotal: 0,
};
rows.forEach((row) => {
if (summary[row.status] !== undefined) summary[row.status] += 1;
summary.suggestQtyTotal += row.suggestQty || 0;
- summary.storageQtyTotal += row.storageQty || 0;
- if (row.status === STATUS.PICK_OUT) summary.pickoutQtyTotal += row.suggestQty || 0;
- if (row.hasPickSlot) summary.withPickSlot += 1;
+ summary.orderQtyTotal += row.orderQty || 0;
+ summary.exceedTotal += row.excess || 0;
+ summary.shortfallTotal += row.shortfall || 0;
+ summary.pickTotal += row.pickQty || 0;
+ summary.storageTotal += row.storageQty || 0;
+ if (row.pickEmpty) summary.pickEmpty += 1;
});
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
- summary.pickoutQtyTotal = round2(summary.pickoutQtyTotal);
- summary.storageQtyTotal = round2(summary.storageQtyTotal);
+ summary.orderQtyTotal = round2(summary.orderQtyTotal);
+ summary.exceedTotal = round2(summary.exceedTotal);
+ summary.shortfallTotal = round2(summary.shortfallTotal);
+ summary.pickTotal = round2(summary.pickTotal);
+ summary.storageTotal = round2(summary.storageTotal);
return summary;
}
async function loadDataset(warehouse, { refresh = false } = {}) {
- const cacheKey = warehouse.key;
+ const orderDate = await loadOrderDate(warehouse);
+ // 把订单日期纳入缓存键:跨天后自动失效,无需等 TTL
+ const cacheKey = `${warehouse.key}::${orderDate || 'none'}`;
const cached = cacheStore.get(cacheKey);
const ttl = resolveCacheTtlMs();
const now = Date.now();
@@ -466,31 +340,21 @@ async function loadDataset(warehouse, { refresh = false } = {}) {
}
const epoch = cacheEpoch;
- const [replicaRows, pickBins, dailySaleMap] = await Promise.all([
- loadReplicaRows(warehouse),
- loadPickBinRows(warehouse.key),
- loadDailySaleMap(warehouse).catch(() => new Map()),
+ const [invRows, orderMap] = await Promise.all([
+ loadReplicaInventory(warehouse),
+ loadTodayOrderMap(warehouse, orderDate).catch(() => new Map()),
]);
- const pickBinSet = new Set(pickBins.map((row) => normalizeBin(row.binCode)).filter(Boolean));
- const useFallback = pickBinSet.size === 0;
- const rows = buildSkuRows({
- replicaRows,
- pickBinSet,
- dailySaleMap,
- targetDays: DEFAULT_TARGET_DAYS,
- useFallback,
- });
+ const rows = buildSkuRows({ invRows, orderMap, orderDate });
const dataset = {
at: now,
rows,
warehouse,
+ orderDate,
meta: {
- source: useFallback ? 'fallback' : 'import',
- pickBinCount: pickBinSet.size,
- dailySaleSkus: dailySaleMap.size,
rowCount: rows.length,
+ orderSkuCount: orderMap.size,
},
};
// 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据
@@ -507,10 +371,9 @@ function invalidateCache(warehouseKey) {
function matchesStatus(row, status) {
const key = toText(status, 20).toLowerCase();
if (!key || key === 'all') return true;
- if (key === 'action' || key === 'todo') {
- return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW || row.status === STATUS.EMPTY;
+ if (key === 'action' || key === 'todo' || key === 'replenish') {
+ return row.status === STATUS.NEED || row.status === STATUS.SHORT;
}
- if (key === 'replenish') return row.status === STATUS.PICK_OUT || row.status === STATUS.LOW;
return row.status === key;
}
@@ -518,6 +381,7 @@ function sortRows(rows) {
return rows.slice().sort((a, b) => (
(STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0)
|| (b.suggestQty || 0) - (a.suggestQty || 0)
+ || (b.excess || 0) - (a.excess || 0)
|| String(a.skuId).localeCompare(String(b.skuId))
));
}
@@ -535,18 +399,9 @@ function filterRows(rows, { keyword, status }) {
async function getReplenishList(options = {}) {
const warehouse = resolveWarehouse(options.warehouse);
- const targetDays = resolveTargetDays(options.targetDays);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
-
- // targetDays 是页面参数,不进缓存:缓存的是原始聚合,这里按参数重算目标/建议量
- const rows = applyTargetDays(dataset.rows, targetDays);
-
- const filtered = sortRows(filterRows(rows, options));
- const pickBinMeta = await getPickBinMeta(warehouse.key);
- const summary = buildSummary(rows, {
- source: pickBinMeta.source,
- pickBinCount: pickBinMeta.count,
- });
+ const filtered = sortRows(filterRows(dataset.rows, options));
+ const summary = buildSummary(dataset.rows, dataset.meta.orderSkuCount);
const page = Math.max(1, Number.parseInt(options.page, 10) || 1);
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(options.pageSize, 10) || 50));
const start = (page - 1) * pageSize;
@@ -559,31 +414,25 @@ async function getReplenishList(options = {}) {
summary,
warehouse,
warehouses: Object.values(WAREHOUSES),
- targetDays,
+ orderDate: dataset.orderDate,
cacheHit: dataset.cacheHit,
updatedAt: new Date(dataset.at).toISOString(),
cacheTtlMs: resolveCacheTtlMs(),
- pickBinMeta,
- dailySaleSkus: dataset.meta.dailySaleSkus,
};
}
async function getReplenishExportRows(options = {}) {
const warehouse = resolveWarehouse(options.warehouse);
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
- const targetDays = resolveTargetDays(options.targetDays);
- const rows = applyTargetDays(dataset.rows, targetDays);
return {
warehouse,
- rows: sortRows(filterRows(rows, options)),
- summary: buildSummary(rows, {
- source: dataset.meta.source,
- pickBinCount: dataset.meta.pickBinCount,
- }),
+ orderDate: dataset.orderDate,
+ rows: sortRows(filterRows(dataset.rows, options)),
+ summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount),
};
}
-/** 启动预热:视图查询 35~45s,不能让首个用户请求等 */
+/** 启动预热:库存视图 + 当日订单 union 各自 30s~1min,不能让首个用户请求等 */
async function warmBinReplenishCache() {
try {
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];
@@ -597,16 +446,15 @@ async function warmBinReplenishCache() {
module.exports = {
DEFAULT_WAREHOUSE_KEY,
+ PICK_PREFIXES,
+ STORE_PREFIXES,
STATUS,
STATUS_LABELS,
WAREHOUSES,
- clearPickBins,
- getPickBinMeta,
+ clearPickBins: async () => ({ removed: 0 }),
getReplenishExportRows,
getReplenishList,
- importPickBins,
loadDataset,
- normalizeBin,
resolveWarehouse,
resolveWarehouseKey,
warmBinReplenishCache,
diff --git a/frontend/src/api/binReplenish.js b/frontend/src/api/binReplenish.js
index 0c2f050..dd33343 100644
--- a/frontend/src/api/binReplenish.js
+++ b/frontend/src/api/binReplenish.js
@@ -2,8 +2,8 @@ import request from './request';
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
-// 缺货补货监控(拣货位缺货 → 从存货位补货)
-// params.warehouse: changzhou / wh7 / suining / henglin2 / subei / henglin3(缺省 = 常州主仓)
+// 缺货补货监控(订单驱动版)
+// 判断口径:当日订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
export const getBinReplenishList = (params = {}) => request.get(
'/api/bin-replenish/list',
{ params, timeout: LONG_TIMEOUT_MS }
@@ -18,23 +18,3 @@ export const exportBinReplenish = (params = {}) => request.get(
silentError: true,
}
);
-
-export const getPickBinMeta = (warehouse) => request.get(
- '/api/bin-replenish/pick-bins',
- { params: warehouse ? { warehouse } : {} }
-);
-
-export const importPickBins = (file, warehouse) => {
- const formData = new FormData();
- formData.append('file', file);
- if (warehouse) formData.append('warehouse', warehouse);
- return request.post('/api/bin-replenish/pick-bins', formData, {
- headers: { 'Content-Type': 'multipart/form-data' },
- timeout: LONG_TIMEOUT_MS,
- });
-};
-
-export const clearPickBins = (warehouse) => request.delete(
- '/api/bin-replenish/pick-bins',
- { params: warehouse ? { warehouse } : {} }
-);
diff --git a/frontend/src/pages/BinReplenishMonitor/index.jsx b/frontend/src/pages/BinReplenishMonitor/index.jsx
index 292c3fc..aadd4a8 100644
--- a/frontend/src/pages/BinReplenishMonitor/index.jsx
+++ b/frontend/src/pages/BinReplenishMonitor/index.jsx
@@ -1,14 +1,12 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
- Alert, Button, Empty, Input, InputNumber, Popconfirm, Segmented, Select, Spin, Table, Tag, Tooltip, Upload,
+ Alert, Button, Empty, Input, Segmented, Select, Spin, Table, Tag, Tooltip,
message,
} from 'antd';
import {
- DeleteOutlined, DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UploadOutlined,
+ DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined,
} from '@ant-design/icons';
-import {
- clearPickBins, exportBinReplenish, getBinReplenishList, importPickBins,
-} from '../../api/binReplenish';
+import { exportBinReplenish, getBinReplenishList } from '../../api/binReplenish';
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
import { SycmPage } from '../../components/Common';
import './style.css';
@@ -18,48 +16,24 @@ const num = (value) => (value === null || value === undefined || value === ''
: Number(value).toLocaleString('zh-CN'));
const STATUS_TAGS = {
- pickout: { color: 'red', label: '拣货位缺货' },
- empty: { color: 'default', label: '无货可补' },
- low: { color: 'orange', label: '拣货位偏低' },
- nopick: { color: 'blue', label: '未设拣货位' },
- ok: { color: 'green', label: '正常' },
+ short: { color: 'red', label: '存储不足' },
+ need: { color: 'gold', label: '需补货' },
+ enough: { color: 'green', label: '拣货位充足' },
+ no_order: { color: 'default', label: '今日无单' },
};
const STATUS_OPTIONS = [
{ label: '待补货', value: 'replenish' },
- { label: '无货可补', value: 'empty' },
- { label: '未设拣货位', value: 'nopick' },
+ { label: '存储不足', value: 'short' },
+ { label: '拣货位充足', value: 'enough' },
+ { label: '今日无单', value: 'no_order' },
{ label: '全部', value: 'all' },
];
-const DEFAULT_TARGET_DAYS = 7;
-
-const renderBinTags = (bins = [], emptyText = '未维护', limit = 3) => {
- if (!bins.length) return {emptyText};
- const visible = bins.slice(0, limit);
- const rest = bins.length - visible.length;
- return (
-
- {visible.map((item) => (
-
- 盯的是拣货位:拣货位没货就拣不出来,需要从存货位把货补过去。 - 状态、建议补货量都按当前库存实时算,数据来自聚水潭「箱及仓位库存」。 + 盯的是拣货位:今天下的订单,如果拣货位库存不够拣,超出的那部分就要从存储位补到拣货位。 + 库存取自聚水潭「箱及仓位库存」,订单取自崔桥工厂仓当日订单。