420 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Empty, Input, Segmented, Select, Spin, Table, Tag, Tooltip,
message,
} from 'antd';
import {
DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined,
} from '@ant-design/icons';
import { exportBinReplenish, getBinReplenishList } from '../../api/binReplenish';
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
import { SycmPage } from '../../components/Common';
import './style.css';
const num = (value) => (value === null || value === undefined || value === ''
? '--'
: Number(value).toLocaleString('zh-CN'));
const STATUS_TAGS = {
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: 'short' },
{ label: '拣货位充足', value: 'enough' },
{ label: '无待发单', value: 'no_order' },
{ label: '全部', value: 'all' },
];
const BinReplenishMonitor = () => {
const [warehouse, setWarehouse] = useState('cuiqiao');
const [status, setStatus] = useState('replenish');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState({
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderScope: '', updatedAt: '', cacheHit: false,
});
const keywordTimer = useRef(null);
const load = useCallback(async (options = {}) => {
const isRefresh = options.refresh === true;
if (isRefresh) setRefreshing(true);
else setLoading(true);
setError('');
try {
const response = await getBinReplenishList({
warehouse,
status,
keyword,
page: options.page || page,
pageSize: options.pageSize || pageSize,
refresh: isRefresh ? 'true' : undefined,
});
const payload = response?.data?.data || response?.data || {};
setData({
rows: payload.rows || [],
total: payload.total || 0,
summary: payload.summary || {},
warehouse: payload.warehouse || null,
warehouses: payload.warehouses || [],
orderScope: payload.orderScope || '',
updatedAt: payload.updatedAt || '',
cacheHit: Boolean(payload.cacheHit),
});
if (isRefresh) message.success('已重新回源取数');
} catch (err) {
setError(err?.message || '加载失败');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [warehouse, status, keyword, page, pageSize]);
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [warehouse, status, page, pageSize]);
useEffect(() => {
if (keywordTimer.current) clearTimeout(keywordTimer.current);
keywordTimer.current = setTimeout(() => {
setPage(1);
load({ page: 1 });
}, 400);
return () => { if (keywordTimer.current) clearTimeout(keywordTimer.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword]);
const summary = data.summary;
const needCount = (summary.need || 0) + (summary.short || 0);
const metrics = useMemo(() => ([
{
key: 'replenish',
tone: 'red',
label: '待补货 SKU',
value: needCount,
unit: '个',
foot: `建议补货合计 ${num(summary.suggestQtyTotal)} 件`,
tip: '待发订单量 > 拣货位库存:拣货位不够待发订单拣,需要从存储位把货补过来',
},
{
key: 'short',
tone: 'grey',
label: '其中存储不足',
value: summary.short || 0,
unit: '个',
foot: `存储缺口 ${num(summary.shortfallTotal)} 件`,
tip: '拣货位缺货,且存储位也不够补(存储缺口 > 0):光靠库内调拨补不齐,需要采购 / 紧急调拨',
},
{
key: 'exceed',
tone: 'orange',
label: '超出订单数合计',
value: summary.exceedTotal || 0,
unit: '件',
foot: '待发拣货位缺口订单量',
tip: '所有待补货 SKU 的「待发订单量 − 拣货位库存」之和:这是需要从存储位搬到拣货位的总量',
},
{
key: 'order',
tone: 'blue',
label: '有待发单 SKU',
value: summary.orderSkuCount || 0,
unit: '个',
foot: `待发订单量 ${num(summary.orderQtyTotal)} 件`,
tip: '有待发订单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件;不含已发/取消/拆单/合单)',
},
]), [summary, needCount]);
const handleExport = async () => {
try {
const response = await exportBinReplenish({ warehouse, status, keyword });
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_待发订单.xlsx`);
} catch (error_) {
const text = await readBlobErrorMessage(error_);
message.error(text || '导出失败');
}
};
const columns = [
{
title: '状态',
dataIndex: 'status',
width: 100,
fixed: 'left',
render: (value) => {
const tag = STATUS_TAGS[value] || STATUS_TAGS.no_order;
return <Tag color={tag.color} className="brp-status-tag">{tag.label}</Tag>;
},
},
{
title: '商品编码',
dataIndex: 'skuId',
width: 175,
render: (value, row) => (
<div className="brp-sku">
<span className="brp-sku__code">{value}</span>
{row.supplier ? <span className="brp-sku__supplier">{row.supplier}</span> : null}
</div>
),
},
{ title: '品名', dataIndex: 'productName', width: 260, ellipsis: true },
{
title: '拣货位库存',
dataIndex: 'pickQty',
width: 100,
align: 'right',
render: (value, row) => (
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>
{num(value)}{row.pickEmpty ? ' ⚠' : ''}
</span>
),
},
{
title: '存储位库存',
dataIndex: 'storageQty',
width: 100,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
{
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',
render: (value) => (
<span className={Number(value) > 0 ? 'brp-qty brp-qty--suggest' : 'brp-qty brp-qty--zero'}>
{num(value)}
</span>
),
},
{
title: '存储缺口',
dataIndex: 'shortfall',
width: 100,
align: 'right',
render: (value) => (
<span className={Number(value) > 0 ? 'brp-qty brp-qty--danger' : 'brp-qty brp-qty--zero'}>{num(value)}</span>
),
},
{
title: '现有库存',
dataIndex: 'totalQty',
width: 100,
align: 'right',
render: (value) => <span className="brp-qty">{num(value)}</span>,
},
];
return (
<SycmPage className="brp-page">
<div className="brp-hero">
<div className="brp-hero__main">
<h1 className="brp-hero__title">缺货补货监控</h1>
<p className="brp-hero__desc">
盯的是<b>拣货位</b>:待发订单如果需要拣的货超过拣货位库存,超出的那部分就要从<b>存储位</b>补到拣货位。
库存取自聚水潭「箱及仓位库存」,订单只统计<b>待发</b>(已发货的不再计入,因为货已经发出、不再消耗拣货位)。
</p>
</div>
<div className="brp-hero__meta">
<div>
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
</div>
<div>
统计口径:<b>{data.orderScope || '待发订单(不含已发/取消/拆单/合单),不限日期'}</b>
</div>
<div>
取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b>
{data.cacheHit ? <span className="brp-muted">(缓存)</span> : null}
</div>
<div>
共 <b>{num(summary.skuTotal)}</b> 个 SKU,其中无待发单 <b>{num(summary.noOrder)}</b> 个
</div>
</div>
</div>
<div className="brp-metrics">
{metrics.map((item) => (
<div key={item.key} className={`brp-metric brp-metric--${item.tone}`}>
<Tooltip title={item.tip}>
<div className="brp-metric__label">
{item.label}
<InfoCircleOutlined className="brp-metric__info" />
</div>
</Tooltip>
<div className="brp-metric__value">
{num(item.value)}
<span className="brp-metric__unit">{item.unit}</span>
</div>
<div className="brp-metric__foot">{item.foot}</div>
</div>
))}
</div>
<div className="brp-toolbar">
<span className="brp-toolbar__field">
<span className="brp-toolbar__label">仓库</span>
<Select
value={warehouse}
onChange={(value) => { setWarehouse(value); setPage(1); }}
style={{ width: 210 }}
options={(data.warehouses || []).map((item) => ({
value: item.key,
label: item.name,
}))}
/>
</span>
<span className="brp-toolbar__field">
<span className="brp-toolbar__label">范围</span>
<Segmented
value={status}
onChange={(value) => { setStatus(value); setPage(1); }}
options={STATUS_OPTIONS.map((item) => ({
...item,
label: item.value === 'replenish'
? `${item.label} ${num(needCount)}`
: item.value === 'short'
? `${item.label} ${num(summary.short || 0)}`
: item.value === 'no_order'
? `${item.label} ${num(summary.noOrder || 0)}`
: item.label,
}))}
/>
</span>
<Input
allowClear
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜商品编码 / 品名 / 供应商"
prefix={<SearchOutlined />}
style={{ width: 260 }}
/>
<span className="brp-toolbar__spacer" />
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={refreshing}
onClick={() => load({ refresh: true })}
>
刷新数据
</Button>
</div>
<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>待发订单量 &gt; 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
</span>
)}
/>
{error ? (
<Alert
type="error"
showIcon
className="brp-alert"
message={error}
action={<Button size="small" onClick={() => load()}>重试</Button>}
/>
) : null}
<Spin
spinning={loading}
tip="正在聚合仓位库存 + 待发订单,首次约 30~90 秒"
wrapperClassName="brp-table-spin"
>
<Table
rowKey="skuId"
size="small"
className="brp-table"
columns={columns}
dataSource={data.rows}
scroll={{ x: 1500 }}
locale={{
emptyText: (
<Empty
description={status === 'replenish'
? '当前没有需要补货的拣货位'
: '没有符合条件的商品'}
/>
),
}}
pagination={{
current: page,
pageSize,
total: data.total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (total) => `共 ${total} 个 SKU`,
onChange: (nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
},
}}
/>
</Spin>
<div className="brp-notes">
<div className="brp-notes__title">数据来源与口径说明</div>
<ul>
<li>
<b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码(sku_id)聚合。
「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
</li>
<li>
<b>订单</b>:只统计<b>待发</b>订单 = rpt.jst_Orders(线上店铺)+ rpt.jst_qmcust_orderlist(全民客户 B2B),
排除 已发(Sent) / 取消(Cancelled) / 删除(Delete) / 拆单(Split) / 合单(Merged);组合装(sku_type=combine)已按 jst_comboProductItem 展开为子件。
<b>已发货的订单不计入</b>——货已发出、不再消耗拣货位,不该驱动补货。
</li>
<li>
<b>判断规则</b>:对每个 SKU,若 <b>待发订单量 &gt; 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量;
「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。
</li>
<li>
<b>统计范围</b>:待发订单<b>不限日期</b>,含历史未发积压。这样最贴近「需要补货去拣的货」;
已发/取消/拆单/合单一律排除,任何新增的待发状态也会自动落入统计。
</li>
<li>
<b>时效</b>:仓位库存视图聚合 + 待发订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热);
点「刷新数据」会强制回源,需要等 1 分钟左右。
</li>
</ul>
</div>
</SycmPage>
);
};
export default BinReplenishMonitor;