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