youruo 0e77a70c33 feat: 每日进销存支持导入聚水潭官方进销存校正数据
业务库只能还原 4/12 个流水列且口径与聚水潭不一致(采购按单据日期、销售按订单发货日期,
jst_salesOutboundInfo.qty 全为 0),调拨/盘点/加工/其它出入库无单据表,故以官方导出为准:

- 新增本地表 daily_inventory_official(biz_date + warehouse 存期初/12 流水列/期末)
- 新增解析与导入服务:xlsx 解析、仓库 ID 映射、「期初+流水=期末」恒等式校验
- GET /api/daily-inventory 支持 ?date= 指定业务日,官方值优先、缺失回退推算并返回来源标记
- 新增 POST /api/daily-inventory/official-import(xlsx 上传/JSON)与 GET /official 查询
- 导入后回写本地库存快照(官方期初→上一日期末、官方期末→当日期末),使推算也对齐
- 前端:业务日切换、官方/推算来源标记、导入弹窗、口径说明同步更新
- 仓库清单补入「简尚-横林三仓主仓」(聚水潭导出中存在)
- 命令行导入:npm run inventory:import-official -- <日期> <xlsx>
2026-09-27 14:16:06 +08:00

683 lines
27 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, DatePicker, Input, Modal, Select, Space, Spin, Switch, Table, Tag, TimePicker,
Tooltip, Typography, Upload,
} from 'antd';
import {
BellOutlined, InfoCircleOutlined, PlusOutlined, ReloadOutlined, UploadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { SycmPage } from '../../components/Common';
import { getDailyInventory, importDailyInventoryOfficial } from '../../api/dailyInventory';
import {
getDailyInventoryNotificationConfig,
runDailyInventoryNotificationTest,
saveDailyInventoryNotificationConfig,
} from '../../api/dailyInventoryNotification.js';
import {
DAILY_INVENTORY_COLUMNS,
COLUMN_SOURCE,
FORMULA_DESCRIPTION,
} from '../../constants/dailyInventory.js';
import './style.css';
const { Title, Text } = Typography;
const TIME_FORMAT = 'HH:mm';
/** 当前登录用户是否为管理员(决定「通知设置」入口是否可见) */
function readIsAdmin() {
try {
const stored = JSON.parse(localStorage.getItem('auth_user') || 'null');
if (stored?.role) return stored.role === 'admin';
const token = localStorage.getItem('token') || '';
const payload = JSON.parse(window.atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
return payload?.role === 'admin';
} catch {
return false;
}
}
/** 收件人名单规范化:去空、去重、限长(后端会再校验一次) */
function normalizeRecipientList(list = []) {
const seen = new Set();
const result = [];
for (const raw of list) {
const name = String(raw ?? '').trim().slice(0, 50);
if (!name || seen.has(name)) continue;
seen.add(name);
result.push(name);
}
return result;
}
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 percent = (value) => {
if (value === null || value === undefined || value === '') return '—';
return `${Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}%`;
};
/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */
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 isAdmin = useMemo(readIsAdmin, []);
const [notifyOpen, setNotifyOpen] = useState(false);
const [notifyLoading, setNotifyLoading] = useState(false);
const [notifySaving, setNotifySaving] = useState(false);
const [notifyTesting, setNotifyTesting] = useState(false);
const [notifyConfig, setNotifyConfig] = useState(null);
const [notifyResult, setNotifyResult] = useState(null);
const [recipientDraft, setRecipientDraft] = useState('');
const [notifyForm, setNotifyForm] = useState({ enabled: false, notifyTime: '01:00', recipients: [] });
// ── 业务日切换 + 聚水潭官方进销存导入 ──
const [selectedDate, setSelectedDate] = useState(''); // 空 = 业务库最新业务日
const [importOpen, setImportOpen] = useState(false);
const [importDate, setImportDate] = useState(null);
const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState(null);
const load = useCallback(async (force = false) => {
setLoading(true);
setError('');
try {
const params = {};
if (selectedDate) params.date = selectedDate;
if (force) params.refresh = '1';
const res = await getDailyInventory(params);
setData(res?.data ?? null);
} catch (err) {
setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败');
setData(null);
} finally {
setLoading(false);
}
}, [selectedDate]);
useEffect(() => { load(); }, [load]);
const items = useMemo(() => data?.items ?? [], [data]);
const officialFlowKeys = useMemo(() => new Set(data?.flowColumnsOfficial || []), [data]);
const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => {
const isRuntime = col.source === COLUMN_SOURCE.runtime;
const isUnavailable = col.source === COLUMN_SOURCE.unavailable;
// 该列已导入聚水潭官方数据时,不再按「无数据来源」置灰
const noSource = isUnavailable && !officialFlowKeys.has(col.key);
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' ? 260 : 130,
fixed: col.key === 'warehouse' ? 'left' : undefined,
className: isRuntime
? 'daily-inventory-col--runtime'
: (noSource ? 'daily-inventory-col--unavailable' : undefined),
render: (value, record) => {
if (record?.isTotal) {
// 合计行:四级库存量累加,周转率按「总发货 / 总库存」重算(后端已给出)
if (col.key === 'warehouse') return <span className="daily-inventory-warehouse">{value}</span>;
// 该列整体无数据来源时,合计行同样显示「—」,避免误读为 0
if (value === null || value === undefined || value === '') {
return <span className="daily-inventory-cell--na">—</span>;
}
if (col.key === 'turnoverDays') return percent(value);
return int(value);
}
if (col.key === 'warehouse') {
return (
<span className="daily-inventory-warehouse">
{value}
{record?.source?.hasOfficial ? (
<Tooltip title="该仓已导入聚水潭官方进销存,期初/流水/期末均为官方值">
<Tag color="blue" className="daily-inventory-tag">官方</Tag>
</Tooltip>
) : (
<Tooltip title="该仓未导入聚水潭官方进销存,数值为业务库推算(流水列可能缺 8 类单据)">
<Tag className="daily-inventory-tag">推算</Tag>
</Tooltip>
)}
</span>
);
}
if (value === null || value === undefined || value === '') {
return <span className="daily-inventory-cell--na">—</span>;
}
// 数据来源标记:官方值正常显示,业务库推算值标灰提示口径差异
const cellSource = col.key === 'openingQty' ? record?.source?.opening
: col.key === 'closingQty' ? record?.source?.closing
: record?.source?.flows?.[col.key];
const wrap = (node) => (cellSource === 'derived' ? (
<Tooltip title="业务库推算值,口径与聚水潭进销存存在差异;导入该业务日的聚水潭导出后自动切换为官方值">
<span className="daily-inventory-cell--derived">{node}</span>
</Tooltip>
) : node);
if (col.key === 'openingQty' || col.key === 'openingQtyExtra' || col.key === 'closingQty') {
return wrap(int(value));
}
if (col.key === 'turnoverDays') return percent(value);
return wrap(renderSigned(value));
},
};
}), [officialFlowKeys]);
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]);
/** 业务日下拉:最新业务日 + 已导入聚水潭官方数据的日期 */
const dateOptions = useMemo(() => {
const opts = [{
value: '__latest__',
label: `最新业务日${data?.systemBizDate ? `(${data.systemBizDate})` : ''}`,
}];
(data?.official?.availableDates || [])
.filter((d) => d !== data?.systemBizDate)
.forEach((d) => opts.push({ value: d, label: `${d}(聚水潭官方)` }));
return opts;
}, [data]);
const openImport = useCallback(() => {
setImportResult(null);
setImportFile(null);
setImportDate(data?.latestBizDate ? dayjs(data.latestBizDate) : dayjs());
setImportOpen(true);
}, [data]);
const submitImport = useCallback(async () => {
if (!importFile) {
setImportResult({ type: 'error', text: '请先选择聚水潭导出的 xlsx 文件' });
return;
}
if (!importDate) {
setImportResult({ type: 'error', text: '请选择业务日期' });
return;
}
setImporting(true);
setImportResult(null);
try {
const res = await importDailyInventoryOfficial({
file: importFile,
date: importDate.format('YYYY-MM-DD'),
});
const result = res?.data ?? {};
const bizDate = result.bizDate || importDate.format('YYYY-MM-DD');
const notes = [
...(result.warnings || []),
...(result.unknownWarehouses || []).map((w) => `未匹配到仓库 ID,已按仓库名存档:${w}`),
...(result.mismatches || []).map((m) => `${m.warehouse}:期初 + 流水 − 期末 = ${m.diff}`),
];
setImportResult({
type: 'success',
text: `${bizDate} 已导入 ${result.imported || 0} 个仓库${result.mismatches?.length ? ',存在恒等式不闭合的仓库' : ',恒等式逐仓闭合'}`,
notes,
});
if (selectedDate === bizDate) await load(true);
else setSelectedDate(bizDate);
} catch (err) {
setImportResult({
type: 'error',
text: err?.response?.data?.error || err?.message || '导入失败',
notes: err?.response?.data?.detail ? [err.response.data.detail] : [],
});
} finally {
setImporting(false);
}
}, [importFile, importDate, selectedDate, load]);
// ── 通知设置:读取 / 保存 / 立即测试 ──
const applyNotifyConfig = useCallback((config = {}) => {
setNotifyConfig(config);
setNotifyForm({
enabled: config.enabled === true,
notifyTime: config.notifyTime || '01:00',
recipients: normalizeRecipientList(config.recipients || []),
});
}, []);
const loadNotifyConfig = useCallback(async () => {
setNotifyLoading(true);
try {
const res = await getDailyInventoryNotificationConfig();
applyNotifyConfig(res?.data ?? {});
} catch (err) {
setNotifyConfig({ loadError: err?.response?.data?.error || err?.message || '加载通知设置失败' });
} finally {
setNotifyLoading(false);
}
}, [applyNotifyConfig]);
useEffect(() => {
if (notifyOpen) {
setNotifyResult(null);
loadNotifyConfig();
}
}, [notifyOpen, loadNotifyConfig]);
const saveNotifySettings = useCallback(async () => {
setNotifySaving(true);
setNotifyResult(null);
try {
const res = await saveDailyInventoryNotificationConfig({
enabled: notifyForm.enabled,
notifyTime: notifyForm.notifyTime,
recipients: normalizeRecipientList(notifyForm.recipients),
});
applyNotifyConfig(res?.data?.admin ?? res?.data ?? {});
setNotifyResult({ type: 'success', text: '通知设置已保存' });
} catch (err) {
setNotifyResult({
type: 'error',
text: err?.response?.data?.error || err?.message || '保存通知设置失败',
});
} finally {
setNotifySaving(false);
}
}, [notifyForm, applyNotifyConfig]);
const runNotifyTest = useCallback(async () => {
setNotifyTesting(true);
setNotifyResult(null);
try {
const res = await runDailyInventoryNotificationTest();
const result = res?.data ?? {};
if (result.success) {
setNotifyResult({
type: 'success',
text: `测试完成:截图已上传,成功通知 ${(result.recipients || []).filter((r) => r.status === 'sent').length} 人`,
url: result.screenshotUrl,
});
} else {
setNotifyResult({ type: 'error', text: `测试未成功:${result.error || result.reason || '未知原因'}` });
}
loadNotifyConfig();
} catch (err) {
setNotifyResult({
type: 'error',
text: err?.response?.data?.error || err?.message || '执行测试失败',
});
} finally {
setNotifyTesting(false);
}
}, [loadNotifyConfig]);
const addRecipients = useCallback(() => {
const incoming = normalizeRecipientList(String(recipientDraft || '').split(/[,,;;\n]+/));
if (!incoming.length) return;
setNotifyForm((old) => ({
...old,
recipients: normalizeRecipientList([...(old.recipients || []), ...incoming]),
}));
setRecipientDraft('');
}, [recipientDraft]);
const removeRecipient = useCallback((name) => {
setNotifyForm((old) => ({
...old,
recipients: (old.recipients || []).filter((item) => item !== name),
}));
}, []);
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?.openingDate || '—'}</span>
<span>库存快照日:{data?.latestBizDate || '—'}</span>
<span>分级基准时刻:{data?.asOf || '—'}</span>
{data?.shipWindow ? (
<span>
发货统计窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天)
</span>
) : null}
{data?.items?.[0]?.closingTakenAt ? (
<span>
库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })}
</span>
) : null}
</div>
<Space wrap>
<Tooltip title="切换业务日:导入过聚水潭进销存导出的日期会以官方值显示">
<Select
style={{ width: 210 }}
value={selectedDate || '__latest__'}
onChange={(v) => setSelectedDate(v === '__latest__' ? '' : v)}
options={dateOptions}
/>
</Tooltip>
{isAdmin ? (
<Tooltip title="上传聚水潭「进销存按仓导出」xlsx,按业务日校正期初 / 12 个流水列 / 期末">
<Button icon={<UploadOutlined />} onClick={openImport}>导入聚水潭进销存</Button>
</Tooltip>
) : null}
{isAdmin ? (
<Button icon={<BellOutlined />} onClick={() => setNotifyOpen(true)}>通知设置</Button>
) : null}
<Button icon={<ReloadOutlined />} onClick={() => load(false)} loading={loading}>刷新</Button>
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细重算周转率与分级(约 15~20 秒)">
<Button onClick={() => load(true)} loading={loading}>强制重算</Button>
</Tooltip>
</Space>
</div>
<Alert
type={data?.official?.hasData ? 'success' : 'info'}
showIcon
message={data?.official?.hasData
? `已按聚水潭官方进销存校正(${data.latestBizDate}):12 个流水列全部为官方值`
: '尚未导入聚水潭官方进销存:流水列为业务库推算值'}
description={
<span>
{data?.official?.hasData ? (
<>
本页 <b>{data.latestBizDate}</b> 的期初、12 个流水列、期末取自聚水潭「进销存按仓导出」,
共 <b>{data.official.warehouseCount}</b> 个仓;
{data.official.balanceMismatch?.length
? `其中 ${data.official.balanceMismatch.length} 个仓「期初 + 流水 ≠ 期末」,请核对导出文件。`
: '「期初 + 流水 = 期末」逐仓闭合。'}
{' '}未纳入该导出的仓库仍用业务库推算(仓库列标记为「推算」)。
可在右上角切换业务日或重新导入。
</>
) : (
<>
业务库只能还原 4/12 个流水列,且口径与聚水潭进销存不一致(采购按单据日期、销售按订单发货日期),
调拨/盘点/加工/其它出入库没有对应单据表。
请在右上角「导入聚水潭进销存」上传导出文件,导入后该业务日的期初与 12 个流水列即为官方值。
</>
)}
{data?.staleMetrics ? ` 当前查看的是历史业务日 ${data.latestBizDate},右侧周转率与四级库存量为当前实时口径。` : ''}
{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={notifyOpen}
onCancel={() => setNotifyOpen(false)}
width={720}
destroyOnHidden
styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }}
footer={(
<Space wrap>
<Button type="primary" loading={notifySaving} disabled={notifyLoading} onClick={saveNotifySettings}>
保存设置
</Button>
<Tooltip title="真实执行一次截图、上传与通知;测试不会占用正式推送的幂等记录">
<Button loading={notifyTesting} disabled={notifyLoading} onClick={runNotifyTest}>
立即测试
</Button>
</Tooltip>
<Button onClick={() => setNotifyOpen(false)}>关闭</Button>
</Space>
)}
>
<Spin spinning={notifyLoading}>
{notifyConfig?.loadError ? (
<Alert type="error" showIcon message={notifyConfig.loadError} style={{ marginBottom: 12 }} />
) : null}
{notifyConfig?.blockingReasons?.length ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message="当前不会自动推送,原因如下"
description={<span>{notifyConfig.blockingReasons.join(';')}</span>}
/>
) : null}
<div className="daily-inventory-notify-form">
<div className="daily-inventory-notify-row">
<span className="daily-inventory-notify-label">启用每日截图通知</span>
<Switch
checked={notifyForm.enabled}
onChange={(checked) => setNotifyForm((old) => ({ ...old, enabled: checked }))}
/>
</div>
<div className="daily-inventory-notify-row">
<span className="daily-inventory-notify-label">通知时间</span>
<TimePicker
format={TIME_FORMAT}
minuteStep={5}
allowClear={false}
value={dayjs(`2026-01-01T${notifyForm.notifyTime}:00`)}
onChange={(value) => setNotifyForm((old) => ({
...old,
notifyTime: value ? value.format(TIME_FORMAT) : '01:00',
}))}
style={{ width: 140 }}
/>
<Text type="secondary">({notifyConfig?.timezone || 'Asia/Shanghai'},默认 01:00)</Text>
</div>
<div className="daily-inventory-notify-row">
<span className="daily-inventory-notify-label">通知用户</span>
<Input
allowClear
placeholder="输入通知中心中的姓名或别名,回车添加;多个可用逗号分隔"
value={recipientDraft}
onChange={(e) => setRecipientDraft(e.target.value)}
onPressEnter={addRecipients}
style={{ width: 320 }}
/>
<Button icon={<PlusOutlined />} onClick={addRecipients} disabled={!String(recipientDraft || '').trim()}>
添加
</Button>
</div>
<div className="daily-inventory-notify-recipients">
{notifyForm.recipients.length ? (
notifyForm.recipients.map((name) => (
<Tag key={name} closable onClose={() => removeRecipient(name)}>{name}</Tag>
))
) : (
<Text type="secondary">尚未添加通知用户</Text>
)}
</div>
<Text type="secondary">
自动去除空值与重复姓名,最多 {notifyConfig?.maxRecipients || 20} 人。
</Text>
</div>
{notifyResult ? (
<Alert
type={notifyResult.type}
showIcon
style={{ marginTop: 12 }}
message={notifyResult.text}
description={notifyResult.url ? (
<a href={notifyResult.url} target="_blank" rel="noreferrer">{notifyResult.url}</a>
) : null}
/>
) : null}
{notifyConfig && !notifyConfig.loadError ? (
<div className="daily-inventory-notify-meta">
<div>
统一通知中心:
<Text type={notifyConfig.notificationCenter?.configured ? 'success' : 'warning'} strong>
{notifyConfig.notificationCenter?.configured ? '已配置' : '未配置'}
</Text>
{notifyConfig.notificationCenter?.baseUrl ? (
<Text type="secondary">{` ${notifyConfig.notificationCenter.baseUrl}`}</Text>
) : null}
</div>
<div>
对象存储:
<Text type={notifyConfig.objectStorage?.configured ? 'success' : 'warning'} strong>
{notifyConfig.objectStorage?.configured ? '已配置' : '未配置'}
</Text>
{notifyConfig.objectStorage?.bucket ? (
<Text type="secondary">{` 桶 ${notifyConfig.objectStorage.bucket}`}</Text>
) : null}
</div>
<div>
上次执行:
<b>{notifyConfig.lastRunAt ? new Date(notifyConfig.lastRunAt).toLocaleString('zh-CN') : '—'}</b>
{notifyConfig.lastRunSuccess === null ? '' : (
<Text type={notifyConfig.lastRunSuccess ? 'success' : 'danger'}>
{` ${notifyConfig.lastRunSuccess ? '成功' : '失败'}`}
</Text>
)}
</div>
</div>
) : null}
{notifyConfig?.runs?.length ? (
<div className="daily-inventory-notify-runs">
<Text strong>最近执行记录</Text>
{notifyConfig.runs.slice(0, 5).map((run, index) => (
<div key={`${run.runAt}-${index}`} className="daily-inventory-notify-run">
<span>{new Date(run.runAt).toLocaleString('zh-CN')}</span>
<Text type={run.success ? 'success' : 'danger'}>
{run.success ? '成功' : `失败(${run.error || run.reason || '未知原因'})`}
</Text>
{run.screenshotUrl ? (
<a href={run.screenshotUrl} target="_blank" rel="noreferrer">截图</a>
) : null}
</div>
))}
</div>
) : null}
</Spin>
</Modal>
<Modal
title="导入聚水潭进销存(按仓)"
open={importOpen}
onCancel={() => setImportOpen(false)}
width={620}
destroyOnHidden
footer={(
<Space wrap>
<Button type="primary" loading={importing} onClick={submitImport}>开始导入</Button>
<Button onClick={() => setImportOpen(false)}>关闭</Button>
</Space>
)}
>
<div className="daily-inventory-import">
<div className="daily-inventory-import-row">
<span className="daily-inventory-import-label">业务日期</span>
<DatePicker
value={importDate}
onChange={(v) => setImportDate(v)}
allowClear={false}
style={{ width: 180 }}
/>
<Text type="secondary">导出文件对应的进销存日期</Text>
</div>
<div className="daily-inventory-import-row">
<span className="daily-inventory-import-label">导出文件</span>
<Upload
accept=".xlsx,.xls"
maxCount={1}
fileList={importFile ? [{ uid: 'import', name: importFile.name, status: 'done' }] : []}
beforeUpload={(file) => { setImportFile(file); return false; }}
onRemove={() => { setImportFile(null); return true; }}
>
<Button icon={<UploadOutlined />}>选择文件</Button>
</Upload>
</div>
<Text type="secondary">
需包含「仓库 / 期初数量 / 12 个流水列 / 期末数量」表头;同一业务日重复导入会整体覆盖。
导入后会自动回写本地库存快照,使「今日期初 = 昨日结存」的推算也对齐聚水潭口径。
</Text>
{importResult ? (
<Alert
type={importResult.type}
showIcon
style={{ marginTop: 12 }}
message={importResult.text}
description={importResult.notes?.length
? <span>{importResult.notes.join(';')}</span>
: null}
/>
) : null}
</div>
</Modal>
<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;