feat: 开发采集、采集箱和商品编辑功能
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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)+ 定价参数(进货价/净利率/物流等级/汇率/预留折扣)。
|
||||
* 重量、尺寸直接读「主要信息」的包装字段,不重复填写。
|
||||
* 任一定价参数变化即重算并回填售价(= 销售价)与划线价(= 预留折扣前价格)。
|
||||
*/
|
||||
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 [reserve, setReserve] = useState(pricing.discountReserve ?? 50);
|
||||
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; reserve: number; fxRate: number }> = {}) =>
|
||||
calculatePricing({
|
||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||
profitRate: patch.profitRate ?? profitRate,
|
||||
logisticsLevel: patch.level ?? level,
|
||||
weightG: weightGrams(product),
|
||||
dims: dimsCm(product),
|
||||
tdPrice,
|
||||
discountReserve: patch.reserve ?? reserve,
|
||||
fxRate: patch.fxRate ?? fxRate,
|
||||
});
|
||||
|
||||
/** 重算并落库:定价参数 + 售价/划线价一并保存 */
|
||||
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) => {
|
||||
const p = {
|
||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||
profitRate: patch.profitRate ?? profitRate,
|
||||
level: patch.level ?? level,
|
||||
reserve: patch.reserve ?? reserve,
|
||||
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,
|
||||
discountReserve: p.reserve,
|
||||
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.reservedPriceCny,
|
||||
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={95}
|
||||
value={reserve}
|
||||
onChange={(v) => {
|
||||
setReserve(v ?? 0);
|
||||
recalc({ reserve: 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user