forked from lunanting/cangku
420 lines
15 KiB
JavaScript
420 lines
15 KiB
JavaScript
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>待发订单量 > 拣货位库存</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>待发订单量 > 拣货位库存</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;
|