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 }) => ( {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 [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: {col.label}, 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 {value}; // 该列整体无数据来源时,合计行同样显示「—」,避免误读为 0 if (value === null || value === undefined || value === '') { return —; } if (col.key === 'turnoverDays') return percent(value); return int(value); } if (col.key === 'warehouse') { return ( {value} {record?.source?.hasOfficial ? ( 官方 ) : ( 推算 )} ); } if (value === null || value === undefined || value === '') { return —; } // 数据来源标记:官方值正常显示,业务库推算值标灰提示口径差异 const cellSource = col.key === 'openingQty' ? record?.source?.opening : col.key === 'closingQty' ? record?.source?.closing : record?.source?.flows?.[col.key]; const wrap = (node) => (cellSource === 'derived' ? ( {node} ) : 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 (
每日进销存
期初日期:{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}
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} setImportOpen(false)} width={620} destroyOnHidden footer={( )} >
业务日期 setImportDate(v)} allowClear={false} style={{ width: 180 }} /> 导出文件对应的进销存日期
导出文件 { setImportFile(file); return false; }} onRemove={() => { setImportFile(null); return true; }} >
需包含「仓库 / 期初数量 / 12 个流水列 / 期末数量」表头;同一业务日重复导入会整体覆盖。 导入后会自动回写本地库存快照,使「今日期初 = 昨日结存」的推算也对齐聚水潭口径。 {importResult ? ( {importResult.notes.join(';')} : null} /> ) : null}
setHelpOpen(false)} footer={null} width={720} >
{FORMULA_DESCRIPTION.map((line, i) => (

{line}

))}
); }; export default DailyInventory;