按「库存监控」表格在侧边栏增加一级/二级菜单与共用页面 (路由 /planning/large-stock-monitor/:warehouseSlug)。 后端: - 新增 largeStockMonitorConfig.js:26 仓配置,含 wms_co_id / 快照仓名 / routeKey / slug 映射 - 新增 largeStockMonitor.js:config / overview / sku-list 三个接口 - 新增 largeStockMonitorFlow.js:采购进仓、采购退货、销售出仓、销售退货四类单据流水取数 - 新增 verify-large-stock-monitor.js:26 仓端到端校验脚本 - index.js:挂载 /api/large-stock-monitor 路由 口径说明: - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%,下沉到 SQL CASE WHEN 计算 - 四级分级(滞销预警/慢流/呆滞/僵尸)按库龄 + 周转率双维度判定 - 期末数量取聚水潭实际库存 jst_inventoryInfo(按 wms_co_id 单仓), 不用 pm_stockMonitor.jsStock —— 后者 warehouse 是「归属仓」口径, 会把已调拨到外仓的货仍计在本仓名下(崔桥工厂仓因此虚高约 2.2 万) - 截止时刻统一为快照日次日 01:00(跑批时点),库龄/分级均以此为基准,不再用 GETDATE() 前端: - 新增 LargeStockMonitor 页面、18 列定义、API 封装与样式 - 路由与侧边栏:26 条二级菜单 + 权限点 冲突处理: - 主仓库已下线 /api/dashboard(backend/src/api/dashboard.js 被移除), 相应删除 dashboardRoutes 的 require 与挂载,避免后端启动时 require 失败崩溃 - 保留主仓库新增的「模块化库存监控」菜单组及其子项
502 lines
16 KiB
JavaScript
502 lines
16 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Input,
|
||
Popover,
|
||
Segmented,
|
||
Skeleton,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
} from 'antd';
|
||
import {
|
||
InfoCircleOutlined,
|
||
QuestionCircleOutlined,
|
||
ReloadOutlined,
|
||
SearchOutlined,
|
||
WarningOutlined,
|
||
} from '@ant-design/icons';
|
||
import { SycmBusinessPanel, SycmPage } from '../../components/Common';
|
||
import {
|
||
findWarehouseBySlug,
|
||
FORMULA_DESCRIPTION,
|
||
LARGE_STOCK_MONITOR_WAREHOUSES,
|
||
LEVEL_COLOR,
|
||
LEVEL_FILTER_OPTIONS,
|
||
LEVEL_LABEL,
|
||
OVERVIEW_COLUMNS,
|
||
} from '../../constants/largeStockMonitor';
|
||
import {
|
||
getLargeStockMonitorOverview,
|
||
getLargeStockMonitorSkuList,
|
||
} from '../../api/largeStockMonitor';
|
||
import './style.css';
|
||
|
||
function formatNumber(value) {
|
||
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return value;
|
||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 });
|
||
}
|
||
|
||
function formatPercent(value) {
|
||
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return value;
|
||
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })}%`;
|
||
}
|
||
|
||
function formatMonths(value) {
|
||
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return value;
|
||
return `${n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} 月`;
|
||
}
|
||
|
||
function formatStock(value) {
|
||
if (value === null || value === undefined || Number.isNaN(Number(value))) return value;
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return value;
|
||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||
}
|
||
|
||
const OVERVIEW_RENDERERS = {
|
||
periodStart: formatStock,
|
||
openingBalance: formatStock,
|
||
purchaseReturn: formatStock,
|
||
purchaseIn: formatStock,
|
||
salesOut: formatStock,
|
||
salesReturn: formatStock,
|
||
closingQty: formatStock,
|
||
turnoverDays: formatPercent,
|
||
warnLevel: formatStock,
|
||
slowLevel: formatStock,
|
||
staleLevel: formatStock,
|
||
zombieLevel: formatStock,
|
||
};
|
||
|
||
const FormulaHelp = () => (
|
||
<div className="large-stock-monitor-formula">
|
||
<div className="large-stock-monitor-formula__title">计算口径</div>
|
||
<ul>
|
||
{FORMULA_DESCRIPTION.map((line) => (
|
||
<li key={line}>{line}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
|
||
const LargeStockMonitor = () => {
|
||
const { warehouseSlug } = useParams();
|
||
const navigate = useNavigate();
|
||
|
||
const warehouse = useMemo(() => {
|
||
if (!warehouseSlug) return null;
|
||
return findWarehouseBySlug(warehouseSlug) || LARGE_STOCK_MONITOR_WAREHOUSES[0] || null;
|
||
}, [warehouseSlug]);
|
||
|
||
const [overview, setOverview] = useState(null);
|
||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||
const [overviewError, setOverviewError] = useState(null);
|
||
|
||
const [skuListState, setSkuListState] = useState({
|
||
items: [],
|
||
total: 0,
|
||
page: 1,
|
||
pageSize: 20,
|
||
loading: false,
|
||
});
|
||
const [filterLevel, setFilterLevel] = useState('');
|
||
const [keyword, setKeyword] = useState('');
|
||
|
||
useEffect(() => {
|
||
if (!warehouse) {
|
||
setOverview(null);
|
||
setOverviewLoading(false);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setOverviewLoading(true);
|
||
setOverviewError(null);
|
||
getLargeStockMonitorOverview(warehouse.warehouse)
|
||
.then((res) => {
|
||
if (cancelled) return;
|
||
setOverview(res?.data || null);
|
||
})
|
||
.catch((error) => {
|
||
if (cancelled) return;
|
||
setOverviewError(error?.message || '获取概览失败');
|
||
setOverview(null);
|
||
})
|
||
.finally(() => {
|
||
if (cancelled) return;
|
||
setOverviewLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [warehouse]);
|
||
|
||
const reloadSkuList = (nextPage, nextPageSize, nextLevel, nextKeyword) => {
|
||
if (!warehouse) return;
|
||
setSkuListState((prev) => ({ ...prev, loading: true }));
|
||
getLargeStockMonitorSkuList({
|
||
warehouse: warehouse.warehouse,
|
||
page: nextPage,
|
||
pageSize: nextPageSize,
|
||
level: nextLevel,
|
||
keyword: nextKeyword,
|
||
})
|
||
.then((res) => {
|
||
setSkuListState({
|
||
items: res?.data?.items || [],
|
||
total: res?.data?.total || 0,
|
||
page: nextPage,
|
||
pageSize: nextPageSize,
|
||
loading: false,
|
||
});
|
||
})
|
||
.catch(() => {
|
||
setSkuListState((prev) => ({ ...prev, loading: false }));
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!warehouse) return;
|
||
reloadSkuList(1, skuListState.pageSize, filterLevel, keyword.trim());
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [warehouse, filterLevel]);
|
||
|
||
const handleTableChange = (pagination) => {
|
||
const nextPage = pagination.current || 1;
|
||
const nextPageSize = pagination.pageSize || 20;
|
||
reloadSkuList(nextPage, nextPageSize, filterLevel, keyword.trim());
|
||
};
|
||
|
||
const handleSearch = () => {
|
||
reloadSkuList(1, skuListState.pageSize, filterLevel, keyword.trim());
|
||
};
|
||
|
||
const overviewCells = useMemo(() => {
|
||
const map = overview?.overview || null;
|
||
return OVERVIEW_COLUMNS.map((column) => {
|
||
const value = map ? map[column.key] : '—';
|
||
const rendered = column.available && value !== '—' && OVERVIEW_RENDERERS[column.key]
|
||
? OVERVIEW_RENDERERS[column.key](value)
|
||
: value;
|
||
return { ...column, value, rendered };
|
||
});
|
||
}, [overview]);
|
||
|
||
const skuColumns = useMemo(() => ([
|
||
{
|
||
title: 'SKU',
|
||
dataIndex: 'sku',
|
||
key: 'sku',
|
||
width: 130,
|
||
fixed: 'left',
|
||
render: (value, record) => (
|
||
<div className="large-stock-monitor-sku-cell">
|
||
<strong>{value || '-'}</strong>
|
||
{record.spu ? <span className="large-stock-monitor-sku-cell__meta">SPU {record.spu}</span> : null}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '商品名称',
|
||
dataIndex: 'skuName',
|
||
key: 'skuName',
|
||
width: 260,
|
||
render: (value) => <span title={value}>{value || '-'}</span>,
|
||
},
|
||
{
|
||
title: '分类',
|
||
dataIndex: 'category',
|
||
key: 'category',
|
||
width: 120,
|
||
render: (value) => value || '-',
|
||
},
|
||
{
|
||
title: '供应商',
|
||
dataIndex: 'supplier',
|
||
key: 'supplier',
|
||
width: 140,
|
||
render: (value) => value || '-',
|
||
},
|
||
{
|
||
title: '当前库存',
|
||
dataIndex: 'jsStock',
|
||
key: 'jsStock',
|
||
width: 100,
|
||
align: 'right',
|
||
render: formatStock,
|
||
},
|
||
{
|
||
title: () => (
|
||
<span>
|
||
库龄
|
||
<Tooltip title="(今天 - 入库时间) / 30,单位:月">
|
||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
||
</Tooltip>
|
||
</span>
|
||
),
|
||
dataIndex: 'ageMonths',
|
||
key: 'ageMonths',
|
||
width: 100,
|
||
align: 'right',
|
||
render: formatMonths,
|
||
},
|
||
{
|
||
title: () => (
|
||
<span>
|
||
月度发货
|
||
<Tooltip title="≈ threeAvgSale × 30(业务库仅有 3 天日均销量)">
|
||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
||
</Tooltip>
|
||
</span>
|
||
),
|
||
dataIndex: 'monthlyShipment',
|
||
key: 'monthlyShipment',
|
||
width: 110,
|
||
align: 'right',
|
||
render: formatNumber,
|
||
},
|
||
{
|
||
title: () => (
|
||
<span>
|
||
日均库存
|
||
<Tooltip title="≈ jsStock(业务库仅保留最近一天快照,作为日均近似)">
|
||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
||
</Tooltip>
|
||
</span>
|
||
),
|
||
dataIndex: 'avgDailyStock',
|
||
key: 'avgDailyStock',
|
||
width: 110,
|
||
align: 'right',
|
||
render: formatStock,
|
||
},
|
||
{
|
||
title: () => (
|
||
<span>
|
||
库存周转天数
|
||
<Tooltip title="= 月度发货 / 日均库存 × 100%(实际为周转率 %,沿用 Excel 列名)">
|
||
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
|
||
</Tooltip>
|
||
</span>
|
||
),
|
||
dataIndex: 'turnoverDays',
|
||
key: 'turnoverDays',
|
||
width: 130,
|
||
align: 'right',
|
||
render: formatPercent,
|
||
},
|
||
{
|
||
title: '级别',
|
||
dataIndex: 'level',
|
||
key: 'level',
|
||
width: 130,
|
||
fixed: 'right',
|
||
render: (value) => {
|
||
if (!value) return <span style={{ color: '#999' }}>—</span>;
|
||
return <Tag color={LEVEL_COLOR[value]}>{LEVEL_LABEL[value] || value}</Tag>;
|
||
},
|
||
},
|
||
]), []);
|
||
|
||
const siblingOptions = useMemo(() => LARGE_STOCK_MONITOR_WAREHOUSES.map((entry) => ({
|
||
label: `${entry.group} · ${entry.warehouse.replace(/^简尚-/, '')}`,
|
||
value: entry.slug,
|
||
idx: entry.idx,
|
||
group: entry.group,
|
||
})), []);
|
||
|
||
const fallbackNotice = warehouse && overview && !overview.hasData;
|
||
|
||
return (
|
||
<SycmPage className="large-stock-monitor-page">
|
||
<SycmBusinessPanel className="large-stock-monitor-header" aria-label="大货库存监控概览">
|
||
<div className="large-stock-monitor-header__top">
|
||
<div>
|
||
<h2>
|
||
大货库存监控 · {warehouse ? warehouse.warehouse : '请选择仓库'}
|
||
<Popover
|
||
content={<FormulaHelp />}
|
||
title="计算口径"
|
||
trigger="hover"
|
||
placement="rightTop"
|
||
>
|
||
<InfoCircleOutlined
|
||
style={{ marginLeft: 8, fontSize: 16, color: '#1677ff', cursor: 'help' }}
|
||
/>
|
||
</Popover>
|
||
</h2>
|
||
<p className="large-stock-monitor-header__meta">
|
||
<span>二级分类:{warehouse ? warehouse.group : '-'}</span>
|
||
<span>分布时间:{overview?.asOf || overview?.latestBizDate || '—'}</span>
|
||
{overview?.latestBizDate ? <span>库存快照日:{overview.latestBizDate}</span> : null}
|
||
{overview?.closingTakenAt ? (
|
||
<span>库存取数时间:{new Date(overview.closingTakenAt).toLocaleString('zh-CN', { hour12: false })}(实时)</span>
|
||
) : null}
|
||
{overview?.period ? (
|
||
<span>
|
||
流水区间:{overview.period.start} ~ {overview.period.end}(左闭右开)
|
||
</span>
|
||
) : null}
|
||
<span>SKU 数:{overview?.skuCount ?? '—'}</span>
|
||
<span>平均周转率:{overview?.turnoverDaysAvg != null ? formatPercent(overview.turnoverDaysAvg) : '—'}</span>
|
||
<span>最大周转率:{overview?.turnoverDaysMax != null ? formatPercent(overview.turnoverDaysMax) : '—'}</span>
|
||
</p>
|
||
</div>
|
||
<div className="large-stock-monitor-header__actions">
|
||
<Segmented
|
||
value={warehouse?.slug}
|
||
onChange={(value) => {
|
||
if (value) {
|
||
navigate(`/planning/large-stock-monitor/${value}`);
|
||
}
|
||
}}
|
||
options={siblingOptions.slice(0, 6)}
|
||
className="large-stock-monitor-quick-switch"
|
||
/>
|
||
<Button
|
||
icon={<ReloadOutlined />}
|
||
onClick={() => {
|
||
if (warehouse) navigate(`/planning/large-stock-monitor/${warehouse.slug}`);
|
||
}}
|
||
>
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{overviewError ? (
|
||
<Alert
|
||
type="error"
|
||
showIcon
|
||
message={`获取概览失败:${overviewError}`}
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
) : null}
|
||
|
||
{fallbackNotice ? (
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
icon={<WarningOutlined />}
|
||
message="业务库暂未同步该仓库的库存快照,周转率与四级库存量无数据源,显示「—」"
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
) : null}
|
||
|
||
{overview && overview.hasGrade === false ? (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message="该仓库没有库存快照(pm_stockMonitor),库龄、周转天数与四级库存量无数据源,显示「—」;期末数量已按聚水潭实际库存取数"
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
) : null}
|
||
|
||
{overview?.hasGrade && overview?.gradeBasisStock ? (
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
icon={<WarningOutlined />}
|
||
message={`期末数量按聚水潭实际库存统计;四级库存量由库存快照分级得出,快照为「归属仓」口径(含已调拨到外仓的货,合计 ${formatStock(overview.gradeBasisStock)}),两者口径不同,不能直接相加核对`}
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
) : null}
|
||
|
||
{overview?.requestedDate && overview?.fallbackDate ? (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message={`未找到 ${overview.requestedDate} 的库存快照,已自动回退并展示 ${overview.fallbackDate} 的数据`}
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
) : null}
|
||
|
||
{overview?.requestedDate && !overview?.fallbackDate && !overview?.hasData ? (
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
message={`未找到 ${overview.requestedDate} 及之前的库存快照,当前汇总字段全部为占位符「—」`}
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
) : null}
|
||
|
||
<div className="large-stock-monitor-overview">
|
||
{overviewLoading ? (
|
||
<Skeleton active paragraph={{ rows: 2 }} />
|
||
) : (
|
||
<div className="large-stock-monitor-grid">
|
||
{overviewCells.map((cell) => (
|
||
<div
|
||
key={cell.key}
|
||
className={`large-stock-monitor-grid__cell${cell.available ? ' is-available' : ' is-placeholder'}`}
|
||
>
|
||
<span className="large-stock-monitor-grid__label">
|
||
{cell.label}
|
||
{cell.estimated ? <em className="large-stock-monitor-grid__est">估</em> : null}
|
||
{cell.tip ? (
|
||
<Tooltip title={cell.tip}>
|
||
<QuestionCircleOutlined className="large-stock-monitor-grid__help" />
|
||
</Tooltip>
|
||
) : null}
|
||
</span>
|
||
<strong className="large-stock-monitor-grid__value">{cell.rendered}</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</SycmBusinessPanel>
|
||
|
||
<SycmBusinessPanel className="large-stock-monitor-table" aria-label="SKU 库存明细">
|
||
<div className="large-stock-monitor-toolbar">
|
||
<span className="large-stock-monitor-toolbar__note">
|
||
明细按库存快照(归属仓口径)分级,用于识别滞销/慢流/呆滞/僵尸 SKU
|
||
</span>
|
||
<Segmented
|
||
value={filterLevel}
|
||
onChange={setFilterLevel}
|
||
options={LEVEL_FILTER_OPTIONS}
|
||
/>
|
||
<Input.Search
|
||
allowClear
|
||
placeholder="搜索 SKU 编码或商品名称"
|
||
value={keyword}
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
onSearch={handleSearch}
|
||
enterButton={<><SearchOutlined /> 查询</>}
|
||
style={{ maxWidth: 320 }}
|
||
/>
|
||
</div>
|
||
|
||
<Table
|
||
className="large-stock-monitor-data-table"
|
||
rowKey={(record) => `${record.sku}-${record.warehouse}`}
|
||
columns={skuColumns}
|
||
dataSource={skuListState.items}
|
||
loading={skuListState.loading}
|
||
scroll={{ x: 1280 }}
|
||
pagination={{
|
||
current: skuListState.page,
|
||
pageSize: skuListState.pageSize,
|
||
total: skuListState.total,
|
||
showSizeChanger: true,
|
||
showTotal: (total) => `共 ${total} 条`,
|
||
pageSizeOptions: ['20', '50', '100'],
|
||
}}
|
||
onChange={handleTableChange}
|
||
locale={{ emptyText: '该仓库暂无 SKU 库存快照' }}
|
||
/>
|
||
</SycmBusinessPanel>
|
||
</SycmPage>
|
||
);
|
||
};
|
||
|
||
export default LargeStockMonitor; |