实现分销网后台基础功能与独立前端
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// DistributionSite 表示一个独立分销网站主体。
|
||||
type DistributionSite struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
SiteCode string `gorm:"size:64;not null;uniqueIndex" json:"site_code"`
|
||||
SiteName string `gorm:"size:128;not null" json:"site_name"`
|
||||
Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"`
|
||||
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
|
||||
DisplayAccountEnabled bool `gorm:"not null;default:true" json:"display_account_enabled"`
|
||||
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
|
||||
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
|
||||
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DistributionSite) TableName() string { return "distribution_sites" }
|
||||
|
||||
// DistributionSiteMember 将现有后台管理员映射到分销网站。
|
||||
type DistributionSiteMember struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
SiteID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_member;index" json:"site_id"`
|
||||
AdminUserID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_member;index" json:"admin_user_id"`
|
||||
MemberType string `gorm:"size:32;not null" json:"member_type"`
|
||||
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
|
||||
DataScope string `gorm:"size:32;not null;default:'site'" json:"data_scope"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DistributionSiteMember) TableName() string { return "distribution_site_members" }
|
||||
|
||||
// DistributionSiteAccount 控制账号在分销网站的展示状态。
|
||||
type DistributionSiteAccount struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
SiteID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"site_id"`
|
||||
AccountID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"account_id"`
|
||||
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
|
||||
DisplayStatus string `gorm:"size:16;not null;default:'enabled';index" json:"display_status"`
|
||||
DisplayStartedAt *time.Time `json:"display_started_at,omitempty"`
|
||||
DisplayEndedAt *time.Time `json:"display_ended_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DistributionSiteAccount) TableName() string { return "distribution_site_accounts" }
|
||||
|
||||
// DistributionRuleVersion 是新订单使用的分账规则快照来源。
|
||||
type DistributionRuleVersion struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
SiteID *uint64 `gorm:"index" json:"site_id,omitempty"`
|
||||
Mode string `gorm:"size:32;not null;index" json:"mode"`
|
||||
Version int `gorm:"not null" json:"version"`
|
||||
Status string `gorm:"size:16;not null;default:'draft';index" json:"status"`
|
||||
PlatformReceiveRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"platform_receive_ratio"`
|
||||
PlatformSellRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"platform_sell_ratio"`
|
||||
DistributorReceiveRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"distributor_receive_ratio"`
|
||||
DistributorSellRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"distributor_sell_ratio"`
|
||||
DistributorARatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"distributor_a_ratio"`
|
||||
DistributorBRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"distributor_b_ratio"`
|
||||
ServiceFeeValue float64 `gorm:"type:decimal(12,4);not null;default:0" json:"service_fee_value"`
|
||||
ServiceFeeUnit string `gorm:"size:24;not null;default:'percent'" json:"service_fee_unit"`
|
||||
PaymentFeeBPS int64 `gorm:"not null;default:50" json:"payment_fee_bps"`
|
||||
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
|
||||
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
|
||||
EffectiveFrom time.Time `gorm:"index" json:"effective_from"`
|
||||
EffectiveTo *time.Time `gorm:"index" json:"effective_to,omitempty"`
|
||||
CreatedBy *uint64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (DistributionRuleVersion) TableName() string { return "distribution_rule_versions" }
|
||||
|
||||
// DistributionOrderFinancial 保存正常订单的分销财务快照。
|
||||
type DistributionOrderFinancial struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
OrderID uint64 `gorm:"not null;uniqueIndex" json:"order_id"`
|
||||
SiteID uint64 `gorm:"not null;index" json:"site_id"`
|
||||
RuleVersionID uint64 `gorm:"not null" json:"rule_version_id"`
|
||||
GrossPaidAmountCent int64 `gorm:"not null;default:0" json:"gross_paid_amount_cent"`
|
||||
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
|
||||
NetPaidAmountCent int64 `gorm:"not null;default:0" json:"net_paid_amount_cent"`
|
||||
PaymentFeeBPS int64 `gorm:"not null;default:50" json:"payment_fee_bps"`
|
||||
PaymentFeeCent int64 `gorm:"not null;default:0" json:"payment_fee_cent"`
|
||||
ServiceFeeValue float64 `gorm:"type:decimal(12,4);not null;default:0" json:"service_fee_value"`
|
||||
ServiceFeeUnit string `gorm:"size:24;not null;default:'percent'" json:"service_fee_unit"`
|
||||
ServiceFeeCent int64 `gorm:"not null;default:0" json:"service_fee_cent"`
|
||||
MarkupAmountCent int64 `gorm:"not null;default:0" json:"markup_amount_cent"`
|
||||
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
|
||||
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
|
||||
TaxAmountCent int64 `gorm:"not null;default:0" json:"tax_amount_cent"`
|
||||
SplitBaseAmountCent int64 `gorm:"not null;default:0" json:"split_base_amount_cent"`
|
||||
RoundingAdjustmentCent int64 `gorm:"not null;default:0" json:"rounding_adjustment_cent"`
|
||||
SettlementStatus string `gorm:"size:24;not null;default:'pending';index" json:"settlement_status"`
|
||||
SettledAt *time.Time `json:"settled_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DistributionOrderFinancial) TableName() string { return "distribution_order_financials" }
|
||||
|
||||
// DistributionOrderParty 保存订单创建时的参与方和金额快照,避免规则或站点变更影响历史订单。
|
||||
type DistributionOrderParty struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||
SiteID uint64 `gorm:"not null;index" json:"site_id"`
|
||||
PartyRole string `gorm:"size:32;not null;index" json:"party_role"`
|
||||
PartyType string `gorm:"size:16;not null" json:"party_type"`
|
||||
PartyID uint64 `gorm:"not null" json:"party_id"`
|
||||
Ratio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"ratio"`
|
||||
IncomeAmountCent int64 `gorm:"not null;default:0" json:"income_amount_cent"`
|
||||
CostAmountCent int64 `gorm:"not null;default:0" json:"cost_amount_cent"`
|
||||
ProfitAmountCent int64 `gorm:"not null;default:0" json:"profit_amount_cent"`
|
||||
TaxAmountCent int64 `gorm:"not null;default:0" json:"tax_amount_cent"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DistributionOrderParty) TableName() string { return "distribution_order_parties" }
|
||||
|
||||
// DistributionSettlementBatch 是按站点生成的结算批次。
|
||||
type DistributionSettlementBatch struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
SiteID uint64 `gorm:"not null;index" json:"site_id"`
|
||||
BatchNo string `gorm:"size:64;not null;uniqueIndex" json:"batch_no"`
|
||||
PeriodStart time.Time `gorm:"not null" json:"period_start"`
|
||||
PeriodEnd time.Time `gorm:"not null" json:"period_end"`
|
||||
TotalAmountCent int64 `gorm:"not null;default:0" json:"total_amount_cent"`
|
||||
Status string `gorm:"size:16;not null;default:'draft';index" json:"status"`
|
||||
CreatedBy *uint64 `json:"created_by,omitempty"`
|
||||
SettledAt *time.Time `json:"settled_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DistributionSettlementBatch) TableName() string { return "distribution_settlement_batches" }
|
||||
|
||||
type DistributionSettlementItem struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
BatchID uint64 `gorm:"not null;index" json:"batch_id"`
|
||||
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||
PartyID uint64 `gorm:"not null;index" json:"party_id"`
|
||||
AmountCent int64 `gorm:"not null;default:0" json:"amount_cent"`
|
||||
Status string `gorm:"size:16;not null;default:'pending'" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (DistributionSettlementItem) TableName() string { return "distribution_settlement_items" }
|
||||
|
||||
// DistributionSitePaymentChannel 将一个或多个现有支付商户配置绑定到分销站点。
|
||||
type DistributionSitePaymentChannel struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
SiteID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_payment;index" json:"site_id"`
|
||||
ConfigID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_payment;index" json:"config_id"`
|
||||
Priority int `gorm:"not null;default:0" json:"priority"`
|
||||
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DistributionSitePaymentChannel) TableName() string { return "distribution_site_payment_channels" }
|
||||
@@ -41,30 +41,36 @@ type RentalOrder struct {
|
||||
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
|
||||
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
||||
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
|
||||
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
|
||||
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
|
||||
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
|
||||
SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"`
|
||||
OfflineSettlementStatus string `gorm:"size:16;not null;default:'none';index" json:"offline_settlement_status"`
|
||||
OfflineSettlementAmountCent int64 `gorm:"not null;default:0" json:"offline_settlement_amount_cent"`
|
||||
OfflineSettlementRemark string `gorm:"size:255;not null;default:''" json:"offline_settlement_remark"`
|
||||
OfflineSettledBy *uint64 `json:"offline_settled_by"`
|
||||
OfflineSettledAt *time.Time `json:"offline_settled_at"`
|
||||
RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"`
|
||||
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
|
||||
RefundedAt *time.Time `json:"refunded_at"`
|
||||
DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"`
|
||||
DepositHoldAmountCent int64 `gorm:"not null;default:0" json:"deposit_hold_amount_cent"`
|
||||
DepositHoldReason string `gorm:"size:255;not null;default:''" json:"deposit_hold_reason"`
|
||||
DepositHeldBy *uint64 `json:"deposit_held_by"`
|
||||
DepositHeldAt *time.Time `json:"deposit_held_at"`
|
||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"`
|
||||
OwnerSettledAt *time.Time `json:"owner_settled_at"`
|
||||
SettledAt *time.Time `json:"settled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"`
|
||||
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
|
||||
// 分销正常订单字段:仅在卖家和租客都通过分销网站完成交易时填写。
|
||||
DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"`
|
||||
DistributionMode string `gorm:"size:32;not null;default:'';index" json:"distribution_mode"`
|
||||
DistributionRuleVersionID *uint64 `gorm:"index" json:"distribution_rule_version_id,omitempty"`
|
||||
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
||||
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
|
||||
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
|
||||
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
|
||||
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
|
||||
SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"`
|
||||
OfflineSettlementStatus string `gorm:"size:16;not null;default:'none';index" json:"offline_settlement_status"`
|
||||
OfflineSettlementAmountCent int64 `gorm:"not null;default:0" json:"offline_settlement_amount_cent"`
|
||||
OfflineSettlementRemark string `gorm:"size:255;not null;default:''" json:"offline_settlement_remark"`
|
||||
OfflineSettledBy *uint64 `json:"offline_settled_by"`
|
||||
OfflineSettledAt *time.Time `json:"offline_settled_at"`
|
||||
RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"`
|
||||
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
|
||||
RefundedAt *time.Time `json:"refunded_at"`
|
||||
DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"`
|
||||
DepositHoldAmountCent int64 `gorm:"not null;default:0" json:"deposit_hold_amount_cent"`
|
||||
DepositHoldReason string `gorm:"size:255;not null;default:''" json:"deposit_hold_reason"`
|
||||
DepositHeldBy *uint64 `json:"deposit_held_by"`
|
||||
DepositHeldAt *time.Time `json:"deposit_held_at"`
|
||||
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"`
|
||||
OwnerSettledAt *time.Time `json:"owner_settled_at"`
|
||||
SettledAt *time.Time `json:"settled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (RentalOrder) TableName() string {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package distribution
|
||||
|
||||
import "time"
|
||||
|
||||
type SiteDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
SiteCode string `json:"site_code"`
|
||||
SiteName string `json:"site_name"`
|
||||
Domain string `json:"domain"`
|
||||
Status string `json:"status"`
|
||||
DisplayAccountEnabled bool `json:"display_account_enabled"`
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
||||
TaxEnabled bool `json:"tax_enabled"`
|
||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateSiteRequest struct {
|
||||
SiteCode string `json:"site_code" binding:"required,max=64"`
|
||||
SiteName string `json:"site_name" binding:"required,max=128"`
|
||||
Domain string `json:"domain" binding:"required,max=255"`
|
||||
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||
TaxEnabled *bool `json:"tax_enabled"`
|
||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||
}
|
||||
|
||||
type UpdateSiteRequest struct {
|
||||
SiteName *string `json:"site_name"`
|
||||
Domain *string `json:"domain"`
|
||||
Status *string `json:"status"`
|
||||
DisplayAccountEnabled *bool `json:"display_account_enabled"`
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
|
||||
TaxEnabled *bool `json:"tax_enabled"`
|
||||
TaxRateBPS *int64 `json:"tax_rate_bps"`
|
||||
}
|
||||
|
||||
type RuleDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
SiteID *uint64 `json:"site_id,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
Version int `json:"version"`
|
||||
Status string `json:"status"`
|
||||
PlatformReceiveRatio float64 `json:"platform_receive_ratio"`
|
||||
PlatformSellRatio float64 `json:"platform_sell_ratio"`
|
||||
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
|
||||
DistributorSellRatio float64 `json:"distributor_sell_ratio"`
|
||||
DistributorARatio float64 `json:"distributor_a_ratio"`
|
||||
DistributorBRatio float64 `json:"distributor_b_ratio"`
|
||||
ServiceFeeValue float64 `json:"service_fee_value"`
|
||||
ServiceFeeUnit string `json:"service_fee_unit"`
|
||||
PaymentFeeBPS int64 `json:"payment_fee_bps"`
|
||||
TaxEnabled bool `json:"tax_enabled"`
|
||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||
EffectiveFrom time.Time `json:"effective_from"`
|
||||
EffectiveTo *time.Time `json:"effective_to,omitempty"`
|
||||
CreatedBy *uint64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateRuleRequest struct {
|
||||
SiteID *uint64 `json:"site_id"`
|
||||
Mode string `json:"mode" binding:"required"`
|
||||
PlatformReceiveRatio float64 `json:"platform_receive_ratio"`
|
||||
PlatformSellRatio float64 `json:"platform_sell_ratio"`
|
||||
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
|
||||
DistributorSellRatio float64 `json:"distributor_sell_ratio"`
|
||||
DistributorARatio float64 `json:"distributor_a_ratio"`
|
||||
DistributorBRatio float64 `json:"distributor_b_ratio"`
|
||||
ServiceFeeValue float64 `json:"service_fee_value"`
|
||||
ServiceFeeUnit string `json:"service_fee_unit" binding:"required"`
|
||||
PaymentFeeBPS int64 `json:"payment_fee_bps"`
|
||||
TaxEnabled bool `json:"tax_enabled"`
|
||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||
EffectiveFrom time.Time `json:"effective_from"`
|
||||
}
|
||||
|
||||
type UpdateRuleStatusRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=draft active disabled"`
|
||||
}
|
||||
|
||||
type DistributionOrderDTO struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
SiteID uint64 `json:"site_id"`
|
||||
ListingID uint64 `json:"listing_id"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
RenterID uint64 `json:"renter_id"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
RefundStatus string `json:"refund_status"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
DistributionMode string `json:"distribution_mode"`
|
||||
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
|
||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||
NetPaidAmountCent int64 `json:"net_paid_amount_cent"`
|
||||
PaymentFeeCent int64 `json:"payment_fee_cent"`
|
||||
ServiceFeeCent int64 `json:"service_fee_cent"`
|
||||
MarkupAmountCent int64 `json:"markup_amount_cent"`
|
||||
TaxAmountCent int64 `json:"tax_amount_cent"`
|
||||
SplitBaseAmountCent int64 `json:"split_base_amount_cent"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type DistributionOrderListQuery struct {
|
||||
SiteID uint64
|
||||
Kind string
|
||||
Status string
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
type DistributionOrderPage struct {
|
||||
Items []DistributionOrderDTO `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type UpdateAccountDisplayRequest struct {
|
||||
DisplayStatus string `json:"display_status" binding:"required,oneof=enabled disabled sold offline"`
|
||||
}
|
||||
|
||||
type PaymentChannelDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
SiteID uint64 `json:"site_id"`
|
||||
ConfigID uint64 `json:"config_id"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type BindPaymentChannelRequest struct {
|
||||
ConfigID uint64 `json:"config_id" binding:"required"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
type SettlementBatchDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
SiteID uint64 `json:"site_id"`
|
||||
BatchNo string `json:"batch_no"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
TotalAmountCent int64 `json:"total_amount_cent"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CreateSettlementBatchRequest struct {
|
||||
SiteID uint64 `json:"site_id" binding:"required"`
|
||||
PeriodStart time.Time `json:"period_start" binding:"required"`
|
||||
PeriodEnd time.Time `json:"period_end" binding:"required"`
|
||||
}
|
||||
|
||||
type SnapshotPartyRequest struct {
|
||||
PartyRole string `json:"party_role" binding:"required"`
|
||||
PartyType string `json:"party_type" binding:"required"`
|
||||
PartyID uint64 `json:"party_id"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
IncomeAmountCent int64 `json:"income_amount_cent"`
|
||||
CostAmountCent int64 `json:"cost_amount_cent"`
|
||||
}
|
||||
|
||||
type CreateFinancialSnapshotRequest struct {
|
||||
SiteID uint64 `json:"site_id" binding:"required"`
|
||||
RuleVersionID uint64 `json:"rule_version_id" binding:"required"`
|
||||
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
|
||||
RefundAmountCent int64 `json:"refund_amount_cent"`
|
||||
MarkupAmountCent int64 `json:"markup_amount_cent"`
|
||||
Parties []SnapshotPartyRequest `json:"parties"`
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct{ service *Service }
|
||||
|
||||
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
|
||||
|
||||
func (h *Handler) ListSites(c *gin.Context) {
|
||||
items, err := h.service.ListSites(c.Request.Context())
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) GetSite(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.FindSite(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateSite(c *gin.Context) {
|
||||
var req CreateSiteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "分销网站参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateSite(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateSite(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpdateSiteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "分销网站参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateSite(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListRules(c *gin.Context) {
|
||||
var siteID *uint64
|
||||
if raw := strings.TrimSpace(c.Query("site_id")); raw != "" {
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
siteID = &id
|
||||
}
|
||||
items, err := h.service.ListRules(c.Request.Context(), siteID, c.Query("mode"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateRule(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
var req CreateRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "分销规则参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateRule(c.Request.Context(), adminID, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateRuleStatus(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpdateRuleStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "规则状态不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateRuleStatus(c.Request.Context(), id, req.Status)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListOrders(c *gin.Context) {
|
||||
siteID := parseQueryUint(c, "site_id")
|
||||
if siteID < 0 {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
result, err := h.service.ListOrders(c.Request.Context(), DistributionOrderListQuery{SiteID: uint64(siteID), Kind: c.Query("kind"), Status: c.Query("status"), Page: page, Size: size})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) FindOrder(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.FindOrder(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFinancialSnapshot(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req CreateFinancialSnapshotRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "分销财务快照参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateFinancialSnapshot(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListAccountDisplays(c *gin.Context) {
|
||||
siteID := parseQueryUint(c, "site_id")
|
||||
if siteID <= 0 {
|
||||
if siteID == 0 {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
}
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListAccountDisplays(c.Request.Context(), uint64(siteID), c.Query("status"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateAccountDisplay(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpdateAccountDisplayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "账号展示状态不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.UpdateAccountDisplay(c.Request.Context(), id, req.DisplayStatus)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListPaymentChannels(c *gin.Context) {
|
||||
siteID := parseQueryUint(c, "site_id")
|
||||
if siteID <= 0 {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListPaymentChannels(c.Request.Context(), uint64(siteID))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) BindPaymentChannel(c *gin.Context) {
|
||||
siteID := parseQueryUint(c, "site_id")
|
||||
if siteID <= 0 {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
var req BindPaymentChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "支付通道参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.BindPaymentChannel(c.Request.Context(), uint64(siteID), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UnbindPaymentChannel(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.UnbindPaymentChannel(c.Request.Context(), id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *Handler) ListSettlementBatches(c *gin.Context) {
|
||||
siteID := parseQueryUint(c, "site_id")
|
||||
if siteID <= 0 {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListSettlementBatches(c.Request.Context(), uint64(siteID))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateSettlementBatch(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
var req CreateSettlementBatchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "结算周期参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateSettlementBatch(c.Request.Context(), adminID, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) MarkSettlementBatch(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.MarkSettlementBatch(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
// parseQueryUint 返回 -1 表示格式错误,0 表示未传入。
|
||||
func parseQueryUint(c *gin.Context, key string) int64 {
|
||||
raw := strings.TrimSpace(c.Query(key))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, key+" 不正确")
|
||||
return -1
|
||||
}
|
||||
return int64(id)
|
||||
}
|
||||
|
||||
func currentAdminID(c *gin.Context) (uint64, bool) {
|
||||
value, ok := c.Get(middleware.ContextAdminID)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
id, ok := value.(uint64)
|
||||
return id, ok && id > 0
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, "ID 不正确")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func writeError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrSiteNotFound), errors.Is(err, ErrRuleNotFound), errors.Is(err, ErrOrderNotFound), errors.Is(err, ErrAccountDisplayNotFound), errors.Is(err, ErrPaymentChannelNotFound), errors.Is(err, ErrSettlementBatchNotFound):
|
||||
response.NotFound(c, "分销资源不存在")
|
||||
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled):
|
||||
response.BadRequest(c, "分销规则参数不正确")
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "分销服务暂时不可用")
|
||||
default:
|
||||
response.RecordError(c, err)
|
||||
response.Error(c, http.StatusInternalServerError, "distribution_error", "分销服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
ModeTwoPartyDistributorReceive = "two_party_distributor_receive"
|
||||
ModeTwoPartyPlatformReceive = "two_party_platform_receive"
|
||||
ModeThreeParty = "three_party"
|
||||
|
||||
ServiceFeeUnitPercent = "percent"
|
||||
ServiceFeeUnitFixedAmount = "fixed_amount"
|
||||
ServiceFeeUnitRatio = "ratio"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidAmount = errors.New("invalid distribution amount")
|
||||
ErrInvalidRate = errors.New("invalid distribution rate")
|
||||
ErrInvalidServiceUnit = errors.New("invalid service fee unit")
|
||||
ErrUnsupportedMode = errors.New("unsupported distribution mode")
|
||||
)
|
||||
|
||||
// Rule 是后台可编辑的分账规则。比例字段按参与方角色命名,避免方向歧义。
|
||||
type Rule struct {
|
||||
Mode string
|
||||
PlatformReceiveRatio float64
|
||||
PlatformSellRatio float64
|
||||
DistributorReceiveRatio float64
|
||||
DistributorSellRatio float64
|
||||
DistributorARatio float64
|
||||
DistributorBRatio float64
|
||||
ServiceFeeValue float64
|
||||
ServiceFeeUnit string
|
||||
PaymentFeeBPS int64
|
||||
TaxEnabled bool
|
||||
TaxRateBPS int64
|
||||
}
|
||||
|
||||
// SettlementInput 使用支付单和退款单的整数分快照计算分销费用。
|
||||
type SettlementInput struct {
|
||||
GrossPaidAmountCent int64
|
||||
RefundAmountCent int64
|
||||
MarkupAmountCent int64
|
||||
GrossProfitByParty []PartyProfitInput
|
||||
}
|
||||
|
||||
type PartyProfitInput struct {
|
||||
PartyRole string
|
||||
IncomeAmountCent int64
|
||||
CostAmountCent int64
|
||||
}
|
||||
|
||||
type PartyTaxResult struct {
|
||||
PartyRole string `json:"party_role"`
|
||||
GrossProfitCent int64 `json:"gross_profit_cent"`
|
||||
TaxAmountCent int64 `json:"tax_amount_cent"`
|
||||
}
|
||||
|
||||
type SettlementResult struct {
|
||||
GrossPaidAmountCent int64
|
||||
RefundAmountCent int64
|
||||
NetPaidAmountCent int64
|
||||
PaymentFeeCent int64
|
||||
ServiceFeeCent int64
|
||||
MarkupAmountCent int64
|
||||
SplitBaseAmountCent int64
|
||||
TaxAmountCent int64
|
||||
TaxByParty []PartyTaxResult
|
||||
}
|
||||
|
||||
// DefaultRule 返回文档中约定的第一版默认值;调用方应将其写入规则表而非写死到业务流程。
|
||||
func DefaultRule(mode string) (Rule, error) {
|
||||
base := Rule{
|
||||
Mode: mode, ServiceFeeValue: 2.5, ServiceFeeUnit: ServiceFeeUnitRatio,
|
||||
PaymentFeeBPS: 50, TaxEnabled: false, TaxRateBPS: 100,
|
||||
}
|
||||
switch mode {
|
||||
case ModeTwoPartyDistributorReceive:
|
||||
base.DistributorReceiveRatio = 3.5
|
||||
base.PlatformSellRatio = 4
|
||||
case ModeTwoPartyPlatformReceive:
|
||||
base.PlatformReceiveRatio = 3.5
|
||||
base.DistributorSellRatio = 4
|
||||
case ModeThreeParty:
|
||||
base.DistributorARatio = 3.5
|
||||
base.DistributorBRatio = 4
|
||||
default:
|
||||
return Rule{}, ErrUnsupportedMode
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// CalculateSettlement 先扣支付手续费,再计算服务费和分成基数;退款金额不能超过支付金额。
|
||||
func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, error) {
|
||||
if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 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 {
|
||||
return SettlementResult{}, ErrInvalidRate
|
||||
}
|
||||
if rule.ServiceFeeUnit != ServiceFeeUnitPercent && rule.ServiceFeeUnit != ServiceFeeUnitFixedAmount && rule.ServiceFeeUnit != ServiceFeeUnitRatio {
|
||||
return SettlementResult{}, ErrInvalidServiceUnit
|
||||
}
|
||||
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
|
||||
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
|
||||
serviceFee, err := serviceFeeCent(rule, netPaid)
|
||||
if err != nil {
|
||||
return SettlementResult{}, err
|
||||
}
|
||||
splitBase := netPaid - paymentFee - serviceFee
|
||||
if splitBase < 0 {
|
||||
splitBase = 0
|
||||
}
|
||||
result := SettlementResult{
|
||||
GrossPaidAmountCent: input.GrossPaidAmountCent,
|
||||
RefundAmountCent: input.RefundAmountCent,
|
||||
NetPaidAmountCent: netPaid,
|
||||
PaymentFeeCent: paymentFee,
|
||||
ServiceFeeCent: serviceFee,
|
||||
MarkupAmountCent: input.MarkupAmountCent,
|
||||
SplitBaseAmountCent: splitBase,
|
||||
}
|
||||
if rule.TaxEnabled {
|
||||
result.TaxByParty = make([]PartyTaxResult, 0, len(input.GrossProfitByParty))
|
||||
for _, party := range input.GrossProfitByParty {
|
||||
profit := party.IncomeAmountCent - party.CostAmountCent
|
||||
tax := roundHalfUp(float64(max(profit, 0)) * float64(rule.TaxRateBPS) / 10000)
|
||||
result.TaxAmountCent += tax
|
||||
result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profit, TaxAmountCent: tax})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func serviceFeeCent(rule Rule, netPaid int64) (int64, error) {
|
||||
switch rule.ServiceFeeUnit {
|
||||
case ServiceFeeUnitPercent:
|
||||
return roundHalfUp(float64(netPaid) * rule.ServiceFeeValue / 100), nil
|
||||
case ServiceFeeUnitFixedAmount:
|
||||
return roundHalfUp(rule.ServiceFeeValue * 100), nil
|
||||
case ServiceFeeUnitRatio:
|
||||
if rule.ServiceFeeValue <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return roundHalfUp(float64(netPaid) / rule.ServiceFeeValue), nil
|
||||
default:
|
||||
return 0, ErrInvalidServiceUnit
|
||||
}
|
||||
}
|
||||
|
||||
func roundHalfUp(value float64) int64 {
|
||||
return int64(math.Floor(value + 0.5))
|
||||
}
|
||||
|
||||
func max(a, b int64) int64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package distribution
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultRules(t *testing.T) {
|
||||
cases := []struct {
|
||||
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 }},
|
||||
}
|
||||
for _, item := range cases {
|
||||
rule, err := DefaultRule(item.mode)
|
||||
if err != nil || !item.check(rule) || rule.ServiceFeeValue != 2.5 || rule.PaymentFeeBPS != 50 {
|
||||
t.Fatalf("default rule %s = %#v, err=%v", item.mode, rule, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSettlementDeductsFeeBeforeSplit(t *testing.T) {
|
||||
rule := Rule{ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50}
|
||||
got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.NetPaidAmountCent != 9001 || got.PaymentFeeCent != 45 || got.SplitBaseAmountCent != 8956 {
|
||||
t.Fatalf("unexpected settlement: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSettlementTaxesEachPositivePartyProfit(t *testing.T) {
|
||||
rule := Rule{ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 0, TaxEnabled: true, TaxRateBPS: 100}
|
||||
got, err := CalculateSettlement(rule, SettlementInput{
|
||||
GrossPaidAmountCent: 10000,
|
||||
GrossProfitByParty: []PartyProfitInput{
|
||||
{PartyRole: "distributor_a", IncomeAmountCent: 1000, CostAmountCent: 400},
|
||||
{PartyRole: "distributor_b", IncomeAmountCent: 500, CostAmountCent: 700},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TaxAmountCent != 6 || len(got.TaxByParty) != 2 || got.TaxByParty[0].TaxAmountCent != 6 || got.TaxByParty[1].TaxAmountCent != 0 {
|
||||
t.Fatalf("unexpected tax result: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateSettlementRejectsRefundOverPayment(t *testing.T) {
|
||||
_, err := CalculateSettlement(Rule{ServiceFeeUnit: ServiceFeeUnitFixedAmount}, SettlementInput{GrossPaidAmountCent: 1, RefundAmountCent: 2})
|
||||
if err != ErrInvalidAmount {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSiteNotFound = errors.New("distribution site not found")
|
||||
ErrRuleNotFound = errors.New("distribution rule not found")
|
||||
ErrOrderNotFound = errors.New("distribution order not found")
|
||||
ErrInvalidOrderKind = errors.New("invalid distribution order kind")
|
||||
ErrAccountDisplayNotFound = errors.New("distribution account display not found")
|
||||
ErrPaymentChannelNotFound = errors.New("distribution payment channel not found")
|
||||
ErrInvalidPeriod = errors.New("invalid distribution settlement period")
|
||||
ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found")
|
||||
ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled")
|
||||
)
|
||||
|
||||
type Repository struct{ db *gorm.DB }
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
|
||||
|
||||
func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
||||
var rows []model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]SiteDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, siteDTO(row))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
|
||||
var row model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSiteNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
item := siteDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
|
||||
row := model.DistributionSite{
|
||||
SiteCode: strings.TrimSpace(req.SiteCode), SiteName: strings.TrimSpace(req.SiteName), Domain: strings.TrimSpace(req.Domain),
|
||||
Status: "active", DisplayAccountEnabled: true, TaxRateBPS: 100,
|
||||
}
|
||||
if req.DisplayAccountEnabled != nil {
|
||||
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
||||
}
|
||||
if req.DefaultSupportGroupID != nil {
|
||||
row.DefaultSupportGroupID = req.DefaultSupportGroupID
|
||||
}
|
||||
if req.TaxEnabled != nil {
|
||||
row.TaxEnabled = *req.TaxEnabled
|
||||
}
|
||||
if req.TaxRateBPS != nil {
|
||||
row.TaxRateBPS = *req.TaxRateBPS
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := siteDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
||||
var row model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSiteNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if req.SiteName != nil {
|
||||
row.SiteName = strings.TrimSpace(*req.SiteName)
|
||||
}
|
||||
if req.Domain != nil {
|
||||
row.Domain = strings.TrimSpace(*req.Domain)
|
||||
}
|
||||
if req.Status != nil {
|
||||
row.Status = strings.TrimSpace(*req.Status)
|
||||
}
|
||||
if req.DisplayAccountEnabled != nil {
|
||||
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
||||
}
|
||||
if req.DefaultSupportGroupID != nil {
|
||||
row.DefaultSupportGroupID = req.DefaultSupportGroupID
|
||||
}
|
||||
if req.TaxEnabled != nil {
|
||||
row.TaxEnabled = *req.TaxEnabled
|
||||
}
|
||||
if req.TaxRateBPS != nil {
|
||||
row.TaxRateBPS = *req.TaxRateBPS
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := siteDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
|
||||
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Order("effective_from DESC, id DESC")
|
||||
if siteID == nil {
|
||||
db = db.Where("site_id IS NULL")
|
||||
} else {
|
||||
db = db.Where("site_id = ?", *siteID)
|
||||
}
|
||||
if mode = strings.TrimSpace(mode); mode != "" {
|
||||
db = db.Where("mode = ?", mode)
|
||||
}
|
||||
var rows []model.DistributionRuleVersion
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]RuleDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, ruleDTO(row))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
|
||||
version := 1
|
||||
var maxVersion int
|
||||
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Where("mode = ?", req.Mode)
|
||||
if req.SiteID == nil {
|
||||
db = db.Where("site_id IS NULL")
|
||||
} else {
|
||||
db = db.Where("site_id = ?", *req.SiteID)
|
||||
}
|
||||
if err := db.Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version = maxVersion + 1
|
||||
effectiveFrom := req.EffectiveFrom
|
||||
if effectiveFrom.IsZero() {
|
||||
effectiveFrom = time.Now()
|
||||
}
|
||||
row := model.DistributionRuleVersion{
|
||||
SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft",
|
||||
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),
|
||||
PaymentFeeBPS: req.PaymentFeeBPS, TaxEnabled: req.TaxEnabled, TaxRateBPS: req.TaxRateBPS,
|
||||
EffectiveFrom: effectiveFrom, CreatedBy: &adminID,
|
||||
}
|
||||
if row.PaymentFeeBPS == 0 {
|
||||
row.PaymentFeeBPS = 50
|
||||
}
|
||||
if row.TaxRateBPS == 0 {
|
||||
row.TaxRateBPS = 100
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := ruleDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
|
||||
var row model.DistributionRuleVersion
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrRuleNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
row.Status = strings.TrimSpace(status)
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := ruleDTO(row)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func siteDTO(row model.DistributionSite) SiteDTO {
|
||||
return SiteDTO{ID: row.ID, SiteCode: row.SiteCode, SiteName: row.SiteName, Domain: row.Domain, Status: row.Status, DisplayAccountEnabled: row.DisplayAccountEnabled, DefaultSupportGroupID: row.DefaultSupportGroupID, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
|
||||
func ruleDTO(row model.DistributionRuleVersion) RuleDTO {
|
||||
return RuleDTO{ID: row.ID, SiteID: row.SiteID, Mode: row.Mode, Version: row.Version, Status: row.Status, PlatformReceiveRatio: row.PlatformReceiveRatio, PlatformSellRatio: row.PlatformSellRatio, DistributorReceiveRatio: row.DistributorReceiveRatio, DistributorSellRatio: row.DistributorSellRatio, DistributorARatio: row.DistributorARatio, DistributorBRatio: row.DistributorBRatio, ServiceFeeValue: row.ServiceFeeValue, ServiceFeeUnit: row.ServiceFeeUnit, PaymentFeeBPS: row.PaymentFeeBPS, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, EffectiveFrom: row.EffectiveFrom, EffectiveTo: row.EffectiveTo, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt}
|
||||
}
|
||||
|
||||
func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
|
||||
page, size := query.Page, query.Size
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
base := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id")
|
||||
if query.SiteID > 0 {
|
||||
base = base.Where("ro.distribution_site_id = ?", query.SiteID)
|
||||
}
|
||||
if query.Status != "" {
|
||||
base = base.Where("ro.status = ?", query.Status)
|
||||
}
|
||||
switch strings.TrimSpace(query.Kind) {
|
||||
case "sold":
|
||||
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_sell','distributor_b'))")
|
||||
case "receive":
|
||||
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_receive','distributor_a'))")
|
||||
case "", "all":
|
||||
default:
|
||||
return nil, ErrInvalidOrderKind
|
||||
}
|
||||
var total int64
|
||||
if err := base.Session(&gorm.Session{}).Select("ro.id").Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]DistributionOrderDTO, 0)
|
||||
selectSQL := "ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, ro.created_at"
|
||||
if err := base.Select(selectSQL).Order("ro.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DistributionOrderPage{Items: items, Total: total, Page: page, PageSize: size}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
|
||||
var item DistributionOrderDTO
|
||||
err := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").Select("ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, ro.created_at").Where("ro.id = ?", id).Scan(&item).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.OrderID == 0 {
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
|
||||
var rows []model.DistributionSiteAccount
|
||||
db := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("id DESC")
|
||||
if status != "" {
|
||||
db = db.Where("display_status = ?", status)
|
||||
}
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]AccountDisplayDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, AccountDisplayDTO{ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
|
||||
var row model.DistributionSiteAccount
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrAccountDisplayNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
row.DisplayStatus = strings.TrimSpace(status)
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &AccountDisplayDTO{ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
|
||||
var rows []model.DistributionSitePaymentChannel
|
||||
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("priority ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]PaymentChannelDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
|
||||
row := model.DistributionSitePaymentChannel{SiteID: siteID, ConfigID: req.ConfigID, Priority: req.Priority, Status: "active"}
|
||||
if err := r.db.WithContext(ctx).Where("site_id = ? AND config_id = ?", siteID, req.ConfigID).FirstOrCreate(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UnbindPaymentChannel(ctx context.Context, id uint64) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.DistributionSitePaymentChannel{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrPaymentChannelNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
|
||||
var rows []model.DistributionSettlementBatch
|
||||
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("id DESC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]SettlementBatchDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64, req CreateSettlementBatchRequest) (*SettlementBatchDTO, error) {
|
||||
if req.PeriodEnd.Before(req.PeriodStart) {
|
||||
return nil, ErrInvalidPeriod
|
||||
}
|
||||
row := model.DistributionSettlementBatch{SiteID: req.SiteID, BatchNo: "DS" + time.Now().Format("20060102150405.000000000"), PeriodStart: req.PeriodStart, PeriodEnd: req.PeriodEnd, Status: "draft", CreatedBy: &adminID}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
|
||||
var row model.DistributionSettlementBatch
|
||||
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSettlementBatchNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if row.Status == "settled" {
|
||||
return nil, ErrBatchAlreadySettled
|
||||
}
|
||||
now := time.Now()
|
||||
row.Status = "settled"
|
||||
row.SettledAt = &now
|
||||
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
|
||||
var rule model.DistributionRuleVersion
|
||||
if err := r.db.WithContext(ctx).First(&rule, req.RuleVersionID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrRuleNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if req.SiteID == 0 || orderID == 0 {
|
||||
return nil, ErrInvalidAmount
|
||||
}
|
||||
partyInputs := make([]PartyProfitInput, 0, len(req.Parties))
|
||||
for _, party := range req.Parties {
|
||||
partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent})
|
||||
}
|
||||
result, err := CalculateSettlement(Rule{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})
|
||||
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, SettlementStatus: "pending"}
|
||||
if err := tx.Where("order_id = ?", orderID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("order_id = ?", orderID).Delete(&model.DistributionOrderParty{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for idx, party := range req.Parties {
|
||||
tax := int64(0)
|
||||
if idx < len(result.TaxByParty) {
|
||||
tax = result.TaxByParty[idx].TaxAmountCent
|
||||
}
|
||||
profit := party.IncomeAmountCent - party.CostAmountCent
|
||||
row := model.DistributionOrderParty{OrderID: orderID, SiteID: req.SiteID, PartyRole: party.PartyRole, PartyType: party.PartyType, PartyID: party.PartyID, Ratio: party.Ratio, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent, ProfitAmountCent: profit, TaxAmountCent: tax}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{"distribution_site_id": req.SiteID, "distribution_rule_version_id": req.RuleVersionID}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.FindOrder(ctx, orderID)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func distributionTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.DistributionSite{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", DisplayAccountEnabled: true, TaxRateBPS: 100}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50, TaxRateBPS: 100, EffectiveFrom: now}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order := model.RentalOrder{OrderNo: "D-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed"}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := repo.CreateFinancialSnapshot(t.Context(), order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300, Parties: []SnapshotPartyRequest{{PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 1000, CostAmountCent: 400}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{SiteID: site.ID, Kind: "receive", Page: 1, Size: 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].PaymentFeeCent != 45 || page.Items[0].NetPaidAmountCent != 9001 {
|
||||
t.Fatalf("unexpected page: %#v", page)
|
||||
}
|
||||
if _, err := repo.FindOrder(t.Context(), order.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", TaxRateBPS: 100}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 9, Priority: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := repo.ListPaymentChannels(t.Context(), site.ID)
|
||||
if err != nil || len(items) != 1 || items[0].ConfigID != 9 {
|
||||
t.Fatalf("channels = %#v, err=%v", items, err)
|
||||
}
|
||||
if err := repo.UnbindPaymentChannel(t.Context(), channel.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now().Add(-time.Hour)
|
||||
batch, err := repo.CreateSettlementBatch(t.Context(), 1, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start, PeriodEnd: time.Now()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settled, err := repo.MarkSettlementBatch(t.Context(), batch.ID)
|
||||
if err != nil || settled.Status != "settled" {
|
||||
t.Fatalf("settled = %#v, err=%v", settled, err)
|
||||
}
|
||||
if _, err := repo.MarkSettlementBatch(t.Context(), batch.ID); err != ErrBatchAlreadySettled {
|
||||
t.Fatalf("second settle err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrDependencyUnavailable = errors.New("distribution dependency unavailable")
|
||||
|
||||
type Service struct{ repo *Repository }
|
||||
|
||||
func NewService(repo *Repository) *Service { return &Service{repo: repo} }
|
||||
|
||||
func (s *Service) available() error {
|
||||
if s == nil || s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListSites(ctx)
|
||||
}
|
||||
func (s *Service) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.FindSite(ctx, id)
|
||||
}
|
||||
func (s *Service) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.CreateSite(ctx, req)
|
||||
}
|
||||
func (s *Service) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.UpdateSite(ctx, id, req)
|
||||
}
|
||||
func (s *Service) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListRules(ctx, siteID, mode)
|
||||
}
|
||||
func (s *Service) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.CreateRule(ctx, adminID, req)
|
||||
}
|
||||
func (s *Service) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.UpdateRuleStatus(ctx, id, status)
|
||||
}
|
||||
|
||||
func (s *Service) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListOrders(ctx, query)
|
||||
}
|
||||
func (s *Service) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.FindOrder(ctx, id)
|
||||
}
|
||||
func (s *Service) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListAccountDisplays(ctx, siteID, status)
|
||||
}
|
||||
func (s *Service) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.UpdateAccountDisplay(ctx, id, status)
|
||||
}
|
||||
func (s *Service) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListPaymentChannels(ctx, siteID)
|
||||
}
|
||||
func (s *Service) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.BindPaymentChannel(ctx, siteID, req)
|
||||
}
|
||||
func (s *Service) UnbindPaymentChannel(ctx context.Context, id uint64) error {
|
||||
if err := s.available(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.UnbindPaymentChannel(ctx, id)
|
||||
}
|
||||
func (s *Service) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListSettlementBatches(ctx, siteID)
|
||||
}
|
||||
func (s *Service) CreateSettlementBatch(ctx context.Context, adminID uint64, req CreateSettlementBatchRequest) (*SettlementBatchDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.CreateSettlementBatch(ctx, adminID, req)
|
||||
}
|
||||
func (s *Service) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.MarkSettlementBatch(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.CreateFinancialSnapshot(ctx, orderID, req)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"hfb_sys/backend/internal/modules/chat"
|
||||
"hfb_sys/backend/internal/modules/chathub"
|
||||
"hfb_sys/backend/internal/modules/dispute"
|
||||
"hfb_sys/backend/internal/modules/distribution"
|
||||
filemodule "hfb_sys/backend/internal/modules/file"
|
||||
"hfb_sys/backend/internal/modules/listing"
|
||||
"hfb_sys/backend/internal/modules/mohong"
|
||||
@@ -308,6 +309,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
}
|
||||
systemConfigService := systemconfig.NewService(systemConfigRepo)
|
||||
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
||||
var distributionRepo *distribution.Repository
|
||||
if deps.DB != nil {
|
||||
distributionRepo = distribution.NewRepository(deps.DB)
|
||||
}
|
||||
distributionService := distribution.NewService(distributionRepo)
|
||||
distributionHandler := distribution.NewHandler(distributionService)
|
||||
backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile)
|
||||
var adminRoleRepo *adminrole.Repository
|
||||
if deps.DB != nil {
|
||||
adminRoleRepo = adminrole.NewRepository(deps.DB, deps.Redis)
|
||||
@@ -588,6 +596,24 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
|
||||
adminRoutes.POST("/files/upload", fileHandler.Upload)
|
||||
adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary)
|
||||
adminRoutes.GET("/distribution/sites", requirePerm("distribution:site_view"), distributionHandler.ListSites)
|
||||
adminRoutes.GET("/distribution/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite)
|
||||
adminRoutes.POST("/distribution/sites", requirePerm("distribution:site_manage"), distributionHandler.CreateSite)
|
||||
adminRoutes.PUT("/distribution/sites/:id", requirePerm("distribution:site_manage"), distributionHandler.UpdateSite)
|
||||
adminRoutes.GET("/distribution/rules", requirePerm("distribution:rule_view"), distributionHandler.ListRules)
|
||||
adminRoutes.POST("/distribution/rules", requirePerm("distribution:rule_manage"), distributionHandler.CreateRule)
|
||||
adminRoutes.PUT("/distribution/rules/:id/status", requirePerm("distribution:rule_manage"), distributionHandler.UpdateRuleStatus)
|
||||
adminRoutes.GET("/distribution/orders", requirePerm("distribution:order_view"), distributionHandler.ListOrders)
|
||||
adminRoutes.GET("/distribution/orders/:id", requirePerm("distribution:order_view"), distributionHandler.FindOrder)
|
||||
adminRoutes.POST("/distribution/orders/:id/financial-snapshot", requirePerm("distribution:settlement_manage"), distributionHandler.CreateFinancialSnapshot)
|
||||
adminRoutes.GET("/distribution/accounts", requirePerm("distribution:account_view"), distributionHandler.ListAccountDisplays)
|
||||
adminRoutes.PUT("/distribution/accounts/:id/display", requirePerm("distribution:account_display"), distributionHandler.UpdateAccountDisplay)
|
||||
adminRoutes.GET("/distribution/payment-channels", requirePerm("distribution:payment_view"), distributionHandler.ListPaymentChannels)
|
||||
adminRoutes.POST("/distribution/payment-channels", requirePerm("distribution:payment_manage"), distributionHandler.BindPaymentChannel)
|
||||
adminRoutes.DELETE("/distribution/payment-channels/:id", requirePerm("distribution:payment_manage"), distributionHandler.UnbindPaymentChannel)
|
||||
adminRoutes.GET("/distribution/settlement-batches", requirePerm("distribution:settlement_view"), distributionHandler.ListSettlementBatches)
|
||||
adminRoutes.POST("/distribution/settlement-batches", requirePerm("distribution:settlement_manage"), distributionHandler.CreateSettlementBatch)
|
||||
adminRoutes.POST("/distribution/settlement-batches/:id/settle", requirePerm("distribution:settlement_manage"), distributionHandler.MarkSettlementBatch)
|
||||
adminRoutes.GET("/files/object", fileHandler.Object)
|
||||
adminRoutes.GET("/users", requirePerm("user:view"), adminUserHandler.List)
|
||||
adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
-- +goose Up
|
||||
|
||||
CREATE TABLE distribution_sites (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
site_code VARCHAR(64) NOT NULL,
|
||||
site_name VARCHAR(128) NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
display_account_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
default_support_group_id BIGINT UNSIGNED NULL,
|
||||
tax_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
tax_rate_bps BIGINT NOT NULL DEFAULT 100,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_distribution_sites_code (site_code),
|
||||
UNIQUE KEY uk_distribution_sites_domain (domain),
|
||||
KEY idx_distribution_sites_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销网站主体';
|
||||
|
||||
CREATE TABLE distribution_site_members (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
site_id BIGINT UNSIGNED NOT NULL,
|
||||
admin_user_id BIGINT UNSIGNED NOT NULL,
|
||||
member_type VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
data_scope VARCHAR(32) NOT NULL DEFAULT 'site',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_distribution_site_member (site_id, admin_user_id),
|
||||
KEY idx_distribution_site_members_admin (admin_user_id, status),
|
||||
CONSTRAINT fk_distribution_site_members_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id),
|
||||
CONSTRAINT fk_distribution_site_members_admin FOREIGN KEY (admin_user_id) REFERENCES admin_users (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销网站后台成员';
|
||||
|
||||
CREATE TABLE distribution_site_accounts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
site_id BIGINT UNSIGNED NOT NULL,
|
||||
account_id BIGINT UNSIGNED NOT NULL,
|
||||
listing_id BIGINT UNSIGNED NOT NULL,
|
||||
display_status VARCHAR(16) NOT NULL DEFAULT 'enabled',
|
||||
display_started_at DATETIME NULL,
|
||||
display_ended_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_distribution_site_account (site_id, account_id),
|
||||
KEY idx_distribution_site_accounts_listing (listing_id),
|
||||
KEY idx_distribution_site_accounts_display (site_id, display_status),
|
||||
CONSTRAINT fk_distribution_site_accounts_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销网站账号展示关系';
|
||||
|
||||
CREATE TABLE distribution_rule_versions (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
site_id BIGINT UNSIGNED NULL,
|
||||
mode VARCHAR(32) NOT NULL,
|
||||
version INT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'draft',
|
||||
platform_receive_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
platform_sell_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
distributor_receive_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
distributor_sell_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
distributor_a_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
distributor_b_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
service_fee_value DECIMAL(12,4) NOT NULL DEFAULT 0,
|
||||
service_fee_unit VARCHAR(24) NOT NULL DEFAULT 'ratio',
|
||||
payment_fee_bps BIGINT NOT NULL DEFAULT 50,
|
||||
tax_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
tax_rate_bps BIGINT NOT NULL DEFAULT 100,
|
||||
effective_from DATETIME NOT NULL,
|
||||
effective_to DATETIME NULL,
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_distribution_rule_version (site_id, mode, version),
|
||||
KEY idx_distribution_rule_effective (site_id, mode, status, effective_from),
|
||||
CONSTRAINT fk_distribution_rule_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id),
|
||||
CONSTRAINT fk_distribution_rule_admin FOREIGN KEY (created_by) REFERENCES admin_users (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销分账规则版本';
|
||||
|
||||
CREATE TABLE distribution_order_financials (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
site_id BIGINT UNSIGNED NOT NULL,
|
||||
rule_version_id BIGINT UNSIGNED NOT NULL,
|
||||
gross_paid_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
refund_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
net_paid_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
payment_fee_bps BIGINT NOT NULL DEFAULT 50,
|
||||
payment_fee_cent BIGINT NOT NULL DEFAULT 0,
|
||||
service_fee_value DECIMAL(12,4) NOT NULL DEFAULT 0,
|
||||
service_fee_unit VARCHAR(24) NOT NULL DEFAULT 'ratio',
|
||||
service_fee_cent BIGINT NOT NULL DEFAULT 0,
|
||||
markup_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
tax_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
tax_rate_bps BIGINT NOT NULL DEFAULT 100,
|
||||
tax_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
split_base_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
rounding_adjustment_cent BIGINT NOT NULL DEFAULT 0,
|
||||
settlement_status VARCHAR(24) NOT NULL DEFAULT 'pending',
|
||||
settled_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_distribution_order_financial_order (order_id),
|
||||
KEY idx_distribution_order_financial_site_status (site_id, settlement_status),
|
||||
CONSTRAINT fk_distribution_order_financial_order FOREIGN KEY (order_id) REFERENCES rental_orders (id),
|
||||
CONSTRAINT fk_distribution_order_financial_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id),
|
||||
CONSTRAINT fk_distribution_order_financial_rule FOREIGN KEY (rule_version_id) REFERENCES distribution_rule_versions (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销正常订单财务快照';
|
||||
|
||||
CREATE TABLE distribution_order_parties (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
site_id BIGINT UNSIGNED NOT NULL,
|
||||
party_role VARCHAR(32) NOT NULL,
|
||||
party_type VARCHAR(16) NOT NULL,
|
||||
party_id BIGINT UNSIGNED NOT NULL,
|
||||
ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
income_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
cost_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
profit_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
tax_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_distribution_order_parties_order (order_id),
|
||||
KEY idx_distribution_order_parties_site_role (site_id, party_role),
|
||||
CONSTRAINT fk_distribution_order_parties_order FOREIGN KEY (order_id) REFERENCES rental_orders (id),
|
||||
CONSTRAINT fk_distribution_order_parties_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销订单参与方快照';
|
||||
|
||||
CREATE TABLE distribution_settlement_batches (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
site_id BIGINT UNSIGNED NOT NULL,
|
||||
batch_no VARCHAR(64) NOT NULL,
|
||||
period_start DATETIME NOT NULL,
|
||||
period_end DATETIME NOT NULL,
|
||||
total_amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'draft',
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
settled_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_distribution_settlement_batch_no (batch_no),
|
||||
KEY idx_distribution_settlement_batches_site_status (site_id, status),
|
||||
CONSTRAINT fk_distribution_settlement_batches_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id),
|
||||
CONSTRAINT fk_distribution_settlement_batches_admin FOREIGN KEY (created_by) REFERENCES admin_users (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销结算批次';
|
||||
|
||||
CREATE TABLE distribution_settlement_items (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
batch_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
party_id BIGINT UNSIGNED NOT NULL,
|
||||
amount_cent BIGINT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_distribution_settlement_item (batch_id, order_id, party_id),
|
||||
KEY idx_distribution_settlement_items_order (order_id),
|
||||
CONSTRAINT fk_distribution_settlement_items_batch FOREIGN KEY (batch_id) REFERENCES distribution_settlement_batches (id),
|
||||
CONSTRAINT fk_distribution_settlement_items_order FOREIGN KEY (order_id) REFERENCES rental_orders (id),
|
||||
CONSTRAINT fk_distribution_settlement_items_party FOREIGN KEY (party_id) REFERENCES distribution_order_parties (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销结算明细';
|
||||
|
||||
CREATE TABLE distribution_site_payment_channels (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
site_id BIGINT UNSIGNED NOT NULL,
|
||||
config_id BIGINT UNSIGNED NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_distribution_site_payment (site_id, config_id),
|
||||
KEY idx_distribution_site_payment_status (site_id, status, priority),
|
||||
CONSTRAINT fk_distribution_site_payment_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id),
|
||||
CONSTRAINT fk_distribution_site_payment_config FOREIGN KEY (config_id) REFERENCES payment_merchant_configs (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销网站收款通道绑定';
|
||||
|
||||
ALTER TABLE rental_orders
|
||||
ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER source_channel,
|
||||
ADD COLUMN distribution_mode VARCHAR(32) NOT NULL DEFAULT '' AFTER distribution_site_id,
|
||||
ADD COLUMN distribution_rule_version_id BIGINT UNSIGNED NULL AFTER distribution_mode,
|
||||
ADD KEY idx_rental_orders_distribution_site (distribution_site_id, distribution_mode),
|
||||
ADD CONSTRAINT fk_rental_orders_distribution_site FOREIGN KEY (distribution_site_id) REFERENCES distribution_sites (id),
|
||||
ADD CONSTRAINT fk_rental_orders_distribution_rule FOREIGN KEY (distribution_rule_version_id) REFERENCES distribution_rule_versions (id);
|
||||
|
||||
INSERT INTO permissions (code, name, resource, action) VALUES
|
||||
('distribution:site_view', '查看分销网站', 'distribution', 'site_view'),
|
||||
('distribution:site_manage', '管理分销网站', 'distribution', 'site_manage'),
|
||||
('distribution:order_view', '查看分销订单', 'distribution', 'order_view'),
|
||||
('distribution:process_view', '查看分销订单过程', 'distribution', 'process_view'),
|
||||
('distribution:finance_view', '查看分销财务', 'distribution', 'finance_view'),
|
||||
('distribution:settlement_view', '查看分销结算', 'distribution', 'settlement_view'),
|
||||
('distribution:settlement_manage', '管理分销结算', 'distribution', 'settlement_manage'),
|
||||
('distribution:rule_view', '查看分销规则', 'distribution', 'rule_view'),
|
||||
('distribution:rule_manage', '管理分销规则', 'distribution', 'rule_manage'),
|
||||
('distribution:account_view', '查看分销账号', 'distribution', 'account_view'),
|
||||
('distribution:account_display', '管理分销账号展示', 'distribution', 'account_display'),
|
||||
('distribution:payment_view', '查看分销支付通道', 'distribution', 'payment_view'),
|
||||
('distribution:payment_manage', '管理分销支付通道', 'distribution', 'payment_manage'),
|
||||
('distribution:chat_view', '查看分销客服消息', 'distribution', 'chat_view'),
|
||||
('distribution:chat_send', '发送分销客服消息', 'distribution', 'chat_send')
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), resource = VALUES(resource), action = VALUES(action);
|
||||
|
||||
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
|
||||
WHERE r.code = 'super_admin' AND p.code LIKE 'distribution:%';
|
||||
|
||||
INSERT INTO distribution_rule_versions (
|
||||
site_id, mode, version, status,
|
||||
platform_receive_ratio, platform_sell_ratio,
|
||||
distributor_receive_ratio, distributor_sell_ratio,
|
||||
distributor_a_ratio, distributor_b_ratio,
|
||||
service_fee_value, service_fee_unit, payment_fee_bps,
|
||||
tax_enabled, tax_rate_bps, effective_from, created_at
|
||||
) VALUES
|
||||
(NULL, 'two_party_distributor_receive', 1, 'active', 0, 4, 3.5, 0, 0, 0, 2.5, 'ratio', 50, 0, 100, NOW(), NOW()),
|
||||
(NULL, 'two_party_platform_receive', 1, 'active', 3.5, 0, 0, 4, 0, 0, 2.5, 'ratio', 50, 0, 100, NOW(), NOW()),
|
||||
(NULL, 'three_party', 1, 'active', 0, 0, 0, 0, 3.5, 4, 2.5, 'ratio', 50, 0, 100, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
service_fee_value = VALUES(service_fee_value),
|
||||
service_fee_unit = VALUES(service_fee_unit),
|
||||
payment_fee_bps = VALUES(payment_fee_bps);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP TABLE IF EXISTS distribution_order_financials;
|
||||
DROP TABLE IF EXISTS distribution_site_payment_channels;
|
||||
DROP TABLE IF EXISTS distribution_settlement_items;
|
||||
DROP TABLE IF EXISTS distribution_settlement_batches;
|
||||
DROP TABLE IF EXISTS distribution_order_parties;
|
||||
ALTER TABLE rental_orders
|
||||
DROP FOREIGN KEY fk_rental_orders_distribution_rule,
|
||||
DROP FOREIGN KEY fk_rental_orders_distribution_site,
|
||||
DROP KEY idx_rental_orders_distribution_site,
|
||||
DROP COLUMN distribution_rule_version_id,
|
||||
DROP COLUMN distribution_mode,
|
||||
DROP COLUMN distribution_site_id;
|
||||
DROP TABLE IF EXISTS distribution_rule_versions;
|
||||
DROP TABLE IF EXISTS distribution_site_accounts;
|
||||
DROP TABLE IF EXISTS distribution_site_members;
|
||||
DROP TABLE IF EXISTS distribution_sites;
|
||||
|
||||
DELETE rp FROM role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE p.code LIKE 'distribution:%';
|
||||
|
||||
DELETE FROM permissions WHERE code IN (
|
||||
'distribution:site_view', 'distribution:site_manage', 'distribution:order_view', 'distribution:process_view',
|
||||
'distribution:finance_view', 'distribution:settlement_view', 'distribution:settlement_manage',
|
||||
'distribution:rule_view', 'distribution:rule_manage', 'distribution:account_view', 'distribution:account_display',
|
||||
'distribution:payment_view', 'distribution:payment_manage', 'distribution:chat_view', 'distribution:chat_send'
|
||||
);
|
||||
Reference in New Issue
Block a user