本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。
主要变更:
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 排除。
341 lines
17 KiB
JavaScript
341 lines
17 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert, Button, Card, Col, Drawer, Dropdown, Image, Input, Row, Select,
|
||
Segmented, Space, Spin, Statistic, Table, Tabs, Tag, Tooltip, Typography, message,
|
||
} from 'antd';
|
||
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||
import { SycmPage } from '../../components/Common';
|
||
import {
|
||
getProductMonitoringList,
|
||
getProductMonitoringFilters,
|
||
getProductMonitoringOperators,
|
||
} from '../../api/productMonitoring';
|
||
import { exportTableFile } from '../../utils/tableExport';
|
||
import './style.css';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
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 COMPANY_TAG_COLOR = { S: 'red', A: 'orange', B: 'blue', C: 'default', D: 'default' };
|
||
const DEPARTMENT_TAG_COLOR = { 'G-S': 'red', 'G-A': 'orange', 'G-B': 'blue', 'G-C': 'default' };
|
||
const STATUS_TABS = [
|
||
{ key: '销售', label: '销售' },
|
||
{ key: '停产', label: '停产' },
|
||
{ key: '退市', label: '退市' },
|
||
{ key: '__all__', label: '全部' },
|
||
];
|
||
|
||
const toOptions = (list = []) => (Array.isArray(list) ? list : [])
|
||
.filter(Boolean)
|
||
.map((value) => ({ value, label: value }));
|
||
|
||
const ProductMonitoringPanel = () => {
|
||
const [status, setStatus] = useState('销售');
|
||
const [filters, setFilters] = useState({
|
||
category: '', spu: '', sku: '', productName: '', supplier: '', warehouse: '',
|
||
purchaser: '', companyTag: [], departmentTag: [], departmentOwner: '',
|
||
});
|
||
const [filterOptions, setFilterOptions] = useState({ categories: [], suppliers: [], warehouses: [], purchasers: [] });
|
||
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, latestDate: 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: 'companySales7d', order: 'DESC' });
|
||
const [selected, setSelected] = useState(null);
|
||
const [operators, setOperators] = useState([]);
|
||
const [operatorsLoading, setOperatorsLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
getProductMonitoringFilters()
|
||
.then((result) => {
|
||
const payload = result?.data || {};
|
||
if (cancelled) return;
|
||
setFilterOptions({
|
||
categories: payload.categories || [],
|
||
suppliers: payload.suppliers || [],
|
||
warehouses: payload.warehouses || [],
|
||
purchasers: payload.purchasers || [],
|
||
});
|
||
})
|
||
.catch(() => {});
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
const query = useMemo(() => {
|
||
const params = {
|
||
page,
|
||
pageSize,
|
||
sortField: sorter.field,
|
||
sortOrder: sorter.order,
|
||
};
|
||
if (status === '__all__') params.includeAll = '1';
|
||
else params.saleStatus = status;
|
||
|
||
Object.entries(filters).forEach(([key, value]) => {
|
||
if (Array.isArray(value)) {
|
||
if (value.length) params[key] = value.join(',');
|
||
} else if (value) {
|
||
params[key] = value;
|
||
}
|
||
});
|
||
return params;
|
||
}, [page, pageSize, sorter, status, filters]);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const result = await getProductMonitoringList(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 changeStatus = (key) => {
|
||
setStatus(key);
|
||
setPage(1);
|
||
};
|
||
const changeFilter = (key) => (value) => {
|
||
setFilters((old) => ({ ...old, [key]: value || '' }));
|
||
setPage(1);
|
||
};
|
||
const changeMultiFilter = (key) => (value) => {
|
||
setFilters((old) => ({ ...old, [key]: value || [] }));
|
||
setPage(1);
|
||
};
|
||
const resetFilters = () => {
|
||
setFilters({
|
||
category: '', spu: '', sku: '', productName: '', supplier: '', warehouse: '',
|
||
purchaser: '', companyTag: [], departmentTag: [], departmentOwner: '',
|
||
});
|
||
setPage(1);
|
||
};
|
||
|
||
const openDetail = async (record) => {
|
||
setSelected(record);
|
||
setOperators([]);
|
||
setOperatorsLoading(true);
|
||
try {
|
||
const result = await getProductMonitoringOperators(record.skuCode || record.skuId);
|
||
setOperators(result?.data?.operators || []);
|
||
} catch (requestError) {
|
||
message.error(requestError?.response?.data?.error || requestError?.message || '加载运营明细失败');
|
||
} finally {
|
||
setOperatorsLoading(false);
|
||
}
|
||
};
|
||
|
||
const columns = useMemo(() => [
|
||
{ title: '图片', dataIndex: 'imageUrl', width: 64, fixed: 'left', exportable: false, render: (value) => (value ? <Image src={value} width={36} height={36} style={{ objectFit: 'cover', borderRadius: 4 }} placeholder /> : <span className="pm-muted">--</span>) },
|
||
{ title: '品类', dataIndex: 'category', width: 150, ellipsis: true, sorter: true },
|
||
{ title: 'SPU', dataIndex: 'spu', width: 130, sorter: true },
|
||
{ title: 'SKU', dataIndex: 'skuId', width: 150, fixed: 'left', sorter: true, render: (value, row) => <Button type="link" className="pm-id-link" onClick={() => openDetail(row)}>{value}</Button> },
|
||
{ title: '条码', dataIndex: 'barcode', width: 150, ellipsis: true },
|
||
{ title: '产品名称', dataIndex: 'productName', width: 240, ellipsis: true, sorter: true, render: (value) => <Tooltip title={value}>{text(value)}</Tooltip> },
|
||
{ title: '材质', dataIndex: 'material', width: 100, ellipsis: true, render: text },
|
||
{ title: '颜色', dataIndex: 'color', width: 100, ellipsis: true, render: text },
|
||
{ title: '尺寸', dataIndex: 'size', width: 140, ellipsis: true, render: text },
|
||
{ title: '销售状态', dataIndex: 'saleStatus', width: 90, sorter: true, render: (v) => <Tag color={v === '销售' ? 'green' : v === '停产' ? 'orange' : 'default'}>{text(v)}</Tag> },
|
||
{ title: '供应商', dataIndex: 'supplier', width: 180, ellipsis: true, sorter: true, render: text },
|
||
{ title: '发货仓库', dataIndex: 'warehouse', width: 120, ellipsis: true, sorter: true, render: text },
|
||
{ title: '公司SKU负责人', dataIndex: 'companyOwner', width: 130, sorter: true, render: text },
|
||
{ title: '采购负责人', dataIndex: 'purchaser', width: 110, sorter: true, render: text },
|
||
{ title: '公司近7日日销', dataIndex: 'companySales7d', width: 120, align: 'right', sorter: true, render: (v) => number(v) },
|
||
{ title: '可使用库存', dataIndex: 'availableStock', width: 110, align: 'right', sorter: true, render: int },
|
||
{ title: '在途库存', dataIndex: 'inTransitStock', width: 100, align: 'right', render: (v) => <span className="pm-muted">{text(v)}</span> },
|
||
{ title: '可使用天数', dataIndex: 'stockDays', width: 110, align: 'right', sorter: true, render: (v) => number(v, 1) },
|
||
{ title: '公司商品标签', dataIndex: 'companyTag', width: 110, sorter: true, render: (v) => (v ? <Tag color={COMPANY_TAG_COLOR[v] || 'default'}>{v}</Tag> : <span className="pm-muted">--</span>) },
|
||
{ title: '部门', dataIndex: 'department', width: 110, ellipsis: true, sorter: true, render: text },
|
||
{ title: '部门的标签', dataIndex: 'departmentTag', width: 110, sorter: true, render: (v) => (v ? <Tag color={DEPARTMENT_TAG_COLOR[v] || 'default'}>{v}</Tag> : <span className="pm-muted">--</span>) },
|
||
{ title: '部门SKU负责人', dataIndex: 'departmentOwner', width: 130, sorter: true, render: text },
|
||
{ title: '近30天销售额', dataIndex: 'performance30d', width: 130, align: 'right', sorter: true, render: (v) => number(v) },
|
||
{ title: '近7天销售额', dataIndex: 'performance7d', width: 120, align: 'right', sorter: true, render: (v) => number(v) },
|
||
{ title: '部门近7日日销', dataIndex: 'departmentSales7d', width: 120, align: 'right', sorter: true, render: (v) => number(v) },
|
||
{ title: '部门未发货数量', dataIndex: 'departmentUnshipped', width: 120, align: 'right', render: (v) => <span className="pm-muted">{text(v)}</span> },
|
||
{ title: '建议备货数量', dataIndex: 'suggestedReplenish', width: 120, align: 'right', sorter: true, render: int },
|
||
{ title: '部门主销售渠道', dataIndex: 'mainSalesChannel', width: 180, ellipsis: true, sorter: true, render: text },
|
||
{ title: '部门主销售运营', dataIndex: 'mainSalesOperator', width: 130, sorter: true, render: text },
|
||
], []);
|
||
|
||
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, `商品监控_${status === '__all__' ? '全部' : status}_${data.latestDate ? String(data.latestDate).slice(0, 10) : '最新'}`, exportColumns, data.rows, '商品监控');
|
||
message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`);
|
||
} catch (exportError) {
|
||
message.error(exportError?.message || '导出失败');
|
||
}
|
||
};
|
||
|
||
const summary = data.summary || {};
|
||
const operatorColumns = [
|
||
{ title: '运营人员', dataIndex: 'operator', width: 140 },
|
||
{ title: '日销(近7天日均件数)', dataIndex: 'dailySales', align: 'right', render: (v) => number(v) },
|
||
{ title: '近7天销售件数', dataIndex: 'salesQty7d', align: 'right', render: int },
|
||
{ title: '近7天销售额', dataIndex: 'salesAmount7d', align: 'right', render: (v) => number(v) },
|
||
{ title: '主销售渠道', dataIndex: 'mainChannel', render: text },
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<Card className="pm-filter-card" bordered={false}>
|
||
<div className="pm-status-bar">
|
||
<Text type="secondary">销售状态</Text>
|
||
<Segmented
|
||
value={status}
|
||
onChange={changeStatus}
|
||
options={STATUS_TABS.map((item) => ({ label: item.label, value: item.key }))}
|
||
/>
|
||
</div>
|
||
<Space wrap size={[12, 12]}>
|
||
<Select allowClear showSearch placeholder="品类" options={toOptions(filterOptions.categories)} value={filters.category || undefined} onChange={changeFilter('category')} style={{ width: 180 }} />
|
||
<Select allowClear showSearch placeholder="供应商" options={toOptions(filterOptions.suppliers)} value={filters.supplier || undefined} onChange={changeFilter('supplier')} style={{ width: 200 }} />
|
||
<Select allowClear showSearch placeholder="发货仓库" options={toOptions(filterOptions.warehouses)} value={filters.warehouse || undefined} onChange={changeFilter('warehouse')} style={{ width: 150 }} />
|
||
<Select allowClear showSearch placeholder="采购负责人" options={toOptions(filterOptions.purchasers)} value={filters.purchaser || undefined} onChange={changeFilter('purchaser')} style={{ width: 150 }} />
|
||
<Select
|
||
mode="multiple" allowClear placeholder="公司标签" maxTagCount="responsive"
|
||
options={['S', 'A', 'B', 'C', 'D'].map((value) => ({ value, label: value }))}
|
||
value={filters.companyTag} onChange={changeMultiFilter('companyTag')} style={{ minWidth: 160 }}
|
||
/>
|
||
<Select
|
||
mode="multiple" allowClear placeholder="部门标签" maxTagCount="responsive"
|
||
options={['G-S', 'G-A', 'G-B', 'G-C'].map((value) => ({ value, label: value }))}
|
||
value={filters.departmentTag} onChange={changeMultiFilter('departmentTag')} style={{ minWidth: 160 }}
|
||
/>
|
||
<Input allowClear prefix={<SearchOutlined />} placeholder="SPU" value={filters.spu} onChange={(e) => changeFilter('spu')(e.target.value)} style={{ width: 150 }} />
|
||
<Input allowClear prefix={<SearchOutlined />} placeholder="SKU / 条码" value={filters.sku} onChange={(e) => changeFilter('sku')(e.target.value)} style={{ width: 160 }} />
|
||
<Input allowClear prefix={<SearchOutlined />} placeholder="产品名称" value={filters.productName} onChange={(e) => changeFilter('productName')(e.target.value)} style={{ width: 180 }} />
|
||
<Input allowClear prefix={<SearchOutlined />} placeholder="部门SKU负责人" value={filters.departmentOwner} onChange={(e) => changeFilter('departmentOwner')(e.target.value)} style={{ width: 160 }} />
|
||
<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="pm-summary-row">
|
||
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-blue"><Statistic title="SKU 总数" value={summary.skuCount || 0} /></Card></Col>
|
||
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-red"><Statistic title="零库存 SKU" value={summary.outOfStockCount || 0} /></Card></Col>
|
||
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-orange"><Statistic title="可用天数 < 7 天" value={summary.lowStockDaysCount || 0} /></Card></Col>
|
||
<Col xs={12} md={6}><Card bordered={false} className="pm-summary-card is-green"><Statistic title="建议备货合计" value={summary.totalSuggestedReplenish || 0} suffix={`(${summary.needReplenishCount || 0} 个 SKU)`} /></Card></Col>
|
||
</Row>
|
||
|
||
<Card
|
||
bordered={false}
|
||
className="pm-panel"
|
||
title={(
|
||
<Space size={12} wrap>
|
||
<span>商品明细({data.total || 0} 条)</span>
|
||
{data.latestDate ? <Text type="secondary" className="pm-panel__date">统计日 {String(data.latestDate).slice(0, 10)}</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="正在加载商品监控数据…">
|
||
<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: 3400, y: 'min(520px, calc(100vh - 520px))' }}
|
||
/>
|
||
</Spin>
|
||
</Card>
|
||
|
||
<Drawer
|
||
className="pm-detail-drawer"
|
||
title={selected ? `${selected.skuId} · 部门运营明细` : '部门运营明细'}
|
||
width={820}
|
||
open={Boolean(selected)}
|
||
onClose={() => setSelected(null)}
|
||
>
|
||
{selected ? (
|
||
<>
|
||
<Space size={[16, 8]} wrap className="pm-detail-meta">
|
||
<Text type="secondary">产品名称:{text(selected.productName)}</Text>
|
||
<Text type="secondary">部门:{text(selected.department)}</Text>
|
||
<Text type="secondary">部门标签:{text(selected.departmentTag)}</Text>
|
||
<Text type="secondary">部门SKU负责人:{text(selected.departmentOwner)}</Text>
|
||
</Space>
|
||
<Table
|
||
className="pm-daily-table"
|
||
loading={operatorsLoading}
|
||
rowKey="operator"
|
||
size="small"
|
||
pagination={false}
|
||
dataSource={operators}
|
||
columns={operatorColumns}
|
||
locale={{ emptyText: '该 SKU 近 7 天暂无销售明细' }}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
const ProductMonitoring = () => (
|
||
<SycmPage className="pm-page">
|
||
<header className="pm-header">
|
||
<div className="pm-header__heading">
|
||
<Title level={3}>商品监控</Title>
|
||
<Text type="secondary">全公司商品档案、库存、标签分级与部门运营指标;点击 SKU 可下钻查看部门运营明细</Text>
|
||
</div>
|
||
</header>
|
||
<Tabs
|
||
className="pm-tabs"
|
||
activeKey="all"
|
||
items={[{ key: 'all', label: '全公司', children: <ProductMonitoringPanel /> }]}
|
||
/>
|
||
</SycmPage>
|
||
);
|
||
|
||
export default ProductMonitoring;
|