上一版为去掉冗余把「类型」整列删了,用户要求的报表列结构中该列仍在, 现恢复:导出与列表均保留「类型」列,值恒为「拣选」(存货位不参与统计,报表中不会出现) 验证(基准日 2026-10-08、30 天): - 导出表头为 仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / 仓位SKU数 / 商品销量 / 分摊销量 / 商品日销 / 动销等级 / 统计天数 / 统计基准日 - 11897 行,类型列取值集合仅 ['拣选'],空值 0 行;分摊销量合计 98087 = 去重商品销量合计 98087 - 列表接口 binTypes=['拣选'],明细类型集合仅 ['拣选'] - 单测 9/9 通过;语法检查 182 文件通过;eslint 无报错、vite build 成功
289 lines
11 KiB
JavaScript
289 lines
11 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert, Button, Card, Col, Input, Modal, Row, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message,
|
||
} from 'antd';
|
||
import { DownloadOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||
import { SycmPage } from '../../components/Common';
|
||
import {
|
||
exportPickingBinSalesAnalysis,
|
||
getPickingBinSalesAnalysis,
|
||
} from '../../api/pickingBinSalesAnalysis';
|
||
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
|
||
import {
|
||
PICKING_BIN_COLUMNS,
|
||
PICKING_BIN_WINDOW_OPTIONS,
|
||
PICKING_BIN_DEFAULT_DAYS,
|
||
PICKING_BIN_GRADE_COLORS,
|
||
PICKING_BIN_DESCRIPTION,
|
||
} from '../../constants/pickingBinSalesAnalysis';
|
||
import './style.css';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
const int = (value) => {
|
||
if (value === null || value === undefined || value === '') return '—';
|
||
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||
};
|
||
|
||
const num = (value, digits = 2) => {
|
||
if (value === null || value === undefined || value === '') return '—';
|
||
return Number(value).toLocaleString('zh-CN', {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
});
|
||
};
|
||
|
||
const TipTitle = ({ tip, children }) => (
|
||
<Tooltip title={tip}>
|
||
<span className="picking-bin-th-tip">{children}</span>
|
||
</Tooltip>
|
||
);
|
||
|
||
const PickingBinSalesAnalysis = () => {
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [data, setData] = useState(null);
|
||
const [helpOpen, setHelpOpen] = useState(false);
|
||
const [days, setDays] = useState(PICKING_BIN_DEFAULT_DAYS);
|
||
const [warehouse, setWarehouse] = useState('');
|
||
const [grades, setGrades] = useState([]);
|
||
const [keyword, setKeyword] = useState('');
|
||
const [exporting, setExporting] = useState(false);
|
||
|
||
const params = useMemo(() => ({
|
||
days,
|
||
...(warehouse ? { wmsCoId: warehouse } : {}),
|
||
}), [days, warehouse]);
|
||
|
||
const load = useCallback(async (options = {}) => {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const res = await getPickingBinSalesAnalysis({
|
||
...(options.params ?? params),
|
||
...(options.force ? { refresh: '1' } : {}),
|
||
});
|
||
setData(res?.data ?? null);
|
||
} catch (err) {
|
||
setError(err?.response?.data?.error || err?.message || '拣选库位动销分析数据加载失败');
|
||
setData(null);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [params]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const items = useMemo(() => data?.items ?? [], [data]);
|
||
const gradeMap = useMemo(() => new Map((data?.grades ?? []).map((g) => [g.key, g])), [data]);
|
||
|
||
const filteredRows = useMemo(() => {
|
||
const kw = keyword.trim().toLowerCase();
|
||
return items.filter((it) => {
|
||
if (grades.length > 0 && !grades.includes(it.gradeKey)) return false;
|
||
if (kw
|
||
&& !String(it.binName).toLowerCase().includes(kw)
|
||
&& !String(it.skuId).toLowerCase().includes(kw)) return false;
|
||
return true;
|
||
});
|
||
}, [items, grades, keyword]);
|
||
|
||
const columns = useMemo(() => PICKING_BIN_COLUMNS.map((col) => ({
|
||
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
|
||
key: col.key,
|
||
dataIndex: col.key,
|
||
align: ['warehouse', 'binName', 'binType', 'skuId', 'gradeKey'].includes(col.key) ? 'left' : 'right',
|
||
width: col.key === 'warehouse' ? 240
|
||
: (col.key === 'gradeKey' ? 170 : (col.key === 'skuId' ? 150 : 110)),
|
||
sorter: ['stockQty', 'salesQty', 'dailySales'].includes(col.key)
|
||
? (a, b) => (a[col.key] ?? 0) - (b[col.key] ?? 0)
|
||
: undefined,
|
||
render: (value) => {
|
||
if (col.key === 'gradeKey') {
|
||
const g = gradeMap.get(value);
|
||
return <Tag color={PICKING_BIN_GRADE_COLORS[value] || 'default'}>{g?.label || value}</Tag>;
|
||
}
|
||
if (col.key === 'binType' || col.key === 'skuId') return <Text>{value || '—'}</Text>;
|
||
if (col.key === 'dailySales') return num(value, 2);
|
||
if (col.key === 'salesQty') return num(value, 2);
|
||
if (col.key === 'stockQty') return int(value);
|
||
return value || '—';
|
||
},
|
||
})), [gradeMap]);
|
||
|
||
const handleExport = useCallback(async () => {
|
||
setExporting(true);
|
||
try {
|
||
const blob = await exportPickingBinSalesAnalysis({
|
||
...params,
|
||
...(grades.length > 0 ? { grade: grades.join(',') } : {}),
|
||
format: 'xlsx',
|
||
});
|
||
const dateKey = (data?.latestBizDate || '').replace(/-/g, '') || '未知日期';
|
||
const fileName = `拣选库位动销分析-${dateKey}.xlsx`;
|
||
await downloadBlobAsFile(blob, fileName);
|
||
message.success(`已开始下载:${fileName}`);
|
||
} catch (exportError) {
|
||
message.error(await readBlobErrorMessage(exportError, '库位动销明细导出失败'));
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}, [params, grades, data?.latestBizDate]);
|
||
|
||
const toggleGrade = useCallback((key) => {
|
||
setGrades((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||
}, []);
|
||
|
||
const summary = data?.summary ?? [];
|
||
const total = data?.total ?? null;
|
||
|
||
return (
|
||
<SycmPage className="picking-bin-page">
|
||
<div className="picking-bin-header">
|
||
<div className="picking-bin-header__title">
|
||
<Title level={4} style={{ margin: 0 }}>拣选库位动销分析</Title>
|
||
<Button
|
||
type="text"
|
||
icon={<InfoCircleOutlined />}
|
||
onClick={() => setHelpOpen(true)}
|
||
className="picking-bin-header__help"
|
||
>
|
||
口径说明
|
||
</Button>
|
||
</div>
|
||
<div className="picking-bin-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={PICKING_BIN_WINDOW_OPTIONS}
|
||
onChange={setDays}
|
||
style={{ width: 110 }}
|
||
/>
|
||
</Space>
|
||
<Select
|
||
allowClear
|
||
value={warehouse || undefined}
|
||
options={(data?.warehouses ?? []).map((w) => ({ label: w.warehouse, value: w.wmsCoId }))}
|
||
onChange={(v) => setWarehouse(v || '')}
|
||
showSearch
|
||
optionFilterProp="label"
|
||
style={{ minWidth: 240 }}
|
||
placeholder="全部仓库"
|
||
/>
|
||
<Input.Search
|
||
allowClear
|
||
value={keyword}
|
||
onChange={(e) => setKeyword(e.target.value)}
|
||
placeholder="搜索仓位 / 商品编码"
|
||
style={{ width: 180 }}
|
||
/>
|
||
<Button icon={<ReloadOutlined />} onClick={() => load()} loading={loading}>刷新</Button>
|
||
<Tooltip title="跳过 5 分钟缓存,重新扫描订单与仓位明细重算(约 5~20 秒)">
|
||
<Button onClick={() => load({ force: true })} loading={loading}>强制重算</Button>
|
||
</Tooltip>
|
||
<Tooltip title="导出当前筛选下的明细(仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / 仓位SKU数 / 商品销量 / 分摊销量 / 商品日销 / 动销等级),仅含拣选位。按「仓位 × 商品编码」拆行;商品销量为整档销量(同一商品跨仓位重复,不可直接求和),需要求和请用「分摊销量」列;动销等级按商品日销判定">
|
||
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出明细</Button>
|
||
</Tooltip>
|
||
</Space>
|
||
</div>
|
||
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message={`按「商品日均销量(商品日销)」划分 ${summary.length} 个动销等级,一行 = 一个「仓位 × 商品编码」组合`}
|
||
description="商品日销 = 该商品近 N 天销量 ÷ N,不做仓位分摊,因此同一件商品放在几个仓位都是同一等级。商品销量跨仓位重复显示,不能直接按行求和(需要求和请导出用「分摊销量」列)。点击下方等级卡片可筛选。"
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
|
||
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
|
||
|
||
<Spin spinning={loading}>
|
||
<Row gutter={[8, 8]} className="picking-bin-summary">
|
||
{summary.map((s) => (
|
||
<Col key={s.key} flex="1 1 150px">
|
||
<Card
|
||
size="small"
|
||
hoverable
|
||
onClick={() => toggleGrade(s.key)}
|
||
className={grades.includes(s.key) ? 'picking-bin-card is-active' : 'picking-bin-card'}
|
||
>
|
||
<Tooltip title={`${s.tip};明细 ${int(s.rowCount)} 行,库存合计 ${int(s.stockQty)} 件,日销合计 ${num(s.dailySales, 1)}(销量按去重商品计)`}>
|
||
<Statistic
|
||
title={s.label}
|
||
value={s.skuCount}
|
||
suffix="品"
|
||
valueStyle={{ fontSize: 18 }}
|
||
/>
|
||
</Tooltip>
|
||
<div className="picking-bin-card__meta">
|
||
明细 {int(s.rowCount)} 行 · 库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)}
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
{total ? (
|
||
<Col flex="1 1 150px">
|
||
<Card size="small" className="picking-bin-card is-total">
|
||
<Tooltip title={`去重商品 ${int(total.skuCount)} 个,涉及「仓位 × 商品」组合 ${int(total.rowCount)} 行`}>
|
||
<Statistic title={total.label} value={total.skuCount} suffix="品" valueStyle={{ fontSize: 18 }} />
|
||
</Tooltip>
|
||
<div className="picking-bin-card__meta">
|
||
明细 {int(total.rowCount)} 行 · 库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)}
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
) : null}
|
||
</Row>
|
||
|
||
<Table
|
||
className="picking-bin-table"
|
||
columns={columns}
|
||
dataSource={filteredRows}
|
||
rowKey={(r) => `${r.wmsCoId}::${r.binName}::${r.skuId}`}
|
||
size="small"
|
||
bordered
|
||
pagination={{
|
||
pageSize: 50,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [50, 100, 200],
|
||
showTotal: (t) => `共 ${t} 条明细`,
|
||
}}
|
||
scroll={{ x: 'max-content', y: 'calc(100vh - 420px)' }}
|
||
/>
|
||
</Spin>
|
||
|
||
<Text type="secondary" className="picking-bin-footer">
|
||
当前筛选出 {filteredRows.length} 条明细
|
||
{items.length !== filteredRows.length ? `(共 ${items.length} 条)` : ''};
|
||
去重商品 {int(data?.total?.skuCount ?? 0)} 个;统计范围:仅拣选位。
|
||
</Text>
|
||
|
||
<Modal
|
||
title="计算口径说明"
|
||
open={helpOpen}
|
||
onCancel={() => setHelpOpen(false)}
|
||
footer={null}
|
||
width={760}
|
||
>
|
||
<div className="picking-bin-help">
|
||
{PICKING_BIN_DESCRIPTION.map((line, i) => (
|
||
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
|
||
))}
|
||
</div>
|
||
</Modal>
|
||
</SycmPage>
|
||
);
|
||
};
|
||
|
||
export default PickingBinSalesAnalysis;
|