Files
ozon-seller-kit/studio/src/pages/product/PriceInfoPanel.tsx
T
2026-08-27 10:35:07 +08:00

243 lines
9.8 KiB
TypeScript
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 { useEffect, useState } from 'react';
import { Col, InputNumber, message, Radio, Row, Space, Tag, Typography } from 'antd';
import { calculatePricing, LogisticsLevel, validateLogisticsLevel } from '@/pricing/pricing';
import { getFxRate } from '@/services/fx';
import { ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import FieldLabel, { fieldRowStyle } from './FieldLabel';
const { Text } = Typography;
interface Props {
product: ProductDetail;
onSave: (p: Partial<ProductDetail>) => void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PricingPayload = Record<string, any>;
/** 包装信息 → 计价输入:重量统一 g */
function weightGrams(product: ProductDetail): number {
const w = product.weight ?? 0;
return product.weight_unit === 'kg' ? w * 1000 : w;
}
/** 包装信息 → 计价输入:尺寸统一 cm */
function dimsCm(product: ProductDetail): { l: number; w: number; h: number } {
const scale = product.dimension_unit === 'cm' ? 1 : 0.1; // mm → cm
return {
l: (product.depth ?? 0) * scale,
w: (product.width ?? 0) * scale,
h: (product.height ?? 0) * scale,
};
}
/**
* 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/划线价倍数)。
* 重量、尺寸直接读「主要信息」的包装字段,不重复填写。
* 任一定价参数变化即重算并回填售价(= 销售价)与划线价(= 销售价 × (1 + 倍数%))。
*/
export default function PriceInfoPanel({ product, onSave }: Props) {
const pricing = (product.pricing ?? {}) as PricingPayload;
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
// 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次
useEffect(() => {
if (product.currency_code !== 'CNY') onSave({ currency_code: 'CNY' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 汇率:未快照时拉取;已有计价记录的则跟随重算
useEffect(() => {
if (!fxRate) {
getFxRate()
.then((r) => {
setFxRate(r.rate);
if (pricing.calculatedAt) recalc({ fxRate: r.rate });
})
.catch((e) => message.error(apiErrorMessage(e)));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const tdPrice = pricing.tdPrice ?? 3;
/** 用当前参数(可被 patch 覆盖)实时计算,不落库 */
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) =>
calculatePricing({
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
logisticsLevel: patch.level ?? level,
weightG: weightGrams(product),
dims: dimsCm(product),
tdPrice,
lineMultiplier: patch.multiplier ?? multiplier,
fxRate: patch.fxRate ?? fxRate,
});
/** 重算并落库:定价参数 + 售价/划线价一并保存 */
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) => {
const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
level: patch.level ?? level,
multiplier: patch.multiplier ?? multiplier,
fxRate: patch.fxRate ?? fxRate,
};
const r = compute(patch);
onSave({
pricing: {
...pricing,
purchasePrice: p.purchasePrice,
profitRate: p.profitRate,
logisticsLevel: p.level,
weightG: weightGrams(product),
dims: dimsCm(product),
tdPrice,
lineMultiplier: p.multiplier,
fxRate: p.fxRate,
logisticsFee: r.logisticsFee,
fullCommission: r.fullCommission,
totalCost: r.totalCost,
sellingPriceCny: r.sellingPriceCny,
sellingPriceRub: r.sellingPriceRub,
calculatedAt: new Date().toISOString(),
},
fx_rate: p.fxRate,
price: r.sellingPriceCny,
old_price: r.linePriceCny,
currency_code: 'CNY',
});
};
const preview = fxRate ? compute() : null;
const hint = preview ? validateLogisticsLevel(preview.sellingPriceRub, level) : '';
return (
<div>
{/* 售价 / 划线价 / 币种 */}
<Row gutter={16} style={fieldRowStyle}>
<Col span={6}>
<FieldLabel>售价 ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={product.price ?? undefined}
onChange={(v) => onSave({ price: v ?? null, currency_code: 'CNY' })}
/>
</Col>
<Col span={6}>
<FieldLabel>划线价 ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={product.old_price ?? undefined}
onChange={(v) => onSave({ old_price: v ?? null })}
/>
</Col>
<Col span={6}>
<FieldLabel>币种</FieldLabel>
<Text style={{ lineHeight: '32px' }}>人民币(CNY</Text>
</Col>
<Col span={6}>
<Text type="secondary" style={{ fontSize: 12, lineHeight: '32px' }}>
售价/划线价随定价参数自动回填,也可手动微调
</Text>
</Col>
</Row>
{/* 定价参数 */}
<Row gutter={16} style={fieldRowStyle}>
<Col span={5}>
<FieldLabel>进货价 ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={purchasePrice}
onChange={(v) => {
setPurchasePrice(v ?? 0);
recalc({ purchasePrice: v ?? 0 });
}}
/>
</Col>
<Col span={5}>
<FieldLabel>净利率 %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={profitRate}
onChange={(v) => {
setProfitRate(v ?? 0);
recalc({ profitRate: v ?? 0 });
}}
/>
</Col>
<Col span={5}>
<FieldLabel>汇率 ¥→₽</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={fxRate}
onChange={(v) => {
setFxRate(v ?? 0);
recalc({ fxRate: v ?? 0 });
}}
/>
</Col>
<Col span={5}>
<FieldLabel>划线价倍数 %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
max={500}
value={multiplier}
onChange={(v) => {
setMultiplier(v ?? 0);
recalc({ multiplier: v ?? 0 });
}}
/>
</Col>
<Col span={4} style={{ alignSelf: 'flex-end', paddingBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
重量/尺寸取自包装信息
</Text>
</Col>
</Row>
{/* 物流等级 */}
<div style={fieldRowStyle}>
<FieldLabel>物流等级</FieldLabel>
<Radio.Group
block
optionType="button"
buttonStyle="solid"
value={level}
onChange={(e) => {
setLevel(e.target.value);
recalc({ level: e.target.value });
}}
options={[
{ value: 'low', label: '低 (low)' },
{ value: 'high', label: '高 (high)' },
{ value: 'high2', label: 'Premium (high2)' },
]}
/>
</div>
{/* 实时结果摘要 */}
{preview && (
<Space wrap>
<Tag>完全成本 ¥ {preview.totalCost.toFixed(2)}</Tag>
<Tag>物流费 ¥ {preview.logisticsFee.toFixed(2)}</Tag>
<Tag>销售价 {preview.sellingPriceRub.toFixed(0)}(参考)</Tag>
{hint && <Tag color="orange">{hint}</Tag>}
</Space>
)}
</div>
);
}