Files
junhong_cmp_fiber/internal/query/operationsreport/query.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

551 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package operationsreport 提供运营报表(设备激活情况与套餐续费情况)的只读投影。
//
// 查询只读三张日报快照表,不回查设备、卡、套餐使用等实时事实;
// 累计类指标一律取所选结束日的快照,结束日无快照时不回退更早快照;
// 时间筛选复用统一时间筛选契约的严格解析器(带时区的 RFC3339 秒级、闭区间),
// 快照日期按「零点(+08:00落入区间」判定。
package operationsreport
import (
"context"
"sort"
"time"
"gorm.io/gorm"
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
"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"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// Query 查询运营报表日报快照。
type Query struct {
db *gorm.DB
}
// NewQuery 创建运营报表查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db}
}
// ActivationSummary 查询设备激活情况汇总。
//
// 累计类指标取所选结束日的快照;新增激活数按结束日与基期累计之差计算;
// 未选择分组维度时只返回一行「全部」,与头行合计完全一致。
func (q *Query) ActivationSummary(ctx context.Context, request dto.OperationsActivationSummaryRequest) (*dto.OperationsActivationSummaryResponse, error) {
if err := q.ready(ctx); err != nil {
return nil, err
}
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return nil, err
}
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.ActivationDimensionName)
if err != nil {
return nil, err
}
days, err := q.matchedDays(ctx, lowerDay(start), upperDay(end))
if err != nil {
return nil, err
}
response := &dto.OperationsActivationSummaryResponse{
SnapshotDates: formatDays(days),
GroupBy: dimension,
GroupName: groupName,
Items: []dto.OperationsActivationSummaryItem{},
}
endDay, hasSelectedEnd := selectedEndDay(end, days)
if !hasSelectedEnd {
return response, nil
}
heads, err := q.headRows(ctx, []time.Time{endDay})
if err != nil {
return nil, err
}
head, ok := heads[dayKey(endDay)]
if !ok {
// 所选结束日无快照:累计类与派生指标一律为空、分组行为空集,绝不就近回退更早快照。
return response, nil
}
response.HasSnapshot = true
baseDay, hasBase := summaryBaseDay(start, days)
var base *activationMetrics
if hasBase {
baseHeads, err := q.headRows(ctx, []time.Time{baseDay})
if err != nil {
return nil, err
}
if row, exists := baseHeads[dayKey(baseDay)]; exists {
metrics := metricsFromHead(row)
base = &metrics
}
}
// 可见店铺范围SubordinateShopIDs只在代理账号上计算而本能力只放行超管与平台账号
// 因此 scope 恒为空Totals 取头行即等于分组行之和;若未来放开给带店铺范围的账号,
// Totals 与导出合计行都必须同步收敛到按 scope 的聚合,不能继续读全库头行。
endMetrics := metricsFromHead(head)
totals := endMetrics.item(domainreport.DimensionAll, endDay)
totals.NewActivatedDeviceCount = newActivatedDeviceCount(endMetrics.ActivatedDeviceCount, base)
response.Totals = &totals
if dimension == "" && len(snapshotScope(ctx)) == 0 {
response.Items = append(response.Items, totals)
return response, nil
}
groups, err := q.activationDayGroups(ctx, []time.Time{endDay}, dimension, snapshotScope(ctx))
if err != nil {
return nil, err
}
var baseGroups map[groupKey]activationMetrics
if base != nil {
baseDayGroups, err := q.activationDayGroups(ctx, []time.Time{baseDay}, dimension, snapshotScope(ctx))
if err != nil {
return nil, err
}
baseGroups = baseDayGroups[dayKey(baseDay)]
}
endGroups := groups[dayKey(endDay)]
items := make([]dto.OperationsActivationSummaryItem, 0, len(endGroups))
for key, metrics := range endGroups {
item := metrics.item(key.value, endDay)
baseMetrics, hasBaseMetrics := baseGroups[key]
item.NewActivatedDeviceCount = newActivatedDeviceCount(metrics.ActivatedDeviceCount,
activationMetricsPointer(baseMetrics, hasBaseMetrics))
items = append(items, item)
}
sortActivationItems(items)
response.Items = items
return response, nil
}
// ActivationTrend 查询设备激活情况趋势。
//
// 按日每个快照日一点,按月每个自然月一点;累计类指标取该期最后一个有快照日的快照值;
// 新增激活数取相邻期同口径之差,任一侧无快照时为空;无快照的期不出现。
func (q *Query) ActivationTrend(ctx context.Context, request dto.OperationsActivationTrendRequest) (*dto.OperationsActivationTrendResponse, error) {
if err := q.ready(ctx); err != nil {
return nil, err
}
granularity, ok := domainreport.NormalizeGranularity(request.Granularity)
if !ok {
return nil, errors.New(errors.CodeInvalidParam, "granularity 只能为 day 或 month")
}
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return nil, err
}
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.ActivationDimensionName)
if err != nil {
return nil, err
}
periods, err := q.activationPeriods(ctx, granularity, start, end)
if err != nil {
return nil, err
}
response := &dto.OperationsActivationTrendResponse{
Granularity: granularity,
GroupBy: dimension,
GroupName: groupName,
Points: []dto.OperationsActivationTrendPoint{},
}
if len(periods) == 0 {
return response, nil
}
scope := snapshotScope(ctx)
pointDays := make([]time.Time, 0, len(periods)*2)
seen := make(map[string]struct{}, len(periods)*2)
baseDayOfPeriod := make(map[string]time.Time, len(periods))
for _, period := range periods {
pointDays = appendUniqueDay(pointDays, seen, period.RepresentativeDay)
}
previousDays, err := q.previousPeriodRepresentatives(ctx, granularity, periods)
if err != nil {
return nil, err
}
for _, period := range periods {
if base, exists := previousDays[period.Key]; exists {
baseDayOfPeriod[period.Key] = base
pointDays = appendUniqueDay(pointDays, seen, base)
}
}
metricsByDay := make(map[string]map[groupKey]activationMetrics, len(pointDays))
if dimension == "" && len(scope) == 0 {
heads, err := q.headRows(ctx, pointDays)
if err != nil {
return nil, err
}
for _, day := range pointDays {
head, exists := heads[dayKey(day)]
if !exists {
continue
}
metricsByDay[dayKey(day)] = map[groupKey]activationMetrics{
{value: domainreport.DimensionAll}: metricsFromHead(head),
}
}
} else {
metricsByDay, err = q.activationDayGroups(ctx, pointDays, dimension, scope)
if err != nil {
return nil, err
}
}
for _, period := range periods {
element, exists := metricsByDay[dayKey(period.RepresentativeDay)]
if !exists {
continue
}
baseElement := metricsByDay[dayKey(baseDayOfPeriod[period.Key])]
keys := sortedMetricsKeys(element)
for _, key := range keys {
metrics := element[key]
item := metrics.item(key.value, period.RepresentativeDay)
baseMetrics, hasBaseMetrics := baseElement[key]
item.NewActivatedDeviceCount = newActivatedDeviceCount(metrics.ActivatedDeviceCount,
activationMetricsPointer(baseMetrics, hasBaseMetrics))
response.Points = append(response.Points, dto.OperationsActivationTrendPoint{
Period: period.Key,
OperationsActivationSummaryItem: item,
})
}
}
return response, nil
}
// RenewalSummary 查询套餐续费情况汇总。
//
// 到期与续费都是统计期内的流式指标,按资产去重;同一资产在统计期内多次到期各计一次;
// 续费资产集合是到期资产集合的子集,续费率不超过 100% 由构造保证。
func (q *Query) RenewalSummary(ctx context.Context, request dto.OperationsRenewalSummaryRequest) (*dto.OperationsRenewalSummaryResponse, error) {
if err := q.ready(ctx); err != nil {
return nil, err
}
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return nil, err
}
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.RenewalDimensionName)
if err != nil {
return nil, err
}
days, err := q.matchedDays(ctx, lowerDay(start), upperDay(end))
if err != nil {
return nil, err
}
response := &dto.OperationsRenewalSummaryResponse{
SnapshotDates: formatDays(days),
GroupBy: dimension,
GroupName: groupName,
Items: []dto.OperationsRenewalSummaryItem{},
}
endDay, hasSelectedEnd := selectedEndDay(end, days)
if !hasSelectedEnd {
return response, nil
}
heads, err := q.headRows(ctx, []time.Time{endDay})
if err != nil {
return nil, err
}
head, ok := heads[dayKey(endDay)]
if !ok {
// 所选结束日无快照:全部指标为空且分组行为空集,绝不就近回退更早快照。
return response, nil
}
response.HasSnapshot = true
scope := snapshotScope(ctx)
if len(days) == 1 && dimension == "" && len(scope) == 0 {
// 单日且不分组时直接用头行作为合计,读取量为 O(1)。
item := renewalMetricsFromHead(head).item(domainreport.DimensionAll)
response.Totals = &item
response.Items = append(response.Items, item)
return response, nil
}
lower, upper := days[0], endDay
groups, err := q.renewalsAggregate(ctx, &lower, &upper, "", dimension, scope)
if err != nil {
return nil, err
}
totalItem := groups[""][groupKey{value: domainreport.DimensionAll}].item(domainreport.DimensionAll)
response.Totals = &totalItem
if dimension == "" {
response.Items = append(response.Items, totalItem)
return response, nil
}
items := make([]dto.OperationsRenewalSummaryItem, 0, len(groups[""]))
for key, metrics := range groups[""] {
items = append(items, metrics.item(key.value))
}
sortRenewalItems(items)
response.Items = items
return response, nil
}
// RenewalTrend 查询套餐续费情况趋势。
//
// 按日每个快照日一点,按月每个自然月一点;到期与续费为期内按资产去重的流式指标;
// 无快照的期不出现。
func (q *Query) RenewalTrend(ctx context.Context, request dto.OperationsRenewalTrendRequest) (*dto.OperationsRenewalTrendResponse, error) {
if err := q.ready(ctx); err != nil {
return nil, err
}
granularity, ok := domainreport.NormalizeGranularity(request.Granularity)
if !ok {
return nil, errors.New(errors.CodeInvalidParam, "granularity 只能为 day 或 month")
}
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return nil, err
}
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.RenewalDimensionName)
if err != nil {
return nil, err
}
response := &dto.OperationsRenewalTrendResponse{
Granularity: granularity,
GroupBy: dimension,
GroupName: groupName,
Points: []dto.OperationsRenewalTrendPoint{},
}
lower, upper := lowerDay(start), upperDay(end)
if lower != nil && upper != nil && lower.After(*upper) {
return response, nil
}
groups, err := q.renewalsAggregate(ctx, lower, upper, periodExpr(granularity), dimension, snapshotScope(ctx))
if err != nil {
return nil, err
}
periodKeys := make([]string, 0, len(groups))
for key := range groups {
periodKeys = append(periodKeys, key)
}
sort.Strings(periodKeys)
for _, periodKey := range periodKeys {
element := groups[periodKey]
keys := sortedMetricsKeys(element)
for _, key := range keys {
response.Points = append(response.Points, dto.OperationsRenewalTrendPoint{
Period: periodKey,
OperationsRenewalSummaryItem: element[key].item(key.value),
})
}
}
return response, nil
}
// ready 校验查询已装配,并要求调用者为超级管理员或平台账号;其他账号统一按资源不可见处理。
func (q *Query) ready(ctx context.Context) error {
if q == nil || q.db == nil {
return errors.New(errors.CodeServiceUnavailable, "运营报表查询尚未配置")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return nil
}
// snapshotScope 返回请求人的可见店铺范围;为空表示不受限。
func snapshotScope(ctx context.Context) []uint {
return middleware.GetSubordinateShopIDs(ctx)
}
// matchedDays 返回闭区间内实际命中的快照日期(升序);两端缺省表示该端不限。
func (q *Query) matchedDays(ctx context.Context, lower, upper *time.Time) ([]time.Time, error) {
query := q.db.WithContext(ctx).Table("tb_operations_report_snapshot").Select("snapshot_date")
if lower != nil {
query = query.Where("snapshot_date >= ?::date", domainreport.FormatSnapshotDay(*lower))
}
if upper != nil {
query = query.Where("snapshot_date <= ?::date", domainreport.FormatSnapshotDay(*upper))
}
var rows []time.Time
if err := query.Order("snapshot_date ASC").Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营报表快照日期失败")
}
// DATE 列读回是 UTC 零点,统一归一为上海自然日零点,
// 使一切跨日比较与期归属使用同一时间表示(避免 UTC 零点与东八区零点相差 8 小时)。
days := make([]time.Time, 0, len(rows))
for _, row := range rows {
days = append(days, domainreport.SnapshotDay(row))
}
return days, nil
}
// headRows 批量读取指定快照日的头行,键为 yyyy-MM-dd。
func (q *Query) headRows(ctx context.Context, days []time.Time) (map[string]model.OperationsReportSnapshot, error) {
result := make(map[string]model.OperationsReportSnapshot, len(days))
if len(days) == 0 {
return result, nil
}
var rows []model.OperationsReportSnapshot
if err := q.db.WithContext(ctx).Model(&model.OperationsReportSnapshot{}).
Where("snapshot_date IN ?", uniqueDays(days)).
Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营报表快照头行失败")
}
for _, row := range rows {
result[domainreport.FormatSnapshotDay(row.SnapshotDate)] = row
}
return result, nil
}
// uniqueDays 去除重复快照日后返回。
func uniqueDays(days []time.Time) []time.Time {
seen := make(map[string]struct{}, len(days))
result := make([]time.Time, 0, len(days))
for _, day := range days {
key := dayKey(day)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
result = append(result, day)
}
return result
}
// lowerDay 把区间起点换算为最早入选的快照日期;起点缺省表示不限。
func lowerDay(start *time.Time) *time.Time {
if start == nil {
return nil
}
day := domainreport.LowerBoundDay(*start)
return &day
}
// upperDay 把区间终点换算为最晚入选的快照日期;终点缺省表示不限。
func upperDay(end *time.Time) *time.Time {
if end == nil {
return nil
}
day := domainreport.UpperBoundDay(*end)
return &day
}
// selectedEndDay 返回累计类指标必须取用的「所选结束日」。
//
// 传了 end_time 时一律取落界规则给出的那一天domain.UpperBoundDay即该时刻所在上海自然日
// 该日无快照就按无快照作答,绝不就近回退到区间内更早的快照;
// 未传 end_time 时才退化为区间内最后一个命中快照日。
func selectedEndDay(end *time.Time, days []time.Time) (time.Time, bool) {
// 区间内没有任何命中快照日时不得再往前走:同日亚日区间(起点晚于该日零点)下
// 该日零点并不落在区间内,该日不得入选,否则 RenewalSummary 会取空切片的 days[0]。
if len(days) == 0 {
return time.Time{}, false
}
if end != nil {
return domainreport.UpperBoundDay(*end), true
}
return days[len(days)-1], true
}
// summaryBaseDay 返回汇总查询的基期快照日:传入区间起点时按「起始日的前一自然日」取值,
// 未传起点时取结束日之前最近的一个快照日。基期无快照时新增类指标为空。
func summaryBaseDay(start *time.Time, days []time.Time) (time.Time, bool) {
if len(days) == 0 {
return time.Time{}, false
}
endDay := days[len(days)-1]
if start != nil {
return domainreport.LowerBoundDay(*start).AddDate(0, 0, -1), true
}
for index := len(days) - 1; index >= 0; index-- {
if days[index].Before(endDay) {
return days[index], true
}
}
return time.Time{}, false
}
// resolveGroupDimension 校验并归一分组维度。
// 未选择维度时分组列为「全部」;维度不受支持时按参数非法拒绝。
func resolveGroupDimension(code string, resolve func(string) (string, bool)) (string, string, error) {
if code == "" {
return "", domainreport.DimensionAll, nil
}
name, ok := resolve(code)
if !ok {
return "", "", errors.New(errors.CodeInvalidParam, "不支持的分组维度 "+code)
}
return code, name, nil
}
// newActivatedDeviceCount 计算新增激活数:结束日累计减去基期累计。
// 基期缺失或基期无快照时为空;不做零下限截断,实名逆转导致的新增为负数如实返回。
func newActivatedDeviceCount(current int64, base *activationMetrics) *int64 {
if base == nil {
return nil
}
value := current - base.ActivatedDeviceCount
return &value
}
// activationMetricsPointer 按存在性返回指标指针,用于区分「基期无该分组」与「基期该分组为零」。
func activationMetricsPointer(metrics activationMetrics, exists bool) *activationMetrics {
if !exists {
return nil
}
return &metrics
}
// formatDays 输出快照日期文本集合。
func formatDays(days []time.Time) []string {
result := make([]string, 0, len(days))
for _, day := range days {
result = append(result, dayKey(day))
}
return result
}
// dayKey 把快照日归一为上海自然日的 yyyy-MM-dd 文本。
// 查询侧的快照日一律归一为上海自然日零点,因此这里按上海自然日取键。
func dayKey(day time.Time) string {
return domainreport.FormatSnapshotDay(day)
}
// appendUniqueDay 按日期键去重追加。
func appendUniqueDay(target []time.Time, seen map[string]struct{}, day time.Time) []time.Time {
key := dayKey(day)
if _, exists := seen[key]; exists {
return target
}
seen[key] = struct{}{}
return append(target, day)
}
// sortedMetricsKeys 按分组值排序分组键,保证行序可复现。
func sortedMetricsKeys[T any](element map[groupKey]T) []groupKey {
keys := make([]groupKey, 0, len(element))
for key := range element {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].value != keys[j].value {
return keys[i].value < keys[j].value
}
return keys[i].identity < keys[j].identity
})
return keys
}
// sortActivationItems 按分组值与维度名排序激活情况分组行。
func sortActivationItems(items []dto.OperationsActivationSummaryItem) {
sort.Slice(items, func(i, j int) bool { return items[i].GroupValue < items[j].GroupValue })
}
// sortRenewalItems 按分组值排序套餐续费分组行。
func sortRenewalItems(items []dto.OperationsRenewalSummaryItem) {
sort.Slice(items, func(i, j int) bool { return items[i].GroupValue < items[j].GroupValue })
}