package packageexpiry import ( "context" "sort" "strings" "time" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" "gorm.io/gorm" ) const expiryWindowDays = 15 // ListResult 是临期资产 Query 的分页结果。 type ListResult struct { Items []dto.ExpiringAssetItem Total int64 Page int Size int Summary dto.ExpiringAssetSummary } type assetCandidate struct { AssetType string AssetID uint Identifier string ShopID *uint } // List 查询当前权限范围内的临期资产,并返回同口径数量汇总。 func (q *Query) List(ctx context.Context, request dto.ExpiringAssetListRequest) (ListResult, error) { if q == nil || q.db == nil { return ListResult{}, errors.New(errors.CodeInternalError, "套餐临期查询未配置") } if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise { return ListResult{}, errors.New(errors.CodeForbidden, "企业账号无权查看临期资产列表") } request = normalizeListRequest(request) if err := validateListRequest(request); err != nil { return ListResult{}, err } items, err := q.collect(ctx, request) if err != nil { return ListResult{}, err } summary := summarize(items) total := int64(len(items)) start := (request.Page - 1) * request.PageSize if start > len(items) { start = len(items) } end := start + request.PageSize if end > len(items) { end = len(items) } return ListResult{Items: items[start:end], Total: total, Page: request.Page, Size: request.PageSize, Summary: summary}, nil } // ReminderCandidates 查询当天精确命中 15、7、3 天节点的全部资产,供每日通知任务复用。 func (q *Query) ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error) { items, err := q.collect(ctx, normalizeListRequest(dto.ExpiringAssetListRequest{})) if err != nil { return nil, err } result := make([]dto.ExpiringAssetItem, 0) for _, item := range items { if item.DaysUntilFinalExpiry == nil { continue } switch *item.DaysUntilFinalExpiry { case 15, 7, 3: result = append(result, item) } } return result, nil } func (q *Query) collect(ctx context.Context, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) { candidates := make([]assetCandidate, 0) if request.AssetType == "" || request.AssetType == constants.AssetTypeIotCard { cards, err := q.findCardCandidates(ctx, request) if err != nil { return nil, err } candidates = append(candidates, cards...) } if request.AssetType == "" || request.AssetType == constants.AssetTypeDevice { devices, err := q.findDeviceCandidates(ctx, request) if err != nil { return nil, err } candidates = append(candidates, devices...) } items, err := q.resolveCandidates(ctx, candidates, request) if err != nil { return nil, err } if err := q.fillShopNames(ctx, items); err != nil { return nil, err } sort.SliceStable(items, func(i, j int) bool { if items[i].IsPriority != items[j].IsPriority { return items[i].IsPriority } left, right := items[i].EstimatedFinalExpiresAt, items[j].EstimatedFinalExpiresAt if left != nil && right != nil && !left.Equal(*right) { return left.Before(*right) } if items[i].AssetType != items[j].AssetType { return items[i].AssetType < items[j].AssetType } return items[i].AssetID < items[j].AssetID }) return items, nil } func (q *Query) findCardCandidates(ctx context.Context, request dto.ExpiringAssetListRequest) ([]assetCandidate, error) { var rows []model.IotCard query := q.db.WithContext(ctx).Model(&model.IotCard{}). Select("id, iccid, shop_id") query = applyStrictShopScope(ctx, query) if request.ShopID != nil { query = query.Where("shop_id = ?", *request.ShopID) } if request.Keyword != "" { keyword := "%" + strings.TrimSpace(request.Keyword) + "%" query = query.Where("iccid ILIKE ? OR msisdn ILIKE ? OR virtual_no ILIKE ?", keyword, keyword, keyword) } query = query.Where(`EXISTS ( SELECT 1 FROM tb_package_usage pu WHERE pu.iot_card_id = tb_iot_card.id AND pu.deleted_at IS NULL AND pu.master_usage_id IS NULL AND pu.refund_id IS NULL AND pu.status IN ? AND pu.expires_at IS NOT NULL AND pu.expires_at < ? )`, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, dateInShanghai(q.now()).AddDate(0, 0, expiryWindowDays+1)) if err := query.Find(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期卡候选失败") } result := make([]assetCandidate, 0, len(rows)) for _, row := range rows { result = append(result, assetCandidate{AssetType: constants.AssetTypeIotCard, AssetID: row.ID, Identifier: row.ICCID, ShopID: row.ShopID}) } return result, nil } func (q *Query) findDeviceCandidates(ctx context.Context, request dto.ExpiringAssetListRequest) ([]assetCandidate, error) { var rows []model.Device query := q.db.WithContext(ctx).Model(&model.Device{}). Select("id, virtual_no, imei, shop_id") query = applyStrictShopScope(ctx, query) if request.ShopID != nil { query = query.Where("shop_id = ?", *request.ShopID) } if request.Keyword != "" { keyword := "%" + strings.TrimSpace(request.Keyword) + "%" query = query.Where("virtual_no ILIKE ? OR imei ILIKE ?", keyword, keyword) } query = query.Where(`EXISTS ( SELECT 1 FROM tb_package_usage pu WHERE pu.device_id = tb_device.id AND pu.deleted_at IS NULL AND pu.master_usage_id IS NULL AND pu.refund_id IS NULL AND pu.status IN ? AND pu.expires_at IS NOT NULL AND pu.expires_at < ? )`, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, dateInShanghai(q.now()).AddDate(0, 0, expiryWindowDays+1)) if err := query.Find(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期设备候选失败") } result := make([]assetCandidate, 0, len(rows)) for _, row := range rows { identifier := row.VirtualNo if identifier == "" { identifier = row.IMEI } result = append(result, assetCandidate{AssetType: constants.AssetTypeDevice, AssetID: row.ID, Identifier: identifier, ShopID: row.ShopID}) } return result, nil } func (q *Query) resolveCandidates(ctx context.Context, candidates []assetCandidate, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) { groupedIDs := map[string][]uint{constants.AssetTypeIotCard: {}, constants.AssetTypeDevice: {}} for _, candidate := range candidates { groupedIDs[candidate.AssetType] = append(groupedIDs[candidate.AssetType], candidate.AssetID) } estimates := make(map[string]map[uint]dto.PackageExpiryEstimate, 2) finalUsages := make(map[string]map[uint]*model.PackageUsage, 2) for _, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} { var err error estimates[assetType], err = q.ResolveBatch(ctx, assetType, groupedIDs[assetType]) if err != nil { return nil, err } finalUsages[assetType], err = q.loadFinalUsages(ctx, assetType, groupedIDs[assetType]) if err != nil { return nil, err } } from, to, err := parseExpiryRange(request) if err != nil { return nil, err } items := make([]dto.ExpiringAssetItem, 0, len(candidates)) for _, candidate := range candidates { estimate := estimates[candidate.AssetType][candidate.AssetID] usage := finalUsages[candidate.AssetType][candidate.AssetID] if usage == nil || estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.DaysUntilFinalExpiry == nil || estimate.EstimatedFinalExpiresAt == nil { continue } days := *estimate.DaysUntilFinalExpiry if days < 0 || days > expiryWindowDays || request.DaysMin != nil && days < *request.DaysMin || request.DaysMax != nil && days > *request.DaysMax { continue } expiryDate := dateInShanghai(*estimate.EstimatedFinalExpiresAt) if from != nil && expiryDate.Before(*from) || to != nil && expiryDate.After(*to) { continue } if request.PackageID != nil && usage.PackageID != *request.PackageID { continue } level, levelName := expiryLevel(days) items = append(items, dto.ExpiringAssetItem{ AssetType: candidate.AssetType, AssetID: candidate.AssetID, Identifier: candidate.Identifier, ShopID: candidate.ShopID, PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageName: usage.PackageName, PackageExpiryEstimate: estimate, ExpiryLevel: level, ExpiryLevelName: levelName, IsPriority: days <= 3, }) } return items, nil } func (q *Query) loadFinalUsages(ctx context.Context, assetType string, assetIDs []uint) (map[uint]*model.PackageUsage, error) { result := make(map[uint]*model.PackageUsage, len(assetIDs)) if len(assetIDs) == 0 { return result, nil } column, ok := packageUsageAssetColumn(assetType) if !ok { return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") } var usages []*model.PackageUsage if err := q.db.WithContext(ctx).Where(column+" IN ?", assetIDs). Where("master_usage_id IS NULL AND refund_id IS NULL"). Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). Order("priority ASC, created_at ASC, id ASC").Find(&usages).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期资产最终套餐失败") } for _, usage := range usages { assetID := usage.IotCardID if assetType == constants.AssetTypeDevice { assetID = usage.DeviceID } result[assetID] = usage } return result, nil } func (q *Query) fillShopNames(ctx context.Context, items []dto.ExpiringAssetItem) error { ids := make([]uint, 0) seen := make(map[uint]struct{}) for _, item := range items { if item.ShopID == nil { continue } if _, exists := seen[*item.ShopID]; !exists { seen[*item.ShopID] = struct{}{} ids = append(ids, *item.ShopID) } } if len(ids) == 0 { return nil } var shops []model.Shop if err := q.db.WithContext(ctx).Select("id, shop_name").Where("id IN ?", ids).Find(&shops).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询临期资产店铺名称失败") } names := make(map[uint]string, len(shops)) for _, shop := range shops { names[shop.ID] = shop.ShopName } for index := range items { if items[index].ShopID != nil { items[index].ShopName = names[*items[index].ShopID] } } return nil } func applyStrictShopScope(ctx context.Context, query *gorm.DB) *gorm.DB { if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent { return query } shopIDs := middleware.GetSubordinateShopIDs(ctx) if len(shopIDs) == 0 { return query.Where("1 = 0") } return query.Where("shop_id IN ?", shopIDs) } func normalizeListRequest(request dto.ExpiringAssetListRequest) dto.ExpiringAssetListRequest { if request.Page <= 0 { request.Page = 1 } if request.PageSize <= 0 { request.PageSize = constants.DefaultPageSize } return request } func validateListRequest(request dto.ExpiringAssetListRequest) error { if request.PageSize > constants.MaxPageSize || request.Page < 1 { return errors.New(errors.CodeInvalidParam) } if request.AssetType != "" && request.AssetType != constants.AssetTypeIotCard && request.AssetType != constants.AssetTypeDevice { return errors.New(errors.CodeInvalidParam) } if request.DaysMin != nil && (*request.DaysMin < 0 || *request.DaysMin > expiryWindowDays) || request.DaysMax != nil && (*request.DaysMax < 0 || *request.DaysMax > expiryWindowDays) { return errors.New(errors.CodeInvalidParam) } if request.DaysMin != nil && request.DaysMax != nil && *request.DaysMin > *request.DaysMax { return errors.New(errors.CodeInvalidParam, "最小剩余天数不能大于最大剩余天数") } _, _, err := parseExpiryRange(request) return err } func parseExpiryRange(request dto.ExpiringAssetListRequest) (*time.Time, *time.Time, error) { parse := func(value string) (*time.Time, error) { if value == "" { return nil, nil } result, err := time.ParseInLocation("2006-01-02", value, shanghaiLocation) if err != nil { return nil, errors.New(errors.CodeInvalidParam, "到期日期格式无效") } return &result, nil } from, err := parse(request.ExpiresFrom) if err != nil { return nil, nil, err } to, err := parse(request.ExpiresTo) if err != nil { return nil, nil, err } if from != nil && to != nil && from.After(*to) { return nil, nil, errors.New(errors.CodeInvalidParam, "到期开始日期不能晚于结束日期") } return from, to, nil } func expiryLevel(days int) (string, string) { if days <= 3 { return "red", "红色" } if days <= 7 { return "purple", "紫色" } return "pink", "粉红色" } func summarize(items []dto.ExpiringAssetItem) dto.ExpiringAssetSummary { result := dto.ExpiringAssetSummary{WindowDays: expiryWindowDays, TotalCount: int64(len(items))} for _, item := range items { if item.AssetType == constants.AssetTypeIotCard { result.CardCount++ } else if item.AssetType == constants.AssetTypeDevice { result.DeviceCount++ } } return result }