import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, Button, Input, Modal, Space, Spin, Switch, Table, Tag, TimePicker, Tooltip, Typography, } from 'antd'; import { BellOutlined, InfoCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import { SycmPage } from '../../components/Common'; import { getDailyInventory } 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 }) => ( {children} ); 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 0; return {int(n)}; }; 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 load = useCallback(async (force = false) => { setLoading(true); setError(''); try { const res = await getDailyInventory(force ? { refresh: '1' } : {}); 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; const isUnavailable = col.source === COLUMN_SOURCE.unavailable; return { title: {col.label}, 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' : (isUnavailable ? 'daily-inventory-col--unavailable' : undefined), render: (value, record) => { if (record?.isTotal) { // 合计行:四级库存量累加,周转率按「总发货 / 总库存」重算(后端已给出) if (col.key === 'warehouse') return {value}; // 流水列暂无数据来源时,合计行同样显示「—」,避免误读为 0 if (isUnavailable) return —; if (col.key === 'turnoverDays') return percent(value); return int(value); } if (col.key === 'warehouse') return {value}; if (col.key === 'closingQty') return int(value); if (col.key === 'turnoverDays') return percent(value); if (isUnavailable) return —; 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]); // ── 通知设置:读取 / 保存 / 立即测试 ── 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 (
每日进销存
期初日期:{data?.openingDate || '—'} 库存快照日:{data?.latestBizDate || '—'} 分级基准时刻:{data?.asOf || '—'} {data?.shipWindow ? ( 发货统计窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天) ) : null} {data?.items?.[0]?.closingTakenAt ? ( 库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })} ) : null}
{isAdmin ? ( ) : null}
期初数量取本地快照表中 {data?.openingDate || '—'} 的期末结存(每日 01:30 自动落库,逐日滚动;快照缺失时自动用库存监控表/实时库存扣流水推导补写); 期末数量与右侧 5 列为运行时从业务库实时取数。 流水列中采购进仓/采购退货/销售出仓/销售退货已从业务库单据表取数; 其余 8 列(加工/其它/盘点/调拨)因业务库缺单据表仍显示「—」,已向 DBA 提交补表需求,因此「期初+流水」与「期末」暂不相等。 {data?.noOpeningCount > 0 ? ` 其中 ${data.noOpeningCount} 个仓尚无 ${data.openingDate} 的期初快照(新纳入的仓库从次日起自动补齐)。` : ''} {noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在聚水潭库存表中无有效库存,周转率与四级库存量显示「—」。` : ''} } style={{ marginBottom: 12 }} /> {error ? : null} 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' : '')} /> 共 {items.length} 个仓库;蓝色底纹列为运行时实时取数,灰色列为暂无单据来源,其余为本地快照表取值。 setNotifyOpen(false)} width={720} destroyOnHidden styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }} footer={( )} > {notifyConfig?.loadError ? ( ) : null} {notifyConfig?.blockingReasons?.length ? ( {notifyConfig.blockingReasons.join(';')}} /> ) : null}
启用每日截图通知 setNotifyForm((old) => ({ ...old, enabled: checked }))} />
通知时间 setNotifyForm((old) => ({ ...old, notifyTime: value ? value.format(TIME_FORMAT) : '01:00', }))} style={{ width: 140 }} /> ({notifyConfig?.timezone || 'Asia/Shanghai'},默认 01:00)
通知用户 setRecipientDraft(e.target.value)} onPressEnter={addRecipients} style={{ width: 320 }} />
{notifyForm.recipients.length ? ( notifyForm.recipients.map((name) => ( removeRecipient(name)}>{name} )) ) : ( 尚未添加通知用户 )}
自动去除空值与重复姓名,最多 {notifyConfig?.maxRecipients || 20} 人。
{notifyResult ? ( {notifyResult.url} ) : null} /> ) : null} {notifyConfig && !notifyConfig.loadError ? (
统一通知中心: {notifyConfig.notificationCenter?.configured ? '已配置' : '未配置'} {notifyConfig.notificationCenter?.baseUrl ? ( {` ${notifyConfig.notificationCenter.baseUrl}`} ) : null}
对象存储: {notifyConfig.objectStorage?.configured ? '已配置' : '未配置'} {notifyConfig.objectStorage?.bucket ? ( {` 桶 ${notifyConfig.objectStorage.bucket}`} ) : null}
上次执行: {notifyConfig.lastRunAt ? new Date(notifyConfig.lastRunAt).toLocaleString('zh-CN') : '—'} {notifyConfig.lastRunSuccess === null ? '' : ( {` ${notifyConfig.lastRunSuccess ? '成功' : '失败'}`} )}
) : null} {notifyConfig?.runs?.length ? (
最近执行记录 {notifyConfig.runs.slice(0, 5).map((run, index) => (
{new Date(run.runAt).toLocaleString('zh-CN')} {run.success ? '成功' : `失败(${run.error || run.reason || '未知原因'})`} {run.screenshotUrl ? ( 截图 ) : null}
))}
) : null}
setHelpOpen(false)} footer={null} width={720} >
{FORMULA_DESCRIPTION.map((line, i) => (

{line}

))}
); }; export default DailyInventory;