lunanting 2bc949099b feat: 新增大货库存监控(26 仓)
按「库存监控」表格在侧边栏增加一级/二级菜单与共用页面
(路由 /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 失败崩溃
- 保留主仓库新增的「模块化库存监控」菜单组及其子项
2026-09-23 13:42:58 +08:00

502 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, { 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;