业务库只能还原 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>
683 lines
27 KiB
JavaScript
683 lines
27 KiB
JavaScript
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;
|