From 22fcaa5945ff11d7520015e2433cd34c1fbfe63f Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 17 Sep 2026 15:54:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E5=88=86=E7=AB=99?= =?UTF-8?q?=E6=94=B6=E5=8F=B7=E5=8E=8B=E4=BB=B7=E8=A7=A3=E8=80=A6=E4=B9=B0?= =?UTF-8?q?=E5=AE=B6=E4=BB=B7=EF=BC=88=E6=96=B9=E6=A1=88=20B=20=E6=96=B0?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心语义反转: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 --- backend/internal/model/distribution.go | 1 + backend/internal/modules/distribution/dto.go | 4 ++ .../modules/distribution/repository.go | 23 ++++++++ .../modules/listing/receive_pricing.go | 54 ++++++++++++++++--- .../modules/listing/receive_pricing_test.go | 54 ++++++++++++++----- .../modules/listing/service_mutation.go | 8 +-- .../internal/modules/listing/service_test.go | 10 ++-- .../internal/modules/listing/site_receive.go | 43 +++++++++++++++ ...2_distribution_site_receive_cost_delta.sql | 12 +++++ distribution-admin/src/api.ts | 6 ++- distribution-admin/src/pages/SiteSettings.tsx | 22 ++++---- .../features/admin/api/adminDistribution.ts | 2 + .../views/AdminDistributionSitesView.vue | 26 ++++----- .../seller/composables/usePublishForm.ts | 2 + .../composables/usePricingCalculator.ts | 34 +++++++++--- frontend/src/shared/utils/pricing.ts | 10 ++++ frontend/src/stores/distributionSite.ts | 4 ++ 17 files changed, 253 insertions(+), 62 deletions(-) create mode 100644 backend/migrations/000072_distribution_site_receive_cost_delta.sql diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go index 3570654..48038a3 100644 --- a/backend/internal/model/distribution.go +++ b/backend/internal/model/distribution.go @@ -19,6 +19,7 @@ type DistributionSite struct { ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"` SMSConfigJSON string `gorm:"type:text" json:"-"` 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"` TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"` SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"` diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index 7cdedb1..ef64a3b 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -17,6 +17,7 @@ type SiteDTO struct { DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` ListingQrcodeURL string `json:"listing_qrcode_url"` ReceiveRatioDelta float64 `json:"receive_ratio_delta"` + ReceiveCostDelta float64 `json:"receive_cost_delta"` TaxEnabled bool `json:"tax_enabled"` TaxRateBPS int64 `json:"tax_rate_bps"` SettlementHoldDays int `json:"settlement_hold_days"` @@ -32,6 +33,7 @@ type CreateSiteRequest struct { DefaultSupportGroupID *uint64 `json:"default_support_group_id"` ListingQrcodeURL *string `json:"listing_qrcode_url"` ReceiveRatioDelta *float64 `json:"receive_ratio_delta"` + ReceiveCostDelta *float64 `json:"receive_cost_delta"` TaxEnabled *bool `json:"tax_enabled"` TaxRateBPS *int64 `json:"tax_rate_bps"` SettlementHoldDays *int `json:"settlement_hold_days"` @@ -45,6 +47,7 @@ type UpdateSiteRequest struct { DefaultSupportGroupID *uint64 `json:"default_support_group_id"` ListingQrcodeURL *string `json:"listing_qrcode_url"` ReceiveRatioDelta *float64 `json:"receive_ratio_delta"` + ReceiveCostDelta *float64 `json:"receive_cost_delta"` TaxEnabled *bool `json:"tax_enabled"` TaxRateBPS *int64 `json:"tax_rate_bps"` SettlementHoldDays *int `json:"settlement_hold_days"` @@ -248,6 +251,7 @@ type PublicSiteInfoDTO struct { FooterInfoJSON string `json:"footer_info_json"` DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` ReceiveRatioDelta float64 `json:"receive_ratio_delta"` + ReceiveCostDelta float64 `json:"receive_cost_delta"` } type UpdateAccountDisplayRequest struct { diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 44c33b0..57fab80 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -73,6 +73,13 @@ func normalizeReceiveRatioDelta(delta float64) (float64, error) { 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 { db *gorm.DB redis *redis.Client @@ -333,6 +340,13 @@ func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*Si } 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 { row.TaxEnabled = *req.TaxEnabled } @@ -385,6 +399,13 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd } row.ReceiveRatioDelta = delta } + if req.ReceiveCostDelta != nil { + delta, err := normalizeReceiveCostDelta(*req.ReceiveCostDelta) + if err != nil { + return err + } + row.ReceiveCostDelta = delta + } if !globalAccess { if err := tx.Save(&row).Error; err != nil { return err @@ -573,6 +594,7 @@ func siteDTO(row model.DistributionSite) SiteDTO { DefaultSupportGroupID: row.DefaultSupportGroupID, ListingQrcodeURL: row.ListingQrcodeURL, ReceiveRatioDelta: row.ReceiveRatioDelta, + ReceiveCostDelta: row.ReceiveCostDelta, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, SettlementHoldDays: row.SettlementHoldDays, @@ -2330,6 +2352,7 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) AnnouncementsJSON: site.AnnouncementsJSON, FooterInfoJSON: site.FooterInfoJSON, ReceiveRatioDelta: site.ReceiveRatioDelta, + ReceiveCostDelta: site.ReceiveCostDelta, } if r.redis != nil { if bytes, err := json.Marshal(res); err == nil { diff --git a/backend/internal/modules/listing/receive_pricing.go b/backend/internal/modules/listing/receive_pricing.go index b515d07..c81f299 100644 --- a/backend/internal/modules/listing/receive_pricing.go +++ b/backend/internal/modules/listing/receive_pricing.go @@ -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 { return nil } @@ -48,11 +48,12 @@ func (s *Service) applyMainSiteReceivePricing(ctx context.Context, req *CreateRe if err != nil { return err } - applyMainSiteReceivePricing(req, options, config, receiveRatioDelta, onDistributionSite) + applyMainSiteReceivePricing(req, options, config, receiveRatioDelta, receiveCostDelta, onDistributionSite) 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 { return } @@ -70,18 +71,20 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis if requestedRatio <= 0 { requestedRatio = readSummaryNumber(breakdown["accelerated_sale_ratio"]) } + costDelta := NormalizeSiteReceiveCostDelta(receiveCostDelta) + ratioDelta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta) sellerRatio := clampSaleRatio(referenceRatio, requestedRatio) - delta := NormalizeSiteReceiveRatioDelta(receiveRatioDelta) + // 分站新口径:收号比例 = R + 压价点数(收号价更低),买家定价锚定主站参考比例 R,不随收号价联动。 if onDistributionSite { - sellerRatio = ApplySiteReceiveRatioDelta(referenceRatio, delta) + sellerRatio = ApplySiteReceiveCostDelta(referenceRatio, costDelta) } coinWan := coinM * 100 sellerCoinBasePrice := roundMoney(coinWan / sellerRatio) consumablePrice := consumableValue(req.AssetSummary) sellerTotalPrice := roundMoney(sellerCoinBasePrice + consumablePrice) 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_ratio"] = sellerRatio breakdown["seller_coin_base_price"] = sellerCoinBasePrice @@ -89,7 +92,12 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis breakdown["consumable_price"] = consumablePrice breakdown["daily_loss_ratio_adjustment"] = calculateDailyLossRatioAdjustment(dailyLoss) 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_total_price"] = pricing.BuyerTotalPrice breakdown["buyer_ratio"] = pricing.BuyerRatio @@ -100,6 +108,36 @@ func applyMainSiteReceivePricing(req *CreateRequest, options systemconfig.Publis 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]。 func NormalizeSiteReceiveRatioDelta(delta float64) float64 { if delta < 0 || math.IsNaN(delta) || math.IsInf(delta, 0) { diff --git a/backend/internal/modules/listing/receive_pricing_test.go b/backend/internal/modules/listing/receive_pricing_test.go index 9f92dee..e00ff66 100644 --- a/backend/internal/modules/listing/receive_pricing_test.go +++ b/backend/internal/modules/listing/receive_pricing_test.go @@ -40,7 +40,7 @@ func TestCalculateSellerReferenceRatioUsesMainSiteConfig(t *testing.T) { func TestApplyMainSiteReceivePricingOverwritesClientRatio(t *testing.T) { req := fullReceivePricingRequest(10) - applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, false) + applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, false) breakdown := ensurePriceBreakdown(req.AssetSummary) if readSummaryNumber(breakdown["seller_ratio"]) != 43 { t.Fatalf("seller_ratio = %v, want 43", breakdown["seller_ratio"]) @@ -61,7 +61,7 @@ func TestApplyMainSiteReceivePricingOverwritesClientRatio(t *testing.T) { func TestApplyMainSiteReceivePricingKeepsAcceleratedRatioWithinMainSiteRange(t *testing.T) { req := fullReceivePricingRequest(50) - applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, false) + applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, false) breakdown := ensurePriceBreakdown(req.AssetSummary) if readSummaryNumber(breakdown["seller_ratio"]) != 50 { 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) - applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, true) + applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 0, true) breakdown := ensurePriceBreakdown(req.AssetSummary) 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) - applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 3, true) + applyMainSiteReceivePricing(&req, systemconfig.DefaultPublishOptions(), defaultSalePriceConfig(), 0, 3, true) breakdown := ensurePriceBreakdown(req.AssetSummary) - if readSummaryNumber(breakdown["seller_ratio"]) != 40 { - t.Fatalf("distribution seller_ratio = %v, want 40 after lowering 3 points", breakdown["seller_ratio"]) + // 压价 3 点:收号比例 = R+3 = 46,号主收 217.4(低于主站 232.6)。 + 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 { - t.Fatalf("seller_reference_ratio = %v, want 43", breakdown["seller_reference_ratio"]) + if got := readSummaryNumber(breakdown["seller_coin_base_price"]); got != 217.4 { + 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 { - t.Fatalf("site_receive_ratio_delta = %v, want 3", breakdown["site_receive_ratio_delta"]) + // 卖价锚定主站:买家比例仍为 38、买家价仍为 263.2,不随收号价下降。 + 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) } } diff --git a/backend/internal/modules/listing/service_mutation.go b/backend/internal/modules/listing/service_mutation.go index 7859cb3..b75586f 100644 --- a/backend/internal/modules/listing/service_mutation.go +++ b/backend/internal/modules/listing/service_mutation.go @@ -16,8 +16,8 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, if len(siteCode) > 0 { effectiveSiteCode = strings.TrimSpace(siteCode[0]) } - delta, onDistribution := s.repo.ResolveReceiveRatioDelta(ctx, 0, effectiveSiteCode) - if err := s.applyMainSiteReceivePricing(ctx, &req, delta, onDistribution); err != nil { + delta, onDistribution := s.repo.resolveSiteReceiveCostDelta(ctx, 0, effectiveSiteCode) + if err := s.applyMainSiteReceivePricing(ctx, &req, 0, delta, onDistribution); err != nil { return nil, err } 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 { effectiveSiteCode = strings.TrimSpace(siteCode[0]) } - delta, onDistribution := s.repo.ResolveReceiveRatioDelta(ctx, id, effectiveSiteCode) - if err := s.applyMainSiteReceivePricing(ctx, &req, delta, onDistribution); err != nil { + delta, onDistribution := s.repo.resolveSiteReceiveCostDelta(ctx, id, effectiveSiteCode) + if err := s.applyMainSiteReceivePricing(ctx, &req, 0, delta, onDistribution); err != nil { return nil, err } rules, err := s.publishRules(ctx) diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 145a651..d988a8a 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -1116,7 +1116,7 @@ func TestCreateDistributionListingUsesMainSiteReceiveRatio(t *testing.T) { } } -func TestCreateDistributionListingAppliesSiteReceiveRatioDelta(t *testing.T) { +func TestCreateDistributionListingAppliesSiteReceiveCostDelta(t *testing.T) { db := database.NewTestDB() if err := database.MigrateListingLifecycleTestSchema(db); err != nil { t.Fatalf("migrate err: %v", err) @@ -1129,11 +1129,11 @@ func TestCreateDistributionListingAppliesSiteReceiveRatioDelta(t *testing.T) { } site := model.DistributionSite{ SiteCode: "dist_ratio_down", - SiteName: "下调收号站", + SiteName: "压价收号站", Domain: "ratio-down.example.com", Status: "active", DisplayAccountEnabled: true, - ReceiveRatioDelta: 3, + ReceiveCostDelta: 3, } if err := db.Create(&site).Error; err != nil { t.Fatal(err) @@ -1169,8 +1169,8 @@ func TestCreateDistributionListingAppliesSiteReceiveRatioDelta(t *testing.T) { t.Fatalf("Create failed: %v", err) } pricing := ReceivePricingFromAssetSummary(mustMarshalSummary(t, dto.AssetSummary)) - if pricing.SellerRatio != 40 { - t.Fatalf("seller_ratio = %v, want 40 after site delta 3 below main-site 43", pricing.SellerRatio) + if pricing.SellerRatio != 46 { + t.Fatalf("seller_ratio = %v, want 46 after site cost delta 3 (R=43+3) under decoupled model", pricing.SellerRatio) } } diff --git a/backend/internal/modules/listing/site_receive.go b/backend/internal/modules/listing/site_receive.go index a868301..d3b5252 100644 --- a/backend/internal/modules/listing/site_receive.go +++ b/backend/internal/modules/listing/site_receive.go @@ -7,6 +7,48 @@ import ( "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 { siteCode = strings.TrimSpace(siteCode) if r == nil || r.db == nil || siteCode == "" { @@ -21,6 +63,7 @@ func (r *Repository) ReceiveRatioDeltaBySiteCode(ctx context.Context, siteCode s return NormalizeSiteReceiveRatioDelta(delta) } +// ResolveReceiveRatioDelta 旧口径解析入口,保留供旧快照读侧使用。 func (r *Repository) ResolveReceiveRatioDelta(ctx context.Context, listingID uint64, siteCode string) (float64, bool) { if code := strings.TrimSpace(siteCode); code != "" { return r.ReceiveRatioDeltaBySiteCode(ctx, code), true diff --git a/backend/migrations/000072_distribution_site_receive_cost_delta.sql b/backend/migrations/000072_distribution_site_receive_cost_delta.sql new file mode 100644 index 0000000..2498db0 --- /dev/null +++ b/backend/migrations/000072_distribution_site_receive_cost_delta.sql @@ -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; diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index 28c10fe..4e1b591 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -129,6 +129,7 @@ export interface Site { footer_info_json?: string listing_qrcode_url?: string receive_ratio_delta?: number + receive_cost_delta?: number created_at: string } @@ -338,7 +339,10 @@ export async function listSites(): Promise { return extract(resp).items ?? [] } -export async function updateSite(siteID: number, payload: Partial>): Promise { +export async function updateSite( + siteID: number, + payload: Partial> +): Promise { const resp = await api.put>(`/distribution/sites/${siteID}`, payload) return extract(resp) } diff --git a/distribution-admin/src/pages/SiteSettings.tsx b/distribution-admin/src/pages/SiteSettings.tsx index 34aa826..4063420 100644 --- a/distribution-admin/src/pages/SiteSettings.tsx +++ b/distribution-admin/src/pages/SiteSettings.tsx @@ -84,7 +84,7 @@ export default function SiteSettings() { icp: '', contact: '', }) - const [receiveRatioDelta, setReceiveRatioDelta] = useState(0) + const [receiveCostDelta, setReceiveCostDelta] = useState(0) const [savingRatio, setSavingRatio] = useState(false) // SMS config state @@ -140,7 +140,7 @@ export default function SiteSettings() { } catch { setFooterInfo({}) } - setReceiveRatioDelta(Math.max(0, found.receive_ratio_delta || 0)) + setReceiveCostDelta(Math.max(0, found.receive_cost_delta || 0)) try { const sms = await fetchSiteSMSConfig(id) @@ -234,10 +234,10 @@ export default function SiteSettings() { if (!siteID) return setSavingRatio(true) try { - const updated = await updateSite(siteID, { receive_ratio_delta: receiveRatioDelta }) + const updated = await updateSite(siteID, { receive_cost_delta: receiveCostDelta }) setCurrentSite(updated) - setReceiveRatioDelta(Math.max(0, updated.receive_ratio_delta || 0)) - message.success('收号比例已保存,本站上传账号将按新比例计价') + setReceiveCostDelta(Math.max(0, updated.receive_cost_delta || 0)) + message.success('收号压价已保存,本站收号价比主站更低,售价与主站一致') } catch (error: unknown) { const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message message.error(respMessage || '保存收号比例失败') @@ -421,26 +421,26 @@ export default function SiteSettings() {
- 本站收号比例 + 本站收号压价 - 主站回收比例是上限。下调点数越大,号主底价越高、本站利润空间越大,但不能高于主站比例。0 表示与主站一致。 + 压价点数越大,号主在本站拿到的收号价越低。买家售价保持与主站一致,压价差进入三方分成(本站 35% / 卖出方 40% / 平台 25%)。0 表示与主站同价收号。
- 相对主站下调 + 收号价比主站低 setReceiveRatioDelta(Math.max(0, Number(value || 0)))} + value={receiveCostDelta} + onChange={(value) => setReceiveCostDelta(Math.max(0, Number(value || 0)))} addonAfter="点" style={{ width: 160 }} />
diff --git a/frontend/src/features/admin/api/adminDistribution.ts b/frontend/src/features/admin/api/adminDistribution.ts index 9f3c25a..c1664f7 100644 --- a/frontend/src/features/admin/api/adminDistribution.ts +++ b/frontend/src/features/admin/api/adminDistribution.ts @@ -13,6 +13,7 @@ export interface DistributionSite { settlement_hold_days: number listing_qrcode_url?: string receive_ratio_delta?: number + receive_cost_delta?: number created_at: string } @@ -154,6 +155,7 @@ export async function updateDistributionSite( | 'settlement_hold_days' | 'listing_qrcode_url' | 'receive_ratio_delta' + | 'receive_cost_delta' > > ) { diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index 3edb989..c359b75 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -59,7 +59,7 @@ const receiveRatioSubmitting = ref(false) const receiveRatioForm = reactive({ site_id: 0, site_name: '', - receive_ratio_delta: 0, + receive_cost_delta: 0, }) const qrDialogVisible = ref(false) @@ -136,7 +136,7 @@ async function handleSettlementSubmit() { function handleOpenReceiveRatio(row: DistributionSite) { receiveRatioForm.site_id = row.id 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 } @@ -144,9 +144,9 @@ async function handleReceiveRatioSubmit() { receiveRatioSubmitting.value = true try { 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 await loadSites() } catch (err) { @@ -530,15 +530,15 @@ onMounted(() => { - + - + @@ -769,16 +769,16 @@ onMounted(() => { type="info" :closable="false" show-icon - title="主站回收比例是上限。下调点数越大,号主底价越高、分站利润空间越大,但不能高于主站比例。0 表示与主站一致。" + title="分站收号价比主站更低:压价点数越大,号主在分站拿到的收号价越低。买家售价保持与主站一致,压价差进入三方分成(35% 收号 / 40% 卖出 / 25% 服务费)。0 表示与主站同价收号。" style="margin-bottom: 16px" /> - + siteStore.isDistribution) const receiveRatioDelta = computed(() => Number(siteStore.receiveRatioDelta || 0)) + const receiveCostDelta = computed(() => Number(siteStore.receiveCostDelta || 0)) const loading = ref(false) const uploading = ref(false) const suppressDraftSave = ref(false) @@ -95,6 +96,7 @@ export function usePublishForm(options: UsePublishFormOptions) { selectedSkins, isDistribution, receiveRatioDelta, + receiveCostDelta, }) // ========== 上号方式互斥 ========== diff --git a/frontend/src/shared/composables/usePricingCalculator.ts b/frontend/src/shared/composables/usePricingCalculator.ts index 22646fc..28e11ca 100644 --- a/frontend/src/shared/composables/usePricingCalculator.ts +++ b/frontend/src/shared/composables/usePricingCalculator.ts @@ -19,11 +19,10 @@ import { hasAcceleratedSaleRatioInput as hasAcceleratedSaleRatioValue, isQuantityItemDisabledForInsurance, readFinalSaleRatio, - applySiteReceiveRatioDelta, + applySiteReceiveCostDelta, roundMoney, roundRatio, } from '@/shared/utils/pricing' - export function usePricingCalculator(options: { publishOptions: Ref salePriceConfig: Ref @@ -34,6 +33,7 @@ export function usePricingCalculator(options: { selectedSkins: Ref isDistribution?: Ref receiveRatioDelta?: Ref + receiveCostDelta?: Ref }) { const serverOptions = computed(() => options.publishOptions.value.server_options) const faceOptions = computed(() => options.publishOptions.value.face_options) @@ -91,12 +91,17 @@ export function usePricingCalculator(options: { const siteReceiveRatioDelta = computed(() => 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(() => { if (calculatedDefaultSaleRatio.value <= 0) return 0 if (options.isDistribution?.value) { - return applySiteReceiveRatioDelta( + return applySiteReceiveCostDelta( calculatedDefaultSaleRatio.value, - siteReceiveRatioDelta.value + siteReceiveCostDelta.value ) } return readFinalSaleRatio( @@ -105,10 +110,19 @@ export function usePricingCalculator(options: { maxAcceleratedSaleRatio.value ) }) + const anchorRatio = computed(() => { + if (calculatedDefaultSaleRatio.value <= 0) return 0 + if (options.isDistribution?.value) return calculatedDefaultSaleRatio.value + return calculatedRatio.value + }) const calculatedCoinBasePrice = computed(() => { if (calculatedRatio.value <= 0) return 0 return roundMoney(coinWanAmount.value / calculatedRatio.value) }) + const anchorCoinBasePrice = computed(() => { + if (anchorRatio.value <= 0) return 0 + return roundMoney(coinWanAmount.value / anchorRatio.value) + }) const calculatedConsumablePrice = computed(() => calculateConsumablePrice({ quantityItems: quantityItems.value, @@ -122,13 +136,18 @@ export function usePricingCalculator(options: { ? roundMoney(calculatedCoinBasePrice.value + calculatedConsumablePrice.value) : 0 ) + const anchorSellerPrice = computed(() => + anchorRatio.value > 0 + ? roundMoney(anchorCoinBasePrice.value + calculatedConsumablePrice.value) + : 0 + ) const calculatedPlatformPricing = computed(() => calculatePlatformPricing({ coinMAmount: coinMAmount.value, coinWanAmount: coinWanAmount.value, - sellerRatio: calculatedRatio.value, - sellerCoinBasePrice: calculatedCoinBasePrice.value, - sellerTotalPrice: calculatedSellerPrice.value, + sellerRatio: anchorRatio.value, + sellerCoinBasePrice: anchorCoinBasePrice.value, + sellerTotalPrice: anchorSellerPrice.value, consumablePrice: calculatedConsumablePrice.value, salePriceConfig: options.salePriceConfig.value, }) @@ -212,6 +231,7 @@ export function usePricingCalculator(options: { calculatedDefaultSaleRatio, maxAcceleratedSaleRatio, siteReceiveRatioDelta, + siteReceiveCostDelta, calculatedRatio, calculatedCoinBasePrice, calculatedConsumablePrice, diff --git a/frontend/src/shared/utils/pricing.ts b/frontend/src/shared/utils/pricing.ts index 4e4ce5d..04141d6 100644 --- a/frontend/src/shared/utils/pricing.ts +++ b/frontend/src/shared/utils/pricing.ts @@ -165,6 +165,16 @@ export function applySiteReceiveRatioDelta(referenceRatio: number, delta: number 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) { const ratio = Number(value) return ( diff --git a/frontend/src/stores/distributionSite.ts b/frontend/src/stores/distributionSite.ts index 14650c1..28245ba 100644 --- a/frontend/src/stores/distributionSite.ts +++ b/frontend/src/stores/distributionSite.ts @@ -28,6 +28,7 @@ export interface DistributionSiteInfo { announcements_json?: string footer_info_json?: string receive_ratio_delta?: number + receive_cost_delta?: number } /** @@ -74,6 +75,7 @@ export const useDistributionSiteStore = defineStore('distributionSite', { announcements: [] as string[], footerInfo: null as DistributionFooterInfo | null, receiveRatioDelta: 0, + receiveCostDelta: 0, initialized: false, } }, @@ -178,6 +180,7 @@ export const useDistributionSiteStore = defineStore('distributionSite', { } 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_name', this.siteName) @@ -217,6 +220,7 @@ export const useDistributionSiteStore = defineStore('distributionSite', { this.announcements = [] this.footerInfo = null this.receiveRatioDelta = 0 + this.receiveCostDelta = 0 sessionStorage.removeItem('hfb_site_code') sessionStorage.removeItem('hfb_site_name') localStorage.removeItem('hfb_site_code')