yanhui 530d1ad27a feat: 仓储中心发货时效独立页面 + 口径定版(审核时间分轮/快递剔除/停更告警)
一、前端:从 MkhBomData 侧栏抽成独立页面
- 新增 frontend/src/pages/WarehouseShipTimeliness/{index.jsx,style.css},
  路由 /planning/operation/warehouse-ship-timeliness,侧栏导航与 BasicLayout 各加 1 行入口。
- 删除 frontend/src/pages/MkhBomData/WarehouseShipTimelinessSidebar.jsx,
  并把 MkhBomData/index.jsx、style.css 里相关代码与样式一并摘除(-131 / -277 行)。

二、业务口径(2026-10-20 业务多次纠偏后的定版)
- 轮次归属改用「审核时间」= jst_order_express_details.last_oa_confirm_time
  (聚水潭订单列表「审核时间」列,最后一次审核,重审会刷新):
  13:00 轮 = 今天 07:00 前审核 + 历史未发;18:00 轮 = 截止 16:00 前审核 + 历史未发。
  不再按「创建时间」切自然日窗口,也不再有「满 24 小时」门槛。
- 仓级例外(仅 18:00 轮):山东青岗集仓 / 山东品牌仓 15:00 前审核、河南洛宁仓 13:00 前审核,
  核验时刻仍为 18:00(registry 新增 windowEndHourByRound)。
- 明细去掉「创建时间」,改显示「发货时间」(send_time);未发但有运单号的标「已打单 · 未揽收」。

三、已发判定与不考核快递
- 已发 = 状态表 send_time 非空 OR 订单表 status='Sent'(对齐聚水潭销售订单口径)。
  不再用运单号反推(已发货与未揽收单都会出现运单号,判不出来)。
- 自提 / 中铁 / 壹米滴答 三家快递不做未发考核,剔除量计入 summary.carrierSkipped。

四、数据源停更必须让用户看见
- freshness 由写死 stale:false 改为实算:快照距今 >90 分钟 → 黄告警;
  快照早于本轮核验时刻 → 红告警「数据未覆盖核验时刻,超时单量偏大」。

五、测试
- backend/test/warehouseShipTimeliness.test.js 扩到 17 用例(仓级例外、重审取 last_oa、
  快递剔除、Sent 判已发、coversDeadline 告警)。
2026-10-21 07:52:02 +08:00

