feat(distribution): 分站收号压价解耦买家价(方案 B 新口径)
核心语义反转:receive_cost_delta 为压价点数,收号比例 = 主站参考比例 R + 点数, 点数越大号主收号价越低;买家售价锚定主站参考比例推导,不再随收号价联动。 - 迁移 000072:distribution_sites 新增 receive_cost_delta(旧 receive_ratio_delta 保留只读) - receive_pricing:新增 Normalize/ApplySiteReceiveCostDelta;分站定价买家侧改用 R 锚点(referenceSellerCoinBasePrice),快照写入 site_receive_cost_delta 与 pricing_model 标记 - site_receive:新增 resolveSiteReceiveCostDelta 读取链路,旧 ResolveReceiveRatioDelta 保留 - 分成不变:压价差经结算层 profit 自动进入 35/40/25 三方分成池; 货架自主加价仍 100% 归分站,两条边界均以主站为锚 - 权限对齐旧字段:主站全局管理员与分站操作员均可设置 - 前端三端同步:主站 pricing.ts/usePricingCalculator 买卖解耦试算、 主站后台弹窗改「收号压价」价格语义文案、分销后台 SiteSettings 同步 - 测试:单测锚定 R=43 时 cost_delta=3 → 收号 217.4 / 卖价 263.2 不变 / markup 池 30.6→54.9
This commit is contained in:
@@ -19,6 +19,7 @@ type DistributionSite struct {
|
|||||||
ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"`
|
ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"`
|
||||||
SMSConfigJSON string `gorm:"type:text" json:"-"`
|
SMSConfigJSON string `gorm:"type:text" json:"-"`
|
||||||
ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"`
|
ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"`
|
||||||
|
ReceiveCostDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_cost_delta"`
|
||||||
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
|
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
|
||||||
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
|
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
|
||||||
SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"`
|
SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"`
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type SiteDTO struct {
|
|||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
||||||
ListingQrcodeURL string `json:"listing_qrcode_url"`
|
ListingQrcodeURL string `json:"listing_qrcode_url"`
|
||||||
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
|
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
|
||||||
|
ReceiveCostDelta float64 `json:"receive_cost_delta"`
|
||||||
TaxEnabled bool `json:"tax_enabled"`
|
TaxEnabled bool `json:"tax_enabled"`
|
||||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||||
SettlementHoldDays int `json:"settlement_hold_days"`
|
SettlementHoldDays int `json:"settlement_hold_days"`
|
||||||
@@ -32,6 +33,7 @@ type CreateSiteRequest struct {
|
|||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||||
ListingQrcodeURL *string `json:"listing_qrcode_url"`
|
ListingQrcodeURL *string `json:"listing_qrcode_url"`
|
||||||
ReceiveRatioDelta *float64 `json:"receive_ratio_delta"`
|
ReceiveRatioDelta *float64 `json:"receive_ratio_delta"`
|
||||||
|
ReceiveCostDelta *float64 `json:"receive_cost_delta"`
|
||||||
TaxEnabled *bool `json:"tax_enabled"`
|
TaxEnabled *bool `json:"tax_enabled"`
|
||||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||||
SettlementHoldDays *int `json:"settlement_hold_days"`
|
SettlementHoldDays *int `json:"settlement_hold_days"`
|
||||||
@@ -45,6 +47,7 @@ type UpdateSiteRequest struct {
|
|||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||||
ListingQrcodeURL *string `json:"listing_qrcode_url"`
|
ListingQrcodeURL *string `json:"listing_qrcode_url"`
|
||||||
ReceiveRatioDelta *float64 `json:"receive_ratio_delta"`
|
ReceiveRatioDelta *float64 `json:"receive_ratio_delta"`
|
||||||
|
ReceiveCostDelta *float64 `json:"receive_cost_delta"`
|
||||||
TaxEnabled *bool `json:"tax_enabled"`
|
TaxEnabled *bool `json:"tax_enabled"`
|
||||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||||
SettlementHoldDays *int `json:"settlement_hold_days"`
|
SettlementHoldDays *int `json:"settlement_hold_days"`
|
||||||
@@ -248,6 +251,7 @@ type PublicSiteInfoDTO struct {
|
|||||||
FooterInfoJSON string `json:"footer_info_json"`
|
FooterInfoJSON string `json:"footer_info_json"`
|
||||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
||||||
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
|
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
|
||||||
|
ReceiveCostDelta float64 `json:"receive_cost_delta"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateAccountDisplayRequest struct {
|
type UpdateAccountDisplayRequest struct {
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ func normalizeReceiveRatioDelta(delta float64) (float64, error) {
|
|||||||
return listingpkg.NormalizeSiteReceiveRatioDelta(delta), nil
|
return listingpkg.NormalizeSiteReceiveRatioDelta(delta), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeReceiveCostDelta(delta float64) (float64, error) {
|
||||||
|
if delta < 0 {
|
||||||
|
return 0, ErrInvalidAmount
|
||||||
|
}
|
||||||
|
return listingpkg.NormalizeSiteReceiveCostDelta(delta), nil
|
||||||
|
}
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
@@ -333,6 +340,13 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si
|
|||||||
}
|
}
|
||||||
row.ReceiveRatioDelta = delta
|
row.ReceiveRatioDelta = delta
|
||||||
}
|
}
|
||||||
|
if req.ReceiveCostDelta != nil {
|
||||||
|
delta, err := normalizeReceiveCostDelta(*req.ReceiveCostDelta)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.ReceiveCostDelta = delta
|
||||||
|
}
|
||||||
if req.TaxEnabled != nil {
|
if req.TaxEnabled != nil {
|
||||||
row.TaxEnabled = *req.TaxEnabled
|
row.TaxEnabled = *req.TaxEnabled
|
||||||
}
|
}
|
||||||
@@ -385,6 +399,13 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd
|
|||||||
}
|
}
|
||||||
row.ReceiveRatioDelta = delta
|
row.ReceiveRatioDelta = delta
|
||||||
}
|
}
|
||||||
|
if req.ReceiveCostDelta != nil {
|
||||||
|
delta, err := normalizeReceiveCostDelta(*req.ReceiveCostDelta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row.ReceiveCostDelta = delta
|
||||||
|
}
|
||||||
if !globalAccess {
|
if !globalAccess {
|
||||||
if err := tx.Save(&row).Error; err != nil {
|
if err := tx.Save(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -573,6 +594,7 @@ func siteDTO(row model.DistributionSite) SiteDTO {
|
|||||||
DefaultSupportGroupID: row.DefaultSupportGroupID,
|
DefaultSupportGroupID: row.DefaultSupportGroupID,
|
||||||
ListingQrcodeURL: row.ListingQrcodeURL,
|
ListingQrcodeURL: row.ListingQrcodeURL,
|
||||||
ReceiveRatioDelta: row.ReceiveRatioDelta,
|
ReceiveRatioDelta: row.ReceiveRatioDelta,
|
||||||
|
ReceiveCostDelta: row.ReceiveCostDelta,
|
||||||
TaxEnabled: row.TaxEnabled,
|
TaxEnabled: row.TaxEnabled,
|
||||||
TaxRateBPS: row.TaxRateBPS,
|
TaxRateBPS: row.TaxRateBPS,
|
||||||
SettlementHoldDays: row.SettlementHoldDays,
|
SettlementHoldDays: row.SettlementHoldDays,
|
||||||
@@ -2330,6 +2352,7 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string)
|
|||||||
AnnouncementsJSON: site.AnnouncementsJSON,
|
AnnouncementsJSON: site.AnnouncementsJSON,
|
||||||
FooterInfoJSON: site.FooterInfoJSON,
|
FooterInfoJSON: site.FooterInfoJSON,
|
||||||
ReceiveRatioDelta: site.ReceiveRatioDelta,
|
ReceiveRatioDelta: site.ReceiveRatioDelta,
|
||||||
|
ReceiveCostDelta: site.ReceiveCostDelta,
|
||||||
}
|
}
|
||||||
if r.redis != nil {
|
if r.redis != nil {
|
||||||
if bytes, err := json.Marshal(res); err == nil {
|
if bytes, err := json.Marshal(res); err == nil {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func ReceivePricingFromAssetSummary(raw datatypes.JSON) ReceivePricingView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRequest, receiveRatioDelta float64, onDistributionSite bool) error {
|
func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRequest, receiveRatioDelta, receiveCostDelta float64, onDistributionSite bool) error {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -48,11 +48,12 @@ func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
applyMainSiteReceivePricing(req, options, config, receiveRatioDelta, onDistributionSite)
|
applyMainSiteReceivePricing(req, options, config, receiveRatioDelta, receiveCostDelta, onDistributionSite)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.PublishOptionsDTO, config salePriceConfig, receiveRatioDelta float64, onDistributionSite bool) {
|
func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.PublishOptionsDTO, config salePriceConfig, receiveRatioDelta, receiveCostDelta float64, onDistributionSite bool) {
|
||||||
|
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -70,18 +71,20 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis
|
|||||||
if requestedRatio <= 0 {
|
if requestedRatio <= 0 {
|
||||||
requestedRatio = readSummaryNumber(breakdown["accelerated_sale_ratio"])
|
requestedRatio = readSummaryNumber(breakdown["accelerated_sale_ratio"])
|
||||||
}
|
}
|
||||||
|
costDelta := NormalizeSiteReceiveCostDelta(receiveCostDelta)
|
||||||
|
ratioDelta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta)
|
||||||
sellerRatio := clampSaleRatio(referenceRatio, requestedRatio)
|
sellerRatio := clampSaleRatio(referenceRatio, requestedRatio)
|
||||||
delta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta)
|
// 分站新口径:收号比例 = R + 压价点数(收号价更低),买家定价锚定主站参考比例 R,不随收号价联动。
|
||||||
if onDistributionSite {
|
if onDistributionSite {
|
||||||
sellerRatio = ApplySiteReceiveRatioDelta(referenceRatio, delta)
|
sellerRatio = ApplySiteReceiveCostDelta(referenceRatio, costDelta)
|
||||||
}
|
}
|
||||||
coinWan := coinM * 100
|
coinWan := coinM * 100
|
||||||
sellerCoinBasePrice := roundMoney(coinWan / sellerRatio)
|
sellerCoinBasePrice := roundMoney(coinWan / sellerRatio)
|
||||||
consumablePrice := consumableValue(req.AssetSummary)
|
consumablePrice := consumableValue(req.AssetSummary)
|
||||||
sellerTotalPrice := roundMoney(sellerCoinBasePrice + consumablePrice)
|
sellerTotalPrice := roundMoney(sellerCoinBasePrice + consumablePrice)
|
||||||
dailyLoss := readSummaryNumber(req.AssetSummary["daily_loss_m"])
|
dailyLoss := readSummaryNumber(req.AssetSummary["daily_loss_m"])
|
||||||
pricing := calculateExternalPlatformPricing(coinM, sellerRatio, sellerCoinBasePrice, consumablePrice, config)
|
// 买家价始终按主站口径推导:分站只改收号侧,卖价与主站同基准(压价差进入三方分成池)。
|
||||||
|
pricing := calculateExternalPlatformPricing(coinM, referenceRatio, referenceSellerCoinBasePrice(coinWan, referenceRatio), consumablePrice, config)
|
||||||
breakdown["seller_reference_ratio"] = referenceRatio
|
breakdown["seller_reference_ratio"] = referenceRatio
|
||||||
breakdown["seller_ratio"] = sellerRatio
|
breakdown["seller_ratio"] = sellerRatio
|
||||||
breakdown["seller_coin_base_price"] = sellerCoinBasePrice
|
breakdown["seller_coin_base_price"] = sellerCoinBasePrice
|
||||||
@@ -89,7 +92,12 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis
|
|||||||
breakdown["consumable_price"] = consumablePrice
|
breakdown["consumable_price"] = consumablePrice
|
||||||
breakdown["daily_loss_ratio_adjustment"] = calculateDailyLossRatioAdjustment(dailyLoss)
|
breakdown["daily_loss_ratio_adjustment"] = calculateDailyLossRatioAdjustment(dailyLoss)
|
||||||
breakdown["accelerated_sale_ratio"] = sellerRatio
|
breakdown["accelerated_sale_ratio"] = sellerRatio
|
||||||
breakdown["site_receive_ratio_delta"] = delta
|
if onDistributionSite {
|
||||||
|
breakdown["site_receive_cost_delta"] = costDelta
|
||||||
|
breakdown["pricing_model"] = "site_cost_decoupled_v1"
|
||||||
|
} else {
|
||||||
|
breakdown["site_receive_ratio_delta"] = ratioDelta
|
||||||
|
}
|
||||||
breakdown["buyer_coin_base_price"] = pricing.BuyerCoinBasePrice
|
breakdown["buyer_coin_base_price"] = pricing.BuyerCoinBasePrice
|
||||||
breakdown["buyer_total_price"] = pricing.BuyerTotalPrice
|
breakdown["buyer_total_price"] = pricing.BuyerTotalPrice
|
||||||
breakdown["buyer_ratio"] = pricing.BuyerRatio
|
breakdown["buyer_ratio"] = pricing.BuyerRatio
|
||||||
@@ -100,6 +108,36 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis
|
|||||||
req.PriceCent = yuanToCent(pricing.BuyerTotalPrice)
|
req.PriceCent = yuanToCent(pricing.BuyerTotalPrice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeSiteReceiveCostDelta 将分站收号压价点数限制在 [0, 20]。
|
||||||
|
// 点数越大,分站收号比例越高(相对主站参考比例),收号价越低;0 表示与主站同价收号。
|
||||||
|
func NormalizeSiteReceiveCostDelta(delta float64) float64 {
|
||||||
|
if delta < 0 || math.IsNaN(delta) || math.IsInf(delta, 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if delta > maxSiteReceiveRatioDelta {
|
||||||
|
return maxSiteReceiveRatioDelta
|
||||||
|
}
|
||||||
|
return roundRatio(delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplySiteReceiveCostDelta 按新口径计算分站收号比例:R + 压价点数(收号价更低)。
|
||||||
|
// 与旧 ApplySiteReceiveRatioDelta 相反方向,买家价已解耦、不再从收号比例推导。
|
||||||
|
func ApplySiteReceiveCostDelta(referenceRatio, delta float64) float64 {
|
||||||
|
if referenceRatio <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
siteRatio := roundRatio(referenceRatio + NormalizeSiteReceiveCostDelta(delta))
|
||||||
|
return siteRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
// referenceSellerCoinBasePrice 计算主站参考比例下的号主基础价,用作买家定价锚点。
|
||||||
|
func referenceSellerCoinBasePrice(coinWan, referenceRatio float64) float64 {
|
||||||
|
if referenceRatio <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return roundMoney(coinWan / referenceRatio)
|
||||||
|
}
|
||||||
|
|
||||||
// NormalizeSiteReceiveRatioDelta 将分站收号下调点数限制在 [0, 20]。
|
// NormalizeSiteReceiveRatioDelta 将分站收号下调点数限制在 [0, 20]。
|
||||||
func NormalizeSiteReceiveRatioDelta(delta float64) float64 {
|
func NormalizeSiteReceiveRatioDelta(delta float64) float64 {
|
||||||
if delta < 0 || math.IsNaN(delta) || math.IsInf(delta, 0) {
|
if delta < 0 || math.IsNaN(delta) || math.IsInf(delta, 0) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestCalculateSellerReferenceRatioUsesMainSiteConfig(t *testing.T) {
|
|||||||
|
|
||||||
func TestApplyMainSiteReceivePricingOverwritesClientRatio(t *testing.T) {
|
func TestApplyMainSiteReceivePricingOverwritesClientRatio(t *testing.T) {
|
||||||
req := fullReceivePricingRequest(10)
|
req := fullReceivePricingRequest(10)
|
||||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, false)
|
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, false)
|
||||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||||
if readSummaryNumber(breakdown["seller_ratio"]) != 43 {
|
if readSummaryNumber(breakdown["seller_ratio"]) != 43 {
|
||||||
t.Fatalf("seller_ratio = %v, want 43", breakdown["seller_ratio"])
|
t.Fatalf("seller_ratio = %v, want 43", breakdown["seller_ratio"])
|
||||||
@@ -61,7 +61,7 @@ func TestApplyMainSiteReceivePricingOverwritesClientRatio(t *testing.T) {
|
|||||||
|
|
||||||
func TestApplyMainSiteReceivePricingKeepsAcceleratedRatioWithinMainSiteRange(t *testing.T) {
|
func TestApplyMainSiteReceivePricingKeepsAcceleratedRatioWithinMainSiteRange(t *testing.T) {
|
||||||
req := fullReceivePricingRequest(50)
|
req := fullReceivePricingRequest(50)
|
||||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, false)
|
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, false)
|
||||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||||
if readSummaryNumber(breakdown["seller_ratio"]) != 50 {
|
if readSummaryNumber(breakdown["seller_ratio"]) != 50 {
|
||||||
t.Fatalf("accelerated seller_ratio = %v, want 50", breakdown["seller_ratio"])
|
t.Fatalf("accelerated seller_ratio = %v, want 50", breakdown["seller_ratio"])
|
||||||
@@ -71,27 +71,55 @@ func TestApplyMainSiteReceivePricingKeepsAcceleratedRatioWithinMainSiteRange(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDistributionReceiveRatioCannotExceedMainSite(t *testing.T) {
|
func TestDistributionReceiveCostDeltaZeroKeepsMainSitePrice(t *testing.T) {
|
||||||
req := fullReceivePricingRequest(50)
|
req := fullReceivePricingRequest(50)
|
||||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, true)
|
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, true)
|
||||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||||
if readSummaryNumber(breakdown["seller_ratio"]) != 43 {
|
if readSummaryNumber(breakdown["seller_ratio"]) != 43 {
|
||||||
t.Fatalf("distribution seller_ratio = %v, want main-site ceiling 43", breakdown["seller_ratio"])
|
t.Fatalf("distribution seller_ratio = %v, want main-site reference 43 when cost delta is 0", breakdown["seller_ratio"])
|
||||||
|
}
|
||||||
|
if readSummaryNumber(breakdown["buyer_total_price"]) != 263.2 {
|
||||||
|
t.Fatalf("distribution buyer_total_price = %v, want 263.2 anchored to main site", breakdown["buyer_total_price"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDistributionReceiveRatioDeltaLowersBelowMainSite(t *testing.T) {
|
func TestDistributionReceiveCostDeltaLowersReceivePriceKeepsSellPrice(t *testing.T) {
|
||||||
req := fullReceivePricingRequest(50)
|
req := fullReceivePricingRequest(50)
|
||||||
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 3, true)
|
applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 3, true)
|
||||||
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
breakdown := ensurePriceBreakdown(req.AssetSummary)
|
||||||
if readSummaryNumber(breakdown["seller_ratio"]) != 40 {
|
// 压价 3 点:收号比例 = R+3 = 46,号主收 217.4(低于主站 232.6)。
|
||||||
t.Fatalf("distribution seller_ratio = %v, want 40 after lowering 3 points", breakdown["seller_ratio"])
|
if readSummaryNumber(breakdown["seller_ratio"]) != 46 {
|
||||||
|
t.Fatalf("distribution seller_ratio = %v, want 46 (R+3) under cost-decoupled model", breakdown["seller_ratio"])
|
||||||
}
|
}
|
||||||
if readSummaryNumber(breakdown["seller_reference_ratio"]) != 43 {
|
if got := readSummaryNumber(breakdown["seller_coin_base_price"]); got != 217.4 {
|
||||||
t.Fatalf("seller_reference_ratio = %v, want 43", breakdown["seller_reference_ratio"])
|
t.Fatalf("seller_coin_base_price = %v, want 217.4 (lower than main site 232.6)", got)
|
||||||
}
|
}
|
||||||
if readSummaryNumber(breakdown["site_receive_ratio_delta"]) != 3 {
|
// 卖价锚定主站:买家比例仍为 38、买家价仍为 263.2,不随收号价下降。
|
||||||
t.Fatalf("site_receive_ratio_delta = %v, want 3", breakdown["site_receive_ratio_delta"])
|
if readSummaryNumber(breakdown["buyer_ratio"]) != 38 {
|
||||||
|
t.Fatalf("buyer_ratio = %v, want 38 (decoupled from receive ratio)", breakdown["buyer_ratio"])
|
||||||
|
}
|
||||||
|
if readSummaryNumber(breakdown["buyer_total_price"]) != 263.2 {
|
||||||
|
t.Fatalf("buyer_total_price = %v, want 263.2 unchanged by cost delta", breakdown["buyer_total_price"])
|
||||||
|
}
|
||||||
|
// 注意:发布快照的 platform_markup_amount 仍为主站口径提价(30.6,锚定价→买价);
|
||||||
|
// 压价差不在此体现,而是结算层 profit = 净付款 − 号主快照价(217.4)自动进入 35/40/25 分成池。
|
||||||
|
if readSummaryNumber(breakdown["platform_markup_amount"]) != 30.6 {
|
||||||
|
t.Fatalf("platform_markup_amount = %v, want 30.6 (main-site anchored markup)", breakdown["platform_markup_amount"])
|
||||||
|
}
|
||||||
|
if readSummaryNumber(breakdown["site_receive_cost_delta"]) != 3 {
|
||||||
|
t.Fatalf("site_receive_cost_delta = %v, want 3", breakdown["site_receive_cost_delta"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDistributionReceivePriceNeverAboveMainSite(t *testing.T) {
|
||||||
|
mainReq := fullReceivePricingRequest(50)
|
||||||
|
applyMainSiteReceivePricing(&mainReq, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, false)
|
||||||
|
siteReq := fullReceivePricingRequest(50)
|
||||||
|
applyMainSiteReceivePricing(&siteReq, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 10, true)
|
||||||
|
mainPrice := readSummaryNumber(ensurePriceBreakdown(mainReq.AssetSummary)["seller_total_price"])
|
||||||
|
sitePrice := readSummaryNumber(ensurePriceBreakdown(siteReq.AssetSummary)["seller_total_price"])
|
||||||
|
if sitePrice >= mainPrice {
|
||||||
|
t.Fatalf("distribution receive price %v must stay below main-site %v (dead boundary)", sitePrice, mainPrice)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest,
|
|||||||
if len(siteCode) > 0 {
|
if len(siteCode) > 0 {
|
||||||
effectiveSiteCode = strings.TrimSpace(siteCode[0])
|
effectiveSiteCode = strings.TrimSpace(siteCode[0])
|
||||||
}
|
}
|
||||||
delta, onDistribution := s.repo.ResolveReceiveRatioDelta(ctx, 0, effectiveSiteCode)
|
delta, onDistribution := s.repo.resolveSiteReceiveCostDelta(ctx, 0, effectiveSiteCode)
|
||||||
if err := s.applyMainSiteReceivePricing(ctx, &req, delta, onDistribution); err != nil {
|
if err := s.applyMainSiteReceivePricing(ctx, &req, 0, delta, onDistribution); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rules, err := s.publishRules(ctx)
|
rules, err := s.publishRules(ctx)
|
||||||
@@ -49,8 +49,8 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd
|
|||||||
if len(siteCode) > 0 {
|
if len(siteCode) > 0 {
|
||||||
effectiveSiteCode = strings.TrimSpace(siteCode[0])
|
effectiveSiteCode = strings.TrimSpace(siteCode[0])
|
||||||
}
|
}
|
||||||
delta, onDistribution := s.repo.ResolveReceiveRatioDelta(ctx, id, effectiveSiteCode)
|
delta, onDistribution := s.repo.resolveSiteReceiveCostDelta(ctx, id, effectiveSiteCode)
|
||||||
if err := s.applyMainSiteReceivePricing(ctx, &req, delta, onDistribution); err != nil {
|
if err := s.applyMainSiteReceivePricing(ctx, &req, 0, delta, onDistribution); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rules, err := s.publishRules(ctx)
|
rules, err := s.publishRules(ctx)
|
||||||
|
|||||||
@@ -1116,7 +1116,7 @@ func TestCreateDistributionListingUsesMainSiteReceiveRatio(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateDistributionListingAppliesSiteReceiveRatioDelta(t *testing.T) {
|
func TestCreateDistributionListingAppliesSiteReceiveCostDelta(t *testing.T) {
|
||||||
db := database.NewTestDB()
|
db := database.NewTestDB()
|
||||||
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
|
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
|
||||||
t.Fatalf("migrate err: %v", err)
|
t.Fatalf("migrate err: %v", err)
|
||||||
@@ -1129,11 +1129,11 @@ func TestCreateDistributionListingAppliesSiteReceiveRatioDelta(t *testing.T) {
|
|||||||
}
|
}
|
||||||
site := model.DistributionSite{
|
site := model.DistributionSite{
|
||||||
SiteCode: "dist_ratio_down",
|
SiteCode: "dist_ratio_down",
|
||||||
SiteName: "下调收号站",
|
SiteName: "压价收号站",
|
||||||
Domain: "ratio-down.example.com",
|
Domain: "ratio-down.example.com",
|
||||||
Status: "active",
|
Status: "active",
|
||||||
DisplayAccountEnabled: true,
|
DisplayAccountEnabled: true,
|
||||||
ReceiveRatioDelta: 3,
|
ReceiveCostDelta: 3,
|
||||||
}
|
}
|
||||||
if err := db.Create(&site).Error; err != nil {
|
if err := db.Create(&site).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -1169,8 +1169,8 @@ func TestCreateDistributionListingAppliesSiteReceiveRatioDelta(t *testing.T) {
|
|||||||
t.Fatalf("Create failed: %v", err)
|
t.Fatalf("Create failed: %v", err)
|
||||||
}
|
}
|
||||||
pricing := ReceivePricingFromAssetSummary(mustMarshalSummary(t, dto.AssetSummary))
|
pricing := ReceivePricingFromAssetSummary(mustMarshalSummary(t, dto.AssetSummary))
|
||||||
if pricing.SellerRatio != 40 {
|
if pricing.SellerRatio != 46 {
|
||||||
t.Fatalf("seller_ratio = %v, want 40 after site delta 3 below main-site 43", pricing.SellerRatio)
|
t.Fatalf("seller_ratio = %v, want 46 after site cost delta 3 (R=43+3) under decoupled model", pricing.SellerRatio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,48 @@ import (
|
|||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ReceiveCostDeltaBySiteCode 读取分站收号压价点数(新口径,收号比例 = R + 点数)。
|
||||||
|
func (r *Repository) ReceiveCostDeltaBySiteCode(ctx context.Context, siteCode string) float64 {
|
||||||
|
siteCode = strings.TrimSpace(siteCode)
|
||||||
|
if r == nil || r.db == nil || siteCode == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var delta float64
|
||||||
|
_ = r.db.WithContext(ctx).Model(&model.DistributionSite{}).
|
||||||
|
Select("COALESCE(receive_cost_delta, 0)").
|
||||||
|
Where("site_code = ? AND status = 'active'", siteCode).
|
||||||
|
Limit(1).
|
||||||
|
Scan(&delta)
|
||||||
|
return NormalizeSiteReceiveCostDelta(delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSiteReceiveCostDelta 按站点编码或商品归属站点解析压价点数。
|
||||||
|
// 返回 onDistribution 表示当前上下文是否为分站(false 时压价不生效)。
|
||||||
|
func (r *Repository) resolveSiteReceiveCostDelta(ctx context.Context, listingID uint64, siteCode string) (float64, bool) {
|
||||||
|
if code := strings.TrimSpace(siteCode); code != "" {
|
||||||
|
return r.ReceiveCostDeltaBySiteCode(ctx, code), true
|
||||||
|
}
|
||||||
|
if r == nil || r.db == nil || listingID == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var siteID *uint64
|
||||||
|
if err := r.db.WithContext(ctx).Model(&model.RentalListing{}).
|
||||||
|
Select("distribution_site_id").
|
||||||
|
Where("id = ?", listingID).
|
||||||
|
Limit(1).
|
||||||
|
Scan(&siteID).Error; err != nil || siteID == nil || *siteID == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var delta float64
|
||||||
|
_ = r.db.WithContext(ctx).Model(&model.DistributionSite{}).
|
||||||
|
Select("COALESCE(receive_cost_delta, 0)").
|
||||||
|
Where("id = ? AND status = 'active'", *siteID).
|
||||||
|
Limit(1).
|
||||||
|
Scan(&delta)
|
||||||
|
return NormalizeSiteReceiveCostDelta(delta), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveRatioDeltaBySiteCode 旧口径(加价抢货)读取,保留只读兼容。
|
||||||
func (r *Repository) ReceiveRatioDeltaBySiteCode(ctx context.Context, siteCode string) float64 {
|
func (r *Repository) ReceiveRatioDeltaBySiteCode(ctx context.Context, siteCode string) float64 {
|
||||||
siteCode = strings.TrimSpace(siteCode)
|
siteCode = strings.TrimSpace(siteCode)
|
||||||
if r == nil || r.db == nil || siteCode == "" {
|
if r == nil || r.db == nil || siteCode == "" {
|
||||||
@@ -21,6 +63,7 @@ func (r *Repository) ReceiveRatioDeltaBySiteCode(ctx context.Context, siteCode s
|
|||||||
return NormalizeSiteReceiveRatioDelta(delta)
|
return NormalizeSiteReceiveRatioDelta(delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveReceiveRatioDelta 旧口径解析入口,保留供旧快照读侧使用。
|
||||||
func (r *Repository) ResolveReceiveRatioDelta(ctx context.Context, listingID uint64, siteCode string) (float64, bool) {
|
func (r *Repository) ResolveReceiveRatioDelta(ctx context.Context, listingID uint64, siteCode string) (float64, bool) {
|
||||||
if code := strings.TrimSpace(siteCode); code != "" {
|
if code := strings.TrimSpace(siteCode); code != "" {
|
||||||
return r.ReceiveRatioDeltaBySiteCode(ctx, code), true
|
return r.ReceiveRatioDeltaBySiteCode(ctx, code), true
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- 分站收号压价点数(新口径):收号比例 = 主站参考比例 R + cost_delta,比例变大 → 收号价更低。
|
||||||
|
-- 卖出价不再跟随收号价联动,锚定主站参考比例推导(解耦),压价差进入 35/40/25 三方分成池。
|
||||||
|
-- 旧字段 receive_ratio_delta(加价抢货口径)保留只读,新单不再使用。
|
||||||
|
ALTER TABLE distribution_sites
|
||||||
|
ADD COLUMN receive_cost_delta DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '分站收号压价点数:收号比例=主站参考比例+点数,点数越大收号价越低,上限20' AFTER receive_ratio_delta;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE distribution_sites
|
||||||
|
DROP COLUMN receive_cost_delta;
|
||||||
@@ -129,6 +129,7 @@ export interface Site {
|
|||||||
footer_info_json?: string
|
footer_info_json?: string
|
||||||
listing_qrcode_url?: string
|
listing_qrcode_url?: string
|
||||||
receive_ratio_delta?: number
|
receive_ratio_delta?: number
|
||||||
|
receive_cost_delta?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +339,10 @@ export async function listSites(): Promise<Site[]> {
|
|||||||
return extract(resp).items ?? []
|
return extract(resp).items ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSite(siteID: number, payload: Partial<Pick<Site, 'receive_ratio_delta'>>): Promise<Site> {
|
export async function updateSite(
|
||||||
|
siteID: number,
|
||||||
|
payload: Partial<Pick<Site, 'receive_ratio_delta' | 'receive_cost_delta'>>
|
||||||
|
): Promise<Site> {
|
||||||
const resp = await api.put<ApiResponse<Site>>(`/distribution/sites/${siteID}`, payload)
|
const resp = await api.put<ApiResponse<Site>>(`/distribution/sites/${siteID}`, payload)
|
||||||
return extract(resp)
|
return extract(resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export default function SiteSettings() {
|
|||||||
icp: '',
|
icp: '',
|
||||||
contact: '',
|
contact: '',
|
||||||
})
|
})
|
||||||
const [receiveRatioDelta, setReceiveRatioDelta] = useState(0)
|
const [receiveCostDelta, setReceiveCostDelta] = useState(0)
|
||||||
const [savingRatio, setSavingRatio] = useState(false)
|
const [savingRatio, setSavingRatio] = useState(false)
|
||||||
|
|
||||||
// SMS config state
|
// SMS config state
|
||||||
@@ -140,7 +140,7 @@ export default function SiteSettings() {
|
|||||||
} catch {
|
} catch {
|
||||||
setFooterInfo({})
|
setFooterInfo({})
|
||||||
}
|
}
|
||||||
setReceiveRatioDelta(Math.max(0, found.receive_ratio_delta || 0))
|
setReceiveCostDelta(Math.max(0, found.receive_cost_delta || 0))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sms = await fetchSiteSMSConfig(id)
|
const sms = await fetchSiteSMSConfig(id)
|
||||||
@@ -234,10 +234,10 @@ export default function SiteSettings() {
|
|||||||
if (!siteID) return
|
if (!siteID) return
|
||||||
setSavingRatio(true)
|
setSavingRatio(true)
|
||||||
try {
|
try {
|
||||||
const updated = await updateSite(siteID, { receive_ratio_delta: receiveRatioDelta })
|
const updated = await updateSite(siteID, { receive_cost_delta: receiveCostDelta })
|
||||||
setCurrentSite(updated)
|
setCurrentSite(updated)
|
||||||
setReceiveRatioDelta(Math.max(0, updated.receive_ratio_delta || 0))
|
setReceiveCostDelta(Math.max(0, updated.receive_cost_delta || 0))
|
||||||
message.success('收号比例已保存,本站上传账号将按新比例计价')
|
message.success('收号压价已保存,本站收号价比主站更低,售价与主站一致')
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
|
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
|
||||||
message.error(respMessage || '保存收号比例失败')
|
message.error(respMessage || '保存收号比例失败')
|
||||||
@@ -421,26 +421,26 @@ export default function SiteSettings() {
|
|||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={5} style={{ marginBottom: 4 }}>
|
<Typography.Title level={5} style={{ marginBottom: 4 }}>
|
||||||
本站收号比例
|
本站收号压价
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
主站回收比例是上限。下调点数越大,号主底价越高、本站利润空间越大,但不能高于主站比例。0 表示与主站一致。
|
压价点数越大,号主在本站拿到的收号价越低。买家售价保持与主站一致,压价差进入三方分成(本站 35% / 卖出方 40% / 平台 25%)。0 表示与主站同价收号。
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Space wrap align="center">
|
<Space wrap align="center">
|
||||||
<Typography.Text>相对主站下调</Typography.Text>
|
<Typography.Text>收号价比主站低</Typography.Text>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={0}
|
min={0}
|
||||||
max={20}
|
max={20}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
precision={1}
|
precision={1}
|
||||||
value={receiveRatioDelta}
|
value={receiveCostDelta}
|
||||||
onChange={(value) => setReceiveRatioDelta(Math.max(0, Number(value || 0)))}
|
onChange={(value) => setReceiveCostDelta(Math.max(0, Number(value || 0)))}
|
||||||
addonAfter="点"
|
addonAfter="点"
|
||||||
style={{ width: 160 }}
|
style={{ width: 160 }}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" icon={<SaveOutlined />} loading={savingRatio} onClick={() => void handleSaveReceiveRatio()}>
|
<Button type="primary" icon={<SaveOutlined />} loading={savingRatio} onClick={() => void handleSaveReceiveRatio()}>
|
||||||
保存收号比例
|
保存收号压价
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface DistributionSite {
|
|||||||
settlement_hold_days: number
|
settlement_hold_days: number
|
||||||
listing_qrcode_url?: string
|
listing_qrcode_url?: string
|
||||||
receive_ratio_delta?: number
|
receive_ratio_delta?: number
|
||||||
|
receive_cost_delta?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ export async function updateDistributionSite(
|
|||||||
| 'settlement_hold_days'
|
| 'settlement_hold_days'
|
||||||
| 'listing_qrcode_url'
|
| 'listing_qrcode_url'
|
||||||
| 'receive_ratio_delta'
|
| 'receive_ratio_delta'
|
||||||
|
| 'receive_cost_delta'
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const receiveRatioSubmitting = ref(false)
|
|||||||
const receiveRatioForm = reactive({
|
const receiveRatioForm = reactive({
|
||||||
site_id: 0,
|
site_id: 0,
|
||||||
site_name: '',
|
site_name: '',
|
||||||
receive_ratio_delta: 0,
|
receive_cost_delta: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const qrDialogVisible = ref(false)
|
const qrDialogVisible = ref(false)
|
||||||
@@ -136,7 +136,7 @@ async function handleSettlementSubmit() {
|
|||||||
function handleOpenReceiveRatio(row: DistributionSite) {
|
function handleOpenReceiveRatio(row: DistributionSite) {
|
||||||
receiveRatioForm.site_id = row.id
|
receiveRatioForm.site_id = row.id
|
||||||
receiveRatioForm.site_name = row.site_name
|
receiveRatioForm.site_name = row.site_name
|
||||||
receiveRatioForm.receive_ratio_delta = Number(row.receive_ratio_delta || 0)
|
receiveRatioForm.receive_cost_delta = Number(row.receive_cost_delta || 0)
|
||||||
receiveRatioDialogVisible.value = true
|
receiveRatioDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,9 +144,9 @@ async function handleReceiveRatioSubmit() {
|
|||||||
receiveRatioSubmitting.value = true
|
receiveRatioSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await updateDistributionSite(receiveRatioForm.site_id, {
|
await updateDistributionSite(receiveRatioForm.site_id, {
|
||||||
receive_ratio_delta: Number(receiveRatioForm.receive_ratio_delta),
|
receive_cost_delta: Number(receiveRatioForm.receive_cost_delta),
|
||||||
})
|
})
|
||||||
ElMessage.success(`【${receiveRatioForm.site_name}】收号比例下调已更新`)
|
ElMessage.success(`【${receiveRatioForm.site_name}】收号压价已更新`)
|
||||||
receiveRatioDialogVisible.value = false
|
receiveRatioDialogVisible.value = false
|
||||||
await loadSites()
|
await loadSites()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -530,15 +530,15 @@ onMounted(() => {
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="收号下调" width="110">
|
<el-table-column label="收号压价" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<el-tag
|
||||||
:type="Number(row.receive_ratio_delta || 0) > 0 ? 'warning' : 'info'"
|
:type="Number(row.receive_cost_delta || 0) > 0 ? 'warning' : 'info'"
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
Number(row.receive_ratio_delta || 0) > 0
|
Number(row.receive_cost_delta || 0) > 0
|
||||||
? `-${Number(row.receive_ratio_delta).toFixed(1)} 点`
|
? `低 ${Number(row.receive_cost_delta).toFixed(1)} 点`
|
||||||
: '与主站一致'
|
: '与主站一致'
|
||||||
}}
|
}}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
@@ -758,10 +758,10 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 分站收号比例(主站为上限) -->
|
<!-- 分站收号压价(收号价始终不高于主站参考价) -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="receiveRatioDialogVisible"
|
v-model="receiveRatioDialogVisible"
|
||||||
title="分站收号比例"
|
title="分站收号压价"
|
||||||
width="520px"
|
width="520px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
@@ -769,16 +769,16 @@ onMounted(() => {
|
|||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
title="主站回收比例是上限。下调点数越大,号主底价越高、分站利润空间越大,但不能高于主站比例。0 表示与主站一致。"
|
title="分站收号价比主站更低:压价点数越大,号主在分站拿到的收号价越低。买家售价保持与主站一致,压价差进入三方分成(35% 收号 / 40% 卖出 / 25% 服务费)。0 表示与主站同价收号。"
|
||||||
style="margin-bottom: 16px"
|
style="margin-bottom: 16px"
|
||||||
/>
|
/>
|
||||||
<el-form label-width="110px">
|
<el-form label-width="110px">
|
||||||
<el-form-item label="分销站点">
|
<el-form-item label="分销站点">
|
||||||
<el-input :model-value="receiveRatioForm.site_name" disabled />
|
<el-input :model-value="receiveRatioForm.site_name" disabled />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="相对主站下调">
|
<el-form-item label="收号价比主站低">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="receiveRatioForm.receive_ratio_delta"
|
v-model="receiveRatioForm.receive_cost_delta"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="20"
|
:max="20"
|
||||||
:step="0.1"
|
:step="0.1"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
const siteStore = useDistributionSiteStore()
|
const siteStore = useDistributionSiteStore()
|
||||||
const isDistribution = computed(() => siteStore.isDistribution)
|
const isDistribution = computed(() => siteStore.isDistribution)
|
||||||
const receiveRatioDelta = computed(() => Number(siteStore.receiveRatioDelta || 0))
|
const receiveRatioDelta = computed(() => Number(siteStore.receiveRatioDelta || 0))
|
||||||
|
const receiveCostDelta = computed(() => Number(siteStore.receiveCostDelta || 0))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const suppressDraftSave = ref(false)
|
const suppressDraftSave = ref(false)
|
||||||
@@ -95,6 +96,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
selectedSkins,
|
selectedSkins,
|
||||||
isDistribution,
|
isDistribution,
|
||||||
receiveRatioDelta,
|
receiveRatioDelta,
|
||||||
|
receiveCostDelta,
|
||||||
})
|
})
|
||||||
|
|
||||||
// ========== 上号方式互斥 ==========
|
// ========== 上号方式互斥 ==========
|
||||||
|
|||||||
@@ -19,11 +19,10 @@ import {
|
|||||||
hasAcceleratedSaleRatioInput as hasAcceleratedSaleRatioValue,
|
hasAcceleratedSaleRatioInput as hasAcceleratedSaleRatioValue,
|
||||||
isQuantityItemDisabledForInsurance,
|
isQuantityItemDisabledForInsurance,
|
||||||
readFinalSaleRatio,
|
readFinalSaleRatio,
|
||||||
applySiteReceiveRatioDelta,
|
applySiteReceiveCostDelta,
|
||||||
roundMoney,
|
roundMoney,
|
||||||
roundRatio,
|
roundRatio,
|
||||||
} from '@/shared/utils/pricing'
|
} from '@/shared/utils/pricing'
|
||||||
|
|
||||||
export function usePricingCalculator(options: {
|
export function usePricingCalculator(options: {
|
||||||
publishOptions: Ref<ListingPublishOptions>
|
publishOptions: Ref<ListingPublishOptions>
|
||||||
salePriceConfig: Ref<PublishSalePriceConfig>
|
salePriceConfig: Ref<PublishSalePriceConfig>
|
||||||
@@ -34,6 +33,7 @@ export function usePricingCalculator(options: {
|
|||||||
selectedSkins: Ref<string[]>
|
selectedSkins: Ref<string[]>
|
||||||
isDistribution?: Ref<boolean>
|
isDistribution?: Ref<boolean>
|
||||||
receiveRatioDelta?: Ref<number>
|
receiveRatioDelta?: Ref<number>
|
||||||
|
receiveCostDelta?: Ref<number>
|
||||||
}) {
|
}) {
|
||||||
const serverOptions = computed(() => options.publishOptions.value.server_options)
|
const serverOptions = computed(() => options.publishOptions.value.server_options)
|
||||||
const faceOptions = computed(() => options.publishOptions.value.face_options)
|
const faceOptions = computed(() => options.publishOptions.value.face_options)
|
||||||
@@ -91,12 +91,17 @@ export function usePricingCalculator(options: {
|
|||||||
const siteReceiveRatioDelta = computed(() =>
|
const siteReceiveRatioDelta = computed(() =>
|
||||||
Math.max(0, Number(options.receiveRatioDelta?.value || 0))
|
Math.max(0, Number(options.receiveRatioDelta?.value || 0))
|
||||||
)
|
)
|
||||||
|
const siteReceiveCostDelta = computed(() =>
|
||||||
|
Math.max(0, Number(options.receiveCostDelta?.value || 0))
|
||||||
|
)
|
||||||
|
// 分站新口径:收号价按「R + 压价点数」计算(压价收号);买家定价仍按主站口径推导。
|
||||||
|
// 买卖两侧解耦:calculatedRatio 决定号主收号价,anchorRatio 决定买家价基准。
|
||||||
const calculatedRatio = computed(() => {
|
const calculatedRatio = computed(() => {
|
||||||
if (calculatedDefaultSaleRatio.value <= 0) return 0
|
if (calculatedDefaultSaleRatio.value <= 0) return 0
|
||||||
if (options.isDistribution?.value) {
|
if (options.isDistribution?.value) {
|
||||||
return applySiteReceiveRatioDelta(
|
return applySiteReceiveCostDelta(
|
||||||
calculatedDefaultSaleRatio.value,
|
calculatedDefaultSaleRatio.value,
|
||||||
siteReceiveRatioDelta.value
|
siteReceiveCostDelta.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return readFinalSaleRatio(
|
return readFinalSaleRatio(
|
||||||
@@ -105,10 +110,19 @@ export function usePricingCalculator(options: {
|
|||||||
maxAcceleratedSaleRatio.value
|
maxAcceleratedSaleRatio.value
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
const anchorRatio = computed(() => {
|
||||||
|
if (calculatedDefaultSaleRatio.value <= 0) return 0
|
||||||
|
if (options.isDistribution?.value) return calculatedDefaultSaleRatio.value
|
||||||
|
return calculatedRatio.value
|
||||||
|
})
|
||||||
const calculatedCoinBasePrice = computed(() => {
|
const calculatedCoinBasePrice = computed(() => {
|
||||||
if (calculatedRatio.value <= 0) return 0
|
if (calculatedRatio.value <= 0) return 0
|
||||||
return roundMoney(coinWanAmount.value / calculatedRatio.value)
|
return roundMoney(coinWanAmount.value / calculatedRatio.value)
|
||||||
})
|
})
|
||||||
|
const anchorCoinBasePrice = computed(() => {
|
||||||
|
if (anchorRatio.value <= 0) return 0
|
||||||
|
return roundMoney(coinWanAmount.value / anchorRatio.value)
|
||||||
|
})
|
||||||
const calculatedConsumablePrice = computed(() =>
|
const calculatedConsumablePrice = computed(() =>
|
||||||
calculateConsumablePrice({
|
calculateConsumablePrice({
|
||||||
quantityItems: quantityItems.value,
|
quantityItems: quantityItems.value,
|
||||||
@@ -122,13 +136,18 @@ export function usePricingCalculator(options: {
|
|||||||
? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value)
|
? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value)
|
||||||
: 0
|
: 0
|
||||||
)
|
)
|
||||||
|
const anchorSellerPrice = computed(() =>
|
||||||
|
anchorRatio.value > 0
|
||||||
|
? roundMoney(anchorCoinBasePrice.value + calculatedConsumablePrice.value)
|
||||||
|
: 0
|
||||||
|
)
|
||||||
const calculatedPlatformPricing = computed(() =>
|
const calculatedPlatformPricing = computed(() =>
|
||||||
calculatePlatformPricing({
|
calculatePlatformPricing({
|
||||||
coinMAmount: coinMAmount.value,
|
coinMAmount: coinMAmount.value,
|
||||||
coinWanAmount: coinWanAmount.value,
|
coinWanAmount: coinWanAmount.value,
|
||||||
sellerRatio: calculatedRatio.value,
|
sellerRatio: anchorRatio.value,
|
||||||
sellerCoinBasePrice: calculatedCoinBasePrice.value,
|
sellerCoinBasePrice: anchorCoinBasePrice.value,
|
||||||
sellerTotalPrice: calculatedSellerPrice.value,
|
sellerTotalPrice: anchorSellerPrice.value,
|
||||||
consumablePrice: calculatedConsumablePrice.value,
|
consumablePrice: calculatedConsumablePrice.value,
|
||||||
salePriceConfig: options.salePriceConfig.value,
|
salePriceConfig: options.salePriceConfig.value,
|
||||||
})
|
})
|
||||||
@@ -212,6 +231,7 @@ export function usePricingCalculator(options: {
|
|||||||
calculatedDefaultSaleRatio,
|
calculatedDefaultSaleRatio,
|
||||||
maxAcceleratedSaleRatio,
|
maxAcceleratedSaleRatio,
|
||||||
siteReceiveRatioDelta,
|
siteReceiveRatioDelta,
|
||||||
|
siteReceiveCostDelta,
|
||||||
calculatedRatio,
|
calculatedRatio,
|
||||||
calculatedCoinBasePrice,
|
calculatedCoinBasePrice,
|
||||||
calculatedConsumablePrice,
|
calculatedConsumablePrice,
|
||||||
|
|||||||
@@ -165,6 +165,16 @@ export function applySiteReceiveRatioDelta(referenceRatio: number, delta: number
|
|||||||
return roundRatio(Math.max(referenceRatio - safeDelta, 0.01))
|
return roundRatio(Math.max(referenceRatio - safeDelta, 0.01))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分站收号压价(新口径):收号比例 = 主站参考比例 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 | '' | null | undefined) {
|
export function hasAcceleratedSaleRatioInput(value: number | '' | null | undefined) {
|
||||||
const ratio = Number(value)
|
const ratio = Number(value)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface DistributionSiteInfo {
|
|||||||
announcements_json?: string
|
announcements_json?: string
|
||||||
footer_info_json?: string
|
footer_info_json?: string
|
||||||
receive_ratio_delta?: number
|
receive_ratio_delta?: number
|
||||||
|
receive_cost_delta?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +75,7 @@ export const useDistributionSiteStore = defineStore('distributionSite', {
|
|||||||
announcements: [] as string[],
|
announcements: [] as string[],
|
||||||
footerInfo: null as DistributionFooterInfo | null,
|
footerInfo: null as DistributionFooterInfo | null,
|
||||||
receiveRatioDelta: 0,
|
receiveRatioDelta: 0,
|
||||||
|
receiveCostDelta: 0,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -178,6 +180,7 @@ export const useDistributionSiteStore = defineStore('distributionSite', {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.receiveRatioDelta = Math.max(0, Number(info.receive_ratio_delta || 0))
|
this.receiveRatioDelta = Math.max(0, Number(info.receive_ratio_delta || 0))
|
||||||
|
this.receiveCostDelta = Math.max(0, Number(info.receive_cost_delta || 0))
|
||||||
|
|
||||||
sessionStorage.setItem('hfb_site_code', this.siteCode)
|
sessionStorage.setItem('hfb_site_code', this.siteCode)
|
||||||
sessionStorage.setItem('hfb_site_name', this.siteName)
|
sessionStorage.setItem('hfb_site_name', this.siteName)
|
||||||
@@ -217,6 +220,7 @@ export const useDistributionSiteStore = defineStore('distributionSite', {
|
|||||||
this.announcements = []
|
this.announcements = []
|
||||||
this.footerInfo = null
|
this.footerInfo = null
|
||||||
this.receiveRatioDelta = 0
|
this.receiveRatioDelta = 0
|
||||||
|
this.receiveCostDelta = 0
|
||||||
sessionStorage.removeItem('hfb_site_code')
|
sessionStorage.removeItem('hfb_site_code')
|
||||||
sessionStorage.removeItem('hfb_site_name')
|
sessionStorage.removeItem('hfb_site_name')
|
||||||
localStorage.removeItem('hfb_site_code')
|
localStorage.removeItem('hfb_site_code')
|
||||||
|
|||||||
Reference in New Issue
Block a user