From 113b3edd69501b86d4f9060632d8cab8e025b2bc Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 31 Jan 2026 12:01:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(order):=20=E6=94=AF=E6=8C=81=E4=BB=A3?= =?UTF-8?q?=E8=B4=AD=E8=AE=A2=E5=8D=95=E5=92=8C=E5=BC=BA=E5=85=85=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderService 新增代购订单支持 - 强充要求检查(首次购买最低充值) - 代购订单支付限制(无需支付) - 强充金额验证 - 新增 OrderDTO 请求/响应结构 - PurchaseCheckRequest/Response(购买预检) - CreatePurchaseOnBehalfRequest(代购订单创建) - Order 模型新增支付方式 - PaymentMethodOffline(线下支付,仅平台代购使用) - OrderService 依赖注入扩展 - 新增 SeriesAllocationStore、IotCardStore、DeviceStore - 支持强充要求检查逻辑 - 完整的集成测试覆盖(534 行) - 代购订单创建、强充验证、支付限制等场景 Co-authored-by: Sisyphus --- internal/model/dto/order_dto.go | 22 + internal/model/order.go | 7 +- internal/service/order/service.go | 221 ++++++++++ internal/service/order/service_test.go | 534 ++++++++++++++++++++++++- 4 files changed, 779 insertions(+), 5 deletions(-) diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index d0f7efb..cc9520a 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -72,3 +72,25 @@ type PayOrderParams struct { ID uint `path:"id" description:"订单ID" required:"true"` PayOrderRequest } + +type PurchaseCheckRequest struct { + OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"` + ResourceID uint `json:"resource_id" validate:"required,min=1" required:"true" description:"资源ID (IoT卡ID或设备ID)"` + PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` +} + +type PurchaseCheckResponse struct { + TotalPackageAmount int64 `json:"total_package_amount" description:"套餐总价(分)"` + NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强充"` + ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"` + ActualPayment int64 `json:"actual_payment" description:"实际支付金额(分)"` + WalletCredit int64 `json:"wallet_credit" description:"钱包到账金额(分)"` + Message string `json:"message" description:"提示信息"` +} + +type CreatePurchaseOnBehalfRequest struct { + OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"` + IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"` + DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"` + PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` +} diff --git a/internal/model/order.go b/internal/model/order.go index eca678a..8167ca9 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -63,9 +63,10 @@ const ( // 支付方式常量 const ( - PaymentMethodWallet = "wallet" // 钱包支付 - PaymentMethodWechat = "wechat" // 微信支付 - PaymentMethodAlipay = "alipay" // 支付宝支付 + PaymentMethodWallet = "wallet" // 钱包支付 + PaymentMethodWechat = "wechat" // 微信支付 + PaymentMethodAlipay = "alipay" // 支付宝支付 + PaymentMethodOffline = "offline" // 线下支付(仅平台代购使用) ) // 支付状态常量 diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 589c808..bba1bf3 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -27,6 +27,9 @@ type Service struct { walletStore *postgres.WalletStore purchaseValidationService *purchase_validation.Service allocationConfigStore *postgres.ShopSeriesAllocationConfigStore + seriesAllocationStore *postgres.ShopSeriesAllocationStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore wechatPayment wechat.PaymentServiceInterface queueClient *queue.Client logger *zap.Logger @@ -39,6 +42,9 @@ func New( walletStore *postgres.WalletStore, purchaseValidationService *purchase_validation.Service, allocationConfigStore *postgres.ShopSeriesAllocationConfigStore, + seriesAllocationStore *postgres.ShopSeriesAllocationStore, + iotCardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, wechatPayment wechat.PaymentServiceInterface, queueClient *queue.Client, logger *zap.Logger, @@ -50,6 +56,9 @@ func New( walletStore: walletStore, purchaseValidationService: purchaseValidationService, allocationConfigStore: allocationConfigStore, + seriesAllocationStore: seriesAllocationStore, + iotCardStore: iotCardStore, + deviceStore: deviceStore, wechatPayment: wechatPayment, queueClient: queueClient, logger: logger, @@ -78,6 +87,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer return nil, err } + forceRechargeCheck := s.checkForceRechargeRequirement(validationResult) + if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount { + return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求") + } + userID := middleware.GetUserIDFromContext(ctx) configVersion := s.snapshotCommissionConfig(ctx, validationResult.Allocation.ID) @@ -254,6 +268,10 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, return errors.New(errors.CodeForbidden, "无权操作此订单") } + if order.IsPurchaseOnBehalf { + return errors.New(errors.CodeInvalidStatus, "代购订单无需支付") + } + var resourceType string var resourceID uint @@ -646,3 +664,206 @@ func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto. H5URL: result.H5URL, }, nil } + +type ForceRechargeRequirement struct { + NeedForceRecharge bool + ForceRechargeAmount int64 + TriggerType string +} + +func (s *Service) checkForceRechargeRequirement(result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement { + if result.Allocation == nil { + return &ForceRechargeRequirement{NeedForceRecharge: false} + } + + allocation := result.Allocation + if !allocation.EnableOneTimeCommission { + return &ForceRechargeRequirement{NeedForceRecharge: false} + } + + var firstCommissionPaid bool + if result.Card != nil { + firstCommissionPaid = result.Card.FirstCommissionPaid + } else if result.Device != nil { + firstCommissionPaid = result.Device.FirstCommissionPaid + } + + if firstCommissionPaid { + return &ForceRechargeRequirement{NeedForceRecharge: false} + } + + if allocation.OneTimeCommissionTrigger == model.OneTimeCommissionTriggerSingleRecharge { + return &ForceRechargeRequirement{ + NeedForceRecharge: true, + ForceRechargeAmount: allocation.OneTimeCommissionThreshold, + TriggerType: model.OneTimeCommissionTriggerSingleRecharge, + } + } + + if allocation.EnableForceRecharge { + forceAmount := allocation.ForceRechargeAmount + if forceAmount == 0 { + forceAmount = allocation.OneTimeCommissionThreshold + } + return &ForceRechargeRequirement{ + NeedForceRecharge: true, + ForceRechargeAmount: forceAmount, + TriggerType: model.OneTimeCommissionTriggerAccumulatedRecharge, + } + } + + return &ForceRechargeRequirement{NeedForceRecharge: false} +} + +func (s *Service) GetPurchaseCheck(ctx context.Context, req *dto.PurchaseCheckRequest) (*dto.PurchaseCheckResponse, error) { + var validationResult *purchase_validation.PurchaseValidationResult + var err error + + if req.OrderType == model.OrderTypeSingleCard { + validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, req.ResourceID, req.PackageIDs) + } else if req.OrderType == model.OrderTypeDevice { + validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, req.ResourceID, req.PackageIDs) + } else { + return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型") + } + + if err != nil { + return nil, err + } + + forceRechargeCheck := s.checkForceRechargeRequirement(validationResult) + + response := &dto.PurchaseCheckResponse{ + TotalPackageAmount: validationResult.TotalPrice, + NeedForceRecharge: forceRechargeCheck.NeedForceRecharge, + ForceRechargeAmount: forceRechargeCheck.ForceRechargeAmount, + ActualPayment: validationResult.TotalPrice, + WalletCredit: validationResult.TotalPrice, + } + + if forceRechargeCheck.NeedForceRecharge { + if validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount { + response.ActualPayment = forceRechargeCheck.ForceRechargeAmount + response.WalletCredit = forceRechargeCheck.ForceRechargeAmount + response.Message = "首次购买需满足最低充值要求" + } + } + + return response, nil +} + +func (s *Service) CreatePurchaseOnBehalf(ctx context.Context, req *dto.CreatePurchaseOnBehalfRequest, operatorID uint) (*dto.OrderResponse, error) { + var validationResult *purchase_validation.PurchaseValidationResult + var resourceShopID *uint + var err error + + if req.OrderType == model.OrderTypeSingleCard { + if req.IotCardID == nil { + return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID") + } + validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs) + if err != nil { + return nil, err + } + if validationResult.Card != nil { + resourceShopID = validationResult.Card.ShopID + } + } else if req.OrderType == model.OrderTypeDevice { + if req.DeviceID == nil { + return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID") + } + validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs) + if err != nil { + return nil, err + } + if validationResult.Device != nil { + resourceShopID = validationResult.Device.ShopID + } + } else { + return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型") + } + + if resourceShopID == nil || *resourceShopID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "资源未分配给代理商,无法代购") + } + + buyerID := *resourceShopID + buyerAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, buyerID, validationResult.Allocation.SeriesID) + if err != nil { + return nil, errors.New(errors.CodeInvalidParam, "买家没有该套餐系列的分配配置") + } + + buyerCostPrice := utils.CalculateCostPrice(buyerAllocation, validationResult.TotalPrice) + + configVersion := s.snapshotCommissionConfig(ctx, validationResult.Allocation.ID) + + var seriesID *uint + var sellerShopID *uint + if validationResult.Allocation != nil { + seriesID = &validationResult.Allocation.SeriesID + sellerShopID = &validationResult.Allocation.ShopID + } + + now := time.Now() + order := &model.Order{ + BaseModel: model.BaseModel{ + Creator: operatorID, + Updater: operatorID, + }, + OrderNo: s.orderStore.GenerateOrderNo(), + OrderType: req.OrderType, + BuyerType: model.BuyerTypeAgent, + BuyerID: buyerID, + IotCardID: req.IotCardID, + DeviceID: req.DeviceID, + TotalAmount: buyerCostPrice, + PaymentMethod: model.PaymentMethodOffline, + PaymentStatus: model.PaymentStatusPaid, + PaidAt: &now, + CommissionStatus: model.CommissionStatusPending, + CommissionConfigVersion: configVersion, + SeriesID: seriesID, + SellerShopID: sellerShopID, + SellerCostPrice: buyerCostPrice, + IsPurchaseOnBehalf: true, + } + + var items []*model.OrderItem + for _, pkg := range validationResult.Packages { + item := &model.OrderItem{ + BaseModel: model.BaseModel{ + Creator: operatorID, + Updater: operatorID, + }, + PackageID: pkg.ID, + PackageName: pkg.PackageName, + Quantity: 1, + UnitPrice: pkg.SuggestedRetailPrice, + Amount: pkg.SuggestedRetailPrice, + } + items = append(items, item) + } + + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(order).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建代购订单失败") + } + + for _, item := range items { + item.OrderID = order.ID + } + if err := tx.CreateInBatches(items, 100).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败") + } + + return s.activatePackage(ctx, tx, order) + }) + + if err != nil { + return nil, err + } + + s.enqueueCommissionCalculation(ctx, order.ID) + + return s.buildOrderResponse(order, items), nil +} diff --git a/internal/service/order/service_test.go b/internal/service/order/service_test.go index e233f05..fe5a465 100644 --- a/internal/service/order/service_test.go +++ b/internal/service/order/service_test.go @@ -126,7 +126,7 @@ func setupOrderTestEnv(t *testing.T) *testEnv { purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) logger := zap.NewNop() - orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, nil, logger) + orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{ UserID: 1, @@ -536,7 +536,7 @@ func TestOrderService_IdempotencyAndConcurrency(t *testing.T) { purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) logger := zap.NewNop() - orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, nil, logger) + orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{ UserID: 1, @@ -620,3 +620,533 @@ func TestOrderService_IdempotencyAndConcurrency(t *testing.T) { assert.Equal(t, int64(0), usageCount) }) } + +func TestOrderService_ForceRechargeValidation(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + iotCardStore := postgres.NewIotCardStore(tx, rdb) + deviceStore := postgres.NewDeviceStore(tx, rdb) + packageStore := postgres.NewPackageStore(tx) + seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) + packageSeriesStore := postgres.NewPackageSeriesStore(tx) + carrierStore := postgres.NewCarrierStore(tx) + shopStore := postgres.NewShopStore(tx, rdb) + orderStore := postgres.NewOrderStore(tx, rdb) + orderItemStore := postgres.NewOrderItemStore(tx, rdb) + walletStore := postgres.NewWalletStore(tx, rdb) + + ctx := context.Background() + + carrier := &model.Carrier{ + CarrierCode: "TEST_CARRIER_FR", + CarrierName: "测试运营商强充", + CarrierType: constants.CarrierTypeCMCC, + Status: constants.StatusEnabled, + } + require.NoError(t, carrierStore.Create(ctx, carrier)) + + shop := &model.Shop{ + ShopName: "测试店铺FR", + ShopCode: "TEST_SHOP_FR", + Level: 1, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, shopStore.Create(ctx, shop)) + + series := &model.PackageSeries{ + SeriesCode: "TEST_SERIES_FR", + SeriesName: "测试套餐系列强充", + Description: "测试用", + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, packageSeriesStore.Create(ctx, series)) + + allocation := &model.ShopSeriesAllocation{ + ShopID: shop.ID, + SeriesID: series.ID, + Status: constants.StatusEnabled, + EnableOneTimeCommission: true, + OneTimeCommissionTrigger: model.OneTimeCommissionTriggerSingleRecharge, + OneTimeCommissionThreshold: 20000, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, seriesAllocationStore.Create(ctx, allocation)) + + cheapPkg := &model.Package{ + PackageCode: "TEST_PKG_CHEAP", + PackageName: "便宜套餐", + SeriesID: series.ID, + PackageType: "formal", + DurationMonths: 1, + DataAmountMB: 512, + SuggestedRetailPrice: 5000, + Status: constants.StatusEnabled, + ShelfStatus: constants.ShelfStatusOn, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, packageStore.Create(ctx, cheapPkg)) + + expensivePkg := &model.Package{ + PackageCode: "TEST_PKG_EXP", + PackageName: "高价套餐", + SeriesID: series.ID, + PackageType: "formal", + DurationMonths: 12, + DataAmountMB: 10240, + SuggestedRetailPrice: 25000, + Status: constants.StatusEnabled, + ShelfStatus: constants.ShelfStatusOn, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, packageStore.Create(ctx, expensivePkg)) + + shopIDPtr := &shop.ID + card := &model.IotCard{ + ICCID: "89860000000000000FR1", + ShopID: shopIDPtr, + CarrierID: carrier.ID, + SeriesAllocationID: &allocation.ID, + Status: constants.StatusEnabled, + FirstCommissionPaid: false, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, iotCardStore.Create(ctx, card)) + + purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) + logger := zap.NewNop() + orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) + + userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypeAgent, + ShopID: shop.ID, + }) + + t.Run("强充验证-金额不足拒绝", func(t *testing.T) { + req := &dto.CreateOrderRequest{ + OrderType: model.OrderTypeSingleCard, + IotCardID: &card.ID, + PackageIDs: []uint{cheapPkg.ID}, + } + + _, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) + require.Error(t, err) + appErr, ok := err.(*errors.AppError) + require.True(t, ok) + assert.Equal(t, errors.CodeForceRechargeRequired, appErr.Code) + }) + + t.Run("强充验证-金额足够通过", func(t *testing.T) { + req := &dto.CreateOrderRequest{ + OrderType: model.OrderTypeSingleCard, + IotCardID: &card.ID, + PackageIDs: []uint{expensivePkg.ID}, + } + + resp, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) + require.NoError(t, err) + assert.NotZero(t, resp.ID) + assert.Equal(t, expensivePkg.SuggestedRetailPrice, resp.TotalAmount) + }) + + t.Run("已付佣金-跳过强充验证", func(t *testing.T) { + card2 := &model.IotCard{ + ICCID: "89860000000000000FR2", + ShopID: shopIDPtr, + CarrierID: carrier.ID, + SeriesAllocationID: &allocation.ID, + Status: constants.StatusEnabled, + FirstCommissionPaid: true, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, iotCardStore.Create(ctx, card2)) + + req := &dto.CreateOrderRequest{ + OrderType: model.OrderTypeSingleCard, + IotCardID: &card2.ID, + PackageIDs: []uint{cheapPkg.ID}, + } + + resp, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) + require.NoError(t, err) + assert.NotZero(t, resp.ID) + }) +} + +func TestOrderService_GetPurchaseCheck(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + iotCardStore := postgres.NewIotCardStore(tx, rdb) + deviceStore := postgres.NewDeviceStore(tx, rdb) + packageStore := postgres.NewPackageStore(tx) + seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) + packageSeriesStore := postgres.NewPackageSeriesStore(tx) + carrierStore := postgres.NewCarrierStore(tx) + shopStore := postgres.NewShopStore(tx, rdb) + orderStore := postgres.NewOrderStore(tx, rdb) + orderItemStore := postgres.NewOrderItemStore(tx, rdb) + walletStore := postgres.NewWalletStore(tx, rdb) + + ctx := context.Background() + + carrier := &model.Carrier{ + CarrierCode: "TEST_CARRIER_PC", + CarrierName: "测试运营商预检", + CarrierType: constants.CarrierTypeCMCC, + Status: constants.StatusEnabled, + } + require.NoError(t, carrierStore.Create(ctx, carrier)) + + shop := &model.Shop{ + ShopName: "测试店铺PC", + ShopCode: "TEST_SHOP_PC", + Level: 1, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, shopStore.Create(ctx, shop)) + + series := &model.PackageSeries{ + SeriesCode: "TEST_SERIES_PC", + SeriesName: "测试套餐系列预检", + Description: "测试用", + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, packageSeriesStore.Create(ctx, series)) + + allocation := &model.ShopSeriesAllocation{ + ShopID: shop.ID, + SeriesID: series.ID, + Status: constants.StatusEnabled, + EnableOneTimeCommission: true, + OneTimeCommissionTrigger: model.OneTimeCommissionTriggerSingleRecharge, + OneTimeCommissionThreshold: 10000, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, seriesAllocationStore.Create(ctx, allocation)) + + pkg := &model.Package{ + PackageCode: "TEST_PKG_PC", + PackageName: "测试套餐预检", + SeriesID: series.ID, + PackageType: "formal", + DurationMonths: 1, + DataAmountMB: 1024, + SuggestedRetailPrice: 5000, + Status: constants.StatusEnabled, + ShelfStatus: constants.ShelfStatusOn, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, packageStore.Create(ctx, pkg)) + + shopIDPtr := &shop.ID + card := &model.IotCard{ + ICCID: "89860000000000000PC1", + ShopID: shopIDPtr, + CarrierID: carrier.ID, + SeriesAllocationID: &allocation.ID, + Status: constants.StatusEnabled, + FirstCommissionPaid: false, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, iotCardStore.Create(ctx, card)) + + purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) + logger := zap.NewNop() + orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) + + t.Run("预检-需要强充", func(t *testing.T) { + req := &dto.PurchaseCheckRequest{ + OrderType: model.OrderTypeSingleCard, + ResourceID: card.ID, + PackageIDs: []uint{pkg.ID}, + } + + resp, err := orderSvc.GetPurchaseCheck(ctx, req) + require.NoError(t, err) + assert.Equal(t, pkg.SuggestedRetailPrice, resp.TotalPackageAmount) + assert.True(t, resp.NeedForceRecharge) + assert.Equal(t, allocation.OneTimeCommissionThreshold, resp.ForceRechargeAmount) + assert.Equal(t, allocation.OneTimeCommissionThreshold, resp.ActualPayment) + assert.NotEmpty(t, resp.Message) + }) + + t.Run("预检-无需强充", func(t *testing.T) { + card2 := &model.IotCard{ + ICCID: "89860000000000000PC2", + ShopID: shopIDPtr, + CarrierID: carrier.ID, + SeriesAllocationID: &allocation.ID, + Status: constants.StatusEnabled, + FirstCommissionPaid: true, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, iotCardStore.Create(ctx, card2)) + + req := &dto.PurchaseCheckRequest{ + OrderType: model.OrderTypeSingleCard, + ResourceID: card2.ID, + PackageIDs: []uint{pkg.ID}, + } + + resp, err := orderSvc.GetPurchaseCheck(ctx, req) + require.NoError(t, err) + assert.Equal(t, pkg.SuggestedRetailPrice, resp.TotalPackageAmount) + assert.False(t, resp.NeedForceRecharge) + assert.Equal(t, pkg.SuggestedRetailPrice, resp.ActualPayment) + assert.Empty(t, resp.Message) + }) +} + +func TestOrderService_CreatePurchaseOnBehalf(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + iotCardStore := postgres.NewIotCardStore(tx, rdb) + deviceStore := postgres.NewDeviceStore(tx, rdb) + packageStore := postgres.NewPackageStore(tx) + seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) + packageSeriesStore := postgres.NewPackageSeriesStore(tx) + carrierStore := postgres.NewCarrierStore(tx) + shopStore := postgres.NewShopStore(tx, rdb) + orderStore := postgres.NewOrderStore(tx, rdb) + orderItemStore := postgres.NewOrderItemStore(tx, rdb) + walletStore := postgres.NewWalletStore(tx, rdb) + + ctx := context.Background() + + carrier := &model.Carrier{ + CarrierCode: "TEST_CARRIER_POB", + CarrierName: "测试运营商代购", + CarrierType: constants.CarrierTypeCMCC, + Status: constants.StatusEnabled, + } + require.NoError(t, carrierStore.Create(ctx, carrier)) + + shop := &model.Shop{ + ShopName: "测试店铺POB", + ShopCode: "TEST_SHOP_POB", + Level: 1, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, shopStore.Create(ctx, shop)) + + series := &model.PackageSeries{ + SeriesCode: "TEST_SERIES_POB", + SeriesName: "测试套餐系列代购", + Description: "测试用", + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, packageSeriesStore.Create(ctx, series)) + + allocation := &model.ShopSeriesAllocation{ + ShopID: shop.ID, + SeriesID: series.ID, + AllocatorShopID: 0, + BaseCommissionMode: model.CommissionModePercent, + BaseCommissionValue: 100, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, seriesAllocationStore.Create(ctx, allocation)) + + pkg := &model.Package{ + PackageCode: "TEST_PKG_POB", + PackageName: "测试套餐代购", + SeriesID: series.ID, + PackageType: "formal", + DurationMonths: 1, + DataAmountMB: 1024, + SuggestedRetailPrice: 10000, + Status: constants.StatusEnabled, + ShelfStatus: constants.ShelfStatusOn, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, packageStore.Create(ctx, pkg)) + + shopIDPtr := &shop.ID + card := &model.IotCard{ + ICCID: "89860000000000000POB", + ShopID: shopIDPtr, + CarrierID: carrier.ID, + SeriesAllocationID: &allocation.ID, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, iotCardStore.Create(ctx, card)) + + purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) + logger := zap.NewNop() + orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) + + t.Run("代购订单创建成功", func(t *testing.T) { + req := &dto.CreatePurchaseOnBehalfRequest{ + OrderType: model.OrderTypeSingleCard, + IotCardID: &card.ID, + PackageIDs: []uint{pkg.ID}, + } + + resp, err := orderSvc.CreatePurchaseOnBehalf(ctx, req, 1) + require.NoError(t, err) + assert.NotZero(t, resp.ID) + assert.Equal(t, model.PaymentStatusPaid, resp.PaymentStatus) + assert.Equal(t, model.PaymentMethodOffline, resp.PaymentMethod) + assert.Equal(t, model.BuyerTypeAgent, resp.BuyerType) + assert.Equal(t, shop.ID, resp.BuyerID) + assert.NotNil(t, resp.PaidAt) + + expectedCostPrice := pkg.SuggestedRetailPrice - (pkg.SuggestedRetailPrice * allocation.BaseCommissionValue / 1000) + assert.Equal(t, expectedCostPrice, resp.TotalAmount) + + var usageCount int64 + err = tx.Model(&model.PackageUsage{}).Where("order_id = ?", resp.ID).Count(&usageCount).Error + require.NoError(t, err) + assert.Equal(t, int64(1), usageCount) + }) + + t.Run("代购订单-资源未分配拒绝", func(t *testing.T) { + cardNoShop := &model.IotCard{ + ICCID: "89860000000000NOSHOP", + ShopID: nil, + CarrierID: carrier.ID, + SeriesAllocationID: &allocation.ID, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, iotCardStore.Create(ctx, cardNoShop)) + + req := &dto.CreatePurchaseOnBehalfRequest{ + OrderType: model.OrderTypeSingleCard, + IotCardID: &cardNoShop.ID, + PackageIDs: []uint{pkg.ID}, + } + + _, err := orderSvc.CreatePurchaseOnBehalf(ctx, req, 1) + require.Error(t, err) + appErr, ok := err.(*errors.AppError) + require.True(t, ok) + assert.Equal(t, errors.CodeInvalidParam, appErr.Code) + }) +} + +func TestOrderService_WalletPay_PurchaseOnBehalf(t *testing.T) { + tx := testutils.NewTestTransaction(t) + rdb := testutils.GetTestRedis(t) + testutils.CleanTestRedisKeys(t, rdb) + + iotCardStore := postgres.NewIotCardStore(tx, rdb) + deviceStore := postgres.NewDeviceStore(tx, rdb) + packageStore := postgres.NewPackageStore(tx) + seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) + packageSeriesStore := postgres.NewPackageSeriesStore(tx) + carrierStore := postgres.NewCarrierStore(tx) + shopStore := postgres.NewShopStore(tx, rdb) + orderStore := postgres.NewOrderStore(tx, rdb) + orderItemStore := postgres.NewOrderItemStore(tx, rdb) + walletStore := postgres.NewWalletStore(tx, rdb) + + ctx := context.Background() + + carrier := &model.Carrier{ + CarrierCode: "TEST_CARRIER_WP", + CarrierName: "测试运营商WP", + CarrierType: constants.CarrierTypeCMCC, + Status: constants.StatusEnabled, + } + require.NoError(t, carrierStore.Create(ctx, carrier)) + + shop := &model.Shop{ + ShopName: "测试店铺WP", + ShopCode: "TEST_SHOP_WP", + Level: 1, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, shopStore.Create(ctx, shop)) + + series := &model.PackageSeries{ + SeriesCode: "TEST_SERIES_WP", + SeriesName: "测试套餐系列WP", + Description: "测试用", + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, packageSeriesStore.Create(ctx, series)) + + allocation := &model.ShopSeriesAllocation{ + ShopID: shop.ID, + SeriesID: series.ID, + AllocatorShopID: 0, + BaseCommissionMode: model.CommissionModePercent, + BaseCommissionValue: 100, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, seriesAllocationStore.Create(ctx, allocation)) + + pkg := &model.Package{ + PackageCode: "TEST_PKG_WP", + PackageName: "测试套餐WP", + SeriesID: series.ID, + PackageType: "formal", + DurationMonths: 1, + DataAmountMB: 1024, + SuggestedRetailPrice: 10000, + Status: constants.StatusEnabled, + ShelfStatus: constants.ShelfStatusOn, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, packageStore.Create(ctx, pkg)) + + wallet := &model.Wallet{ + ResourceType: "shop", + ResourceID: shop.ID, + WalletType: "main", + Balance: 100000, + Version: 1, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, tx.Create(wallet).Error) + + shopIDPtr := &shop.ID + card := &model.IotCard{ + ICCID: "89860000000000000WP1", + ShopID: shopIDPtr, + CarrierID: carrier.ID, + SeriesAllocationID: &allocation.ID, + Status: constants.StatusEnabled, + BaseModel: model.BaseModel{Creator: 1, Updater: 1}, + } + require.NoError(t, iotCardStore.Create(ctx, card)) + + purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) + logger := zap.NewNop() + orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) + + t.Run("代购订单无法进行钱包支付", func(t *testing.T) { + req := &dto.CreatePurchaseOnBehalfRequest{ + OrderType: model.OrderTypeSingleCard, + IotCardID: &card.ID, + PackageIDs: []uint{pkg.ID}, + } + + created, err := orderSvc.CreatePurchaseOnBehalf(ctx, req, 1) + require.NoError(t, err) + + err = orderSvc.WalletPay(ctx, created.ID, model.BuyerTypeAgent, shop.ID) + require.Error(t, err) + appErr, ok := err.(*errors.AppError) + require.True(t, ok) + assert.Equal(t, errors.CodeInvalidStatus, appErr.Code) + }) +}