feat: 价格试算模块优化
This commit is contained in:
+10
-7
@@ -55,9 +55,12 @@
|
||||
## 4. 02 价格试算(公式从 web/js/app.js 移植,单位口径不变)
|
||||
|
||||
输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、
|
||||
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`discountReserve` 预留折扣%(默认50,≤95)、
|
||||
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineMultiplier` 划线价倍数%(默认100,0-500)、
|
||||
`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸取自 01 区块。
|
||||
|
||||
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
|
||||
> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。
|
||||
|
||||
**物流费**(`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`):
|
||||
|
||||
| 等级 | 条件 | 公式 | 规则文案 |
|
||||
@@ -81,8 +84,8 @@
|
||||
平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用
|
||||
平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费
|
||||
完全成本 = 进货价 + 物流费 + 平台总抽成
|
||||
预留后¥ = 销售价 / (1 - 预留折扣%);差额 = 预留后 - 销售价
|
||||
销售价₽ = 销售价¥ × 汇率;预留₽ = 预留后¥ × 汇率
|
||||
预留后¥ = 销售价 × (1 + 划线价倍数%)(即划线价);差额 = 划线价 - 销售价
|
||||
销售价₽ = 销售价¥ × 汇率;划线₽ = 划线价¥ × 汇率
|
||||
```
|
||||
|
||||
**校验**(v2.1 新增移植到 `pricing.ts`:`validateDimensions` / `validateLogisticsLevelCny` / `validatePriceRange`,注意等级/区间建议用的销售价单位是 **CNY**,与 v1 一致):
|
||||
@@ -99,14 +102,14 @@
|
||||
// product.pricing(PriceInfoPanel 字段的超集,向后兼容)
|
||||
{
|
||||
"purchasePrice": 30, "profitRate": 100, "logisticsLevel": "low", "tdPrice": 3,
|
||||
"discountReserve": 50, "fxRate": 11.5, "weightG": 600, "dims": { "l": 20, "w": 15, "h": 10 },
|
||||
"lineMultiplier": 100, "fxRate": 11.5, "weightG": 600, "dims": { "l": 20, "w": 15, "h": 10 },
|
||||
"logisticsFee": 22.12, "receivedPrice": 60, "profitPrice": 30, "commission": 11.63,
|
||||
"fullCommission": 15.02, "totalCost": 67.14,
|
||||
"sellingPriceCny": 77.51, "reservedPriceCny": 155.02,
|
||||
"sellingPriceRub": 891.4, "reservedPriceRub": 1782.7,
|
||||
"sellingPriceCny": 77.51, "linePriceCny": 155.02,
|
||||
"sellingPriceRub": 891.4, "linePriceRub": 1782.7,
|
||||
"calculatedAt": "2026-08-26T12:00:00Z"
|
||||
}
|
||||
// 同时回填:price=销售价CNY,old_price=预留后CNY,currency_code=CNY,fx_rate=汇率
|
||||
// 同时回填:price=销售价CNY,old_price=划线价CNY,currency_code=CNY,fx_rate=汇率
|
||||
```
|
||||
|
||||
## 5. 03 俄文文案
|
||||
|
||||
@@ -33,16 +33,16 @@ function dimsCm(product: ProductDetail): { l: number; w: number; h: number } {
|
||||
}
|
||||
|
||||
/**
|
||||
* 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/预留折扣)。
|
||||
* 售价信息:售价/划线价(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 [reserve, setReserve] = useState(pricing.discountReserve ?? 50);
|
||||
const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
|
||||
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
|
||||
|
||||
// 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次
|
||||
@@ -67,7 +67,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
|
||||
const tdPrice = pricing.tdPrice ?? 3;
|
||||
|
||||
/** 用当前参数(可被 patch 覆盖)实时计算,不落库 */
|
||||
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) =>
|
||||
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) =>
|
||||
calculatePricing({
|
||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||
profitRate: patch.profitRate ?? profitRate,
|
||||
@@ -75,17 +75,17 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
|
||||
weightG: weightGrams(product),
|
||||
dims: dimsCm(product),
|
||||
tdPrice,
|
||||
discountReserve: patch.reserve ?? reserve,
|
||||
lineMultiplier: patch.multiplier ?? multiplier,
|
||||
fxRate: patch.fxRate ?? fxRate,
|
||||
});
|
||||
|
||||
/** 重算并落库:定价参数 + 售价/划线价一并保存 */
|
||||
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) => {
|
||||
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,
|
||||
reserve: patch.reserve ?? reserve,
|
||||
multiplier: patch.multiplier ?? multiplier,
|
||||
fxRate: patch.fxRate ?? fxRate,
|
||||
};
|
||||
const r = compute(patch);
|
||||
@@ -98,7 +98,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
|
||||
weightG: weightGrams(product),
|
||||
dims: dimsCm(product),
|
||||
tdPrice,
|
||||
discountReserve: p.reserve,
|
||||
lineMultiplier: p.multiplier,
|
||||
fxRate: p.fxRate,
|
||||
logisticsFee: r.logisticsFee,
|
||||
fullCommission: r.fullCommission,
|
||||
@@ -109,7 +109,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
|
||||
},
|
||||
fx_rate: p.fxRate,
|
||||
price: r.sellingPriceCny,
|
||||
old_price: r.reservedPriceCny,
|
||||
old_price: r.linePriceCny,
|
||||
currency_code: 'CNY',
|
||||
});
|
||||
};
|
||||
@@ -189,15 +189,15 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
|
||||
/>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<FieldLabel>预留折扣 %</FieldLabel>
|
||||
<FieldLabel>划线价倍数 %</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
max={95}
|
||||
value={reserve}
|
||||
max={500}
|
||||
value={multiplier}
|
||||
onChange={(v) => {
|
||||
setReserve(v ?? 0);
|
||||
recalc({ reserve: v ?? 0 });
|
||||
setMultiplier(v ?? 0);
|
||||
recalc({ multiplier: v ?? 0 });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 05 入库与导出(docs/v2.1/trial-page.md §7):
|
||||
* 入库即各区块自动落库;此处提供 本商品CSV / 批量CSV(16 列,对齐 v1 web)与 组合码(SKU 预留₽价)。
|
||||
* 入库即各区块自动落库;此处提供 本商品CSV / 批量CSV(16 列,对齐 v1 web)与 组合码(SKU 划线₽价)。
|
||||
* Phase A 由前端拼装(逐个拉详情),Phase D 可换 GET /api/export/trial-csv。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
@@ -50,11 +50,11 @@ function buildRow(p: ProductDetail): Array<string | number | null> {
|
||||
];
|
||||
}
|
||||
|
||||
/** 组合码一行:`货号 预留₽价(两位)`,无预留价回退销售价₽ */
|
||||
/** 组合码一行:`货号 划线₽价(两位)`,无划线价回退销售价₽ */
|
||||
// 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;
|
||||
const rub = pr.linePriceRub ?? pr.reservedPriceRub ?? pr.sellingPriceRub;
|
||||
if (!p.offer_id || rub == null) return '';
|
||||
return `${p.offer_id} ${Number(rub).toFixed(2)}`;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export default function TrialExportPanel({ product }: Props) {
|
||||
const copyCurrentCombo = async () => {
|
||||
const code = comboCode(product);
|
||||
if (!code) {
|
||||
message.warning('缺少货号或计价结果(卢布预留价)');
|
||||
message.warning('缺少货号或计价结果(卢布划线价)');
|
||||
return;
|
||||
}
|
||||
const ok = await copyText(code);
|
||||
@@ -141,7 +141,7 @@ export default function TrialExportPanel({ product }: Props) {
|
||||
const list = await fetchAllPricedProducts();
|
||||
const lines = list.map(comboCode).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
message.warning('没有可用的组合码(需要货号 + 卢布预留价)');
|
||||
message.warning('没有可用的组合码(需要货号 + 卢布划线价)');
|
||||
return;
|
||||
}
|
||||
setComboText(lines.join('\n'));
|
||||
@@ -188,7 +188,7 @@ export default function TrialExportPanel({ product }: Props) {
|
||||
</Space>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
|
||||
组合码格式:`货号 预留₽价`(无预留价回退销售价₽),用于 Ozon 后台批量改价;
|
||||
组合码格式:`货号 划线₽价`(无划线价回退销售价₽),用于 Ozon 后台批量改价;
|
||||
CSV 为 16 列(对齐 v1 工具台),UTF-8 BOM 编码可直接用 Excel 打开。
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import { Alert, Button, Card, Col, InputNumber, Radio, Row, Space, Typography, message } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Col, InputNumber, message, Radio, Row, Space, Typography } from 'antd';
|
||||
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import { getFxRate } from '@/services/fx';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { copyText } from '@/utils/file';
|
||||
import {
|
||||
calculatePricing,
|
||||
logisticsFeeRule,
|
||||
@@ -28,7 +29,7 @@ interface Props {
|
||||
|
||||
type PricingPayload = Record<string, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type PricingState = Partial<{ purchasePrice: number; profitRate: number; tdPrice: number; level: LogisticsLevel; reserve: number; fxRate: number }>;
|
||||
type PricingState = Partial<{ purchasePrice: number; profitRate: number; tdPrice: number; level: LogisticsLevel; multiplier: number; fxRate: number }>;
|
||||
|
||||
function weightGrams(product: ProductDetail): number {
|
||||
const w = product.weight ?? 0;
|
||||
@@ -55,22 +56,24 @@ function Metric({ label, value, rule, color }: { label: string; value: string; r
|
||||
);
|
||||
}
|
||||
|
||||
/** 销售价格四宫格:人民币/卢布 × 销售价/划线价,价格居中,划线价带删除线 */
|
||||
/** 销售价格四宫格:人民币/卢布 × 销售价/划线价,价格居中,划线价带删除线;人民币销售价可一键复制 */
|
||||
function SalePriceGrid({
|
||||
cnySelling,
|
||||
cnySellingNumber,
|
||||
cnyLine,
|
||||
rubSelling,
|
||||
rubLine,
|
||||
reserve,
|
||||
multiplier,
|
||||
fxRate,
|
||||
cnyGap,
|
||||
rubGap,
|
||||
}: {
|
||||
cnySelling: string;
|
||||
cnySellingNumber?: string;
|
||||
cnyLine: string;
|
||||
rubSelling: string;
|
||||
rubLine: string;
|
||||
reserve: number;
|
||||
multiplier: number;
|
||||
fxRate: number;
|
||||
cnyGap: number | null;
|
||||
rubGap: number | null;
|
||||
@@ -88,11 +91,37 @@ function SalePriceGrid({
|
||||
lineHeight: '30px',
|
||||
};
|
||||
const noteStyle: CSSProperties = { fontSize: 11, color: 'rgba(0,0,0,0.35)' };
|
||||
const multiple = (1 + multiplier / 100).toFixed(2);
|
||||
|
||||
const copyCnySelling = async () => {
|
||||
if (!cnySellingNumber) return;
|
||||
const ok = await copyText(cnySellingNumber);
|
||||
ok ? message.success(`已复制:${cnySellingNumber}`) : message.error('复制失败');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px dashed #e5e7eb', paddingTop: 14, marginTop: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div style={cellStyle}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>人民币-销售价格</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
人民币-销售价格
|
||||
{cnySellingNumber && (
|
||||
<CopyOutlined
|
||||
title="复制人民币销售价"
|
||||
style={{ color: 'rgba(0,0,0,0.45)', cursor: 'pointer' }}
|
||||
onClick={copyCnySelling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={priceStyle}>{cnySelling}</div>
|
||||
<div style={noteStyle}>销售价(现价)</div>
|
||||
</div>
|
||||
@@ -107,14 +136,16 @@ function SalePriceGrid({
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>人民币-划线价</div>
|
||||
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{cnyLine}</div>
|
||||
<div style={noteStyle}>
|
||||
预留 {reserve}% 后{cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''}
|
||||
销售价 × {multiple}
|
||||
{cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style={cellStyle}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>卢布-划线价</div>
|
||||
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{rubLine}</div>
|
||||
<div style={noteStyle}>
|
||||
预留 {reserve}% 后{rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''}
|
||||
销售价 × {multiple}
|
||||
{rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,9 +156,10 @@ function SalePriceGrid({
|
||||
/**
|
||||
* 02 价格试算:公式与告警完整移植 v1 web/js/app.js(trial-page.md §4)。
|
||||
* 布局:左侧操作项(进货价→物流等级),右侧展示项(物流费→销售价)。
|
||||
* 划线价为「倍数」口径:划线价 = 销售价 × (1 + 倍数%),0-500% 线性可算(v2.1 调整)。
|
||||
* 颜色对齐 v1:完全成本/平台总抽成红色,人民币/卢布销售价绿色。
|
||||
* 参数变化即重算并落库(pricing JSON + price/old_price/fx_rate);
|
||||
* 尺寸不合规时不落计价结果;预留折扣空间 ≥100% 时划线价无法计算(显示 --,只落销售价)。
|
||||
* 尺寸不合规时不落计价结果。
|
||||
*/
|
||||
export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
const pricing = (product.pricing ?? {}) as PricingPayload;
|
||||
@@ -135,7 +167,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
|
||||
const [tdPrice, setTdPrice] = useState(pricing.tdPrice ?? 3);
|
||||
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
|
||||
const [reserve, setReserve] = useState(pricing.discountReserve ?? 50);
|
||||
const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
|
||||
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
|
||||
const [fxSource, setFxSource] = useState('');
|
||||
|
||||
@@ -178,7 +210,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
weightG: weightGrams(product),
|
||||
dims: dimsCm(product),
|
||||
tdPrice: patch.tdPrice ?? tdPrice,
|
||||
discountReserve: patch.reserve ?? reserve,
|
||||
lineMultiplier: patch.multiplier ?? multiplier,
|
||||
fxRate: patch.fxRate ?? fxRate,
|
||||
});
|
||||
|
||||
@@ -189,7 +221,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
profitRate: patch.profitRate ?? profitRate,
|
||||
tdPrice: patch.tdPrice ?? tdPrice,
|
||||
level: patch.level ?? level,
|
||||
reserve: patch.reserve ?? reserve,
|
||||
multiplier: patch.multiplier ?? multiplier,
|
||||
fxRate: patch.fxRate ?? fxRate,
|
||||
};
|
||||
const dims = dimsCm(product);
|
||||
@@ -201,40 +233,9 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
weightG: weightGrams(product),
|
||||
dims,
|
||||
tdPrice: p.tdPrice,
|
||||
discountReserve: p.reserve,
|
||||
lineMultiplier: p.multiplier,
|
||||
fxRate: p.fxRate,
|
||||
});
|
||||
// 预留折扣空间 ≥100% 时划线价无数学意义(除数 ≤0):只保存销售价,并清空划线价字段
|
||||
const lineValid = p.reserve < 100;
|
||||
const lineFields = r && lineValid
|
||||
? {
|
||||
logisticsFee: r.logisticsFee,
|
||||
receivedPrice: r.receivedPrice,
|
||||
profitPrice: r.profitPrice,
|
||||
commission: r.commission,
|
||||
fullCommission: r.fullCommission,
|
||||
totalCost: r.totalCost,
|
||||
sellingPriceCny: r.sellingPriceCny,
|
||||
reservedPriceCny: r.reservedPriceCny,
|
||||
sellingPriceRub: r.sellingPriceRub,
|
||||
reservedPriceRub: r.reservedPriceRub,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
}
|
||||
: r
|
||||
? {
|
||||
logisticsFee: r.logisticsFee,
|
||||
receivedPrice: r.receivedPrice,
|
||||
profitPrice: r.profitPrice,
|
||||
commission: r.commission,
|
||||
fullCommission: r.fullCommission,
|
||||
totalCost: r.totalCost,
|
||||
sellingPriceCny: r.sellingPriceCny,
|
||||
sellingPriceRub: r.sellingPriceRub,
|
||||
reservedPriceCny: null,
|
||||
reservedPriceRub: null,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
}
|
||||
: {};
|
||||
onSave({
|
||||
pricing: {
|
||||
...pricing,
|
||||
@@ -242,18 +243,28 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
profitRate: p.profitRate,
|
||||
tdPrice: p.tdPrice,
|
||||
logisticsLevel: p.level,
|
||||
discountReserve: p.reserve,
|
||||
lineMultiplier: p.multiplier,
|
||||
fxRate: p.fxRate,
|
||||
weightG: weightGrams(product),
|
||||
dims,
|
||||
...lineFields,
|
||||
...(r
|
||||
? {
|
||||
logisticsFee: r.logisticsFee,
|
||||
receivedPrice: r.receivedPrice,
|
||||
profitPrice: r.profitPrice,
|
||||
commission: r.commission,
|
||||
fullCommission: r.fullCommission,
|
||||
totalCost: r.totalCost,
|
||||
sellingPriceCny: r.sellingPriceCny,
|
||||
linePriceCny: r.linePriceCny,
|
||||
sellingPriceRub: r.sellingPriceRub,
|
||||
linePriceRub: r.linePriceRub,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
fx_rate: p.fxRate,
|
||||
...(r && lineValid
|
||||
? { price: r.sellingPriceCny, old_price: r.reservedPriceCny }
|
||||
: r
|
||||
? { price: r.sellingPriceCny, old_price: null }
|
||||
: {}),
|
||||
...(r ? { price: r.sellingPriceCny, old_price: r.linePriceCny } : {}),
|
||||
currency_code: 'CNY',
|
||||
});
|
||||
};
|
||||
@@ -262,8 +273,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
const preview = fxRate && !dimError ? compute() : null;
|
||||
const levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : '';
|
||||
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
|
||||
const lineValid = reserve < 100;
|
||||
const discountGap = preview && lineValid ? preview.reservedPriceCny - preview.sellingPriceCny : 0;
|
||||
const discountGap = preview ? preview.linePriceCny - preview.sellingPriceCny : 0;
|
||||
|
||||
const num = (v: number | undefined | null, digits = 2) => (v == null ? '--' : v.toFixed(digits));
|
||||
|
||||
@@ -314,17 +324,17 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FieldLabel>预留折扣空间 %</FieldLabel>
|
||||
<FieldLabel>划线价倍数 %</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
max={500}
|
||||
step={5}
|
||||
value={reserve}
|
||||
value={multiplier}
|
||||
onChange={(v) => {
|
||||
const n = v ?? 0;
|
||||
setReserve(n);
|
||||
recalc({ reserve: n });
|
||||
setMultiplier(n);
|
||||
recalc({ multiplier: n });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
@@ -384,19 +394,19 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
|
||||
参数变化自动计价并入库;尺寸不合规时不落计价结果。重量/尺寸在「商品信息」区块维护。
|
||||
划线价 = 销售价 × (1 + 倍数%);参数变化自动计价并入库;尺寸不合规时不落计价结果。重量/尺寸在「商品信息」区块维护。
|
||||
</Text>
|
||||
</Col>
|
||||
|
||||
{/* ── 右:展示项(物流费 → 销售价) ── */}
|
||||
<Col span={14}>
|
||||
{(levelHint || rangeHint) && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{levelHint && <Alert type="warning" showIcon message={levelHint} style={{ marginBottom: 8 }} />}
|
||||
{rangeHint && <Alert type="warning" showIcon message={rangeHint} />}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
{/* ── 右:展示项(物流费 → 销售价) ── */}
|
||||
<Col span={14}>
|
||||
<Card size="small" style={{ background: '#fafafa' }}>
|
||||
{/* 第一行:物流费 / 平台佣金 / 提现费及其他 */}
|
||||
<Row gutter={[16, 20]}>
|
||||
@@ -445,13 +455,14 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
{/* 第三、四行:人民币/卢布 销售价 + 划线价 四宫格 */}
|
||||
<SalePriceGrid
|
||||
cnySelling={preview ? `¥ ${num(preview.sellingPriceCny)}` : '--'}
|
||||
cnyLine={preview && lineValid ? `¥ ${num(preview.reservedPriceCny)}` : '--'}
|
||||
cnySellingNumber={preview ? preview.sellingPriceCny.toFixed(2) : undefined}
|
||||
cnyLine={preview ? `¥ ${num(preview.linePriceCny)}` : '--'}
|
||||
rubSelling={preview ? `₽ ${num(preview.sellingPriceRub, 0)}` : '--'}
|
||||
rubLine={preview && lineValid ? `₽ ${num(preview.reservedPriceRub, 0)}` : '--'}
|
||||
reserve={reserve}
|
||||
rubLine={preview ? `₽ ${num(preview.linePriceRub, 0)}` : '--'}
|
||||
multiplier={multiplier}
|
||||
fxRate={fxRate}
|
||||
cnyGap={preview && lineValid ? discountGap : null}
|
||||
rubGap={preview && lineValid ? preview.reservedPriceRub - preview.sellingPriceRub : null}
|
||||
cnyGap={preview ? discountGap : null}
|
||||
rubGap={preview ? preview.linePriceRub - preview.sellingPriceRub : null}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 计价纯函数 —— 从 v1 web/js/app.js 抄录(只抄不改,见 docs/v2/migration.md §3)。
|
||||
* 输入:进货价/净利率/物流等级/重量/尺寸/贴单费/预留折扣/汇率
|
||||
* 输出:物流费/佣金/完全成本/销售价(¥/₽)/预留折扣价
|
||||
* v2.1 调整:划线价改为「倍数」口径(划线价 = 销售价 × (1 + 倍数%)),替代 v1 的 ÷(1-预留折扣)。
|
||||
* 输入:进货价/净利率/物流等级/重量/尺寸/贴单费/划线价倍数/汇率
|
||||
* 输出:物流费/佣金/完全成本/销售价(¥/₽)/划线价(¥/₽)
|
||||
*/
|
||||
|
||||
export type LogisticsLevel = 'low' | 'high' | 'high2';
|
||||
@@ -13,7 +14,7 @@ export interface PricingInput {
|
||||
weightG: number; // 重量 g
|
||||
dims: { l: number; w: number; h: number }; // cm
|
||||
tdPrice: number; // 贴单费用 ¥
|
||||
discountReserve: number; // 预留折扣 %
|
||||
lineMultiplier: number; // 划线价倍数 %(划线价 = 销售价 × (1 + 倍数/100))
|
||||
fxRate: number; // 汇率 CNY→RUB
|
||||
}
|
||||
|
||||
@@ -27,8 +28,8 @@ export interface PricingResult {
|
||||
commission: number; // 展示用平台佣金
|
||||
totalCost: number; // 完全成本
|
||||
sellingPriceRub: number; // 销售价 ₽
|
||||
reservedPriceCny: number;// 预留折扣后 ¥
|
||||
reservedPriceRub: number;// 预留折扣后 ₽
|
||||
linePriceCny: number; // 划线价 ¥(销售价 × (1 + 倍数))
|
||||
linePriceRub: number; // 划线价 ₽
|
||||
}
|
||||
|
||||
/** 物流费(不含贴单费) */
|
||||
@@ -44,7 +45,7 @@ export function baseLogisticsFee(weightG: number, level: LogisticsLevel): number
|
||||
}
|
||||
|
||||
export function calculatePricing(input: PricingInput): PricingResult {
|
||||
const { purchasePrice, profitRate, logisticsLevel, weightG, tdPrice, discountReserve, fxRate } = input;
|
||||
const { purchasePrice, profitRate, logisticsLevel, weightG, tdPrice, lineMultiplier, fxRate } = input;
|
||||
|
||||
const logisticsFee = baseLogisticsFee(weightG, logisticsLevel) + tdPrice;
|
||||
const profitDecimal = profitRate / 100;
|
||||
@@ -57,8 +58,8 @@ export function calculatePricing(input: PricingInput): PricingResult {
|
||||
const totalCost = purchasePrice + logisticsFee + fullCommission;
|
||||
|
||||
const sellingPriceRub = sellingPriceCny * fxRate;
|
||||
const reservedPriceCny = sellingPriceCny / (1 - discountReserve / 100);
|
||||
const reservedPriceRub = reservedPriceCny * fxRate;
|
||||
const linePriceCny = sellingPriceCny * (1 + lineMultiplier / 100);
|
||||
const linePriceRub = linePriceCny * fxRate;
|
||||
|
||||
return {
|
||||
logisticsFee,
|
||||
@@ -70,8 +71,8 @@ export function calculatePricing(input: PricingInput): PricingResult {
|
||||
commission,
|
||||
totalCost,
|
||||
sellingPriceRub,
|
||||
reservedPriceCny,
|
||||
reservedPriceRub,
|
||||
linePriceCny,
|
||||
linePriceRub,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user