342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
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',
|
||
]
|
||
|
||
/**
|
||
* 将金额四舍五入到角精度(0.1元)
|
||
* 统一全项目金额处理规范
|
||
* @example roundMoney(12.34) -> 12.3
|
||
* @example roundMoney(12.36) -> 12.4
|
||
* @example roundMoney(12.35) -> 12.4
|
||
*/
|
||
export function roundMoney(value: number) {
|
||
return Math.round(value * 10) / 10
|
||
}
|
||
|
||
/**
|
||
* 将比例四舍五入到一位小数
|
||
*/
|
||
export function roundRatio(value: number) {
|
||
return Math.round(value * 10) / 10
|
||
}
|
||
|
||
/**
|
||
* 格式化数字为字符串(保留1位小数)
|
||
*/
|
||
export function formatNumber(value: number) {
|
||
const rounded = Math.round(value * 10) / 10
|
||
return rounded.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
|
||
)
|
||
}
|