refactor(listing): 卖家发布定价改用整数分与基点

This commit is contained in:
yml2213
2026-10-30 14:49:33 +08:00
parent 37e1980d2d
commit ce93f1636d
18 changed files with 1492 additions and 343 deletions
@@ -85,6 +85,8 @@ function statusTag(meta: StatusMeta) {
function sellerPriceCent(row: SellerListing) {
const breakdown = recordValue(row.asset_summary?.price_breakdown)
const cent = numberValue(breakdown.seller_total_price_cent)
if (cent > 0) return Math.round(cent)
const price = numberValue(breakdown.seller_total_price)
return price > 0 ? Math.round(price * 100) : row.price_cent
}
@@ -103,11 +105,20 @@ function onlineTimeText(summary: Record<string, unknown>) {
return `${start || '—'} 至 ${end || '—'}`
}
function ratioText(value: unknown) {
const ratio = Number(value)
function ratioText(bpsValue: unknown, legacyRatio: unknown) {
const bps = Number(bpsValue)
if (Number.isFinite(bps) && bps > 0) return `1:${(bps / 10000).toFixed(1)}`
const ratio = Number(legacyRatio)
return Number.isFinite(ratio) && ratio > 0 ? `1:${ratio.toFixed(1)}` : '—'
}
function moneyCent(centValue: unknown, legacyYuanValue: unknown) {
const cent = Number(centValue)
if (Number.isFinite(cent) && cent > 0) return Math.round(cent)
const yuan = Number(legacyYuanValue)
return Number.isFinite(yuan) ? Math.round(yuan * 100) : 0
}
function screenshotsOf(row: SellerListing) {
const summary = row.asset_summary ?? {}
const groups = recordValue(summary.screenshot_groups)
@@ -423,27 +434,42 @@ export function SellerListingDetailPage() {
<div className="seller-price-grid">
<div>
<span>参考比例</span>
<strong>{ratioText(breakdown.seller_reference_ratio)}</strong>
<strong>
{ratioText(breakdown.seller_reference_ratio_bps, breakdown.seller_reference_ratio)}
</strong>
</div>
<div>
<span>卖家比例</span>
<strong>{ratioText(breakdown.seller_ratio)}</strong>
<strong>{ratioText(breakdown.seller_ratio_bps, breakdown.seller_ratio)}</strong>
</div>
<div>
<span>纯币基础价</span>
<strong>{formatMoney(numberValue(breakdown.seller_coin_base_price) * 100)}</strong>
<strong>
{formatMoney(
moneyCent(breakdown.seller_coin_base_price_cent, breakdown.seller_coin_base_price)
)}
</strong>
</div>
<div>
<span>消耗品价值</span>
<strong>{formatMoney(numberValue(breakdown.consumable_price) * 100)}</strong>
<strong>
{formatMoney(moneyCent(breakdown.consumable_price_cent, breakdown.consumable_price))}
</strong>
</div>
<div>
<span>买家比例</span>
<strong>{ratioText(breakdown.buyer_ratio)}</strong>
<strong>{ratioText(breakdown.buyer_ratio_bps, breakdown.buyer_ratio)}</strong>
</div>
<div>
<span>平台加价</span>
<strong>{formatMoney(numberValue(breakdown.platform_markup_amount) * 100)}</strong>
<strong>
{formatMoney(
moneyCent(
breakdown.platform_markup_amount_cent,
breakdown.platform_markup_amount
)
)}
</strong>
</div>
</div>
</Card>
@@ -36,18 +36,23 @@ import { uploadFile } from '@/shared/api/files'
import { useSessionStore } from '@/stores/session'
import {
dailyLossOptions,
calculateConsumablePrice,
calculateDailyLossRatioAdjustment,
calculatePlatformPricing,
calculateRecommendedDeposit,
calculateSellerReferenceRatio,
buildDepositBreakdownItems,
formatNumber,
calculateConsumablePriceCent,
calculateDailyLossRatioAdjustmentBPS,
calculatePlatformPricingCent,
calculateRecommendedDepositCent,
calculateSellerCoinBasePriceCent,
calculateSellerReferenceRatioBPS,
bpsToRatio,
formatRatioBPS,
hafCoinUnitsToM,
hasAcceleratedSaleRatioInput,
isQuantityItemDisabledForInsurance,
readFinalSaleRatio,
roundMoney,
roundRatio,
maxAcceleratedSaleRatioBPS,
parseHafCoinMToUnits,
parseYuanToCent,
ratioToBPS,
readFinalSaleRatioBPS,
} from '@/shared/utils/pricing'
import { formatMoney } from '@/shared/utils/format'
import { confirmModal } from '@/shared/utils/confirm'
@@ -69,7 +74,6 @@ import {
} from './options'
import { defaultPublishForm, type PublishForm } from './types'
const COIN_UNIT = 1_000_000
const MAX_SCREENSHOTS = 9
const SCAN_ONLY_MARK = '微信'
const SCAN_LOGIN_METHOD = '扫码'
@@ -108,6 +112,16 @@ function numberOrDefault(value: unknown, fallback: number) {
return Number.isFinite(parsed) ? parsed : fallback
}
function ratioInputFromBreakdown(
breakdown: Record<string, unknown>,
bpsKey: string,
ratioKey: string
): number | '' {
const bps = Number(breakdown[bpsKey])
if (Number.isFinite(bps) && bps > 0) return bps / 10000
return numberOrEmpty(breakdown[ratioKey])
}
function toTimeValue(value: string): Dayjs | null {
const match = /^(\d{1,2}):(\d{2})/.exec(value.trim())
if (!match) return null
@@ -267,12 +281,16 @@ export function SellerListingFormPage() {
setForm({
server_region: listing.server_region || '',
face_owner: stringValue(summary.face_owner),
haf_coin_amount: listing.haf_coin_amount > 0 ? listing.haf_coin_amount / COIN_UNIT : '',
haf_coin_amount: listing.haf_coin_amount > 0 ? hafCoinUnitsToM(listing.haf_coin_amount) : '',
rank_level: listing.rank_level || '',
secret_kd: stringValue(summary.secret_kd),
fire_level: numberOrEmpty(summary.fire_level),
daily_loss_m: numberOrDefault(summary.daily_loss_m, 10),
accelerated_sale_ratio: numberOrEmpty(breakdown.accelerated_sale_ratio),
accelerated_sale_ratio: ratioInputFromBreakdown(
breakdown,
'accelerated_sale_ratio_bps',
'accelerated_sale_ratio'
),
season_insurance: stringValue(summary.season_insurance),
stamina_level: stringValue(summary.stamina_level),
load_level: stringValue(summary.load_level),
@@ -350,44 +368,43 @@ export function SellerListingFormPage() {
.filter(Boolean).length
const pricing = useMemo(() => {
const coinMAmount = Number(form.haf_coin_amount || 0)
const coinWanAmount = coinMAmount * 100
const dailyLossMAmount = Number(form.daily_loss_m || 10)
const dailyLossRatioAdjustment = calculateDailyLossRatioAdjustment(dailyLossMAmount)
const referenceRatio = calculateSellerReferenceRatio({
coinMAmount,
const hafCoinAmount = parseHafCoinMToUnits(form.haf_coin_amount === '' ? 0 : form.haf_coin_amount)
const coinMAmount = hafCoinUnitsToM(hafCoinAmount)
const dailyLossMAmount = Math.trunc(Number(form.daily_loss_m || 10))
const dailyLossRatioAdjustmentBPS = calculateDailyLossRatioAdjustmentBPS(dailyLossMAmount)
const referenceRatioBPS = calculateSellerReferenceRatioBPS({
hafCoinAmount,
form,
ratioConfig: options.ratio_config,
skinGroups: options.skin_groups,
selectedSkins,
levelOptions: options.level_options,
dailyLossRatioAdjustment,
dailyLossRatioAdjustmentBPS,
})
const maxAcceleratedSaleRatio = referenceRatio > 0 ? roundRatio(referenceRatio + 10) : 0
const finalRatio = readFinalSaleRatio(
referenceRatio,
const maxRatioBPS = maxAcceleratedSaleRatioBPS(referenceRatioBPS)
const finalRatioBPS = readFinalSaleRatioBPS(
referenceRatioBPS,
form.accelerated_sale_ratio,
maxAcceleratedSaleRatio
maxRatioBPS
)
const coinBasePrice = finalRatio > 0 ? roundMoney(coinWanAmount / finalRatio) : 0
const consumablePrice = calculateConsumablePrice({
const coinBasePriceCent =
finalRatioBPS > 0 ? calculateSellerCoinBasePriceCent(hafCoinAmount, finalRatioBPS) : 0
const consumablePriceCent = calculateConsumablePriceCent({
quantityItems: options.quantity_items,
quantityValues,
quantityModes,
seasonInsurance: form.season_insurance,
})
const sellerPrice =
finalRatio > 0 ? roundMoney(coinBasePrice + consumablePrice) : 0
const platformPricing = calculatePlatformPricing({
coinMAmount,
coinWanAmount,
sellerRatio: finalRatio,
sellerCoinBasePrice: coinBasePrice,
sellerTotalPrice: sellerPrice,
consumablePrice,
const sellerPriceCent = finalRatioBPS > 0 ? coinBasePriceCent + consumablePriceCent : 0
const platformPricing = calculatePlatformPricingCent({
hafCoinAmount,
sellerRatioBPS: finalRatioBPS,
sellerCoinBasePriceCent: coinBasePriceCent,
sellerTotalPriceCent: sellerPriceCent,
consumablePriceCent,
salePriceConfig,
})
const recommendedDeposit = calculateRecommendedDeposit({
const recommendedDepositCent = calculateRecommendedDepositCent({
depositRecommendConfig: options.deposit_recommend_config,
skinGroups: options.skin_groups,
selectedSkins,
@@ -398,29 +415,27 @@ export function SellerListingFormPage() {
selectedSkins,
})
return {
hafCoinAmount,
coinMAmount,
coinWanAmount,
dailyLossMAmount,
dailyLossRatioAdjustment,
referenceRatio,
maxAcceleratedSaleRatio,
finalRatio,
coinBasePrice,
consumablePrice,
sellerPrice,
dailyLossRatioAdjustmentBPS,
referenceRatioBPS,
maxAcceleratedSaleRatioBPS: maxRatioBPS,
finalRatioBPS,
coinBasePriceCent,
consumablePriceCent,
sellerPriceCent,
platformPricing,
finalPrice: platformPricing.buyerTotalPrice,
recommendedDeposit,
finalPriceCent: platformPricing.buyerTotalPriceCent,
recommendedDepositCent,
depositBreakdown,
}
}, [form, options, salePriceConfig, quantityValues, quantityModes, selectedSkins])
const depositAmount = Number(form.depositAmountYuan)
const depositAmountCent = parseYuanToCent(form.depositAmountYuan === '' ? '' : form.depositAmountYuan)
const depositBelowRecommended =
pricing.recommendedDeposit > 0 &&
(form.depositAmountYuan === '' ||
!Number.isFinite(depositAmount) ||
depositAmount < pricing.recommendedDeposit)
pricing.recommendedDepositCent > 0 &&
(!Number.isFinite(depositAmountCent) || depositAmountCent < pricing.recommendedDepositCent)
function isQuantityDisabled(item: PublishQuantityItem) {
return isQuantityItemDisabledForInsurance(item, form.season_insurance)
@@ -535,27 +550,38 @@ export function SellerListingFormPage() {
}
function buildAssetSummary(): Record<string, unknown> {
const acceleratedRatioBPS = hasAcceleratedSaleRatioInput(form.accelerated_sale_ratio)
? ratioToBPS(form.accelerated_sale_ratio as number)
: pricing.referenceRatioBPS
const platformPricing = pricing.platformPricing
return {
face_owner: form.face_owner,
secret_kd: form.secret_kd,
fire_level: Number(form.fire_level),
daily_loss_m: pricing.dailyLossMAmount,
publish_ratio: pricing.platformPricing.buyerRatio,
publish_ratio: bpsToRatio(platformPricing.buyerRatioBPS),
price_breakdown: {
seller_reference_ratio: pricing.referenceRatio,
seller_ratio: pricing.finalRatio,
seller_coin_base_price: pricing.coinBasePrice,
seller_total_price: pricing.sellerPrice,
consumable_price: pricing.consumablePrice,
daily_loss_ratio_adjustment: pricing.dailyLossRatioAdjustment,
accelerated_sale_ratio: hasAcceleratedSaleRatioInput(form.accelerated_sale_ratio)
? Number(form.accelerated_sale_ratio)
: pricing.referenceRatio,
buyer_coin_base_price: pricing.platformPricing.buyerCoinBasePrice,
buyer_total_price: pricing.finalPrice,
buyer_ratio: pricing.platformPricing.buyerRatio,
platform_markup_amount: pricing.platformPricing.platformMarkupAmount,
platform_rule_type: pricing.platformPricing.ruleType,
seller_reference_ratio_bps: pricing.referenceRatioBPS,
seller_ratio_bps: pricing.finalRatioBPS,
buyer_ratio_bps: platformPricing.buyerRatioBPS,
seller_coin_base_price_cent: pricing.coinBasePriceCent,
seller_total_price_cent: pricing.sellerPriceCent,
consumable_price_cent: pricing.consumablePriceCent,
buyer_coin_base_price_cent: platformPricing.buyerCoinBasePriceCent,
buyer_total_price_cent: pricing.finalPriceCent,
platform_markup_amount_cent: platformPricing.platformMarkupAmountCent,
seller_reference_ratio: bpsToRatio(pricing.referenceRatioBPS),
seller_ratio: bpsToRatio(pricing.finalRatioBPS),
seller_coin_base_price: pricing.coinBasePriceCent / 100,
seller_total_price: pricing.sellerPriceCent / 100,
consumable_price: pricing.consumablePriceCent / 100,
daily_loss_ratio_adjustment: bpsToRatio(pricing.dailyLossRatioAdjustmentBPS),
accelerated_sale_ratio: bpsToRatio(acceleratedRatioBPS),
buyer_coin_base_price: platformPricing.buyerCoinBasePriceCent / 100,
buyer_total_price: pricing.finalPriceCent / 100,
buyer_ratio: bpsToRatio(platformPricing.buyerRatioBPS),
platform_markup_amount: platformPricing.platformMarkupAmountCent / 100,
platform_rule_type: platformPricing.ruleType,
},
season_insurance: form.season_insurance,
stamina_level: form.stamina_level,
@@ -565,6 +591,7 @@ export function SellerListingFormPage() {
key: item.key,
label: item.label,
price: item.price,
price_cent: item.price_cent,
quantity: isQuantityDisabled(item) ? 0 : Number(quantityValues[item.key] || 0),
mode: isQuantityDisabled(item) ? '赠送' : quantityModes[item.key] || '收费',
}))
@@ -618,27 +645,36 @@ export function SellerListingFormPage() {
if (options.next_season_pass_enabled && !form.next_season_pass) {
return '请选择下赛季是否购买通行证'
}
if (form.depositAmountYuan === '' || Number(form.depositAmountYuan) < 0) {
if (form.depositAmountYuan === '' || !Number.isFinite(depositAmountCent)) {
return '请填写押金'
}
if (!Number.isFinite(Number(form.depositAmountYuan))) return '押金格式不正确'
if (pricing.recommendedDeposit > 0 && Number(form.depositAmountYuan) < pricing.recommendedDeposit) {
return `押金不能低于智能推荐 ¥${pricing.recommendedDeposit}`
if (depositAmountCent < 0) return '押金格式不正确'
if (
pricing.recommendedDepositCent > 0 &&
depositAmountCent < pricing.recommendedDepositCent
) {
return `押金不能低于智能推荐 ${formatMoney(pricing.recommendedDepositCent)}`
}
if (pricing.consumablePrice > 0 && Number(form.depositAmountYuan) <= pricing.consumablePrice) {
return `押金必须大于额外消耗品总价值 ¥${pricing.consumablePrice}`
if (
pricing.consumablePriceCent > 0 &&
depositAmountCent <= pricing.consumablePriceCent
) {
return `押金必须大于额外消耗品总价值 ${formatMoney(pricing.consumablePriceCent)}`
}
if (pricing.finalPrice <= 0) {
if (pricing.finalPriceCent <= 0) {
return '暂无法计算买家价,请确认后台发布选项与价格规则已配置'
}
if (hasAcceleratedSaleRatioInput(form.accelerated_sale_ratio)) {
const ratio = Number(form.accelerated_sale_ratio)
if (!Number.isFinite(ratio) || ratio <= 0) return '加速出售比例格式不正确'
if (pricing.referenceRatio > 0 && ratio < pricing.referenceRatio) {
return `加速出售比例不能低于参考比例 1:${formatNumber(pricing.referenceRatio)}`
const ratioBPS = ratioToBPS(form.accelerated_sale_ratio as number)
if (ratioBPS <= 0) return '加速出售比例格式不正确'
if (pricing.referenceRatioBPS > 0 && ratioBPS < pricing.referenceRatioBPS) {
return `加速出售比例不能低于参考比例 1:${formatRatioBPS(pricing.referenceRatioBPS)}`
}
if (pricing.maxAcceleratedSaleRatio > 0 && ratio > pricing.maxAcceleratedSaleRatio) {
return `加速出售比例不能超过 1:${formatNumber(pricing.maxAcceleratedSaleRatio)}`
if (
pricing.maxAcceleratedSaleRatioBPS > 0 &&
ratioBPS > pricing.maxAcceleratedSaleRatioBPS
) {
return `加速出售比例不能超过 1:${formatRatioBPS(pricing.maxAcceleratedSaleRatioBPS)}`
}
}
if (screenshotUrls.length === 0) return '请至少上传 1 张账号截图'
@@ -674,11 +710,11 @@ export function SellerListingFormPage() {
server_region: form.server_region,
login_platform: form.login_method,
rank_level: form.rank_level,
haf_coin_amount: Math.round(pricing.coinMAmount * COIN_UNIT),
haf_coin_amount: pricing.hafCoinAmount,
asset_summary: buildAssetSummary(),
screenshot_urls: screenshotUrls,
price_cent: Math.round(pricing.finalPrice * 100),
deposit_amount_cent: Math.round(Number(form.depositAmountYuan) * 100),
price_cent: pricing.finalPriceCent,
deposit_amount_cent: depositAmountCent,
agreed_virtual_asset_sale: virtualAgreement,
agreed_seller_agreement: sellerAgreement,
}
@@ -1211,10 +1247,12 @@ export function SellerListingFormPage() {
className="seller-number-input"
/>
<Button
onClick={() => updateForm('depositAmountYuan', pricing.recommendedDeposit)}
disabled={pricing.recommendedDeposit <= 0}
onClick={() =>
updateForm('depositAmountYuan', pricing.recommendedDepositCent / 100)
}
disabled={pricing.recommendedDepositCent <= 0}
>
使用推荐 ¥{pricing.recommendedDeposit}
使用推荐 {formatMoney(pricing.recommendedDepositCent)}
</Button>
</div>
<small>{options.price_config.deposit_hint}</small>
@@ -1222,7 +1260,7 @@ export function SellerListingFormPage() {
{pricing.depositBreakdown.map(item => (
<Tag key={`${item.label}-${item.count}`} bordered={false}>
{item.label}
{item.count > 1 ? ` x${item.count}` : ''} ¥{item.amount}
{item.count > 1 ? ` x${item.count}` : ''} {formatMoney(item.amountCent)}
</Tag>
))}
</div>
@@ -1230,7 +1268,7 @@ export function SellerListingFormPage() {
<Alert
type="warning"
showIcon
message={`押金不能低于智能推荐 ¥${pricing.recommendedDeposit}`}
message={`押金不能低于智能推荐 ${formatMoney(pricing.recommendedDepositCent)}`}
/>
) : null}
</label>
@@ -1245,31 +1283,34 @@ export function SellerListingFormPage() {
form.accelerated_sale_ratio === '' ? null : form.accelerated_sale_ratio
}
placeholder={
pricing.referenceRatio > 0
? `默认 ${formatNumber(pricing.referenceRatio)},最高 ${formatNumber(pricing.maxAcceleratedSaleRatio)}`
pricing.referenceRatioBPS > 0
? `默认 ${formatRatioBPS(pricing.referenceRatioBPS)},最高 ${formatRatioBPS(pricing.maxAcceleratedSaleRatioBPS)}`
: '完善资料后自动生成可设置范围'
}
onChange={value => updateForm('accelerated_sale_ratio', value ?? '')}
className="seller-number-input"
/>
<Button
disabled={pricing.referenceRatio <= 0}
disabled={pricing.referenceRatioBPS <= 0}
onClick={() => updateForm('accelerated_sale_ratio', '')}
>
参考比例
</Button>
<Button
disabled={pricing.maxAcceleratedSaleRatio <= 0}
disabled={pricing.maxAcceleratedSaleRatioBPS <= 0}
onClick={() =>
updateForm('accelerated_sale_ratio', pricing.maxAcceleratedSaleRatio)
updateForm(
'accelerated_sale_ratio',
pricing.maxAcceleratedSaleRatioBPS / 10000
)
}
>
最高
</Button>
</div>
<small>
{pricing.referenceRatio > 0
? `可设置 1:${formatNumber(pricing.referenceRatio)} ~ 1:${formatNumber(pricing.maxAcceleratedSaleRatio)},比例越高出租越快。`
{pricing.referenceRatioBPS > 0
? `可设置 1:${formatRatioBPS(pricing.referenceRatioBPS)} ~ 1:${formatRatioBPS(pricing.maxAcceleratedSaleRatioBPS)},比例越高出租越快。`
: options.price_config.ratio_description || '请先完善赛季保险、体力和负重。'}
</small>
</label>
@@ -1334,7 +1375,7 @@ export function SellerListingFormPage() {
<Card className="seller-price-panel">
<Typography.Text className="seller-eyebrow">BUYER PRICE · 买家挂牌价</Typography.Text>
<div className="seller-price-panel__main">
<strong>{formatMoney(Math.round(pricing.finalPrice * 100))}</strong>
<strong>{formatMoney(pricing.finalPriceCent)}</strong>
<span>
{pricing.platformPricing.ruleType === 'fixed_markup'
? '固定加价'
@@ -1347,40 +1388,40 @@ export function SellerListingFormPage() {
<div>
<span>参考比例</span>
<strong>
{pricing.referenceRatio > 0
? `1:${formatNumber(pricing.referenceRatio)}`
{pricing.referenceRatioBPS > 0
? `1:${formatRatioBPS(pricing.referenceRatioBPS)}`
: '--'}
</strong>
</div>
<div>
<span>卖家比例</span>
<strong>
{pricing.finalRatio > 0 ? `1:${formatNumber(pricing.finalRatio)}` : '--'}
{pricing.finalRatioBPS > 0 ? `1:${formatRatioBPS(pricing.finalRatioBPS)}` : '--'}
</strong>
</div>
<div>
<span>买家比例</span>
<strong>
{pricing.platformPricing.buyerRatio > 0
? `1:${formatNumber(pricing.platformPricing.buyerRatio)}`
{pricing.platformPricing.buyerRatioBPS > 0
? `1:${formatRatioBPS(pricing.platformPricing.buyerRatioBPS)}`
: '--'}
</strong>
</div>
<div>
<span>纯币基础价</span>
<strong>{formatMoney(Math.round(pricing.coinBasePrice * 100))}</strong>
<strong>{formatMoney(pricing.coinBasePriceCent)}</strong>
</div>
<div>
<span>消耗品价值</span>
<strong>{formatMoney(Math.round(pricing.consumablePrice * 100))}</strong>
<strong>{formatMoney(pricing.consumablePriceCent)}</strong>
</div>
<div>
<span>卖家到手价</span>
<strong>{formatMoney(Math.round(pricing.sellerPrice * 100))}</strong>
<strong>{formatMoney(pricing.sellerPriceCent)}</strong>
</div>
<div>
<span>推荐押金</span>
<strong>{formatMoney(Math.round(pricing.recommendedDeposit * 100))}</strong>
<strong>{formatMoney(pricing.recommendedDepositCent)}</strong>
</div>
</div>
<Divider />
@@ -73,7 +73,10 @@ function sellerReferencePrice(row: SellerListing) {
const summary = row.asset_summary ?? {}
const breakdown = summary.price_breakdown
if (breakdown && typeof breakdown === 'object') {
const value = Number((breakdown as Record<string, unknown>).seller_total_price)
const record = breakdown as Record<string, unknown>
const cent = Number(record.seller_total_price_cent)
if (Number.isFinite(cent) && cent > 0) return Math.round(cent)
const value = Number(record.seller_total_price)
if (Number.isFinite(value) && value > 0) return Math.round(value * 100)
}
return row.price_cent
+40 -4
View File
@@ -19,6 +19,8 @@ export interface PublishQuantityItem {
key: QuantityKey
label: string
price: string
/** 规范化单价(分);大于 0 时优先于 price 字符串。 */
price_cent?: number
placeholder?: string
}
@@ -40,16 +42,22 @@ export interface PublishDepositSkinGroupRule {
group_key: string
label: string
amount_per_item: number
/** 规范化单件押金(分);大于 0 时优先于 amount_per_item。 */
amount_per_item_cent?: number
}
export interface PublishDepositRecommendConfig {
base_amount: number
/** 规范化基础押金(分);大于 0 时优先于 base_amount。 */
base_amount_cent?: number
skin_group_rules: PublishDepositSkinGroupRule[]
}
export interface PublishInsuranceBaseRatio {
insurance: string
ratio: number
/** 规范化比例(基点);大于 0 时优先于 ratio。 */
ratio_bps?: number
}
export interface PublishRatioConfigItem {
@@ -58,23 +66,31 @@ export interface PublishRatioConfigItem {
kind: string
group_key?: string
missing_penalty: number
/** 规范化缺失扣减(基点);大于 0 时优先于 missing_penalty。 */
missing_penalty_bps?: number
}
export interface PublishCoinCorrection {
threshold_m: number
correction: number
/** 规范化哈夫币修正(基点);大于 0 时优先于 correction。 */
correction_bps?: number
}
export interface PublishSaleFixedMarkupRule {
min_m: number
max_m: number
markup_amount: number
/** 规范化固定加价(分);大于 0 时优先于 markup_amount。 */
markup_amount_cent?: number
}
export interface PublishSaleRatioAdjustmentRule {
min_m: number
max_m: number
ratio_subtract: number
/** 规范化比例修正(基点);大于 0 时优先于 ratio_subtract。 */
ratio_subtract_bps?: number
}
export interface PublishRatioConfig {
@@ -289,6 +305,7 @@ function normalizeQuantityItems(values?: unknown[]): PublishQuantityItem[] {
key: typeof row.key === 'string' ? row.key.trim() : '',
label: typeof row.label === 'string' ? row.label.trim() : '',
price: typeof row.price === 'string' ? row.price.trim() : '',
price_cent: readOptionalPositiveInteger(row.price_cent),
placeholder: typeof row.placeholder === 'string' ? row.placeholder.trim() : undefined,
}
})
@@ -330,11 +347,12 @@ function normalizeDepositRecommendConfig(value?: unknown): PublishDepositRecomme
const row = isRecord(value) ? value : {}
const config = {
base_amount: readNumber(row.base_amount),
base_amount_cent: readOptionalPositiveInteger(row.base_amount_cent),
skin_group_rules: normalizeDepositSkinGroupRules(
Array.isArray(row.skin_group_rules) ? row.skin_group_rules : []
),
}
if (config.base_amount <= 0) {
if (config.base_amount <= 0 && !config.base_amount_cent) {
config.base_amount = emptyListingPublishOptions.deposit_recommend_config.base_amount
}
if (config.skin_group_rules.length === 0) {
@@ -353,6 +371,7 @@ function normalizeDepositSkinGroupRules(values: unknown[]): PublishDepositSkinGr
group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '',
label: typeof row.label === 'string' ? row.label.trim() : '',
amount_per_item: readNumber(row.amount_per_item),
amount_per_item_cent: readOptionalPositiveInteger(row.amount_per_item_cent),
}
})
.filter(item => item.group_key && item.label && item.amount_per_item >= 0)
@@ -401,9 +420,10 @@ function normalizeInsuranceBaseRatios(values: unknown[]): PublishInsuranceBaseRa
return {
insurance: typeof row.insurance === 'string' ? row.insurance.trim() : '',
ratio: readNumber(row.ratio),
ratio_bps: readOptionalPositiveInteger(row.ratio_bps),
}
})
.filter(item => item.insurance && item.ratio > 0)
.filter(item => item.insurance && (item.ratio > 0 || Boolean(item.ratio_bps)))
}
function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[] {
@@ -416,6 +436,7 @@ function normalizeRatioConfigItems(values: unknown[]): PublishRatioConfigItem[]
kind: typeof row.kind === 'string' ? row.kind.trim() : '',
group_key: typeof row.group_key === 'string' ? row.group_key.trim() : '',
missing_penalty: readNumber(row.missing_penalty),
missing_penalty_bps: readOptionalPositiveInteger(row.missing_penalty_bps),
}
})
.filter(item => item.key && item.label && item.kind)
@@ -428,9 +449,12 @@ function normalizeCoinCorrections(values: unknown[]): PublishCoinCorrection[] {
return {
threshold_m: readNumber(row.threshold_m),
correction: readNumber(row.correction),
correction_bps: readOptionalPositiveInteger(row.correction_bps),
}
})
.filter(item => item.threshold_m >= 0 && item.correction > 0)
.filter(
item => item.threshold_m >= 0 && (item.correction > 0 || Boolean(item.correction_bps))
)
}
function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarkupRule[] {
@@ -441,9 +465,15 @@ function normalizeSaleFixedMarkupRules(values: unknown[]): PublishSaleFixedMarku
min_m: readNumber(row.min_m),
max_m: readNumber(row.max_m),
markup_amount: readNumber(row.markup_amount),
markup_amount_cent: readOptionalPositiveInteger(row.markup_amount_cent),
}
})
.filter(item => item.min_m >= 0 && item.max_m >= item.min_m && item.markup_amount >= 0)
.filter(
item =>
item.min_m >= 0 &&
(item.max_m === 0 || item.max_m >= item.min_m) &&
item.markup_amount >= 0
)
}
function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioAdjustmentRule[] {
@@ -454,6 +484,7 @@ function normalizeSaleRatioAdjustmentRules(values: unknown[]): PublishSaleRatioA
min_m: readNumber(row.min_m),
max_m: readNumber(row.max_m),
ratio_subtract: readNumber(row.ratio_subtract),
ratio_subtract_bps: readOptionalPositiveInteger(row.ratio_subtract_bps),
}
})
.filter(
@@ -469,6 +500,11 @@ function readNumber(value: unknown) {
return Number.isFinite(parsed) ? parsed : 0
}
function readOptionalPositiveInteger(value: unknown) {
const parsed = Math.trunc(readNumber(value))
return parsed > 0 ? parsed : undefined
}
function readPositiveInteger(value: unknown, fallback: number) {
const parsed = Math.trunc(readNumber(value))
return parsed > 0 ? parsed : fallback
+1 -1
View File
@@ -43,7 +43,7 @@ export interface PublishDraft {
export interface DepositBreakdownItem {
label: string
amount: number
amountCent: number
count: number
}
+266 -148
View File
@@ -1,58 +1,97 @@
import type {
ChargeMode,
ListingPublishOptions,
PublishDepositRecommendConfig,
PublishOptionGroup,
PublishQuantityItem,
PublishRatioConfig,
PublishSalePriceConfig,
} from '@/features/seller/options'
import type {
DepositBreakdownItem,
PublishForm,
PublishPlatformPricing,
} from '@/features/seller/types'
import type { DepositBreakdownItem, PublishForm } from '@/features/seller/types'
export const dailyLossOptions = [10, 20, 30, 40, 50]
/**
* 将金额四舍五入到角精度(0.1 元)。
* 统一全项目金额处理规范。
*/
export function roundMoney(value: number) {
return Math.round(value * 10) / 10
}
const BPS_SCALE = 10_000
const HAF_COIN_UNIT = 1_000_000
const MAX_ACCELERATED_RATIO_BPS_INCREMENT = 10 * BPS_SCALE
/**
* 将比例四舍五入到一位小数。
* 把十进制字符串精确换算为缩放整数,全程不使用浮点乘法。
* 超出目标精度时按第三位数字四舍五入(仅用于用户输入边界)。
*/
export function roundRatio(value: number) {
return Math.round(value * 10) / 10
function decimalToScaled(value: number | string, scale: number): number {
const text = typeof value === 'number' ? String(value) : value.trim()
const match = /^([+-]?)(\d*)(?:\.(\d*))?$/.exec(text)
if (!match || (!match[2] && !match[3])) return Number.NaN
const sign = match[1] === '-' ? -1 : 1
const whole = Number(match[2] || '0')
if (!Number.isSafeInteger(whole)) return Number.NaN
const precision = String(scale).length - 1
const fractionText = (match[3] || '').padEnd(precision + 1, '0')
let fraction = Number(fractionText.slice(0, precision) || '0')
const nextDigit = Number(fractionText[precision] || '0')
if (nextDigit >= 5) fraction += 1
return sign * (whole * scale + fraction)
}
/**
* 格式化数字为字符串(保留 1 位小数)。
*/
export function formatNumber(value: number) {
const rounded = Math.round(value * 10) / 10
return rounded.toFixed(1)
/** 元字符串/数字 -> 分。非法或负数返回 NaN。 */
export function parseYuanToCent(value: number | string): number {
const cent = decimalToScaled(value, 100)
if (!Number.isFinite(cent) || cent < 0) return Number.NaN
return cent
}
/** 解析「8元/个」「8元」「8元 / 3个」形式的资源单价。 */
export function readUnitPrice(priceText: string) {
/** 哈夫币 M 输入 -> 整数微单位。非法或负数返回 0。 */
export function parseHafCoinMToUnits(value: number | string): number {
const units = decimalToScaled(value, HAF_COIN_UNIT)
if (!Number.isFinite(units) || units < 0) return 0
return units
}
/** 整数微单位 -> M,仅用于展示/回填表单。 */
export function hafCoinUnitsToM(value: number): number {
return Number.isFinite(value) ? value / HAF_COIN_UNIT : 0
}
/** 比例输入 -> 基点(1.0 = 10000 bps)。非法或负数返回 0。 */
export function ratioToBPS(value: number | string): number {
const bps = decimalToScaled(value, BPS_SCALE)
if (!Number.isFinite(bps) || bps < 0) return 0
return bps
}
/** 基点 -> 比例浮点值,仅用于旧字段/展示兼容。 */
export function bpsToRatio(bps: number): number {
return Number.isFinite(bps) ? bps / BPS_SCALE : 0
}
/** 比例展示:保留一位小数。 */
export function formatRatioBPS(bps: number): string {
if (!Number.isFinite(bps) || bps <= 0) return '0.0'
const tenths = Math.round(bps / 1000)
return (tenths / 10).toFixed(1)
}
/** 解析「8元/个」「8元」「1元/2个」形式的资源单价为分。 */
export function parseUnitPriceCent(priceText: string): number {
const normalized = priceText.replace(/,/g, ',').trim()
const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/)
const fractionMatch = /(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/.exec(normalized)
if (fractionMatch) {
const amount = Number(fractionMatch[1])
const count = Number(fractionMatch[2])
return count > 0 ? amount / count : 0
const amountCent = parseYuanToCent(fractionMatch[1])
const countText = fractionMatch[2]
if (Number.isFinite(amountCent) && /^\d+$/.test(countText)) {
const count = Number(countText)
if (count > 0) return Math.floor(amountCent / count)
}
}
const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/)
return singleMatch ? Number(singleMatch[1]) : 0
const singleMatch = /(\d+(?:\.\d+)?)\s*元/.exec(normalized)
if (!singleMatch) return 0
const amountCent = parseYuanToCent(singleMatch[1])
return Number.isFinite(amountCent) ? amountCent : 0
}
export function calculateDailyLossRatioAdjustment(dailyLossMAmount: number) {
return Math.min(Math.max(Math.floor((dailyLossMAmount - 10) / 10), 0), 4)
export function calculateDailyLossRatioAdjustmentBPS(dailyLossMAmount: number) {
const dailyLoss = Math.trunc(Number(dailyLossMAmount) || 0)
const points = Math.min(Math.max(Math.floor((dailyLoss - 10) / 10), 0), 4)
return points * BPS_SCALE
}
export function isGridCardQuantityItem(item: { key: string; label: string }) {
@@ -66,35 +105,60 @@ export function isQuantityItemDisabledForInsurance(
return seasonInsurance === '3*3' && isGridCardQuantityItem(item)
}
export function calculateConsumablePrice(options: {
export function quantityItemUnitPriceCent(item: PublishQuantityItem): number {
if (Number.isFinite(item.price_cent) && Number(item.price_cent) > 0) {
return Math.trunc(Number(item.price_cent))
}
return parseUnitPriceCent(item.price)
}
export function calculateConsumablePriceCent(options: {
quantityItems: PublishQuantityItem[]
quantityValues: Record<string, number>
quantityModes: Record<string, ChargeMode>
seasonInsurance: string
}) {
const total = options.quantityItems.reduce((sum, item) => {
const quantity = Number(options.quantityValues[item.key] || 0)
return options.quantityItems.reduce((sum, item) => {
const quantity = Math.trunc(Number(options.quantityValues[item.key] || 0))
const mode = options.quantityModes[item.key] || '收费'
if (isQuantityItemDisabledForInsurance(item, options.seasonInsurance)) return sum
if (quantity <= 0 || mode !== '收费') return sum
return sum + quantity * readUnitPrice(item.price)
return sum + quantity * quantityItemUnitPriceCent(item)
}, 0)
return roundMoney(total)
}
export function calculateRecommendedDeposit(options: {
export function depositRuleAmountCent(rule: {
amount_per_item: number
amount_per_item_cent?: number
}) {
if (Number.isFinite(rule.amount_per_item_cent) && Number(rule.amount_per_item_cent) > 0) {
return Math.trunc(Number(rule.amount_per_item_cent))
}
const cent = parseYuanToCent(rule.amount_per_item)
return Number.isFinite(cent) ? cent : 0
}
export function depositBaseAmountCent(config: PublishDepositRecommendConfig) {
if (Number.isFinite(config.base_amount_cent) && Number(config.base_amount_cent) > 0) {
return Math.trunc(Number(config.base_amount_cent))
}
const cent = parseYuanToCent(config.base_amount)
return Number.isFinite(cent) ? cent : 0
}
export function calculateRecommendedDepositCent(options: {
depositRecommendConfig: PublishDepositRecommendConfig
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
}) {
const baseAmount = Number(options.depositRecommendConfig.base_amount || 0)
const skinAmount = options.depositRecommendConfig.skin_group_rules.reduce((sum, rule) => {
const baseCent = depositBaseAmountCent(options.depositRecommendConfig)
const skinCent = options.depositRecommendConfig.skin_group_rules.reduce((sum, rule) => {
const group = options.skinGroups.find(item => item.key === rule.group_key)
if (!group) return sum
const selectedCount = group.options.filter(skin => options.selectedSkins.includes(skin)).length
return sum + selectedCount * Number(rule.amount_per_item || 0)
return sum + selectedCount * depositRuleAmountCent(rule)
}, 0)
return roundMoney(baseAmount + skinAmount)
return baseCent + skinCent
}
export function buildDepositBreakdownItems(options: {
@@ -105,7 +169,7 @@ export function buildDepositBreakdownItems(options: {
const items: DepositBreakdownItem[] = [
{
label: '基础押金',
amount: Number(options.depositRecommendConfig.base_amount || 0),
amountCent: depositBaseAmountCent(options.depositRecommendConfig),
count: 1,
},
]
@@ -116,144 +180,174 @@ export function buildDepositBreakdownItems(options: {
if (count <= 0) continue
items.push({
label: rule.label,
amount: Number(rule.amount_per_item || 0) * count,
amountCent: depositRuleAmountCent(rule) * count,
count,
})
}
return items
}
export function calculateSellerReferenceRatio(options: {
coinMAmount: number
export function calculateSellerReferenceRatioBPS(options: {
hafCoinAmount: number
form: PublishForm
ratioConfig: PublishRatioConfig
ratioConfig: {
insurance_base_ratios: Array<{ insurance: string; ratio: number; ratio_bps?: number }>
config_items: Array<{
kind: string
group_key?: string
missing_penalty: number
missing_penalty_bps?: number
}>
coin_corrections: Array<{
threshold_m: number
correction: number
correction_bps?: number
}>
}
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
levelOptions: string[]
dailyLossRatioAdjustment: number
dailyLossRatioAdjustmentBPS: number
}) {
const { coinMAmount, form, ratioConfig } = options
if (coinMAmount <= 0 || !form.season_insurance || !form.stamina_level || !form.load_level) {
const { hafCoinAmount, form, ratioConfig } = options
if (hafCoinAmount <= 0 || !form.season_insurance || !form.stamina_level || !form.load_level) {
return 0
}
const baseRatio = getInsuranceBaseRatio(ratioConfig, form.season_insurance)
if (baseRatio <= 0) return 0
const baseRatioBPS = getInsuranceBaseRatioBPS(ratioConfig, form.season_insurance)
if (baseRatioBPS <= 0) return 0
return (
baseRatio +
calculateConfigPenalty(ratioConfig, options) +
getCoinCorrection(ratioConfig, coinMAmount) +
options.dailyLossRatioAdjustment
baseRatioBPS +
calculateConfigPenaltyBPS(ratioConfig, options) +
getCoinCorrectionBPS(ratioConfig, hafCoinAmount) +
options.dailyLossRatioAdjustmentBPS
)
}
export function readFinalSaleRatio(
defaultRatio: number,
acceleratedSaleRatio: number | '',
maxAcceleratedSaleRatio: number
export function readFinalSaleRatioBPS(
defaultRatioBPS: number,
acceleratedSaleRatio: number | '' | string,
maxAcceleratedSaleRatioBPS: number
) {
if (defaultRatio <= 0) return 0
if (!hasAcceleratedSaleRatioInput(acceleratedSaleRatio)) return defaultRatio
const ratio = Number(acceleratedSaleRatio)
if (!Number.isFinite(ratio) || ratio <= 0) return defaultRatio
return roundRatio(Math.min(Math.max(ratio, defaultRatio), maxAcceleratedSaleRatio))
if (defaultRatioBPS <= 0) return 0
if (!hasAcceleratedSaleRatioInput(acceleratedSaleRatio)) return defaultRatioBPS
const ratioBPS = ratioToBPS(acceleratedSaleRatio as number | string)
if (ratioBPS <= 0) return defaultRatioBPS
return Math.min(Math.max(ratioBPS, defaultRatioBPS), maxAcceleratedSaleRatioBPS)
}
export function applySiteReceiveRatioDelta(referenceRatio: number, delta: number) {
if (referenceRatio <= 0) return 0
const safeDelta = Math.min(20, Math.max(0, Number(delta) || 0))
return roundRatio(Math.max(referenceRatio - safeDelta, 0.01))
export function maxAcceleratedSaleRatioBPS(referenceRatioBPS: number) {
return referenceRatioBPS > 0
? referenceRatioBPS + MAX_ACCELERATED_RATIO_BPS_INCREMENT
: 0
}
/**
* 分站收号压价(新口径):收号比例 = 主站参考比例 R + 点数,比例变大 → 收号价更低。
* 买家价已解耦,不再从收号比例推导,始终锚定主站参考口径。
*/
export function applySiteReceiveCostDelta(referenceRatio: number, delta: number) {
if (referenceRatio <= 0) return 0
const safeDelta = Math.min(20, Math.max(0, Number(delta) || 0))
return roundRatio(referenceRatio + safeDelta)
export function hasAcceleratedSaleRatioInput(value: number | '' | string | null | undefined) {
if (value === '' || value === null || value === undefined) return false
return ratioToBPS(value) > 0
}
export function hasAcceleratedSaleRatioInput(value: number | '' | null | undefined) {
const ratio = Number(value)
return (
value !== '' && value !== null && value !== undefined && Number.isFinite(ratio) && ratio > 0
)
export function calculateSellerCoinBasePriceCent(hafCoinAmount: number, ratioBPS: number) {
if (hafCoinAmount <= 0 || ratioBPS <= 0) return 0
return Math.floor((hafCoinAmount * 100) / ratioBPS)
}
export function calculatePlatformPricing(options: {
coinMAmount: number
coinWanAmount: number
sellerRatio: number
sellerCoinBasePrice: number
sellerTotalPrice: number
consumablePrice: number
export interface PlatformPricingCent {
buyerCoinBasePriceCent: number
buyerTotalPriceCent: number
buyerRatioBPS: number
platformMarkupAmountCent: number
ruleType: 'fixed_markup' | 'ratio_subtract' | 'none' | string
}
export function calculatePlatformPricingCent(options: {
hafCoinAmount: number
sellerRatioBPS: number
sellerCoinBasePriceCent: number
sellerTotalPriceCent: number
consumablePriceCent: number
salePriceConfig: PublishSalePriceConfig
}): PublishPlatformPricing {
if (options.sellerRatio <= 0 || options.sellerCoinBasePrice <= 0) return emptyPlatformPricing()
const fixedRule = findSaleFixedMarkupRule(options.salePriceConfig, options.coinMAmount)
}): PlatformPricingCent {
if (options.sellerRatioBPS <= 0 || options.sellerCoinBasePriceCent <= 0) {
return emptyPlatformPricingCent()
}
const ratioRule = findSaleRatioAdjustmentRule(options.salePriceConfig, options.hafCoinAmount)
if (ratioRule) {
const subtractBPS =
Number(ratioRule.ratio_subtract_bps || 0) > 0
? Math.trunc(Number(ratioRule.ratio_subtract_bps))
: ratioToBPS(ratioRule.ratio_subtract)
const buyerRatioBPS = options.sellerRatioBPS - subtractBPS
if (buyerRatioBPS > 0) {
const buyerCoinBasePriceCent = Math.max(
options.sellerCoinBasePriceCent,
calculateSellerCoinBasePriceCent(options.hafCoinAmount, buyerRatioBPS)
)
return buildPlatformPricingCent(buyerCoinBasePriceCent, 'ratio_subtract', options)
}
}
const fixedRule = findSaleFixedMarkupRule(options.salePriceConfig, options.hafCoinAmount)
if (fixedRule) {
return buildPlatformPricing(
roundMoney(options.sellerCoinBasePrice + Number(fixedRule.markup_amount || 0)),
const markupCent =
Number(fixedRule.markup_amount_cent || 0) > 0
? Math.trunc(Number(fixedRule.markup_amount_cent))
: (() => {
const cent = parseYuanToCent(fixedRule.markup_amount)
return Number.isFinite(cent) ? cent : 0
})()
return buildPlatformPricingCent(
options.sellerCoinBasePriceCent + markupCent,
'fixed_markup',
options
)
}
const ratioRule = findSaleRatioAdjustmentRule(options.salePriceConfig, options.coinMAmount)
const ratioSubtract = ratioRule ? Number(ratioRule.ratio_subtract || 0) : 0
const buyerRatio = options.sellerRatio - ratioSubtract
if (buyerRatio > 0 && ratioRule) {
return buildPlatformPricing(
roundMoney(options.coinWanAmount / buyerRatio),
'ratio_subtract',
options
)
}
return buildPlatformPricing(options.sellerCoinBasePrice, 'none', options)
return buildPlatformPricingCent(options.sellerCoinBasePriceCent, 'none', options)
}
export function emptyPlatformPricing(): PublishPlatformPricing {
export function emptyPlatformPricingCent(): PlatformPricingCent {
return {
buyerCoinBasePrice: 0,
buyerTotalPrice: 0,
buyerRatio: 0,
platformMarkupAmount: 0,
buyerCoinBasePriceCent: 0,
buyerTotalPriceCent: 0,
buyerRatioBPS: 0,
platformMarkupAmountCent: 0,
ruleType: 'none',
}
}
function buildPlatformPricing(
buyerCoinBasePrice: number,
function buildPlatformPricingCent(
buyerCoinBasePriceCent: number,
ruleType: string,
options: {
coinWanAmount: number
sellerTotalPrice: number
consumablePrice: number
hafCoinAmount: number
sellerTotalPriceCent: number
consumablePriceCent: number
}
): PublishPlatformPricing {
const buyerTotalPrice = roundMoney(buyerCoinBasePrice + options.consumablePrice)
): PlatformPricingCent {
const buyerTotalPriceCent = buyerCoinBasePriceCent + options.consumablePriceCent
return {
buyerCoinBasePrice,
buyerTotalPrice,
buyerRatio: calculateEffectiveRatio(options.coinWanAmount, buyerCoinBasePrice),
platformMarkupAmount: roundMoney(buyerTotalPrice - options.sellerTotalPrice),
buyerCoinBasePriceCent,
buyerTotalPriceCent,
buyerRatioBPS:
buyerCoinBasePriceCent > 0
? Math.floor((options.hafCoinAmount * 100) / buyerCoinBasePriceCent)
: 0,
platformMarkupAmountCent: buyerTotalPriceCent - options.sellerTotalPriceCent,
ruleType,
}
}
function findSaleFixedMarkupRule(config: PublishSalePriceConfig, coinMAmount: number) {
function findSaleFixedMarkupRule(config: PublishSalePriceConfig, hafCoinAmount: number) {
return [...config.fixed_markup_rules]
.sort((a, b) => a.min_m - b.min_m)
.find((item, index, rules) =>
isCoinInSaleRange(item, index, rules, coinMAmount, { includeLastMax: true })
isCoinInSaleRange(item, index, rules, hafCoinAmount, { includeLastMax: true })
)
}
function findSaleRatioAdjustmentRule(config: PublishSalePriceConfig, coinMAmount: number) {
function findSaleRatioAdjustmentRule(config: PublishSalePriceConfig, hafCoinAmount: number) {
return [...config.ratio_adjustment_rules]
.sort((a, b) => a.min_m - b.min_m)
.find((item, index, rules) =>
isCoinInSaleRange(item, index, rules, coinMAmount, { excludeFirstMin: true })
isCoinInSaleRange(item, index, rules, hafCoinAmount, { excludeFirstMin: true })
)
}
@@ -261,33 +355,43 @@ function isCoinInSaleRange(
item: { min_m: number; max_m: number },
index: number,
rules: Array<{ min_m: number; max_m: number }>,
coinMAmount: number,
hafCoinAmount: number,
options: { includeLastMax?: boolean; excludeFirstMin?: boolean } = {}
) {
const maxM = Number(item.max_m || 0)
const minM = Number(item.min_m || 0)
const minUnits = Math.round(minM * HAF_COIN_UNIT)
const maxUnits = Math.round(maxM * HAF_COIN_UNIT)
const minMatched =
options.excludeFirstMin && index === 0 ? coinMAmount > minM : coinMAmount >= minM
options.excludeFirstMin && index === 0 ? hafCoinAmount > minUnits : hafCoinAmount >= minUnits
const isLastRule = index === rules.length - 1
const maxMatched =
maxM <= 0 || coinMAmount < maxM || (options.includeLastMax && isLastRule && coinMAmount <= maxM)
maxUnits <= 0 ||
hafCoinAmount < maxUnits ||
(options.includeLastMax && isLastRule && hafCoinAmount <= maxUnits)
return minMatched && maxMatched
}
function calculateEffectiveRatio(coinWanAmount: number, price: number) {
if (price <= 0) return 0
return roundRatio(coinWanAmount / price)
}
function getInsuranceBaseRatio(
config: Pick<ListingPublishOptions['ratio_config'], 'insurance_base_ratios'>,
function getInsuranceBaseRatioBPS(
config: {
insurance_base_ratios: Array<{ insurance: string; ratio: number; ratio_bps?: number }>
},
insurance: string
) {
return config.insurance_base_ratios.find(item => item.insurance === insurance)?.ratio || 0
const item = config.insurance_base_ratios.find(row => row.insurance === insurance)
if (!item) return 0
return Number(item.ratio_bps || 0) > 0 ? Math.trunc(Number(item.ratio_bps)) : ratioToBPS(item.ratio)
}
function calculateConfigPenalty(
config: Pick<ListingPublishOptions['ratio_config'], 'config_items'>,
function calculateConfigPenaltyBPS(
config: {
config_items: Array<{
kind: string
group_key?: string
missing_penalty: number
missing_penalty_bps?: number
}>
},
options: {
form: PublishForm
skinGroups: PublishOptionGroup[]
@@ -296,7 +400,13 @@ function calculateConfigPenalty(
}
) {
return config.config_items.reduce((sum, item) => {
return isRatioConfigItemMatched(item, options) ? sum : sum + Number(item.missing_penalty || 0)
if (isRatioConfigItemMatched(item, options)) return sum
return (
sum +
(Number(item.missing_penalty_bps || 0) > 0
? Math.trunc(Number(item.missing_penalty_bps))
: ratioToBPS(item.missing_penalty))
)
}, 0)
}
@@ -341,13 +451,21 @@ function readLevelNumber(value: string) {
return match ? Number(match[0]) : 0
}
function getCoinCorrection(
config: Pick<ListingPublishOptions['ratio_config'], 'coin_corrections'>,
coinM: number
function getCoinCorrectionBPS(
config: {
coin_corrections: Array<{
threshold_m: number
correction: number
correction_bps?: number
}>
},
hafCoinAmount: number
) {
return (
[...config.coin_corrections]
.sort((a, b) => b.threshold_m - a.threshold_m)
.find(item => coinM > item.threshold_m)?.correction || 0
)
const item = [...config.coin_corrections]
.sort((a, b) => b.threshold_m - a.threshold_m)
.find(row => hafCoinAmount > Math.round(row.threshold_m * HAF_COIN_UNIT))
if (!item) return 0
return Number(item.correction_bps || 0) > 0
? Math.trunc(Number(item.correction_bps))
: ratioToBPS(item.correction)
}