178 lines
6.3 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, 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;