yanhui 445347e664 feat: 启用运营分析模块,新增模块化库存监控与 MKH-BOM 资料
本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。

主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
   - retiredFeatures 移出对应退役名单与清表语句,改为正常保留
   - 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
   - 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
   - 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
     当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
   - MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
   name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
   相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
   parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
   而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
   真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)

验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
  Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
  与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
  会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
  --ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致

未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
2026-09-15 16:53:12 +08:00

429 lines
16 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, Col, Dropdown, Image, Input, Row, Select,
Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getModularStockList, getModularStockFilters } from '../../api/modularStockMonitor';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
const { Title, Text } = Typography;
const WARNING_COLOR = { 缺货: 'red', 预警: 'orange', 正常: 'green' };
// 表头带口径说明,避免把「真实值 0」误读成「没数据」
const TipTitle = ({ title, tip }) => (
<Tooltip title={tip}>
<span className="msm-th-tip">{title}</span>
</Tooltip>
);
const number = (value, digits = 2) => value === null || value === undefined || value === ''
? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
const int = (value) => number(value, 0);
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
const toOptions = (list = []) => (Array.isArray(list) ? list : [])
.filter(Boolean)
.map((value) => ({ value, label: value }));
const EMPTY_FILTERS = { keyword: '', category: '', warningLevel: '', mkhStatus: '' };
const AccessoryStockPanel = () => {
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [onlyWarning, setOnlyWarning] = useState(false);
const [filterOptions, setFilterOptions] = useState({ categories: [], warningLevels: [] });
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: null });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [sorter, setSorter] = useState({ field: 'realtimeStock', order: 'DESC' });
const tableWrapRef = useRef(null);
const [bodyHeight, setBodyHeight] = useState(420);
// 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc:
// 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行
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();
// 加回 scrollTop,抵消滚动对 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);
};
}, []);
useEffect(() => {
let cancelled = false;
getModularStockFilters()
.then((result) => {
const payload = result?.data || {};
if (cancelled) return;
setFilterOptions({
categories: payload.categories || [],
warningLevels: payload.warningLevels || ['缺货', '预警', '正常'],
mkhStatuses: payload.mkhStatuses || ['已配置MKH', '未配置MKH'],
});
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
const query = useMemo(() => {
const params = {
page,
pageSize,
sortField: sorter.field,
sortOrder: sorter.order,
};
Object.entries(filters).forEach(([key, value]) => {
if (value) params[key] = value;
});
if (onlyWarning) params.onlyWarning = '1';
return params;
}, [page, pageSize, sorter, filters, onlyWarning]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await getModularStockList(query);
setData(result?.data || { rows: [], total: 0, summary: {} });
} catch (requestError) {
setError(requestError?.response?.data?.error || requestError?.message || '加载配件库存监控数据失败');
} finally {
setLoading(false);
}
}, [query]);
useEffect(() => { load(); }, [load]);
const changeFilter = (key) => (value) => {
setFilters((old) => ({ ...old, [key]: value || '' }));
setPage(1);
};
const resetFilters = () => {
setFilters(EMPTY_FILTERS);
setOnlyWarning(false);
setPage(1);
};
const columns = useMemo(() => [
{
title: '图片',
dataIndex: 'imageUrl',
width: 56,
fixed: 'left',
exportable: false,
render: (value) => (value
? <Image src={value} width={36} height={36} style={{ objectFit: 'cover', borderRadius: 4 }} placeholder />
: <span className="msm-muted">--</span>),
},
{
title: '预警',
dataIndex: 'warningLevel',
width: 76,
sorter: true,
render: (value) => (value
? <Tag color={WARNING_COLOR[value] || 'default'}>{value}</Tag>
: <span className="msm-muted">--</span>),
},
{
title: <TipTitle
title="状态"
tip="该 SKU 是否已在「产品资料系统数据库」中建立商品档案。已配置MKH = 产品资料库中能查到该 SKU 编码的商品资料;未配置MKH = 产品资料库中查不到该 SKU 档案"
/>,
dataIndex: 'mkhStatus',
width: 110,
sorter: true,
render: (value) => {
const v = value || '未配置MKH';
return v === '已配置MKH'
? <Tag color="green">{v}</Tag>
: <Tag color="default">{v}</Tag>;
},
},
{
title: 'SKU编码',
dataIndex: 'skuId',
width: 176,
fixed: 'left',
sorter: true,
render: (value) => <span className="msm-sku">{text(value)}</span>,
},
{
// 必须显式给 width:antd 在 scroll.x 场景下会把无宽度列压成 0 宽(此前该列不可见的原因)
title: '商品名称',
dataIndex: 'productName',
width: 220,
ellipsis: true,
sorter: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
{
title: <TipTitle
title="当日入库库存"
tip="监控日(pm_stockMonitor 最新快照日)的采购到货入库数量,取自 jst_purchaseItem.inQty。业务库无出入库流水表,0 表示该 SKU 当日无到货"
/>,
dataIndex: 'inQtyToday',
width: 116,
align: 'right',
render: (value) => (value === null || value === undefined
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: <TipTitle
title="当日出库库存"
tip="监控日的最近一日发货量,取自 pm_stockMonitor.nearSecondSale。业务库无出入库流水表,0 表示该 SKU 当日无发货"
/>,
dataIndex: 'outQtyToday',
width: 116,
align: 'right',
render: (value) => (value === null || value === undefined
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: '实时库存',
dataIndex: 'realtimeStock',
width: 96,
align: 'right',
sorter: true,
render: int,
},
{
title: <TipTitle
title="仓位"
tip="业务库 jst_sku.bin / other_bin 全库 55.7 万行均为空,上游未回传库位数据,因此该列暂无值"
/>,
dataIndex: 'location',
width: 88,
sorter: true,
render: (value) => (value
? <span>{value}</span>
: <Tooltip title="上游未提供库位数据">
<span className="msm-muted">未维护</span>
</Tooltip>),
},
{
title: '近7日日均发货量',
dataIndex: 'avgShip7d',
width: 138,
align: 'right',
sorter: true,
render: (value) => number(value),
},
{
title: '库存可用天数',
dataIndex: 'stockDays',
width: 116,
align: 'right',
sorter: true,
render: (value) => number(value, 1),
},
{
title: '所在仓库',
dataIndex: 'warehouse',
width: 200,
ellipsis: true,
sorter: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
], []);
const exportColumns = useMemo(
() => columns.filter((column) => column.exportable !== false),
[columns]
);
const handleExport = async (format) => {
if (!data.rows.length) {
message.warning('暂无数据可导出');
return;
}
try {
await exportTableFile(
format,
`配件库存监控_${data.monitorDate || '最新'}`,
exportColumns,
data.rows,
'配件库存监控'
);
message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`);
} catch (exportError) {
message.error(exportError?.message || '导出失败');
}
};
const summary = data.summary || {};
return (
<>
<Card className="msm-filter-card" bordered={false}>
<Space wrap size={[12, 12]}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="SKU 编码 / 名称"
value={filters.keyword}
onChange={(e) => changeFilter('keyword')(e.target.value)}
style={{ width: 220 }}
/>
<Select
allowClear
showSearch
placeholder="品类"
options={toOptions(filterOptions.categories)}
value={filters.category || undefined}
onChange={changeFilter('category')}
style={{ width: 200 }}
/>
<Select
allowClear
placeholder="预警等级"
options={toOptions(filterOptions.warningLevels)}
value={filters.warningLevel || undefined}
onChange={changeFilter('warningLevel')}
style={{ width: 140 }}
/>
<Select
allowClear
placeholder="MKH 配置状态"
options={toOptions(filterOptions.mkhStatuses)}
value={filters.mkhStatus || undefined}
onChange={changeFilter('mkhStatus')}
style={{ width: 160 }}
/>
<Space size={6}>
<Switch checked={onlyWarning} onChange={(checked) => { setOnlyWarning(checked); setPage(1); }} size="small" />
<Text type="secondary">仅看缺货 / 预警</Text>
</Space>
<Button onClick={resetFilters}>重置</Button>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
</Space>
</Card>
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
<Row gutter={[12, 12]} className="msm-summary-row">
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="SKU 总数" value={summary.skuCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-red"><Statistic title="缺货 SKU" value={summary.outOfStockCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-orange"><Statistic title="可用天数不足" value={summary.lowStockDaysCount || 0} suffix="个 SKU" /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-green"><Statistic title="实时库存合计" value={summary.totalStock || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="已配置MKH" value={summary.configuredCount || 0} /></Card></Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="未配置MKH" value={summary.unconfiguredCount || 0} /></Card></Col>
</Row>
<Card
bordered={false}
className="msm-panel"
title={(
<Space size={12} wrap>
<span>配件库存明细({data.total || 0} 条)</span>
{data.monitorDate ? <Text type="secondary" className="msm-panel__date">监控日 {data.monitorDate}</Text> : null}
</Space>
)}
extra={(
<Dropdown menu={{
items: [
{ key: 'xlsx', label: '导出 XLSX' },
{ key: 'csv', label: '导出 CSV' },
],
onClick: ({ key }) => handleExport(key),
}}>
<Button icon={<DownloadOutlined />}>导出</Button>
</Dropdown>
)}
>
<Spin spinning={loading} tip="正在加载配件库存监控数据…">
<div ref={tableWrapRef}>
<Table
rowKey="skuId"
size="small"
columns={columns}
dataSource={data.rows || []}
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: 1508, y: bodyHeight }}
/>
</div>
</Spin>
</Card>
</>
);
};
const ModularStockMonitor = () => (
<SycmPage className="msm-page">
<header className="msm-header">
<div className="msm-header__heading">
<Title level={3}>模块化库存监控</Title>
<Text type="secondary">
配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 7 日日均发货量与可用天数
</Text>
<Text type="secondary" className="msm-note">
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 7 日日均发货量、
库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖);
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」;
图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。
状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。
</Text>
</div>
</header>
<Tabs
className="msm-tabs"
activeKey="accessory"
items={[{
key: 'accessory',
label: '配件库存监控',
children: <AccessoryStockPanel />,
}]}
/>
</SycmPage>
);
export default ModularStockMonitor;