本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。
主要变更:
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 排除。
429 lines
16 KiB
JavaScript
429 lines
16 KiB
JavaScript
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;
|