615 lines
24 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, Card, Col, Dropdown, Image, Input, Modal, Row, Select,
Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getModularStockList, getModularStockFilters, getPurchaseInTransitList, syncModularStock } from '../../api/modularStockMonitor';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
const { Title, Text } = Typography;
const WARNING_COLOR = { 缺货: 'red', 预警: 'orange', 正常: 'green' };
// 表头带口径说明,避免把「真实值 0」误读成「没数据」
const TipTitle = ({ title, tip }) => (
<Tooltip title={tip}>
<span className="msm-th-tip">{title}</span>
</Tooltip>
);
const number = (value, digits = 2) => value === null || value === undefined || value === ''
? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
const int = (value) => number(value, 0);
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
const toOptions = (list = []) => (Array.isArray(list) ? list : [])
.filter(Boolean)
.map((value) => ({ value, label: value }));
const EMPTY_FILTERS = { keyword: '', category: '', warningLevel: '', mkhStatus: '' };
const AccessoryStockPanel = () => {
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [onlyWarning, setOnlyWarning] = useState(false);
const [filterOptions, setFilterOptions] = useState({ categories: [], warningLevels: [] });
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: null });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [syncing, setSyncing] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [sorter, setSorter] = useState({ field: 'realtimeStock', order: 'DESC' });
const tableWrapRef = useRef(null);
const [bodyHeight, setBodyHeight] = useState(420);
// 采购在途明细弹窗状态
const [transitVisible, setTransitVisible] = useState(false);
const [transitData, setTransitData] = useState({ rows: [], total: 0 });
const [transitLoading, setTransitLoading] = useState(false);
const [transitPage, setTransitPage] = useState(1);
const [transitPageSize, setTransitPageSize] = useState(50);
const [transitKeyword, setTransitKeyword] = useState('');
const [transitSorter, setTransitSorter] = useState({ field: 'transitQty', order: 'DESC' });
// 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc:
// 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行
useEffect(() => {
const el = tableWrapRef.current;
if (!el) return undefined;
// 分页器 + 卡片内边距 + 横向滚动条余量
const RESERVE_BELOW = 104;
const measure = () => {
const scroller = el.closest('.planning-subsystem-content');
const elRect = el.getBoundingClientRect();
let available;
if (scroller) {
const scrollerRect = scroller.getBoundingClientRect();
// 加回 scrollTop,抵消滚动对 getBoundingClientRect 的影响
available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop;
} else {
available = window.innerHeight - elRect.top - RESERVE_BELOW;
}
const next = Math.max(240, Math.round(available));
setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
if (el.parentElement) ro.observe(el.parentElement);
window.addEventListener('resize', measure);
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
};
}, []);
useEffect(() => {
let cancelled = false;
getModularStockFilters()
.then((result) => {
const payload = result?.data || {};
if (cancelled) return;
setFilterOptions({
categories: payload.categories || [],
warningLevels: payload.warningLevels || ['缺货', '预警', '正常'],
mkhStatuses: payload.mkhStatuses || ['已配置MKH', '未配置MKH'],
});
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
const query = useMemo(() => {
const params = {
page,
pageSize,
sortField: sorter.field,
sortOrder: sorter.order,
};
Object.entries(filters).forEach(([key, value]) => {
if (value) params[key] = value;
});
if (onlyWarning) params.onlyWarning = '1';
return params;
}, [page, pageSize, sorter, filters, onlyWarning]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await getModularStockList(query);
setData(result?.data || { rows: [], total: 0, summary: {} });
} catch (requestError) {
setError(requestError?.response?.data?.error || requestError?.message || '加载配件库存监控数据失败');
} finally {
setLoading(false);
}
}, [query]);
useEffect(() => { load(); }, [load]);
const changeFilter = (key) => (value) => {
setFilters((old) => ({ ...old, [key]: value || '' }));
setPage(1);
};
const resetFilters = () => {
setFilters(EMPTY_FILTERS);
setOnlyWarning(false);
setPage(1);
};
// 点击顶部统计卡片 → 自动设置对应筛选条件并滚动到表格明细
const applySummaryFilter = (type) => () => {
const next = { ...EMPTY_FILTERS };
let warning = false;
if (type === 'outOfStock') {
next.warningLevel = '缺货';
warning = true;
} else if (type === 'lowStockDays') {
next.warningLevel = '预警';
warning = true;
} else if (type === 'configured') {
next.mkhStatus = '已配置MKH';
} else if (type === 'unconfigured') {
next.mkhStatus = '未配置MKH';
}
setFilters(next);
setOnlyWarning(warning);
setPage(1);
setTimeout(() => {
tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 0);
};
// 打开采购在途明细弹窗
const openPurchaseTransit = () => {
setTransitVisible(true);
setTransitPage(1);
setTransitKeyword('');
setTransitSorter({ field: 'transitQty', order: 'DESC' });
loadPurchaseTransit(1, '', { field: 'transitQty', order: 'DESC' });
};
const loadPurchaseTransit = useCallback(async (p = transitPage, kw = transitKeyword, sort = transitSorter) => {
setTransitLoading(true);
try {
const result = await getPurchaseInTransitList({
page: p,
pageSize: transitPageSize,
keyword: kw,
sortField: sort.field,
sortOrder: sort.order,
});
setTransitData(result?.data || { rows: [], total: 0 });
} catch (err) {
message.error(err?.response?.data?.error || err?.message || '加载采购在途明细失败');
} finally {
setTransitLoading(false);
}
}, [transitPage, transitPageSize, transitKeyword, transitSorter]);
const columns = useMemo(() => [
{
title: '图片',
dataIndex: 'imageUrl',
width: 56,
fixed: 'left',
exportable: false,
render: (value) => (value
? <Image src={value} width={36} height={36} style={{ objectFit: 'cover', borderRadius: 4 }} placeholder />
: <span className="msm-muted">--</span>),
},
{
title: '预警',
dataIndex: 'warningLevel',
width: 76,
sorter: true,
render: (value) => (value
? <Tag color={WARNING_COLOR[value] || 'default'}>{value}</Tag>
: <span className="msm-muted">--</span>),
},
{
title: <TipTitle
title="状态"
tip="该商品(配件)是否作为「配件商品编码」出现在聚水潭 SKU 级 BOM 维护中、被某 MKH 模块化成品引用。已配置MKH = 业务库 rpt.jst_skuBomItem 中 map_outer_sku_id 命中该商品编码、且父项 sku_id 以 MKH 开头(被某 MKH 成品 BOM 引用为配件);未配置MKH = 未出现在任何 MKH 成品 BOM 的配件清单中"
/>,
dataIndex: 'mkhStatus',
width: 110,
sorter: true,
render: (value) => {
const v = value || '未配置MKH';
return v === '已配置MKH'
? <Tag color="green">{v}</Tag>
: <Tag color="default">{v}</Tag>;
},
},
{
title: '商品编码',
dataIndex: 'skuId',
width: 176,
fixed: 'left',
sorter: true,
render: (value) => <span className="msm-sku">{text(value)}</span>,
},
{
// 必须显式给 width:antd 在 scroll.x 场景下会把无宽度列压成 0 宽(此前该列不可见的原因)
title: '商品名称',
dataIndex: 'productName',
width: 220,
ellipsis: true,
sorter: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
{
title: <TipTitle
title="当日入库库存"
tip="监控日(pm_stockMonitor 最新快照日)的采购到货入库数量,取自 jst_purchaseItem.inQty。业务库无出入库流水表,0 表示该 SKU 当日无到货"
/>,
dataIndex: 'inQtyToday',
width: 116,
align: 'right',
render: (value) => (value === null || value === undefined
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: <TipTitle
title="当日出库库存"
tip="监控日的最近一日发货量,取自 pm_stockMonitor.nearSecondSale。业务库无出入库流水表,0 表示该 SKU 当日无发货"
/>,
dataIndex: 'outQtyToday',
width: 116,
align: 'right',
render: (value) => (value === null || value === undefined
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: <TipTitle
title="实时库存"
tip="优先取目标仓 jst_inventoryInfo;该表为 0 或缺少记录、但箱及仓位库存已有正库存时,用同仓 jst_packInventoryInfo 合计补齐"
/>,
dataIndex: 'realtimeStock',
width: 96,
align: 'right',
sorter: true,
render: int,
},
{
title: <TipTitle
title="采购在途数量"
tip="采购单中『已下单 − 已入库』的数量,取自 jst_purchaseItem.qty − inQty;它与实时库存独立,上游采购到货字段滞后时可能同时显示库存和在途"
/>,
dataIndex: 'purchaseTransit',
width: 120,
align: 'right',
render: (value) => (value === null || value === undefined || Number(value) === 0
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: <TipTitle
title="仓位"
tip="取自聚水潭「箱及仓位库存」jst_packInventoryInfo 的真货位(bin),同商品多箱时显示库存最大的主货位;该表查不到才显示「未维护」"
/>,
dataIndex: 'location',
width: 88,
sorter: true,
render: (value) => (value
? <span>{value}</span>
: <Tooltip title="上游未提供库位数据">
<span className="msm-muted">未维护</span>
</Tooltip>),
},
{
title: '近15天日均发货量',
dataIndex: 'avgShip15d',
width: 138,
align: 'right',
sorter: true,
render: (value) => number(value),
},
{
title: '库存可用天数',
dataIndex: 'stockDays',
width: 116,
align: 'right',
sorter: true,
render: (value) => number(value, 1),
},
{
title: '所在仓库',
dataIndex: 'warehouse',
width: 200,
ellipsis: true,
sorter: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
], []);
const exportColumns = useMemo(
() => columns.filter((column) => column.exportable !== false),
[columns]
);
const handleExport = async (format) => {
if (!data.rows.length) {
message.warning('暂无数据可导出');
return;
}
try {
await exportTableFile(
format,
`配件库存监控_${data.monitorDate || '最新'}`,
exportColumns,
data.rows,
'配件库存监控'
);
message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`);
} catch (exportError) {
message.error(exportError?.message || '导出失败');
}
};
// 手动同步:后端重新取最新监控日(清服务端缓存)后再拉一次列表,
// 拿到的是副本库当前最新快照;可随时点击,不受 24 点定时任务限制
const handleSync = async () => {
setSyncing(true);
try {
await syncModularStock();
await load();
message.success('已同步最新数据');
} catch (syncError) {
message.error(syncError?.response?.data?.error || syncError?.message || '同步失败');
} finally {
setSyncing(false);
}
};
const summary = data.summary || {};
return (
<>
<Card className="msm-filter-card" bordered={false}>
<Space wrap size={[12, 12]}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="商品编码 / 名称"
value={filters.keyword}
onChange={(e) => changeFilter('keyword')(e.target.value)}
style={{ width: 220 }}
/>
<Select
allowClear
showSearch
placeholder="品类"
options={toOptions(filterOptions.categories)}
value={filters.category || undefined}
onChange={changeFilter('category')}
style={{ width: 200 }}
/>
<Select
allowClear
placeholder="预警等级"
options={toOptions(filterOptions.warningLevels)}
value={filters.warningLevel || undefined}
onChange={changeFilter('warningLevel')}
style={{ width: 140 }}
/>
<Select
allowClear
placeholder="MKH 配置状态"
options={toOptions(filterOptions.mkhStatuses)}
value={filters.mkhStatus || undefined}
onChange={changeFilter('mkhStatus')}
style={{ width: 160 }}
/>
<Space size={6}>
<Switch checked={onlyWarning} onChange={(checked) => { setOnlyWarning(checked); setPage(1); }} size="small" />
<Text type="secondary">仅看缺货 / 预警</Text>
</Space>
<Button onClick={resetFilters}>重置</Button>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
<Tooltip title="立即重新拉取副本库最新数据(清服务端缓存后重查),可随时点击,不必等凌晨的定时刷新">
<Button type="primary" icon={<SyncOutlined />} onClick={handleSync} loading={syncing}>同步最新数据</Button>
</Tooltip>
</Space>
</Card>
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
<Row gutter={[12, 12]} className="msm-summary-row">
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="SKU 总数" value={summary.skuCount || 0} /></Card></Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-red is-clickable" onClick={applySummaryFilter('outOfStock')}>
<Statistic title="缺货 SKU" value={summary.outOfStockCount || 0} />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-orange is-clickable" onClick={applySummaryFilter('lowStockDays')}>
<Statistic title="可用天数不足" value={summary.lowStockDaysCount || 0} suffix="个 SKU" />
</Card>
</Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-green"><Statistic title="实时库存合计" value={summary.totalStock || 0} /></Card></Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-blue is-clickable" onClick={openPurchaseTransit}>
<Statistic title="采购在途" value={summary.totalPurchaseTransit || 0} suffix="件" />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-clickable" onClick={applySummaryFilter('configured')}>
<Statistic title="已配置MKH" value={summary.configuredCount || 0} />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-clickable" onClick={applySummaryFilter('unconfigured')}>
<Statistic title="未配置MKH" value={summary.unconfiguredCount || 0} />
</Card>
</Col>
</Row>
<Card
bordered={false}
className="msm-panel"
title={(
<Space size={12} wrap>
<span>配件库存明细({data.total || 0} 条)</span>
{data.monitorDate ? <Text type="secondary" className="msm-panel__date">监控日 {data.monitorDate}</Text> : null}
</Space>
)}
extra={(
<Dropdown menu={{
items: [
{ key: 'xlsx', label: '导出 XLSX' },
{ key: 'csv', label: '导出 CSV' },
],
onClick: ({ key }) => handleExport(key),
}}>
<Button icon={<DownloadOutlined />}>导出</Button>
</Dropdown>
)}
>
<Spin spinning={loading} tip="正在加载配件库存监控数据…">
<div ref={tableWrapRef}>
<Table
rowKey="skuId"
size="small"
columns={columns}
dataSource={data.rows || []}
onChange={(pagination, _filter, nextSorter) => {
setPage(pagination.current || 1);
setPageSize(pagination.pageSize || 50);
if (nextSorter?.field) {
setSorter({
field: nextSorter.field,
order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC',
});
}
}}
pagination={{
current: data.page || page,
pageSize: data.pageSize || pageSize,
total: data.total || 0,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
showTotal: (total) => `共 ${total} 条`,
}}
scroll={{ x: 1508, y: bodyHeight }}
/>
</div>
</Spin>
</Card>
<Modal
title={(
<Space>
<span>采购在途明细</span>
<Text type="secondary" style={{ fontSize: 13 }}>
共 {transitData.total || 0} 条采购单 / 在途 {summary.totalPurchaseTransit || 0} 件
</Text>
</Space>
)}
open={transitVisible}
onCancel={() => setTransitVisible(false)}
width={960}
footer={null}
destroyOnHidden
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Input.Search
allowClear
placeholder="商品编码 / 名称"
defaultValue={transitKeyword}
onSearch={(value) => { setTransitKeyword(value); setTransitPage(1); loadPurchaseTransit(1, value, transitSorter); }}
style={{ width: 260 }}
/>
<Table
rowKey={(row) => `${row.skuId}-${row.poiId || row.poId || Math.random()}`}
size="small"
loading={transitLoading}
columns={[
{
title: '商品编码', dataIndex: 'skuId', width: 176, render: (v) => <span className="msm-sku">{text(v)}</span>,
},
{
title: '商品名称', dataIndex: 'productName', width: 240, ellipsis: true, render: (v) => <Tooltip title={v}>{text(v)}</Tooltip>,
},
{ title: '采购数量', dataIndex: 'purchaseQty', width: 96, align: 'right', render: int, sorter: true },
{ title: '已入库', dataIndex: 'inQty', width: 84, align: 'right', render: int, sorter: true },
{
title: '在途数量', dataIndex: 'transitQty', width: 96, align: 'right', sorter: true,
render: (value) => <strong style={{ color: '#1677ff' }}>{int(value)}</strong>,
},
{
title: '计划到货日期', dataIndex: 'deliveryDate', width: 120, align: 'center', sorter: true,
render: (value) => text(value),
},
{ title: '采购单号', dataIndex: 'poId', width: 116, render: text },
]}
dataSource={transitData.rows || []}
pagination={{
current: transitPage,
pageSize: transitPageSize,
total: transitData.total || 0,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
showTotal: (total) => `共 ${total} 条`,
}}
onChange={(pagination, _filters, nextSorter) => {
setTransitPage(pagination.current || 1);
setTransitPageSize(pagination.pageSize || 50);
const nextSort = nextSorter?.field
? { field: nextSorter.field, order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC' }
: { field: 'transitQty', order: 'DESC' };
setTransitSorter(nextSort);
loadPurchaseTransit(pagination.current || 1, transitKeyword, nextSort);
}}
scroll={{ x: 860, y: 360 }}
/>
</Space>
</Modal>
</>
);
};
const ModularStockMonitor = () => (
<SycmPage className="msm-page">
<header className="msm-header">
<div className="msm-header__heading">
<Title level={3}>模块化库存监控</Title>
<Text type="secondary">
配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 15 天日均发货量与可用天数
</Text>
<Text type="secondary" className="msm-note">
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、
库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖);
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;
仓位取聚水潭「箱及仓位库存」(jst_packInventoryInfo)的真货位,同商品多箱时显示库存最大的主货位,查不到才显示「未维护」;
图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。
注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天);
库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。
状态列:基于聚水潭 SKU 级 BOM 维护(rpt.jst_skuBomItem)判断该商品是否作为「配件商品编码」被某 MKH 成品 BOM 引用——已配置MKH = 出现在某 MKH 成品的 BOM 配件清单中;未配置MKH = 未出现。
</Text>
</div>
</header>
<Tabs
className="msm-tabs"
activeKey="accessory"
items={[{
key: 'accessory',
label: '配件库存监控',
children: <AccessoryStockPanel />,
}]}
/>
</SycmPage>
);
export default ModularStockMonitor;