feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
- 新增成对迁移 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
This commit is contained in:
375
internal/query/operationsreport/aggregate.go
Normal file
375
internal/query/operationsreport/aggregate.go
Normal file
@@ -0,0 +1,375 @@
|
||||
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')"
|
||||
}
|
||||
Reference in New Issue
Block a user