forked from lunanting/cangku
数据补充(全部来自数据库,无人工填数): - 流水列接入 4/12:采购进仓(jst_purchase×Item,po_date,Confirmed/Finished)、 采购退货(jst_purchaseout×Item,io_date)、销售出仓(jst_Orders×OrderItem,send_date, 与周转率发货口径一致)、销售退货(jst_returnAndRefund×Item,as_date,实收 r_qty)。 其余 8 列业务库确无单据表(已核实全库表清单),保持「—」待 DBA 建表 - 期初快照缺失时自动兜底:优先用 rpt.pm_stockMonitor(报表库 01:00 跑批 T-1 库存, 按 dbWarehouse 聚合 jsStock,精确);监控表没有的虚拟仓(运营专用/京东仓)用 实时库存扣自期初时刻以来的带符号流水回推(近似),推导结果补写快照逐日滚动 缺陷修复: - 合计行仓库名显示 NaN:isTotal 渲染分支拦截了所有列,'合计' 被强转数字; 现仓库名原样显示,无数据来源的流水列合计也显示「—」而非误导性 0 - 合计行:某列全部仓库均无数据时合计置空,避免 0 误读 新增 4 项单元测试(流水符号约定、参数展开、期初双路径推导)。
492 lines
19 KiB
JavaScript
492 lines
19 KiB
JavaScript
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 }) => (
|
||
<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 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: <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'
|
||
: (isUnavailable ? '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 (isUnavailable) 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}</span>;
|
||
if (col.key === 'closingQty') return int(value);
|
||
if (col.key === 'turnoverDays') return percent(value);
|
||
if (isUnavailable) return <span className="daily-inventory-cell--na">—</span>;
|
||
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 (
|
||
<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>
|
||
{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="info"
|
||
showIcon
|
||
message="全部数据来自数据库,流水列已接入 4/12"
|
||
description={
|
||
<span>
|
||
期初数量取本地快照表中 <b>{data?.openingDate || '—'}</b> 的期末结存(每日 01:30 自动落库,逐日滚动;快照缺失时自动用库存监控表/实时库存扣流水推导补写);
|
||
期末数量与右侧 5 列为运行时从业务库实时取数。
|
||
流水列中采购进仓/采购退货/销售出仓/销售退货已从业务库单据表取数;
|
||
其余 8 列(加工/其它/盘点/调拨)因业务库缺单据表仍显示「—」,已向 DBA 提交补表需求,因此「期初+流水」与「期末」暂不相等。
|
||
{data?.noOpeningCount > 0 ? ` 其中 ${data.noOpeningCount} 个仓尚无 ${data.openingDate} 的期初快照(新纳入的仓库从次日起自动补齐)。` : ''}
|
||
{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={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;
|