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 = `-` 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 (
按 Esc 关闭预览
MKH-BOM 资料
模块化商品配件明细 · 打印模板
打印时间:{now}
配件种类:{items.length}
{items.map((item, idx) => ( {idx === 0 ? ( ) : null} ))} {items.length === 0 ? ( ) : null}
MKH商品编码 配件明细 数量 配件仓位
{text(data.skuId)}{text(item.skuName)}{item.skuId ? {item.skuId} : null} {number(item.qty)} {item.location ? text(stripWarehousePrefix(item.location)) : 未维护}
{text(data.skuId)} 该 MKH 商品尚无可用的聚水潭商品 BOM 明细
MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充; 配件仓位只取聚水潭「箱及仓位库存」,未维护货位时显示箱号,无装箱记录显示「未维护」。
); }; 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) => {text(value)}, }, { title: '商品名称', dataIndex: 'productName', ellipsis: true, render: (value) => {text(value)}, }, { title: '预警', dataIndex: 'warningLevel', width: 140, align: 'center', render: (value, row) => ( {text(value)} {/* 配件不足(后端文件头 ⑪):可组装数 = 0,补不出来 —— 只加标记,不改变预警级别 */} {row.componentShortage ? ( 配件不足 ) : null} ), }, { title: '近15天日销', dataIndex: 'dailySale', width: 106, align: 'right', render: (value, row) => { const text = Number(value) > 0 ? number(value, 2) : 0; // 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。 // 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。 if (!String(row.dailySaleSource || '').startsWith('orders')) return text; const liveMode = row.dailySaleSource === 'orders-live'; return ( {text} ); }, }, { title: '现有库存', dataIndex: 'stock', width: 100, align: 'right', render: (value, row) => ( {number(value)} ), }, { title: '在途加工量', dataIndex: 'pendingWorkOrderQty', width: 116, align: 'right', render: (value, row) => (row.hasPendingWorkOrder ? ( {number(value)} ) : —), }, { title: '待发货订单', dataIndex: 'pendingOrderQty', width: 116, align: 'right', render: (value, row) => (Number(value) > 0 ? ( {number(value)} ) : —), }, { // 结余库存(2026-09-18 新增口径):现有库存先扣掉待发货订单,才是可自由支配的库存, // 补货预警 / 可用天数一律按它算(待发订单为 0 时 ≡ 现有库存) title: '结余库存', dataIndex: 'availableStock', width: 100, align: 'right', render: (value, row) => ( 0 ? `结余库存 = 现有库存 ${number(row.stock)} − 待发货订单 ${number(row.pendingOrderQty)}` : '无待发货订单,结余库存 = 现有库存'} > {number(value)} ), }, { title: '可用天数', dataIndex: 'stockDays', width: 106, align: 'right', sorter: true, render: (value, row) => { if (value === null || value === undefined) { // 真正算不出天数:无日销且无代发订单 → 无销量;订单驱动分支现已如实给出天数,不会进这里 return 无销量; } const text = {number(value, 2)}; if (row.orderDriven) { // 近 15 天无日销,可用天数按「库存 ÷ 代发订单件数」估算,≤ 3 天即预警 return ( {text} ); } return ( 0 ? `可用天数 = 结余库存 ${number(row.availableStock)} ÷ 近15天日销 ${number(row.dailySale, 2)}(结余库存已扣待发货订单)` : `可用天数 = 现有库存 ${number(row.stock)} ÷ 近15天日销 ${number(row.dailySale, 2)}` }> {text} ); }, }, { title: '建议补货量', dataIndex: 'replenishQty', width: 130, align: 'right', render: (value, row) => { // 配件不足(后端文件头 ⑪):凑不出一台,不给补货目标,避免业务照着补不到的量去采购 if (row.componentShortage) { return ( — ); } return row.hasWarning && Number(value) > 0 ? {number(value)} : —; }, }, { title: 'BOM 状态', dataIndex: 'bomConfigured', width: 100, align: 'center', render: (value, row) => (row.bomConfigured === '已配置' ? 已配置 : 未配置), }, { 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) => ( ), }, ], [expandedKeys, printLoading, loadDetail]); const expandedRowRender = (row) => { const detail = detailCache[row.skuId]; if (detailLoading[row.skuId] && !detail) { return
加载配件明细…
; } const items = detail?.items || []; return (
{items.length === 0 ? (
该 MKH 商品在聚水潭商品 BOM、业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。 上游维护好该商品的正式 BOM 后,此处会自动显示配件明细、数量与仓位。
) : null} ( {text(v)} {r.skuId ? {r.skuId} : null} ), }, { title: '数量', dataIndex: 'qty', width: 90, align: 'right', render: (v) => number(v) }, { title: '配件仓位', dataIndex: 'location', width: 140, render: (v) => (v ? text(v) : ( 未维护 )), }, ]} /> ); }; // 手动同步:服务端清缓存后在同一次请求中直连副本库返回新快照。 // 请求代际会阻止同步前尚未完成的旧请求覆盖这个新快照。 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 ( {progressView.stage} 已用时 {refreshProgress.elapsedSeconds} 秒,完成后页面会自动显示新数据。 进度为查询阶段估算,实际完成以服务端返回为准。
MKH-BOM 资料 模块化(MKH 开头)成品的配件明细与补货预警:近 15 天日销核算,库存低于 3 天日销即预警 数据口径:MKH 商品编码取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”,并且必须是聚水潭正式 BOM 的父项; 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo, 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
补货预警:近15天日销按已出库订单实时计算,现有库存取品牌仓当前库存,并用箱/货位库存补充当日入库; 实时查询不回退延迟监控快照。结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0), 可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货; 建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。 无日销且无订单的商品不参与预警。
待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; 可用天数 = 现有库存 ÷ 代发(待发货)订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警; 建议补货量:缺货时按「代发件数 − 现有库存」、预警时按「3 × 代发件数 − 现有库存」补到 3 天缓冲。
在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算 (作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。
配件不足:缺货 / 预警的成品若配件「可组装数 = 0」则无法补货,标记「配件不足」、建议补货量显示「—」。 可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ BOM 单台用量⌋ 取最小值(木桶短板决定), 配件库存取「{data.componentStockWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」的现有库存,不含采购在途。
} placeholder="MKH商品编码 / 名称" value={filters.keyword} onChange={(e) => changeFilter('keyword')(e.target.value)} style={{ width: 260 }} /> handleExport(key) }} > {error ? 重试} /> : null} {!loading && !error && data.workOrderDegraded ? ( 刷新数据} /> ) : null} {!loading && !error && data.pendingOrderDegraded ? ( 刷新数据} /> ) : null} {!loading && !error && data.componentStockDegraded ? ( 刷新数据} /> ) : null} {!loading && (data.warningCount || 0) > 0 ? ( 按近 {data.saleWindowDays || 15} 天已出库订单实时核算: 其中 {data.outOfStockCount || 0} 个已缺货, {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。 {(data.orderDrivenCount || 0) > 0 ? ( <>
其中 {data.orderDrivenCount} 个近 15 天暂无日销,按代发(待发货)订单列入预警 (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件),可用天数 = 库存 ÷ 代发件数、≤ 3 天即预警。 ) : null} {(data.scheduledCount || 0) > 0 ? ( <>
另有 {data.scheduledCount} 个商品在「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」已有未完成加工单 (在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。 ) : null} {(data.componentShortageCount || 0) > 0 ? ( <>
其中 {data.componentShortageCount} 个商品配件不足 :现有配件最多只能组装 0 台(已扣除未完成加工单占用的配件),补不出来, 建议补货量显示「—」,需先采购配件。 ) : null} )} action={( )} /> ) : null} {!loading && (data.total || 0) > 0 ? ( 0 ? 'info' : 'warning'} showIcon message={data.bomRowCount > 0 ? `已接入 ${data.bomRowCount} 个模块化商品的配件 BOM` : '聚水潭商品 BOM 及补充来源中尚无可用的模块化商品配件明细'} description={( MKH 商品编码({data.total || 0} 条)取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的正式 BOM 父项; 配件明细优先取自聚水潭商品 BOM({data.bomOfficialRowCount || 0} 个), 业务组合 BOM({data.bomBusinessRowCount || 0} 个)与产品资料系统({data.bomProductDbRowCount || 0} 个)作为补充。 BOM 中没有指定仓库商品档案的组合编码不会加入商品列表。 上游补充正式 BOM 后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 )} /> ) : null} MKH 模块化成品({data.total || 0} 条)} >
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 }} /> {printData ? setPrintData(null)} /> : null} setAlertOpen(false)} width={680} destroyOnHidden styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }} footer={( )} >
开启推送 setAlertForm((old) => ({ ...old, enabled: checked }))} /> 关闭后自动任务不再发送,本页仍可手动推送
收件人 setAlertForm((old) => ({ ...old, recipientAlias: e.target.value }))} style={{ width: 280 }} />
推送范围 setCenterForm((old) => ({ ...old, baseUrl: e.target.value }))} placeholder="https://sc.jiansun.vip/notification-center" style={{ width: 380 }} />
项目代码 setCenterForm((old) => ({ ...old, projectCode: e.target.value }))} style={{ width: 280 }} />
项目密钥 setCenterForm((old) => ({ ...old, projectSecret: e.target.value }))} placeholder={alertConfig?.notificationCenter?.projectSecretConfigured ? `已配置(${alertConfig.notificationCenter.projectSecretPreview}),留空则不修改` : '未配置'} style={{ width: 280 }} />
), }]} /> {alertResult ? (
当前预警 {alertResult.summary?.totalCount ?? 0} 个 (缺货 {alertResult.summary?.outOfStockCount ?? 0} | {alertResult.summary?.replenishDaysThreshold || 3}天内 {alertResult.summary?.warningOnlyCount ?? 0}) ,本次新增 {alertResult.summary?.freshCount ?? 0} 个,历史已推 {alertResult.summary?.persistedCount ?? 0} 个。
{alertResult.payload ? (
{alertResult.payload.title}
{alertResult.payload.content}
收件人:{alertResult.payload.recipientAlias}
) : null} )} /> ) : null}
「立即检查并推送」按线上口径执行(只推新出现的);「重推全部预警」会清空已推送记录后重新推送当前全部预警,用于首次验证。
); }; export default MkhBomData;