Files
junhong_cmp_fiber/internal/exporter/operations_report_scene.go
break 5e78809b93
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
- 新增成对迁移 000231 与三张快照表:日级头行、设备粒度激活行、到期事件粒度续费行,以快照日期为唯一键
- 新增每日 03:30(Asia/Shanghai)日报快照生成任务与幂等整日替换,失败重试沿用同一目标日
- 新增六条受控入口:两张报表的汇总、日/月趋势与受控导出,配套查询层只读快照事实
- 新增 operations_activation 与 operations_renewal 两个导出场景,创建期冻结筛选与可见店铺范围、派发期冻结表头、执行期只按冻结值复核资格
- 采购数量口径按系统内未删除设备数实施并在 PRD 标注,附实测差额依据
- 同步证据链 requirement-evidence.json 与入口能力矩阵、ARCHITECTURE 与验证记录
- 归档 change add-operations-reports 并新建主 Spec openspec/specs/operations-report/spec.md
2026-09-18 09:42:28 +08:00

335 lines
12 KiB
Go

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 = "合计"
// OperationsActivationDataSource 设备激活情况报表导出数据源。
//
// 导出列与页面展示字段一致并包含合计行,不含任何文字总结;
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
// 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再按任务内冻结的账号类型复核一次,
// 阻止通过通用导出入口以代理身份创建本场景任务后读到运营报表数据。
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{
response.GroupName, "采购数量", "累计激活数", "激活率", "新增激活数",
"累计在网数", "活跃用户数", "累计用量(GB)", "单用户卡均(GB)", "含零预测卡均(GB)", "不含零预测卡均(GB)",
}
rows := make([][]string, 0, len(response.Items)+1)
for _, item := range response.Items {
rows = append(rows, []string{
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 套餐续费情况报表导出数据源。
//
// 导出列与页面展示字段一致并包含合计行,不含任何文字总结;
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
// 本场景同样按任务内冻结的账号类型复核导出资格。
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{response.GroupName, "到期资产数", "续费资产数", "续费率", "新增未续费数"}
rows := make([][]string, 0, len(response.Items)+1)
for _, item := range response.Items {
rows = append(rows, []string{
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)
}