Compare commits
16 Commits
codex/luna
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| db1185c19f | |||
| 4e4f8890c1 | |||
| 8c96877244 | |||
| a0b075dfdd | |||
| 88913260a3 | |||
| fe195114e4 | |||
| 1957330ebd | |||
| c374cbc713 | |||
| e9923d7383 | |||
| 9991b92325 | |||
| 9b1ec36c70 | |||
| 7939816b64 | |||
| b223db7961 | |||
| 57debbe022 | |||
| 03fd3f97f3 | |||
| f786a92387 |
@ -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 } });
|
||||
|
||||
@ -9,6 +9,13 @@
|
||||
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
|
||||
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
|
||||
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
|
||||
* 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。
|
||||
* ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。
|
||||
* ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。
|
||||
* ⚠️ 明细**按仓聚集**:同一个仓的行相邻排在一起,组间按该仓异常包裹数降序,
|
||||
* 组内保持服务层时长降序(业务 10-22 要求,方便各仓在群里只看自己那段)。
|
||||
* ⚠️ 取前 N 条时**每仓保底 1 条**(剩余额度按各仓异常数比例分),
|
||||
* 否则异常最多的仓会把额度吃满、其它仓一条都看不到(10-22 真数据踩到过)。
|
||||
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
|
||||
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
|
||||
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
|
||||
@ -74,34 +81,97 @@ function cleanCell(value, fallback = '') {
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
/** '2026-10-21 16:00:00' → '10-21 16:00';空值给「—」 */
|
||||
function shortTime(value) {
|
||||
const text = cleanCell(value);
|
||||
if (!text) return '—';
|
||||
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
|
||||
if (!match) return text.slice(0, 16);
|
||||
return `${match[2]}-${match[3]} ${match[4]}:${match[5]}`;
|
||||
}
|
||||
|
||||
function formatHours(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '—';
|
||||
return `${Math.round(num * 10) / 10}h`;
|
||||
}
|
||||
|
||||
/** 明细一行:仓|内部单号|快递|异常类型|发货时间|待处理 */
|
||||
/**
|
||||
* 明细一行:仓|快递单号|快递|异常类型
|
||||
* ⚠️ 业务明确要求明细**不带发货时间**(10-22 反馈),别再加 sendTime 回去 ——
|
||||
* 仓库看单子只需知道「哪个单、什么异常」,时间类信息一律到页面上看。
|
||||
* ⚠️ 第二段用**快递单号**(`trackingNo` = 上游 `logistic_id`),不是内部单号 `oId`(10-22 二次要求)。
|
||||
* 「未揽收 / 无物流单」这类包裹本来就没有快递单号 → 退回内部单号,否则群里这条无法定位。
|
||||
* ⚠️ 末尾的「待处理小时数」(`pendingHours`)也已按 10-22 三次要求去掉,别再加回来。
|
||||
*/
|
||||
function buildDetailLine(row, index) {
|
||||
const parts = [
|
||||
cleanCell(row?.warehouseName, '未知仓'),
|
||||
cleanCell(row?.oId, '无单号'),
|
||||
cleanCell(row?.trackingNo || row?.oId, '无单号'),
|
||||
cleanCell(row?.carrier, '未知快递'),
|
||||
cleanCell(row?.exceptionLabel, '异常'),
|
||||
shortTime(row?.sendTime),
|
||||
formatHours(row?.pendingHours),
|
||||
];
|
||||
return `${index}. ${parts.join('|')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按仓分桶:组间按异常包裹数降序(与上面「按仓(异常包裹)」块一致),数量相同按首次出现顺序。
|
||||
* 组内保持服务层原顺序(已按 pendingHours 降序,压得最久的排最前),别在这里再排。
|
||||
*/
|
||||
function bucketRowsByWarehouse(rows) {
|
||||
const buckets = new Map();
|
||||
const firstSeen = new Map();
|
||||
(Array.isArray(rows) ? rows : []).forEach((row, index) => {
|
||||
const key = cleanCell(row?.warehouseName, '未知仓');
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, []);
|
||||
firstSeen.set(key, index);
|
||||
}
|
||||
buckets.get(key).push(row);
|
||||
});
|
||||
|
||||
return Array.from(buckets.entries())
|
||||
.map(([key, items]) => ({ key, items, index: firstSeen.get(key) }))
|
||||
.sort((a, b) => b.items.length - a.items.length || a.index - b.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细按仓聚集:同一个仓的行排在一起(10-22 业务要求,便于各仓在群里只看自己那一段)。
|
||||
* - 组间:按该仓异常包裹数降序;组内:保持服务层原顺序(时长降序)。
|
||||
* - 仓名取 `warehouseName`,缺失归到「未知仓」一组,避免 undefined 行被拆成散行。
|
||||
*/
|
||||
function groupRowsByWarehouse(rows) {
|
||||
return bucketRowsByWarehouse(rows).flatMap((group) => group.items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按仓分配明细额度,返回**已按仓聚集**的行。
|
||||
* ⚠️ 不能直接 `rows.slice(0, N)`:某个仓异常特别多时会一口气吃满额度,
|
||||
* 真数据就出现过 40 条明细全是一个仓、其它仓一条都看不到(10-22 实测)。
|
||||
* 规则:
|
||||
* ① 仓数 ≥ 额度 → 每仓 1 条,砍掉的是异常最少的仓;
|
||||
* ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法,保证总数正好等于额度)。
|
||||
*/
|
||||
function pickDetailRows(rows, limit) {
|
||||
const groups = bucketRowsByWarehouse(rows);
|
||||
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
|
||||
const quota = Math.max(0, Math.min(Math.round(Number(limit) || 0), total));
|
||||
|
||||
if (quota === 0) return [];
|
||||
if (quota >= total) return groups.flatMap((group) => group.items);
|
||||
if (groups.length >= quota) return groups.slice(0, quota).map((group) => group.items[0]);
|
||||
|
||||
const alloc = groups.map((group) => ({ group, take: 1, left: group.items.length - 1 }));
|
||||
const remaining = quota - groups.length;
|
||||
const leftTotal = alloc.reduce((sum, item) => sum + item.left, 0);
|
||||
|
||||
if (remaining > 0 && leftTotal > 0) {
|
||||
alloc.forEach((item) => {
|
||||
item.exact = (item.left / leftTotal) * remaining;
|
||||
item.extra = Math.min(item.left, Math.floor(item.exact));
|
||||
});
|
||||
let assigned = alloc.reduce((sum, item) => sum + item.extra, 0);
|
||||
// 取整剩下的零星额度给「小数部分最大」的仓,避免白浪费额度
|
||||
[...alloc]
|
||||
.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)))
|
||||
.forEach((item) => {
|
||||
if (assigned >= remaining || item.extra >= item.left) return;
|
||||
item.extra += 1;
|
||||
assigned += 1;
|
||||
});
|
||||
alloc.forEach((item) => {
|
||||
item.take += item.extra;
|
||||
});
|
||||
}
|
||||
|
||||
return alloc.flatMap((item) => item.group.items.slice(0, item.take));
|
||||
}
|
||||
|
||||
/** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/
|
||||
function buildCenterLines(data = {}) {
|
||||
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||||
@ -187,8 +257,9 @@ function buildNotifyText(data = {}, options = {}) {
|
||||
}
|
||||
|
||||
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||||
const detailAll = rows.map((row, index) => buildDetailLine(row, index + 1));
|
||||
const wantCount = Math.min(maxDetailRows, detailAll.length);
|
||||
// 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序
|
||||
const detailAll = pickDetailRows(rows, maxDetailRows).map((row, index) => buildDetailLine(row, index + 1));
|
||||
const wantCount = detailAll.length;
|
||||
|
||||
// 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出)
|
||||
const buildTail = (listed) => {
|
||||
@ -425,9 +496,10 @@ module.exports = {
|
||||
buildTypeLine,
|
||||
chinaDateKey,
|
||||
chinaHourMinute,
|
||||
groupRowsByWarehouse,
|
||||
pickDetailRows,
|
||||
cleanCell,
|
||||
pushWarehouseLogisticsExceptionNotify,
|
||||
runWarehouseLogisticsExceptionNotifyJob,
|
||||
shortTime,
|
||||
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||
};
|
||||
|
||||
@ -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,215 @@ 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,
|
||||
};
|
||||
const STATUS_SUM_KEY = { need: 'need', short: 'short', enough: 'enough', no_order: 'noOrder' };
|
||||
rows.forEach((row) => {
|
||||
if (summary[row.status] !== undefined) summary[row.status] += 1;
|
||||
const key = STATUS_SUM_KEY[row.status];
|
||||
if (key && summary[key] !== undefined) summary[key] += 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 +342,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 +373,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 +383,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 +401,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 +416,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 +448,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,
|
||||
|
||||
@ -307,8 +307,30 @@ const CREATED_BACKFILL_BATCH = 200;
|
||||
// 单子早随主单发走了,状态表还停在「发货中」。
|
||||
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
|
||||
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
|
||||
// 🔥 2026-10-22 补 `Question` —— **但不能塞进这个数组**:
|
||||
// 这个数组在「状态分类之前」就判定,会把状态表「异常」的单(订单表常是 Question)
|
||||
// 从「异常待处理」清单里一起吞掉(实测 heldCount 472 → 10,少掉 462 单)。
|
||||
// ⟹ Question 单独在 `state === '发货中'` 之后判,见 VOID_QUESTION_STATUS。
|
||||
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
|
||||
|
||||
// 订单表 status=Question(聚水潭后台显示「已作废/问题单」)—— 业务 2026-10-22 确认:
|
||||
// so_id 3655421011854634(9823980) / 3658479011191285(9880810) 在后台都已作废,
|
||||
// 而状态表 order_status_name 仍停在「发货中」+ send_time 空、售后「暂无售后」
|
||||
// ⟹ 上游状态没同步,只信状态表判不出来。仅在「发货中」分支生效,不影响异常清单。
|
||||
const VOID_QUESTION_STATUS = 'Question';
|
||||
|
||||
// 订单表 `labels` 里出现这些关键词,等价于「客户发货前退款/取消」= 作废,不考核仓库。
|
||||
// 实测 9823980 的 labels = 「发货前售后,大单待确认,店铺-京东,晚发赔,禁止拆分」;
|
||||
// 已取消单(order_status_name='已取消')几乎全都带这个标签,可作交叉验证。
|
||||
// ⚠️ `labels` 是长文本列,回订单表补它会让 backfill 变慢(见 service 里的投影说明),
|
||||
// 所以只投影一个 `CASE WHEN ... THEN 1 ELSE 0 END` 布尔列,不把原文传回来。
|
||||
const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
|
||||
|
||||
// 状态表 `aftersale_status_name` 的「已作废」—— 售后维度最权威的作废标记。
|
||||
// 目前带这个值的单在订单表基本都是 Sent(已被其它判据剔除),这里是防御性兜底:
|
||||
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
|
||||
const AFTERSALE_VOID_STATUS = '已作废';
|
||||
|
||||
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||
@ -554,6 +576,9 @@ module.exports = {
|
||||
CREATED_SOURCE_TABLES,
|
||||
CREATED_BACKFILL_BATCH,
|
||||
VOID_ORDER_STATUS,
|
||||
VOID_QUESTION_STATUS,
|
||||
VOID_ORDER_LABEL_KEYWORDS,
|
||||
AFTERSALE_VOID_STATUS,
|
||||
SHIP_TIMELINESS_HOURS,
|
||||
toNumber,
|
||||
};
|
||||
|
||||
@ -129,9 +129,17 @@ const {
|
||||
CREATED_SOURCE_TABLES,
|
||||
CREATED_BACKFILL_BATCH,
|
||||
VOID_ORDER_STATUS,
|
||||
VOID_QUESTION_STATUS,
|
||||
VOID_ORDER_LABEL_KEYWORDS,
|
||||
AFTERSALE_VOID_STATUS,
|
||||
resolveCenter,
|
||||
} = registry;
|
||||
|
||||
// `labels` 里命中任一关键词即视为作废(SQL 侧用 LIKE 拼,避免把长文本传回 Node)
|
||||
const VOID_LABEL_SQL = VOID_ORDER_LABEL_KEYWORDS
|
||||
.map((k) => `labels LIKE N'%${k}%'`)
|
||||
.join(' OR ');
|
||||
|
||||
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
|
||||
const DEFAULT_DETAIL_LIMIT = 500;
|
||||
const MAX_DETAIL_LIMIT = 2000;
|
||||
@ -224,7 +232,7 @@ async function loadPendingStates(round) {
|
||||
// mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原
|
||||
// 就是库里存的北京时间,不会偏移 8 小时。
|
||||
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
|
||||
e.order_status_name, e.logistic_status_name,
|
||||
e.order_status_name, e.aftersale_status_name, e.logistic_status_name,
|
||||
e.logistic_id, e.logistic_company_name,
|
||||
e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time,
|
||||
e.send_time, e.query_time
|
||||
@ -293,7 +301,8 @@ async function backfillOrderFacts(oids) {
|
||||
const results = await mapLimit(batches, 4, async (batch) => {
|
||||
try {
|
||||
return await cloudDatabase.querySlow(
|
||||
`SELECT o_id, created, status, send_date, is_merge, is_split
|
||||
`SELECT o_id, created, status, send_date, is_merge, is_split,
|
||||
CASE WHEN ${VOID_LABEL_SQL} THEN 1 ELSE 0 END AS presaleVoid
|
||||
FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`,
|
||||
{},
|
||||
{ connectRequestTimeoutMs: 300000 }
|
||||
@ -314,10 +323,15 @@ async function backfillOrderFacts(oids) {
|
||||
createdAt: formatDateTime(r.created),
|
||||
status,
|
||||
sendDate,
|
||||
// 作废:被合并 / 被拆分 / 已取消 —— 这单不会自己发货
|
||||
// 作废(硬):被合并 / 被拆分 / 已取消 —— 这单不会自己发货,状态分类前就剔除
|
||||
void: VOID_ORDER_STATUS.includes(status)
|
||||
|| Number(r.is_merge) === 1
|
||||
|| Number(r.is_split) === 1,
|
||||
// 作废(软):问题单 Question / 标签含「发货前售后」。
|
||||
// ⚠️ 只能作用于「发货中」分支(见主循环),否则状态表「异常」的单
|
||||
// 在订单表常也是 Question,会被一起吞掉 —— 实测 heldCount 472 → 10。
|
||||
question: status === VOID_QUESTION_STATUS,
|
||||
presaleVoid: Number(r.presaleVoid) === 1,
|
||||
// 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。
|
||||
// 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。
|
||||
shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status),
|
||||
@ -569,6 +583,19 @@ async function getShipTimeliness(options = {}) {
|
||||
|
||||
if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示
|
||||
|
||||
// 🔥 2026-10-22:状态表说「发货中」,但订单表/售后维度已有作废证据 → 不考核。
|
||||
// ⚠️ 必须放在「发货中」之后:订单表 Question 的单里有一大批在状态表是「异常」,
|
||||
// 若在状态分类前判,会把 462 单「异常待处理」从 heldOrders 里吞掉。
|
||||
// 三类证据(业务 2026-10-22 确认「聚水潭后台已作废却仍算超时」):
|
||||
// · 状态表 aftersale_status_name =「已作废」(售后维度最权威)
|
||||
// · 订单表 status = Question(聚水潭后台显示「已作废/问题单」)—— 9823980 / 9880810
|
||||
// · 订单表 labels 含「发货前售后」(客户发货前退款/取消)—— 9823980 也带
|
||||
if (String(row.aftersale_status_name || '').trim() === AFTERSALE_VOID_STATUS
|
||||
|| (fact && (fact.question || fact.presaleVoid))) {
|
||||
voidCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛)
|
||||
if (!benchmarkAt) {
|
||||
bucket.heldCount += 1;
|
||||
|
||||
@ -118,10 +118,11 @@ test('群消息正文:抬头带「异常」关键词、数字与分类型、
|
||||
assert.equal(built.truncated, false);
|
||||
});
|
||||
|
||||
test('明细行:仓|内部单号|快递|异常类型|发货时间|待处理,字段里换行要压平', () => {
|
||||
test('明细行:仓|快递单号|快递|异常类型(4 段,不带发货时间和待处理时长),字段里换行要压平', () => {
|
||||
const line = job.buildDetailLine({
|
||||
warehouseName: '崔桥工厂仓',
|
||||
oId: '9845001',
|
||||
trackingNo: 'JDVA00123456789',
|
||||
carrier: '京东快递',
|
||||
exceptionLabel: '已揽收未中转',
|
||||
sendTime: '2026-10-21 16:00:00',
|
||||
@ -129,17 +130,119 @@ test('明细行:仓|内部单号|快递|异常类型|发货时间|
|
||||
lastLogisticInfo: '第一行\n第二行',
|
||||
}, 3);
|
||||
|
||||
assert.equal(line, '3. 崔桥工厂仓|9845001|京东快递|已揽收未中转|10-21 16:00|17.3h');
|
||||
assert.equal(line, '3. 崔桥工厂仓|JDVA00123456789|京东快递|已揽收未中转');
|
||||
// 业务要求:第二段是快递单号,不是内部单号
|
||||
assert.ok(!line.includes('9845001'), '明细行不该再显示内部单号');
|
||||
// 业务要求:明细不带发货时间(哪怕传了 sendTime 也不能出现在消息里)
|
||||
assert.ok(!line.includes('10-21'), '明细行不该再带发货时间');
|
||||
assert.ok(!line.includes('16:00'), '明细行不该再带发货时间');
|
||||
// 业务要求:末尾的待处理小时数也去掉(哪怕传了 pendingHours 也不能出现在消息里)
|
||||
assert.ok(!line.includes('17.3h'), '明细行不该再带待处理时长');
|
||||
assert.equal(line.split('|').length, 4, '明细必须正好 4 段');
|
||||
|
||||
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
|
||||
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
|
||||
// 未揽收/无物流单没有快递单号 → 退回内部单号(否则群里这条无法定位)
|
||||
assert.equal(
|
||||
job.buildDetailLine({ oId: '9845002', carrier: '中通' }, 1),
|
||||
'1. 未知仓|9845002|中通|异常'
|
||||
);
|
||||
// 空值兜底:不能让消息里出现「undefined|null」
|
||||
assert.equal(
|
||||
job.buildDetailLine({}, 1),
|
||||
'1. 未知仓|无单号|未知快递|异常|—|—'
|
||||
'1. 未知仓|无单号|未知快递|异常'
|
||||
);
|
||||
});
|
||||
|
||||
test('明细按仓聚集:同仓相邻、组间按异常数降序、组内保持原顺序(时长降序)', () => {
|
||||
const rows = [
|
||||
{ warehouseName: '睢宁仓', trackingNo: 'SN1', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
|
||||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ1', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||||
{ warehouseName: '睢宁仓', trackingNo: 'SN2', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
|
||||
{ warehouseName: '山东青岗集仓', trackingNo: 'SD1', carrier: '山东中通', exceptionLabel: '已揽收未中转' },
|
||||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ2', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ3', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||||
];
|
||||
|
||||
const grouped = job.groupRowsByWarehouse(rows);
|
||||
// 崔桥 3 条 > 睢宁 2 条 > 青岗集 1 条,组间按异常数降序
|
||||
assert.deepEqual(grouped.map((r) => r.trackingNo), ['CQ1', 'CQ2', 'CQ3', 'SN1', 'SN2', 'SD1']);
|
||||
// 不丢行、不改行
|
||||
assert.equal(grouped.length, rows.length);
|
||||
|
||||
const text = job.buildNotifyText(sampleData({ rows }), { maxDetailRows: 30 }).text;
|
||||
// 只取「明细前 N 条」块里的行(上面「按仓」块也是 `1. xxx` 格式,别混进来)
|
||||
const detailBlock = text.split('明细前')[1] || '';
|
||||
const detailLines = detailBlock.split('\n').filter((line) => /^\d+\. /.test(line));
|
||||
const warehouses = detailLines.map((line) => line.split('|')[0].replace(/^\d+\. /, ''));
|
||||
|
||||
// 同仓必须相邻:切成连续段后不应出现重复仓名
|
||||
const runs = warehouses.filter((name, i) => name !== warehouses[i - 1]);
|
||||
assert.equal(new Set(runs).size, runs.length, `同仓没有排在一起:${warehouses.join(' / ')}`);
|
||||
assert.deepEqual(warehouses, ['崔桥工厂仓', '崔桥工厂仓', '崔桥工厂仓', '睢宁仓', '睢宁仓', '山东青岗集仓']);
|
||||
// 编号仍然全局连续,不能每组从 1 开始(否则群里看到重复序号)
|
||||
assert.deepEqual(detailLines.map((line) => line.match(/^(\d+)\./)[1]), ['1', '2', '3', '4', '5', '6']);
|
||||
});
|
||||
|
||||
test('明细按仓聚集:仓名缺失的行归到「未知仓」一组,不会被拆散', () => {
|
||||
const rows = [
|
||||
{ warehouseName: '', trackingNo: 'A1' },
|
||||
{ warehouseName: '睢宁仓', trackingNo: 'B1' },
|
||||
{ trackingNo: 'A2' },
|
||||
];
|
||||
const grouped = job.groupRowsByWarehouse(rows);
|
||||
// 未知仓 2 条(含 undefined)> 睢宁 1 条
|
||||
assert.deepEqual(grouped.map((r) => r.trackingNo), ['A1', 'A2', 'B1']);
|
||||
});
|
||||
|
||||
test('明细额度按仓分配:大仓不能吃满,每仓至少 1 条', () => {
|
||||
// 真数据就是这样:青岗集一家占了大头(10-22 实测 140 条里前 40 条全是它)
|
||||
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
|
||||
warehouseName: name,
|
||||
trackingNo: `${name}-${i}`,
|
||||
}));
|
||||
const rows = [
|
||||
...make('青岗集仓', 100),
|
||||
...make('崔桥工厂仓', 20),
|
||||
...make('睢宁仓', 8),
|
||||
...make('新苏北仓', 6),
|
||||
...make('横林二仓', 6),
|
||||
];
|
||||
|
||||
const picked = job.pickDetailRows(rows, 40);
|
||||
assert.equal(picked.length, 40, '额度必须正好用满(140 条 > 40)');
|
||||
|
||||
const counts = picked.reduce((acc, row) => {
|
||||
acc[row.warehouseName] = (acc[row.warehouseName] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
// 每个仓都要露面,否则群里那个仓的人看不到自己的单
|
||||
for (const name of ['青岗集仓', '崔桥工厂仓', '睢宁仓', '新苏北仓', '横林二仓']) {
|
||||
assert.ok(counts[name] >= 1, `${name} 一条明细都没分到`);
|
||||
}
|
||||
// 大仓仍然拿得多,但不能吃满额度
|
||||
assert.ok(counts['青岗集仓'] < 40, '大仓不该独占全部额度');
|
||||
assert.ok(counts['青岗集仓'] > counts['崔桥工厂仓'], '异常多的仓应分到更多明细');
|
||||
|
||||
// 同仓仍然相邻
|
||||
const names = picked.map((row) => row.warehouseName);
|
||||
const runs = names.filter((name, i) => name !== names[i - 1]);
|
||||
assert.equal(runs.length, new Set(runs).size, `同仓被拆散:${names.join(',')}`);
|
||||
});
|
||||
|
||||
test('明细额度比仓数还少时:按异常数从多到少,每仓只取 1 条', () => {
|
||||
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
|
||||
warehouseName: name,
|
||||
trackingNo: `${name}-${i}`,
|
||||
}));
|
||||
const rows = [...make('大仓', 10), ...make('中仓', 5), ...make('小仓', 2)];
|
||||
const picked = job.pickDetailRows(rows, 2);
|
||||
assert.deepEqual(picked.map((row) => row.trackingNo), ['大仓-0', '中仓-0']);
|
||||
// 额度 0 / 超额度的边界
|
||||
assert.deepEqual(job.pickDetailRows(rows, 0), []);
|
||||
assert.equal(job.pickDetailRows(rows, 99).length, 17);
|
||||
});
|
||||
|
||||
test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => {
|
||||
const data = sampleData({
|
||||
rows: [],
|
||||
|
||||
@ -207,6 +207,13 @@ function stubDb(dbPath, scenario = 'default') {
|
||||
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
|
||||
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
|
||||
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
|
||||
// ── 2026-10-22 作废单三类形态 ──
|
||||
// 38:订单表 Question(聚水潭「已作废/问题单」)
|
||||
38: f('jst_Orders', { created: '2026-10-20 09:10:00', status: 'Question' }),
|
||||
// 39:labels 含「发货前售后」—— stub 直接给布尔结果(真实 SQL 用 CASE WHEN LIKE 转)
|
||||
39: f('jst_Orders', { created: '2026-10-20 10:10:00', presaleVoid: 1 }),
|
||||
// 40:靠状态表 aftersale_status_name='已作废' 剔除,订单表本身没异常
|
||||
40: f('jst_Orders', { created: '2026-10-20 11:10:00' }),
|
||||
};
|
||||
|
||||
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
|
||||
@ -227,6 +234,8 @@ function stubDb(dbPath, scenario = 'default') {
|
||||
shop_name: '测试店',
|
||||
logistic_status_name: '',
|
||||
query_time: '2026-10-20 11:18:27',
|
||||
// 状态表售后状态:默认「暂无售后」;2026-10-22 新增「已作废」判据要用到
|
||||
aftersale_status_name: '暂无售后',
|
||||
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
|
||||
first_oa_confirm_time: o.last_oa_confirm_time ?? null,
|
||||
...o,
|
||||
@ -326,6 +335,19 @@ function stubDb(dbPath, scenario = 'default') {
|
||||
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
|
||||
last_oa_confirm_time: '2026-09-22 09:05:00' }),
|
||||
// ── 2026-10-22 作废单三类形态(业务反馈 3 单在聚水潭后台已作废却仍算超时)──
|
||||
// 38:订单表 status=Question(聚水潭后台的「已作废/问题单」)—— 真实案例 9823980 / 9880810
|
||||
mk({ so_id: 'SO-38', inner_order_id: 38, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 09:10:00',
|
||||
last_oa_confirm_time: '2026-10-20 09:15:00' }),
|
||||
// 39:订单表状态正常,但 labels 含「发货前售后」→ 视同作废 —— 真实案例 9823980
|
||||
mk({ so_id: 'SO-39', inner_order_id: 39, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 10:10:00',
|
||||
last_oa_confirm_time: '2026-10-20 10:15:00' }),
|
||||
// 40:状态表售后状态「已作废」→ 最权威的作废标记,直接剔除
|
||||
mk({ so_id: 'SO-40', inner_order_id: 40, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', aftersale_status_name: '已作废',
|
||||
pay_time: '2026-10-20 11:10:00', last_oa_confirm_time: '2026-10-20 11:15:00' }),
|
||||
];
|
||||
|
||||
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
|
||||
@ -392,6 +414,7 @@ function stubDb(dbPath, scenario = 'default') {
|
||||
send_date: x.send_date,
|
||||
is_merge: x.is_merge,
|
||||
is_split: x.is_split,
|
||||
presaleVoid: x.presaleVoid || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -506,12 +529,13 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
|
||||
// 被排除的仓不能算
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
|
||||
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
|
||||
for (const id of ['9', '10', '11', '12']) {
|
||||
// 38/39/40 = 2026-10-22 新补的三类作废形态(Question / 发货前售后标签 / 售后已作废)
|
||||
for (const id of ['9', '10', '11', '12', '38', '39', '40']) {
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
|
||||
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
|
||||
}
|
||||
// 这 5 单要计进 voidCount(9/10/11/12 作废或已发 + 37 销售订单已发货 Sent)
|
||||
assert.equal(data.summary.voidCount, 5, '作废/已发单要计入剔除数');
|
||||
// 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类)
|
||||
assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数');
|
||||
|
||||
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
|
||||
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
|
||||
@ -561,13 +585,16 @@ test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStub
|
||||
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
|
||||
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
|
||||
|
||||
// 补创建时间:必须走两张订单表,且**只投影 6 个窄列**
|
||||
// 补创建时间:必须走两张订单表;6 个窄列 + 1 个「发货前售后」布尔(不把 labels 原文传回来)
|
||||
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
|
||||
for (const bs of calls.backfillSql) {
|
||||
assert.match(bs, /SELECT o_id, created, status, send_date, is_merge, is_split\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
|
||||
`补订单事实只能取这 6 个窄列:${bs.slice(0, 90)}`);
|
||||
assert.doesNotMatch(bs, /pay_amount|labels|buyer_message|remark|SELECT \*/u,
|
||||
assert.match(bs,
|
||||
/SELECT o_id, created, status, send_date, is_merge, is_split,\s+CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END AS presaleVoid\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
|
||||
`补订单事实只允许 6 个窄列 + 1 个作废布尔:${bs.slice(0, 130)}`);
|
||||
assert.doesNotMatch(bs, /pay_amount|buyer_message|remark|SELECT \*/u,
|
||||
'多取一列就会把 0.2s 拖到 38s');
|
||||
// labels 只允许出现在 `LIKE` 里转成布尔,不许直接投影原文
|
||||
assert.doesNotMatch(bs, /\blabels\s*(,|AS\b)/u, 'labels 长文本不许直接投影,只允许 CASE WHEN ... LIKE 转布尔');
|
||||
}
|
||||
|
||||
// ⚠️ 窗口参数走 params,不在 SQL 里手写
|
||||
|
||||
@ -7,8 +7,9 @@
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"prebuild": "node ./scripts/write-build-version.mjs",
|
||||
"dev": "node ./scripts/write-build-version.mjs && vite --configLoader native",
|
||||
"build": "vite build && node ./scripts/verify-planning-entry.mjs",
|
||||
"build": "node ./scripts/verify-router-lazy.mjs && vite build && node ./scripts/verify-planning-entry.mjs",
|
||||
"check": "npm run lint && npm test && npm run build",
|
||||
"verify:router-lazy": "node ./scripts/verify-router-lazy.mjs",
|
||||
"verify:public-build": "node ./scripts/verify-public-build.mjs",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
|
||||
70
frontend/scripts/verify-router-lazy.mjs
Normal file
70
frontend/scripts/verify-router-lazy.mjs
Normal file
@ -0,0 +1,70 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
/**
|
||||
* 校验路由表里每个 renderLazyComponent(X) 用到的组件 X 都在同文件里
|
||||
* 用 `const X = lazy(() => import(...))` 声明过。
|
||||
*
|
||||
* 为什么要这个检查(2026-10-22 生产事故):
|
||||
* PR #74 新增「缺货补货监控」时,router/index.jsx 里写了
|
||||
* `element: renderLazyComponent(BinReplenishMonitor)` 却漏了 lazy 声明。
|
||||
* 这行在模块顶层执行 → 加载即抛 `ReferenceError: BinReplenishMonitor is not defined`
|
||||
* → 整个 React 应用挂掉、**全站白屏,连登录页都渲染不出来**。
|
||||
* 而 `vite build` 完全能构建成功(未声明标识符在 JS 里是合法语法),
|
||||
* 所以必须在构建期用静态扫描拦住。
|
||||
*/
|
||||
export async function verifyRouterLazy(routerPath = path.resolve(process.cwd(), 'src/router/index.jsx')) {
|
||||
const source = await readFile(routerPath, 'utf8');
|
||||
|
||||
const used = new Set();
|
||||
for (const match of source.matchAll(/renderLazyComponent\s*\(\s*([A-Za-z_$][\w$]*)/g)) {
|
||||
used.add(match[1]);
|
||||
}
|
||||
|
||||
const declared = new Set();
|
||||
for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*lazy\s*\(/g)) {
|
||||
declared.add(match[1]);
|
||||
}
|
||||
|
||||
const missing = [...used].filter((name) => !declared.has(name)).sort();
|
||||
|
||||
if (missing.length > 0) {
|
||||
const lines = missing.map((name) => {
|
||||
const idx = source.indexOf(`renderLazyComponent(${name}`);
|
||||
const line = idx === -1 ? '?' : source.slice(0, idx).split('\n').length;
|
||||
return ` - ${name} (router/index.jsx:${line} 使用,缺少 const ${name} = lazy(() => import('...')) 声明)`;
|
||||
});
|
||||
throw new Error(
|
||||
'router/index.jsx 有组件被 renderLazyComponent 使用但未声明为 lazy,' +
|
||||
'这会导致运行时 ReferenceError 并让整站白屏:\n' +
|
||||
lines.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
return { routerPath, used: [...used].sort(), declared: [...declared].sort() };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const routerArg = process.argv[2];
|
||||
const routerPath = routerArg
|
||||
? path.resolve(process.cwd(), routerArg)
|
||||
: path.resolve(process.cwd(), 'src/router/index.jsx');
|
||||
const result = await verifyRouterLazy(routerPath);
|
||||
process.stdout.write(
|
||||
`Verified router lazy declarations: ${result.used.length} used, ${result.declared.length} declared ` +
|
||||
`(${path.relative(process.cwd(), result.routerPath)})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const isDirectExecution = process.argv[1]
|
||||
? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url
|
||||
: false;
|
||||
|
||||
if (isDirectExecution) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@ -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 } : {} }
|
||||
);
|
||||
|
||||
@ -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 <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);
|
||||
|
||||
@ -67,9 +41,8 @@ const BinReplenishMonitor = () => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [data, setData] = useState({
|
||||
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], pickBinMeta: null, updatedAt: '', cacheHit: false,
|
||||
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderDate: '', updatedAt: '', cacheHit: false,
|
||||
});
|
||||
const [importing, setImporting] = useState(false);
|
||||
const keywordTimer = useRef(null);
|
||||
|
||||
const load = useCallback(async (options = {}) => {
|
||||
@ -82,7 +55,6 @@ const BinReplenishMonitor = () => {
|
||||
warehouse,
|
||||
status,
|
||||
keyword,
|
||||
targetDays,
|
||||
page: options.page || page,
|
||||
pageSize: options.pageSize || pageSize,
|
||||
refresh: isRefresh ? 'true' : undefined,
|
||||
@ -94,7 +66,7 @@ const BinReplenishMonitor = () => {
|
||||
summary: payload.summary || {},
|
||||
warehouse: payload.warehouse || null,
|
||||
warehouses: payload.warehouses || [],
|
||||
pickBinMeta: payload.pickBinMeta || null,
|
||||
orderDate: payload.orderDate || '',
|
||||
updatedAt: payload.updatedAt || '',
|
||||
cacheHit: Boolean(payload.cacheHit),
|
||||
});
|
||||
@ -105,12 +77,12 @@ const BinReplenishMonitor = () => {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [warehouse, status, keyword, targetDays, page, pageSize]);
|
||||
}, [warehouse, status, keyword, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [warehouse, status, page, pageSize, targetDays]);
|
||||
}, [warehouse, status, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keywordTimer.current) clearTimeout(keywordTimer.current);
|
||||
@ -123,93 +95,65 @@ const BinReplenishMonitor = () => {
|
||||
}, [keyword]);
|
||||
|
||||
const summary = data.summary;
|
||||
const pickBinMeta = data.pickBinMeta || {};
|
||||
const usingFallback = pickBinMeta.source !== 'import';
|
||||
const needCount = (summary.need || 0) + (summary.short || 0);
|
||||
|
||||
const metrics = useMemo(() => ([
|
||||
{
|
||||
key: 'pickout',
|
||||
key: 'replenish',
|
||||
tone: 'red',
|
||||
label: '拣货位缺货',
|
||||
value: summary.pickout || 0,
|
||||
unit: '个 SKU',
|
||||
foot: `可立即从存货位补 ${num(summary.suggestQtyTotal)} 件`,
|
||||
tip: '拣货位库存为 0、存货位还有货:需要把存货位的库存补到拣货位',
|
||||
label: '待补货 SKU',
|
||||
value: needCount,
|
||||
unit: '个',
|
||||
foot: `建议补货合计 ${num(summary.suggestQtyTotal)} 件`,
|
||||
tip: '当日订单量 > 拣货位库存:拣货位不够今天的订单拣,需要从存储位把货补过来',
|
||||
},
|
||||
{
|
||||
key: 'empty',
|
||||
key: 'short',
|
||||
tone: 'grey',
|
||||
label: '无货可补',
|
||||
value: summary.empty || 0,
|
||||
unit: '个 SKU',
|
||||
foot: '拣货位与存货位都为 0,需采购',
|
||||
tip: '拣货位库存为 0,且仓库里已经没有该商品的存货,补货要走走采购',
|
||||
label: '其中存储不足',
|
||||
value: summary.short || 0,
|
||||
unit: '个',
|
||||
foot: `存储缺口 ${num(summary.shortfallTotal)} 件`,
|
||||
tip: '拣货位缺货,且存储位也不够补(存储缺口 > 0):光靠库内调拨补不齐,需要采购 / 紧急调拨',
|
||||
},
|
||||
{
|
||||
key: 'low',
|
||||
key: 'exceed',
|
||||
tone: 'orange',
|
||||
label: '拣货位偏低',
|
||||
value: summary.low || 0,
|
||||
unit: '个 SKU',
|
||||
foot: `低于 ${targetDays} 天销量目标`,
|
||||
tip: `拣货位还有货,但低于目标库存(= 近15天日销 × ${targetDays},最少 ${10} 件)`,
|
||||
label: '超出订单数合计',
|
||||
value: summary.exceedTotal || 0,
|
||||
unit: '件',
|
||||
foot: '今日拣货位缺口订单量',
|
||||
tip: '所有待补货 SKU 的「订单量 − 拣货位库存」之和:这是今天需要从存储位搬到拣货位的总量',
|
||||
},
|
||||
{
|
||||
key: 'qty',
|
||||
key: 'order',
|
||||
tone: 'blue',
|
||||
label: '建议补货合计',
|
||||
value: summary.suggestQtyTotal || 0,
|
||||
unit: '件',
|
||||
foot: `覆盖 ${num(summary.withPickSlot)} 个有拣货位的 SKU`,
|
||||
tip: '建议补货量 = min(存货位库存, 目标拣货位库存 − 当前拣货位库存)',
|
||||
label: '当日有单 SKU',
|
||||
value: summary.orderSkuCount || 0,
|
||||
unit: '个',
|
||||
foot: `当日订单量 ${num(summary.orderQtyTotal)} 件`,
|
||||
tip: '统计日当天下过单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件)',
|
||||
},
|
||||
]), [summary, targetDays]);
|
||||
]), [summary, needCount]);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await exportBinReplenish({ warehouse, status, keyword, targetDays });
|
||||
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控.xlsx`);
|
||||
const response = await exportBinReplenish({ warehouse, status, keyword });
|
||||
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_${data.orderDate || ''}.xlsx`);
|
||||
} catch (error_) {
|
||||
const text = await readBlobErrorMessage(error_);
|
||||
message.error(text || '导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (file) => {
|
||||
setImporting(true);
|
||||
try {
|
||||
const response = await importPickBins(file, warehouse);
|
||||
const result = response?.data?.data || {};
|
||||
message.success(`已导入 ${result.importedRows || 0} 个拣货位,正在重新取数…`);
|
||||
await load({ refresh: true, page: 1 });
|
||||
setPage(1);
|
||||
} catch (error_) {
|
||||
message.error(error_?.message || '导入失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
await clearPickBins(warehouse);
|
||||
message.success('已清除货位清单,回到「货位散件记录」回退口径');
|
||||
setPage(1);
|
||||
await load({ refresh: true, page: 1 });
|
||||
} catch (error_) {
|
||||
message.error(error_?.message || '清除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 112,
|
||||
width: 100,
|
||||
fixed: 'left',
|
||||
render: (value) => {
|
||||
const tag = STATUS_TAGS[value] || STATUS_TAGS.ok;
|
||||
const tag = STATUS_TAGS[value] || STATUS_TAGS.no_order;
|
||||
return <Tag 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: 'pickBins',
|
||||
width: 300,
|
||||
render: (bins, row) => (row.hasPickSlot ? renderBinTags(bins) : <span className="brp-muted">未维护拣货位</span>),
|
||||
},
|
||||
{
|
||||
title: '拣货位库存',
|
||||
dataIndex: 'pickQty',
|
||||
width: 108,
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => (
|
||||
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>{num(value)}</span>
|
||||
render: (value, row) => (
|
||||
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>
|
||||
{num(value)}{row.pickEmpty ? ' ⚠' : ''}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '存货位库存',
|
||||
title: '存储位库存',
|
||||
dataIndex: 'storageQty',
|
||||
width: 108,
|
||||
width: 100,
|
||||
align: 'right',
|
||||
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',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
@ -259,24 +215,13 @@ const BinReplenishMonitor = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '目标拣货位',
|
||||
dataIndex: 'targetQty',
|
||||
width: 108,
|
||||
title: '存储缺口',
|
||||
dataIndex: 'shortfall',
|
||||
width: 100,
|
||||
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>,
|
||||
render: (value) => (
|
||||
<span className={Number(value) > 0 ? 'brp-qty brp-qty--danger' : 'brp-qty brp-qty--zero'}>{num(value)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '现有库存',
|
||||
@ -293,8 +238,8 @@ const BinReplenishMonitor = () => {
|
||||
<div className="brp-hero__main">
|
||||
<h1 className="brp-hero__title">缺货补货监控</h1>
|
||||
<p className="brp-hero__desc">
|
||||
盯的是<b>拣货位</b>:拣货位没货就拣不出来,需要从<b>存货位</b>把货补过去。
|
||||
状态、建议补货量都按当前库存实时算,数据来自聚水潭「箱及仓位库存」。
|
||||
盯的是<b>拣货位</b>:今天下的订单,如果拣货位库存不够拣,超出的那部分就要从<b>存储位</b>补到拣货位。
|
||||
库存取自聚水潭「箱及仓位库存」,订单取自崔桥工厂仓当日订单。
|
||||
</p>
|
||||
</div>
|
||||
<div className="brp-hero__meta">
|
||||
@ -302,18 +247,15 @@ const BinReplenishMonitor = () => {
|
||||
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
|
||||
</div>
|
||||
<div>
|
||||
拣货位口径:
|
||||
<b className={usingFallback ? '' : 'brp-ok'}>
|
||||
{usingFallback ? '默认拣选位(Bin 且非存货位)' : `导入清单 ${num(pickBinMeta.count)} 个`}
|
||||
</b>
|
||||
订单日期:<b>{data.orderDate || '--'}</b>
|
||||
<span className="brp-muted">(取该仓最近一个有订单的营业日)</span>
|
||||
</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> 个(不参与待补货)
|
||||
共 <b>{num(summary.skuTotal)}</b> 个 SKU,其中今日无单 <b>{num(summary.noOrder)}</b> 个
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -357,23 +299,15 @@ const BinReplenishMonitor = () => {
|
||||
options={STATUS_OPTIONS.map((item) => ({
|
||||
...item,
|
||||
label: item.value === 'replenish'
|
||||
? `${item.label} ${num((summary.pickout || 0) + (summary.low || 0))}`
|
||||
: item.value === 'empty'
|
||||
? `${item.label} ${num(summary.empty || 0)}`
|
||||
: item.label,
|
||||
? `${item.label} ${num(needCount)}`
|
||||
: item.value === 'short'
|
||||
? `${item.label} ${num(summary.short || 0)}`
|
||||
: item.value === 'no_order'
|
||||
? `${item.label} ${num(summary.noOrder || 0)}`
|
||||
: item.label,
|
||||
}))}
|
||||
/>
|
||||
</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}
|
||||
@ -383,14 +317,6 @@ const BinReplenishMonitor = () => {
|
||||
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"
|
||||
@ -402,38 +328,18 @@ const BinReplenishMonitor = () => {
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="brp-alert"
|
||||
message="货位口径(崔桥工厂仓约定)"
|
||||
description={(
|
||||
<span>
|
||||
拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;其余货位(DB / Y* / Z* 等,占比极小)不计入。
|
||||
判断规则:<b>当日订单量 > 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert
|
||||
@ -447,7 +353,7 @@ const BinReplenishMonitor = () => {
|
||||
|
||||
<Spin
|
||||
spinning={loading}
|
||||
tip="正在从仓位库存视图聚合,首次约 30~60 秒"
|
||||
tip="正在聚合仓位库存 + 当日订单,首次约 30~90 秒"
|
||||
wrapperClassName="brp-table-spin"
|
||||
>
|
||||
<Table
|
||||
@ -456,7 +362,7 @@ const BinReplenishMonitor = () => {
|
||||
className="brp-table"
|
||||
columns={columns}
|
||||
dataSource={data.rows}
|
||||
scroll={{ x: 1900 }}
|
||||
scroll={{ x: 1500 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
@ -485,28 +391,24 @@ const BinReplenishMonitor = () => {
|
||||
<div className="brp-notes__title">数据来源与口径说明</div>
|
||||
<ul>
|
||||
<li>
|
||||
<b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码 + 货位汇总。
|
||||
「拣货位库存」= 拣货位上的数量合计;「存货位库存」= 其余货位(含整箱、未分箱)的数量合计。
|
||||
<b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码(sku_id)聚合。
|
||||
「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
|
||||
</li>
|
||||
<li>
|
||||
<b>拣货位</b>:副本没有货位类型字段,按导入的货位清单判定;未导入时回退为
|
||||
pack_type = Bin 的记录(页面顶部会标出当前口径)。
|
||||
<b>订单</b>:崔桥工厂仓当日订单 = rpt.jst_Orders(线上店铺)+ rpt.jst_qmcust_orderlist(全民客户 B2B),
|
||||
均按 send_date 落在「订单日期」当天;组合装(sku_type=combine)已按 jst_comboProductItem 展开为子件。
|
||||
</li>
|
||||
<li>
|
||||
<b>目标库存</b>:max(10, 近15天日销 × {targetDays});日销取 rpt.pm_stockMonitor 同仓最新快照的
|
||||
fifteenAvgSale,取不到的按 10 件兜底。
|
||||
<b>判断规则</b>:对每个 SKU,若 <b>当日订单量 > 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量;
|
||||
「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。
|
||||
</li>
|
||||
<li>
|
||||
<b>建议补货量</b>:min(存货位库存, 目标拣货位库存 − 当前拣货位库存),只在「拣货位缺货 / 偏低」时给数字;
|
||||
「无货可补」表示拣货位和存货位都没货,要走走采购,不是仓库内部调拨。
|
||||
<b>订单日期</b>:取该仓订单表 send_date 的最大日期(即最近一个有订单的营业日),副本 ETL 有延迟时不会为空;
|
||||
页面顶部明确标注统计的是哪一天。
|
||||
</li>
|
||||
<li>
|
||||
<b>未设拣货位</b>:该 SKU 在拣货位清单里一个货位都没有(多为整箱直发、或还没维护拣货位)。
|
||||
这类不算「拣货位缺货」,不进待补货清单,可在「未设拣货位」页签单独核对。
|
||||
</li>
|
||||
<li>
|
||||
<b>时效</b>:仓位库存视图单次聚合约 35~45 秒,服务端缓存 10 分钟(启动时已预热);
|
||||
点「刷新数据」会强制回源,需要等半分钟左右。
|
||||
<b>时效</b>:仓位库存视图聚合 + 当日订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热);
|
||||
点「刷新数据」会强制回源,需要等 1 分钟左右。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -250,6 +250,16 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brp-qty--warn {
|
||||
color: var(--brp-orange);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brp-qty--danger {
|
||||
color: var(--brp-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brp-qty--muted {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-weight: 400;
|
||||
|
||||
@ -25,6 +25,10 @@ const DailyInventory = lazy(() => import('../pages/DailyInventory'));
|
||||
const StockSalesAnalysis = lazy(() => import('../pages/StockSalesAnalysis'));
|
||||
const PickingBinSalesAnalysis = lazy(() => import('../pages/PickingBinSalesAnalysis'));
|
||||
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
|
||||
// ⚠️ 每个在下面路由表里被 renderLazyComponent(...) 用到的页面都必须先在这里声明。
|
||||
// 漏一个 = 模块顶层抛 ReferenceError(xxx is not defined)→ 整个应用白屏、连登录页都进不去。
|
||||
// PR #74 就漏了 BinReplenishMonitor,导致生产 10-22 全站白屏(2026-10-22 修复)。
|
||||
const BinReplenishMonitor = lazy(() => import('../pages/BinReplenishMonitor'));
|
||||
|
||||
const lazyLoadingElement = <PageLoadingFallback />;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user