1355 lines
57 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, useRef, useState } from 'react';
import {
Alert, Button, Card, Collapse, Dropdown, Input, Modal, Progress, Segmented, Select, Space, Spin, Switch,
Table, Tooltip, Typography, message,
} from 'antd';
import {
BellOutlined, DownloadOutlined, PrinterOutlined, ReloadOutlined, SearchOutlined, SyncOutlined,
} from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import {
getMkhBomList,
getMkhBomDetail,
getMkhBomExportRows,
getMkhReplenishAlertConfig,
saveMkhReplenishAlertConfig,
saveMkhNotificationCenter,
checkMkhReplenishAlert,
syncMkhBom,
} from '../../api/mkhBom';
import { createRequestGate } from '../../utils/requestGate';
import { requireMkhFreshContract } from '../../utils/mkhFreshContract';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
const { Title, Text } = Typography;
const number = (value, digits = 0) => (value === null || value === undefined || value === ''
? '0'
: Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}));
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
const EMPTY_FILTERS = { keyword: '', scope: 'warning' };
// 页面长时打开时定期重读。后端共享 10 分钟索引缓存,因此多个页面的轮询
// 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。
const AUTO_REFRESH_MS = 2 * 60 * 1000;
const getRefreshProgress = (elapsedSeconds) => {
if (elapsedSeconds < 3) {
return { percent: 12 + elapsedSeconds * 8, stage: '正在读取实时库存、箱表和加工单…' };
}
if (elapsedSeconds < 15) {
return { percent: 36 + (elapsedSeconds - 3) * 3, stage: '正在核对待发货订单和近15天销量…' };
}
return {
percent: Math.min(95, 72 + Math.floor((elapsedSeconds - 15) / 2)),
stage: '正在合并最新数据与 BOM 配件结果…',
};
};
// 预警等级 → 标签配色
const WARNING_TONE = {
缺货: 'danger',
预警: 'warn',
已排产: 'scheduled',
正常: 'ok',
无销量: 'muted',
};
// 配件不足说明(口径见后端 mkhBomService 文件头 ⑪):
// 可组装数 = min(⌊(配件库存 − 未完成加工单占用) ÷ 单台用量⌋),为 0 即凑不出一台、补不出来
const shortageTooltip = (row) => {
const bottlenecks = Array.isArray(row.bottleneckComponents) ? row.bottleneckComponents : [];
const details = bottlenecks
.filter((item) => Number(item.available) <= 0)
.map((item) => `短板 ${item.code}${item.name ? `(${item.name})` : ''}`
+ `:库存 ${number(item.stock)}`
+ `${Number(item.occupied) > 0 ? `,未完成加工单占用 ${number(item.occupied)}` : ''}`
+ `,单台用量 ${number(item.perQty)}`);
return [
`配件不足,无法补货:现有配件最多只能组装 ${number(row.assemblableQty)} 台`,
...details,
'口径:可组装数 = 各配件 ⌊(库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小;为 0 即补不出来',
].filter(Boolean).join(';');
};
// 展示范围切换
const SCOPE_OPTIONS = [
{ label: '仅预警', value: 'warning' },
{ label: '有日销', value: 'sold' },
{ label: '加工在途', value: 'work-order' },
{ label: '全部', value: 'all' },
];
// ── 导出(列口径与后端 listMkhBomExportRows 的字段一一对应)──────────
// 商品清单:与页面表格列一致,去掉「操作」列
const PRODUCT_EXPORT_COLUMNS = [
{ title: 'MKH商品编码', dataIndex: 'skuId' },
{ title: '商品名称', dataIndex: 'productName' },
{ title: '预警', dataIndex: 'warningLevel' },
{ title: '近15天日销', dataIndex: 'dailySale' },
{ title: '现有库存', dataIndex: 'stock' },
{ title: '在途加工量', dataIndex: 'pendingWorkOrderQty' },
{ title: '待发货订单', dataIndex: 'pendingOrderQty' },
{ title: '结余库存', dataIndex: 'availableStock' },
{
title: '可用天数',
dataIndex: 'stockDays',
// 无销量且无代发订单的商品 stockDays 为 null,导出成文字而非空白;
// 订单驱动分支(日销=0 但有代发/待发货订单)现已如实给出可用天数(库存÷代发件数),不会进这里
exportValue: (value, row) => {
if (value !== null && value !== undefined) return value;
return row.orderDriven ? '无日销·按订单' : '无销量';
},
},
{
title: '建议补货量',
dataIndex: 'replenishQty',
// 与页面一致:非预警商品不显示数字;配件不足(可组装数 = 0)时显示「—」不给补货目标
exportValue: (value, row) => {
if (row.componentShortage) return '—';
return (row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : '';
},
},
{ title: '配件不足', dataIndex: 'componentShortage' },
{ title: 'BOM 状态', dataIndex: 'bomConfigured' },
{ title: '配件种类', dataIndex: 'accessoryCount' },
{ title: '配件总数量', dataIndex: 'accessoryQtySum' },
];
// 配件明细:与打印模板一致,额外带部件条码便于下游核对
const COMPONENT_EXPORT_COLUMNS = [
{ title: 'MKH商品编码', dataIndex: 'skuId' },
{ title: '部件条码', dataIndex: 'componentBarcode' },
{ title: '配件名称', dataIndex: 'componentName' },
{ title: '配件数量', dataIndex: 'qty' },
{ title: '仓位', dataIndex: 'location' },
];
// 导出下拉菜单:key = `<mode>-<format>`
const EXPORT_MENU_ITEMS = [
{ key: 'products-xlsx', label: '商品清单 · XLSX' },
{ key: 'products-csv', label: '商品清单 · CSV' },
{ type: 'divider' },
{ key: 'components-xlsx', label: '配件明细 · XLSX' },
{ key: 'components-csv', label: '配件明细 · CSV' },
];
const EXPORT_SCOPE_LABEL = {
warning: '仅预警', sold: '有日销', 'work-order': '加工在途', all: '全部',
};
// 补货预警推送:范围选项与跳过原因文案
const ALERT_SCOPE_OPTIONS = [
{ label: '缺货 + 3天内预警', value: 'warning' },
{ label: '仅缺货', value: 'outOfStock' },
];
const ALERT_REASON_TEXT = {
no_new_alert: '没有新出现的预警,未发送(已推送过的不重复提醒)',
no_alert: '当前没有需要补货的 MKH 商品,未发送',
disabled: '推送开关已关闭,未发送',
recipient_not_configured: '未配置收件人,未发送',
notification_center_not_configured: '统一通知中心尚未配置,未发送',
error: '执行出错,未发送',
};
const ALERT_RECIPIENT_SOURCE_TEXT = {
config: '来自本页配置',
environment: '来自环境变量',
notification_center: '来自统一通知中心默认收件人',
none: '未找到可用的收件人',
};
// 「现有库存」列的取值来源说明(后端 stockSource 字段):
// realtime = 品牌仓实时库存表(jst_inventoryInfo,日更)
// pack = 品牌仓箱/货位表(jst_packInventoryInfo,小时级)——
// 实时库存表当天还没同步到这条货(当日入库)时用它兜底
// monitor = 两个实时源都没有记录,退回监控快照库存
const STOCK_SOURCE_TEXT = {
realtime: '品牌仓实时库存',
pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)',
monitor: '品牌仓实时库存缺失,取监控快照库存',
'live-missing': '实时库存表和箱/货位库存表均无记录,实时口径按 0 计算',
};
// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位
// 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」),
// 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。
const stripWarehousePrefix = (value) => String(value || '')
.split('、')
.map((part) => {
const idx = part.lastIndexOf('·');
return (idx >= 0 ? part.slice(idx + 1) : part).trim();
})
.filter(Boolean)
.join('、');
const PrintPreview = ({ data, onClose }) => {
const now = useMemo(() => new Date().toLocaleString('zh-CN'), []);
const items = data.items || [];
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div className="mkh-print-mask">
<div className="mkh-print-toolbar">
<Space>
<Button type="primary" icon={<PrinterOutlined />} onClick={() => window.print()}>一键打印</Button>
<Button onClick={onClose}>关闭</Button>
<Text type="secondary">按 Esc 关闭预览</Text>
</Space>
</div>
<div className="mkh-print-sheet">
<div className="mkh-print-head">
<div className="mkh-print-title">MKH-BOM 资料</div>
<div className="mkh-print-sub">模块化商品配件明细 · 打印模板</div>
</div>
<div className="mkh-print-meta">
<div><span>打印时间:</span><b>{now}</b></div>
<div><span>配件种类:</span><b>{items.length}</b></div>
</div>
<table className="mkh-print-table">
<thead>
<tr>
<th style={{ width: '190px' }}>MKH商品编码</th>
<th>配件明细</th>
<th style={{ width: '64px' }}>数量</th>
<th style={{ width: '110px' }}>配件仓位</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={item.index}>
{idx === 0 ? (
<td className="mkh-print-sku" rowSpan={items.length}>{text(data.skuId)}</td>
) : null}
<td>{text(item.skuName)}{item.skuId ? <span className="mkh-print-code">{item.skuId}</span> : null}</td>
<td className="mkh-center">{number(item.qty)}</td>
<td className="mkh-center">{item.location ? text(stripWarehousePrefix(item.location)) : <span className="mkh-muted">未维护</span>}</td>
</tr>
))}
{items.length === 0 ? (
<tr>
<td className="mkh-print-sku">{text(data.skuId)}</td>
<td colSpan={3} className="mkh-muted">该 MKH 商品尚无可用的聚水潭商品 BOM 明细</td>
</tr>
) : null}
</tbody>
</table>
<div className="mkh-print-foot">
<span>
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充;
配件仓位只取聚水潭「箱及仓位库存」,未维护货位时显示箱号,无装箱记录显示「未维护」。
</span>
</div>
</div>
</div>
);
};
const MkhBomData = () => {
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
// 默认按可用天数升序 —— 补货预警场景下最紧急的排最前
const [sorter, setSorter] = useState({ field: 'stockDays', order: 'ASC' });
const [expandedKeys, setExpandedKeys] = useState({});
const [detailCache, setDetailCache] = useState({});
const [detailLoading, setDetailLoading] = useState({});
const [printData, setPrintData] = useState(null);
const [printLoading, setPrintLoading] = useState(false);
// 导出(当前筛选条件下的全部数据)
const [exporting, setExporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const [refreshProgress, setRefreshProgress] = useState({ open: false, elapsedSeconds: 0, title: '' });
// 补货预警推送设置
const [alertOpen, setAlertOpen] = useState(false);
const [alertConfig, setAlertConfig] = useState(null);
const [alertLoading, setAlertLoading] = useState(false);
const [alertBusy, setAlertBusy] = useState(false);
const [alertForm, setAlertForm] = useState({ enabled: true, recipientAlias: '', scope: 'warning' });
const [alertResult, setAlertResult] = useState(null);
const [centerForm, setCenterForm] = useState({ baseUrl: '', projectCode: '', projectSecret: '' });
const [centerSaving, setCenterSaving] = useState(false);
const tableWrapRef = useRef(null);
const listRequestGateRef = useRef(createRequestGate());
const detailGenerationRef = useRef(0);
const refreshProgressGenerationRef = useRef(0);
const lastListRefreshAtRef = useRef(0);
const autoRefreshPendingRef = useRef(false);
const [bodyHeight, setBodyHeight] = useState(420);
const beginRefreshProgress = useCallback((title) => {
refreshProgressGenerationRef.current += 1;
const generation = refreshProgressGenerationRef.current;
setRefreshProgress({ open: true, elapsedSeconds: 0, title, generation });
return generation;
}, []);
const endRefreshProgress = useCallback((generation) => {
if (generation !== refreshProgressGenerationRef.current) return;
setRefreshProgress((current) => ({ ...current, open: false }));
}, []);
useEffect(() => {
if (!refreshProgress.open) return undefined;
const startedAt = Date.now();
const timer = window.setInterval(() => {
setRefreshProgress((current) => ({
...current,
elapsedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000)),
}));
}, 1000);
return () => window.clearInterval(timer);
}, [refreshProgress.open]);
useEffect(() => {
const el = tableWrapRef.current;
if (!el) return undefined;
const RESERVE_BELOW = 104;
const measure = () => {
const scroller = el.closest('.planning-subsystem-content');
const elRect = el.getBoundingClientRect();
let available;
if (scroller) {
const scrollerRect = scroller.getBoundingClientRect();
available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop;
} else {
available = window.innerHeight - elRect.top - RESERVE_BELOW;
}
const next = Math.max(240, Math.round(available));
setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
if (el.parentElement) ro.observe(el.parentElement);
window.addEventListener('resize', measure);
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
};
}, []);
const query = useMemo(() => {
const params = { page, pageSize, sortField: sorter.field, sortOrder: sorter.order };
Object.entries(filters).forEach(([key, value]) => { if (value) params[key] = value; });
return params;
}, [page, pageSize, sorter, filters]);
const load = useCallback(async ({ background = false } = {}) => {
const requestGeneration = listRequestGateRef.current.begin();
let showProgress = false;
let progressGeneration = null;
if (!background) setLoading(true);
if (!background) setError('');
try {
const now = Date.now();
const forceFresh = !lastListRefreshAtRef.current
|| now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS;
showProgress = forceFresh && !background;
if (showProgress) progressGeneration = beginRefreshProgress('正在更新最新业务数据');
// 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。
// 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。
const result = await getMkhBomList({
...query,
live: 1,
...(forceFresh ? { fresh: 1 } : null),
_refresh: now,
});
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
if (!result || result.success === false) {
setError(result?.error || '加载 MKH-BOM 资料失败');
return;
}
const snapshot = result?.data || null;
try {
requireMkhFreshContract(snapshot, { requireApplied: forceFresh });
} catch (contractError) {
setData({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false });
throw contractError;
}
setData(snapshot);
if (forceFresh) lastListRefreshAtRef.current = Date.now();
setError('');
} catch (requestError) {
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
} finally {
if (!background && listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false);
if (showProgress) endRefreshProgress(progressGeneration);
}
}, [beginRefreshProgress, endRefreshProgress, query]);
useEffect(() => { load(); }, [load]);
// 页面保持打开时自动更新;从后台切回且数据已超过轮询周期时立即重读。
// 后台刷新保留当前表格,不显示全页加载态;请求门仍会阻止旧响应覆盖新数据。
useEffect(() => {
const refreshIfStale = () => {
if (document.visibilityState !== 'visible' || syncing || autoRefreshPendingRef.current) return;
if (Date.now() - lastListRefreshAtRef.current < AUTO_REFRESH_MS) return;
autoRefreshPendingRef.current = true;
load({ background: true }).finally(() => {
autoRefreshPendingRef.current = false;
});
};
const timer = window.setInterval(refreshIfStale, AUTO_REFRESH_MS);
document.addEventListener('visibilitychange', refreshIfStale);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', refreshIfStale);
};
}, [load, syncing]);
const changeFilter = (key) => (value) => {
setFilters((old) => ({ ...old, [key]: value || '' }));
setPage(1);
// 点击「仅预警 / 有日销 / 加工在途 / 全部」后自动滚动到下方表格明细
if (key === 'scope') {
setTimeout(() => {
tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 0);
}
};
const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); };
// ── 导出 ────────────────────────────────────────────────────
// 与列表共用同一套筛选/排序口径,但走不分页接口(导出当前筛选条件下的「全部」行)。
const handleExport = async (menuKey) => {
const [mode, format] = String(menuKey || '').split('-');
if (!mode || !format) return;
setExporting(true);
try {
const result = await getMkhBomExportRows({
mode,
keyword: filters.keyword,
scope: filters.scope,
sortField: sorter.field,
sortOrder: sorter.order,
});
if (!result || result.success === false) {
message.error(result?.error || '导出失败');
return;
}
const payload = result.data || {};
const rows = payload.rows || [];
if (!rows.length) {
message.warning('当前筛选条件下没有可导出的数据');
return;
}
const isComponents = mode === 'components';
const scopeLabel = EXPORT_SCOPE_LABEL[payload.scope] || '全部';
const stamp = payload.monitorDate || new Date().toISOString().slice(0, 10);
await exportTableFile(
format,
`${isComponents ? 'MKH配件明细' : 'MKH商品清单'}_${scopeLabel}_${stamp}`,
isComponents ? COMPONENT_EXPORT_COLUMNS : PRODUCT_EXPORT_COLUMNS,
rows,
isComponents ? '配件明细' : '商品清单'
);
message.success(`已导出 ${rows.length} 行(${format.toUpperCase()})`);
} catch (exportError) {
message.error(exportError?.response?.data?.error || exportError?.message || '导出失败');
} finally {
setExporting(false);
}
};
// ── 补货预警推送 ────────────────────────────────────────────
const applyAlertConfig = (config = {}) => {
setAlertConfig(config);
setAlertForm({
enabled: config.enabled !== false,
recipientAlias: config.recipientAlias || '',
scope: config.scope || 'warning',
});
setCenterForm({
baseUrl: config.notificationCenter?.baseUrl || '',
projectCode: config.notificationCenter?.projectCode || '',
projectSecret: '',
});
};
const saveCenterConfig = async () => {
setCenterSaving(true);
try {
const result = await saveMkhNotificationCenter(centerForm);
if (!result || result.success === false) {
message.error(result?.error || '保存推送通道失败');
return;
}
applyAlertConfig(result.data || {});
message.success('推送通道配置已保存');
} catch (e) {
message.error(e?.response?.data?.error || e?.message || '保存推送通道失败');
} finally {
setCenterSaving(false);
}
};
const openAlertSettings = async () => {
setAlertOpen(true);
setAlertResult(null);
setAlertLoading(true);
try {
const result = await getMkhReplenishAlertConfig();
if (!result || result.success === false) {
message.error(result?.error || '加载推送设置失败');
return;
}
applyAlertConfig(result.data || {});
} catch (e) {
message.error(e?.response?.data?.error || e?.message || '加载推送设置失败');
} finally {
setAlertLoading(false);
}
};
const saveAlertSettings = async () => {
setAlertBusy(true);
try {
const result = await saveMkhReplenishAlertConfig(alertForm);
if (!result || result.success === false) {
message.error(result?.error || '保存推送设置失败');
return;
}
applyAlertConfig(result.data || {});
message.success('推送设置已保存');
} catch (e) {
message.error(e?.response?.data?.error || e?.message || '保存推送设置失败');
} finally {
setAlertBusy(false);
}
};
const runAlertCheck = async (payload, doneText) => {
setAlertBusy(true);
setAlertResult(null);
try {
const result = await checkMkhReplenishAlert(payload);
if (!result || result.success === false) {
message.error(result?.error || '执行推送失败');
return;
}
const outcome = result.data || {};
setAlertResult(outcome);
if (outcome.sent) {
message.success(`${doneText || '已推送'}:新增 ${outcome.newCount} 个预警 → ${outcome.recipient}`);
} else if (outcome.reason === 'dry_run') {
message.info('已生成预览内容,未实际发送');
} else {
message.warning(ALERT_REASON_TEXT[outcome.reason] || `未发送:${outcome.reason || '无新预警'}`);
}
const refreshed = await getMkhReplenishAlertConfig().catch(() => null);
if (refreshed?.success) applyAlertConfig(refreshed.data || {});
} catch (e) {
message.error(e?.response?.data?.error || e?.message || '执行推送失败');
} finally {
setAlertBusy(false);
}
};
const loadDetail = useCallback(async (skuId) => {
if (detailCache[skuId] || detailLoading[skuId]) return;
const detailGeneration = detailGenerationRef.current;
setDetailLoading((old) => ({ ...old, [skuId]: true }));
try {
const result = await getMkhBomDetail(skuId);
if (detailGeneration !== detailGenerationRef.current) return;
if (!result || result.success === false) {
message.error(result?.error || '加载配件明细失败');
return;
}
setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } }));
} catch (e) {
if (detailGeneration !== detailGenerationRef.current) return;
message.error(e?.response?.data?.error || e?.message || '加载配件明细失败');
} finally {
if (detailGeneration === detailGenerationRef.current) {
setDetailLoading((old) => ({ ...old, [skuId]: false }));
}
}
}, [detailCache, detailLoading]);
const handlePrint = async (skuId) => {
setPrintLoading(true);
try {
const result = await getMkhBomDetail(skuId);
if (!result || result.success === false) {
message.error(result?.error || '加载打印数据失败');
return;
}
setPrintData(result?.data || { skuId, items: [] });
} catch (e) {
message.error(e?.response?.data?.error || e?.message || '加载打印数据失败');
} finally {
setPrintLoading(false);
}
};
const columns = useMemo(() => [
{
title: 'MKH商品编码',
dataIndex: 'skuId',
width: 230,
fixed: 'left',
sorter: true,
render: (value) => <span className="mkh-sku">{text(value)}</span>,
},
{
title: '商品名称',
dataIndex: 'productName',
ellipsis: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
{
title: '预警',
dataIndex: 'warningLevel',
width: 140,
align: 'center',
render: (value, row) => (
<Space size={4}>
<span className={`mkh-tag mkh-tag--${WARNING_TONE[value] || 'muted'}`}>{text(value)}</span>
{/* 配件不足(后端文件头 ⑪):可组装数 = 0,补不出来 —— 只加标记,不改变预警级别 */}
{row.componentShortage ? (
<Tooltip title={shortageTooltip(row)}>
<span className="mkh-tag mkh-tag--shortage">配件不足</span>
</Tooltip>
) : null}
</Space>
),
},
{
title: '近15天日销',
dataIndex: 'dailySale',
width: 106,
align: 'right',
render: (value, row) => {
const text = Number(value) > 0 ? number(value, 2) : <span className="mkh-muted">0</span>;
// 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。
// 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。
if (!String(row.dailySaleSource || '').startsWith('orders')) return text;
const liveMode = row.dailySaleSource === 'orders-live';
return (
<Tooltip
title={[
liveMode
? `实时口径未采用延迟快照(快照值 ${number(row.snapshotDailySale, 2)})`
: `上游近15天日销快照为 ${number(row.snapshotDailySale, 2)}(漏算)`,
`此处按订单实算:近15天已发货 ${number(row.recentShippedQty)} 件 ÷ 15 天`,
liveMode
? '已排除样品/测试专用店铺;点击同步会重新读取当前订单和库存数据'
: '已排除样品/测试专用店铺;上游修好后会自动以上游数字为准',
].join(';')}
>
<span className="mkh-sale-fallback">{text}</span>
</Tooltip>
);
},
},
{
title: '现有库存',
dataIndex: 'stock',
width: 100,
align: 'right',
render: (value, row) => (
<Tooltip
title={STOCK_SOURCE_TEXT[row.stockSource] || STOCK_SOURCE_TEXT.monitor}
>
<span>{number(value)}</span>
</Tooltip>
),
},
{
title: '在途加工量',
dataIndex: 'pendingWorkOrderQty',
width: 116,
align: 'right',
render: (value, row) => (row.hasPendingWorkOrder ? (
<Tooltip
title={[
`半成品包装项目仓未完成加工单 ${row.pendingWorkOrderCount || 0} 张`,
row.pendingWorkOrderEta ? `预计 ${row.pendingWorkOrderEta} 完成` : '',
row.pendingWorkOrderNo ? `单号 ${row.pendingWorkOrderNo} 起` : '',
'作废单据已剔除,不计入在途量',
].filter(Boolean).join(';')}
>
<span className="mkh-inbound">{number(value)}</span>
</Tooltip>
) : <span className="mkh-muted">—</span>),
},
{
title: '待发货订单',
dataIndex: 'pendingOrderQty',
width: 116,
align: 'right',
render: (value, row) => (Number(value) > 0 ? (
<Tooltip
title={[
`聚水潭未发货订单 ${row.pendingOrderCount || 0} 单,合计 ${number(value)} 件`,
row.pendingOrderEarliest ? `最早一单下单于 ${row.pendingOrderEarliest}` : '',
'已取消 / 已合并单据不核算',
row.orderDriven ? '该商品近 15 天暂无日销,按代发(待发货)订单量列入补货预警,可用天数 = 库存 ÷ 代发件数' : '',
].filter(Boolean).join(';')}
>
<span className="mkh-order">{number(value)}</span>
</Tooltip>
) : <span className="mkh-muted">—</span>),
},
{
// 结余库存(2026-09-18 新增口径):现有库存先扣掉待发货订单,才是可自由支配的库存,
// 补货预警 / 可用天数一律按它算(待发订单为 0 时 ≡ 现有库存)
title: '结余库存',
dataIndex: 'availableStock',
width: 100,
align: 'right',
render: (value, row) => (
<Tooltip
title={Number(row.pendingOrderQty) > 0
? `结余库存 = 现有库存 ${number(row.stock)} − 待发货订单 ${number(row.pendingOrderQty)}`
: '无待发货订单,结余库存 = 现有库存'}
>
<span className={Number(value) <= 0 ? 'mkh-days--warn' : undefined}>{number(value)}</span>
</Tooltip>
),
},
{
title: '可用天数',
dataIndex: 'stockDays',
width: 106,
align: 'right',
sorter: true,
render: (value, row) => {
if (value === null || value === undefined) {
// 真正算不出天数:无日销且无代发订单 → 无销量;订单驱动分支现已如实给出天数,不会进这里
return <span className="mkh-muted">无销量</span>;
}
const text = <span className={row.hasWarning ? 'mkh-days--warn' : undefined}>{number(value, 2)}</span>;
if (row.orderDriven) {
// 近 15 天无日销,可用天数按「库存 ÷ 代发订单件数」估算,≤ 3 天即预警
return (
<Tooltip title="近 15 天暂无日销,可用天数 = 现有库存 ÷ 代发(待发货)订单件数(≤ 3 天即预警)">
{text}
</Tooltip>
);
}
return (
<Tooltip title={
Number(row.pendingOrderQty) > 0
? `可用天数 = 结余库存 ${number(row.availableStock)} ÷ 近15天日销 ${number(row.dailySale, 2)}(结余库存已扣待发货订单)`
: `可用天数 = 现有库存 ${number(row.stock)} ÷ 近15天日销 ${number(row.dailySale, 2)}`
}>
{text}
</Tooltip>
);
},
},
{
title: '建议补货量',
dataIndex: 'replenishQty',
width: 130,
align: 'right',
render: (value, row) => {
// 配件不足(后端文件头 ⑪):凑不出一台,不给补货目标,避免业务照着补不到的量去采购
if (row.componentShortage) {
return (
<Tooltip title={shortageTooltip(row)}>
<span className="mkh-muted">—</span>
</Tooltip>
);
}
return row.hasWarning && Number(value) > 0
? <span className="mkh-replenish">{number(value)}</span>
: <span className="mkh-muted">—</span>;
},
},
{
title: 'BOM 状态',
dataIndex: 'bomConfigured',
width: 100,
align: 'center',
render: (value, row) => (row.bomConfigured === '已配置'
? <span className="mkh-tag mkh-tag--ok">已配置</span>
: <span className="mkh-tag mkh-tag--muted">未配置</span>),
},
{
title: '配件种类',
dataIndex: 'accessoryCount',
width: 96,
align: 'right',
render: (value) => number(value),
},
{
title: '配件总数量',
dataIndex: 'accessoryQtySum',
width: 110,
align: 'right',
render: (value) => number(value),
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
render: (_value, row) => (
<Space size={6}>
<Button
type="primary"
size="small"
icon={<PrinterOutlined />}
loading={printLoading}
onClick={() => handlePrint(row.skuId)}
>
一键打印
</Button>
<Button
size="small"
onClick={() => {
const next = !expandedKeys[row.skuId];
setExpandedKeys((old) => ({ ...old, [row.skuId]: next }));
if (next) loadDetail(row.skuId);
}}
>
{expandedKeys[row.skuId] ? '收起' : '配件明细'}
</Button>
</Space>
),
},
], [expandedKeys, printLoading, loadDetail]);
const expandedRowRender = (row) => {
const detail = detailCache[row.skuId];
if (detailLoading[row.skuId] && !detail) {
return <div className="mkh-detail-loading"><Spin size="small" /> 加载配件明细…</div>;
}
const items = detail?.items || [];
return (
<div className="mkh-detail">
{items.length === 0 ? (
<div className="mkh-detail-tip">
<Text type="secondary">
该 MKH 商品在聚水潭商品 BOM、业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。
上游维护好该商品的正式 BOM 后,此处会自动显示配件明细、数量与仓位。
</Text>
</div>
) : null}
<Table
rowKey="index"
size="small"
pagination={false}
dataSource={items}
scroll={{ x: 720 }}
locale={{ emptyText: '暂无配件明细' }}
columns={[
{ title: '序号', dataIndex: 'index', width: 60, align: 'center' },
{
title: '配件明细',
dataIndex: 'skuName',
ellipsis: true,
render: (v, r) => (
<Tooltip title={`${v || ''}${r.skuId ? `(${r.skuId})` : ''}`}>
<span>{text(v)}</span>
{r.skuId ? <span className="mkh-detail-code">{r.skuId}</span> : null}
</Tooltip>
),
},
{ title: '数量', dataIndex: 'qty', width: 90, align: 'right', render: (v) => number(v) },
{
title: '配件仓位',
dataIndex: 'location',
width: 140,
render: (v) => (v ? text(v) : (
<Tooltip title="箱及仓位库存表中没有该配件的有效记录">
<span className="mkh-muted">未维护</span>
</Tooltip>
)),
},
]}
/>
</div>
);
};
// 手动同步:服务端清缓存后在同一次请求中直连副本库返回新快照。
// 请求代际会阻止同步前尚未完成的旧请求覆盖这个新快照。
const handleSync = async () => {
setSyncing(true);
setLoading(true);
const progressGeneration = beginRefreshProgress('正在强制同步全部最新数据');
// 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。
listRequestGateRef.current.invalidate();
detailGenerationRef.current += 1;
setDetailCache({});
setDetailLoading({});
setExpandedKeys({});
const syncGeneration = listRequestGateRef.current.begin();
try {
const result = await syncMkhBom(query);
if (!listRequestGateRef.current.isCurrent(syncGeneration)) return;
if (!result || result.success === false || !result?.data?.snapshot) {
throw new Error(result?.error || '同步接口未返回数据库快照');
}
setData(requireMkhFreshContract(result.data.snapshot, { requireApplied: true }));
lastListRefreshAtRef.current = Date.now();
setError('');
message.success('已实时读取最新订单、库存、BOM 与仓位数据');
} catch (syncError) {
if (listRequestGateRef.current.isCurrent(syncGeneration)) {
setError(syncError?.response?.data?.error || syncError?.message || '同步失败');
}
message.error(syncError?.response?.data?.error || syncError?.message || '同步失败');
} finally {
if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false);
setSyncing(false);
endRefreshProgress(progressGeneration);
}
};
const progressView = getRefreshProgress(refreshProgress.elapsedSeconds);
return (
<SycmPage className="mkh-page">
<Modal
open={refreshProgress.open}
title={refreshProgress.title}
footer={null}
closable={false}
keyboard={false}
maskClosable={false}
centered
width={480}
>
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<Progress percent={progressView.percent} status="active" strokeColor="#1677ff" />
<Text>{progressView.stage}</Text>
<Text type="secondary">已用时 {refreshProgress.elapsedSeconds} 秒,完成后页面会自动显示新数据。</Text>
<Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text>
</Space>
</Modal>
<header className="mkh-header">
<div className="mkh-header__heading">
<Title level={3}>MKH-BOM 资料</Title>
<Text type="secondary">
模块化(MKH 开头)成品的配件明细与补货预警:近 15 天日销核算,库存低于 3 天日销即预警
</Text>
<Text type="secondary" className="mkh-note">
数据口径:MKH 商品编码取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”,并且必须是聚水潭正式 BOM 的父项;
配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充;
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo,
优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
<br />
补货预警:近15天日销按已出库订单实时计算,现有库存取品牌仓当前库存,并用箱/货位库存补充当日入库;
实时查询不回退延迟监控快照。<b>结余库存 = 现有库存 − 待发货订单</b>(已下单未发货的部分已被占用,不足 0 取 0),
可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货;
建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。
无日销且无订单的商品不参与预警。
<br />
待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警
(新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」;
可用天数 = 现有库存 ÷ 代发(待发货)订单件数,&lt; 3 天预警、= 0(无库存)缺货、≥ 3 天不预警;
建议补货量:缺货时按「代发件数 − 现有库存」、预警时按「3 × 代发件数 − 现有库存」补到 3 天缓冲。
<br />
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算
(作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。
<br />
配件不足:缺货 / 预警的成品若配件「可组装数 = 0」则无法补货,标记「配件不足」、建议补货量显示「—」。
可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ BOM 单台用量⌋ 取最小值(木桶短板决定),
配件库存取「{data.componentStockWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」的现有库存,不含采购在途。
</Text>
</div>
</header>
<Card className="mkh-filter-card" bordered={false}>
<Space wrap size={[12, 12]}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="MKH商品编码 / 名称"
value={filters.keyword}
onChange={(e) => changeFilter('keyword')(e.target.value)}
style={{ width: 260 }}
/>
<Segmented
value={filters.scope}
onChange={changeFilter('scope')}
options={[
{ label: `仅预警 (${data.warningCount ?? 0})`, value: 'warning' },
{ label: `有日销 (${data.soldCount ?? 0})`, value: 'sold' },
{ label: `加工在途 (${data.pendingWorkOrderSkuCount ?? 0})`, value: 'work-order' },
{ label: `全部 (${data.mkhTotalCount ?? 0})`, value: 'all' },
]}
/>
<Button onClick={resetFilters}>重置</Button>
<Button icon={<ReloadOutlined />} onClick={handleSync} loading={syncing}>刷新数据</Button>
<Tooltip title="立即清除服务端缓存,实时重查当前订单、库存、BOM 与仓位数据">
<Button type="primary" icon={<SyncOutlined />} onClick={handleSync} loading={syncing}>同步最新数据</Button>
</Tooltip>
<Dropdown
trigger={['click']}
disabled={exporting}
menu={{ items: EXPORT_MENU_ITEMS, onClick: ({ key }) => handleExport(key) }}
>
<Button icon={<DownloadOutlined />} loading={exporting}>导出</Button>
</Dropdown>
<Button icon={<BellOutlined />} onClick={openAlertSettings}>预警推送</Button>
</Space>
</Card>
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={handleSync}>重试</Button>} /> : null}
{!loading && !error && data.workOrderDegraded ? (
<Alert
className="mkh-alert"
type="warning"
showIcon
message="加工单数据源暂时不可用,在途加工量按 0 处理,预警结果可能偏多,请稍后点击「刷新数据」重试。"
action={<Button size="small" onClick={handleSync}>刷新数据</Button>}
/>
) : null}
{!loading && !error && data.pendingOrderDegraded ? (
<Alert
className="mkh-alert"
type="warning"
showIcon
message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近15天无日销但有订单」的商品,请稍后点击「刷新数据」重试。"
action={<Button size="small" onClick={handleSync}>刷新数据</Button>}
/>
) : null}
{!loading && !error && data.componentStockDegraded ? (
<Alert
className="mkh-alert"
type="warning"
showIcon
message="配件库存数据源暂时不可用,本次未做「配件不足」判定,可能存在配件凑不齐、补不出来的商品,请稍后点击「刷新数据」重试。"
action={<Button size="small" onClick={handleSync}>刷新数据</Button>}
/>
) : null}
{!loading && (data.warningCount || 0) > 0 ? (
<Alert
className="mkh-alert"
type="warning"
showIcon
message={`补货预警:${data.warningCount} 个 MKH 商品需要补货`}
description={(
<span>
按近 {data.saleWindowDays || 15} 天已出库订单实时核算:
其中 <b>{data.outOfStockCount || 0}</b> 个已缺货,
<b>{Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))}</b> 个可用天数不足 {data.replenishDaysThreshold || 3} 天。
建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。
{(data.orderDrivenCount || 0) > 0 ? (
<>
<br />
其中 <b>{data.orderDrivenCount}</b> 个近 15 天暂无日销,按代发(待发货)订单列入预警
(待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件),可用天数 = 库存 ÷ 代发件数、≤ 3 天即预警。
</>
) : null}
{(data.scheduledCount || 0) > 0 ? (
<>
<br />
另有 <b>{data.scheduledCount}</b> 个商品在「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」已有未完成加工单
(在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。
</>
) : null}
{(data.componentShortageCount || 0) > 0 ? (
<>
<br />
其中 <b>{data.componentShortageCount}</b> 个商品<span className="mkh-tag mkh-tag--shortage">配件不足</span>
:现有配件最多只能组装 0 台(已扣除未完成加工单占用的配件),<b>补不出来</b>,
建议补货量显示「—」,需先采购配件。
</>
) : null}
</span>
)}
action={(
<Button size="small" type="primary" onClick={() => changeFilter('scope')('warning')}>
只看预警
</Button>
)}
/>
) : null}
{!loading && (data.total || 0) > 0 ? (
<Alert
type={data.bomRowCount > 0 ? 'info' : 'warning'}
showIcon
message={data.bomRowCount > 0
? `已接入 ${data.bomRowCount} 个模块化商品的配件 BOM`
: '聚水潭商品 BOM 及补充来源中尚无可用的模块化商品配件明细'}
description={(
<span>
MKH 商品编码({data.total || 0} 条)取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的正式 BOM 父项;
配件明细优先取自聚水潭商品 BOM({data.bomOfficialRowCount || 0} 个),
业务组合 BOM({data.bomBusinessRowCount || 0} 个)与产品资料系统({data.bomProductDbRowCount || 0} 个)作为补充。
BOM 中没有指定仓库商品档案的组合编码不会加入商品列表。
上游补充正式 BOM 后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。
</span>
)}
/>
) : null}
<Card
bordered={false}
className="mkh-panel"
title={<span>MKH 模块化成品({data.total || 0} 条)</span>}
>
<Spin spinning={loading} tip="正在加载 MKH-BOM 资料…">
<div ref={tableWrapRef}>
<Table
rowKey="skuId"
size="small"
columns={columns}
dataSource={data.rows || []}
expandable={{
expandedRowKeys: Object.keys(expandedKeys).filter((k) => expandedKeys[k]),
expandedRowRender,
onExpand: (expanded, record) => {
setExpandedKeys((old) => ({ ...old, [record.skuId]: expanded }));
if (expanded) loadDetail(record.skuId);
},
}}
onChange={(pagination, _filter, nextSorter) => {
setPage(pagination.current || 1);
setPageSize(pagination.pageSize || 50);
if (nextSorter?.field) {
setSorter({
field: nextSorter.field,
order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC',
});
}
}}
pagination={{
current: data.page || page,
pageSize: data.pageSize || pageSize,
total: data.total || 0,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
showTotal: (total) => `共 ${total} 条`,
}}
scroll={{ x: 1866, y: bodyHeight }}
/>
</div>
</Spin>
</Card>
{printData ? <PrintPreview data={printData} onClose={() => setPrintData(null)} /> : null}
<Modal
title="补货预警推送"
open={alertOpen}
onCancel={() => setAlertOpen(false)}
width={680}
destroyOnHidden
styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }}
footer={(
<Space wrap>
<Button type="primary" loading={alertBusy} disabled={alertLoading} onClick={saveAlertSettings}>
保存设置
</Button>
<Button loading={alertBusy} disabled={alertLoading} onClick={() => runAlertCheck({ dryRun: true, force: true })}>
预览推送内容
</Button>
<Button loading={alertBusy} disabled={alertLoading} onClick={() => runAlertCheck({}, '已推送')}>
立即检查并推送
</Button>
<Button loading={alertBusy} disabled={alertLoading} onClick={() => runAlertCheck({ force: true, reset: true }, '已重推')}>
重推全部预警
</Button>
</Space>
)}
>
<Spin spinning={alertLoading}>
<Alert
type="info"
showIcon
message={`每天 09:00 自动检查「${alertConfig?.scope === 'outOfStock' ? '缺货' : '缺货 + 3天内预警'}」商品,只推送新出现的预警;已推送过但尚未补货的商品不重复提醒。`}
/>
<div className="mkh-alert-form">
<div className="mkh-alert-row">
<span className="mkh-alert-label">开启推送</span>
<Switch
checked={alertForm.enabled}
onChange={(checked) => setAlertForm((old) => ({ ...old, enabled: checked }))}
/>
<Text type="secondary">关闭后自动任务不再发送,本页仍可手动推送</Text>
</div>
<div className="mkh-alert-row">
<span className="mkh-alert-label">收件人</span>
<Input
allowClear
placeholder="通知中心收件人别名,留空则用默认收件人"
value={alertForm.recipientAlias}
onChange={(e) => setAlertForm((old) => ({ ...old, recipientAlias: e.target.value }))}
style={{ width: 280 }}
/>
</div>
<div className="mkh-alert-row">
<span className="mkh-alert-label">推送范围</span>
<Select
value={alertForm.scope}
onChange={(value) => setAlertForm((old) => ({ ...old, scope: value }))}
options={ALERT_SCOPE_OPTIONS}
style={{ width: 200 }}
/>
</div>
</div>
{alertConfig ? (
<div className="mkh-alert-meta">
<div>
生效收件人:<b>{alertConfig.effectiveRecipientAlias || '未设置'}</b>
<Text type="secondary">
({ALERT_RECIPIENT_SOURCE_TEXT[alertConfig.recipientSource] || '未知来源'})
</Text>
</div>
<div>
统一通知中心:
<Text type={alertConfig.notificationCenterConfigured ? 'success' : 'warning'} strong>
{alertConfig.notificationCenterConfigured ? '已配置' : '未配置'}
</Text>
{alertConfig.notificationCenterBaseUrl ? (
<Text type="secondary">{` ${alertConfig.notificationCenterBaseUrl}`}</Text>
) : null}
</div>
<div>
已推送记录:<b>{alertConfig.notifiedCount || 0}</b> 个商品
{` | `}上次检查:<b>{alertConfig.lastRunAt ? new Date(alertConfig.lastRunAt).toLocaleString('zh-CN') : '—'}</b>
</div>
</div>
) : null}
<Collapse
ghost
className="mkh-alert-center"
defaultActiveKey={alertConfig?.notificationCenterConfigured ? [] : ['center']}
items={[{
key: 'center',
label: (
<Space size={6}>
<span>推送通道(统一通知中心)</span>
<Text type={alertConfig?.notificationCenterConfigured ? 'success' : 'warning'} strong>
{alertConfig?.notificationCenterConfigured ? '已配置' : '未配置'}
</Text>
</Space>
),
children: (
<div className="mkh-alert-form mkh-alert-form--inner">
{!alertConfig?.notificationCenterConfigured ? (
<Alert
type="warning"
showIcon
message="统一通知中心尚未配置,补货预警无法送达。请向运维获取「项目代码 / 项目密钥」后在此填写。"
/>
) : null}
<div className="mkh-alert-row">
<span className="mkh-alert-label">服务地址</span>
<Input
value={centerForm.baseUrl}
onChange={(e) => setCenterForm((old) => ({ ...old, baseUrl: e.target.value }))}
placeholder="https://sc.jiansun.vip/notification-center"
style={{ width: 380 }}
/>
</div>
<div className="mkh-alert-row">
<span className="mkh-alert-label">项目代码</span>
<Input
value={centerForm.projectCode}
onChange={(e) => setCenterForm((old) => ({ ...old, projectCode: e.target.value }))}
style={{ width: 280 }}
/>
</div>
<div className="mkh-alert-row">
<span className="mkh-alert-label">项目密钥</span>
<Input.Password
value={centerForm.projectSecret}
onChange={(e) => setCenterForm((old) => ({ ...old, projectSecret: e.target.value }))}
placeholder={alertConfig?.notificationCenter?.projectSecretConfigured
? `已配置(${alertConfig.notificationCenter.projectSecretPreview}),留空则不修改`
: '未配置'}
style={{ width: 280 }}
/>
</div>
<div>
<Button type="primary" loading={centerSaving} onClick={saveCenterConfig}>
保存推送通道
</Button>
</div>
</div>
),
}]}
/>
{alertResult ? (
<Alert
className="mkh-alert-result"
type={alertResult.sent ? 'success' : (alertResult.reason === 'dry_run' ? 'info' : 'warning')}
showIcon
message={alertResult.sent
? `已推送给 ${alertResult.recipient}:新增 ${alertResult.newCount} 个预警`
: (alertResult.reason === 'dry_run'
? '推送内容预览(未实际发送)'
: (ALERT_REASON_TEXT[alertResult.reason] || `未发送:${alertResult.reason || ''}`))}
description={(
<div className="mkh-alert-result-body">
<div>
当前预警 {alertResult.summary?.totalCount ?? 0} 个
(缺货 {alertResult.summary?.outOfStockCount ?? 0} |
{alertResult.summary?.replenishDaysThreshold || 3}天内 {alertResult.summary?.warningOnlyCount ?? 0})
,本次新增 {alertResult.summary?.freshCount ?? 0} 个,历史已推 {alertResult.summary?.persistedCount ?? 0} 个。
</div>
{alertResult.payload ? (
<div className="mkh-alert-preview">
<div className="mkh-alert-preview-title">{alertResult.payload.title}</div>
<pre>{alertResult.payload.content}</pre>
<Text type="secondary">收件人:{alertResult.payload.recipientAlias}</Text>
</div>
) : null}
</div>
)}
/>
) : null}
<div className="mkh-alert-hint">
「立即检查并推送」按线上口径执行(只推新出现的);「重推全部预警」会清空已推送记录后重新推送当前全部预警,用于首次验证。
</div>
</Spin>
</Modal>
</SycmPage>
);
};
export default MkhBomData;