243 lines
9.8 KiB
TypeScript
243 lines
9.8 KiB
TypeScript
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>
|
||
);
|
||
}
|