feat: 商品试算页
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 05 入库与导出(docs/v2.1/trial-page.md §7):
|
||||
* 入库即各区块自动落库;此处提供 本商品CSV / 批量CSV(16 列,对齐 v1 web)与 组合码(SKU 预留₽价)。
|
||||
* Phase A 由前端拼装(逐个拉详情),Phase D 可换 GET /api/export/trial-csv。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Input, message, Modal, Space, Tag, Typography } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { getProduct, listProducts, ProductDetail } from '@/services/product';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { copyText, downloadCsv } from '@/utils/file';
|
||||
import { STAGE_LABEL } from '../collection/CollectionPage';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const CSV_HEADER = [
|
||||
'货号', '商品名', '进货价', '物流费', '平台总抽成', '实收价', '完全成本', '销售价',
|
||||
'净利润', '净利率', '卢布销价', '重量(g)', '尺寸(cm)', '状态', 'Ozon地址', '采买地址',
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function buildRow(p: ProductDetail): Array<string | number | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pr = (p.pricing ?? {}) as Record<string, any>;
|
||||
const raw = (p.raw ?? {}) as Record<string, unknown>;
|
||||
const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? p.name ?? '').trim();
|
||||
const scale = p.dimension_unit === 'cm' ? 1 : 0.1;
|
||||
const dims =
|
||||
p.depth != null && p.width != null && p.height != null
|
||||
? `${p.depth * scale}×${p.width * scale}×${p.height * scale}`
|
||||
: '';
|
||||
const n = (v: unknown, digits = 2) => (v == null || v === '' ? '' : Number(v).toFixed(digits));
|
||||
return [
|
||||
p.offer_id || '',
|
||||
titleZh,
|
||||
n(pr.purchasePrice),
|
||||
n(pr.logisticsFee),
|
||||
n(pr.fullCommission),
|
||||
n(pr.receivedPrice),
|
||||
n(pr.totalCost),
|
||||
n(pr.sellingPriceCny),
|
||||
n(pr.profitPrice),
|
||||
pr.profitRate != null ? pr.profitRate : '',
|
||||
n(pr.sellingPriceRub, 0),
|
||||
p.weight ?? '',
|
||||
dims,
|
||||
STAGE_LABEL[p.stage] ?? p.stage,
|
||||
p.offer_id ? `https://www.ozon.ru/product/${p.offer_id}` : '',
|
||||
(raw.purchase_url as string) ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/** 组合码一行:`货号 预留₽价(两位)`,无预留价回退销售价₽ */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function comboCode(p: ProductDetail): string {
|
||||
const pr = (p.pricing ?? {}) as Record<string, any>;
|
||||
const rub = pr.reservedPriceRub ?? pr.sellingPriceRub;
|
||||
if (!p.offer_id || rub == null) return '';
|
||||
return `${p.offer_id} ${Number(rub).toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** 拉取全部「已计价」商品详情(分页列表 → 逐个详情;自用规模够用) */
|
||||
async function fetchAllPricedProducts(): Promise<ProductDetail[]> {
|
||||
const pageSize = 100;
|
||||
const details: ProductDetail[] = [];
|
||||
for (let page = 1; page < 50; page++) {
|
||||
const res = await listProducts({ page, page_size: pageSize });
|
||||
for (const item of res.items) {
|
||||
try {
|
||||
const d = await getProduct(item.id);
|
||||
if ((d.pricing as Record<string, unknown> | null)?.calculatedAt) details.push(d);
|
||||
} catch {
|
||||
/* 单个失败跳过,不阻断批量导出 */
|
||||
}
|
||||
}
|
||||
if (page * pageSize >= res.total) break;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
function stamp(): string {
|
||||
const d = new Date();
|
||||
const p = (v: number) => String(v).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
}
|
||||
|
||||
export default function TrialExportPanel({ product }: Props) {
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [comboOpen, setComboOpen] = useState(false);
|
||||
const [comboText, setComboText] = useState('');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pr = (product.pricing ?? {}) as Record<string, any>;
|
||||
|
||||
const exportCurrent = () => {
|
||||
if (!pr.calculatedAt) {
|
||||
message.warning('尚未计价,请先在「价格试算」完成参数填写');
|
||||
return;
|
||||
}
|
||||
downloadCsv([CSV_HEADER, buildRow(product)], `试算_${product.offer_id || '商品'}_${stamp()}.csv`);
|
||||
message.success('已导出本商品 CSV');
|
||||
};
|
||||
|
||||
const exportBatch = async () => {
|
||||
setBatchLoading(true);
|
||||
const hide = message.loading('正在汇总已计价商品…', 0);
|
||||
try {
|
||||
const list = await fetchAllPricedProducts();
|
||||
if (list.length === 0) {
|
||||
message.warning('没有已计价的商品');
|
||||
return;
|
||||
}
|
||||
downloadCsv([CSV_HEADER, ...list.map(buildRow)], `试算导出_${list.length}条_${stamp()}.csv`);
|
||||
message.success(`已导出 ${list.length} 条`);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
hide();
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCurrentCombo = async () => {
|
||||
const code = comboCode(product);
|
||||
if (!code) {
|
||||
message.warning('缺少货号或计价结果(卢布预留价)');
|
||||
return;
|
||||
}
|
||||
const ok = await copyText(code);
|
||||
ok ? message.success(`已复制:${code}`) : message.error('复制失败,请手动复制');
|
||||
};
|
||||
|
||||
const openBatchCombo = async () => {
|
||||
setBatchLoading(true);
|
||||
const hide = message.loading('正在汇总组合码…', 0);
|
||||
try {
|
||||
const list = await fetchAllPricedProducts();
|
||||
const lines = list.map(comboCode).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
message.warning('没有可用的组合码(需要货号 + 卢布预留价)');
|
||||
return;
|
||||
}
|
||||
setComboText(lines.join('\n'));
|
||||
setComboOpen(true);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
hide();
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Descriptions size="small" column={3} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="计价入库">
|
||||
{pr.calculatedAt ? (
|
||||
<Tag color="green">已入库 {new Date(pr.calculatedAt as string).toLocaleString()}</Tag>
|
||||
) : (
|
||||
<Tag color="orange">未计价</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="货号">{product.offer_id || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="俄文标题">
|
||||
<Text ellipsis={{ tooltip: product.name }} style={{ maxWidth: 220 }}>
|
||||
{product.name || '—'}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<DownloadOutlined />} onClick={exportCurrent}>
|
||||
导出本商品 CSV
|
||||
</Button>
|
||||
<Button icon={<FileTextOutlined />} loading={batchLoading} onClick={exportBatch}>
|
||||
批量导出 CSV(全部已计价)
|
||||
</Button>
|
||||
<Button icon={<CopyOutlined />} onClick={copyCurrentCombo}>
|
||||
复制本商品组合码
|
||||
</Button>
|
||||
<Button loading={batchLoading} onClick={openBatchCombo}>
|
||||
批量组合码
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
|
||||
组合码格式:`货号 预留₽价`(无预留价回退销售价₽),用于 Ozon 后台批量改价;
|
||||
CSV 为 16 列(对齐 v1 工具台),UTF-8 BOM 编码可直接用 Excel 打开。
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
title={`上品组合码(${comboText ? comboText.split('\n').length : 0} 条)`}
|
||||
open={comboOpen}
|
||||
onCancel={() => setComboOpen(false)}
|
||||
footer={[
|
||||
<Button
|
||||
key="copy"
|
||||
type="primary"
|
||||
onClick={async () => {
|
||||
const ok = await copyText(comboText);
|
||||
ok ? message.success('已复制') : message.error('复制失败,请手动复制');
|
||||
}}
|
||||
>
|
||||
复制全部
|
||||
</Button>,
|
||||
<Button key="close" onClick={() => setComboOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={12} readOnly value={comboText} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user