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

341 lines
17 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, 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;