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

376 lines
15 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
import (
"context"
"strconv"
"strings"
"time"
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/errors"
)
// groupKey 是一个分组行在结果中的唯一键value 是展示值identity 是实体键。
// 同一展示值下的不同实体不会被合并(例如两个店铺同名为「未设置」仍各占一行)。
type groupKey struct {
value string
identity string
}
// activationMetrics 是一行(头行或分组聚合)的激活指标原始值。
type activationMetrics struct {
PurchasedDeviceCount int64
ActivatedDeviceCount int64
OnlineDeviceCount int64
ActiveDeviceCount int64
TotalRealTrafficMB float64
}
// metricsFromHead 由快照头行构造激活指标原始值。
func metricsFromHead(head model.OperationsReportSnapshot) activationMetrics {
return activationMetrics{
PurchasedDeviceCount: head.PurchasedDeviceCount,
ActivatedDeviceCount: head.ActivatedDeviceCount,
OnlineDeviceCount: head.OnlineDeviceCount,
ActiveDeviceCount: head.ActiveDeviceCount,
TotalRealTrafficMB: head.TotalRealTrafficMB,
}
}
// item 按报表口径组装一行激活指标。
// 分母为零的比率与卡均为空值;累计用量按 1 GB = 1024 MB 折算;
// 预测卡均的当月口径取所选结束日所在上海自然月。
func (m activationMetrics) item(groupValue string, endDay time.Time) dto.OperationsActivationSummaryItem {
purchased := m.PurchasedDeviceCount
activated := m.ActivatedDeviceCount
online := m.OnlineDeviceCount
active := m.ActiveDeviceCount
item := dto.OperationsActivationSummaryItem{
GroupValue: groupValue,
PurchasedDeviceCount: &purchased,
ActivatedDeviceCount: &activated,
OnlineDeviceCount: &online,
ActiveDeviceCount: &active,
}
if rate, ok := domainreport.Ratio(activated, purchased); ok {
item.ActivationRate = &rate
}
trafficGB := domainreport.Round2(m.TotalRealTrafficMB / domainreport.MBPerGB)
item.TotalRealTrafficGB = &trafficGB
if average, ok := domainreport.CardAverageGB(m.TotalRealTrafficMB, online); ok {
item.PerUserAverageGB = &average
}
if forecast, ok := domainreport.ForecastCardAverageGB(m.TotalRealTrafficMB, online, endDay); ok {
item.ForecastAverageIncludingZeroGB = &forecast
}
if forecast, ok := domainreport.ForecastCardAverageGB(m.TotalRealTrafficMB, active, endDay); ok {
item.ForecastAverageExcludingZeroGB = &forecast
}
return item
}
// renewalMetrics 是一组(头行或分组聚合)的到期与续费资产数。
type renewalMetrics struct {
DueAssets int64
RenewedAssets int64
}
// renewal 按报表口径组装一行续费指标。
// 续费率分母为零时为空值;新增未续费数为到期数减续费数,不小于零。
func (m renewalMetrics) item(groupValue string) dto.OperationsRenewalSummaryItem {
due := m.DueAssets
renewed := m.RenewedAssets
unrenewed := due - renewed
if unrenewed < 0 {
unrenewed = 0
}
item := dto.OperationsRenewalSummaryItem{
GroupValue: groupValue,
DueAssetCount: &due,
RenewedAssetCount: &renewed,
NewUnrenewedAssetCount: &unrenewed,
}
if rate, ok := domainreport.RenewalRate(renewed, due); ok {
item.RenewalRate = &rate
}
return item
}
// renewalMetricsFromHead 由快照头行构造续费指标原始值。
func renewalMetricsFromHead(head model.OperationsReportSnapshot) renewalMetrics {
return renewalMetrics{DueAssets: head.RenewalDueAssetCount, RenewedAssets: head.RenewalRenewedAssetCount}
}
// aggregateKeyRow 是一次分组聚合查询的原始投影。
// 只填充当前维度与当前报表实际选择的列,其余列保持零值。
type aggregateKeyRow struct {
Period string `gorm:"column:period"`
SnapshotDay string `gorm:"column:snapshot_day"`
DeviceName string `gorm:"column:device_name"`
DeviceModel string `gorm:"column:device_model"`
Manufacturer string `gorm:"column:manufacturer"`
BusinessUserGroupID *uint `gorm:"column:business_user_group_id"`
BusinessUserGroupName string `gorm:"column:business_user_group_name"`
AgentAccountID *uint `gorm:"column:agent_account_id"`
AgentAccountName string `gorm:"column:agent_account_name"`
RootShopID *uint `gorm:"column:root_shop_id"`
RootShopName string `gorm:"column:root_shop_name"`
ShopID *uint `gorm:"column:shop_id"`
ShopName string `gorm:"column:shop_name"`
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
BusinessOwnerName string `gorm:"column:business_owner_name"`
SeriesID *uint `gorm:"column:series_id"`
SeriesName string `gorm:"column:series_name"`
PackageName string `gorm:"column:package_name"`
PurchasedDeviceCount int64 `gorm:"column:purchased_device_count"`
ActivatedDeviceCount int64 `gorm:"column:activated_device_count"`
OnlineDeviceCount int64 `gorm:"column:online_device_count"`
ActiveDeviceCount int64 `gorm:"column:active_device_count"`
TotalRealTrafficMB float64 `gorm:"column:total_real_traffic_mb"`
DueAssets int64 `gorm:"column:due_assets"`
RenewedAssets int64 `gorm:"column:renewed_assets"`
}
// activationKeyColumns 返回激活情况维度在快照行上的分组键列。
// 实体 ID 列一并进入分组键,避免同展示名不同实体被合并。
func activationKeyColumns(dimension string) []string {
switch dimension {
case domainreport.DimensionDeviceName:
return []string{"device_name"}
case domainreport.DimensionDeviceModel:
return []string{"device_model"}
case domainreport.DimensionManufacturer:
return []string{"manufacturer"}
case domainreport.DimensionBusinessUserGroup:
return []string{"business_user_group_id", "business_user_group_name"}
case domainreport.DimensionAgent:
return []string{"agent_account_id", "agent_account_name", "root_shop_id", "root_shop_name"}
case domainreport.DimensionShop:
return []string{"shop_id", "shop_name"}
case domainreport.DimensionBusinessOwner:
return []string{"business_owner_account_id", "business_owner_name"}
default:
return nil
}
}
// renewalKeyColumns 返回套餐续费维度在快照行上的分组键列。
func renewalKeyColumns(dimension string) []string {
switch dimension {
case domainreport.DimensionPackageSeries:
return []string{"series_id", "series_name"}
case domainreport.DimensionPackageName:
return []string{"package_name"}
case domainreport.DimensionBusinessUserGroup:
return []string{"business_user_group_id", "business_user_group_name"}
case domainreport.DimensionAgent:
return []string{"agent_account_id", "agent_account_name", "root_shop_id", "root_shop_name"}
case domainreport.DimensionShop:
return []string{"shop_id", "shop_name"}
case domainreport.DimensionBusinessOwner:
return []string{"business_owner_account_id", "business_owner_name"}
default:
return nil
}
}
// groupKeyFor 由聚合行与维度编码推导分组键。
//
// 「代理」维度映射为单一取值:优先取代理账号(归属一级代理店铺的 user_type=3 账号),
// 账号缺失时退回一级代理店铺名称;两者都缺失时为固定占位。
func groupKeyFor(dimension string, row aggregateKeyRow) groupKey {
switch dimension {
case domainreport.DimensionDeviceName:
return textKey("device_name", row.DeviceName)
case domainreport.DimensionDeviceModel:
return textKey("device_model", row.DeviceModel)
case domainreport.DimensionManufacturer:
return textKey("manufacturer", row.Manufacturer)
case domainreport.DimensionBusinessUserGroup:
return idKey("business_user_group", formatOptionalUint(row.BusinessUserGroupID), row.BusinessUserGroupName)
case domainreport.DimensionAgent:
if row.AgentAccountID != nil {
return groupKey{
value: domainreport.TextOrPlaceholder(row.AgentAccountName),
identity: "agent_account:" + strconv.FormatUint(uint64(*row.AgentAccountID), 10),
}
}
if row.RootShopID != nil {
return groupKey{
value: domainreport.TextOrPlaceholder(row.RootShopName),
identity: "agent_shop:" + strconv.FormatUint(uint64(*row.RootShopID), 10),
}
}
return groupKey{value: domainreport.PlaceholderUnset, identity: "agent:none"}
case domainreport.DimensionShop:
return idKey("shop", formatOptionalUint(row.ShopID), row.ShopName)
case domainreport.DimensionBusinessOwner:
return idKey("business_owner", formatOptionalUint(row.BusinessOwnerAccountID), row.BusinessOwnerName)
case domainreport.DimensionPackageSeries:
return idKey("package_series", formatOptionalUint(row.SeriesID), row.SeriesName)
case domainreport.DimensionPackageName:
return textKey("package_name", row.PackageName)
default:
return groupKey{value: domainreport.DimensionAll}
}
}
// textKey 以文本值本身作为实体键。
func textKey(prefix, value string) groupKey {
return groupKey{value: domainreport.TextOrPlaceholder(value), identity: prefix + ":" + value}
}
// idKey 以实体 ID 作为实体键ID 缺失时退回文本值。
func idKey(prefix, id, value string) groupKey {
if id == "" {
return groupKey{value: domainreport.TextOrPlaceholder(value), identity: prefix + ":name:" + value}
}
return groupKey{value: domainreport.TextOrPlaceholder(value), identity: prefix + ":" + id}
}
// activationDayGroups 按(快照日,分组维度)聚合激活指标。
// 只读设备激活快照行,不回查实时事实;数据范围按快照行的店铺列施加。
func (q *Query) activationDayGroups(ctx context.Context, days []time.Time, dimension string,
scope []uint) (map[string]map[groupKey]activationMetrics, error) {
result := make(map[string]map[groupKey]activationMetrics, len(days))
if len(days) == 0 {
return result, nil
}
selects := []string{"to_char(snapshot_date, 'YYYY-MM-DD') AS snapshot_day"}
groupColumns := []string{"snapshot_date"}
for _, column := range activationKeyColumns(dimension) {
selects = append(selects, column)
groupColumns = append(groupColumns, column)
}
selects = append(selects, activationAggregateSelects()...)
query := q.db.WithContext(ctx).Table("tb_operations_report_activation_row").
Select(strings.Join(selects, ", ")).
Where("snapshot_date IN ?", uniqueDays(days)).
Group(strings.Join(groupColumns, ", "))
if len(scope) > 0 {
query = query.Where("shop_id IN ?", scope)
}
var rows []aggregateKeyRow
if err := query.Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "聚合运营报表设备激活快照行失败")
}
for _, row := range rows {
day := row.SnapshotDay
if _, exists := result[day]; !exists {
result[day] = make(map[groupKey]activationMetrics)
}
result[day][groupKeyFor(dimension, row)] = activationMetrics{
PurchasedDeviceCount: row.PurchasedDeviceCount,
ActivatedDeviceCount: row.ActivatedDeviceCount,
OnlineDeviceCount: row.OnlineDeviceCount,
ActiveDeviceCount: row.ActiveDeviceCount,
TotalRealTrafficMB: row.TotalRealTrafficMB,
}
}
return result, nil
}
// activationAggregateSelects 返回激活指标的聚合表达式。
func activationAggregateSelects() []string {
return []string{
"COALESCE(SUM(CASE WHEN purchased THEN 1 ELSE 0 END), 0) AS purchased_device_count",
"COALESCE(SUM(CASE WHEN realnamed THEN 1 ELSE 0 END), 0) AS activated_device_count",
"COALESCE(SUM(CASE WHEN online THEN 1 ELSE 0 END), 0) AS online_device_count",
"COALESCE(SUM(CASE WHEN active THEN 1 ELSE 0 END), 0) AS active_device_count",
"COALESCE(SUM(real_traffic_mb), 0)::float8 AS total_real_traffic_mb",
}
}
// renewalsAggregate 聚合续费指标(到期与续费均按资产去重)。
//
// periodExpr 为空表示整段合计(汇总查询);否则按其分组,键与 domain.FormatPeriod 一致。
// 期内的资产去重在 SQL 层完成,因此按月趋势的同一资产多次到期只计一次。
func (q *Query) renewalsAggregate(ctx context.Context, lower, upper *time.Time, periodExpr, dimension string,
scope []uint) (map[string]map[groupKey]renewalMetrics, error) {
selects := make([]string, 0, 4)
groupColumns := make([]string, 0, 4)
if periodExpr != "" {
selects = append(selects, periodExpr+" AS period")
groupColumns = append(groupColumns, periodExpr)
}
for _, column := range renewalKeyColumns(dimension) {
selects = append(selects, column)
groupColumns = append(groupColumns, column)
}
selects = append(selects,
"COUNT(DISTINCT asset_type || ':' || asset_id) AS due_assets",
"COUNT(DISTINCT asset_type || ':' || asset_id) FILTER (WHERE renewed) AS renewed_assets")
query := q.db.WithContext(ctx).Table("tb_operations_report_renewal_row").Select(strings.Join(selects, ", "))
if lower != nil {
query = query.Where("snapshot_date >= ?::date", domainreport.FormatSnapshotDay(*lower))
}
if upper != nil {
query = query.Where("snapshot_date <= ?::date", domainreport.FormatSnapshotDay(*upper))
}
if len(scope) > 0 {
query = query.Where("shop_id IN ?", scope)
}
if len(groupColumns) > 0 {
query = query.Group(strings.Join(groupColumns, ", "))
}
var rows []aggregateKeyRow
if err := query.Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "聚合运营报表套餐续费快照行失败")
}
result := make(map[string]map[groupKey]renewalMetrics, len(rows))
if periodExpr == "" {
result[""] = make(map[groupKey]renewalMetrics)
}
for _, row := range rows {
key := groupKeyFor(dimension, row)
if !rowHasGroup(row, dimension) {
key = groupKey{value: domainreport.DimensionAll}
}
if _, exists := result[row.Period]; !exists {
result[row.Period] = make(map[groupKey]renewalMetrics)
}
result[row.Period][key] = renewalMetrics{DueAssets: row.DueAssets, RenewedAssets: row.RenewedAssets}
}
return result, nil
}
// rowHasGroup 判断聚合行是否携带分组维度取值(未分组时为 false
func rowHasGroup(row aggregateKeyRow, dimension string) bool {
switch dimension {
case domainreport.DimensionPackageSeries:
return row.SeriesID != nil || row.SeriesName != ""
case domainreport.DimensionPackageName:
return row.PackageName != ""
case domainreport.DimensionBusinessUserGroup:
return row.BusinessUserGroupID != nil || row.BusinessUserGroupName != ""
case domainreport.DimensionAgent:
return row.AgentAccountID != nil || row.RootShopID != nil || row.RootShopName != ""
case domainreport.DimensionShop:
return row.ShopID != nil || row.ShopName != ""
case domainreport.DimensionBusinessOwner:
return row.BusinessOwnerAccountID != nil || row.BusinessOwnerName != ""
default:
return false
}
}
// formatOptionalUint 输出可空 ID 的文本形式。
func formatOptionalUint(value *uint) string {
if value == nil {
return ""
}
return strconv.FormatUint(uint64(*value), 10)
}
// periodExpr 返回趋势期在 SQL 中的表达式,键与 domain.FormatPeriod 一致。
func periodExpr(granularity string) string {
if granularity == domainreport.GranularityMonth {
return "to_char(snapshot_date, 'YYYY-MM')"
}
return "to_char(snapshot_date, 'YYYY-MM-DD')"
}