package iot_card import ( "context" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "gorm.io/gorm" ) type Service struct { db *gorm.DB iotCardStore *postgres.IotCardStore shopStore *postgres.ShopStore assetAllocationRecordStore *postgres.AssetAllocationRecordStore seriesAllocationStore *postgres.ShopSeriesAllocationStore } func New( db *gorm.DB, iotCardStore *postgres.IotCardStore, shopStore *postgres.ShopStore, assetAllocationRecordStore *postgres.AssetAllocationRecordStore, seriesAllocationStore *postgres.ShopSeriesAllocationStore, ) *Service { return &Service{ db: db, iotCardStore: iotCardStore, shopStore: shopStore, assetAllocationRecordStore: assetAllocationRecordStore, seriesAllocationStore: seriesAllocationStore, } } func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIotCardRequest) (*dto.ListStandaloneIotCardResponse, error) { page := req.Page pageSize := req.PageSize if page == 0 { page = 1 } if pageSize == 0 { pageSize = constants.DefaultPageSize } opts := &store.QueryOptions{ Page: page, PageSize: pageSize, } filters := make(map[string]interface{}) if req.Status != nil { filters["status"] = *req.Status } if req.CarrierID != nil { filters["carrier_id"] = *req.CarrierID } if req.ShopID != nil { filters["shop_id"] = *req.ShopID } if req.ICCID != "" { filters["iccid"] = req.ICCID } if req.MSISDN != "" { filters["msisdn"] = req.MSISDN } if req.BatchNo != "" { filters["batch_no"] = req.BatchNo } if req.PackageID != nil { filters["package_id"] = *req.PackageID } if req.IsDistributed != nil { filters["is_distributed"] = *req.IsDistributed } if req.ICCIDStart != "" { filters["iccid_start"] = req.ICCIDStart } if req.ICCIDEnd != "" { filters["iccid_end"] = req.ICCIDEnd } if req.IsReplaced != nil { filters["is_replaced"] = *req.IsReplaced } if req.SeriesAllocationID != nil { filters["series_allocation_id"] = *req.SeriesAllocationID } cards, total, err := s.iotCardStore.ListStandalone(ctx, opts, filters) if err != nil { return nil, err } shopMap := s.loadShopNames(ctx, cards) list := make([]*dto.StandaloneIotCardResponse, 0, len(cards)) for _, card := range cards { item := s.toStandaloneResponse(card, shopMap) list = append(list, item) } totalPages := int(total) / pageSize if int(total)%pageSize > 0 { totalPages++ } return &dto.ListStandaloneIotCardResponse{ List: list, Total: total, Page: page, PageSize: pageSize, TotalPages: totalPages, }, nil } // GetByICCID 通过 ICCID 获取单卡详情 func (s *Service) GetByICCID(ctx context.Context, iccid string) (*dto.IotCardDetailResponse, error) { card, err := s.iotCardStore.GetByICCID(ctx, iccid) if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeNotFound, "IoT卡不存在") } return nil, err } shopMap := s.loadShopNames(ctx, []*model.IotCard{card}) standaloneResp := s.toStandaloneResponse(card, shopMap) return &dto.IotCardDetailResponse{ StandaloneIotCardResponse: *standaloneResp, }, nil } func (s *Service) loadShopNames(ctx context.Context, cards []*model.IotCard) map[uint]string { shopIDs := make([]uint, 0) shopIDSet := make(map[uint]bool) for _, card := range cards { if card.ShopID != nil && *card.ShopID > 0 && !shopIDSet[*card.ShopID] { shopIDs = append(shopIDs, *card.ShopID) shopIDSet[*card.ShopID] = true } } shopMap := make(map[uint]string) if len(shopIDs) > 0 { var shops []model.Shop s.db.WithContext(ctx).Where("id IN ?", shopIDs).Find(&shops) for _, shop := range shops { shopMap[shop.ID] = shop.ShopName } } return shopMap } func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]string) *dto.StandaloneIotCardResponse { resp := &dto.StandaloneIotCardResponse{ ID: card.ID, ICCID: card.ICCID, CardType: card.CardType, CardCategory: card.CardCategory, CarrierID: card.CarrierID, CarrierType: card.CarrierType, CarrierName: card.CarrierName, IMSI: card.IMSI, MSISDN: card.MSISDN, BatchNo: card.BatchNo, Supplier: card.Supplier, CostPrice: card.CostPrice, DistributePrice: card.DistributePrice, Status: card.Status, ShopID: card.ShopID, ActivatedAt: card.ActivatedAt, ActivationStatus: card.ActivationStatus, RealNameStatus: card.RealNameStatus, NetworkStatus: card.NetworkStatus, DataUsageMB: card.DataUsageMB, SeriesAllocationID: card.SeriesAllocationID, FirstCommissionPaid: card.FirstCommissionPaid, AccumulatedRecharge: card.AccumulatedRecharge, CreatedAt: card.CreatedAt, UpdatedAt: card.UpdatedAt, } if card.ShopID != nil && *card.ShopID > 0 { resp.ShopName = shopMap[*card.ShopID] } return resp } func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateStandaloneCardsResponse, error) { if err := s.validateDirectSubordinate(ctx, operatorShopID, req.ToShopID); err != nil { return nil, err } cards, err := s.getCardsForAllocation(ctx, req, operatorShopID) if err != nil { return nil, err } if len(cards) == 0 { return &dto.AllocateStandaloneCardsResponse{ TotalCount: 0, SuccessCount: 0, FailCount: 0, FailedItems: []dto.AllocationFailedItem{}, }, nil } var cardIDs []uint var failedItems []dto.AllocationFailedItem boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards)) if err != nil { return nil, err } boundCardIDSet := make(map[uint]bool) for _, id := range boundCardIDs { boundCardIDSet[id] = true } isPlatform := operatorShopID == nil for _, card := range cards { if boundCardIDSet[card.ID] { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "已绑定设备的卡不能单独分配", }) continue } if isPlatform && card.Status != constants.IotCardStatusInStock { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "平台只能分配在库状态的卡", }) continue } if !isPlatform && card.Status != constants.IotCardStatusDistributed { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "代理只能分配已分销状态的卡", }) continue } cardIDs = append(cardIDs, card.ID) } if len(cardIDs) == 0 { return &dto.AllocateStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: 0, FailCount: len(failedItems), FailedItems: failedItems, }, nil } newStatus := constants.IotCardStatusDistributed toShopID := req.ToShopID err = s.db.Transaction(func(tx *gorm.DB) error { txIotCardStore := postgres.NewIotCardStore(tx, nil) txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) if err := txIotCardStore.BatchUpdateShopIDAndStatus(ctx, cardIDs, &toShopID, newStatus); err != nil { return err } allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate) records := s.buildAllocationRecords(cards, cardIDs, operatorShopID, toShopID, operatorID, allocationNo, req.Remark) return txRecordStore.BatchCreate(ctx, records) }) if err != nil { return nil, err } return &dto.AllocateStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: len(cardIDs), FailCount: len(failedItems), AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate), FailedItems: failedItems, }, nil } func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.RecallStandaloneCardsResponse, error) { if err := s.validateDirectSubordinate(ctx, operatorShopID, req.FromShopID); err != nil { return nil, err } cards, err := s.getCardsForRecall(ctx, req) if err != nil { return nil, err } if len(cards) == 0 { return &dto.RecallStandaloneCardsResponse{ TotalCount: 0, SuccessCount: 0, FailCount: 0, FailedItems: []dto.AllocationFailedItem{}, }, nil } var cardIDs []uint var failedItems []dto.AllocationFailedItem boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards)) if err != nil { return nil, err } boundCardIDSet := make(map[uint]bool) for _, id := range boundCardIDs { boundCardIDSet[id] = true } for _, card := range cards { if boundCardIDSet[card.ID] { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "已绑定设备的卡不能单独回收", }) continue } if card.ShopID == nil || *card.ShopID != req.FromShopID { failedItems = append(failedItems, dto.AllocationFailedItem{ ICCID: card.ICCID, Reason: "卡不属于指定的店铺", }) continue } cardIDs = append(cardIDs, card.ID) } if len(cardIDs) == 0 { return &dto.RecallStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: 0, FailCount: len(failedItems), FailedItems: failedItems, }, nil } isPlatform := operatorShopID == nil var newShopID *uint var newStatus int if isPlatform { newShopID = nil newStatus = constants.IotCardStatusInStock } else { newShopID = operatorShopID newStatus = constants.IotCardStatusDistributed } err = s.db.Transaction(func(tx *gorm.DB) error { txIotCardStore := postgres.NewIotCardStore(tx, nil) txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) if err := txIotCardStore.BatchUpdateShopIDAndStatus(ctx, cardIDs, newShopID, newStatus); err != nil { return err } allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall) records := s.buildRecallRecords(cards, cardIDs, req.FromShopID, operatorShopID, operatorID, allocationNo, req.Remark) return txRecordStore.BatchCreate(ctx, records) }) if err != nil { return nil, err } return &dto.RecallStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: len(cardIDs), FailCount: len(failedItems), AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall), FailedItems: failedItems, }, nil } func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID *uint, targetShopID uint) error { if operatorShopID != nil && *operatorShopID == targetShopID { return errors.ErrCannotAllocateToSelf } targetShop, err := s.shopStore.GetByID(ctx, targetShopID) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeShopNotFound) } return err } if operatorShopID == nil { if targetShop.ParentID != nil { return errors.ErrNotDirectSubordinate } } else { if targetShop.ParentID == nil || *targetShop.ParentID != *operatorShopID { return errors.ErrNotDirectSubordinate } } return nil } func (s *Service) getCardsForAllocation(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorShopID *uint) ([]*model.IotCard, error) { switch req.SelectionType { case dto.SelectionTypeList: return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs) case dto.SelectionTypeRange: return s.iotCardStore.GetStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd, operatorShopID) case dto.SelectionTypeFilter: filters := make(map[string]any) if req.CarrierID != nil { filters["carrier_id"] = *req.CarrierID } if req.BatchNo != "" { filters["batch_no"] = req.BatchNo } if req.Status != nil { filters["status"] = *req.Status } return s.iotCardStore.GetStandaloneByFilters(ctx, filters, operatorShopID) default: return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式") } } func (s *Service) getCardsForRecall(ctx context.Context, req *dto.RecallStandaloneCardsRequest) ([]*model.IotCard, error) { fromShopID := req.FromShopID switch req.SelectionType { case dto.SelectionTypeList: return s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs) case dto.SelectionTypeRange: return s.iotCardStore.GetStandaloneByICCIDRange(ctx, req.ICCIDStart, req.ICCIDEnd, &fromShopID) case dto.SelectionTypeFilter: filters := make(map[string]any) if req.CarrierID != nil { filters["carrier_id"] = *req.CarrierID } if req.BatchNo != "" { filters["batch_no"] = req.BatchNo } return s.iotCardStore.GetStandaloneByFilters(ctx, filters, &fromShopID) default: return nil, errors.New(errors.CodeInvalidParam, "无效的选卡方式") } } func (s *Service) extractCardIDs(cards []*model.IotCard) []uint { ids := make([]uint, len(cards)) for i, card := range cards { ids[i] = card.ID } return ids } func (s *Service) buildAllocationRecords(cards []*model.IotCard, successCardIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord { successIDSet := make(map[uint]bool) for _, id := range successCardIDs { successIDSet[id] = true } var records []*model.AssetAllocationRecord for _, card := range cards { if !successIDSet[card.ID] { continue } record := &model.AssetAllocationRecord{ AllocationNo: allocationNo, AllocationType: constants.AssetAllocationTypeAllocate, AssetType: constants.AssetTypeIotCard, AssetID: card.ID, AssetIdentifier: card.ICCID, ToOwnerType: constants.OwnerTypeShop, ToOwnerID: toShopID, OperatorID: operatorID, Remark: remark, } if fromShopID == nil { record.FromOwnerType = constants.OwnerTypePlatform record.FromOwnerID = nil } else { record.FromOwnerType = constants.OwnerTypeShop record.FromOwnerID = fromShopID } records = append(records, record) } return records } func (s *Service) buildRecallRecords(cards []*model.IotCard, successCardIDs []uint, fromShopID uint, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord { successIDSet := make(map[uint]bool) for _, id := range successCardIDs { successIDSet[id] = true } var records []*model.AssetAllocationRecord for _, card := range cards { if !successIDSet[card.ID] { continue } record := &model.AssetAllocationRecord{ AllocationNo: allocationNo, AllocationType: constants.AssetAllocationTypeRecall, AssetType: constants.AssetTypeIotCard, AssetID: card.ID, AssetIdentifier: card.ICCID, FromOwnerType: constants.OwnerTypeShop, FromOwnerID: &fromShopID, OperatorID: operatorID, Remark: remark, } if toShopID == nil { record.ToOwnerType = constants.OwnerTypePlatform record.ToOwnerID = 0 } else { record.ToOwnerType = constants.OwnerTypeShop record.ToOwnerID = *toShopID } records = append(records, record) } return records } // BatchSetSeriesBinding 批量设置卡的套餐系列绑定 func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCardSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetCardSeriesBindngResponse, error) { cards, err := s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs) if err != nil { return nil, err } if len(cards) == 0 { return &dto.BatchSetCardSeriesBindngResponse{ SuccessCount: 0, FailCount: len(req.ICCIDs), FailedItems: s.buildCardNotFoundFailedItems(req.ICCIDs), }, nil } cardMap := make(map[string]*model.IotCard) for _, card := range cards { cardMap[card.ICCID] = card } var seriesAllocation *model.ShopSeriesAllocation if req.SeriesAllocationID > 0 { seriesAllocation, err = s.seriesAllocationStore.GetByID(ctx, req.SeriesAllocationID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeNotFound, "套餐系列分配不存在") } return nil, err } if seriesAllocation.Status != 1 { return nil, errors.New(errors.CodeInvalidParam, "套餐系列分配已禁用") } } var successCardIDs []uint var failedItems []dto.CardSeriesBindngFailedItem for _, iccid := range req.ICCIDs { card, exists := cardMap[iccid] if !exists { failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{ ICCID: iccid, Reason: "卡不存在", }) continue } if req.SeriesAllocationID > 0 { if card.ShopID == nil || *card.ShopID != seriesAllocation.ShopID { failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{ ICCID: iccid, Reason: "卡不属于套餐系列分配的店铺", }) continue } } if operatorShopID != nil { if card.ShopID == nil || *card.ShopID != *operatorShopID { failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{ ICCID: iccid, Reason: "无权操作此卡", }) continue } } successCardIDs = append(successCardIDs, card.ID) } if len(successCardIDs) > 0 { var seriesAllocationIDPtr *uint if req.SeriesAllocationID > 0 { seriesAllocationIDPtr = &req.SeriesAllocationID } if err := s.iotCardStore.BatchUpdateSeriesAllocation(ctx, successCardIDs, seriesAllocationIDPtr); err != nil { return nil, err } } return &dto.BatchSetCardSeriesBindngResponse{ SuccessCount: len(successCardIDs), FailCount: len(failedItems), FailedItems: failedItems, }, nil } func (s *Service) buildCardNotFoundFailedItems(iccids []string) []dto.CardSeriesBindngFailedItem { items := make([]dto.CardSeriesBindngFailedItem, len(iccids)) for i, iccid := range iccids { items[i] = dto.CardSeriesBindngFailedItem{ ICCID: iccid, Reason: "卡不存在", } } return items }