上一版把同一 SKU 的多个仓位用「、」拼在一格里,库存数量仍是 SKU 总量, 无法直接按仓位盘点。现改为每个仓位独立一行: - bin CTE 改为按 (wms_co_id, sku_id, LTRIM(RTRIM(bin))) 分组, SUM(qty) 得到仓位级库存(各仓位行之和 = SKU 总库存,实测 240=60+69+35+76) - 导出行 = graded(SKU 级「销售=0」判定)LEFT JOIN 仓位级明细: 有仓位数据的 SKU 按仓位展开多行、库存数量取该仓位实际 qty(qty=0 的仓位行剔除); 仓位表未覆盖的 SKU 保留一行(仓位为空、库存取总库存),不丢数据 - 排序:仓库 → SKU 总库存倒序 → SKU → 仓位名称,同一 SKU 的各行保持相邻 - 前端按钮提示同步更新拆行与兜底规则说明 验证:后端 syntax 通过、单测 9/9(全量 389/390,唯一失败为既有 Windows 环境项); 前端 eslint/build 通过; 本地实测:单仓(睢宁 196 行,原 128 SKU 行)样例 SKU 正确拆为 4 行且合计 240; 全仓导出 6798 行(原 3650),有仓位行 6529、仓位为空兜底行 269,覆盖 26 仓
268 lines
10 KiB
JavaScript
268 lines
10 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert, Button, Modal, Select, Space, Spin, Table, Tooltip, Typography, message,
|
||
} from 'antd';
|
||
import { DownloadOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||
import { SycmPage } from '../../components/Common';
|
||
import { exportStockSalesZeroDetail, getStockSalesAnalysis } from '../../api/stockSalesAnalysis';
|
||
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
|
||
import {
|
||
STOCK_SALES_COLUMNS,
|
||
STOCK_SALES_WINDOW_OPTIONS,
|
||
STOCK_SALES_DEFAULT_DAYS,
|
||
STOCK_SALES_DESCRIPTION,
|
||
} from '../../constants/stockSalesAnalysis';
|
||
import './style.css';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
const ALL_WAREHOUSES = '__all__';
|
||
|
||
const int = (value) => {
|
||
if (value === null || value === undefined || value === '') return '—';
|
||
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||
};
|
||
|
||
const percent = (value) => {
|
||
if (value === null || value === undefined || value === '') return '—';
|
||
return `${(Number(value) * 100).toLocaleString('zh-CN', {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
})}%`;
|
||
};
|
||
|
||
const TipTitle = ({ tip, children }) => (
|
||
<Tooltip title={tip}>
|
||
<span className="stock-sales-th-tip">{children}</span>
|
||
</Tooltip>
|
||
);
|
||
|
||
const StockSalesAnalysis = () => {
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [data, setData] = useState(null);
|
||
const [helpOpen, setHelpOpen] = useState(false);
|
||
const [days, setDays] = useState(STOCK_SALES_DEFAULT_DAYS);
|
||
const [warehouse, setWarehouse] = useState(ALL_WAREHOUSES);
|
||
const [exporting, setExporting] = useState(false);
|
||
|
||
const load = useCallback(async (options = {}) => {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const res = await getStockSalesAnalysis({
|
||
days: options.days ?? STOCK_SALES_DEFAULT_DAYS,
|
||
...(options.force ? { refresh: '1' } : {}),
|
||
});
|
||
setData(res?.data ?? null);
|
||
} catch (err) {
|
||
setError(err?.response?.data?.error || err?.message || '库存动销分析数据加载失败');
|
||
setData(null);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load({ days: STOCK_SALES_DEFAULT_DAYS }); }, [load]);
|
||
|
||
const handleDaysChange = useCallback((value) => {
|
||
setDays(value);
|
||
load({ days: value });
|
||
}, [load]);
|
||
|
||
const items = useMemo(() => data?.items ?? [], [data]);
|
||
const grand = data?.grand ?? null;
|
||
|
||
/** 导出「销售=0」档 SKU 明细:跟随当前统计天数与仓库筛选 */
|
||
const handleExportZero = useCallback(async () => {
|
||
setExporting(true);
|
||
try {
|
||
const wmsCoId = warehouse === ALL_WAREHOUSES
|
||
? undefined
|
||
: items.find((it) => it.warehouse === warehouse)?.wmsCoId;
|
||
const params = { days, format: 'xlsx', ...(wmsCoId != null ? { wmsCoId: String(wmsCoId) } : {}) };
|
||
const blob = await exportStockSalesZeroDetail(params);
|
||
const dateKey = (data?.latestBizDate || '').replace(/-/g, '') || '未知日期';
|
||
const scope = warehouse === ALL_WAREHOUSES ? '' : `-${warehouse}`;
|
||
const fileName = `库存动销分析-销售0明细${scope}-${dateKey}.xlsx`;
|
||
downloadBlobAsFile(blob, fileName);
|
||
message.success(`已开始下载:${fileName}`);
|
||
} catch (exportError) {
|
||
message.error(await readBlobErrorMessage(exportError, '导出「销售=0」明细失败'));
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}, [days, warehouse, items, data?.latestBizDate]);
|
||
|
||
const warehouseOptions = useMemo(() => ([
|
||
{ label: '全部仓库', value: ALL_WAREHOUSES },
|
||
...items.map((it) => ({ label: it.warehouse, value: it.warehouse })),
|
||
]), [items]);
|
||
|
||
const visibleItems = useMemo(() => (
|
||
warehouse === ALL_WAREHOUSES ? items : items.filter((it) => it.warehouse === warehouse)
|
||
), [items, warehouse]);
|
||
|
||
/** 表格行:每个仓库 6 个等级 + 1 行汇总;仓库列跨行合并 */
|
||
const tableRows = useMemo(() => {
|
||
const rows = [];
|
||
const pushGroup = (warehouseName, buckets, total, extra = {}) => {
|
||
// 注意:bucket / total 自带 key(等级 key),必须放在展开之后覆盖,否则跨仓行 key 会重复
|
||
buckets.forEach((bucket, index) => {
|
||
rows.push({
|
||
...bucket,
|
||
...extra,
|
||
key: `${warehouseName}::${bucket.key}`,
|
||
warehouse: warehouseName,
|
||
warehouseRowSpan: index === 0 ? buckets.length + 1 : 0,
|
||
grade: bucket.label,
|
||
});
|
||
});
|
||
rows.push({
|
||
...total,
|
||
...extra,
|
||
key: `${warehouseName}::__total__`,
|
||
warehouse: warehouseName,
|
||
warehouseRowSpan: 0,
|
||
grade: total.label,
|
||
isSubtotal: true,
|
||
});
|
||
};
|
||
|
||
visibleItems.forEach((item) => pushGroup(item.warehouse, item.buckets, item.total));
|
||
if (grand && (warehouse === ALL_WAREHOUSES || visibleItems.length === 0)) {
|
||
pushGroup(grand.warehouse, grand.buckets, grand.total, { isGrand: true });
|
||
}
|
||
return rows;
|
||
}, [visibleItems, grand, warehouse]);
|
||
|
||
const columns = useMemo(() => STOCK_SALES_COLUMNS.map((col) => ({
|
||
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
|
||
key: col.key,
|
||
dataIndex: col.key,
|
||
align: ['warehouse', 'grade'].includes(col.key) ? 'left' : 'right',
|
||
width: col.key === 'warehouse' ? 260 : (col.key === 'grade' ? 180 : 130),
|
||
fixed: col.key === 'warehouse' ? 'left' : undefined,
|
||
onCell: col.key === 'warehouse'
|
||
? (record) => ({ rowSpan: record.warehouseRowSpan })
|
||
: undefined,
|
||
render: (value, record) => {
|
||
if (col.key === 'warehouse') {
|
||
if (record.warehouseRowSpan === 0) return null;
|
||
return <span className="stock-sales-warehouse">{value}</span>;
|
||
}
|
||
if (col.key === 'grade') {
|
||
return <span className={record.isSubtotal ? 'stock-sales-grade is-total' : 'stock-sales-grade'}>{value}</span>;
|
||
}
|
||
if (col.key === 'skuRatio' || col.key === 'stockRatio') return percent(value);
|
||
return int(value);
|
||
},
|
||
})), []);
|
||
|
||
const gradeCount = data?.grades?.length ?? 0;
|
||
|
||
return (
|
||
<SycmPage className="stock-sales-page">
|
||
<div className="stock-sales-header">
|
||
<div className="stock-sales-header__title">
|
||
<Title level={4} style={{ margin: 0 }}>库存动销分析</Title>
|
||
<Button
|
||
type="text"
|
||
icon={<InfoCircleOutlined />}
|
||
onClick={() => setHelpOpen(true)}
|
||
className="stock-sales-header__help"
|
||
>
|
||
口径说明
|
||
</Button>
|
||
</div>
|
||
<div className="stock-sales-header__meta">
|
||
<span>统计基准日:{data?.latestBizDate || '—'}</span>
|
||
{data?.shipWindow ? (
|
||
<span>
|
||
销量窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天)
|
||
</span>
|
||
) : null}
|
||
{data?.takenAt ? (
|
||
<span>库存取数时间:{new Date(data.takenAt).toLocaleString('zh-CN', { hour12: false })}</span>
|
||
) : null}
|
||
</div>
|
||
<Space wrap>
|
||
<Space size={4}>
|
||
<Text type="secondary">统计天数</Text>
|
||
<Select
|
||
value={days}
|
||
options={STOCK_SALES_WINDOW_OPTIONS}
|
||
onChange={handleDaysChange}
|
||
style={{ width: 120 }}
|
||
/>
|
||
</Space>
|
||
<Select
|
||
value={warehouse}
|
||
options={warehouseOptions}
|
||
onChange={setWarehouse}
|
||
showSearch
|
||
optionFilterProp="label"
|
||
style={{ minWidth: 240 }}
|
||
placeholder="选择仓库"
|
||
/>
|
||
<Button icon={<ReloadOutlined />} onClick={() => load({ days })} loading={loading}>刷新</Button>
|
||
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细与库存明细重算(约 5~20 秒)">
|
||
<Button onClick={() => load({ days, force: true })} loading={loading}>强制重算</Button>
|
||
</Tooltip>
|
||
<Tooltip title="导出当前筛选下「销售=0」档的 SKU 明细(仓库 / SKU编码 / SKU名称 / 系统仓位 / 库存数量),跟随统计天数与仓库筛选;每个仓位独立一行,库存数量为该仓位的实际数量,仓位数据未覆盖的 SKU 保留一行(仓位留空、库存为总量)">
|
||
<Button icon={<DownloadOutlined />} onClick={handleExportZero} loading={exporting}>导出销售=0</Button>
|
||
</Tooltip>
|
||
</Space>
|
||
</div>
|
||
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message={`按「单 SKU 日均销量」把库存拆成 ${gradeCount} 个动销等级,全部数值来自业务库实时取数`}
|
||
description="库存取实时库存表,销量取销售出库单;占比为「该等级 ÷ 该仓库合计」。切换统计天数会按新窗口重算日销与分档。"
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
|
||
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
|
||
|
||
<Spin spinning={loading}>
|
||
<Table
|
||
className="stock-sales-table"
|
||
columns={columns}
|
||
dataSource={tableRows}
|
||
rowKey="key"
|
||
size="small"
|
||
bordered
|
||
pagination={false}
|
||
scroll={{ x: 'max-content', y: 'calc(100vh - 320px)' }}
|
||
rowClassName={(record) => [
|
||
record?.isGrand ? 'stock-sales-row--grand' : '',
|
||
record?.isSubtotal ? 'stock-sales-row--subtotal' : '',
|
||
].filter(Boolean).join(' ')}
|
||
/>
|
||
</Spin>
|
||
|
||
<Text type="secondary" className="stock-sales-footer">
|
||
共 {visibleItems.length} 个仓库;每个仓库 {gradeCount} 个动销等级 + 1 行汇总
|
||
{data?.emptyWarehouseCount > 0 ? `;其中 ${data.emptyWarehouseCount} 个仓在业务库无有效库存或销量` : ''}。
|
||
</Text>
|
||
|
||
<Modal
|
||
title="计算口径说明"
|
||
open={helpOpen}
|
||
onCancel={() => setHelpOpen(false)}
|
||
footer={null}
|
||
width={720}
|
||
>
|
||
<div className="stock-sales-help">
|
||
{STOCK_SALES_DESCRIPTION.map((line, i) => (
|
||
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
|
||
))}
|
||
</div>
|
||
</Modal>
|
||
</SycmPage>
|
||
);
|
||
};
|
||
|
||
export default StockSalesAnalysis;
|