Files
hfb_sys/frontend/src/utils/pricing.ts
T

328 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ChargeMode,
ListingPublishOptions,
PublishDepositRecommendConfig,
PublishOptionGroup,
PublishQuantityItem,
PublishRatioConfig,
PublishSalePriceConfig,
} from '@/features/listings/api/listingOptions'
import type { DepositBreakdownItem, PublishForm, PublishPlatformPricing } from '@/types/publish'
export const dailyLossOptions = [10, 20, 30, 40, 50]
export const commonOnlineTimes = [
'00:00',
'08:00',
'10:00',
'12:00',
'14:00',
'18:00',
'20:00',
'22:00',
'23:59',
]
export function roundMoney(value: number) {
return Math.round(value * 10) / 10
}
export function roundRatio(value: number) {
return Math.round(value * 10) / 10
}
export function formatNumber(value: number) {
return (Math.round(value * 10) / 10).toFixed(1)
}
export function readUnitPrice(priceText: string) {
const normalized = priceText.replace(//g, ',').trim()
const fractionMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元\s*\/\s*(\d+(?:\.\d+)?)/)
if (fractionMatch) {
const amount = Number(fractionMatch[1])
const count = Number(fractionMatch[2])
return count > 0 ? amount / count : 0
}
const singleMatch = normalized.match(/(\d+(?:\.\d+)?)\s*元/)
return singleMatch ? Number(singleMatch[1]) : 0
}
export function calculateDailyLossRatioAdjustment(dailyLossMAmount: number) {
return Math.min(Math.max(Math.floor((dailyLossMAmount - 10) / 10), 0), 4)
}
export function isGridCardQuantityItem(item: { key: string; label: string }) {
return item.key === 'gridCard9' || item.label.includes('9格体验卡')
}
export function isQuantityItemDisabledForInsurance(
item: { key: string; label: string },
seasonInsurance: string
) {
return seasonInsurance === '3*3' && isGridCardQuantityItem(item)
}
export function calculateConsumablePrice(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)
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)
}, 0)
return roundMoney(total)
}
export function calculateRecommendedDeposit(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 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)
}, 0)
return roundMoney(baseAmount + skinAmount)
}
export function buildDepositBreakdownItems(options: {
depositRecommendConfig: PublishDepositRecommendConfig
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
}): DepositBreakdownItem[] {
const items: DepositBreakdownItem[] = [
{
label: '基础押金',
amount: Number(options.depositRecommendConfig.base_amount || 0),
count: 1,
},
]
for (const rule of options.depositRecommendConfig.skin_group_rules) {
const group = options.skinGroups.find(item => item.key === rule.group_key)
if (!group) continue
const count = group.options.filter(skin => options.selectedSkins.includes(skin)).length
if (count <= 0) continue
items.push({
label: rule.label,
amount: Number(rule.amount_per_item || 0) * count,
count,
})
}
return items
}
export function calculateSellerReferenceRatio(options: {
coinMAmount: number
form: PublishForm
ratioConfig: PublishRatioConfig
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
levelOptions: string[]
dailyLossRatioAdjustment: number
}) {
const { coinMAmount, form, ratioConfig } = options
if (coinMAmount <= 0 || !form.season_insurance || !form.stamina_level || !form.load_level)
return 0
const baseRatio = getInsuranceBaseRatio(ratioConfig, form.season_insurance)
if (baseRatio <= 0) return 0
return (
baseRatio +
calculateConfigPenalty(ratioConfig, options) +
getCoinCorrection(ratioConfig, coinMAmount) +
options.dailyLossRatioAdjustment
)
}
export function readFinalSaleRatio(
defaultRatio: number,
acceleratedSaleRatio: number | '',
maxAcceleratedSaleRatio: 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))
}
export function hasAcceleratedSaleRatioInput(value: number | '') {
return value !== '' && value !== null
}
export function calculatePlatformPricing(options: {
coinMAmount: number
coinWanAmount: number
sellerRatio: number
sellerCoinBasePrice: number
sellerTotalPrice: number
consumablePrice: number
salePriceConfig: PublishSalePriceConfig
}): PublishPlatformPricing {
if (options.sellerRatio <= 0 || options.sellerCoinBasePrice <= 0) return emptyPlatformPricing()
const fixedRule = findSaleFixedMarkupRule(options.salePriceConfig, options.coinMAmount)
if (fixedRule) {
return buildPlatformPricing(
roundMoney(options.sellerCoinBasePrice + Number(fixedRule.markup_amount || 0)),
'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)
}
export function emptyPlatformPricing(): PublishPlatformPricing {
return {
buyerCoinBasePrice: 0,
buyerTotalPrice: 0,
buyerRatio: 0,
platformMarkupAmount: 0,
ruleType: 'none',
}
}
function buildPlatformPricing(
buyerCoinBasePrice: number,
ruleType: string,
options: {
coinWanAmount: number
sellerTotalPrice: number
consumablePrice: number
}
): PublishPlatformPricing {
const buyerTotalPrice = roundMoney(buyerCoinBasePrice + options.consumablePrice)
return {
buyerCoinBasePrice,
buyerTotalPrice,
buyerRatio: calculateEffectiveRatio(options.coinWanAmount, buyerCoinBasePrice),
platformMarkupAmount: roundMoney(buyerTotalPrice - options.sellerTotalPrice),
ruleType,
}
}
function findSaleFixedMarkupRule(config: PublishSalePriceConfig, coinMAmount: 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 })
)
}
function findSaleRatioAdjustmentRule(config: PublishSalePriceConfig, coinMAmount: 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 })
)
}
function isCoinInSaleRange(
item: { min_m: number; max_m: number },
index: number,
rules: Array<{ min_m: number; max_m: number }>,
coinMAmount: number,
options: { includeLastMax?: boolean; excludeFirstMin?: boolean } = {}
) {
const maxM = Number(item.max_m || 0)
const minM = Number(item.min_m || 0)
const minMatched =
options.excludeFirstMin && index === 0 ? coinMAmount > minM : coinMAmount >= minM
const isLastRule = index === rules.length - 1
const maxMatched =
maxM <= 0 || coinMAmount < maxM || (options.includeLastMax && isLastRule && coinMAmount <= maxM)
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'>,
insurance: string
) {
return config.insurance_base_ratios.find(item => item.insurance === insurance)?.ratio || 0
}
function calculateConfigPenalty(
config: Pick<ListingPublishOptions['ratio_config'], 'config_items'>,
options: {
form: PublishForm
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
levelOptions: string[]
}
) {
return config.config_items.reduce((sum, item) => {
return isRatioConfigItemMatched(item, options) ? sum : sum + Number(item.missing_penalty || 0)
}, 0)
}
function isRatioConfigItemMatched(
item: { kind: string; group_key?: string },
options: {
form: PublishForm
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
levelOptions: string[]
}
) {
if (item.kind === 'skin_group') return hasSelectedSkinGroup(item.group_key || '', options)
if (item.kind === 'max_stamina')
return isMaxLevel(options.form.stamina_level, options.levelOptions)
if (item.kind === 'max_load') return isMaxLevel(options.form.load_level, options.levelOptions)
return false
}
function hasSelectedSkinGroup(
groupKey: string,
options: {
skinGroups: PublishOptionGroup[]
selectedSkins: string[]
}
) {
const group = options.skinGroups.find(item => item.key === groupKey)
if (!group) return false
return group.options.some(skin => options.selectedSkins.includes(skin))
}
function isMaxLevel(value: string, levelOptions: string[]) {
const currentLevel = readLevelNumber(value)
const maxLevel = Math.max(...levelOptions.map(readLevelNumber).filter(Boolean))
if (currentLevel > 0 && maxLevel > 0) return currentLevel >= maxLevel
return value === levelOptions[levelOptions.length - 1]
}
function readLevelNumber(value: string) {
const match = value.match(/\d+/)
return match ? Number(match[0]) : 0
}
function getCoinCorrection(
config: Pick<ListingPublishOptions['ratio_config'], 'coin_corrections'>,
coinM: number
) {
return (
[...config.coin_corrections]
.sort((a, b) => b.threshold_m - a.threshold_m)
.find(item => coinM > item.threshold_m)?.correction || 0
)
}