package exporter import ( "context" "strconv" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model/dto" packageexpiryquery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // ExpiringAssetDataSource 临期资产导出数据源。 // // 粒度为一行对应一项资产,取该资产当前生效主套餐的最终到期时间与剩余天数,加油包不单独成行。 // 候选预筛、最终到期推算与行序全部复用 internal/query/packageexpiry 的列表实现,不另写第二套到期口径。 // 店铺、业务员与用户组按导出执行时的当前归属补充,结果仍受任务创建时冻结的可见店铺范围约束。 type ExpiringAssetDataSource struct { db *gorm.DB expiryQ *packageexpiryquery.Query } // NewExpiringAssetDataSource 创建临期资产导出数据源。 func NewExpiringAssetDataSource(db *gorm.DB) *ExpiringAssetDataSource { return &ExpiringAssetDataSource{db: db, expiryQ: packageexpiryquery.NewQuery(db)} } // Scene 返回导出场景编码。 func (s *ExpiringAssetDataSource) Scene() string { return constants.ExportTaskSceneExpiringAsset } // Count 统计临期资产导出行数,与列表同一筛选下的行集合一致。 func (s *ExpiringAssetDataSource) Count(ctx context.Context, params ExportParams) (int, error) { items, err := s.items(ctx, params) if err != nil { return 0, err } return len(items), nil } // Headers 返回临期资产导出表头,列序与 111.md §18.1 逐列一致。 func (s *ExpiringAssetDataSource) Headers(context.Context, ExportParams) ([]string, error) { return []string{ "店铺", "业务员", "用户组", "资产类型", "设备类型", "设备型号", "资产标识", "当前套餐", "到期时间", "剩余天数", }, nil } // Fetch 按 offset/limit 查询临期资产导出数据。 func (s *ExpiringAssetDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { if limit <= 0 { return [][]string{}, nil } items, err := s.items(ctx, params) if err != nil { return nil, err } if offset >= len(items) { return [][]string{}, nil } end := offset + limit if end > len(items) { end = len(items) } page := items[offset:end] shopIDs := make([]uint, 0, len(page)) deviceIDs := make([]uint, 0, len(page)) for _, item := range page { if item.ShopID != nil { shopIDs = append(shopIDs, *item.ShopID) } if item.AssetType == constants.AssetTypeDevice { deviceIDs = append(deviceIDs, item.AssetID) } } shops, err := s.loadShopOwners(ctx, normalizeUintSlice(shopIDs)) if err != nil { return nil, err } ownerIDs := make([]uint, 0, len(shops)) for _, shop := range shops { if shop.BusinessOwnerAccountID != nil { ownerIDs = append(ownerIDs, *shop.BusinessOwnerAccountID) } } ownerIDs = normalizeUintSlice(ownerIDs) ownerNames, err := loadAccountNames(ctx, s.db, ownerIDs) if err != nil { return nil, err } groupNames, err := businessUserGroupNames(ctx, s.db, ownerIDs) if err != nil { return nil, err } devices, err := s.loadDeviceAttributes(ctx, normalizeUintSlice(deviceIDs)) if err != nil { return nil, err } rows := make([][]string, 0, len(page)) for _, item := range page { var shopName, ownerName, groupName string if item.ShopID != nil { shop := shops[*item.ShopID] shopName = shop.ShopName if shop.BusinessOwnerAccountID != nil { ownerName = ownerNames[*shop.BusinessOwnerAccountID] groupName = currentOwnerGroupName(groupNames, shop.BusinessOwnerAccountID) } } var deviceType, deviceModel string if item.AssetType == constants.AssetTypeDevice { device := devices[item.AssetID] deviceType, deviceModel = device.DeviceType, device.DeviceModel } rows = append(rows, []string{ shopName, ownerName, groupName, assetTypeName(item.AssetType), deviceType, deviceModel, item.Identifier, item.PackageName, formatOptionalTime(item.EstimatedFinalExpiresAt), formatRemainingDaysValue(item.DaysUntilFinalExpiry), }) } return rows, nil } // items 复用列表同一候选预筛与最终到期推算,数据范围来自任务创建时冻结的可见店铺范围。 func (s *ExpiringAssetDataSource) items(ctx context.Context, params ExportParams) ([]dto.ExpiringAssetItem, error) { filter, err := s.listFilter(params) if err != nil { return nil, err } scope := func(query *gorm.DB) *gorm.DB { return applyExportShopScope(query, params, "shop_id") } return s.expiryQ.ListAllWithScope(ctx, scope, filter) } // listFilter 把任务冻结的筛选快照转换为列表同一口径的筛选条件。 func (s *ExpiringAssetDataSource) listFilter(params ExportParams) (packageexpiryquery.ListFilter, error) { filter := packageexpiryquery.ListFilter{ AssetType: filterValue(params.Filters, "asset_type"), Keyword: filterValue(params.Filters, "keyword"), } if shopID, ok := filterUint(params.Filters, "shop_id"); ok { filter.ShopID = &shopID } if packageID, ok := filterUint(params.Filters, "package_id"); ok { filter.PackageID = &packageID } if daysMin, ok := filterInt(params.Filters, "days_min"); ok { filter.DaysMin = &daysMin } if daysMax, ok := filterInt(params.Filters, "days_max"); ok { filter.DaysMax = &daysMax } start, end, err := strictTimeRange(params.Filters) if err != nil { return packageexpiryquery.ListFilter{}, err } filter.StartTime, filter.EndTime = start, end return filter, nil } func (s *ExpiringAssetDataSource) loadShopOwners(ctx context.Context, shopIDs []uint) (map[uint]expiringAssetShop, error) { result := make(map[uint]expiringAssetShop, len(shopIDs)) if len(shopIDs) == 0 { return result, nil } var rows []expiringAssetShop if err := s.db.WithContext(ctx).Table("tb_shop"). Select("id, shop_name, business_owner_account_id"). Where("id IN ? AND deleted_at IS NULL", shopIDs). Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期导出店铺当前归属失败") } for _, row := range rows { result[row.ID] = row } return result, nil } func (s *ExpiringAssetDataSource) loadDeviceAttributes(ctx context.Context, deviceIDs []uint) (map[uint]expiringAssetDevice, error) { result := make(map[uint]expiringAssetDevice, len(deviceIDs)) if len(deviceIDs) == 0 { return result, nil } var rows []expiringAssetDevice if err := s.db.WithContext(ctx).Table("tb_device"). Select("id, device_type, device_model"). Where("id IN ?", deviceIDs). Scan(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期导出设备类型与型号失败") } for _, row := range rows { result[row.ID] = row } return result, nil } // expiringAssetShop 是执行时当前店铺归属投影。 type expiringAssetShop struct { ID uint `gorm:"column:id"` ShopName string `gorm:"column:shop_name"` BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"` } // expiringAssetDevice 是执行时当前设备类型与型号投影。 type expiringAssetDevice struct { ID uint `gorm:"column:id"` DeviceType string `gorm:"column:device_type"` DeviceModel string `gorm:"column:device_model"` } // formatRemainingDaysValue 输出列表同一最终到期推算给出的剩余上海自然日天数;无法精确推算时为空。 func formatRemainingDaysValue(days *int) string { if days == nil { return "" } return strconv.Itoa(*days) }