一、前端:从 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 告警)。
1355 lines
57 KiB
JavaScript
1355 lines
57 KiB
JavaScript
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,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」;
|
||
可用天数 = 现有库存 ÷ 代发(待发货)订单件数,< 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;
|