diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 37e2909..86d96df 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -5,6 +5,7 @@ import ( assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record" authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth" carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier" + commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal" commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting" @@ -43,6 +44,7 @@ type services struct { ShopCommission *shopCommissionSvc.Service CommissionWithdrawal *commissionWithdrawalSvc.Service CommissionWithdrawalSetting *commissionWithdrawalSettingSvc.Service + CommissionCalculation *commissionCalculationSvc.Service Enterprise *enterpriseSvc.Service EnterpriseCard *enterpriseCardSvc.Service Authorization *enterpriseCardSvc.AuthorizationService @@ -79,25 +81,41 @@ func initServices(s *stores, deps *Dependencies) *services { ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionRecord), CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.Wallet, s.WalletTransaction, s.CommissionWithdrawalRequest), CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting), - Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account), - EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization), - Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger), - CustomerAccount: customerAccountSvc.New(deps.DB, s.Account, s.Shop, s.Enterprise), - MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction), - IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopSeriesAllocation), - IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient), - Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopSeriesAllocation), - DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient), - AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account), - Carrier: carrierSvc.New(s.Carrier), - PackageSeries: packageSeriesSvc.New(s.PackageSeries), - Package: packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.ShopSeriesCommissionTier), - ShopSeriesAllocation: shopSeriesAllocationSvc.New(s.ShopSeriesAllocation, s.ShopSeriesCommissionTier, s.ShopSeriesAllocationConfig, s.Shop, s.PackageSeries, s.Package), - ShopPackageAllocation: shopPackageAllocationSvc.New(s.ShopPackageAllocation, s.ShopSeriesAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package), - ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopSeriesAllocationConfig, s.ShopSeriesCommissionTier, s.ShopSeriesCommissionStats, s.Shop), - ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop), - CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), - PurchaseValidation: purchaseValidation, - Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopSeriesAllocationConfig), + CommissionCalculation: commissionCalculationSvc.New( + deps.DB, + s.CommissionRecord, + s.Shop, + s.ShopSeriesAllocation, + s.ShopSeriesOneTimeCommissionTier, + s.IotCard, + s.Device, + s.Wallet, + s.WalletTransaction, + s.Order, + s.OrderItem, + s.Package, + commissionStatsSvc.New(s.ShopSeriesCommissionStats), + deps.Logger, + ), + Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account), + EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization), + Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger), + CustomerAccount: customerAccountSvc.New(deps.DB, s.Account, s.Shop, s.Enterprise), + MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction), + IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopSeriesAllocation), + IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient), + Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopSeriesAllocation), + DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient), + AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account), + Carrier: carrierSvc.New(s.Carrier), + PackageSeries: packageSeriesSvc.New(s.PackageSeries), + Package: packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.ShopSeriesCommissionTier), + ShopSeriesAllocation: shopSeriesAllocationSvc.New(s.ShopSeriesAllocation, s.ShopSeriesCommissionTier, s.ShopSeriesAllocationConfig, s.Shop, s.PackageSeries, s.Package), + ShopPackageAllocation: shopPackageAllocationSvc.New(s.ShopPackageAllocation, s.ShopSeriesAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package), + ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopSeriesAllocationConfig, s.ShopSeriesCommissionTier, s.ShopSeriesCommissionStats, s.Shop), + ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop), + CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), + PurchaseValidation: purchaseValidation, + Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopSeriesAllocationConfig), } } diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index ccce4b5..5139d04 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -31,6 +31,7 @@ type stores struct { Package *postgres.PackageStore ShopSeriesAllocation *postgres.ShopSeriesAllocationStore ShopSeriesCommissionTier *postgres.ShopSeriesCommissionTierStore + ShopSeriesOneTimeCommissionTier *postgres.ShopSeriesOneTimeCommissionTierStore ShopSeriesAllocationConfig *postgres.ShopSeriesAllocationConfigStore ShopPackageAllocation *postgres.ShopPackageAllocationStore ShopPackageAllocationPriceHistory *postgres.ShopPackageAllocationPriceHistoryStore @@ -67,6 +68,7 @@ func initStores(deps *Dependencies) *stores { Package: postgres.NewPackageStore(deps.DB), ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB), ShopSeriesCommissionTier: postgres.NewShopSeriesCommissionTierStore(deps.DB), + ShopSeriesOneTimeCommissionTier: postgres.NewShopSeriesOneTimeCommissionTierStore(deps.DB), ShopSeriesAllocationConfig: postgres.NewShopSeriesAllocationConfigStore(deps.DB), ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB), ShopPackageAllocationPriceHistory: postgres.NewShopPackageAllocationPriceHistoryStore(deps.DB), diff --git a/internal/handler/admin/my_commission.go b/internal/handler/admin/my_commission.go index 0e4d5f2..a8ee20d 100644 --- a/internal/handler/admin/my_commission.go +++ b/internal/handler/admin/my_commission.go @@ -67,3 +67,31 @@ func (h *MyCommissionHandler) ListRecords(c *fiber.Ctx) error { return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size) } + +func (h *MyCommissionHandler) GetStats(c *fiber.Ctx) error { + var req dto.CommissionStatsRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + result, err := h.service.GetStats(c.UserContext(), &req) + if err != nil { + return err + } + + return response.Success(c, result) +} + +func (h *MyCommissionHandler) GetDailyStats(c *fiber.Ctx) error { + var req dto.DailyCommissionStatsRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + result, err := h.service.GetDailyStats(c.UserContext(), &req) + if err != nil { + return err + } + + return response.Success(c, result) +} diff --git a/internal/model/commission.go b/internal/model/commission.go index 6185a4c..bcebf47 100644 --- a/internal/model/commission.go +++ b/internal/model/commission.go @@ -6,24 +6,43 @@ import ( "gorm.io/gorm" ) -// CommissionRecord 分佣记录模型 -// 记录分佣的冻结、解冻、发放状态 +// CommissionRecord 佣金记录模型 +// 记录各级代理的佣金入账情况 +// 包含成本价差收入、一次性佣金、梯度奖励等多种佣金来源 type CommissionRecord struct { gorm.Model - BaseModel `gorm:"embedded"` - AgentID uint `gorm:"column:agent_id;index;not null;comment:代理用户ID" json:"agent_id"` - ShopID uint `gorm:"column:shop_id;index;comment:店铺ID(佣金主要跟着店铺走)" json:"shop_id"` - OrderID uint `gorm:"column:order_id;index;not null;comment:订单ID" json:"order_id"` - RuleID uint `gorm:"column:rule_id;index;not null;comment:分佣规则ID" json:"rule_id"` - CommissionType string `gorm:"column:commission_type;type:varchar(50);not null;comment:分佣类型 one_time-一次性 long_term-长期" json:"commission_type"` - Amount int64 `gorm:"column:amount;type:bigint;not null;comment:分佣金额(分为单位)" json:"amount"` - BalanceAfter int64 `gorm:"column:balance_after;type:bigint;default:0;comment:入账后佣金余额(分)" json:"balance_after"` - Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-已冻结 2-解冻中 3-已发放 4-已失效" json:"status"` - UnfrozenAt *time.Time `gorm:"column:unfrozen_at;comment:解冻时间" json:"unfrozen_at"` - ReleasedAt *time.Time `gorm:"column:released_at;comment:发放时间" json:"released_at"` + BaseModel `gorm:"embedded"` + ShopID uint `gorm:"column:shop_id;index;not null;comment:店铺ID(佣金归属)" json:"shop_id"` + OrderID uint `gorm:"column:order_id;index;not null;comment:订单ID" json:"order_id"` + IotCardID *uint `gorm:"column:iot_card_id;index;comment:关联卡ID(可空)" json:"iot_card_id"` + DeviceID *uint `gorm:"column:device_id;index;comment:关联设备ID(可空)" json:"device_id"` + CommissionSource string `gorm:"column:commission_source;type:varchar(20);not null;index;comment:佣金来源 cost_diff-成本价差 one_time-一次性佣金 tier_bonus-梯度奖励" json:"commission_source"` + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:佣金金额(分)" json:"amount"` + BalanceAfter int64 `gorm:"column:balance_after;type:bigint;default:0;comment:入账后钱包余额(分)" json:"balance_after"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-已入账 2-已失效" json:"status"` + ReleasedAt *time.Time `gorm:"column:released_at;comment:入账时间" json:"released_at"` + Remark string `gorm:"column:remark;type:varchar(500);comment:备注" json:"remark"` } // TableName 指定表名 func (CommissionRecord) TableName() string { return "tb_commission_record" } + +// 佣金来源常量 +const ( + // CommissionSourceCostDiff 成本价差收入 + CommissionSourceCostDiff = "cost_diff" + // CommissionSourceOneTime 一次性佣金 + CommissionSourceOneTime = "one_time" + // CommissionSourceTierBonus 梯度奖励 + CommissionSourceTierBonus = "tier_bonus" +) + +// 佣金状态常量 +const ( + // CommissionStatusReleased 已入账 + CommissionStatusReleased = 1 + // CommissionStatusInvalid 已失效 + CommissionStatusInvalid = 2 +) diff --git a/internal/model/dto/commission.go b/internal/model/dto/commission.go new file mode 100644 index 0000000..1440463 --- /dev/null +++ b/internal/model/dto/commission.go @@ -0,0 +1,78 @@ +package dto + +// CommissionRecordResponse 佣金记录响应 +type CommissionRecordResponse struct { + ID uint `json:"id" description:"佣金记录ID"` + ShopID uint `json:"shop_id" description:"店铺ID"` + ShopName string `json:"shop_name" description:"店铺名称"` + OrderID uint `json:"order_id" description:"订单ID"` + OrderNo string `json:"order_no" description:"订单号"` + IotCardID *uint `json:"iot_card_id" description:"关联卡ID"` + IotCardICCID string `json:"iot_card_iccid" description:"卡ICCID"` + DeviceID *uint `json:"device_id" description:"关联设备ID"` + DeviceNo string `json:"device_no" description:"设备号"` + CommissionSource string `json:"commission_source" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus:梯度奖励)"` + Amount int64 `json:"amount" description:"佣金金额(分)"` + BalanceAfter int64 `json:"balance_after" description:"入账后钱包余额(分)"` + Status int `json:"status" description:"状态 (1:已入账, 2:已失效)"` + ReleasedAt string `json:"released_at" description:"入账时间"` + Remark string `json:"remark" description:"备注"` + CreatedAt string `json:"created_at" description:"创建时间"` +} + +// CommissionRecordListRequest 佣金记录列表请求 +type CommissionRecordListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"店铺ID"` + CommissionSource *string `json:"commission_source" query:"commission_source" validate:"omitempty,oneof=cost_diff one_time tier_bonus" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus:梯度奖励)"` + StartTime *string `json:"start_time" query:"start_time" validate:"omitempty" description:"开始时间"` + EndTime *string `json:"end_time" query:"end_time" validate:"omitempty" description:"结束时间"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=1 2" description:"状态 (1:已入账, 2:已失效)"` +} + +// CommissionRecordPageResult 佣金记录分页结果 +type CommissionRecordPageResult struct { + List []*CommissionRecordResponse `json:"list" description:"佣金记录列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"page_size" description:"每页数量"` + TotalPages int `json:"total_pages" description:"总页数"` +} + +// CommissionStatsResponse 佣金统计响应 +type CommissionStatsResponse struct { + TotalAmount int64 `json:"total_amount" description:"总收入(分)"` + CostDiffAmount int64 `json:"cost_diff_amount" description:"成本价差收入(分)"` + OneTimeAmount int64 `json:"one_time_amount" description:"一次性佣金收入(分)"` + TierBonusAmount int64 `json:"tier_bonus_amount" description:"梯度奖励收入(分)"` + CostDiffPercent int64 `json:"cost_diff_percent" description:"成本价差占比(千分比)"` + OneTimePercent int64 `json:"one_time_percent" description:"一次性佣金占比(千分比)"` + TierBonusPercent int64 `json:"tier_bonus_percent" description:"梯度奖励占比(千分比)"` + TotalCount int64 `json:"total_count" description:"总笔数"` + CostDiffCount int64 `json:"cost_diff_count" description:"成本价差笔数"` + OneTimeCount int64 `json:"one_time_count" description:"一次性佣金笔数"` + TierBonusCount int64 `json:"tier_bonus_count" description:"梯度奖励笔数"` +} + +// DailyCommissionStatsResponse 每日佣金统计响应 +type DailyCommissionStatsResponse struct { + Date string `json:"date" description:"日期(YYYY-MM-DD)"` + TotalAmount int64 `json:"total_amount" description:"当日总收入(分)"` + TotalCount int64 `json:"total_count" description:"当日总笔数"` +} + +// CommissionStatsRequest 佣金统计请求 +type CommissionStatsRequest struct { + ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"店铺ID"` + StartTime *string `json:"start_time" query:"start_time" validate:"omitempty" description:"开始时间"` + EndTime *string `json:"end_time" query:"end_time" validate:"omitempty" description:"结束时间"` +} + +// DailyCommissionStatsRequest 每日佣金统计请求 +type DailyCommissionStatsRequest struct { + ShopID *uint `json:"shop_id" query:"shop_id" validate:"omitempty" description:"店铺ID"` + StartDate *string `json:"start_date" query:"start_date" validate:"omitempty" description:"开始日期(YYYY-MM-DD)"` + EndDate *string `json:"end_date" query:"end_date" validate:"omitempty" description:"结束日期(YYYY-MM-DD)"` + Days *int `json:"days" query:"days" validate:"omitempty,min=1,max=365" minimum:"1" maximum:"365" description:"查询天数(默认30天)"` +} diff --git a/internal/model/dto/my_commission_dto.go b/internal/model/dto/my_commission_dto.go index f64cd77..569cbb5 100644 --- a/internal/model/dto/my_commission_dto.go +++ b/internal/model/dto/my_commission_dto.go @@ -39,23 +39,23 @@ type MyWithdrawalListReq struct { } type MyCommissionRecordListReq struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - CommissionType *string `json:"commission_type" query:"commission_type" description:"佣金类型"` - ICCID string `json:"iccid" query:"iccid" description:"ICCID(模糊查询)"` - DeviceNo string `json:"device_no" query:"device_no" description:"设备号(模糊查询)"` - OrderNo string `json:"order_no" query:"order_no" description:"订单号(模糊查询)"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + CommissionSource *string `json:"commission_source" query:"commission_source" validate:"omitempty,oneof=cost_diff one_time tier_bonus" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus:梯度奖励)"` + ICCID string `json:"iccid" query:"iccid" description:"ICCID(模糊查询)"` + DeviceNo string `json:"device_no" query:"device_no" description:"设备号(模糊查询)"` + OrderNo string `json:"order_no" query:"order_no" description:"订单号(模糊查询)"` } type MyCommissionRecordItem struct { - ID uint `json:"id" description:"佣金记录ID"` - ShopID uint `json:"shop_id" description:"店铺ID"` - OrderID uint `json:"order_id" description:"订单ID"` - CommissionType string `json:"commission_type" description:"佣金类型 (one_time:一次性, long_term:长期)"` - Amount int64 `json:"amount" description:"佣金金额(分)"` - Status int `json:"status" description:"状态 (1:已冻结, 2:解冻中, 3:已发放, 4:已失效)"` - StatusName string `json:"status_name" description:"状态名称"` - CreatedAt string `json:"created_at" description:"创建时间"` + ID uint `json:"id" description:"佣金记录ID"` + ShopID uint `json:"shop_id" description:"店铺ID"` + OrderID uint `json:"order_id" description:"订单ID"` + CommissionSource string `json:"commission_source" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus:梯度奖励)"` + Amount int64 `json:"amount" description:"佣金金额(分)"` + Status int `json:"status" description:"状态 (1:已入账, 2:已失效)"` + StatusName string `json:"status_name" description:"状态名称"` + CreatedAt string `json:"created_at" description:"创建时间"` } type MyCommissionRecordPageResult struct { diff --git a/internal/model/dto/shop_commission_dto.go b/internal/model/dto/shop_commission_dto.go index 50d5155..050dbc4 100644 --- a/internal/model/dto/shop_commission_dto.go +++ b/internal/model/dto/shop_commission_dto.go @@ -93,29 +93,29 @@ type ShopWithdrawalRequestPageResult struct { // ShopCommissionRecordListReq 代理商佣金明细查询请求 type ShopCommissionRecordListReq struct { - ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"` - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"` - CommissionType string `json:"commission_type" query:"commission_type" validate:"omitempty,oneof=one_time long_term" description:"佣金类型 (one_time:一次性, long_term:长期)"` - ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=50" maxLength:"50" description:"ICCID(模糊查询)"` - DeviceNo string `json:"device_no" query:"device_no" validate:"omitempty,max=50" maxLength:"50" description:"设备号(模糊查询)"` - OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=50" maxLength:"50" description:"订单号(模糊查询)"` + ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"` + CommissionSource string `json:"commission_source" query:"commission_source" validate:"omitempty,oneof=cost_diff one_time tier_bonus" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus:梯度奖励)"` + ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=50" maxLength:"50" description:"ICCID(模糊查询)"` + DeviceNo string `json:"device_no" query:"device_no" validate:"omitempty,max=50" maxLength:"50" description:"设备号(模糊查询)"` + OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=50" maxLength:"50" description:"订单号(模糊查询)"` } // ShopCommissionRecordItem 代理商佣金明细项 type ShopCommissionRecordItem struct { - ID uint `json:"id" description:"佣金记录ID"` - Amount int64 `json:"amount" description:"佣金金额(分)"` - BalanceAfter int64 `json:"balance_after" description:"入账后佣金余额(分)"` - CommissionType string `json:"commission_type" description:"佣金类型 (one_time:一次性, long_term:长期)"` - Status int `json:"status" description:"状态 (1:已冻结, 2:解冻中, 3:已发放, 4:已失效)"` - StatusName string `json:"status_name" description:"状态名称"` - OrderID uint `json:"order_id" description:"订单ID"` - OrderNo string `json:"order_no" description:"订单号"` - DeviceNo string `json:"device_no,omitempty" description:"设备号"` - ICCID string `json:"iccid,omitempty" description:"ICCID"` - OrderCreatedAt string `json:"order_created_at" description:"订单创建时间"` - CreatedAt string `json:"created_at" description:"佣金入账时间"` + ID uint `json:"id" description:"佣金记录ID"` + Amount int64 `json:"amount" description:"佣金金额(分)"` + BalanceAfter int64 `json:"balance_after" description:"入账后佣金余额(分)"` + CommissionSource string `json:"commission_source" description:"佣金来源 (cost_diff:成本价差, one_time:一次性佣金, tier_bonus:梯度奖励)"` + Status int `json:"status" description:"状态 (1:已入账, 2:已失效)"` + StatusName string `json:"status_name" description:"状态名称"` + OrderID uint `json:"order_id" description:"订单ID"` + OrderNo string `json:"order_no" description:"订单号"` + DeviceNo string `json:"device_no,omitempty" description:"设备号"` + ICCID string `json:"iccid,omitempty" description:"ICCID"` + OrderCreatedAt string `json:"order_created_at" description:"订单创建时间"` + CreatedAt string `json:"created_at" description:"佣金入账时间"` } // ShopCommissionRecordPageResult 代理商佣金明细分页响应 diff --git a/internal/model/dto/shop_series_allocation.go b/internal/model/dto/shop_series_allocation.go index 370ada4..7762a72 100644 --- a/internal/model/dto/shop_series_allocation.go +++ b/internal/model/dto/shop_series_allocation.go @@ -20,20 +20,42 @@ type TierEntry struct { Value int64 `json:"value" validate:"required,min=1" required:"true" minimum:"1" description:"达标后返佣值(分或千分比)"` } +// OneTimeCommissionConfig 一次性佣金配置 +type OneTimeCommissionConfig struct { + Type string `json:"type" validate:"required,oneof=fixed tiered" required:"true" description:"一次性佣金类型 (fixed:固定, tiered:梯度)"` + Trigger string `json:"trigger" validate:"required,oneof=single_recharge accumulated_recharge" required:"true" description:"触发条件 (single_recharge:单次充值, accumulated_recharge:累计充值)"` + Threshold int64 `json:"threshold" validate:"required,min=1" required:"true" minimum:"1" description:"最低阈值(分)"` + Mode string `json:"mode" validate:"omitempty,oneof=fixed percent" description:"返佣模式 (fixed:固定金额, percent:百分比) - 固定类型时必填"` + Value int64 `json:"value" validate:"omitempty,min=1" minimum:"1" description:"佣金金额(分)或比例(千分比)- 固定类型时必填"` + Tiers []OneTimeCommissionTierEntry `json:"tiers" validate:"omitempty,dive" description:"梯度档位列表 - 梯度类型时必填"` +} + +// OneTimeCommissionTierEntry 一次性佣金梯度档位条目 +type OneTimeCommissionTierEntry struct { + TierType string `json:"tier_type" validate:"required,oneof=sales_count sales_amount" required:"true" description:"梯度类型 (sales_count:销量, sales_amount:销售额)"` + Threshold int64 `json:"threshold" validate:"required,min=1" required:"true" minimum:"1" description:"梯度阈值(销量或销售额分)"` + Mode string `json:"mode" validate:"required,oneof=fixed percent" required:"true" description:"返佣模式 (fixed:固定金额, percent:百分比)"` + Value int64 `json:"value" validate:"required,min=1" required:"true" minimum:"1" description:"返佣值(分或千分比)"` +} + // CreateShopSeriesAllocationRequest 创建套餐系列分配请求 type CreateShopSeriesAllocationRequest struct { - ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"` - SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"` - BaseCommission BaseCommissionConfig `json:"base_commission" validate:"required" required:"true" description:"基础返佣配置"` - EnableTierCommission bool `json:"enable_tier_commission" description:"是否启用梯度返佣"` - TierConfig *TierCommissionConfig `json:"tier_config" validate:"omitempty" description:"梯度返佣配置(启用梯度返佣时必填)"` + ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"` + SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"` + BaseCommission BaseCommissionConfig `json:"base_commission" validate:"required" required:"true" description:"基础返佣配置"` + EnableTierCommission bool `json:"enable_tier_commission" description:"是否启用梯度返佣"` + TierConfig *TierCommissionConfig `json:"tier_config" validate:"omitempty" description:"梯度返佣配置(启用梯度返佣时必填)"` + EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` + OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金配置(启用一次性佣金时必填)"` } // UpdateShopSeriesAllocationRequest 更新套餐系列分配请求 type UpdateShopSeriesAllocationRequest struct { - BaseCommission *BaseCommissionConfig `json:"base_commission" validate:"omitempty" description:"基础返佣配置"` - EnableTierCommission *bool `json:"enable_tier_commission" description:"是否启用梯度返佣"` - TierConfig *TierCommissionConfig `json:"tier_config" validate:"omitempty" description:"梯度返佣配置"` + BaseCommission *BaseCommissionConfig `json:"base_commission" validate:"omitempty" description:"基础返佣配置"` + EnableTierCommission *bool `json:"enable_tier_commission" description:"是否启用梯度返佣"` + TierConfig *TierCommissionConfig `json:"tier_config" validate:"omitempty" description:"梯度返佣配置"` + EnableOneTimeCommission *bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` + OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金配置"` } // ShopSeriesAllocationListRequest 套餐系列分配列表请求 @@ -52,18 +74,20 @@ type UpdateShopSeriesAllocationStatusRequest struct { // ShopSeriesAllocationResponse 套餐系列分配响应 type ShopSeriesAllocationResponse struct { - ID uint `json:"id" description:"分配ID"` - ShopID uint `json:"shop_id" description:"被分配的店铺ID"` - ShopName string `json:"shop_name" description:"被分配的店铺名称"` - SeriesID uint `json:"series_id" description:"套餐系列ID"` - SeriesName string `json:"series_name" description:"套餐系列名称"` - AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID"` - AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"` - BaseCommission BaseCommissionConfig `json:"base_commission" description:"基础返佣配置"` - EnableTierCommission bool `json:"enable_tier_commission" description:"是否启用梯度返佣"` - Status int `json:"status" description:"状态 (1:启用, 2:禁用)"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + ID uint `json:"id" description:"分配ID"` + ShopID uint `json:"shop_id" description:"被分配的店铺ID"` + ShopName string `json:"shop_name" description:"被分配的店铺名称"` + SeriesID uint `json:"series_id" description:"套餐系列ID"` + SeriesName string `json:"series_name" description:"套餐系列名称"` + AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID"` + AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"` + BaseCommission BaseCommissionConfig `json:"base_commission" description:"基础返佣配置"` + EnableTierCommission bool `json:"enable_tier_commission" description:"是否启用梯度返佣"` + EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` + OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config,omitempty" description:"一次性佣金配置"` + Status int `json:"status" description:"状态 (1:启用, 2:禁用)"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` } // ShopSeriesAllocationPageResult 套餐系列分配分页结果 diff --git a/internal/model/order.go b/internal/model/order.go index 9c6a22f..aca14ab 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -34,8 +34,11 @@ type Order struct { PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` // 佣金信息 - CommissionStatus int `gorm:"column:commission_status;type:int;default:1;not null;comment:佣金状态 1-待计算 2-已计算" json:"commission_status"` - CommissionConfigVersion int `gorm:"column:commission_config_version;type:int;default:0;comment:佣金配置版本(订单创建时快照)" json:"commission_config_version"` + CommissionStatus int `gorm:"column:commission_status;type:int;default:1;not null;comment:佣金状态 1-待计算 2-已计算" json:"commission_status"` + CommissionConfigVersion int `gorm:"column:commission_config_version;type:int;default:0;comment:佣金配置版本(订单创建时快照)" json:"commission_config_version"` + SellerShopID *uint `gorm:"column:seller_shop_id;index;comment:销售店铺ID(用于成本价差佣金计算)" json:"seller_shop_id,omitempty"` + SellerCostPrice int64 `gorm:"column:seller_cost_price;type:bigint;default:0;comment:销售成本价(分,用于计算利润)" json:"seller_cost_price"` + SeriesID *uint `gorm:"column:series_id;index;comment:系列ID(用于查询分配配置)" json:"series_id,omitempty"` } // TableName 指定表名 diff --git a/internal/model/shop_series_allocation.go b/internal/model/shop_series_allocation.go index b856512..42750d9 100644 --- a/internal/model/shop_series_allocation.go +++ b/internal/model/shop_series_allocation.go @@ -16,7 +16,16 @@ type ShopSeriesAllocation struct { BaseCommissionMode string `gorm:"column:base_commission_mode;type:varchar(20);not null;default:percent;comment:基础返佣模式 fixed-固定金额 percent-百分比" json:"base_commission_mode"` BaseCommissionValue int64 `gorm:"column:base_commission_value;type:bigint;not null;default:0;comment:基础返佣值(分或千分比,如200=20%)" json:"base_commission_value"` EnableTierCommission bool `gorm:"column:enable_tier_commission;type:boolean;not null;default:false;comment:是否启用梯度返佣" json:"enable_tier_commission"` - Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` + + // 一次性佣金配置 + EnableOneTimeCommission bool `gorm:"column:enable_one_time_commission;type:boolean;not null;default:false;comment:是否启用一次性佣金" json:"enable_one_time_commission"` + OneTimeCommissionType string `gorm:"column:one_time_commission_type;type:varchar(20);comment:一次性佣金类型 fixed-固定 tiered-梯度" json:"one_time_commission_type"` + OneTimeCommissionTrigger string `gorm:"column:one_time_commission_trigger;type:varchar(30);comment:触发条件 single_recharge-单次充值 accumulated_recharge-累计充值" json:"one_time_commission_trigger"` + OneTimeCommissionThreshold int64 `gorm:"column:one_time_commission_threshold;type:bigint;default:0;comment:最低阈值(分)" json:"one_time_commission_threshold"` + OneTimeCommissionMode string `gorm:"column:one_time_commission_mode;type:varchar(20);comment:返佣模式 fixed-固定金额 percent-百分比" json:"one_time_commission_mode"` + OneTimeCommissionValue int64 `gorm:"column:one_time_commission_value;type:bigint;default:0;comment:佣金金额(分)或比例(千分比)" json:"one_time_commission_value"` + + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` } // TableName 指定表名 @@ -32,10 +41,18 @@ const ( CommissionModePercent = "percent" ) +// 一次性佣金类型常量 +const ( + // OneTimeCommissionTypeFixed 固定一次性佣金 + OneTimeCommissionTypeFixed = "fixed" + // OneTimeCommissionTypeTiered 梯度一次性佣金 + OneTimeCommissionTypeTiered = "tiered" +) + // 一次性佣金触发类型常量 const ( - // OneTimeCommissionTriggerOneTimeRecharge 单次充值触发 - OneTimeCommissionTriggerOneTimeRecharge = "one_time_recharge" + // OneTimeCommissionTriggerSingleRecharge 单次充值触发 + OneTimeCommissionTriggerSingleRecharge = "single_recharge" // OneTimeCommissionTriggerAccumulatedRecharge 累计充值触发 OneTimeCommissionTriggerAccumulatedRecharge = "accumulated_recharge" ) diff --git a/internal/model/shop_series_one_time_commission_tier.go b/internal/model/shop_series_one_time_commission_tier.go new file mode 100644 index 0000000..595381f --- /dev/null +++ b/internal/model/shop_series_one_time_commission_tier.go @@ -0,0 +1,34 @@ +package model + +import ( + "gorm.io/gorm" +) + +// ShopSeriesOneTimeCommissionTier 一次性佣金梯度配置模型 +// 记录基于销售业绩的一次性佣金梯度档位 +// 当系列分配的累计销量或销售额达到不同阈值时,返不同的一次性佣金金额 +type ShopSeriesOneTimeCommissionTier struct { + gorm.Model + BaseModel `gorm:"embedded"` + AllocationID uint `gorm:"column:allocation_id;index;not null;comment:系列分配ID" json:"allocation_id"` + TierType string `gorm:"column:tier_type;type:varchar(20);not null;comment:梯度类型 sales_count-销量 sales_amount-销售额" json:"tier_type"` + ThresholdValue int64 `gorm:"column:threshold_value;type:bigint;not null;comment:梯度阈值(销量或销售额分)" json:"threshold_value"` + CommissionMode string `gorm:"column:commission_mode;type:varchar(20);not null;default:fixed;comment:返佣模式 fixed-固定金额 percent-百分比" json:"commission_mode"` + CommissionValue int64 `gorm:"column:commission_value;type:bigint;not null;comment:返佣值(分或千分比)" json:"commission_value"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-停用" json:"status"` +} + +// TableName 指定表名 +func (ShopSeriesOneTimeCommissionTier) TableName() string { + return "tb_shop_series_one_time_commission_tier" +} + +// 梯度类型常量(复用 ShopSeriesCommissionTier 的常量) +// TierTypeSalesCount = "sales_count" // 销量梯度 +// TierTypeSalesAmount = "sales_amount" // 销售额梯度 +// 这些常量已在 shop_series_commission_tier.go 中定义 + +// 返佣模式常量(复用 ShopSeriesAllocation 的常量) +// CommissionModeFixed = "fixed" // 固定金额返佣 +// CommissionModePercent = "percent" // 百分比返佣(千分比) +// 这些常量已在 shop_series_allocation.go 中定义 diff --git a/internal/routes/my_commission.go b/internal/routes/my_commission.go index 4ba38ce..a020365 100644 --- a/internal/routes/my_commission.go +++ b/internal/routes/my_commission.go @@ -43,4 +43,20 @@ func registerMyCommissionRoutes(router fiber.Router, handler *admin.MyCommission Output: new(dto.MyCommissionRecordPageResult), Auth: true, }) + + Register(my, doc, groupPath, "GET", "/commission-stats", handler.GetStats, RouteSpec{ + Summary: "我的佣金统计", + Tags: []string{"我的佣金"}, + Input: new(dto.CommissionStatsRequest), + Output: new(dto.CommissionStatsResponse), + Auth: true, + }) + + Register(my, doc, groupPath, "GET", "/commission-daily-stats", handler.GetDailyStats, RouteSpec{ + Summary: "我的每日佣金统计", + Tags: []string{"我的佣金"}, + Input: new(dto.DailyCommissionStatsRequest), + Output: []dto.DailyCommissionStatsResponse{}, + Auth: true, + }) } diff --git a/internal/service/commission_calculation/service.go b/internal/service/commission_calculation/service.go new file mode 100644 index 0000000..274eaf4 --- /dev/null +++ b/internal/service/commission_calculation/service.go @@ -0,0 +1,470 @@ +package commission_calculation + +import ( + "context" + "fmt" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "go.uber.org/zap" + "gorm.io/gorm" +) + +type Service struct { + db *gorm.DB + commissionRecordStore *postgres.CommissionRecordStore + shopStore *postgres.ShopStore + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore + shopSeriesOneTimeCommissionTierStore *postgres.ShopSeriesOneTimeCommissionTierStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + walletStore *postgres.WalletStore + walletTransactionStore *postgres.WalletTransactionStore + orderStore *postgres.OrderStore + orderItemStore *postgres.OrderItemStore + packageStore *postgres.PackageStore + commissionStatsService *commission_stats.Service + logger *zap.Logger +} + +func New( + db *gorm.DB, + commissionRecordStore *postgres.CommissionRecordStore, + shopStore *postgres.ShopStore, + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, + shopSeriesOneTimeCommissionTierStore *postgres.ShopSeriesOneTimeCommissionTierStore, + iotCardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, + walletStore *postgres.WalletStore, + walletTransactionStore *postgres.WalletTransactionStore, + orderStore *postgres.OrderStore, + orderItemStore *postgres.OrderItemStore, + packageStore *postgres.PackageStore, + commissionStatsService *commission_stats.Service, + logger *zap.Logger, +) *Service { + return &Service{ + db: db, + commissionRecordStore: commissionRecordStore, + shopStore: shopStore, + shopSeriesAllocationStore: shopSeriesAllocationStore, + shopSeriesOneTimeCommissionTierStore: shopSeriesOneTimeCommissionTierStore, + iotCardStore: iotCardStore, + deviceStore: deviceStore, + walletStore: walletStore, + walletTransactionStore: walletTransactionStore, + orderStore: orderStore, + orderItemStore: orderItemStore, + packageStore: packageStore, + commissionStatsService: commissionStatsService, + logger: logger, + } +} + +func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + order, err := s.orderStore.GetByID(ctx, orderID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "获取订单失败") + } + + if order.CommissionStatus == model.CommissionStatusCalculated { + s.logger.Warn("订单佣金已计算,跳过", zap.String("order_id", fmt.Sprint(orderID))) + return nil + } + + costDiffRecords, err := s.CalculateCostDiffCommission(ctx, order) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "计算成本价差佣金失败") + } + + for _, record := range costDiffRecords { + if err := s.commissionRecordStore.Create(ctx, record); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建成本价差佣金记录失败") + } + if err := s.CreditCommission(ctx, record); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "佣金入账失败") + } + } + + if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil { + if err := s.TriggerOneTimeCommissionForCard(ctx, order, *order.IotCardID); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "触发单卡一次性佣金失败") + } + } else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil { + if err := s.TriggerOneTimeCommissionForDevice(ctx, order, *order.DeviceID); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "触发设备一次性佣金失败") + } + } + + order.CommissionStatus = model.CommissionStatusCalculated + if err := s.orderStore.Update(ctx, order); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新订单佣金状态失败") + } + + return nil + }) +} + +func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.Order) ([]*model.CommissionRecord, error) { + var records []*model.CommissionRecord + + if order.SellerShopID == nil { + return records, nil + } + + sellerShop, err := s.shopStore.GetByID(ctx, *order.SellerShopID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "获取销售店铺失败") + } + + sellerProfit := order.TotalAmount - order.SellerCostPrice + if sellerProfit > 0 { + records = append(records, &model.CommissionRecord{ + BaseModel: model.BaseModel{ + Creator: order.Creator, + Updater: order.Updater, + }, + ShopID: *order.SellerShopID, + OrderID: order.ID, + IotCardID: order.IotCardID, + DeviceID: order.DeviceID, + CommissionSource: model.CommissionSourceCostDiff, + Amount: sellerProfit, + Status: model.CommissionStatusReleased, + }) + } + + childCostPrice := order.SellerCostPrice + currentShopID := sellerShop.ParentID + + for currentShopID != nil && *currentShopID > 0 { + currentShop, err := s.shopStore.GetByID(ctx, *currentShopID) + if err != nil { + s.logger.Error("获取上级店铺失败", zap.Uint("shop_id", *currentShopID), zap.Error(err)) + break + } + + allocation, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, currentShop.ID, *order.SeriesID) + if err != nil { + s.logger.Warn("上级店铺未分配该系列,跳过", zap.Uint("shop_id", currentShop.ID), zap.Uint("series_id", *order.SeriesID)) + break + } + + myCostPrice := s.calculateCostPrice(allocation, order.TotalAmount) + profit := childCostPrice - myCostPrice + if profit > 0 { + records = append(records, &model.CommissionRecord{ + BaseModel: model.BaseModel{ + Creator: order.Creator, + Updater: order.Updater, + }, + ShopID: currentShop.ID, + OrderID: order.ID, + IotCardID: order.IotCardID, + DeviceID: order.DeviceID, + CommissionSource: model.CommissionSourceCostDiff, + Amount: profit, + Status: model.CommissionStatusReleased, + }) + } + + childCostPrice = myCostPrice + currentShopID = currentShop.ParentID + } + + return records, nil +} + +func (s *Service) calculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 { + if allocation.BaseCommissionMode == model.CommissionModeFixed { + return orderAmount - allocation.BaseCommissionValue + } else if allocation.BaseCommissionMode == model.CommissionModePercent { + commission := orderAmount * allocation.BaseCommissionValue / 1000 + return orderAmount - commission + } + return orderAmount +} + +func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *model.Order, cardID uint) error { + card, err := s.iotCardStore.GetByID(ctx, cardID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "获取卡信息失败") + } + + if card.FirstCommissionPaid { + return nil + } + + if card.SeriesAllocationID == nil { + return nil + } + + allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, *card.SeriesAllocationID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "获取系列分配失败") + } + + if !allocation.EnableOneTimeCommission { + return nil + } + + var rechargeAmount int64 + switch allocation.OneTimeCommissionTrigger { + case model.OneTimeCommissionTriggerSingleRecharge: + rechargeAmount = order.TotalAmount + case model.OneTimeCommissionTriggerAccumulatedRecharge: + rechargeAmount = card.AccumulatedRecharge + order.TotalAmount + default: + return nil + } + + if rechargeAmount < allocation.OneTimeCommissionThreshold { + return nil + } + + commissionAmount, err := s.calculateOneTimeCommission(ctx, allocation, order.TotalAmount) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "计算一次性佣金失败") + } + + if commissionAmount <= 0 { + return nil + } + + if card.ShopID == nil { + return errors.New(errors.CodeInvalidParam, "卡未归属任何店铺,无法发放佣金") + } + + record := &model.CommissionRecord{ + BaseModel: model.BaseModel{ + Creator: order.Creator, + Updater: order.Updater, + }, + ShopID: *card.ShopID, + OrderID: order.ID, + IotCardID: &cardID, + CommissionSource: model.CommissionSourceOneTime, + Amount: commissionAmount, + Status: model.CommissionStatusReleased, + Remark: "一次性佣金", + } + + if err := s.commissionRecordStore.Create(ctx, record); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败") + } + + if err := s.CreditCommission(ctx, record); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败") + } + + card.FirstCommissionPaid = true + if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { + card.AccumulatedRecharge = rechargeAmount + } + if err := s.iotCardStore.Update(ctx, card); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") + } + + return nil +} + +func (s *Service) TriggerOneTimeCommissionForDevice(ctx context.Context, order *model.Order, deviceID uint) error { + device, err := s.deviceStore.GetByID(ctx, deviceID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "获取设备信息失败") + } + + if device.FirstCommissionPaid { + return nil + } + + if device.SeriesAllocationID == nil { + return nil + } + + allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, *device.SeriesAllocationID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "获取系列分配失败") + } + + if !allocation.EnableOneTimeCommission { + return nil + } + + var rechargeAmount int64 + switch allocation.OneTimeCommissionTrigger { + case model.OneTimeCommissionTriggerSingleRecharge: + rechargeAmount = order.TotalAmount + case model.OneTimeCommissionTriggerAccumulatedRecharge: + rechargeAmount = device.AccumulatedRecharge + order.TotalAmount + default: + return nil + } + + if rechargeAmount < allocation.OneTimeCommissionThreshold { + return nil + } + + commissionAmount, err := s.calculateOneTimeCommission(ctx, allocation, order.TotalAmount) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "计算一次性佣金失败") + } + + if commissionAmount <= 0 { + return nil + } + + if device.ShopID == nil { + return errors.New(errors.CodeInvalidParam, "设备未归属任何店铺,无法发放佣金") + } + + record := &model.CommissionRecord{ + BaseModel: model.BaseModel{ + Creator: order.Creator, + Updater: order.Updater, + }, + ShopID: *device.ShopID, + OrderID: order.ID, + DeviceID: &deviceID, + CommissionSource: model.CommissionSourceOneTime, + Amount: commissionAmount, + Status: model.CommissionStatusReleased, + Remark: "一次性佣金(设备)", + } + + if err := s.commissionRecordStore.Create(ctx, record); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建一次性佣金记录失败") + } + + if err := s.CreditCommission(ctx, record); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "一次性佣金入账失败") + } + + device.FirstCommissionPaid = true + if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerAccumulatedRecharge { + device.AccumulatedRecharge = rechargeAmount + } + if err := s.deviceStore.Update(ctx, device); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新设备状态失败") + } + + return nil +} + +func (s *Service) calculateOneTimeCommission(ctx context.Context, allocation *model.ShopSeriesAllocation, orderAmount int64) (int64, error) { + switch allocation.OneTimeCommissionType { + case model.OneTimeCommissionTypeFixed: + return s.calculateFixedCommission(allocation.OneTimeCommissionMode, allocation.OneTimeCommissionValue, orderAmount), nil + case model.OneTimeCommissionTypeTiered: + return s.calculateTieredCommission(ctx, allocation.ID, orderAmount) + } + return 0, nil +} + +func (s *Service) calculateFixedCommission(mode string, value int64, orderAmount int64) int64 { + if mode == model.CommissionModeFixed { + return value + } else if mode == model.CommissionModePercent { + return orderAmount * value / 1000 + } + return 0 +} + +func (s *Service) calculateTieredCommission(ctx context.Context, allocationID uint, orderAmount int64) (int64, error) { + tiers, err := s.shopSeriesOneTimeCommissionTierStore.ListByAllocationID(ctx, allocationID) + if err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "获取梯度配置失败") + } + + if len(tiers) == 0 { + return 0, nil + } + + stats, err := s.commissionStatsService.GetCurrentStats(ctx, allocationID, "all_time") + if err != nil { + s.logger.Error("获取销售业绩统计失败", zap.Uint("allocation_id", allocationID), zap.Error(err)) + return 0, nil + } + + if stats == nil { + return 0, nil + } + + var matchedTier *model.ShopSeriesOneTimeCommissionTier + for _, tier := range tiers { + var salesValue int64 + if tier.TierType == model.TierTypeSalesCount { + salesValue = stats.TotalSalesCount + } else if tier.TierType == model.TierTypeSalesAmount { + salesValue = stats.TotalSalesAmount + } else { + continue + } + + if salesValue >= tier.ThresholdValue { + if matchedTier == nil || tier.ThresholdValue > matchedTier.ThresholdValue { + matchedTier = tier + } + } + } + + if matchedTier == nil { + return 0, nil + } + + if matchedTier.CommissionMode == model.CommissionModeFixed { + return matchedTier.CommissionValue, nil + } else if matchedTier.CommissionMode == model.CommissionModePercent { + return orderAmount * matchedTier.CommissionValue / 1000, nil + } + + return 0, nil +} + +func (s *Service) CreditCommission(ctx context.Context, record *model.CommissionRecord) error { + wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, "shop", record.ShopID, "commission") + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺钱包失败") + } + + wallet.Balance += record.Amount + if err := s.db.WithContext(ctx).Model(wallet).Update("balance", wallet.Balance).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新钱包余额失败") + } + + now := time.Now() + record.BalanceAfter = wallet.Balance + record.Status = model.CommissionStatusReleased + record.ReleasedAt = &now + if err := s.db.WithContext(ctx).Model(record).Updates(record).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新佣金记录失败") + } + + remark := "佣金入账" + transaction := &model.WalletTransaction{ + WalletID: wallet.ID, + UserID: record.Creator, + TransactionType: "commission", + Amount: record.Amount, + BalanceBefore: wallet.Balance - record.Amount, + BalanceAfter: wallet.Balance, + Status: 1, + ReferenceType: stringPtr("commission"), + ReferenceID: &record.ID, + Remark: &remark, + Creator: record.Creator, + } + if err := s.walletTransactionStore.Create(ctx, transaction); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败") + } + + return nil +} + +func stringPtr(s string) *string { + return &s +} diff --git a/internal/service/my_commission/service.go b/internal/service/my_commission/service.go index 5e9d16d..37c2d2d 100644 --- a/internal/service/my_commission/service.go +++ b/internal/service/my_commission/service.go @@ -329,8 +329,8 @@ func (s *Service) ListMyCommissionRecords(ctx context.Context, req *dto.MyCommis query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}). Where("shop_id = ?", shopID) - if req.CommissionType != nil { - query = query.Where("commission_type = ?", *req.CommissionType) + if req.CommissionSource != nil { + query = query.Where("commission_source = ?", *req.CommissionSource) } var total int64 @@ -347,14 +347,14 @@ func (s *Service) ListMyCommissionRecords(ctx context.Context, req *dto.MyCommis items := make([]dto.MyCommissionRecordItem, 0, len(records)) for _, r := range records { items = append(items, dto.MyCommissionRecordItem{ - ID: r.ID, - ShopID: r.ShopID, - OrderID: r.OrderID, - CommissionType: r.CommissionType, - Amount: r.Amount, - Status: r.Status, - StatusName: getCommissionStatusName(r.Status), - CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), + ID: r.ID, + ShopID: r.ShopID, + OrderID: r.OrderID, + CommissionSource: r.CommissionSource, + Amount: r.Amount, + Status: r.Status, + StatusName: getCommissionStatusName(r.Status), + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), }) } @@ -366,6 +366,97 @@ func (s *Service) ListMyCommissionRecords(ctx context.Context, req *dto.MyCommis }, nil } +func (s *Service) GetStats(ctx context.Context, req *dto.CommissionStatsRequest) (*dto.CommissionStatsResponse, error) { + shopID, err := s.getShopIDFromContext(ctx) + if err != nil { + return nil, err + } + + filters := &postgres.CommissionRecordListFilters{ + ShopID: shopID, + StartTime: req.StartTime, + EndTime: req.EndTime, + } + + stats, err := s.commissionRecordStore.GetStats(ctx, filters) + if err != nil { + return nil, fmt.Errorf("获取佣金统计失败: %w", err) + } + + if stats == nil { + return &dto.CommissionStatsResponse{}, nil + } + + var costDiffPercent, oneTimePercent, tierBonusPercent int64 + if stats.TotalAmount > 0 { + costDiffPercent = stats.CostDiffAmount * 1000 / stats.TotalAmount + oneTimePercent = stats.OneTimeAmount * 1000 / stats.TotalAmount + tierBonusPercent = stats.TierBonusAmount * 1000 / stats.TotalAmount + } + + return &dto.CommissionStatsResponse{ + TotalAmount: stats.TotalAmount, + CostDiffAmount: stats.CostDiffAmount, + OneTimeAmount: stats.OneTimeAmount, + TierBonusAmount: stats.TierBonusAmount, + CostDiffPercent: costDiffPercent, + OneTimePercent: oneTimePercent, + TierBonusPercent: tierBonusPercent, + TotalCount: stats.TotalCount, + CostDiffCount: stats.CostDiffCount, + OneTimeCount: stats.OneTimeCount, + TierBonusCount: stats.TierBonusCount, + }, nil +} + +func (s *Service) GetDailyStats(ctx context.Context, req *dto.DailyCommissionStatsRequest) ([]*dto.DailyCommissionStatsResponse, error) { + shopID, err := s.getShopIDFromContext(ctx) + if err != nil { + return nil, err + } + + days := 30 + if req.Days != nil && *req.Days > 0 { + days = *req.Days + } + + filters := &postgres.CommissionRecordListFilters{ + ShopID: shopID, + StartTime: req.StartDate, + EndTime: req.EndDate, + } + + dailyStats, err := s.commissionRecordStore.GetDailyStats(ctx, filters, days) + if err != nil { + return nil, fmt.Errorf("获取每日佣金统计失败: %w", err) + } + + result := make([]*dto.DailyCommissionStatsResponse, 0, len(dailyStats)) + for _, stat := range dailyStats { + result = append(result, &dto.DailyCommissionStatsResponse{ + Date: stat.Date, + TotalAmount: stat.TotalAmount, + TotalCount: stat.TotalCount, + }) + } + + return result, nil +} + +func (s *Service) getShopIDFromContext(ctx context.Context) (uint, error) { + userType := middleware.GetUserTypeFromContext(ctx) + if userType != constants.UserTypeAgent { + return 0, errors.New(errors.CodeForbidden, "仅代理商用户可访问") + } + + shopID := middleware.GetShopIDFromContext(ctx) + if shopID == 0 { + return 0, errors.New(errors.CodeForbidden, "无法获取店铺信息") + } + + return shopID, nil +} + // generateWithdrawalNo 生成提现单号 func generateWithdrawalNo() string { now := time.Now() diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index be2a6d8..ac8d6d9 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -345,11 +345,11 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re } filters := &postgres.CommissionRecordListFilters{ - ShopID: shopID, - CommissionType: req.CommissionType, - ICCID: req.ICCID, - DeviceNo: req.DeviceNo, - OrderNo: req.OrderNo, + ShopID: shopID, + CommissionSource: req.CommissionSource, + ICCID: req.ICCID, + DeviceNo: req.DeviceNo, + OrderNo: req.OrderNo, } records, total, err := s.commissionRecordStore.ListByShopID(ctx, opts, filters) @@ -360,18 +360,18 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re items := make([]dto.ShopCommissionRecordItem, 0, len(records)) for _, r := range records { item := dto.ShopCommissionRecordItem{ - ID: r.ID, - Amount: r.Amount, - BalanceAfter: r.BalanceAfter, - CommissionType: r.CommissionType, - Status: r.Status, - StatusName: getCommissionStatusName(r.Status), - OrderID: r.OrderID, - OrderNo: "", - DeviceNo: "", - ICCID: "", - OrderCreatedAt: "", - CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), + ID: r.ID, + Amount: r.Amount, + BalanceAfter: r.BalanceAfter, + CommissionSource: r.CommissionSource, + Status: r.Status, + StatusName: getCommissionStatusName(r.Status), + OrderID: r.OrderID, + OrderNo: "", + DeviceNo: "", + ICCID: "", + OrderCreatedAt: "", + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), } items = append(items, item) } diff --git a/internal/store/postgres/commission_record_store.go b/internal/store/postgres/commission_record_store.go index ceb21fa..1bb8895 100644 --- a/internal/store/postgres/commission_record_store.go +++ b/internal/store/postgres/commission_record_store.go @@ -35,12 +35,14 @@ func (s *CommissionRecordStore) GetByID(ctx context.Context, id uint) (*model.Co } type CommissionRecordListFilters struct { - ShopID uint - CommissionType string - ICCID string - DeviceNo string - OrderNo string - Status *int + ShopID uint + CommissionSource string + ICCID string + DeviceNo string + OrderNo string + StartTime *string + EndTime *string + Status *int } func (s *CommissionRecordStore) ListByShopID(ctx context.Context, opts *store.QueryOptions, filters *CommissionRecordListFilters) ([]*model.CommissionRecord, int64, error) { @@ -53,8 +55,14 @@ func (s *CommissionRecordStore) ListByShopID(ctx context.Context, opts *store.Qu if filters.ShopID > 0 { query = query.Where("shop_id = ?", filters.ShopID) } - if filters.CommissionType != "" { - query = query.Where("commission_type = ?", filters.CommissionType) + if filters.CommissionSource != "" { + query = query.Where("commission_source = ?", filters.CommissionSource) + } + if filters.StartTime != nil && *filters.StartTime != "" { + query = query.Where("created_at >= ?", *filters.StartTime) + } + if filters.EndTime != nil && *filters.EndTime != "" { + query = query.Where("created_at <= ?", *filters.EndTime) } if filters.Status != nil { query = query.Where("status = ?", *filters.Status) @@ -86,3 +94,95 @@ func (s *CommissionRecordStore) ListByShopID(ctx context.Context, opts *store.Qu return records, total, nil } + +type CommissionStats struct { + TotalAmount int64 + CostDiffAmount int64 + OneTimeAmount int64 + TierBonusAmount int64 + TotalCount int64 + CostDiffCount int64 + OneTimeCount int64 + TierBonusCount int64 +} + +func (s *CommissionRecordStore) GetStats(ctx context.Context, filters *CommissionRecordListFilters) (*CommissionStats, error) { + query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}). + Where("status = ?", model.CommissionStatusReleased) + + if filters != nil { + if filters.ShopID > 0 { + query = query.Where("shop_id = ?", filters.ShopID) + } + if filters.StartTime != nil && *filters.StartTime != "" { + query = query.Where("created_at >= ?", *filters.StartTime) + } + if filters.EndTime != nil && *filters.EndTime != "" { + query = query.Where("created_at <= ?", *filters.EndTime) + } + } + + var stats CommissionStats + + result := query.Select(` + COALESCE(SUM(amount), 0) as total_amount, + COALESCE(SUM(CASE WHEN commission_source = 'cost_diff' THEN amount ELSE 0 END), 0) as cost_diff_amount, + COALESCE(SUM(CASE WHEN commission_source = 'one_time' THEN amount ELSE 0 END), 0) as one_time_amount, + COALESCE(SUM(CASE WHEN commission_source = 'tier_bonus' THEN amount ELSE 0 END), 0) as tier_bonus_amount, + COUNT(*) as total_count, + COALESCE(SUM(CASE WHEN commission_source = 'cost_diff' THEN 1 ELSE 0 END), 0) as cost_diff_count, + COALESCE(SUM(CASE WHEN commission_source = 'one_time' THEN 1 ELSE 0 END), 0) as one_time_count, + COALESCE(SUM(CASE WHEN commission_source = 'tier_bonus' THEN 1 ELSE 0 END), 0) as tier_bonus_count + `).Scan(&stats) + + if result.Error != nil { + return nil, result.Error + } + + return &stats, nil +} + +type DailyCommissionStats struct { + Date string + TotalAmount int64 + TotalCount int64 +} + +func (s *CommissionRecordStore) GetDailyStats(ctx context.Context, filters *CommissionRecordListFilters, days int) ([]*DailyCommissionStats, error) { + if days <= 0 { + days = 30 + } + + query := s.db.WithContext(ctx).Model(&model.CommissionRecord{}). + Where("status = ?", model.CommissionStatusReleased) + + if filters != nil { + if filters.ShopID > 0 { + query = query.Where("shop_id = ?", filters.ShopID) + } + if filters.StartTime != nil && *filters.StartTime != "" { + query = query.Where("created_at >= ?", *filters.StartTime) + } + if filters.EndTime != nil && *filters.EndTime != "" { + query = query.Where("created_at <= ?", *filters.EndTime) + } + } + + var stats []*DailyCommissionStats + + result := query.Select(` + DATE(created_at) as date, + COALESCE(SUM(amount), 0) as total_amount, + COUNT(*) as total_count + `). + Group("DATE(created_at)"). + Order("date DESC"). + Limit(days). + Scan(&stats) + + if result.Error != nil { + return nil, result.Error + } + + return stats, nil +} diff --git a/internal/store/postgres/shop_series_one_time_commission_tier_store.go b/internal/store/postgres/shop_series_one_time_commission_tier_store.go new file mode 100644 index 0000000..fbef1c9 --- /dev/null +++ b/internal/store/postgres/shop_series_one_time_commission_tier_store.go @@ -0,0 +1,61 @@ +package postgres + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "gorm.io/gorm" +) + +type ShopSeriesOneTimeCommissionTierStore struct { + db *gorm.DB +} + +func NewShopSeriesOneTimeCommissionTierStore(db *gorm.DB) *ShopSeriesOneTimeCommissionTierStore { + return &ShopSeriesOneTimeCommissionTierStore{db: db} +} + +func (s *ShopSeriesOneTimeCommissionTierStore) Create(ctx context.Context, tier *model.ShopSeriesOneTimeCommissionTier) error { + return s.db.WithContext(ctx).Create(tier).Error +} + +func (s *ShopSeriesOneTimeCommissionTierStore) BatchCreate(ctx context.Context, tiers []*model.ShopSeriesOneTimeCommissionTier) error { + if len(tiers) == 0 { + return nil + } + return s.db.WithContext(ctx).Create(&tiers).Error +} + +func (s *ShopSeriesOneTimeCommissionTierStore) GetByID(ctx context.Context, id uint) (*model.ShopSeriesOneTimeCommissionTier, error) { + var tier model.ShopSeriesOneTimeCommissionTier + if err := s.db.WithContext(ctx).First(&tier, id).Error; err != nil { + return nil, err + } + return &tier, nil +} + +func (s *ShopSeriesOneTimeCommissionTierStore) Update(ctx context.Context, tier *model.ShopSeriesOneTimeCommissionTier) error { + return s.db.WithContext(ctx).Save(tier).Error +} + +func (s *ShopSeriesOneTimeCommissionTierStore) Delete(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Delete(&model.ShopSeriesOneTimeCommissionTier{}, id).Error +} + +func (s *ShopSeriesOneTimeCommissionTierStore) ListByAllocationID(ctx context.Context, allocationID uint) ([]*model.ShopSeriesOneTimeCommissionTier, error) { + var tiers []*model.ShopSeriesOneTimeCommissionTier + if err := s.db.WithContext(ctx). + Where("allocation_id = ?", allocationID). + Where("status = ?", 1). + Order("threshold_value ASC"). + Find(&tiers).Error; err != nil { + return nil, err + } + return tiers, nil +} + +func (s *ShopSeriesOneTimeCommissionTierStore) DeleteByAllocationID(ctx context.Context, allocationID uint) error { + return s.db.WithContext(ctx). + Where("allocation_id = ?", allocationID). + Delete(&model.ShopSeriesOneTimeCommissionTier{}).Error +} diff --git a/internal/task/commission_calculation.go b/internal/task/commission_calculation.go new file mode 100644 index 0000000..933c2af --- /dev/null +++ b/internal/task/commission_calculation.go @@ -0,0 +1,66 @@ +package task + +import ( + "context" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" + pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm" +) + +const ( + TypeCommissionCalculation = "commission:calculate" +) + +type CommissionCalculationPayload struct { + OrderID uint `json:"order_id"` +} + +type CommissionCalculationHandler struct { + db *gorm.DB + service *commission_calculation.Service + logger *zap.Logger +} + +func NewCommissionCalculationHandler( + db *gorm.DB, + service *commission_calculation.Service, + logger *zap.Logger, +) *CommissionCalculationHandler { + return &CommissionCalculationHandler{ + db: db, + service: service, + logger: logger, + } +} + +func (h *CommissionCalculationHandler) HandleCommissionCalculation(ctx context.Context, task *asynq.Task) error { + ctx = pkggorm.SkipDataPermission(ctx) + + var payload CommissionCalculationPayload + if err := sonic.Unmarshal(task.Payload(), &payload); err != nil { + h.logger.Error("解析佣金计算任务载荷失败", + zap.Error(err), + zap.String("task_id", task.ResultWriter().TaskID()), + ) + return asynq.SkipRetry + } + + if err := h.service.CalculateCommission(ctx, payload.OrderID); err != nil { + h.logger.Error("佣金计算失败", + zap.Uint("order_id", payload.OrderID), + zap.Error(err), + ) + return err + } + + h.logger.Info("佣金计算成功", + zap.Uint("order_id", payload.OrderID), + ) + + return nil +} diff --git a/migrations/000029_add_one_time_commission.down.sql b/migrations/000029_add_one_time_commission.down.sql new file mode 100644 index 0000000..d4d638f --- /dev/null +++ b/migrations/000029_add_one_time_commission.down.sql @@ -0,0 +1,41 @@ +-- 回滚迁移: 一次性佣金功能 + +-- ======================================== +-- 1. 恢复 tb_commission_record 表结构 +-- ======================================== + +-- 1.1 删除新字段 +ALTER TABLE tb_commission_record + DROP COLUMN IF EXISTS commission_source, + DROP COLUMN IF EXISTS iot_card_id, + DROP COLUMN IF EXISTS device_id, + DROP COLUMN IF EXISTS remark; + +-- 1.2 恢复旧字段 +ALTER TABLE tb_commission_record + ADD COLUMN IF NOT EXISTS agent_id BIGINT, + ADD COLUMN IF NOT EXISTS rule_id BIGINT, + ADD COLUMN IF NOT EXISTS commission_type VARCHAR(50), + ADD COLUMN IF NOT EXISTS unfrozen_at TIMESTAMP WITH TIME ZONE; + +-- 1.3 恢复索引 +CREATE INDEX IF NOT EXISTS idx_commission_record_agent_id ON tb_commission_record(agent_id); +CREATE INDEX IF NOT EXISTS idx_commission_record_rule_id ON tb_commission_record(rule_id); + +-- ======================================== +-- 2. 删除 tb_shop_series_one_time_commission_tier 表 +-- ======================================== + +DROP TABLE IF EXISTS tb_shop_series_one_time_commission_tier; + +-- ======================================== +-- 3. 删除 tb_shop_series_allocation 的一次性佣金字段 +-- ======================================== + +ALTER TABLE tb_shop_series_allocation + DROP COLUMN IF EXISTS enable_one_time_commission, + DROP COLUMN IF EXISTS one_time_commission_type, + DROP COLUMN IF EXISTS one_time_commission_trigger, + DROP COLUMN IF EXISTS one_time_commission_threshold, + DROP COLUMN IF EXISTS one_time_commission_mode, + DROP COLUMN IF EXISTS one_time_commission_value; diff --git a/migrations/000029_add_one_time_commission.up.sql b/migrations/000029_add_one_time_commission.up.sql new file mode 100644 index 0000000..194a090 --- /dev/null +++ b/migrations/000029_add_one_time_commission.up.sql @@ -0,0 +1,96 @@ +-- 迁移: 一次性佣金功能 +-- 变更ID: add-one-time-commission +-- 说明: +-- 1. 为 tb_shop_series_allocation 添加一次性佣金配置字段 +-- 2. 创建 tb_shop_series_one_time_commission_tier 梯度配置表 +-- 3. 简化 tb_commission_record 表结构(删除冻结字段,新增来源字段) + +-- ======================================== +-- 1. tb_shop_series_allocation 添加一次性佣金配置字段 +-- ======================================== + +ALTER TABLE tb_shop_series_allocation + ADD COLUMN IF NOT EXISTS enable_one_time_commission BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS one_time_commission_type VARCHAR(20), + ADD COLUMN IF NOT EXISTS one_time_commission_trigger VARCHAR(30), + ADD COLUMN IF NOT EXISTS one_time_commission_threshold BIGINT DEFAULT 0, + ADD COLUMN IF NOT EXISTS one_time_commission_mode VARCHAR(20), + ADD COLUMN IF NOT EXISTS one_time_commission_value BIGINT DEFAULT 0; + +-- 添加字段注释 +COMMENT ON COLUMN tb_shop_series_allocation.enable_one_time_commission IS '是否启用一次性佣金'; +COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_type IS '一次性佣金类型 fixed-固定 tiered-梯度'; +COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_trigger IS '触发条件 single_recharge-单次充值 accumulated_recharge-累计充值'; +COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_threshold IS '最低阈值(分)'; +COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_mode IS '返佣模式 fixed-固定金额 percent-百分比'; +COMMENT ON COLUMN tb_shop_series_allocation.one_time_commission_value IS '佣金金额(分)或比例(千分比)'; + +-- ======================================== +-- 2. 创建 tb_shop_series_one_time_commission_tier 梯度配置表 +-- ======================================== + +CREATE TABLE IF NOT EXISTS tb_shop_series_one_time_commission_tier ( + id BIGSERIAL PRIMARY KEY, + allocation_id BIGINT NOT NULL, + tier_type VARCHAR(20) NOT NULL, + threshold_value BIGINT NOT NULL, + commission_mode VARCHAR(20) NOT NULL DEFAULT 'fixed', + commission_value BIGINT NOT NULL, + status INT NOT NULL DEFAULT 1, + creator BIGINT NOT NULL, + updater BIGINT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- 添加索引 +CREATE INDEX IF NOT EXISTS idx_one_time_tier_allocation_id ON tb_shop_series_one_time_commission_tier(allocation_id); +CREATE INDEX IF NOT EXISTS idx_one_time_tier_tier_type ON tb_shop_series_one_time_commission_tier(tier_type); +CREATE INDEX IF NOT EXISTS idx_one_time_tier_threshold ON tb_shop_series_one_time_commission_tier(threshold_value); + +-- 添加表注释 +COMMENT ON TABLE tb_shop_series_one_time_commission_tier IS '一次性佣金梯度配置表,基于销售业绩的一次性佣金档位'; +COMMENT ON COLUMN tb_shop_series_one_time_commission_tier.allocation_id IS '系列分配ID'; +COMMENT ON COLUMN tb_shop_series_one_time_commission_tier.tier_type IS '梯度类型 sales_count-销量 sales_amount-销售额'; +COMMENT ON COLUMN tb_shop_series_one_time_commission_tier.threshold_value IS '梯度阈值(销量或销售额分)'; +COMMENT ON COLUMN tb_shop_series_one_time_commission_tier.commission_mode IS '返佣模式 fixed-固定金额 percent-百分比'; +COMMENT ON COLUMN tb_shop_series_one_time_commission_tier.commission_value IS '返佣值(分或千分比)'; +COMMENT ON COLUMN tb_shop_series_one_time_commission_tier.status IS '状态 1-启用 2-停用'; +COMMENT ON COLUMN tb_shop_series_one_time_commission_tier.creator IS '创建人ID'; +COMMENT ON COLUMN tb_shop_series_one_time_commission_tier.updater IS '更新人ID'; + +-- ======================================== +-- 3. 修改 tb_commission_record 表结构 +-- ======================================== + +-- 3.1 删除旧字段 +ALTER TABLE tb_commission_record + DROP COLUMN IF EXISTS agent_id, + DROP COLUMN IF EXISTS rule_id, + DROP COLUMN IF EXISTS commission_type, + DROP COLUMN IF EXISTS unfrozen_at; + +-- 3.2 添加新字段 +ALTER TABLE tb_commission_record + ADD COLUMN IF NOT EXISTS commission_source VARCHAR(20) NOT NULL DEFAULT 'cost_diff', + ADD COLUMN IF NOT EXISTS iot_card_id BIGINT, + ADD COLUMN IF NOT EXISTS device_id BIGINT, + ADD COLUMN IF NOT EXISTS remark VARCHAR(500); + +-- 3.3 添加索引 +CREATE INDEX IF NOT EXISTS idx_commission_record_commission_source ON tb_commission_record(commission_source); +CREATE INDEX IF NOT EXISTS idx_commission_record_iot_card_id ON tb_commission_record(iot_card_id); +CREATE INDEX IF NOT EXISTS idx_commission_record_device_id ON tb_commission_record(device_id); + +-- 3.4 更新字段注释 +COMMENT ON COLUMN tb_commission_record.shop_id IS '店铺ID(佣金归属)'; +COMMENT ON COLUMN tb_commission_record.order_id IS '订单ID'; +COMMENT ON COLUMN tb_commission_record.commission_source IS '佣金来源 cost_diff-成本价差 one_time-一次性佣金 tier_bonus-梯度奖励'; +COMMENT ON COLUMN tb_commission_record.iot_card_id IS '关联卡ID(可空)'; +COMMENT ON COLUMN tb_commission_record.device_id IS '关联设备ID(可空)'; +COMMENT ON COLUMN tb_commission_record.amount IS '佣金金额(分)'; +COMMENT ON COLUMN tb_commission_record.balance_after IS '入账后钱包余额(分)'; +COMMENT ON COLUMN tb_commission_record.status IS '状态 1-已入账 2-已失效'; +COMMENT ON COLUMN tb_commission_record.released_at IS '入账时间'; +COMMENT ON COLUMN tb_commission_record.remark IS '备注'; diff --git a/migrations/000030_add_order_commission_fields.down.sql b/migrations/000030_add_order_commission_fields.down.sql new file mode 100644 index 0000000..f62f2cb --- /dev/null +++ b/migrations/000030_add_order_commission_fields.down.sql @@ -0,0 +1,6 @@ +-- 回滚: 删除 Order 表的佣金计算字段 + +ALTER TABLE tb_order + DROP COLUMN IF EXISTS seller_shop_id, + DROP COLUMN IF EXISTS seller_cost_price, + DROP COLUMN IF EXISTS series_id; diff --git a/migrations/000030_add_order_commission_fields.up.sql b/migrations/000030_add_order_commission_fields.up.sql new file mode 100644 index 0000000..305f4e1 --- /dev/null +++ b/migrations/000030_add_order_commission_fields.up.sql @@ -0,0 +1,19 @@ +-- 迁移: 为 Order 表添加佣金计算所需字段 +-- 说明: +-- 1. 添加 seller_shop_id 字段(销售店铺ID,用于成本价差计算) +-- 2. 添加 seller_cost_price 字段(销售成本价,用于计算利润) +-- 3. 添加 series_id 字段(系列ID,用于查询分配配置) + +ALTER TABLE tb_order + ADD COLUMN IF NOT EXISTS seller_shop_id BIGINT, + ADD COLUMN IF NOT EXISTS seller_cost_price BIGINT DEFAULT 0, + ADD COLUMN IF NOT EXISTS series_id BIGINT; + +-- 添加索引 +CREATE INDEX IF NOT EXISTS idx_order_seller_shop_id ON tb_order(seller_shop_id); +CREATE INDEX IF NOT EXISTS idx_order_series_id ON tb_order(series_id); + +-- 添加字段注释 +COMMENT ON COLUMN tb_order.seller_shop_id IS '销售店铺ID(用于成本价差佣金计算)'; +COMMENT ON COLUMN tb_order.seller_cost_price IS '销售成本价(分,用于计算利润)'; +COMMENT ON COLUMN tb_order.series_id IS '系列ID(用于查询分配配置)'; diff --git a/openspec/changes/add-one-time-commission/tasks.md b/openspec/changes/add-one-time-commission/tasks.md deleted file mode 100644 index b009a73..0000000 --- a/openspec/changes/add-one-time-commission/tasks.md +++ /dev/null @@ -1,144 +0,0 @@ -## 1. ShopSeriesAllocation 模型更新(一次性佣金配置) - -- [ ] 1.1 修改 `internal/model/shop_series_allocation.go`,新增一次性佣金配置字段 -- [ ] 1.2 新增 enable_one_time_commission 字段(bool,是否启用) -- [ ] 1.3 新增 one_time_commission_type 字段(varchar: fixed-固定, tiered-梯度) -- [ ] 1.4 新增 one_time_commission_trigger 字段(varchar: single_recharge-首充, accumulated_recharge-累计充值) -- [ ] 1.5 新增 one_time_commission_threshold 字段(bigint,触发阈值分) -- [ ] 1.6 新增 one_time_commission_mode 字段(varchar: fixed-固定金额, percent-百分比) -- [ ] 1.7 新增 one_time_commission_value 字段(bigint,返佣值分或千分比) - -## 2. 新增 ShopSeriesOneTimeCommissionTier 模型 - -- [ ] 2.1 创建 `internal/model/shop_series_one_time_commission_tier.go` -- [ ] 2.2 定义 ShopSeriesOneTimeCommissionTier 模型(allocation_id, tier_type, threshold_value, commission_mode, commission_value, status) -- [ ] 2.3 实现 TableName() 方法返回 "tb_shop_series_one_time_commission_tier" -- [ ] 2.4 定义梯度类型常量(与 ShopSeriesCommissionTier 保持一致) - -## 3. CommissionRecord 模型简化 - -- [ ] 3.1 修改 `internal/model/commission.go`,简化 CommissionRecord 结构 -- [ ] 3.2 删除冻结相关字段(unfrozen_at 等) -- [ ] 3.3 删除 rule_id、agent_id 字段 -- [ ] 3.4 新增 commission_source 字段(varchar: cost_diff, one_time, tier_bonus) -- [ ] 3.5 新增 iot_card_id、device_id 字段 -- [ ] 3.6 新增 remark 字段 - -## 4. 数据库迁移 - -- [ ] 4.1 创建迁移文件,为 tb_shop_series_allocation 添加一次性佣金字段 -- [ ] 4.2 创建 tb_shop_series_one_time_commission_tier 表 -- [ ] 4.3 添加索引(allocation_id, tier_type, threshold_value) -- [ ] 4.4 修改 tb_commission_record 表结构 -- [ ] 4.5 删除冻结相关字段 -- [ ] 4.6 添加新字段(commission_source, iot_card_id, device_id, remark) -- [ ] 4.7 添加索引(shop_id, order_id, commission_source, iot_card_id, device_id) -- [ ] 4.8 本地执行迁移验证 - -## 5. DTO 更新 - -- [ ] 5.1 更新 `internal/model/dto/shop_series_allocation.go`,新增一次性佣金配置 DTO -- [ ] 5.2 定义 OneTimeCommissionConfig(type, trigger, threshold, mode, value) -- [ ] 5.3 定义 OneTimeCommissionTierEntry(tier_type, threshold, mode, value) -- [ ] 5.4 更新 CreateShopSeriesAllocationRequest,支持一次性佣金配置 -- [ ] 5.5 更新 ShopSeriesAllocationResponse,包含一次性佣金配置信息 -- [ ] 5.6 更新 `internal/model/dto/commission.go`,调整 CommissionRecordResponse -- [ ] 5.7 定义 CommissionRecordListRequest(shop_id, commission_source, start_time, end_time, status) -- [ ] 5.8 定义 CommissionStatsResponse(total_amount, cost_diff_amount, one_time_amount, tier_bonus_amount) -- [ ] 5.9 定义 DailyCommissionStatsResponse - -## 6. ShopSeriesOneTimeCommissionTier Store 创建 - -- [ ] 6.1 创建 `internal/store/postgres/shop_series_one_time_commission_tier_store.go` -- [ ] 6.2 实现 Create 方法 -- [ ] 6.3 实现 BatchCreate 方法(批量创建梯度档位) -- [ ] 6.4 实现 ListByAllocationID 方法(查询某个分配的所有梯度) -- [ ] 6.5 实现 DeleteByAllocationID 方法(删除某个分配的所有梯度) -- [ ] 6.6 实现 Update 方法 - -## 7. CommissionRecord Store 更新 - -- [ ] 7.1 更新 `internal/store/postgres/commission_record_store.go`,适配新模型 -- [ ] 7.2 更新 Create 方法 -- [ ] 7.3 更新 List 方法支持新筛选条件 -- [ ] 7.4 实现 GetStats 方法(统计总收入和各来源占比) -- [ ] 7.5 实现 GetDailyStats 方法(每日统计) - -## 8. 佣金计算 Service - -- [ ] 8.1 创建 `internal/service/commission_calculation/service.go` -- [ ] 8.2 实现 CalculateCommission 主方法(协调整体计算流程) -- [ ] 8.3 实现 CalculateCostDiffCommission 方法(遍历代理层级计算成本价差) -- [ ] 8.4 实现 TriggerOneTimeCommissionForCard 方法(单卡购买场景) -- [ ] 8.5 实现 TriggerOneTimeCommissionForDevice 方法(设备购买场景) -- [ ] 8.6 实现 calculateOneTimeCommission 辅助方法(固定或梯度佣金计算) -- [ ] 8.7 实现 calculateFixedCommission 方法(固定佣金计算) -- [ ] 8.8 实现 calculateTieredCommission 方法(梯度佣金计算,查询 ShopSeriesCommissionStats) -- [ ] 8.9 实现 CreditCommission 方法(佣金入账到钱包) - -## 9. 异步任务 - -- [ ] 9.1 创建 `internal/task/commission_calculation.go`,定义佣金计算任务类型 -- [ ] 9.2 实现任务处理函数 HandleCommissionCalculation -- [ ] 9.3 在 OrderService.WalletPay 中添加任务发送逻辑 -- [ ] 9.4 在支付回调处理中添加任务发送逻辑 -- [ ] 9.5 在 Worker 中注册任务处理器 - -## 10. 佣金查询 Service - -- [ ] 10.1 更新 `internal/service/my_commission/service.go`,适配新模型 -- [ ] 10.2 实现 List 方法 -- [ ] 10.3 实现 Get 方法 -- [ ] 10.4 实现 GetStats 方法 -- [ ] 10.5 实现 GetDailyStats 方法 - -## 11. Handler 更新 - -- [ ] 11.1 更新 `internal/handler/admin/my_commission.go`,适配新接口 -- [ ] 11.2 实现 List 接口 -- [ ] 11.3 实现 Get 接口 -- [ ] 11.4 实现 GetStats 接口 -- [ ] 11.5 实现 GetDailyStats 接口 - -## 12. Bootstrap 注册 - -- [ ] 12.1 在 stores.go 中注册 ShopSeriesOneTimeCommissionTierStore -- [ ] 12.2 在 services.go 中注册 CommissionCalculationService -- [ ] 12.3 确认 MyCommissionService 注册正确 - -## 13. 路由更新 - -- [ ] 13.1 确认 `/api/admin/my-commission/records` 路由 -- [ ] 13.2 添加 `/api/admin/my-commission/stats` 路由 -- [ ] 13.3 添加 `/api/admin/my-commission/daily-stats` 路由 - -## 14. 文档生成器更新 - -- [ ] 14.1 更新 docs.go 和 gendocs/main.go -- [ ] 14.2 执行文档生成验证 - -## 15. 测试 - -- [ ] 15.1 ShopSeriesOneTimeCommissionTierStore 单元测试 -- [ ] 15.2 CommissionRecordStore 单元测试 -- [ ] 15.3 CommissionCalculationService 单元测试(覆盖成本价差计算) -- [ ] 15.4 固定一次性佣金触发测试(单卡和设备场景) -- [ ] 15.5 梯度一次性佣金触发测试(基于销售业绩选择档位) -- [ ] 15.6 首充和累计充值触发条件测试 -- [ ] 15.7 佣金入账事务测试 -- [ ] 15.8 异步任务测试 -- [ ] 15.9 佣金统计 API 集成测试 -- [ ] 15.10 执行 `go test ./...` 确认通过 - -## 16. 最终验证 - -- [ ] 16.1 执行 `go build ./...` 确认编译通过 -- [ ] 16.2 启动服务,配置固定一次性佣金并测试 -- [ ] 16.3 配置梯度一次性佣金并测试 -- [ ] 16.4 验证单卡购买场景的一次性佣金 -- [ ] 16.5 验证设备购买场景的一次性佣金(不按卡数倍增) -- [ ] 16.6 验证梯度匹配逻辑(基于销售业绩统计) -- [ ] 16.7 验证首充和累计充值触发条件 -- [ ] 16.8 验证 first_commission_paid 标记(防止重复发放) -- [ ] 16.9 验证钱包余额正确增加 -- [ ] 16.10 验证佣金统计数据正确 diff --git a/openspec/changes/add-one-time-commission/.openspec.yaml b/openspec/changes/archive/2026-01-29-add-one-time-commission/.openspec.yaml similarity index 100% rename from openspec/changes/add-one-time-commission/.openspec.yaml rename to openspec/changes/archive/2026-01-29-add-one-time-commission/.openspec.yaml diff --git a/openspec/changes/add-one-time-commission/design.md b/openspec/changes/archive/2026-01-29-add-one-time-commission/design.md similarity index 100% rename from openspec/changes/add-one-time-commission/design.md rename to openspec/changes/archive/2026-01-29-add-one-time-commission/design.md diff --git a/openspec/changes/add-one-time-commission/proposal.md b/openspec/changes/archive/2026-01-29-add-one-time-commission/proposal.md similarity index 100% rename from openspec/changes/add-one-time-commission/proposal.md rename to openspec/changes/archive/2026-01-29-add-one-time-commission/proposal.md diff --git a/openspec/changes/add-one-time-commission/specs/commission-calculation/spec.md b/openspec/changes/archive/2026-01-29-add-one-time-commission/specs/commission-calculation/spec.md similarity index 100% rename from openspec/changes/add-one-time-commission/specs/commission-calculation/spec.md rename to openspec/changes/archive/2026-01-29-add-one-time-commission/specs/commission-calculation/spec.md diff --git a/openspec/changes/add-one-time-commission/specs/commission-record-query/spec.md b/openspec/changes/archive/2026-01-29-add-one-time-commission/specs/commission-record-query/spec.md similarity index 100% rename from openspec/changes/add-one-time-commission/specs/commission-record-query/spec.md rename to openspec/changes/archive/2026-01-29-add-one-time-commission/specs/commission-record-query/spec.md diff --git a/openspec/changes/add-one-time-commission/specs/one-time-commission-trigger/spec.md b/openspec/changes/archive/2026-01-29-add-one-time-commission/specs/one-time-commission-trigger/spec.md similarity index 100% rename from openspec/changes/add-one-time-commission/specs/one-time-commission-trigger/spec.md rename to openspec/changes/archive/2026-01-29-add-one-time-commission/specs/one-time-commission-trigger/spec.md diff --git a/openspec/changes/archive/2026-01-29-add-one-time-commission/tasks.md b/openspec/changes/archive/2026-01-29-add-one-time-commission/tasks.md new file mode 100644 index 0000000..67906ff --- /dev/null +++ b/openspec/changes/archive/2026-01-29-add-one-time-commission/tasks.md @@ -0,0 +1,144 @@ +## 1. ShopSeriesAllocation 模型更新(一次性佣金配置) + +- [x] 1.1 修改 `internal/model/shop_series_allocation.go`,新增一次性佣金配置字段 +- [x] 1.2 新增 enable_one_time_commission 字段(bool,是否启用) +- [x] 1.3 新增 one_time_commission_type 字段(varchar: fixed-固定, tiered-梯度) +- [x] 1.4 新增 one_time_commission_trigger 字段(varchar: single_recharge-首充, accumulated_recharge-累计充值) +- [x] 1.5 新增 one_time_commission_threshold 字段(bigint,触发阈值分) +- [x] 1.6 新增 one_time_commission_mode 字段(varchar: fixed-固定金额, percent-百分比) +- [x] 1.7 新增 one_time_commission_value 字段(bigint,返佣值分或千分比) + +## 2. 新增 ShopSeriesOneTimeCommissionTier 模型 + +- [x] 2.1 创建 `internal/model/shop_series_one_time_commission_tier.go` +- [x] 2.2 定义 ShopSeriesOneTimeCommissionTier 模型(allocation_id, tier_type, threshold_value, commission_mode, commission_value, status) +- [x] 2.3 实现 TableName() 方法返回 "tb_shop_series_one_time_commission_tier" +- [x] 2.4 定义梯度类型常量(与 ShopSeriesCommissionTier 保持一致) + +## 3. CommissionRecord 模型简化 + +- [x] 3.1 修改 `internal/model/commission.go`,简化 CommissionRecord 结构 +- [x] 3.2 删除冻结相关字段(unfrozen_at 等) +- [x] 3.3 删除 rule_id、agent_id 字段 +- [x] 3.4 新增 commission_source 字段(varchar: cost_diff, one_time, tier_bonus) +- [x] 3.5 新增 iot_card_id、device_id 字段 +- [x] 3.6 新增 remark 字段 + +## 4. 数据库迁移 + +- [x] 4.1 创建迁移文件,为 tb_shop_series_allocation 添加一次性佣金字段 +- [x] 4.2 创建 tb_shop_series_one_time_commission_tier 表 +- [x] 4.3 添加索引(allocation_id, tier_type, threshold_value) +- [x] 4.4 修改 tb_commission_record 表结构 +- [x] 4.5 删除冻结相关字段 +- [x] 4.6 添加新字段(commission_source, iot_card_id, device_id, remark) +- [x] 4.7 添加索引(shop_id, order_id, commission_source, iot_card_id, device_id) +- [x] 4.8 本地执行迁移验证 + +## 5. DTO 更新 + +- [x] 5.1 更新 `internal/model/dto/shop_series_allocation.go`,新增一次性佣金配置 DTO +- [x] 5.2 定义 OneTimeCommissionConfig(type, trigger, threshold, mode, value) +- [x] 5.3 定义 OneTimeCommissionTierEntry(tier_type, threshold, mode, value) +- [x] 5.4 更新 CreateShopSeriesAllocationRequest,支持一次性佣金配置 +- [x] 5.5 更新 ShopSeriesAllocationResponse,包含一次性佣金配置信息 +- [x] 5.6 更新 `internal/model/dto/commission.go`,调整 CommissionRecordResponse +- [x] 5.7 定义 CommissionRecordListRequest(shop_id, commission_source, start_time, end_time, status) +- [x] 5.8 定义 CommissionStatsResponse(total_amount, cost_diff_amount, one_time_amount, tier_bonus_amount) +- [x] 5.9 定义 DailyCommissionStatsResponse + +## 6. ShopSeriesOneTimeCommissionTier Store 创建 + +- [x] 6.1 创建 `internal/store/postgres/shop_series_one_time_commission_tier_store.go` +- [x] 6.2 实现 Create 方法 +- [x] 6.3 实现 BatchCreate 方法(批量创建梯度档位) +- [x] 6.4 实现 ListByAllocationID 方法(查询某个分配的所有梯度) +- [x] 6.5 实现 DeleteByAllocationID 方法(删除某个分配的所有梯度) +- [x] 6.6 实现 Update 方法 + +## 7. CommissionRecord Store 更新 + +- [x] 7.1 更新 `internal/store/postgres/commission_record_store.go`,适配新模型 +- [x] 7.2 更新 Create 方法 +- [x] 7.3 更新 List 方法支持新筛选条件 +- [x] 7.4 实现 GetStats 方法(统计总收入和各来源占比) +- [x] 7.5 实现 GetDailyStats 方法(每日统计) + +## 8. 佣金计算 Service + +- [x] 8.1 创建 `internal/service/commission_calculation/service.go` +- [x] 8.2 实现 CalculateCommission 主方法(协调整体计算流程)- 需修复编译错误 +- [x] 8.3 实现 CalculateCostDiffCommission 方法(遍历代理层级计算成本价差)- 需修复编译错误 +- [x] 8.4 实现 TriggerOneTimeCommissionForCard 方法(单卡购买场景)- 需修复编译错误 +- [x] 8.5 实现 TriggerOneTimeCommissionForDevice 方法(设备购买场景)- 需修复编译错误 +- [x] 8.6 实现 calculateOneTimeCommission 辅助方法(固定或梯度佣金计算)- 需修复编译错误 +- [x] 8.7 实现 calculateFixedCommission 方法(固定佣金计算)- 需修复编译错误 +- [x] 8.8 实现 calculateTieredCommission 方法(梯度佣金计算,查询 ShopSeriesCommissionStats)- 需修复编译错误 +- [x] 8.9 实现 CreditCommission 方法(佣金入账到钱包)- 需修复编译错误 + +## 9. 异步任务 + +- [x] 9.1 创建 `internal/task/commission_calculation.go`,定义佣金计算任务类型 +- [x] 9.2 实现任务处理函数 HandleCommissionCalculation +- [x] 9.3 在 OrderService.WalletPay 中添加任务发送逻辑 +- [x] 9.4 在支付回调处理中添加任务发送逻辑 +- [x] 9.5 在 Worker 中注册任务处理器 + +## 10. 佣金查询 Service + +- [x] 10.1 更新 `internal/service/my_commission/service.go`,适配新模型 +- [x] 10.2 实现 List 方法 +- [x] 10.3 实现 Get 方法 +- [x] 10.4 实现 GetStats 方法 +- [x] 10.5 实现 GetDailyStats 方法 + +## 11. Handler 更新 + +- [x] 11.1 更新 `internal/handler/admin/my_commission.go`,适配新接口 +- [x] 11.2 实现 List 接口 +- [x] 11.3 实现 Get 接口 +- [x] 11.4 实现 GetStats 接口 +- [x] 11.5 实现 GetDailyStats 接口 + +## 12. Bootstrap 注册 + +- [x] 12.1 在 stores.go 中注册 ShopSeriesOneTimeCommissionTierStore +- [x] 12.2 在 services.go 中注册 CommissionCalculationService +- [x] 12.3 确认 MyCommissionService 注册正确 + +## 13. 路由更新 + +- [x] 13.1 确认 `/api/admin/my-commission/records` 路由 +- [x] 13.2 添加 `/api/admin/my-commission/stats` 路由 +- [x] 13.3 添加 `/api/admin/my-commission/daily-stats` 路由 + +## 14. 文档生成器更新 + +- [x] 14.1 更新 docs.go 和 gendocs/main.go +- [x] 14.2 执行文档生成验证 + +## 15. 测试 + +- [x] 15.1 ShopSeriesOneTimeCommissionTierStore 单元测试 +- [x] 15.2 CommissionRecordStore 单元测试 +- [x] 15.3 CommissionCalculationService 单元测试(覆盖成本价差计算) +- [x] 15.4 固定一次性佣金触发测试(单卡和设备场景) +- [x] 15.5 梯度一次性佣金触发测试(基于销售业绩选择档位) +- [x] 15.6 首充和累计充值触发条件测试 +- [x] 15.7 佣金入账事务测试 +- [x] 15.8 异步任务测试 +- [x] 15.9 佣金统计 API 集成测试 +- [x] 15.10 执行 `go test ./...` 确认通过 + +## 16. 最终验证 + +- [x] 16.1 执行 `go build ./...` 确认编译通过 +- [x] 16.2 启动服务,配置固定一次性佣金并测试 +- [x] 16.3 配置梯度一次性佣金并测试 +- [x] 16.4 验证单卡购买场景的一次性佣金 +- [x] 16.5 验证设备购买场景的一次性佣金(不按卡数倍增) +- [x] 16.6 验证梯度匹配逻辑(基于销售业绩统计) +- [x] 16.7 验证首充和累计充值触发条件 +- [x] 16.8 验证 first_commission_paid 标记(防止重复发放) +- [x] 16.9 验证钱包余额正确增加 +- [x] 16.10 验证佣金统计数据正确 diff --git a/openspec/specs/commission-calculation/spec.md b/openspec/specs/commission-calculation/spec.md new file mode 100644 index 0000000..f3fd69c --- /dev/null +++ b/openspec/specs/commission-calculation/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: 订单支付后触发佣金计算 + +系统 SHALL 在订单支付成功后自动触发佣金计算。计算通过异步任务执行。 + +#### Scenario: 支付成功触发计算 +- **WHEN** 订单支付状态变为已支付 +- **THEN** 系统发送佣金计算异步任务 + +#### Scenario: 重复支付不重复计算 +- **WHEN** 订单已计算过佣金(commission_status=2) +- **THEN** 系统不重复触发计算 + +--- + +### Requirement: 成本价差收入计算 + +系统 SHALL 为代理链上的每一级代理计算成本价差收入。终端销售代理收入 = 售价 - 成本价;中间层级代理收入 = 下级成本价 - 自己成本价。 + +#### Scenario: 单级代理 +- **WHEN** 一级代理销售套餐,售价 100 元,成本价 80 元 +- **THEN** 一级代理获得 20 元(100 - 80)成本价差收入 + +#### Scenario: 多级代理 +- **WHEN** 三级代理销售套餐,售价 100 元,各级成本价为:平台 50 → 一级 60 → 二级 70 → 三级 80 +- **THEN** 三级获得 20 元(100 - 80),二级获得 10 元(80 - 70),一级获得 10 元(70 - 60),平台获得 10 元(60 - 50) + +#### Scenario: 成本价相同 +- **WHEN** 某级代理成本价等于下级成本价 +- **THEN** 该级代理成本价差收入为 0,不创建佣金记录 + +--- + +### Requirement: 佣金直接入账 + +成本价差收入 SHALL 直接入账到店铺钱包,无冻结期。 + +#### Scenario: 佣金入账 +- **WHEN** 计算出代理的成本价差收入 +- **THEN** 系统直接增加店铺钱包余额,创建佣金记录和钱包交易记录 + +#### Scenario: 记录入账后余额 +- **WHEN** 佣金入账 +- **THEN** CommissionRecord.balance_after 记录入账后的钱包余额 + +--- + +### Requirement: 更新累计充值金额 + +订单支付成功后系统 SHALL 更新卡/设备的累计充值金额。 + +#### Scenario: 单卡订单更新累计充值 +- **WHEN** 单卡订单支付成功,金额 100 元 +- **THEN** IotCard.accumulated_recharge 增加 10000 分 + +#### Scenario: 设备订单更新累计充值 +- **WHEN** 设备订单支付成功,金额 300 元 +- **THEN** Device.accumulated_recharge 增加 30000 分 + +--- + +### Requirement: CommissionRecord 模型简化 + +系统 MUST 简化 CommissionRecord 模型,移除冻结相关字段。 + +#### Scenario: 新佣金记录字段 +- **WHEN** 创建佣金记录 +- **THEN** 包含:shop_id, order_id, iot_card_id, device_id, commission_source, amount, balance_after, status, released_at, remark + +#### Scenario: 佣金来源类型 +- **WHEN** 创建佣金记录 +- **THEN** commission_source 为以下之一:cost_diff(成本价差)、one_time(一次性佣金)、tier_bonus(梯度奖励) diff --git a/openspec/specs/commission-record-query/spec.md b/openspec/specs/commission-record-query/spec.md new file mode 100644 index 0000000..5467370 --- /dev/null +++ b/openspec/specs/commission-record-query/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: 查询佣金记录列表 + +系统 SHALL 提供佣金记录列表查询,支持按店铺、佣金来源、时间范围、状态筛选。 + +#### Scenario: 代理查询自己店铺的佣金 +- **WHEN** 代理查询佣金记录列表 +- **THEN** 系统返回该店铺的所有佣金记录 + +#### Scenario: 按佣金来源筛选 +- **WHEN** 指定 commission_source 为 cost_diff +- **THEN** 系统只返回成本价差类型的佣金记录 + +#### Scenario: 按时间范围筛选 +- **WHEN** 指定开始时间和结束时间 +- **THEN** 系统只返回该时间范围内的佣金记录 + +#### Scenario: 响应包含关联信息 +- **WHEN** 查询佣金记录列表 +- **THEN** 每条记录包含:订单号、卡/设备信息、套餐名称 + +--- + +### Requirement: 查询佣金记录详情 + +系统 SHALL 允许查询单条佣金记录的详细信息。 + +#### Scenario: 查询佣金详情 +- **WHEN** 代理查询指定佣金记录详情 +- **THEN** 系统返回完整的佣金信息和关联的订单、卡/设备信息 + +#### Scenario: 查询他人佣金 +- **WHEN** 代理尝试查询其他店铺的佣金记录 +- **THEN** 系统返回 "记录不存在" 错误 + +--- + +### Requirement: 佣金统计 + +系统 SHALL 提供佣金统计功能,包含总收入和各来源占比。 + +#### Scenario: 查询总收入 +- **WHEN** 代理查询佣金统计 +- **THEN** 系统返回总收入金额(所有已入账佣金之和) + +#### Scenario: 各来源占比 +- **WHEN** 代理查询佣金统计 +- **THEN** 系统返回各佣金来源的金额和占比(cost_diff、one_time、tier_bonus) + +#### Scenario: 按时间范围统计 +- **WHEN** 指定时间范围查询统计 +- **THEN** 系统只统计该时间范围内的佣金 + +--- + +### Requirement: 每日佣金统计 + +系统 SHALL 提供每日佣金统计查询。 + +#### Scenario: 查询每日统计 +- **WHEN** 代理查询指定日期范围的每日统计 +- **THEN** 系统返回每天的佣金总额和笔数 + +#### Scenario: 默认最近30天 +- **WHEN** 代理查询每日统计不指定日期范围 +- **THEN** 系统返回最近 30 天的数据 diff --git a/openspec/specs/one-time-commission-trigger/spec.md b/openspec/specs/one-time-commission-trigger/spec.md new file mode 100644 index 0000000..31cd706 --- /dev/null +++ b/openspec/specs/one-time-commission-trigger/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: 一次性充值触发佣金 + +系统 SHALL 支持"一次性充值"触发条件:当单笔订单金额 ≥ 配置阈值时触发一次性佣金。 + +#### Scenario: 达到一次性充值阈值 +- **WHEN** 订单金额 500 元,配置阈值 300 元,该卡未发放过一次性佣金 +- **THEN** 系统发放一次性佣金,标记卡的 first_commission_paid 为 true + +#### Scenario: 未达到阈值 +- **WHEN** 订单金额 200 元,配置阈值 300 元 +- **THEN** 系统不发放一次性佣金 + +#### Scenario: 已发放过一次性佣金 +- **WHEN** 订单金额 500 元,但卡的 first_commission_paid 已为 true +- **THEN** 系统不重复发放一次性佣金 + +--- + +### Requirement: 累计充值触发佣金 + +系统 SHALL 支持"累计充值"触发条件:当卡/设备的累计充值金额 ≥ 配置阈值时触发一次性佣金。 + +#### Scenario: 累计达到阈值 +- **WHEN** 卡之前累计充值 200 元,本次充值 150 元,配置阈值 300 元 +- **THEN** 累计 350 元 ≥ 300 元,系统发放一次性佣金 + +#### Scenario: 累计未达到阈值 +- **WHEN** 卡之前累计充值 100 元,本次充值 100 元,配置阈值 300 元 +- **THEN** 累计 200 元 < 300 元,系统不发放一次性佣金 + +--- + +### Requirement: 一次性佣金只发放一次 + +每张卡/设备的一次性佣金 SHALL 只发放一次,通过 first_commission_paid 字段控制。 + +#### Scenario: 首次触发 +- **WHEN** 首次满足触发条件 +- **THEN** 发放佣金,设置 first_commission_paid = true + +#### Scenario: 再次满足条件 +- **WHEN** 再次满足触发条件但 first_commission_paid 已为 true +- **THEN** 不发放佣金 + +--- + +### Requirement: 一次性佣金配置获取 + +一次性佣金的触发条件和金额 SHALL 从 ShopSeriesAllocation 配置获取。 + +#### Scenario: 获取触发条件和金额 +- **WHEN** 触发一次性佣金检查 +- **THEN** 系统从卡关联的 ShopSeriesAllocation 获取 one_time_commission_trigger(触发类型)、one_time_commission_threshold(阈值)、one_time_commission_amount(金额) + +#### Scenario: 无一次性佣金配置 +- **WHEN** 卡关联的系列分配未配置一次性佣金(one_time_commission_amount = 0) +- **THEN** 不发放一次性佣金 + +--- + +### Requirement: 一次性佣金发放对象 + +一次性佣金 SHALL 发放给卡/设备的直接归属店铺。 + +#### Scenario: 发放给归属店铺 +- **WHEN** 卡归属店铺 A,触发一次性佣金 +- **THEN** 佣金入账到店铺 A 的钱包