yanhui 3e02c3e393 feat: 日销口径由近 7 天改为近 15 天(MKH 补货预警 + 模块化库存监控)
业务要求「近7日日销」改「近30天日销」。实测业务库与产品资料库均无 30 天日销,
已实现的最长周期是 pm_stockMonitor.fifteenAvgSale(15 天),经与业务确认改用 15 天。

为什么没有 30 天(2026-09-17 实测,避免重复排查):
- rpt.pm_stockMonitor 只有 threeAvgSale(3天)/sevenAvgSale(7天)/fifteenAvgSale(15天),
  且整表只保留 1 天快照(2026-09-16),攒不出 30 天历史
- rpt.dws_sn_sales_full_info_di(stat_date+skuid+goods_count,新鲜到 09-15)
  里 MKH 商品 0 个;目标仓 794 个 SKU 命中也是 0
- rpt.dws_order_sales_cost_detail_di 停在 2025-04(历史归档)
- rpt.jst_salesOutboundInfo 只有出库单头,无 SKU 明细;业务库无出库明细表
- rpt.op_BarcodeSalePrice30dDaily 名字带 30d,但只有 salePrice 没有销量

改动:
- backend/src/services/mkhBomService.js
  · SALE_WINDOW_DAYS 7 → 15,新增 DAILY_SALE_COLUMN='fifteenAvgSale',
    监控快照 SQL 按该常量插值取数(以后改口径只改常量)
  · buildReplenishMetrics 入参 sevenAvgSale → avgSale(语义与周期解耦)
  · 文件头 ⑦ 补「为什么是 15 天」的完整排查结论;⑦ / ⑩ / ⑪ 实测数字按新口径更新
- backend/src/services/modularStockMonitorService.js
  · 取数列与 API 字段名 sevenAvgSale/avgShip7d → fifteenAvgSale/avgShip15d
    (字段名同步改,避免名不副实)
- frontend/src/pages/MkhBomData/index.jsx
  · 列头与口径说明「近7日」→「近15天」;动态文案兜底 7 → 15
- frontend/src/pages/ModularStockMonitor/index.jsx
  · 列头「近7日日均发货量」→「近15天日均发货量」,dataIndex 同步改
  · 口径说明补充:可用天数仍沿用 jsUseDays(源系统口径),与日销并非同一时间窗

影响(同一份 09-16 快照,7 天 vs 15 天):
  有销商品  114 → 170   周期拉长,更多商品进得了「有销」集合
  补货预警   25 → 19    反而变少:15 天日销普遍低于 7 天(近期销量走低),
                         可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少
  缺货        9 → 10
  已排产     11 → 13
  订单驱动   14 → 8     更多商品自己有日销,不再需要靠订单兜底
  配件不足   10 → 12
「预警变少」是口径变化的正常结果,不是 bug。

验证:
- 后端语法检查 151 个文件通过
- mkhBomService + mkhReplenishAlertJob 单测 52/52 通过
- 后端全量 334 tests / 333 pass(唯一失败是 resilientSecretStore 的
  Windows 文件权限断言,与本改动无关的环境固有问题)
- 接口实测:total 4223 / warningCount 19 / outOfStockCount 10 /
  saleWindowDays 15 / componentShortageCount 12
2026-09-17 14:06:50 +08:00

431 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: '近15天日均发货量',
dataIndex: 'avgShip15d',
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 实时库存、近 15 天日均发货量与可用天数
</Text>
<Text type="secondary" className="msm-note">
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、
库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖);
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;仓位上游未回传,暂显示「未维护」;
图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。
注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天);
库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。
状态列:基于「产品资料系统数据库」判断该 SKU 是否已建立商品档案——已配置MKH = 资料库中可查到该 SKU;未配置MKH = 查不到。
</Text>
</div>
</header>
<Tabs
className="msm-tabs"
activeKey="accessory"
items={[{
key: 'accessory',
label: '配件库存监控',
children: <AccessoryStockPanel />,
}]}
/>
</SycmPage>
);
export default ModularStockMonitor;