youruo c47a25b002 fix: 期初兜底弃用 pm_stockMonitor,全部改用实时库存扣流水回推
口径校准(对照聚水潭进销存导出 09-23 期初基准):
  崔桥工厂仓 基准 92388 / pm 路径 114286 / 回推 ≈89650
  横林二仓   基准 63780 / pm 路径  1197  / 回推 ≈64029
  山东青岗集 基准 88377 / pm 路径  67339 / 回推 ≈88210
  河南洛宁   基准 83305 / pm 路径  76992 / 回推 ≈82473

pm_stockMonitor.jsStock 只覆盖纳入监控范围的 SKU 子集(横林二仓实测
1197 vs 全仓 63780),系统性偏低,不能充当全仓结存;「实时库存扣
流水回推」与基准偏差 0.3%~2.7%(来自缺失的 8 类单据),显著更准。
2026-09-27 10:09:19 +08:00

492 lines
19 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, 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;