forked from lunanting/cangku
433 lines
16 KiB
JavaScript
433 lines
16 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
Alert, Button, Card, Input, Space, Spin, Table, Tooltip, Typography, message,
|
||
} from 'antd';
|
||
import { PrinterOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||
import { SycmPage } from '../../components/Common';
|
||
import { getMkhBomList, getMkhBomDetail } from '../../api/mkhBom';
|
||
import './style.css';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
const number = (value, digits = 0) => (value === null || value === undefined || value === ''
|
||
? '0'
|
||
: Number(value).toLocaleString('zh-CN', {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
}));
|
||
|
||
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
|
||
|
||
const EMPTY_FILTERS = { keyword: '' };
|
||
|
||
// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位
|
||
const PrintPreview = ({ data, onClose }) => {
|
||
const now = useMemo(() => new Date().toLocaleString('zh-CN'), []);
|
||
const items = data.items || [];
|
||
|
||
useEffect(() => {
|
||
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [onClose]);
|
||
|
||
return (
|
||
<div className="mkh-print-mask">
|
||
<div className="mkh-print-toolbar">
|
||
<Space>
|
||
<Button type="primary" icon={<PrinterOutlined />} onClick={() => window.print()}>一键打印</Button>
|
||
<Button onClick={onClose}>关闭</Button>
|
||
<Text type="secondary">按 Esc 关闭预览</Text>
|
||
</Space>
|
||
</div>
|
||
|
||
<div className="mkh-print-sheet">
|
||
<div className="mkh-print-head">
|
||
<div className="mkh-print-title">MKH-BOM 资料</div>
|
||
<div className="mkh-print-sub">模块化商品配件明细 · 打印模板</div>
|
||
</div>
|
||
|
||
<div className="mkh-print-meta">
|
||
<div><span>打印时间:</span><b>{now}</b></div>
|
||
<div><span>配件种类:</span><b>{items.length}</b></div>
|
||
</div>
|
||
|
||
<table className="mkh-print-table">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: '170px' }}>MKH商品编码</th>
|
||
<th>配件明细</th>
|
||
<th style={{ width: '64px' }}>数量</th>
|
||
<th style={{ width: '110px' }}>配件仓位</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map((item, idx) => (
|
||
<tr key={item.index}>
|
||
{idx === 0 ? (
|
||
<td className="mkh-print-sku" rowSpan={items.length}>{text(data.skuId)}</td>
|
||
) : null}
|
||
<td>{text(item.skuName)}{item.skuId ? <span className="mkh-print-code">{item.skuId}</span> : null}</td>
|
||
<td className="mkh-center">{number(item.qty)}</td>
|
||
<td className="mkh-center">{item.location ? text(item.location) : <span className="mkh-muted">未维护</span>}</td>
|
||
</tr>
|
||
))}
|
||
{items.length === 0 ? (
|
||
<tr>
|
||
<td className="mkh-print-sku">{text(data.skuId)}</td>
|
||
<td colSpan={3} className="mkh-muted">该 MKH 商品的配件明细尚未录入「产品资料系统数据库」</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div className="mkh-print-foot">
|
||
<span>
|
||
MKH 商品编码与组合配件取自业务库;「产品资料系统数据库」中维护的配件 BOM 作为补充;
|
||
配件仓位取库位主数据,未维护时显示「未维护」。
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const MkhBomData = () => {
|
||
const [filters, setFilters] = useState(EMPTY_FILTERS);
|
||
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false });
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(50);
|
||
const [sorter, setSorter] = useState({ field: 'skuId', order: 'ASC' });
|
||
|
||
const [expandedKeys, setExpandedKeys] = useState({});
|
||
const [detailCache, setDetailCache] = useState({});
|
||
const [detailLoading, setDetailLoading] = useState({});
|
||
|
||
const [printData, setPrintData] = useState(null);
|
||
const [printLoading, setPrintLoading] = useState(false);
|
||
|
||
const tableWrapRef = useRef(null);
|
||
const [bodyHeight, setBodyHeight] = useState(420);
|
||
|
||
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();
|
||
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);
|
||
};
|
||
}, []);
|
||
|
||
const query = useMemo(() => {
|
||
const params = { page, pageSize, sortField: sorter.field, sortOrder: sorter.order };
|
||
Object.entries(filters).forEach(([key, value]) => { if (value) params[key] = value; });
|
||
return params;
|
||
}, [page, pageSize, sorter, filters]);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const result = await getMkhBomList(query);
|
||
if (!result || result.success === false) {
|
||
setError(result?.error || '加载 MKH-BOM 资料失败');
|
||
return;
|
||
}
|
||
setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 });
|
||
} catch (requestError) {
|
||
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [query]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const changeFilter = (key) => (value) => {
|
||
setFilters((old) => ({ ...old, [key]: value || '' }));
|
||
setPage(1);
|
||
};
|
||
const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); };
|
||
|
||
const loadDetail = useCallback(async (skuId) => {
|
||
if (detailCache[skuId] || detailLoading[skuId]) return;
|
||
setDetailLoading((old) => ({ ...old, [skuId]: true }));
|
||
try {
|
||
const result = await getMkhBomDetail(skuId);
|
||
if (!result || result.success === false) {
|
||
message.error(result?.error || '加载配件明细失败');
|
||
return;
|
||
}
|
||
setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } }));
|
||
} catch (e) {
|
||
message.error(e?.response?.data?.error || e?.message || '加载配件明细失败');
|
||
} finally {
|
||
setDetailLoading((old) => ({ ...old, [skuId]: false }));
|
||
}
|
||
}, [detailCache, detailLoading]);
|
||
|
||
const handlePrint = async (skuId) => {
|
||
setPrintLoading(true);
|
||
try {
|
||
const result = await getMkhBomDetail(skuId);
|
||
if (!result || result.success === false) {
|
||
message.error(result?.error || '加载打印数据失败');
|
||
return;
|
||
}
|
||
setPrintData(result?.data || { skuId, items: [] });
|
||
} catch (e) {
|
||
message.error(e?.response?.data?.error || e?.message || '加载打印数据失败');
|
||
} finally {
|
||
setPrintLoading(false);
|
||
}
|
||
};
|
||
|
||
const columns = useMemo(() => [
|
||
{
|
||
title: 'MKH商品编码',
|
||
dataIndex: 'skuId',
|
||
width: 240,
|
||
fixed: 'left',
|
||
sorter: true,
|
||
render: (value) => <span className="mkh-sku">{text(value)}</span>,
|
||
},
|
||
{
|
||
title: '商品名称',
|
||
dataIndex: 'productName',
|
||
ellipsis: true,
|
||
sorter: true,
|
||
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
|
||
},
|
||
{
|
||
title: 'BOM 状态',
|
||
dataIndex: 'bomConfigured',
|
||
width: 100,
|
||
align: 'center',
|
||
render: (value, row) => (row.bomConfigured === '已配置'
|
||
? <span className="mkh-tag mkh-tag--ok">已配置</span>
|
||
: <span className="mkh-tag mkh-tag--muted">未配置</span>),
|
||
},
|
||
{
|
||
title: '配件种类',
|
||
dataIndex: 'accessoryCount',
|
||
width: 110,
|
||
align: 'right',
|
||
sorter: true,
|
||
render: (value) => number(value),
|
||
},
|
||
{
|
||
title: '配件总数量',
|
||
dataIndex: 'accessoryQtySum',
|
||
width: 120,
|
||
align: 'right',
|
||
sorter: true,
|
||
render: (value) => number(value),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 190,
|
||
fixed: 'right',
|
||
render: (_value, row) => (
|
||
<Space size={6}>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<PrinterOutlined />}
|
||
loading={printLoading}
|
||
onClick={() => handlePrint(row.skuId)}
|
||
>
|
||
一键打印
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
onClick={() => {
|
||
const next = !expandedKeys[row.skuId];
|
||
setExpandedKeys((old) => ({ ...old, [row.skuId]: next }));
|
||
if (next) loadDetail(row.skuId);
|
||
}}
|
||
>
|
||
{expandedKeys[row.skuId] ? '收起' : '配件明细'}
|
||
</Button>
|
||
</Space>
|
||
),
|
||
},
|
||
], [expandedKeys, printLoading, loadDetail]);
|
||
|
||
const expandedRowRender = (row) => {
|
||
const detail = detailCache[row.skuId];
|
||
if (detailLoading[row.skuId] && !detail) {
|
||
return <div className="mkh-detail-loading"><Spin size="small" /> 加载配件明细…</div>;
|
||
}
|
||
const items = detail?.items || [];
|
||
return (
|
||
<div className="mkh-detail">
|
||
{items.length === 0 ? (
|
||
<div className="mkh-detail-tip">
|
||
<Text type="secondary">
|
||
该 MKH 商品在业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。
|
||
上游维护好该商品的组合配件后,此处会自动显示配件明细、数量与仓位。
|
||
</Text>
|
||
</div>
|
||
) : null}
|
||
<Table
|
||
rowKey="index"
|
||
size="small"
|
||
pagination={false}
|
||
dataSource={items}
|
||
scroll={{ x: 720 }}
|
||
locale={{ emptyText: '暂无配件明细' }}
|
||
columns={[
|
||
{ title: '序号', dataIndex: 'index', width: 60, align: 'center' },
|
||
{
|
||
title: '配件明细',
|
||
dataIndex: 'skuName',
|
||
ellipsis: true,
|
||
render: (v, r) => (
|
||
<Tooltip title={`${v || ''}${r.skuId ? `(${r.skuId})` : ''}`}>
|
||
<span>{text(v)}</span>
|
||
{r.skuId ? <span className="mkh-detail-code">{r.skuId}</span> : null}
|
||
</Tooltip>
|
||
),
|
||
},
|
||
{ title: '数量', dataIndex: 'qty', width: 90, align: 'right', render: (v) => number(v) },
|
||
{
|
||
title: '配件仓位',
|
||
dataIndex: 'location',
|
||
width: 140,
|
||
render: (v) => (v ? text(v) : (
|
||
<Tooltip title="上游未回传库位数据">
|
||
<span className="mkh-muted">未维护</span>
|
||
</Tooltip>
|
||
)),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<SycmPage className="mkh-page">
|
||
<header className="mkh-header">
|
||
<div className="mkh-header__heading">
|
||
<Title level={3}>MKH-BOM 资料</Title>
|
||
<Text type="secondary">
|
||
模块化(MKH 开头)成品的编码与配件明细:配件明细、数量、配件仓位
|
||
</Text>
|
||
<Text type="secondary" className="mkh-note">
|
||
数据口径:MKH 商品编码仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的成品(当前 4408 条);
|
||
配件明细与数量优先取自业务中台组合商品 BOM,产品资料系统数据库(PostgreSQL / furniture3d_prod)中维护的配件明细作为补充;
|
||
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位未维护时显示「未维护」。
|
||
</Text>
|
||
</div>
|
||
</header>
|
||
|
||
<Card className="mkh-filter-card" bordered={false}>
|
||
<Space wrap size={[12, 12]}>
|
||
<Input
|
||
allowClear
|
||
prefix={<SearchOutlined />}
|
||
placeholder="MKH商品编码 / 名称"
|
||
value={filters.keyword}
|
||
onChange={(e) => changeFilter('keyword')(e.target.value)}
|
||
style={{ width: 260 }}
|
||
/>
|
||
<Button onClick={resetFilters}>重置</Button>
|
||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
|
||
</Space>
|
||
</Card>
|
||
|
||
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
|
||
|
||
{!loading && (data.total || 0) > 0 ? (
|
||
<Alert
|
||
type={data.bomRowCount > 0 ? 'info' : 'warning'}
|
||
showIcon
|
||
message={data.bomRowCount > 0
|
||
? `已接入 ${data.bomRowCount} 个模块化商品的配件 BOM`
|
||
: '业务组合 BOM 和产品资料系统数据库中尚无可用的模块化商品配件明细'}
|
||
description={(
|
||
<span>
|
||
MKH 商品编码({data.total || 0} 条)仅取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”;
|
||
配件明细与数量取自业务组合 BOM({data.bomBusinessRowCount || 0} 个),
|
||
「产品资料系统数据库」维护的 BOM({data.bomProductDbRowCount || 0} 个)作为补充。
|
||
BOM 中没有指定仓库商品档案的组合编码不会加入商品列表。
|
||
在资料库里补充配件明细后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
|
||
</span>
|
||
)}
|
||
/>
|
||
) : null}
|
||
|
||
<Card
|
||
bordered={false}
|
||
className="mkh-panel"
|
||
title={<span>MKH 模块化成品({data.total || 0} 条)</span>}
|
||
>
|
||
<Spin spinning={loading} tip="正在加载 MKH-BOM 资料…">
|
||
<div ref={tableWrapRef}>
|
||
<Table
|
||
rowKey="skuId"
|
||
size="small"
|
||
columns={columns}
|
||
dataSource={data.rows || []}
|
||
expandable={{
|
||
expandedRowKeys: Object.keys(expandedKeys).filter((k) => expandedKeys[k]),
|
||
expandedRowRender,
|
||
onExpand: (expanded, record) => {
|
||
setExpandedKeys((old) => ({ ...old, [record.skuId]: expanded }));
|
||
if (expanded) loadDetail(record.skuId);
|
||
},
|
||
}}
|
||
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: 1000, y: bodyHeight }}
|
||
/>
|
||
</div>
|
||
</Spin>
|
||
</Card>
|
||
|
||
{printData ? <PrintPreview data={printData} onClose={() => setPrintData(null)} /> : null}
|
||
</SycmPage>
|
||
);
|
||
};
|
||
|
||
export default MkhBomData;
|