feat: 商品试算页

This commit is contained in:
R524809
2026-08-26 17:38:44 +08:00
parent 6027d8f8d7
commit ac4ab22ac2
18 changed files with 3055 additions and 3 deletions
+219
View File
@@ -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>
);
}