youruo 2745cf18ff feat: 库存动销分析「销售=0」导出按仓位拆行,库存数量按实际仓位统计
上一版把同一 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 仓
2026-09-28 09:55:45 +08:00

268 lines
10 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, 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;