feat: 新增每日进销存(大货库存监控)
This commit is contained in:
parent
819cd08de2
commit
d3f36d5359
177
frontend/src/pages/DailyInventory/index.jsx
Normal file
177
frontend/src/pages/DailyInventory/index.jsx
Normal file
@ -0,0 +1,177 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Modal, Space, Spin, Table, Tooltip, Typography } from 'antd';
|
||||
import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import { getDailyInventory } from '../../api/dailyInventory';
|
||||
import {
|
||||
DAILY_INVENTORY_COLUMNS,
|
||||
COLUMN_SOURCE,
|
||||
FORMULA_DESCRIPTION,
|
||||
} from '../../constants/dailyInventory.js';
|
||||
import './style.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const TipTitle = ({ tip, children }) => (
|
||||
<Tooltip title={tip}>
|
||||
<span className="daily-inventory-th-tip">{children}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const int = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
};
|
||||
|
||||
const decimal = (value, digits = 1) => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
return Number(value).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
};
|
||||
|
||||
/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */
|
||||
const renderSigned = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n === 0) return <span className="daily-inventory-cell--zero">0</span>;
|
||||
return <span className={n < 0 ? 'daily-inventory-cell--out' : 'daily-inventory-cell--in'}>{int(n)}</span>;
|
||||
};
|
||||
|
||||
const DailyInventory = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [data, setData] = useState(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await getDailyInventory();
|
||||
setData(res?.data ?? null);
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败');
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const items = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => {
|
||||
const isRuntime = col.source === COLUMN_SOURCE.runtime;
|
||||
return {
|
||||
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
|
||||
dataIndex: col.key,
|
||||
key: col.key,
|
||||
align: col.key === 'warehouse' ? 'left' : 'right',
|
||||
width: col.key === 'warehouse' ? 240 : 130,
|
||||
fixed: col.key === 'warehouse' ? 'left' : undefined,
|
||||
className: isRuntime ? 'daily-inventory-col--runtime' : undefined,
|
||||
render: (value, record) => {
|
||||
if (record?.isTotal) {
|
||||
// 合计行:周转天数不累加,显示「—」
|
||||
if (col.key === 'turnoverDays') return '—';
|
||||
return int(value);
|
||||
}
|
||||
if (col.key === 'warehouse') return <span className="daily-inventory-warehouse">{value}</span>;
|
||||
if (col.key === 'closingQty') return int(value);
|
||||
if (col.key === 'turnoverDays') return decimal(value, 1);
|
||||
return renderSigned(value);
|
||||
},
|
||||
};
|
||||
}), []);
|
||||
|
||||
const tableRows = useMemo(() => {
|
||||
if (!data?.totals) return items;
|
||||
return [...items, { ...data.totals, key: '__total__' }];
|
||||
}, [items, data]);
|
||||
|
||||
const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]);
|
||||
|
||||
return (
|
||||
<SycmPage className="daily-inventory-page">
|
||||
<div className="daily-inventory-header">
|
||||
<div className="daily-inventory-header__title">
|
||||
<Title level={4} style={{ margin: 0 }}>每日进销存</Title>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => setHelpOpen(true)}
|
||||
className="daily-inventory-header__help"
|
||||
>
|
||||
口径说明
|
||||
</Button>
|
||||
</div>
|
||||
<div className="daily-inventory-header__meta">
|
||||
<span>基准日期:{data?.baselineDate || '—'}</span>
|
||||
<span>库存快照日:{data?.latestBizDate || '—'}</span>
|
||||
<span>分级基准时刻:{data?.asOf || '—'}</span>
|
||||
{data?.items?.[0]?.closingTakenAt ? (
|
||||
<span>
|
||||
库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="数据来源混合,请注意口径"
|
||||
description={
|
||||
<span>
|
||||
期初数量与 12 个流水列取自 <b>{data?.baselineDate || '2026-09-22'}</b> 聚水潭进销存导出值(业务库缺少调拨/盘点/加工/其它出入库单据表,无法还原);
|
||||
期末数量与右侧 5 列为运行时实时取数。悬浮表头可看每列口径。
|
||||
{noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在库存快照中无数据,周转天数与四级库存量显示「—」。` : ''}
|
||||
</span>
|
||||
}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table
|
||||
className="daily-inventory-table"
|
||||
columns={columns}
|
||||
dataSource={tableRows}
|
||||
rowKey={(record) => record.key || record.warehouse}
|
||||
size="small"
|
||||
bordered
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content', y: 'calc(100vh - 300px)' }}
|
||||
rowClassName={(record) => (record?.isTotal ? 'daily-inventory-row--total' : '')}
|
||||
/>
|
||||
</Spin>
|
||||
|
||||
<Text type="secondary" className="daily-inventory-footer">
|
||||
共 {items.length} 个仓库;蓝色底纹列为运行时取数,其余为基准值。
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
title="计算口径说明"
|
||||
open={helpOpen}
|
||||
onCancel={() => setHelpOpen(false)}
|
||||
footer={null}
|
||||
width={720}
|
||||
>
|
||||
<div className="daily-inventory-help">
|
||||
{FORMULA_DESCRIPTION.map((line, i) => (
|
||||
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyInventory;
|
||||
Loading…
x
Reference in New Issue
Block a user