package exporter import ( "context" "strconv" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model/dto" operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/utils" ) // 导出空值占位:分母为零的比率与卡均、以及无快照时的空指标一律写「-」。 const operationsReportEmptyValue = "-" // 合计行的分组列值。 const operationsReportTotalGroup = "合计" // 序号列表头;分组行按导出展示顺序从 1 连续递增,合计行留空不写入任何数字或文字。 const operationsReportSerialHeader = "序号" // OperationsActivationDataSource 设备激活情况报表导出数据源。 // // 导出列与页面展示字段一致并包含合计行,首列为「序号」:分组行按展示顺序从 1 连续递增, // 合计行的序号列留空且其余各列的既有表示不变;导出不含任何文字总结; // 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。 // 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再按任务内冻结的账号类型复核一次, // 阻止通过通用导出入口以代理身份创建本场景任务后读到运营报表数据。 type OperationsActivationDataSource struct { query *operationsreportquery.Query } // NewOperationsActivationDataSource 创建设备激活情况报表导出数据源。 func NewOperationsActivationDataSource(db *gorm.DB) *OperationsActivationDataSource { return &OperationsActivationDataSource{query: operationsreportquery.NewQuery(db)} } // Scene 返回导出场景编码。 func (s *OperationsActivationDataSource) Scene() string { return constants.ExportTaskSceneOperationsActivation } // Count 统计导出行数(分组行 + 合计行)。 func (s *OperationsActivationDataSource) Count(ctx context.Context, params ExportParams) (int, error) { result, err := s.build(ctx, params) if err != nil { return 0, err } return len(result.rows), nil } // Headers 返回设备激活情况导出表头。 func (s *OperationsActivationDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { result, err := s.build(ctx, params) if err != nil { return nil, err } return result.headers, nil } // Fetch 按 offset/limit 返回设备激活情况导出行。 func (s *OperationsActivationDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { result, err := s.build(ctx, params) if err != nil { return nil, err } return sliceOperationsReportRows(result.rows, offset, limit), nil } // build 构造导出表头与全部行(分组行 + 合计行)。 func (s *OperationsActivationDataSource) build(ctx context.Context, params ExportParams) (*operationsReportResult, error) { if err := ensureOperationsReportExportAllowed(params); err != nil { return nil, err } request, err := activationExportRequest(params) if err != nil { return nil, err } response, err := s.query.ActivationSummary(frozenQueryContext(ctx, params), request) if err != nil { return nil, err } headers := []string{ operationsReportSerialHeader, response.GroupName, "采购数量", "累计激活数", "激活率", "新增激活数", "累计在网数", "活跃用户数", "累计用量(GB)", "单用户卡均(GB)", "含零预测卡均(GB)", "不含零预测卡均(GB)", } rows := make([][]string, 0, len(response.Items)+1) for index, item := range response.Items { rows = append(rows, []string{ strconv.Itoa(index + 1), item.GroupValue, formatOptionalInt64(item.PurchasedDeviceCount), formatOptionalInt64(item.ActivatedDeviceCount), formatOptionalFloat(item.ActivationRate), formatOptionalInt64(item.NewActivatedDeviceCount), formatOptionalInt64(item.OnlineDeviceCount), formatOptionalInt64(item.ActiveDeviceCount), formatOptionalFloat(item.TotalRealTrafficGB), formatOptionalFloat(item.PerUserAverageGB), formatOptionalFloat(item.ForecastAverageIncludingZeroGB), formatOptionalFloat(item.ForecastAverageExcludingZeroGB), }) } if response.Totals != nil { total := response.Totals rows = append(rows, []string{ // 合计行序号列留空,其余各列沿用既有表示。 "", operationsReportTotalGroup, formatOptionalInt64(total.PurchasedDeviceCount), formatOptionalInt64(total.ActivatedDeviceCount), formatOptionalFloat(total.ActivationRate), formatOptionalInt64(total.NewActivatedDeviceCount), formatOptionalInt64(total.OnlineDeviceCount), formatOptionalInt64(total.ActiveDeviceCount), formatOptionalFloat(total.TotalRealTrafficGB), formatOptionalFloat(total.PerUserAverageGB), formatOptionalFloat(total.ForecastAverageIncludingZeroGB), formatOptionalFloat(total.ForecastAverageExcludingZeroGB), }) } return &operationsReportResult{headers: headers, rows: rows}, nil } // OperationsRenewalDataSource 套餐续费情况报表导出数据源。 // // 导出列与页面展示字段一致并包含合计行,首列为「序号」:分组行按展示顺序从 1 连续递增, // 合计行的序号列留空且其余各列的既有表示不变;导出不含任何文字总结; // 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。 // 本场景同样按任务内冻结的账号类型复核导出资格。 type OperationsRenewalDataSource struct { query *operationsreportquery.Query } // NewOperationsRenewalDataSource 创建套餐续费情况报表导出数据源。 func NewOperationsRenewalDataSource(db *gorm.DB) *OperationsRenewalDataSource { return &OperationsRenewalDataSource{query: operationsreportquery.NewQuery(db)} } // Scene 返回导出场景编码。 func (s *OperationsRenewalDataSource) Scene() string { return constants.ExportTaskSceneOperationsRenewal } // Count 统计导出行数(分组行 + 合计行)。 func (s *OperationsRenewalDataSource) Count(ctx context.Context, params ExportParams) (int, error) { result, err := s.build(ctx, params) if err != nil { return 0, err } return len(result.rows), nil } // Headers 返回套餐续费情况导出表头。 func (s *OperationsRenewalDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { result, err := s.build(ctx, params) if err != nil { return nil, err } return result.headers, nil } // Fetch 按 offset/limit 返回套餐续费情况导出行。 func (s *OperationsRenewalDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { result, err := s.build(ctx, params) if err != nil { return nil, err } return sliceOperationsReportRows(result.rows, offset, limit), nil } // build 构造导出表头与全部行(分组行 + 合计行)。 func (s *OperationsRenewalDataSource) build(ctx context.Context, params ExportParams) (*operationsReportResult, error) { if err := ensureOperationsReportExportAllowed(params); err != nil { return nil, err } request, err := renewalExportRequest(params) if err != nil { return nil, err } response, err := s.query.RenewalSummary(frozenQueryContext(ctx, params), request) if err != nil { return nil, err } headers := []string{operationsReportSerialHeader, response.GroupName, "到期资产数", "续费资产数", "续费率", "新增未续费数"} rows := make([][]string, 0, len(response.Items)+1) for index, item := range response.Items { rows = append(rows, []string{ strconv.Itoa(index + 1), item.GroupValue, formatOptionalInt64(item.DueAssetCount), formatOptionalInt64(item.RenewedAssetCount), formatOptionalFloat(item.RenewalRate), formatOptionalInt64(item.NewUnrenewedAssetCount), }) } if response.Totals != nil { total := response.Totals rows = append(rows, []string{ // 合计行序号列留空,其余各列沿用既有表示。 "", operationsReportTotalGroup, formatOptionalInt64(total.DueAssetCount), formatOptionalInt64(total.RenewedAssetCount), formatOptionalFloat(total.RenewalRate), formatOptionalInt64(total.NewUnrenewedAssetCount), }) } return &operationsReportResult{headers: headers, rows: rows}, nil } // operationsReportResult 是一次导出构造的表头与全部行。 type operationsReportResult struct { headers []string rows [][]string } // activationExportRequest 把任务冻结的筛选快照还原为汇总查询请求。 // 时间边界只按统一严格解析器解析冻结值,非法值返回错误由调用方落任务失败。 func activationExportRequest(params ExportParams) (dto.OperationsActivationSummaryRequest, error) { start, end, err := frozenOperationsReportRange(params.Filters) if err != nil { return dto.OperationsActivationSummaryRequest{}, err } groupBy, err := frozenOperationsReportGroupBy(params.Filters) if err != nil { return dto.OperationsActivationSummaryRequest{}, err } return dto.OperationsActivationSummaryRequest{StartTime: start, EndTime: end, GroupBy: groupBy}, nil } // renewalExportRequest 把任务冻结的筛选快照还原为汇总查询请求。 func renewalExportRequest(params ExportParams) (dto.OperationsRenewalSummaryRequest, error) { start, end, err := frozenOperationsReportRange(params.Filters) if err != nil { return dto.OperationsRenewalSummaryRequest{}, err } groupBy, err := frozenOperationsReportGroupBy(params.Filters) if err != nil { return dto.OperationsRenewalSummaryRequest{}, err } return dto.OperationsRenewalSummaryRequest{StartTime: start, EndTime: end, GroupBy: groupBy}, nil } // operationsReportGroupByKey 是冻结筛选中的分组维度键。 // 未选择分组维度时冻结为空串,导出仍然只有唯一一行「全部」,因此空值也按已冻结处理。 const operationsReportGroupByKey = "group_by" // frozenOperationsReportGroupBy 读取冻结的分组维度;键缺失或空串都表示未选择分组维度。 func frozenOperationsReportGroupBy(filters map[string]any) (string, error) { value, exists := filters[operationsReportGroupByKey] if !exists || value == nil { return "", nil } text, ok := value.(string) if !ok { return "", errors.New(errors.CodeInvalidParam, "导出筛选的分组维度格式不正确") } return text, nil } // frozenOperationsReportRange 读取冻结的时间边界并复用统一严格解析器校验格式与顺序。 func frozenOperationsReportRange(filters map[string]any) (string, string, error) { start, err := frozenOperationsReportTime(filters, exportTimeFilterStartKey) if err != nil { return "", "", err } end, err := frozenOperationsReportTime(filters, exportTimeFilterEndKey) if err != nil { return "", "", err } if _, _, err := utils.ParseTimeRange(start, end); err != nil { return "", "", err } return start, end, nil } // frozenOperationsReportTime 读取单个冻结的时间边界值。 func frozenOperationsReportTime(filters map[string]any, key string) (string, error) { value, exists := filters[key] if !exists || value == nil { return "", nil } text, ok := value.(string) if !ok { return "", utils.TimeFilterFormatError(key) } if text == "" { return "", nil } return text, nil } // frozenQueryContext 以任务内冻结的账号类型与可见店铺范围构造查询上下文。 // 执行期不读取当前请求上下文:创建后的角色、店铺归属或筛选变化都不会扩大或收紧已建任务的数据集。 func frozenQueryContext(ctx context.Context, params ExportParams) context.Context { return middleware.SetUserContext(ctx, &middleware.UserContextInfo{ UserType: params.UserType, SubordinateShopIDs: params.ScopeShopIDs, }) } // ensureOperationsReportExportAllowed 只允许超级管理员与平台账号使用运营报表导出场景。 // 判定依据是任务内冻结的账号类型,不读取当前请求上下文,因此创建后角色变化不会放宽或收紧已建任务。 func ensureOperationsReportExportAllowed(params ExportParams) error { if params.UserType == constants.UserTypeSuperAdmin || params.UserType == constants.UserTypePlatform { return nil } return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) } // sliceOperationsReportRows 按 offset/limit 切分导出行。 func sliceOperationsReportRows(rows [][]string, offset, limit int) [][]string { if offset < 0 { offset = 0 } if limit <= 0 || offset >= len(rows) { return [][]string{} } end := offset + limit if end > len(rows) { end = len(rows) } return rows[offset:end] } // formatOptionalInt64 输出可选整数,为空写「-」。 func formatOptionalInt64(value *int64) string { if value == nil { return operationsReportEmptyValue } return strconv.FormatInt(*value, 10) } // formatOptionalFloat 输出可选小数(保留两位),为空写「-」。 func formatOptionalFloat(value *float64) string { if value == nil { return operationsReportEmptyValue } return strconv.FormatFloat(*value, 'f', 2, 64) }