refactor(distribution): 重写分销迁移并落地利润百分比分成与货架比例

将 000064-000078 合并为 064/065/066,默认规则改为收号 35%、卖出 40%、服务费 25%。
货架调价改为增加分站 1 元兑换哈夫币比例。sql-guard 仅保护 main 已发布迁移。
This commit is contained in:
yml2213
2026-09-08 19:02:58 +08:00
parent 48a2961245
commit d762b33f54
48 changed files with 745 additions and 1073 deletions
@@ -48,8 +48,8 @@ func TestOnboardSiteAndLoginFlow(t *testing.T) {
if resp.Member.SiteID == nil || *resp.Member.SiteID != resp.Site.ID {
t.Fatalf("expected SiteID=%d, got %v", resp.Site.ID, resp.Member.SiteID)
}
if resp.Rule.DistributorReceiveRatio != 3.8 {
t.Fatalf("expected rule ratio 3.8, got %f", resp.Rule.DistributorReceiveRatio)
if resp.Rule.DistributorReceiveRatio != 38 {
t.Fatalf("expected rule percent 38, got %f", resp.Rule.DistributorReceiveRatio)
}
if resp.Rule.Status != "draft" {
t.Fatalf("onboard rule must stay draft until split is verified, got %s", resp.Rule.Status)
+41 -38
View File
@@ -131,23 +131,27 @@ type DistributionOrderPage struct {
}
type AccountDisplayDTO struct {
ID uint64 `json:"id"`
SiteID uint64 `json:"site_id"`
AccountID uint64 `json:"account_id"`
ListingID uint64 `json:"listing_id"`
DisplayStatus string `json:"display_status"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
ListingNo string `json:"listing_no,omitempty"`
Title string `json:"title,omitempty"`
GameName string `json:"game_name,omitempty"`
ServerRegion string `json:"server_region,omitempty"`
LoginPlatform string `json:"login_platform,omitempty"`
RankLevel string `json:"rank_level,omitempty"`
HafCoinAmount int64 `json:"haf_coin_amount,omitempty"`
PriceCent int64 `json:"price_cent,omitempty"`
DepositAmountCent int64 `json:"deposit_amount_cent,omitempty"`
ListingStatus string `json:"listing_status,omitempty"`
InTransaction bool `json:"in_transaction,omitempty"`
ID uint64 `json:"id"`
SiteID uint64 `json:"site_id"`
AccountID uint64 `json:"account_id"`
ListingID uint64 `json:"listing_id"`
DisplayStatus string `json:"display_status"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
ShelfRatioDelta float64 `json:"shelf_ratio_delta"`
BaseRatio float64 `json:"base_ratio,omitempty"`
SiteRatio float64 `json:"site_ratio,omitempty"`
ListingNo string `json:"listing_no,omitempty"`
Title string `json:"title,omitempty"`
GameName string `json:"game_name,omitempty"`
ServerRegion string `json:"server_region,omitempty"`
LoginPlatform string `json:"login_platform,omitempty"`
RankLevel string `json:"rank_level,omitempty"`
HafCoinAmount int64 `json:"haf_coin_amount,omitempty"`
PriceCent int64 `json:"price_cent,omitempty"`
SitePriceCent int64 `json:"site_price_cent,omitempty"`
DepositAmountCent int64 `json:"deposit_amount_cent,omitempty"`
ListingStatus string `json:"listing_status,omitempty"`
InTransaction bool `json:"in_transaction,omitempty"`
}
type SyncSiteAccountsRequest struct {
@@ -161,21 +165,22 @@ type SyncSiteAccountsResult struct {
}
type PublicDistributionListingDTO struct {
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
AccountID uint64 `json:"account_id"`
Title string `json:"title"`
GameName string `json:"game_name"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RankLevel string `json:"rank_level"`
HafCoinAmount int64 `json:"haf_coin_amount"`
PriceCent int64 `json:"price_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
InTransaction bool `json:"in_transaction"`
SiteCode string `json:"site_code"`
SiteName string `json:"site_name"`
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
AccountID uint64 `json:"account_id"`
Title string `json:"title"`
GameName string `json:"game_name"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RankLevel string `json:"rank_level"`
HafCoinAmount int64 `json:"haf_coin_amount"`
PriceCent int64 `json:"price_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
ShelfRatioDelta float64 `json:"shelf_ratio_delta"`
InTransaction bool `json:"in_transaction"`
SiteCode string `json:"site_code"`
SiteName string `json:"site_name"`
}
type PublicDistributionListingsResult struct {
@@ -199,7 +204,6 @@ type PublicSiteInfoDTO struct {
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
}
type UpdateAccountDisplayRequest struct {
DisplayStatus string `json:"display_status" binding:"required,oneof=enabled disabled sold offline"`
}
@@ -359,14 +363,14 @@ type UpdateSiteBrandingRequest struct {
FooterInfoJSON *string `json:"footer_info_json"`
}
// 自主加价 DTO
// 自主加价 DTO:增加该站「1 元兑换哈夫币」比例,不是人民币。
type UpdateAccountMarkupRequest struct {
MarkupAmountCent int64 `json:"markup_amount_cent"`
ShelfRatioDelta float64 `json:"shelf_ratio_delta"`
}
type BatchUpdateAccountMarkupRequest struct {
ListingIDs []uint64 `json:"listing_ids" binding:"required"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
ListingIDs []uint64 `json:"listing_ids" binding:"required"`
ShelfRatioDelta float64 `json:"shelf_ratio_delta"`
}
// 商户钱包与提现 DTO
@@ -435,4 +439,3 @@ type DistributionTrendPoint struct {
GMVCent int64 `json:"gmv_cent"`
ProfitCent int64 `json:"profit_cent"`
}
@@ -264,6 +264,9 @@ func (h *Handler) SyncSiteAccounts(c *gin.Context) {
func (h *Handler) ListPublicSiteListings(c *gin.Context) {
siteCode := strings.TrimSpace(c.Param("site_code"))
if siteCode == "" {
siteCode = strings.TrimSpace(c.Param("id"))
}
if siteCode == "" {
siteCode = strings.TrimSpace(c.Query("site_code"))
}
@@ -544,7 +547,7 @@ func (h *Handler) UpdateAccountMarkup(c *gin.Context) {
return
}
adminID, _ := currentAdminID(c)
dto, err := h.service.UpdateAccountMarkup(c.Request.Context(), adminID, id, req.MarkupAmountCent)
dto, err := h.service.UpdateAccountMarkup(c.Request.Context(), adminID, id, req.ShelfRatioDelta)
if err != nil {
writeError(c, err)
return
@@ -148,7 +148,7 @@ func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uin
}
func ruleFromVersion(rule model.DistributionRuleVersion) Rule {
return Rule{
parsed := Rule{
Mode: rule.Mode,
PlatformReceiveRatio: rule.PlatformReceiveRatio,
PlatformSellRatio: rule.PlatformSellRatio,
@@ -162,6 +162,8 @@ func ruleFromVersion(rule model.DistributionRuleVersion) Rule {
TaxEnabled: rule.TaxEnabled,
TaxRateBPS: rule.TaxRateBPS,
}
normalizeRulePercents(&parsed)
return parsed
}
// calculateForRule 按规则快照计算手续费、服务费和分成基数。
@@ -170,7 +172,7 @@ func calculateForRule(rule model.DistributionRuleVersion, input SettlementInput)
}
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
// 参与方金额按规则比例对分成基数拆分,剩余(含尾差)记入 RoundingAdjustmentCent 归平台。
// 利润 = 租金 − 号主 − 通道手续费,再按 35%/40%/25% 切;尾差记入 RoundingAdjustmentCent。
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
if !distributionTablesPresent(tx) {
return nil
@@ -186,16 +188,24 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
if err := tx.First(&rule, *order.DistributionRuleVersionID).Error; err != nil {
return err
}
grossRent := order.RentAmountCent
if grossRent <= 0 {
grossRent = grossPaidCent
}
markup := order.ShelfMarkupCent
if markup < 0 {
markup = 0
}
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossPaidCent, MarkupAmountCent: markup})
ownerRent := order.OwnerRentAmountCent
if ownerRent < 0 {
ownerRent = 0
}
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: grossRent, OwnerRentAmountCent: ownerRent, MarkupAmountCent: markup})
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, markup)
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossPaidCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRemainderCent(result.SplitBaseAmountCent, splits), SettlementStatus: "pending"}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent)
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossRent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, splits), SettlementStatus: "pending"}
if err := tx.Where("order_id = ?", order.ID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
return err
}
@@ -242,19 +252,30 @@ func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
}
return err
}
if refundAmountCent < 0 || refundAmountCent > financial.GrossPaidAmountCent {
if refundAmountCent < 0 {
return ErrInvalidAmount
}
if refundAmountCent > financial.GrossPaidAmountCent {
refundAmountCent = financial.GrossPaidAmountCent
}
var rule model.DistributionRuleVersion
if err := tx.First(&rule, financial.RuleVersionID).Error; err != nil {
return err
}
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, MarkupAmountCent: financial.MarkupAmountCent})
var order model.RentalOrder
ownerRent := int64(0)
if err := tx.Select("owner_rent_amount_cent").First(&order, orderID).Error; err == nil {
ownerRent = order.OwnerRentAmountCent
if ownerRent < 0 {
ownerRent = 0
}
}
result, err := calculateForRule(rule, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, OwnerRentAmountCent: ownerRent, MarkupAmountCent: financial.MarkupAmountCent})
if err != nil {
return err
}
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent, financial.MarkupAmountCent)
if err := tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "net_paid_amount_cent": result.NetPaidAmountCent, "payment_fee_cent": result.PaymentFeeCent, "service_fee_cent": result.ServiceFeeCent, "split_base_amount_cent": result.SplitBaseAmountCent, "rounding_adjustment_cent": SplitRemainderCent(result.SplitBaseAmountCent, splits), "settlement_status": "pending"}).Error; err != nil {
splits := SplitSettlement(ruleFromVersion(rule), result.SplitBaseAmountCent)
if err := tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "net_paid_amount_cent": result.NetPaidAmountCent, "payment_fee_cent": result.PaymentFeeCent, "service_fee_cent": result.ServiceFeeCent, "split_base_amount_cent": result.SplitBaseAmountCent, "rounding_adjustment_cent": SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, splits), "settlement_status": "pending"}).Error; err != nil {
return err
}
for _, split := range splits {
@@ -259,10 +259,10 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
Mode: ModeTwoPartyDistributorReceive,
Version: 1,
Status: "active",
DistributorReceiveRatio: 3.5,
PlatformSellRatio: 4.0,
ServiceFeeValue: 2.5,
ServiceFeeUnit: ServiceFeeUnitFixedAmount,
DistributorReceiveRatio: 35,
PlatformSellRatio: 40,
ServiceFeeValue: 25,
ServiceFeeUnit: ServiceFeeUnitPercent,
PaymentFeeBPS: 50,
TaxEnabled: true,
TaxRateBPS: 100,
@@ -279,8 +279,9 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
OwnerID: 1,
RenterID: 2,
Status: "renting",
PlatformFeeCent: 999,
ShelfMarkupCent: 300,
RentAmountCent: 10000,
OwnerRentAmountCent: 6000,
PlatformFeeCent: 4000,
DistributionSiteID: &site.ID,
DistributionRuleVersionID: &rule.ID,
}
@@ -301,18 +302,18 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
if financial.GrossPaidAmountCent != grossPaid || financial.SiteID != site.ID {
t.Fatalf("unexpected financial snapshot: %+v", financial)
}
if financial.MarkupAmountCent != 300 {
t.Fatalf("markup must come from shelf snapshot, got %d", financial.MarkupAmountCent)
if financial.SplitBaseAmountCent != 3950 || financial.ServiceFeeCent != 988 {
t.Fatalf("profit snapshot = %+v", financial)
}
var parties []model.DistributionOrderParty
if err := db.Where("order_id = ?", order.ID).Order("id ASC").Find(&parties).Error; err != nil || len(parties) != 2 {
t.Fatalf("unexpected parties count: len=%d, err=%v", len(parties), err)
}
if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 340 {
if parties[0].PartyRole != "distributor_receive" || parties[0].PartyType != PartyTypeSite || parties[0].PartyID != site.ID || parties[0].IncomeAmountCent != 1383 {
t.Fatalf("distributor party = %+v", parties[0])
}
if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 688 {
if parties[1].PartyRole != "platform_sell" || parties[1].PartyType != PartyTypePlatform || parties[1].PartyID != 0 || parties[1].IncomeAmountCent != 1580 {
t.Fatalf("platform party = %+v", parties[1])
}
@@ -330,10 +331,6 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
if err := ApplyRefundTx(db, order.ID, -10); err != ErrInvalidAmount {
t.Fatalf("expected ErrInvalidAmount for negative refund, got %v", err)
}
if err := ApplyRefundTx(db, order.ID, grossPaid+100); err != ErrInvalidAmount {
t.Fatalf("expected ErrInvalidAmount for excessive refund, got %v", err)
}
// 合法退款
refundAmount := int64(2000)
if err := ApplyRefundTx(db, order.ID, refundAmount); err != nil {
@@ -352,7 +349,7 @@ func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
}
}
func TestSnapshotPaidOrderTxAddsShelfMarkupToSeller(t *testing.T) {
func TestSnapshotPaidOrderTxSplitsPercentOfProfit(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := model.DistributionSite{SiteCode: "sell_markup", SiteName: "卖出加价站", Domain: "sell-markup.dist.com", Status: "active", DisplayAccountEnabled: true}
@@ -361,8 +358,8 @@ func TestSnapshotPaidOrderTxAddsShelfMarkupToSeller(t *testing.T) {
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID, Mode: ModeTwoPartyPlatformReceive, Version: 1, Status: "active",
PlatformReceiveRatio: 3.5, DistributorSellRatio: 4,
ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50,
PlatformReceiveRatio: 35, DistributorSellRatio: 40,
ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50,
EffectiveFrom: now.Add(-time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
@@ -370,7 +367,7 @@ func TestSnapshotPaidOrderTxAddsShelfMarkupToSeller(t *testing.T) {
}
order := model.RentalOrder{
OrderNo: "ORD-MARKUP-SELL", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "renting",
PlatformFeeCent: 2000, ShelfMarkupCent: 10000,
RentAmountCent: 10000, OwnerRentAmountCent: 6000, PlatformFeeCent: 4000,
DistributionSiteID: &site.ID, DistributionRuleVersionID: &rule.ID, DistributionMode: ModeTwoPartyPlatformReceive,
}
if err := db.Create(&order).Error; err != nil {
@@ -395,20 +392,10 @@ func TestSnapshotPaidOrderTxAddsShelfMarkupToSeller(t *testing.T) {
if seller == nil || platform == nil {
t.Fatalf("missing parties: %+v", parties)
}
if seller.IncomeAmountCent < 10000 {
t.Fatalf("seller split must include 10000 markup, got %d", seller.IncomeAmountCent)
if seller.IncomeAmountCent != 1580 {
t.Fatalf("seller 40%% of profit = %d, want 1580", seller.IncomeAmountCent)
}
if platform.PartyID != 0 {
t.Fatalf("platform party_id = %d, want 0", platform.PartyID)
}
if err := db.Model(&order).Update("shelf_markup_cent", 0).Error; err != nil {
t.Fatal(err)
}
var financial model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil {
t.Fatal(err)
}
if financial.MarkupAmountCent != 10000 {
t.Fatalf("historical snapshot must keep markup, got %d", financial.MarkupAmountCent)
if platform.IncomeAmountCent != 1383 || platform.PartyID != 0 {
t.Fatalf("platform receive = %+v", platform)
}
}
@@ -18,6 +18,10 @@ const (
ServiceFeeUnitPercent = "percent"
ServiceFeeUnitFixedAmount = "fixed_amount"
ServiceFeeUnitRatio = "ratio"
DefaultReceivePercent = 35.0
DefaultSellPercent = 40.0
DefaultServicePercent = 25.0
)
var (
@@ -27,7 +31,7 @@ var (
ErrUnsupportedMode = errors.New("unsupported distribution mode")
)
// Rule 是后台可编辑的分账规则。比例字段按参与方角色命名,避免方向歧义。
// Rule 是后台可编辑的分账规则。比例字段存百分数(35/40/25),按参与方角色命名。
type Rule struct {
Mode string
PlatformReceiveRatio float64
@@ -47,6 +51,7 @@ type Rule struct {
type SettlementInput struct {
GrossPaidAmountCent int64
RefundAmountCent int64
OwnerRentAmountCent int64
MarkupAmountCent int64
GrossProfitByParty []PartyProfitInput
}
@@ -75,23 +80,22 @@ type SettlementResult struct {
TaxByParty []PartyTaxResult
}
// DefaultRule 返回文档中的第一版占位值,仅供对照,禁止当作已手算确认的生效分成。
// 本站自卖没有默认比例,必须单独配置。
// DefaultRule 返回已确认的默认百分比:收号 35%、卖出 40%、服务费 25%。
func DefaultRule(mode string) (Rule, error) {
base := Rule{
Mode: mode, ServiceFeeValue: 2.5, ServiceFeeUnit: ServiceFeeUnitRatio,
Mode: mode, ServiceFeeValue: DefaultServicePercent, ServiceFeeUnit: ServiceFeeUnitPercent,
PaymentFeeBPS: 50, TaxEnabled: false, TaxRateBPS: 100,
}
switch mode {
case ModeTwoPartyDistributorReceive:
base.DistributorReceiveRatio = 3.5
base.PlatformSellRatio = 4
base.DistributorReceiveRatio = DefaultReceivePercent
base.PlatformSellRatio = DefaultSellPercent
case ModeTwoPartyPlatformReceive:
base.PlatformReceiveRatio = 3.5
base.DistributorSellRatio = 4
base.PlatformReceiveRatio = DefaultReceivePercent
base.DistributorSellRatio = DefaultSellPercent
case ModeThreeParty:
base.DistributorARatio = 3.5
base.DistributorBRatio = 4
base.DistributorARatio = DefaultReceivePercent
base.DistributorBRatio = DefaultSellPercent
case ModeSiteSelfSell:
return Rule{}, ErrUnsupportedMode
default:
@@ -100,9 +104,38 @@ func DefaultRule(mode string) (Rule, error) {
return base, nil
}
// CalculateSettlement 先扣支付手续费,再计算服务费和分成基数;退款金额不能超过支付金额。
// NormalizeSplitPercent 把口头 3.5/4/2.5 换成 35/40/25;已经是百分数的原样保留。
func NormalizeSplitPercent(value float64) float64 {
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
if value <= 10 {
return math.Round(value*10*10) / 10
}
return math.Round(value*10) / 10
}
func normalizeRulePercents(row *Rule) {
if row == nil {
return
}
row.PlatformReceiveRatio = NormalizeSplitPercent(row.PlatformReceiveRatio)
row.PlatformSellRatio = NormalizeSplitPercent(row.PlatformSellRatio)
row.DistributorReceiveRatio = NormalizeSplitPercent(row.DistributorReceiveRatio)
row.DistributorSellRatio = NormalizeSplitPercent(row.DistributorSellRatio)
row.DistributorARatio = NormalizeSplitPercent(row.DistributorARatio)
row.DistributorBRatio = NormalizeSplitPercent(row.DistributorBRatio)
if row.ServiceFeeUnit == ServiceFeeUnitRatio || row.ServiceFeeUnit == "" {
row.ServiceFeeUnit = ServiceFeeUnitPercent
}
if row.ServiceFeeUnit == ServiceFeeUnitPercent {
row.ServiceFeeValue = NormalizeSplitPercent(row.ServiceFeeValue)
}
}
// CalculateSettlement 先扣通道手续费和号主应得,得到利润后再按 35/40/25 切。
func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, error) {
if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent {
if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.OwnerRentAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent {
return SettlementResult{}, ErrInvalidAmount
}
if rule.PaymentFeeBPS < 0 || rule.PaymentFeeBPS > 10000 || rule.TaxRateBPS < 0 || rule.TaxRateBPS > 10000 || rule.ServiceFeeValue < 0 {
@@ -113,13 +146,16 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
}
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
serviceFee, err := serviceFeeCent(rule, netPaid)
profit := netPaid - input.OwnerRentAmountCent - paymentFee
if profit < 0 {
profit = 0
}
serviceFee, err := serviceFeeCent(rule, profit, netPaid)
if err != nil {
return SettlementResult{}, err
}
splitBase := netPaid - paymentFee - serviceFee
if splitBase < 0 {
splitBase = 0
if serviceFee > profit {
serviceFee = profit
}
result := SettlementResult{
GrossPaidAmountCent: input.GrossPaidAmountCent,
@@ -128,24 +164,24 @@ func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, er
PaymentFeeCent: paymentFee,
ServiceFeeCent: serviceFee,
MarkupAmountCent: input.MarkupAmountCent,
SplitBaseAmountCent: splitBase,
SplitBaseAmountCent: profit,
}
if rule.TaxEnabled {
result.TaxByParty = make([]PartyTaxResult, 0, len(input.GrossProfitByParty))
for _, party := range input.GrossProfitByParty {
profit := party.IncomeAmountCent - party.CostAmountCent
tax := roundHalfUp(float64(maxInt64(profit, 0)) * float64(rule.TaxRateBPS) / 10000)
profitParty := party.IncomeAmountCent - party.CostAmountCent
tax := roundHalfUp(float64(maxInt64(profitParty, 0)) * float64(rule.TaxRateBPS) / 10000)
result.TaxAmountCent += tax
result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profit, TaxAmountCent: tax})
result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profitParty, TaxAmountCent: tax})
}
}
return result, nil
}
func serviceFeeCent(rule Rule, netPaid int64) (int64, error) {
func serviceFeeCent(rule Rule, profit, netPaid int64) (int64, error) {
switch rule.ServiceFeeUnit {
case ServiceFeeUnitPercent:
return roundHalfUp(float64(netPaid) * rule.ServiceFeeValue / 100), nil
return roundHalfUp(float64(profit) * rule.ServiceFeeValue / 100), nil
case ServiceFeeUnitFixedAmount:
return roundHalfUp(rule.ServiceFeeValue * 100), nil
case ServiceFeeUnitRatio:
@@ -176,11 +212,8 @@ type PartySplit struct {
AmountCent int64
}
// SplitSettlement 按规则模式把分成基数以百分比拆给各分销参与方。
// 销售加价全部计入卖出方收入(设计文档 7.6);
// 剩余金额(含四舍五入尾差)由调用方归入平台留存并记录 RoundingAdjustmentCent,
// 最终业务口径以设计文档 7.2 待确认项的结论为准。
func SplitSettlement(rule Rule, splitBase int64, markupCent int64) []PartySplit {
// SplitSettlement 按百分数切利润:收号 35%、卖出 40%。服务费 25% 记在财务快照,不并进参与方。
func SplitSettlement(rule Rule, profitCent int64) []PartySplit {
roles := []struct {
role string
ratio float64
@@ -206,28 +239,13 @@ func SplitSettlement(rule Rule, splitBase int64, markupCent int64) []PartySplit
}
splits := make([]PartySplit, 0, len(roles))
for _, item := range roles {
amount := roundHalfUp(float64(splitBase) * item.ratio / 100)
amount := roundHalfUp(float64(profitCent) * item.ratio / 100)
splits = append(splits, PartySplit{PartyRole: item.role, Ratio: item.ratio, AmountCent: maxInt64(amount, 0)})
}
if markupCent > 0 {
// 加价归卖出方:两方-分销收号与三方模式的卖出方分别是 platform_sell / distributor_b。
sellerRole := "distributor_sell"
switch rule.Mode {
case ModeTwoPartyDistributorReceive:
sellerRole = "platform_sell"
case ModeThreeParty:
sellerRole = "distributor_b"
}
for i := range splits {
if splits[i].PartyRole == sellerRole {
splits[i].AmountCent += markupCent
}
}
}
return splits
}
// SplitRemainderCent 返回分成基数扣除各方分成后的平台留存金额(含尾差)。
// SplitRemainderCent 返回利润扣除各方分成后的剩余(未扣服务费)。
func SplitRemainderCent(splitBase int64, splits []PartySplit) int64 {
sum := int64(0)
for _, item := range splits {
@@ -235,3 +253,8 @@ func SplitRemainderCent(splitBase int64, splits []PartySplit) int64 {
}
return splitBase - sum
}
// SplitRoundingCent 返回利润扣完收号、卖出和服务费后的四舍五入尾差。
func SplitRoundingCent(profitCent, serviceFeeCent int64, splits []PartySplit) int64 {
return SplitRemainderCent(profitCent, splits) - serviceFeeCent
}
@@ -7,13 +7,13 @@ func TestDefaultRules(t *testing.T) {
mode string
check func(Rule) bool
}{
{ModeTwoPartyDistributorReceive, func(r Rule) bool { return r.DistributorReceiveRatio == 3.5 && r.PlatformSellRatio == 4 }},
{ModeTwoPartyPlatformReceive, func(r Rule) bool { return r.PlatformReceiveRatio == 3.5 && r.DistributorSellRatio == 4 }},
{ModeThreeParty, func(r Rule) bool { return r.DistributorARatio == 3.5 && r.DistributorBRatio == 4 }},
{ModeTwoPartyDistributorReceive, func(r Rule) bool { return r.DistributorReceiveRatio == 35 && r.PlatformSellRatio == 40 }},
{ModeTwoPartyPlatformReceive, func(r Rule) bool { return r.PlatformReceiveRatio == 35 && r.DistributorSellRatio == 40 }},
{ModeThreeParty, func(r Rule) bool { return r.DistributorARatio == 35 && r.DistributorBRatio == 40 }},
}
for _, item := range cases {
rule, err := DefaultRule(item.mode)
if err != nil || !item.check(rule) || rule.ServiceFeeValue != 2.5 || rule.PaymentFeeBPS != 50 {
if err != nil || !item.check(rule) || rule.ServiceFeeValue != 25 || rule.ServiceFeeUnit != ServiceFeeUnitPercent || rule.PaymentFeeBPS != 50 {
t.Fatalf("default rule %s = %#v, err=%v", item.mode, rule, err)
}
}
@@ -22,13 +22,29 @@ func TestDefaultRules(t *testing.T) {
}
}
func TestCalculateSettlementDeductsFeeBeforeSplit(t *testing.T) {
rule := Rule{ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50}
got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300})
func TestNormalizeSplitPercent(t *testing.T) {
if got := NormalizeSplitPercent(3.5); got != 35 {
t.Fatalf("3.5 -> %v, want 35", got)
}
if got := NormalizeSplitPercent(4); got != 40 {
t.Fatalf("4 -> %v, want 40", got)
}
if got := NormalizeSplitPercent(2.5); got != 25 {
t.Fatalf("2.5 -> %v, want 25", got)
}
if got := NormalizeSplitPercent(35); got != 35 {
t.Fatalf("35 stays %v", got)
}
}
func TestCalculateSettlementSplitsProfitAfterOwnerAndFee(t *testing.T) {
rule := Rule{ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50}
got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10000, OwnerRentAmountCent: 6000})
if err != nil {
t.Fatal(err)
}
if got.NetPaidAmountCent != 9001 || got.PaymentFeeCent != 45 || got.SplitBaseAmountCent != 8956 {
// 通道费 50,利润 10000-6000-50=3950,服务费 25%=988
if got.NetPaidAmountCent != 10000 || got.PaymentFeeCent != 50 || got.SplitBaseAmountCent != 3950 || got.ServiceFeeCent != 988 {
t.Fatalf("unexpected settlement: %#v", got)
}
}
@@ -58,48 +74,37 @@ func TestCalculateSettlementRejectsRefundOverPayment(t *testing.T) {
}
func TestSplitSettlementTwoPartyDistributorReceive(t *testing.T) {
rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 3.5, PlatformSellRatio: 4}
splits := SplitSettlement(rule, 10000, 0)
rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 35, PlatformSellRatio: 40}
splits := SplitSettlement(rule, 3950)
if len(splits) != 2 {
t.Fatalf("splits = %#v", splits)
}
if splits[0].PartyRole != "distributor_receive" || splits[0].AmountCent != 350 {
if splits[0].PartyRole != "distributor_receive" || splits[0].AmountCent != 1383 {
t.Fatalf("distributor split = %#v", splits[0])
}
if splits[1].PartyRole != "platform_sell" || splits[1].AmountCent != 400 {
if splits[1].PartyRole != "platform_sell" || splits[1].AmountCent != 1580 {
t.Fatalf("platform split = %#v", splits[1])
}
if got := SplitRemainderCent(10000, splits); got != 9250 {
t.Fatalf("remainder = %d, want 9250", got)
if got := SplitRoundingCent(3950, 988, splits); got != -1 {
t.Fatalf("rounding = %d, want -1", got)
}
}
func TestSplitSettlementMarkupGoesToSeller(t *testing.T) {
// 两方-分销收号模式:卖出方是主站(platform_sell)。
rule := Rule{Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 3.5, PlatformSellRatio: 4}
splits := SplitSettlement(rule, 10000, 300)
if splits[1].AmountCent != 700 {
t.Fatalf("platform split with markup = %d, want 700", splits[1].AmountCent)
func TestSplitSettlementDoesNotAddYuanMarkup(t *testing.T) {
rule := Rule{Mode: ModeTwoPartyPlatformReceive, DistributorSellRatio: 40, PlatformReceiveRatio: 35}
splits := SplitSettlement(rule, 10000)
if splits[0].PartyRole != "distributor_sell" || splits[0].AmountCent != 4000 {
t.Fatalf("distributor sell = %#v", splits[0])
}
// 两方-分销卖出模式:卖出方是分销网(distributor_sell)。
ruleSell := Rule{Mode: ModeTwoPartyPlatformReceive, DistributorSellRatio: 4, PlatformReceiveRatio: 3.5}
splitsSell := SplitSettlement(ruleSell, 10000, 300)
if splitsSell[0].PartyRole != "distributor_sell" || splitsSell[0].AmountCent != 700 {
t.Fatalf("distributor sell split with markup = %#v", splitsSell[0])
}
if got := SplitRemainderCent(10000, splitsSell); got != 8950 {
t.Fatalf("remainder = %d, want 8950", got)
if splits[1].AmountCent != 3500 {
t.Fatalf("platform receive = %#v", splits[1])
}
}
func TestSplitSettlementThreeParty(t *testing.T) {
rule := Rule{Mode: ModeThreeParty, DistributorARatio: 3.5, DistributorBRatio: 4}
splits := SplitSettlement(rule, 10001, 0)
if len(splits) != 2 || splits[0].AmountCent != 350 || splits[1].AmountCent != 400 {
rule := Rule{Mode: ModeThreeParty, DistributorARatio: 35, DistributorBRatio: 40}
splits := SplitSettlement(rule, 10000)
if len(splits) != 2 || splits[0].AmountCent != 3500 || splits[1].AmountCent != 4000 {
t.Fatalf("splits = %#v", splits)
}
// 尾差归平台:10001 - 750 = 9251。
if got := SplitRemainderCent(10001, splits); got != 9251 {
t.Fatalf("remainder = %d, want 9251", got)
}
}
@@ -13,6 +13,7 @@ import (
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
listingpkg "hfb_sys/backend/internal/modules/listing"
"hfb_sys/backend/internal/modules/wallet"
"github.com/redis/go-redis/v9"
@@ -319,12 +320,19 @@ func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateR
if effectiveFrom.IsZero() {
effectiveFrom = time.Now()
}
row := model.DistributionRuleVersion{
SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft",
parsed := Rule{
PlatformReceiveRatio: req.PlatformReceiveRatio, PlatformSellRatio: req.PlatformSellRatio,
DistributorReceiveRatio: req.DistributorReceiveRatio, DistributorSellRatio: req.DistributorSellRatio,
DistributorARatio: req.DistributorARatio, DistributorBRatio: req.DistributorBRatio,
ServiceFeeValue: req.ServiceFeeValue, ServiceFeeUnit: strings.TrimSpace(req.ServiceFeeUnit),
}
normalizeRulePercents(&parsed)
row := model.DistributionRuleVersion{
SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft",
PlatformReceiveRatio: parsed.PlatformReceiveRatio, PlatformSellRatio: parsed.PlatformSellRatio,
DistributorReceiveRatio: parsed.DistributorReceiveRatio, DistributorSellRatio: parsed.DistributorSellRatio,
DistributorARatio: parsed.DistributorARatio, DistributorBRatio: parsed.DistributorBRatio,
ServiceFeeValue: parsed.ServiceFeeValue, ServiceFeeUnit: parsed.ServiceFeeUnit,
PaymentFeeBPS: req.PaymentFeeBPS, TaxEnabled: req.TaxEnabled, TaxRateBPS: req.TaxRateBPS,
EffectiveFrom: effectiveFrom, CreatedBy: &adminID,
}
@@ -443,23 +451,24 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri
func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
type accountDisplayRow struct {
ID uint64 `gorm:"column:id"`
SiteID uint64 `gorm:"column:site_id"`
AccountID uint64 `gorm:"column:account_id"`
ListingID uint64 `gorm:"column:listing_id"`
DisplayStatus string `gorm:"column:display_status"`
MarkupAmountCent int64 `gorm:"column:markup_amount_cent"`
ListingNo string `gorm:"column:listing_no"`
Title string `gorm:"column:title"`
GameName string `gorm:"column:game_name"`
ServerRegion string `gorm:"column:server_region"`
LoginPlatform string `gorm:"column:login_platform"`
RankLevel string `gorm:"column:rank_level"`
HafCoinAmount int64 `gorm:"column:haf_coin_amount"`
PriceCent int64 `gorm:"column:price_cent"`
DepositAmountCent int64 `gorm:"column:deposit_amount_cent"`
ListingStatus string `gorm:"column:listing_status"`
InTransaction bool `gorm:"column:in_transaction"`
ID uint64 `gorm:"column:id"`
SiteID uint64 `gorm:"column:site_id"`
AccountID uint64 `gorm:"column:account_id"`
ListingID uint64 `gorm:"column:listing_id"`
DisplayStatus string `gorm:"column:display_status"`
MarkupAmountCent int64 `gorm:"column:markup_amount_cent"`
ShelfRatioDelta float64 `gorm:"column:shelf_ratio_delta"`
ListingNo string `gorm:"column:listing_no"`
Title string `gorm:"column:title"`
GameName string `gorm:"column:game_name"`
ServerRegion string `gorm:"column:server_region"`
LoginPlatform string `gorm:"column:login_platform"`
RankLevel string `gorm:"column:rank_level"`
HafCoinAmount int64 `gorm:"column:haf_coin_amount"`
PriceCent int64 `gorm:"column:price_cent"`
DepositAmountCent int64 `gorm:"column:deposit_amount_cent"`
ListingStatus string `gorm:"column:listing_status"`
InTransaction bool `gorm:"column:in_transaction"`
}
var rows []accountDisplayRow
@@ -472,6 +481,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta
dsa.listing_id,
dsa.display_status,
COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent,
COALESCE(dsa.shelf_ratio_delta, 0) AS shelf_ratio_delta,
COALESCE(l.listing_no, '') AS listing_no,
COALESCE(ga.title, '') AS title,
COALESCE(ga.game_name, '') AS game_name,
@@ -496,13 +506,17 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta
}
items := make([]AccountDisplayDTO, 0, len(rows))
for _, row := range rows {
sitePrice, baseRatio, siteRatio := listingpkg.ApplyShelfRatio(row.HafCoinAmount, row.PriceCent, row.ShelfRatioDelta)
items = append(items, AccountDisplayDTO{
ID: row.ID,
SiteID: row.SiteID,
AccountID: row.AccountID,
ListingID: row.ListingID,
DisplayStatus: row.DisplayStatus,
MarkupAmountCent: row.MarkupAmountCent,
MarkupAmountCent: sitePrice - row.PriceCent,
ShelfRatioDelta: row.ShelfRatioDelta,
BaseRatio: baseRatio,
SiteRatio: siteRatio,
ListingNo: row.ListingNo,
Title: row.Title,
GameName: row.GameName,
@@ -511,6 +525,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta
RankLevel: row.RankLevel,
HafCoinAmount: row.HafCoinAmount,
PriceCent: row.PriceCent,
SitePriceCent: sitePrice,
DepositAmountCent: row.DepositAmountCent,
ListingStatus: row.ListingStatus,
InTransaction: row.InTransaction,
@@ -913,12 +928,14 @@ func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, order
for _, party := range req.Parties {
partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent})
}
result, err := CalculateSettlement(Rule{Mode: rule.Mode, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
parsed := ruleFromVersion(rule)
result, err := CalculateSettlement(parsed, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
if err != nil {
return nil, err
}
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRemainderCent(result.SplitBaseAmountCent, SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, result.MarkupAmountCent)), SettlementStatus: "pending"}
splits := SplitSettlement(parsed, result.SplitBaseAmountCent)
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: parsed.ServiceFeeValue, ServiceFeeUnit: parsed.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, splits), SettlementStatus: "pending"}
if err := tx.Create(&financial).Error; err != nil {
return err
}
@@ -1184,16 +1201,23 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*
return err
}
// 一键开通只落草稿,禁止把文档占位 3.5/4 当成已生效分成。
// 一键开通只落草稿。字段存百分数;口头 3.5/4/2.5 写入时换成 35/40/25。
parsed := Rule{
DistributorReceiveRatio: req.DistributorReceiveRatio,
PlatformSellRatio: req.PlatformSellRatio,
ServiceFeeValue: req.ServiceFeeValue,
ServiceFeeUnit: ServiceFeeUnitPercent,
}
normalizeRulePercents(&parsed)
rule := model.DistributionRuleVersion{
SiteID: &site.ID,
Mode: ModeTwoPartyDistributorReceive,
Version: 1,
Status: "draft",
DistributorReceiveRatio: req.DistributorReceiveRatio,
PlatformSellRatio: req.PlatformSellRatio,
ServiceFeeValue: req.ServiceFeeValue,
ServiceFeeUnit: ServiceFeeUnitPercent,
DistributorReceiveRatio: parsed.DistributorReceiveRatio,
PlatformSellRatio: parsed.PlatformSellRatio,
ServiceFeeValue: parsed.ServiceFeeValue,
ServiceFeeUnit: parsed.ServiceFeeUnit,
PaymentFeeBPS: 50,
TaxEnabled: taxEnabled,
TaxRateBPS: taxRate,
@@ -1264,19 +1288,19 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string
}
type publicListingRow struct {
ListingID uint64 `gorm:"column:listing_id"`
ListingNo string `gorm:"column:listing_no"`
AccountID uint64 `gorm:"column:account_id"`
Title string `gorm:"column:title"`
GameName string `gorm:"column:game_name"`
ServerRegion string `gorm:"column:server_region"`
LoginPlatform string `gorm:"column:login_platform"`
RankLevel string `gorm:"column:rank_level"`
HafCoinAmount int64 `gorm:"column:haf_coin_amount"`
PriceCent int64 `gorm:"column:price_cent"`
DepositAmountCent int64 `gorm:"column:deposit_amount_cent"`
MarkupAmountCent int64 `gorm:"column:markup_amount_cent"`
InTransaction bool `gorm:"column:in_transaction"`
ListingID uint64 `gorm:"column:listing_id"`
ListingNo string `gorm:"column:listing_no"`
AccountID uint64 `gorm:"column:account_id"`
Title string `gorm:"column:title"`
GameName string `gorm:"column:game_name"`
ServerRegion string `gorm:"column:server_region"`
LoginPlatform string `gorm:"column:login_platform"`
RankLevel string `gorm:"column:rank_level"`
HafCoinAmount int64 `gorm:"column:haf_coin_amount"`
PriceCent int64 `gorm:"column:price_cent"`
DepositAmountCent int64 `gorm:"column:deposit_amount_cent"`
ShelfRatioDelta float64 `gorm:"column:shelf_ratio_delta"`
InTransaction bool `gorm:"column:in_transaction"`
}
baseQuery := r.db.WithContext(ctx).
@@ -1302,9 +1326,9 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string
COALESCE(ga.login_platform, '') AS login_platform,
COALESCE(ga.rank_level, '') AS rank_level,
COALESCE(ga.haf_coin_amount, 0) AS haf_coin_amount,
COALESCE(l.price_cent, 0) + COALESCE(dsa.markup_amount_cent, 0) AS price_cent,
COALESCE(l.price_cent, 0) AS price_cent,
COALESCE(l.deposit_amount_cent, 0) AS deposit_amount_cent,
COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent,
COALESCE(dsa.shelf_ratio_delta, 0) AS shelf_ratio_delta,
COALESCE(l.in_transaction, 0) AS in_transaction
`).
Order("dsa.id DESC").
@@ -1317,6 +1341,7 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string
items := make([]PublicDistributionListingDTO, 0, len(rows))
for _, row := range rows {
sitePrice, _, _ := listingpkg.ApplyShelfRatio(row.HafCoinAmount, row.PriceCent, row.ShelfRatioDelta)
items = append(items, PublicDistributionListingDTO{
ListingID: row.ListingID,
ListingNo: row.ListingNo,
@@ -1327,9 +1352,10 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string
LoginPlatform: row.LoginPlatform,
RankLevel: row.RankLevel,
HafCoinAmount: row.HafCoinAmount,
PriceCent: row.PriceCent,
PriceCent: sitePrice,
DepositAmountCent: row.DepositAmountCent,
MarkupAmountCent: row.MarkupAmountCent,
MarkupAmountCent: sitePrice - row.PriceCent,
ShelfRatioDelta: row.ShelfRatioDelta,
InTransaction: row.InTransaction,
SiteCode: site.SiteCode,
SiteName: site.SiteName,
@@ -1456,8 +1482,8 @@ func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uin
return &dto, nil
}
func (r *Repository) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, markupCent int64) (*AccountDisplayDTO, error) {
if markupCent < 0 {
func (r *Repository) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, ratioDelta float64) (*AccountDisplayDTO, error) {
if ratioDelta < 0 {
return nil, ErrInvalidAmount
}
var row model.DistributionSiteAccount
@@ -1470,7 +1496,7 @@ func (r *Repository) UpdateAccountMarkup(ctx context.Context, adminID, id uint64
if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&row).Update("markup_amount_cent", markupCent).Error; err != nil {
if err := r.db.WithContext(ctx).Model(&row).Update("shelf_ratio_delta", ratioDelta).Error; err != nil {
return nil, err
}
displays, err := r.ListAccountDisplays(ctx, row.SiteID, "")
@@ -1492,7 +1518,7 @@ func (r *Repository) BatchUpdateAccountMarkup(ctx context.Context, adminID, site
if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil {
return err
}
if req.MarkupAmountCent < 0 {
if req.ShelfRatioDelta < 0 {
return ErrInvalidAmount
}
if len(req.ListingIDs) == 0 {
@@ -1500,7 +1526,7 @@ func (r *Repository) BatchUpdateAccountMarkup(ctx context.Context, adminID, site
}
return r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
Where("site_id = ? AND listing_id IN ?", siteID, req.ListingIDs).
Update("markup_amount_cent", req.MarkupAmountCent).Error
Update("shelf_ratio_delta", req.ShelfRatioDelta).Error
}
func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) {
@@ -548,31 +548,31 @@ func TestDistributionBrandingAndMarkup(t *testing.T) {
t.Fatal(err)
}
display, err := repo.UpdateAccountMarkup(t.Context(), 0, dsa.ID, 500)
display, err := repo.UpdateAccountMarkup(t.Context(), 0, dsa.ID, 5)
if err != nil {
t.Fatalf("UpdateAccountMarkup failed: %v", err)
}
if display.MarkupAmountCent != 500 {
t.Fatalf("expected markup 500, got %d", display.MarkupAmountCent)
if display.ShelfRatioDelta != 5 {
t.Fatalf("expected shelf ratio delta 5, got %v", display.ShelfRatioDelta)
}
// Batch update markup
if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{
ListingIDs: nil,
MarkupAmountCent: 800,
ListingIDs: nil,
ShelfRatioDelta: 8,
}); !errors.Is(err, ErrEmptyListingIDs) {
t.Fatalf("empty listing_ids err = %v", err)
}
if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{
ListingIDs: []uint64{201},
MarkupAmountCent: 800,
ListingIDs: []uint64{201},
ShelfRatioDelta: 8,
}); err != nil {
t.Fatalf("BatchUpdateAccountMarkup failed: %v", err)
}
displays, err := repo.ListAccountDisplays(t.Context(), site.ID, "")
if err != nil || len(displays) == 0 || displays[0].MarkupAmountCent != 800 {
t.Fatalf("expected markup 800 after batch, got: %+v", displays)
if err != nil || len(displays) == 0 || displays[0].ShelfRatioDelta != 8 {
t.Fatalf("expected shelf ratio delta 8 after batch, got: %+v", displays)
}
}
@@ -249,11 +249,11 @@ func (s *Service) UpdateSiteBranding(ctx context.Context, adminID, siteID uint64
return s.repo.UpdateSiteBranding(ctx, adminID, siteID, req)
}
func (s *Service) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, markupCent int64) (*AccountDisplayDTO, error) {
func (s *Service) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, ratioDelta float64) (*AccountDisplayDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateAccountMarkup(ctx, adminID, id, markupCent)
return s.repo.UpdateAccountMarkup(ctx, adminID, id, ratioDelta)
}
func (s *Service) BatchUpdateAccountMarkup(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountMarkupRequest) error {