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:
246
internal/application/operationsreport/generate.go
Normal file
246
internal/application/operationsreport/generate.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// Package operationsreport 实现运营报表日报快照的生成用例。
|
||||
//
|
||||
// 生成 = 读取生成时刻的只读事实(设备、当前有效关联卡、套餐使用记录、店铺与业务员、用户组、
|
||||
// 套餐与套餐系列)→ 按报表口径域组装三张快照表的行与头行 → 在单事务内整日替换该日全部行并校验不变量。
|
||||
// 用例本身不依赖 GORM、Fiber、Redis 或 Asynq;读写分别由 FactsReader 与 SnapshotWriter 端口提供。
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// DeviceActivationFact 是一台未删除设备在生成时刻的冻结事实。
|
||||
//
|
||||
// 归属(店铺、业务员、用户组与代理的两个取值)是生成时刻取值,之后不再重新解析:
|
||||
// 历史快照行不因后续归属变化被改写。
|
||||
type DeviceActivationFact struct {
|
||||
DeviceID uint
|
||||
VirtualNo string
|
||||
DeviceName string
|
||||
DeviceModel string
|
||||
Manufacturer string
|
||||
ShopID *uint
|
||||
ShopName string
|
||||
RootShopID *uint
|
||||
RootShopName string
|
||||
AgentAccountID *uint
|
||||
AgentAccountName string
|
||||
BusinessOwnerAccountID *uint
|
||||
BusinessOwnerName string
|
||||
BusinessUserGroupID *uint
|
||||
BusinessUserGroupName string
|
||||
Purchased bool
|
||||
Realnamed bool
|
||||
Online bool
|
||||
Active bool
|
||||
RealTrafficMB float64
|
||||
}
|
||||
|
||||
// RenewalFact 是一条到期事实及其续费判定结果在生成时刻的冻结事实。
|
||||
type RenewalFact struct {
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetIdentifier string
|
||||
ExpiredUsageID uint
|
||||
PackageID uint
|
||||
PackageName string
|
||||
SeriesID *uint
|
||||
SeriesName string
|
||||
ShopID *uint
|
||||
ShopName string
|
||||
RootShopID *uint
|
||||
RootShopName string
|
||||
AgentAccountID *uint
|
||||
AgentAccountName string
|
||||
BusinessOwnerAccountID *uint
|
||||
BusinessOwnerName string
|
||||
BusinessUserGroupID *uint
|
||||
BusinessUserGroupName string
|
||||
Renewed bool
|
||||
}
|
||||
|
||||
// DayFacts 是一个快照日的全部源事实。
|
||||
type DayFacts struct {
|
||||
Devices []DeviceActivationFact
|
||||
Renewals []RenewalFact
|
||||
}
|
||||
|
||||
// FactsReader 读取生成一份日报快照所需的只读事实。
|
||||
type FactsReader interface {
|
||||
// CountUndeletedDevicesAsOf 是采购数量口径的取值端口,只被 domain.PurchaseCountAsOf 调用。
|
||||
CountUndeletedDevicesAsOf(ctx context.Context, snapshotDate time.Time) (int64, error)
|
||||
// LoadDayFacts 读取该快照日的设备事实与到期事实。
|
||||
LoadDayFacts(ctx context.Context, snapshotDate time.Time) (*DayFacts, error)
|
||||
}
|
||||
|
||||
// SnapshotWriter 整日替换日报快照。
|
||||
type SnapshotWriter interface {
|
||||
// ReplaceDay 在单事务内先删除该日三张快照表的全部行,再整日写入头行与两类明细行,并校验不变量。
|
||||
// 任一步失败必须回滚,使该日不残留部分口径。
|
||||
ReplaceDay(ctx context.Context, snapshotDate time.Time, snapshot model.OperationsReportSnapshot,
|
||||
activations []model.OperationsReportActivationRow, renewals []model.OperationsReportRenewalRow) error
|
||||
}
|
||||
|
||||
// Generator 生成某一天的运营报表日报快照。
|
||||
type Generator struct {
|
||||
reader FactsReader
|
||||
writer SnapshotWriter
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewGenerator 创建日报快照生成用例。
|
||||
func NewGenerator(reader FactsReader, writer SnapshotWriter, logger *zap.Logger) *Generator {
|
||||
return &Generator{reader: reader, writer: writer, logger: logger}
|
||||
}
|
||||
|
||||
// Generate 生成指定上海自然日的日报快照。
|
||||
// 同一日期重复生成的结果等于最后一次执行的结果:整日替换保证不产生重复行或第二套口径。
|
||||
func (g *Generator) Generate(ctx context.Context, snapshotDate time.Time) error {
|
||||
if g == nil || g.reader == nil || g.writer == nil {
|
||||
return errors.New(errors.CodeInternalError, "运营报表快照生成用例未配置")
|
||||
}
|
||||
day := domainreport.SnapshotDay(snapshotDate)
|
||||
|
||||
// 采购数量经唯一的采购数量口径函数取值(设计 D6)。
|
||||
purchasedCount, err := domainreport.PurchaseCountAsOf(ctx, g.reader, day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
facts, err := g.reader.LoadDayFacts(ctx, day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if facts == nil {
|
||||
facts = &DayFacts{}
|
||||
}
|
||||
|
||||
generatedAt := time.Now().UTC()
|
||||
snapshot, activations, renewals, err := BuildSnapshotRows(day, generatedAt, purchasedCount, facts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.writer.ReplaceDay(ctx, day, snapshot, activations, renewals); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if g.logger != nil {
|
||||
g.logger.Info("运营报表日报快照生成完成",
|
||||
zap.String("snapshot_date", domainreport.FormatSnapshotDay(day)),
|
||||
zap.Int64("purchased_device_count", snapshot.PurchasedDeviceCount),
|
||||
zap.Int64("activated_device_count", snapshot.ActivatedDeviceCount),
|
||||
zap.Int64("online_device_count", snapshot.OnlineDeviceCount),
|
||||
zap.Int64("active_device_count", snapshot.ActiveDeviceCount),
|
||||
zap.Int64("renewal_due_asset_count", snapshot.RenewalDueAssetCount),
|
||||
zap.Int64("renewal_renewed_asset_count", snapshot.RenewalRenewedAssetCount),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildSnapshotRows 把生成时刻的事实组装为头行与两类明细行。
|
||||
//
|
||||
// 头行值是明细行的汇总值(不是另一套独立读数),因此
|
||||
// 「同一快照日期、任一受支持维度下分组行各指标之和等于头行值」由构造保证;
|
||||
// 到期资产数与续费资产数按资产去重,续费资产恒为到期资产的子集。
|
||||
func BuildSnapshotRows(day time.Time, generatedAt time.Time, purchasedCount int64, facts *DayFacts) (
|
||||
model.OperationsReportSnapshot, []model.OperationsReportActivationRow, []model.OperationsReportRenewalRow, error) {
|
||||
activations := make([]model.OperationsReportActivationRow, 0, len(facts.Devices))
|
||||
snapshot := model.OperationsReportSnapshot{
|
||||
SnapshotDate: day,
|
||||
GeneratedAt: generatedAt,
|
||||
}
|
||||
for _, fact := range facts.Devices {
|
||||
if fact.Purchased {
|
||||
snapshot.PurchasedDeviceCount++
|
||||
}
|
||||
if fact.Realnamed {
|
||||
snapshot.ActivatedDeviceCount++
|
||||
}
|
||||
if fact.Online {
|
||||
snapshot.OnlineDeviceCount++
|
||||
}
|
||||
if fact.Active {
|
||||
snapshot.ActiveDeviceCount++
|
||||
}
|
||||
snapshot.TotalRealTrafficMB = domainreport.Round2(snapshot.TotalRealTrafficMB + fact.RealTrafficMB)
|
||||
activations = append(activations, model.OperationsReportActivationRow{
|
||||
SnapshotDate: day,
|
||||
DeviceID: fact.DeviceID,
|
||||
VirtualNo: fact.VirtualNo,
|
||||
DeviceName: fact.DeviceName,
|
||||
DeviceModel: fact.DeviceModel,
|
||||
Manufacturer: fact.Manufacturer,
|
||||
ShopID: fact.ShopID,
|
||||
ShopName: fact.ShopName,
|
||||
RootShopID: fact.RootShopID,
|
||||
RootShopName: fact.RootShopName,
|
||||
AgentAccountID: fact.AgentAccountID,
|
||||
AgentAccountName: fact.AgentAccountName,
|
||||
BusinessOwnerAccountID: fact.BusinessOwnerAccountID,
|
||||
BusinessOwnerName: fact.BusinessOwnerName,
|
||||
BusinessUserGroupID: fact.BusinessUserGroupID,
|
||||
BusinessUserGroupName: fact.BusinessUserGroupName,
|
||||
Purchased: fact.Purchased,
|
||||
Realnamed: fact.Realnamed,
|
||||
Online: fact.Online,
|
||||
Active: fact.Active,
|
||||
RealTrafficMB: domainreport.Round2(fact.RealTrafficMB),
|
||||
})
|
||||
}
|
||||
if snapshot.PurchasedDeviceCount != purchasedCount {
|
||||
// 采购数量口径函数与明细行必须描述同一总体;不一致说明本次读取跨越了设备增删,
|
||||
// 与其写入一套自相矛盾的快照,不如让该日失败(重试沿用同一目标日期,不产生部分口径)。
|
||||
return model.OperationsReportSnapshot{}, nil, nil, errors.New(errors.CodeDatabaseError,
|
||||
"运营报表快照的采购数量与设备事实不一致,本次生成已终止")
|
||||
}
|
||||
|
||||
renewals := make([]model.OperationsReportRenewalRow, 0, len(facts.Renewals))
|
||||
dueAssets := make(map[assetKey]struct{}, len(facts.Renewals))
|
||||
renewedAssets := make(map[assetKey]struct{}, len(facts.Renewals))
|
||||
for _, fact := range facts.Renewals {
|
||||
key := assetKey{AssetType: fact.AssetType, AssetID: fact.AssetID}
|
||||
dueAssets[key] = struct{}{}
|
||||
if fact.Renewed {
|
||||
renewedAssets[key] = struct{}{}
|
||||
}
|
||||
renewals = append(renewals, model.OperationsReportRenewalRow{
|
||||
SnapshotDate: day,
|
||||
AssetType: fact.AssetType,
|
||||
AssetID: fact.AssetID,
|
||||
AssetIdentifier: fact.AssetIdentifier,
|
||||
ExpiredUsageID: fact.ExpiredUsageID,
|
||||
PackageID: fact.PackageID,
|
||||
PackageName: fact.PackageName,
|
||||
SeriesID: fact.SeriesID,
|
||||
SeriesName: fact.SeriesName,
|
||||
ShopID: fact.ShopID,
|
||||
ShopName: fact.ShopName,
|
||||
RootShopID: fact.RootShopID,
|
||||
RootShopName: fact.RootShopName,
|
||||
AgentAccountID: fact.AgentAccountID,
|
||||
AgentAccountName: fact.AgentAccountName,
|
||||
BusinessOwnerAccountID: fact.BusinessOwnerAccountID,
|
||||
BusinessOwnerName: fact.BusinessOwnerName,
|
||||
BusinessUserGroupID: fact.BusinessUserGroupID,
|
||||
BusinessUserGroupName: fact.BusinessUserGroupName,
|
||||
Renewed: fact.Renewed,
|
||||
})
|
||||
}
|
||||
snapshot.RenewalDueAssetCount = int64(len(dueAssets))
|
||||
snapshot.RenewalRenewedAssetCount = int64(len(renewedAssets))
|
||||
|
||||
return snapshot, activations, renewals, nil
|
||||
}
|
||||
|
||||
// assetKey 是续费指标的资产去重键(沿用既有载体类型取值)。
|
||||
type assetKey struct {
|
||||
AssetType string
|
||||
AssetID uint
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
h5PopupQuery "github.com/break/junhong_cmp_fiber/internal/query/h5popup"
|
||||
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
|
||||
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
|
||||
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
|
||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
|
||||
priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling"
|
||||
@@ -189,6 +190,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit)
|
||||
popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB)
|
||||
packageTrafficAlertQuery := packagetrafficalertquery.NewQuery(deps.DB)
|
||||
operationsReportQuery := operationsreportquery.NewQuery(deps.DB)
|
||||
|
||||
return &Handlers{
|
||||
Auth: authHandler.NewHandler(svc.Auth, validate),
|
||||
@@ -288,6 +290,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
||||
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate),
|
||||
AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(svc.AssetAutoRenewal, validate),
|
||||
OperationsReport: admin.NewOperationsReportHandler(operationsReportQuery, svc.ExportTask, validate),
|
||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
||||
|
||||
@@ -84,6 +84,7 @@ type Handlers struct {
|
||||
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
|
||||
PackageTrafficAlert *admin.PackageTrafficAlertHandler
|
||||
AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler
|
||||
OperationsReport *admin.OperationsReportHandler
|
||||
ClientWechat *app.ClientWechatHandler
|
||||
SuperAdmin *admin.SuperAdminHandler
|
||||
SystemConfig *admin.SystemConfigHandler
|
||||
|
||||
137
internal/domain/operationsreport/dimension.go
Normal file
137
internal/domain/operationsreport/dimension.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package operationsreport
|
||||
|
||||
// 报表分组维度:激活情况表七项、套餐续费表六项。
|
||||
// 只支持单一分组维度,不支持同时按多个维度分组;未选择维度时汇总为一行,分组列值为「全部」。
|
||||
const (
|
||||
// DimensionDeviceName 表示设备名称维度。
|
||||
DimensionDeviceName = "device_name"
|
||||
// DimensionDeviceModel 表示设备型号维度。
|
||||
DimensionDeviceModel = "device_model"
|
||||
// DimensionManufacturer 表示制造商维度。
|
||||
DimensionManufacturer = "manufacturer"
|
||||
// DimensionBusinessUserGroup 表示用户组维度。
|
||||
DimensionBusinessUserGroup = "business_user_group"
|
||||
// DimensionAgent 表示代理维度。
|
||||
DimensionAgent = "agent"
|
||||
// DimensionShop 表示店铺维度。
|
||||
DimensionShop = "shop"
|
||||
// DimensionBusinessOwner 表示业务员维度。
|
||||
DimensionBusinessOwner = "business_owner"
|
||||
// DimensionPackageSeries 表示套餐系列维度。
|
||||
DimensionPackageSeries = "package_series"
|
||||
// DimensionPackageName 表示套餐名称维度。
|
||||
DimensionPackageName = "package_name"
|
||||
)
|
||||
|
||||
// DimensionAll 是未选择分组维度时唯一汇总行的分组列值。
|
||||
const DimensionAll = "全部"
|
||||
|
||||
// 趋势粒度取值:只表达粒度,不引入月份参数。
|
||||
const (
|
||||
// GranularityDay 表示按日趋势。
|
||||
GranularityDay = "day"
|
||||
// GranularityMonth 表示按月趋势。
|
||||
GranularityMonth = "month"
|
||||
)
|
||||
|
||||
// NormalizeGranularity 归一趋势粒度,缺省为按日;返回 false 表示取值不受支持。
|
||||
func NormalizeGranularity(value string) (string, bool) {
|
||||
switch value {
|
||||
case "", GranularityDay:
|
||||
return GranularityDay, true
|
||||
case GranularityMonth:
|
||||
return GranularityMonth, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// PlaceholderUnset 是分组取值为空时的固定占位展示。
|
||||
const PlaceholderUnset = "未设置"
|
||||
|
||||
// activationDimensions 是有序的激活情况分组维度与其中文名。
|
||||
var activationDimensions = []struct {
|
||||
Code string
|
||||
Name string
|
||||
}{
|
||||
{DimensionDeviceName, "设备名称"},
|
||||
{DimensionDeviceModel, "设备型号"},
|
||||
{DimensionManufacturer, "制造商"},
|
||||
{DimensionBusinessUserGroup, "用户组"},
|
||||
{DimensionAgent, "代理"},
|
||||
{DimensionShop, "店铺"},
|
||||
{DimensionBusinessOwner, "业务员"},
|
||||
}
|
||||
|
||||
// renewalDimensions 是有序的套餐续费分组维度与其中文名。
|
||||
var renewalDimensions = []struct {
|
||||
Code string
|
||||
Name string
|
||||
}{
|
||||
{DimensionPackageSeries, "套餐系列"},
|
||||
{DimensionPackageName, "套餐名称"},
|
||||
{DimensionBusinessUserGroup, "用户组"},
|
||||
{DimensionAgent, "代理"},
|
||||
{DimensionShop, "店铺"},
|
||||
{DimensionBusinessOwner, "业务员"},
|
||||
}
|
||||
|
||||
// ActivationDimensionCodes 返回激活情况支持的维度编码(按展示顺序)。
|
||||
func ActivationDimensionCodes() []string {
|
||||
codes := make([]string, 0, len(activationDimensions))
|
||||
for _, dimension := range activationDimensions {
|
||||
codes = append(codes, dimension.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// RenewalDimensionCodes 返回套餐续费支持的维度编码(按展示顺序)。
|
||||
func RenewalDimensionCodes() []string {
|
||||
codes := make([]string, 0, len(renewalDimensions))
|
||||
for _, dimension := range renewalDimensions {
|
||||
codes = append(codes, dimension.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// ActivationDimensionName 返回激活情况维度的中文名;不支持时返回 false。
|
||||
func ActivationDimensionName(code string) (string, bool) {
|
||||
return dimensionName(activationDimensions, code)
|
||||
}
|
||||
|
||||
// RenewalDimensionName 返回套餐续费维度的中文名;不支持时返回 false。
|
||||
func RenewalDimensionName(code string) (string, bool) {
|
||||
return dimensionName(renewalDimensions, code)
|
||||
}
|
||||
|
||||
// IsActivationDimension 判断是否为受支持的激活情况维度。
|
||||
func IsActivationDimension(code string) bool {
|
||||
_, ok := ActivationDimensionName(code)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsRenewalDimension 判断是否为受支持的套餐续费维度。
|
||||
func IsRenewalDimension(code string) bool {
|
||||
_, ok := RenewalDimensionName(code)
|
||||
return ok
|
||||
}
|
||||
|
||||
func dimensionName(dimensions []struct {
|
||||
Code string
|
||||
Name string
|
||||
}, code string) (string, bool) {
|
||||
for _, dimension := range dimensions {
|
||||
if dimension.Code == code {
|
||||
return dimension.Name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// TextOrPlaceholder 返回非空文本,为空时返回固定占位。
|
||||
func TextOrPlaceholder(value string) string {
|
||||
if value == "" {
|
||||
return PlaceholderUnset
|
||||
}
|
||||
return value
|
||||
}
|
||||
150
internal/domain/operationsreport/metrics.go
Normal file
150
internal/domain/operationsreport/metrics.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Package operationsreport 是运营报表(设备激活与套餐续费)的口径域。
|
||||
//
|
||||
// 本包只表达可复现的口径与纯计算:真流量换算、比率与卡均、分母为零语义、
|
||||
// 预测卡均的当月口径、采购数量口径入口与续费判定规则。
|
||||
// 不依赖 Fiber、GORM、Redis、Asynq 或任何外部 SDK,也不做任何读写。
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MBPerGB 是报表域自持的流量换算常量:1 GB = 1024 MB。
|
||||
// 与 internal/domain/carrierthreshold 的换算同值同源;不为一个换算常数建立跨域依赖。
|
||||
const MBPerGB = 1024
|
||||
|
||||
// shanghaiLocation 是报表口径使用的上海时区(固定 +08:00)。
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// ShanghaiLocation 返回报表口径使用的上海时区。
|
||||
func ShanghaiLocation() *time.Location {
|
||||
return shanghaiLocation
|
||||
}
|
||||
|
||||
// SnapshotDay 把任意时刻归一为它所在的上海自然日零点。
|
||||
// 报表的一切跨日比较都使用上海自然日,不使用服务器本地时区。
|
||||
func SnapshotDay(value time.Time) time.Time {
|
||||
local := value.In(shanghaiLocation)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
|
||||
}
|
||||
|
||||
// PreviousDay 返回给定上海自然日的前一自然日零点。
|
||||
func PreviousDay(day time.Time) time.Time {
|
||||
return SnapshotDay(day).AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
// ParseSnapshotDay 解析 yyyy-MM-dd 形式的上海自然日。
|
||||
func ParseSnapshotDay(value string) (time.Time, error) {
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, shanghaiLocation)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// FormatSnapshotDay 输出上海自然日的 yyyy-MM-dd 文本。
|
||||
func FormatSnapshotDay(day time.Time) string {
|
||||
return SnapshotDay(day).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// FormatMonthPeriod 输出上海自然月的 yyyy-MM 文本。
|
||||
func FormatMonthPeriod(day time.Time) string {
|
||||
return SnapshotDay(day).Format("2006-01")
|
||||
}
|
||||
|
||||
// LowerBoundDay 按落界规则返回区间起点入选的最早快照日期。
|
||||
//
|
||||
// 落界规则(设计 D10):快照日期 D 入选,当且仅当 D 的零点(+08:00)落在请求区间内。
|
||||
// 因此起点恰好落在零点时当日入选,否则从次日起入选。
|
||||
func LowerBoundDay(start time.Time) time.Time {
|
||||
day := SnapshotDay(start)
|
||||
if start.After(day) {
|
||||
return day.AddDate(0, 0, 1)
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
// UpperBoundDay 按落界规则返回区间终点入选的最晚快照日期。
|
||||
func UpperBoundDay(end time.Time) time.Time {
|
||||
return SnapshotDay(end)
|
||||
}
|
||||
|
||||
// PeriodOf 返回给定快照日期所属的趋势期标识:按日为上海自然日,按月为该月首日。
|
||||
func PeriodOf(granularity string, day time.Time) time.Time {
|
||||
normalized := SnapshotDay(day)
|
||||
if granularity == GranularityMonth {
|
||||
return time.Date(normalized.Year(), normalized.Month(), 1, 0, 0, 0, 0, shanghaiLocation)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// FormatPeriod 输出趋势期标识:按日为 yyyy-MM-dd,按月为 yyyy-MM。
|
||||
func FormatPeriod(granularity string, day time.Time) string {
|
||||
if granularity == GranularityMonth {
|
||||
return FormatMonthPeriod(day)
|
||||
}
|
||||
return FormatSnapshotDay(day)
|
||||
}
|
||||
|
||||
// PreviousPeriodStart 返回给定期起始日所属期的前一期起始日(按日减一天,按月减一个月)。
|
||||
func PreviousPeriodStart(granularity string, periodStart time.Time) time.Time {
|
||||
if granularity == GranularityMonth {
|
||||
return periodStart.AddDate(0, -1, 0)
|
||||
}
|
||||
return periodStart.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
// Ratio 计算比率并按两位小数取整;分母不大于零时不可计算,返回 false(空值语义)。
|
||||
// 比率不设上限:设备删除或迁移可使激活率超过 100%,如实呈现。
|
||||
func Ratio(numerator, denominator int64) (float64, bool) {
|
||||
if denominator <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return Round2(float64(numerator) / float64(denominator)), true
|
||||
}
|
||||
|
||||
// CardAverageGB 计算卡均用量(GB):累计真流量折算 GB 后除以分母设备数。
|
||||
// 分母不大于零时不可计算,返回 false(空值语义)。
|
||||
func CardAverageGB(totalRealTrafficMB float64, denominator int64) (float64, bool) {
|
||||
if denominator <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
average := totalRealTrafficMB / MBPerGB / float64(denominator)
|
||||
return Round2(average), true
|
||||
}
|
||||
|
||||
// ForecastCardAverageGB 计算预测卡均(GB):先按卡均口径得出日均,再按结束日所在上海自然月年化。
|
||||
// 「当月」= 所选结束日所在上海自然月;已过天数 = 结束日日期号;当月总天数 = 该月自然日数。
|
||||
// 分母不大于零时不可计算,返回 false(空值语义)。
|
||||
func ForecastCardAverageGB(totalRealTrafficMB float64, denominator int64, endDate time.Time) (float64, bool) {
|
||||
average, ok := CardAverageGB(totalRealTrafficMB, denominator)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
elapsedDays, totalDays := MonthElapsedAndTotalDays(endDate)
|
||||
if elapsedDays <= 0 || totalDays <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return Round2(average * float64(totalDays) / float64(elapsedDays)), true
|
||||
}
|
||||
|
||||
// MonthElapsedAndTotalDays 返回结束日所在上海自然月的已过天数与当月总天数。
|
||||
// 已过天数按结束日的日期号取值(不区分当月剩余天数),当月总天数取该自然月的实际天数。
|
||||
func MonthElapsedAndTotalDays(endDate time.Time) (int, int) {
|
||||
day := SnapshotDay(endDate)
|
||||
totalDays := time.Date(day.Year(), day.Month()+1, 0, 0, 0, 0, 0, shanghaiLocation).Day()
|
||||
return day.Day(), totalDays
|
||||
}
|
||||
|
||||
// Round2 按两位小数四舍五入。
|
||||
func Round2(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
// RenewalRate 计算续费率:续费资产数除以到期资产数。
|
||||
// 分母为零时不可计算,返回 false(空值语义,导出写「-」)。
|
||||
// 分子为分母子集,因此续费率不超过 100% 由构造保证,不做任何截断或钳制。
|
||||
func RenewalRate(renewed, due int64) (float64, bool) {
|
||||
return Ratio(renewed, due)
|
||||
}
|
||||
37
internal/domain/operationsreport/purchase.go
Normal file
37
internal/domain/operationsreport/purchase.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// PurchaseCountSource 是采购数量口径的取值端口。
|
||||
// 由基础设施层实现为只读查询(截至快照日系统内未删除的设备数)。
|
||||
type PurchaseCountSource interface {
|
||||
CountUndeletedDevicesAsOf(ctx context.Context, snapshotDate time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// PurchaseCountAsOf 返回截至快照日的采购数量,是采购数量口径的**唯一实现点**(设计 D6)。
|
||||
//
|
||||
// 采用口径:采购数量 = 截至快照日(上海自然日)系统内未删除的设备数,
|
||||
// 与 `111.md` §22.4.1「系统录入的设备数量」同读法,不新建采购或入库台账。
|
||||
//
|
||||
// 被拒绝的字面口径:`tb_device_import_task` 中 `operation_type='import'` 且已完成任务的
|
||||
// `success_count` 之和。生产库实测该值为 474,而系统内未删除设备为 18,970;
|
||||
// 差额来自老系统迁移脚本直接写入设备表、绕过导入任务,按字面口径激活率约 1,399%,指标不可用。
|
||||
//
|
||||
// 切换口径只需替换本函数体内的取值方式(一行),调用方与快照表结构都不需要改动。
|
||||
func PurchaseCountAsOf(ctx context.Context, source PurchaseCountSource, snapshotDate time.Time) (int64, error) {
|
||||
return source.CountUndeletedDevicesAsOf(ctx, snapshotDate)
|
||||
}
|
||||
|
||||
// ValidMainPackageStatuses 是「有效主套餐」的状态集合:生效中与已用完。
|
||||
// 「有效」的完整口径为:主套餐(master_usage_id IS NULL)、状态属于本集合、未退款(refund_id IS NULL),
|
||||
// 既有先例见 internal/query/packageexpiry/list.go、internal/query/assetautorenewal/query.go
|
||||
// 与 internal/infrastructure/packagetrafficalert/scanner.go。
|
||||
var ValidMainPackageStatuses = []int{
|
||||
constants.PackageUsageStatusActive,
|
||||
constants.PackageUsageStatusDepleted,
|
||||
}
|
||||
43
internal/domain/operationsreport/renewal.go
Normal file
43
internal/domain/operationsreport/renewal.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// ExpiredMainUsage 是一条到期事实:快照日(上海自然日)等于其到期日的主套餐使用记录。
|
||||
type ExpiredMainUsage struct {
|
||||
UsageID uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// MainUsageCandidate 是同资产上参与续费判定的主套餐记录投影。
|
||||
// 调用方必须只传入未退款(refund_id IS NULL)的主套餐(master_usage_id IS NULL)记录。
|
||||
type MainUsageCandidate struct {
|
||||
UsageID uint
|
||||
Status int
|
||||
ActivatedAt *time.Time
|
||||
}
|
||||
|
||||
// IsRenewed 判定该到期事实是否已续费(设计 D9):
|
||||
// 存在**另一条**未退款主套餐记录,其生效时间晚于本条到期时间,或处于待生效状态。
|
||||
//
|
||||
// 候选中属于本条记录自身的项不参与判定;判定的结果挂在到期行上,
|
||||
// 使续费资产集合恒为到期资产集合的子集,续费率不超过 100% 由构造保证,不做任何截断或钳制。
|
||||
func IsRenewed(expired ExpiredMainUsage, candidates []MainUsageCandidate) bool {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.UsageID == expired.UsageID {
|
||||
continue
|
||||
}
|
||||
if candidate.Status == constants.PackageUsageStatusPending {
|
||||
return true
|
||||
}
|
||||
if candidate.ActivatedAt != nil && candidate.ActivatedAt.After(expired.ExpiresAt) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
334
internal/exporter/operations_report_scene.go
Normal file
334
internal/exporter/operations_report_scene.go
Normal file
@@ -0,0 +1,334 @@
|
||||
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)
|
||||
}
|
||||
@@ -39,6 +39,8 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
|
||||
NewCommissionRecordDataSource(db),
|
||||
NewPackageTrafficAlertDataSource(db),
|
||||
NewExpiringAssetDataSource(db),
|
||||
NewOperationsActivationDataSource(db),
|
||||
NewOperationsRenewalDataSource(db),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +79,9 @@ func IsSupportedScene(scene string) bool {
|
||||
constants.ExportTaskSceneExchange,
|
||||
constants.ExportTaskSceneCommissionRecord,
|
||||
constants.ExportTaskScenePackageTrafficAlert,
|
||||
constants.ExportTaskSceneExpiringAsset:
|
||||
constants.ExportTaskSceneExpiringAsset,
|
||||
constants.ExportTaskSceneOperationsActivation,
|
||||
constants.ExportTaskSceneOperationsRenewal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -25,6 +25,9 @@ var timeFilterScenes = map[string]struct{}{
|
||||
constants.ExportTaskSceneCommissionRecord: {},
|
||||
constants.ExportTaskSceneExpiringAsset: {},
|
||||
constants.ExportTaskScenePackageTrafficAlert: {},
|
||||
// 运营报表的两个导出场景在创建期冻结 start_time/end_time 与分组维度,执行期只按冻结值严格解析。
|
||||
constants.ExportTaskSceneOperationsActivation: {},
|
||||
constants.ExportTaskSceneOperationsRenewal: {},
|
||||
}
|
||||
|
||||
// legacyTimeFilterKeys 是受影响场景必须拒绝的旧时间筛选键。
|
||||
|
||||
172
internal/handler/admin/operations_report.go
Normal file
172
internal/handler/admin/operations_report.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
|
||||
exportTaskService "github.com/break/junhong_cmp_fiber/internal/service/export_task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
)
|
||||
|
||||
// OperationsReportHandler 运营报表 Handler。
|
||||
// 查询与导出只对超级管理员与平台账号开放(路由组已有角色门禁),Handler 不做任何跳过业务校验的分支。
|
||||
type OperationsReportHandler struct {
|
||||
query *operationsreportquery.Query
|
||||
exportService *exportTaskService.Service
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
// NewOperationsReportHandler 创建运营报表 Handler。
|
||||
func NewOperationsReportHandler(query *operationsreportquery.Query,
|
||||
exportService *exportTaskService.Service, validator *validator.Validate) *OperationsReportHandler {
|
||||
return &OperationsReportHandler{query: query, exportService: exportService, validator: validator}
|
||||
}
|
||||
|
||||
// ActivationSummary 查询设备激活情况汇总。
|
||||
// GET /api/admin/operations-reports/activation-summary
|
||||
func (h *OperationsReportHandler) ActivationSummary(c *fiber.Ctx) error {
|
||||
var req dto.OperationsActivationSummaryRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, validation.Message("查询设备激活情况汇总参数不合法", &req, err))
|
||||
}
|
||||
result, err := h.query.ActivationSummary(c.UserContext(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// ActivationTrend 查询设备激活情况日/月趋势。
|
||||
// GET /api/admin/operations-reports/activation-trend
|
||||
func (h *OperationsReportHandler) ActivationTrend(c *fiber.Ctx) error {
|
||||
var req dto.OperationsActivationTrendRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, validation.Message("查询设备激活情况趋势参数不合法", &req, err))
|
||||
}
|
||||
result, err := h.query.ActivationTrend(c.UserContext(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// RenewalSummary 查询套餐续费情况汇总。
|
||||
// GET /api/admin/operations-reports/package-renewal-summary
|
||||
func (h *OperationsReportHandler) RenewalSummary(c *fiber.Ctx) error {
|
||||
var req dto.OperationsRenewalSummaryRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐续费情况汇总参数不合法", &req, err))
|
||||
}
|
||||
result, err := h.query.RenewalSummary(c.UserContext(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// RenewalTrend 查询套餐续费情况日/月趋势。
|
||||
// GET /api/admin/operations-reports/package-renewal-trend
|
||||
func (h *OperationsReportHandler) RenewalTrend(c *fiber.Ctx) error {
|
||||
var req dto.OperationsRenewalTrendRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐续费情况趋势参数不合法", &req, err))
|
||||
}
|
||||
result, err := h.query.RenewalTrend(c.UserContext(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// ExportActivationSummary 创建设备激活情况报表异步导出任务。
|
||||
// POST /api/admin/operations-reports/activation-summary/export
|
||||
// 受控入口:创建时冻结筛选条件、操作者与可见店铺范围,非法时间在创建期拒绝。
|
||||
func (h *OperationsReportHandler) ExportActivationSummary(c *fiber.Ctx) error {
|
||||
request, err := h.parseExportRequest(c, constants.ExportTaskSceneOperationsActivation, domainreport.IsActivationDimension,
|
||||
"导出设备激活情况参数不合法")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.createExportTask(c, constants.ExportTaskSceneOperationsActivation, request)
|
||||
}
|
||||
|
||||
// ExportRenewalSummary 创建套餐续费情况报表异步导出任务。
|
||||
// POST /api/admin/operations-reports/package-renewal-summary/export
|
||||
// 受控入口:创建时冻结筛选条件、操作者与可见店铺范围,非法时间在创建期拒绝。
|
||||
func (h *OperationsReportHandler) ExportRenewalSummary(c *fiber.Ctx) error {
|
||||
request, err := h.parseExportRequest(c, constants.ExportTaskSceneOperationsRenewal, domainreport.IsRenewalDimension,
|
||||
"导出套餐续费情况参数不合法")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.createExportTask(c, constants.ExportTaskSceneOperationsRenewal, request)
|
||||
}
|
||||
|
||||
// createExportTask 复用既有导出任务创建路径:创建期冻结操作者、可见店铺范围与筛选快照。
|
||||
func (h *OperationsReportHandler) createExportTask(c *fiber.Ctx, scene string, request dto.ExportOperationsReportRequest) error {
|
||||
createRequest := dto.CreateExportTaskRequest{
|
||||
Scene: scene,
|
||||
Format: request.Format,
|
||||
Query: map[string]interface{}{"filters": exportOperationsReportFilters(request)},
|
||||
}
|
||||
result, err := h.exportService.CreateTask(c.UserContext(), &createRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// parseExportRequest 解析并校验受控导出请求。
|
||||
// 时间边界在创建期用统一严格解析器校验(格式非法或开始晚于结束一律拒绝),
|
||||
// 分组维度必须属于该报表支持的维度集合。
|
||||
func (h *OperationsReportHandler) parseExportRequest(c *fiber.Ctx, scene string,
|
||||
isDimension func(string) bool, message string) (dto.ExportOperationsReportRequest, error) {
|
||||
var request dto.ExportOperationsReportRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return request, errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
|
||||
}
|
||||
if err := h.validator.Struct(&request); err != nil {
|
||||
return request, errors.New(errors.CodeInvalidParam, validation.Message(message, &request, err))
|
||||
}
|
||||
if _, _, err := utils.ParseTimeRange(request.StartTime, request.EndTime); err != nil {
|
||||
return request, err
|
||||
}
|
||||
if request.GroupBy != "" && !isDimension(request.GroupBy) {
|
||||
return request, errors.New(errors.CodeInvalidParam, "不支持的分组维度 "+request.GroupBy)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// exportOperationsReportFilters 把导出请求转换为导出任务的筛选快照。
|
||||
// 时间边界按统一契约冻结为纯字符串值,创建期由导出任务服务规范化为 UTC RFC3339 秒级;
|
||||
// 分组维度恒定冻结(未选择时为空串),执行期不再重新解释请求。
|
||||
func exportOperationsReportFilters(request dto.ExportOperationsReportRequest) map[string]interface{} {
|
||||
filters := make(map[string]interface{}, 3)
|
||||
if request.StartTime != "" {
|
||||
filters["start_time"] = request.StartTime
|
||||
}
|
||||
if request.EndTime != "" {
|
||||
filters["end_time"] = request.EndTime
|
||||
}
|
||||
filters["group_by"] = request.GroupBy
|
||||
return filters
|
||||
}
|
||||
143
internal/infrastructure/operationsreport/snapshot_store.go
Normal file
143
internal/infrastructure/operationsreport/snapshot_store.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
// activationInsertBatchSize 是设备粒度激活行的批量写入行数。
|
||||
activationInsertBatchSize = 500
|
||||
// renewalInsertBatchSize 是到期事件粒度续费行的批量写入行数。
|
||||
renewalInsertBatchSize = 500
|
||||
)
|
||||
|
||||
// SnapshotStore 是运营报表日报快照的整日替换写入适配。
|
||||
//
|
||||
// 三张表都不设软删除列,因此删除是物理删除;整日替换与「快照日期唯一」不冲突。
|
||||
// 一切跨日比较都使用显式日期参数(snapshot_date = ?::date),不把 time.Time 直接与 DATE 列比较。
|
||||
type SnapshotStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSnapshotStore 创建运营报表日报快照写入适配。
|
||||
func NewSnapshotStore(db *gorm.DB) *SnapshotStore {
|
||||
return &SnapshotStore{db: db}
|
||||
}
|
||||
|
||||
// ReplaceDay 在单事务内先删除该日三张快照表的全部行,再整日写入头行与两类明细行,最后校验不变量。
|
||||
//
|
||||
// 幂等:同一日期重复执行的结果等于最后一次执行的结果,不产生重复行或第二套口径;
|
||||
// 任一步失败整体回滚,使该日不残留部分口径(失败交既有任务重试,重试沿用同一目标日期)。
|
||||
func (s *SnapshotStore) ReplaceDay(ctx context.Context, snapshotDate time.Time,
|
||||
snapshot model.OperationsReportSnapshot, activations []model.OperationsReportActivationRow,
|
||||
renewals []model.OperationsReportRenewalRow) error {
|
||||
day := domainreport.FormatSnapshotDay(snapshotDate)
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := deleteDay(ctx, tx, day); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&snapshot).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入运营报表快照头行失败")
|
||||
}
|
||||
if len(activations) > 0 {
|
||||
if err := tx.CreateInBatches(activations, activationInsertBatchSize).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入运营报表设备激活快照行失败")
|
||||
}
|
||||
}
|
||||
if len(renewals) > 0 {
|
||||
if err := tx.CreateInBatches(renewals, renewalInsertBatchSize).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入运营报表套餐续费快照行失败")
|
||||
}
|
||||
}
|
||||
return verifyDay(ctx, tx, day, snapshot)
|
||||
})
|
||||
}
|
||||
|
||||
// deleteDay 物理删除该日的三张快照表全部行(整日替换的前半段)。
|
||||
func deleteDay(ctx context.Context, tx *gorm.DB, day string) error {
|
||||
if err := tx.WithContext(ctx).
|
||||
Where("snapshot_date = ?::date", day).
|
||||
Delete(&model.OperationsReportSnapshot{}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "删除运营报表快照头行失败")
|
||||
}
|
||||
if err := tx.WithContext(ctx).
|
||||
Where("snapshot_date = ?::date", day).
|
||||
Delete(&model.OperationsReportActivationRow{}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "删除运营报表设备激活快照行失败")
|
||||
}
|
||||
if err := tx.WithContext(ctx).
|
||||
Where("snapshot_date = ?::date", day).
|
||||
Delete(&model.OperationsReportRenewalRow{}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "删除运营报表套餐续费快照行失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activationAggregate 是激活明细行的库内汇总,用于独立复核头行值。
|
||||
type activationAggregate struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// renewalAggregate 是续费明细行的库内去重汇总,用于独立复核头行值。
|
||||
type renewalAggregate struct {
|
||||
DueAssets int64 `gorm:"column:due_assets"`
|
||||
RenewedAssets int64 `gorm:"column:renewed_assets"`
|
||||
}
|
||||
|
||||
// verifyDay 在写入后从库里重新汇总,校验两条可验证不变量。
|
||||
//
|
||||
// ① 分组行之和等于头行值:结构化分组不改变总和,因此该不变量在 SQL 层等价于
|
||||
// 「全部明细行的指标之和等于头行值」,只要成立,任一受支持维度下的分组行之和必然等于头行值。
|
||||
// ② 续费资产数不大于到期资产数:续费资产集合是到期资产集合的子集,续费率不超过 100% 由构造保证。
|
||||
// 任一不变量不成立即返回错误,由外层事务整体回滚。
|
||||
func verifyDay(ctx context.Context, tx *gorm.DB, day string, snapshot model.OperationsReportSnapshot) error {
|
||||
var activation activationAggregate
|
||||
if err := tx.WithContext(ctx).Table("tb_operations_report_activation_row").
|
||||
Select(`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`).
|
||||
Where("snapshot_date = ?::date", day).
|
||||
Scan(&activation).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "复核运营报表设备激活快照行失败")
|
||||
}
|
||||
if activation.PurchasedDeviceCount != snapshot.PurchasedDeviceCount ||
|
||||
activation.ActivatedDeviceCount != snapshot.ActivatedDeviceCount ||
|
||||
activation.OnlineDeviceCount != snapshot.OnlineDeviceCount ||
|
||||
activation.ActiveDeviceCount != snapshot.ActiveDeviceCount ||
|
||||
domainreport.Round2(activation.TotalRealTrafficMB) != domainreport.Round2(snapshot.TotalRealTrafficMB) {
|
||||
return errors.New(errors.CodeInternalError,
|
||||
"运营报表快照头行与设备激活分组行不一致,本次生成已回滚")
|
||||
}
|
||||
|
||||
var renewal renewalAggregate
|
||||
if err := tx.WithContext(ctx).Table("tb_operations_report_renewal_row").
|
||||
Select(`COUNT(DISTINCT asset_type || ':' || asset_id) AS due_assets,
|
||||
COUNT(DISTINCT asset_type || ':' || asset_id) FILTER (WHERE renewed) AS renewed_assets`).
|
||||
Where("snapshot_date = ?::date", day).
|
||||
Scan(&renewal).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "复核运营报表套餐续费快照行失败")
|
||||
}
|
||||
if renewal.DueAssets != snapshot.RenewalDueAssetCount ||
|
||||
renewal.RenewedAssets != snapshot.RenewalRenewedAssetCount {
|
||||
return errors.New(errors.CodeInternalError,
|
||||
"运营报表快照头行与套餐续费分组行不一致,本次生成已回滚")
|
||||
}
|
||||
if renewal.RenewedAssets > renewal.DueAssets {
|
||||
return errors.New(errors.CodeInternalError,
|
||||
"运营报表快照的续费资产数超过到期资产数,本次生成已回滚")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
599
internal/infrastructure/operationsreport/source.go
Normal file
599
internal/infrastructure/operationsreport/source.go
Normal file
@@ -0,0 +1,599 @@
|
||||
// Package operationsreport 是运营报表日报快照的只读数据访问适配。
|
||||
//
|
||||
// 只做读取与投影:设备与设备属性、当前有效卡绑定、卡实名状态、套餐使用记录、
|
||||
// 店铺与业务员、业务用户组、套餐与套餐系列。所有口径为生成时刻的读数,
|
||||
// 生成后由快照行冻结,历史结果不随实时状态漂移。
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
applicationreport "github.com/break/junhong_cmp_fiber/internal/application/operationsreport"
|
||||
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// maxShopRootHops 是上溯店铺根节点的最大跳数,用于防御异常数据造成的环。
|
||||
const maxShopRootHops = 32
|
||||
|
||||
// Source 是运营报表日报快照的只读数据源。
|
||||
type Source struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSource 创建运营报表日报快照只读数据源。
|
||||
func NewSource(db *gorm.DB) *Source {
|
||||
return &Source{db: db}
|
||||
}
|
||||
|
||||
// asOfDevicePredicate 是「截至快照日」的设备谓词,采购数量与设备明细行必须使用同一谓词,
|
||||
// 否则「采购数量 = 明细行数」的不变量会立刻失败。
|
||||
//
|
||||
// as-of 维度是设备创建时间:created_at 早于快照日次日 00:00(上海自然日);
|
||||
// 删除维度按生成时刻判定(deleted_at IS NULL),即不重建「当日是否已删除」的历史——
|
||||
// 该限制已登记在 design 实施登记中。
|
||||
// tb_device.created_at 是 naive timestamp 列(仓库约定:naive 列存上海墙钟),
|
||||
// 因此边界以「yyyy-MM-dd 00:00:00」文本传入,避免 time.Time 被按 UTC 编码后差 8 小时。
|
||||
func asOfDevicePredicate(snapshotDate time.Time) (string, string) {
|
||||
day := domainreport.SnapshotDay(snapshotDate).AddDate(0, 0, 1)
|
||||
return "deleted_at IS NULL AND created_at < ?", day.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// CountUndeletedDevicesAsOf 返回截至快照日系统内未删除的设备数(采购数量)。
|
||||
//
|
||||
// 口径与 domain.PurchaseCountAsOf 一致:as-of 到快照日(创建时间早于快照日次日零点)且未删除。
|
||||
func (s *Source) CountUndeletedDevicesAsOf(ctx context.Context, snapshotDate time.Time) (int64, error) {
|
||||
condition, createdAtBound := asOfDevicePredicate(snapshotDate)
|
||||
var total int64
|
||||
if err := s.db.WithContext(ctx).Table("tb_device").
|
||||
Where(condition, createdAtBound).
|
||||
Count(&total).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计采购数量失败")
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// deviceRow 是设备事实投影。
|
||||
type deviceRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
VirtualNo string `gorm:"column:virtual_no"`
|
||||
DeviceName string `gorm:"column:device_name"`
|
||||
DeviceModel string `gorm:"column:device_model"`
|
||||
Manufacturer string `gorm:"column:manufacturer"`
|
||||
ShopID *uint `gorm:"column:shop_id"`
|
||||
}
|
||||
|
||||
// bindingRow 是当前有效关联卡投影,附带卡实名状态。
|
||||
type bindingRow struct {
|
||||
DeviceID uint `gorm:"column:device_id"`
|
||||
IotCardID uint `gorm:"column:iot_card_id"`
|
||||
RealNameStatus int `gorm:"column:real_name_status"`
|
||||
}
|
||||
|
||||
// usageAggregateRow 是当前有效使用记录按载体聚合的真流量与主套餐存在性。
|
||||
type usageAggregateRow struct {
|
||||
OwnerID uint `gorm:"column:owner_id"`
|
||||
TrafficMB float64 `gorm:"column:traffic_mb"`
|
||||
HasMainPkg bool `gorm:"column:has_main_pkg"`
|
||||
}
|
||||
|
||||
// shopRow 是店铺归属投影。
|
||||
type shopRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ShopName string `gorm:"column:shop_name"`
|
||||
ParentID *uint `gorm:"column:parent_id"`
|
||||
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
|
||||
}
|
||||
|
||||
// accountRow 是账号名称投影。
|
||||
type accountRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
|
||||
// groupRow 是业务用户组投影。
|
||||
type groupRow struct {
|
||||
AccountID uint `gorm:"column:account_id"`
|
||||
GroupID uint `gorm:"column:group_id"`
|
||||
GroupName string `gorm:"column:group_name"`
|
||||
}
|
||||
|
||||
// expiringUsageRow 是快照日到期的主套餐使用记录投影。
|
||||
type expiringUsageRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
PackageID uint `gorm:"column:package_id"`
|
||||
UsageType string `gorm:"column:usage_type"`
|
||||
IotCardID uint `gorm:"column:iot_card_id"`
|
||||
DeviceID uint `gorm:"column:device_id"`
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
SeriesID *uint `gorm:"column:series_id"`
|
||||
SeriesName string `gorm:"column:series_name"`
|
||||
DeviceVirtualNo string `gorm:"column:device_virtual_no"`
|
||||
CardICCID string `gorm:"column:card_iccid"`
|
||||
DeviceShopID *uint `gorm:"column:device_shop_id"`
|
||||
CardShopID *uint `gorm:"column:card_shop_id"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at"`
|
||||
}
|
||||
|
||||
// renewalCandidateRow 是同资产上参与续费判定的主套餐记录投影。
|
||||
type renewalCandidateRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
UsageType string `gorm:"column:usage_type"`
|
||||
IotCardID uint `gorm:"column:iot_card_id"`
|
||||
DeviceID uint `gorm:"column:device_id"`
|
||||
Status int `gorm:"column:status"`
|
||||
ActivatedAt *time.Time `gorm:"column:activated_at"`
|
||||
}
|
||||
|
||||
// LoadDayFacts 读取该快照日的设备激活事实与套餐续费事实。
|
||||
func (s *Source) LoadDayFacts(ctx context.Context, snapshotDate time.Time) (*applicationreport.DayFacts, error) {
|
||||
db := s.db.WithContext(ctx)
|
||||
day := domainreport.FormatSnapshotDay(snapshotDate)
|
||||
|
||||
devices, err := s.loadDevices(db, snapshotDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindings, err := s.loadCurrentCardBindings(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceUsages, err := s.loadUsageAggregates(db, constants.PackageUsageTypeDevice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cardUsages, err := s.loadUsageAggregates(db, constants.PackageUsageTypeSingleCard)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownership, err := s.loadOwnership(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expiringRows, err := s.loadExpiringUsages(db, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates, err := s.loadRenewalCandidates(db, expiringRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
facts := &applicationreport.DayFacts{
|
||||
Devices: make([]applicationreport.DeviceActivationFact, 0, len(devices)),
|
||||
Renewals: make([]applicationreport.RenewalFact, 0, len(expiringRows)),
|
||||
}
|
||||
for _, device := range devices {
|
||||
cardIDs := bindings.cardsOf(device.ID)
|
||||
realnamed := bindings.realnamedOf(device.ID)
|
||||
traffic, hasMain := deviceUsageTraffic(deviceUsages[device.ID])
|
||||
for _, cardID := range cardIDs {
|
||||
cardTraffic, cardHasMain := cardUsageTraffic(cardUsages[cardID])
|
||||
traffic += cardTraffic
|
||||
hasMain = hasMain || cardHasMain
|
||||
}
|
||||
attribute := ownership.describe(device.ShopID)
|
||||
facts.Devices = append(facts.Devices, applicationreport.DeviceActivationFact{
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceModel: device.DeviceModel,
|
||||
Manufacturer: device.Manufacturer,
|
||||
ShopID: device.ShopID,
|
||||
ShopName: attribute.ShopName,
|
||||
RootShopID: attribute.RootShopID,
|
||||
RootShopName: attribute.RootShopName,
|
||||
AgentAccountID: attribute.AgentAccountID,
|
||||
AgentAccountName: attribute.AgentAccountName,
|
||||
BusinessOwnerAccountID: attribute.BusinessOwnerAccountID,
|
||||
BusinessOwnerName: attribute.BusinessOwnerName,
|
||||
BusinessUserGroupID: attribute.BusinessUserGroupID,
|
||||
BusinessUserGroupName: attribute.BusinessUserGroupName,
|
||||
Purchased: true,
|
||||
Realnamed: realnamed,
|
||||
Online: realnamed && hasMain,
|
||||
Active: traffic > 0,
|
||||
RealTrafficMB: domainreport.Round2(traffic),
|
||||
})
|
||||
}
|
||||
|
||||
for _, row := range expiringRows {
|
||||
assetType, assetID, identifier, shopID := resolveRenewalAsset(row)
|
||||
if assetID == 0 {
|
||||
// 载体缺失(既无卡也无设备)的到期记录无法归属到资产,跳过而不是写入不可用的到期行。
|
||||
continue
|
||||
}
|
||||
renewed := domainreport.IsRenewed(
|
||||
domainreport.ExpiredMainUsage{
|
||||
UsageID: row.ID,
|
||||
AssetType: assetType,
|
||||
AssetID: assetID,
|
||||
ExpiresAt: row.ExpiresAt,
|
||||
},
|
||||
candidates[assetKey(row.UsageType, row.IotCardID, row.DeviceID)],
|
||||
)
|
||||
attribute := ownership.describe(shopID)
|
||||
facts.Renewals = append(facts.Renewals, applicationreport.RenewalFact{
|
||||
AssetType: assetType,
|
||||
AssetID: assetID,
|
||||
AssetIdentifier: identifier,
|
||||
ExpiredUsageID: row.ID,
|
||||
PackageID: row.PackageID,
|
||||
PackageName: row.PackageName,
|
||||
SeriesID: row.SeriesID,
|
||||
SeriesName: row.SeriesName,
|
||||
ShopID: shopID,
|
||||
ShopName: attribute.ShopName,
|
||||
RootShopID: attribute.RootShopID,
|
||||
RootShopName: attribute.RootShopName,
|
||||
AgentAccountID: attribute.AgentAccountID,
|
||||
AgentAccountName: attribute.AgentAccountName,
|
||||
BusinessOwnerAccountID: attribute.BusinessOwnerAccountID,
|
||||
BusinessOwnerName: attribute.BusinessOwnerName,
|
||||
BusinessUserGroupID: attribute.BusinessUserGroupID,
|
||||
BusinessUserGroupName: attribute.BusinessUserGroupName,
|
||||
Renewed: renewed,
|
||||
})
|
||||
}
|
||||
|
||||
return facts, nil
|
||||
}
|
||||
|
||||
// loadDevices 读取截至快照日未删除的设备属性事实,谓词与采购数量完全一致。
|
||||
func (s *Source) loadDevices(db *gorm.DB, snapshotDate time.Time) ([]deviceRow, error) {
|
||||
condition, createdAtBound := asOfDevicePredicate(snapshotDate)
|
||||
var rows []deviceRow
|
||||
if err := db.Table("tb_device").
|
||||
Select("id, virtual_no, device_name, device_model, manufacturer, shop_id").
|
||||
Where(condition, createdAtBound).
|
||||
Order("id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备事实失败")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// currentBindings 是设备的当前有效关联卡与「任一已实名」判定结果。
|
||||
type currentBindings struct {
|
||||
cards map[uint][]uint
|
||||
realnamed map[uint]bool
|
||||
}
|
||||
|
||||
func (b currentBindings) cardsOf(deviceID uint) []uint {
|
||||
return b.cards[deviceID]
|
||||
}
|
||||
|
||||
func (b currentBindings) realnamedOf(deviceID uint) bool {
|
||||
return b.realnamed[deviceID]
|
||||
}
|
||||
|
||||
// loadCurrentCardBindings 读取当前有效关联关系(bind_status=1 且未删除)与卡实名状态。
|
||||
// 实名判定采用完整口径:卡未删除且 real_name_status=1;设备多卡时任一命中即视为已实名,
|
||||
// 同一设备只计一次由调用方按设备汇总保证。
|
||||
func (s *Source) loadCurrentCardBindings(db *gorm.DB) (currentBindings, error) {
|
||||
var rows []bindingRow
|
||||
if err := db.Table("tb_device_sim_binding AS b").
|
||||
Select("b.device_id, b.iot_card_id, c.real_name_status").
|
||||
Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL").
|
||||
Where("b.bind_status = ? AND b.deleted_at IS NULL", constants.BindStatusBound).
|
||||
Order("b.device_id ASC, b.iot_card_id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return currentBindings{}, errors.Wrap(errors.CodeDatabaseError, err, "查询设备当前有效关联卡失败")
|
||||
}
|
||||
result := currentBindings{
|
||||
cards: make(map[uint][]uint, len(rows)),
|
||||
realnamed: make(map[uint]bool, len(rows)),
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.DeviceID == 0 || row.IotCardID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := result.cards[row.DeviceID]; !exists {
|
||||
result.cards[row.DeviceID] = make([]uint, 0, 2)
|
||||
}
|
||||
result.cards[row.DeviceID] = append(result.cards[row.DeviceID], row.IotCardID)
|
||||
if row.RealNameStatus == constants.RealNameStatusVerified {
|
||||
result.realnamed[row.DeviceID] = true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadUsageAggregates 按载体汇总当前有效使用记录的真已用量,并记录是否存在有效主套餐。
|
||||
//
|
||||
// 真流量权威列是 tb_package_usage.data_usage_mb(使用记录在当前重置周期内的真已用量)。
|
||||
// 禁止来源:tb_iot_card.data_usage_mb(卡级全生命周期累计)、current_month_usage_mb(自然月累计)、
|
||||
// last_gateway_reading_mb(运营商通道累计读数)、virtual_total_mb_snapshot 与 display_gain_ratio_snapshot
|
||||
// (虚量与展示量)——这些列一律不参与本查询。
|
||||
//
|
||||
// 有效使用记录口径:未删除、未退款、状态为生效中或已用完;
|
||||
// 主套餐存在性按同一集合内 master_usage_id IS NULL 判定。
|
||||
func (s *Source) loadUsageAggregates(db *gorm.DB, usageType string) (map[uint]usageAggregateRow, error) {
|
||||
var rows []usageAggregateRow
|
||||
ownerColumn := "device_id"
|
||||
if usageType == constants.PackageUsageTypeSingleCard {
|
||||
ownerColumn = "iot_card_id"
|
||||
}
|
||||
if err := db.Table("tb_package_usage").
|
||||
Select(ownerColumn+" AS owner_id, "+
|
||||
"COALESCE(SUM(data_usage_mb), 0)::float8 AS traffic_mb, "+
|
||||
"BOOL_OR(master_usage_id IS NULL) AS has_main_pkg").
|
||||
Where("usage_type = ? AND deleted_at IS NULL AND refund_id IS NULL AND status IN ?",
|
||||
usageType, domainreport.ValidMainPackageStatuses).
|
||||
Group(ownerColumn).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录真流量失败")
|
||||
}
|
||||
result := make(map[uint]usageAggregateRow, len(rows))
|
||||
for _, row := range rows {
|
||||
result[row.OwnerID] = row
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func deviceUsageTraffic(row usageAggregateRow) (float64, bool) {
|
||||
return row.TrafficMB, row.HasMainPkg
|
||||
}
|
||||
|
||||
func cardUsageTraffic(row usageAggregateRow) (float64, bool) {
|
||||
return row.TrafficMB, row.HasMainPkg
|
||||
}
|
||||
|
||||
// ownershipFacts 是生成时刻的归属解析结果(店铺、代理、业务员与用户组)。
|
||||
type ownershipFacts struct {
|
||||
shops map[uint]shopRow
|
||||
accounts map[uint]string
|
||||
agents map[uint]accountRow
|
||||
groups map[uint]groupRow
|
||||
rootCache map[uint]uint
|
||||
}
|
||||
|
||||
// ownershipAttribute 是一行快照冻结的归属取值。
|
||||
type ownershipAttribute struct {
|
||||
ShopName string
|
||||
RootShopID *uint
|
||||
RootShopName string
|
||||
AgentAccountID *uint
|
||||
AgentAccountName string
|
||||
BusinessOwnerAccountID *uint
|
||||
BusinessOwnerName string
|
||||
BusinessUserGroupID *uint
|
||||
BusinessUserGroupName string
|
||||
}
|
||||
|
||||
func (o *ownershipFacts) describe(shopID *uint) ownershipAttribute {
|
||||
if o == nil || shopID == nil {
|
||||
return ownershipAttribute{}
|
||||
}
|
||||
shop, ok := o.shops[*shopID]
|
||||
if !ok {
|
||||
return ownershipAttribute{}
|
||||
}
|
||||
attribute := ownershipAttribute{ShopName: shop.ShopName}
|
||||
if rootID, hasRoot := o.rootShopID(*shopID); hasRoot {
|
||||
rootIDCopy := rootID
|
||||
attribute.RootShopID = &rootIDCopy
|
||||
attribute.RootShopName = o.shops[rootID].ShopName
|
||||
// 代理维度同时冻结两个候选取值:一级代理店铺(上溯至根)与归属该店铺的代理账号。
|
||||
if agent, hasAgent := o.agents[rootID]; hasAgent {
|
||||
agentID := agent.ID
|
||||
attribute.AgentAccountID = &agentID
|
||||
attribute.AgentAccountName = agent.Username
|
||||
}
|
||||
}
|
||||
if shop.BusinessOwnerAccountID != nil {
|
||||
ownerID := *shop.BusinessOwnerAccountID
|
||||
attribute.BusinessOwnerAccountID = &ownerID
|
||||
attribute.BusinessOwnerName = o.accounts[ownerID]
|
||||
if group, hasGroup := o.groups[ownerID]; hasGroup {
|
||||
groupID := group.GroupID
|
||||
attribute.BusinessUserGroupID = &groupID
|
||||
attribute.BusinessUserGroupName = group.GroupName
|
||||
}
|
||||
}
|
||||
return attribute
|
||||
}
|
||||
|
||||
func (o *ownershipFacts) rootShopID(shopID uint) (uint, bool) {
|
||||
if root, ok := o.rootCache[shopID]; ok {
|
||||
return root, root != 0
|
||||
}
|
||||
current := shopID
|
||||
for hop := 0; hop < maxShopRootHops; hop++ {
|
||||
shop, ok := o.shops[current]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if shop.ParentID == nil || *shop.ParentID == 0 {
|
||||
o.rootCache[shopID] = current
|
||||
return current, true
|
||||
}
|
||||
next, ok := o.shops[*shop.ParentID]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
current = next.ID
|
||||
}
|
||||
// 超过最大跳数视为异常数据(疑似环),不做无界上溯。
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// loadOwnership 读取店铺、账号、代理账号与业务用户组归属事实。
|
||||
func (s *Source) loadOwnership(db *gorm.DB) (*ownershipFacts, error) {
|
||||
var shops []shopRow
|
||||
if err := db.Table("tb_shop").
|
||||
Select("id, shop_name, parent_id, business_owner_account_id").
|
||||
Where("deleted_at IS NULL").
|
||||
Scan(&shops).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺归属失败")
|
||||
}
|
||||
ownership := &ownershipFacts{
|
||||
shops: make(map[uint]shopRow, len(shops)),
|
||||
accounts: make(map[uint]string),
|
||||
agents: make(map[uint]accountRow),
|
||||
groups: make(map[uint]groupRow),
|
||||
rootCache: make(map[uint]uint),
|
||||
}
|
||||
ownerIDs := make([]uint, 0, len(shops))
|
||||
for _, shop := range shops {
|
||||
ownership.shops[shop.ID] = shop
|
||||
if shop.BusinessOwnerAccountID != nil && *shop.BusinessOwnerAccountID != 0 {
|
||||
ownerIDs = append(ownerIDs, *shop.BusinessOwnerAccountID)
|
||||
}
|
||||
}
|
||||
if len(ownerIDs) > 0 {
|
||||
var accounts []accountRow
|
||||
if err := db.Table("tb_account").
|
||||
Select("id, username").
|
||||
Where("deleted_at IS NULL AND id IN ?", ownerIDs).
|
||||
Scan(&accounts).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员账号名称失败")
|
||||
}
|
||||
for _, account := range accounts {
|
||||
ownership.accounts[account.ID] = account.Username
|
||||
}
|
||||
var groups []groupRow
|
||||
if err := db.Table("tb_business_user_group_member AS m").
|
||||
Select("m.account_id, g.id AS group_id, g.name AS group_name").
|
||||
Joins("JOIN tb_business_user_group AS g ON g.id = m.business_user_group_id AND g.deleted_at IS NULL").
|
||||
Where("m.deleted_at IS NULL AND m.account_id IN ?", ownerIDs).
|
||||
Scan(&groups).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败")
|
||||
}
|
||||
for _, group := range groups {
|
||||
ownership.groups[group.AccountID] = group
|
||||
}
|
||||
}
|
||||
|
||||
// 代理账号 = 归属该店铺的 user_type=3 账号;同一店铺多账号时取编号最小者,保证生成结果可复现。
|
||||
var agents []struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
ShopID uint `gorm:"column:shop_id"`
|
||||
}
|
||||
if err := db.Table("tb_account").
|
||||
Select("id, username, shop_id").
|
||||
Where("deleted_at IS NULL AND user_type = ? AND shop_id IS NOT NULL", constants.UserTypeAgent).
|
||||
Order("id ASC").
|
||||
Scan(&agents).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理账号失败")
|
||||
}
|
||||
for _, agent := range agents {
|
||||
if agent.ShopID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := ownership.agents[agent.ShopID]; exists {
|
||||
continue
|
||||
}
|
||||
ownership.agents[agent.ShopID] = accountRow{ID: agent.ID, Username: agent.Username}
|
||||
}
|
||||
return ownership, nil
|
||||
}
|
||||
|
||||
// loadExpiringUsages 读取快照日(上海自然日)到期的主套餐使用记录。
|
||||
// 到期事实口径:未退款主套餐(master_usage_id IS NULL 且 refund_id IS NULL、未删除)的到期日等于快照日,
|
||||
// 不按套餐状态过滤——已过期记录正是到期事实本身。
|
||||
func (s *Source) loadExpiringUsages(db *gorm.DB, day string) ([]expiringUsageRow, error) {
|
||||
var rows []expiringUsageRow
|
||||
if err := db.Table("tb_package_usage AS pu").
|
||||
Select(`pu.id, pu.package_id, pu.usage_type, pu.iot_card_id, pu.device_id,
|
||||
COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name,
|
||||
p.series_id,
|
||||
COALESCE(s.series_name, '') AS series_name,
|
||||
COALESCE(d.virtual_no, '') AS device_virtual_no,
|
||||
COALESCE(c.iccid, '') AS card_iccid,
|
||||
d.shop_id AS device_shop_id,
|
||||
c.shop_id AS card_shop_id,
|
||||
pu.expires_at`).
|
||||
Joins("LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_package_series AS s ON s.id = p.series_id AND s.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_device AS d ON d.id = pu.device_id AND d.deleted_at IS NULL").
|
||||
Joins("LEFT JOIN tb_iot_card AS c ON c.id = pu.iot_card_id AND c.deleted_at IS NULL").
|
||||
Where("pu.deleted_at IS NULL AND pu.refund_id IS NULL AND pu.master_usage_id IS NULL").
|
||||
Where("pu.expires_at IS NOT NULL").
|
||||
// tb_package_usage.expires_at 是 naive timestamp 列(仓库约定:naive 列存上海墙钟),
|
||||
// 直接按 date 比较即为上海自然日;不使用 AT TIME ZONE,避免把墙钟值当 UTC 再换算而差一天。
|
||||
Where("pu.expires_at::date = ?::date", day).
|
||||
Order("pu.id ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询到期主套餐使用记录失败")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// loadRenewalCandidates 批量读取到期资产上的全部未退款主套餐记录,供续费判定使用。
|
||||
func (s *Source) loadRenewalCandidates(db *gorm.DB, expiring []expiringUsageRow) (map[string][]domainreport.MainUsageCandidate, error) {
|
||||
result := make(map[string][]domainreport.MainUsageCandidate)
|
||||
if len(expiring) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
deviceIDs := make([]uint, 0, len(expiring))
|
||||
cardIDs := make([]uint, 0, len(expiring))
|
||||
seenDevice := make(map[uint]struct{}, len(expiring))
|
||||
seenCard := make(map[uint]struct{}, len(expiring))
|
||||
for _, row := range expiring {
|
||||
if row.UsageType == constants.PackageUsageTypeDevice && row.DeviceID != 0 {
|
||||
if _, exists := seenDevice[row.DeviceID]; !exists {
|
||||
seenDevice[row.DeviceID] = struct{}{}
|
||||
deviceIDs = append(deviceIDs, row.DeviceID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if row.UsageType == constants.PackageUsageTypeSingleCard && row.IotCardID != 0 {
|
||||
if _, exists := seenCard[row.IotCardID]; !exists {
|
||||
seenCard[row.IotCardID] = struct{}{}
|
||||
cardIDs = append(cardIDs, row.IotCardID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(deviceIDs) == 0 && len(cardIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []renewalCandidateRow
|
||||
query := db.Table("tb_package_usage").
|
||||
Select("id, usage_type, iot_card_id, device_id, status, activated_at").
|
||||
Where("deleted_at IS NULL AND refund_id IS NULL AND master_usage_id IS NULL")
|
||||
switch {
|
||||
case len(deviceIDs) > 0 && len(cardIDs) > 0:
|
||||
query = query.Where("(usage_type = ? AND device_id IN ?) OR (usage_type = ? AND iot_card_id IN ?)",
|
||||
constants.PackageUsageTypeDevice, deviceIDs,
|
||||
constants.PackageUsageTypeSingleCard, cardIDs)
|
||||
case len(deviceIDs) > 0:
|
||||
query = query.Where("usage_type = ? AND device_id IN ?", constants.PackageUsageTypeDevice, deviceIDs)
|
||||
default:
|
||||
query = query.Where("usage_type = ? AND iot_card_id IN ?", constants.PackageUsageTypeSingleCard, cardIDs)
|
||||
}
|
||||
if err := query.Order("id ASC").Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询续费候选主套餐记录失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
key := assetKey(row.UsageType, row.IotCardID, row.DeviceID)
|
||||
result[key] = append(result[key], domainreport.MainUsageCandidate{
|
||||
UsageID: row.ID,
|
||||
Status: row.Status,
|
||||
ActivatedAt: row.ActivatedAt,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// assetKey 生成续费判定使用的资产键(载体类型 + 载体 ID)。
|
||||
func assetKey(usageType string, iotCardID, deviceID uint) string {
|
||||
if usageType == constants.PackageUsageTypeDevice {
|
||||
return constants.AssetTypeDevice + ":" + strconv.FormatUint(uint64(deviceID), 10)
|
||||
}
|
||||
return constants.PackageUsageTypeSingleCard + ":" + strconv.FormatUint(uint64(iotCardID), 10)
|
||||
}
|
||||
|
||||
// resolveRenewalAsset 把到期使用记录映射为资产类型、资产ID、资产标识与资产所属店铺。
|
||||
// 设备资产取设备虚拟号与设备店铺,单卡资产取 ICCID 与卡店铺。
|
||||
func resolveRenewalAsset(row expiringUsageRow) (string, uint, string, *uint) {
|
||||
if row.UsageType == constants.PackageUsageTypeDevice {
|
||||
return constants.AssetTypeDevice, row.DeviceID, row.DeviceVirtualNo, row.DeviceShopID
|
||||
}
|
||||
return constants.PackageUsageTypeSingleCard, row.IotCardID, row.CardICCID, row.CardShopID
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
// CreateExportTaskRequest 创建导出任务请求。
|
||||
type CreateExportTaskRequest struct {
|
||||
Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产)"`
|
||||
Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset operations_activation operations_renewal" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表)"`
|
||||
Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"`
|
||||
Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象,可选);时间筛选固定使用 filters.start_time 与 filters.end_time,取值必须为带显式时区的 RFC3339 秒级时间"`
|
||||
}
|
||||
@@ -22,7 +22,7 @@ type CreateExportTaskResponse struct {
|
||||
type ListExportTaskRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产)"`
|
||||
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset operations_activation operations_renewal" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
|
||||
StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"`
|
||||
EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00)"`
|
||||
@@ -33,7 +33,7 @@ type ExportTaskItem struct {
|
||||
ID uint `json:"id" description:"任务ID"`
|
||||
TaskID uint `json:"task_id" description:"任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警)"`
|
||||
Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表)"`
|
||||
Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"`
|
||||
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
|
||||
StatusName string `json:"status_name" description:"任务状态名称(中文)"`
|
||||
|
||||
120
internal/model/dto/operations_report_dto.go
Normal file
120
internal/model/dto/operations_report_dto.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package dto
|
||||
|
||||
// OperationsActivationSummaryRequest 是设备激活情况汇总查询请求。
|
||||
// 时间筛选只接受带显式时区的 RFC3339 秒级时间与闭区间;快照日期按「零点落入区间」判定。
|
||||
type OperationsActivationSummaryRequest struct {
|
||||
StartTime string `json:"start_time" query:"start_time" description:"快照日期起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"`
|
||||
EndTime string `json:"end_time" query:"end_time" description:"快照日期结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"`
|
||||
GroupBy string `json:"group_by" query:"group_by" validate:"omitempty,oneof=device_name device_model manufacturer business_user_group agent shop business_owner" description:"分组维度 (device_name:设备名称, device_model:设备型号, manufacturer:制造商, business_user_group:用户组, agent:代理, shop:店铺, business_owner:业务员);不传则汇总为一行「全部」"`
|
||||
}
|
||||
|
||||
// OperationsActivationTrendRequest 是设备激活情况趋势查询请求。
|
||||
type OperationsActivationTrendRequest struct {
|
||||
StartTime string `json:"start_time" query:"start_time" description:"快照日期起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"`
|
||||
EndTime string `json:"end_time" query:"end_time" description:"快照日期结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T00:00:00+08:00)"`
|
||||
Granularity string `json:"granularity" query:"granularity" validate:"omitempty,oneof=day month" description:"趋势粒度 (day:按日, month:按月);不传按日"`
|
||||
GroupBy string `json:"group_by" query:"group_by" validate:"omitempty,oneof=device_name device_model manufacturer business_user_group agent shop business_owner" description:"分组维度;不传则每个期返回一行「全部」"`
|
||||
}
|
||||
|
||||
// OperationsRenewalSummaryRequest 是套餐续费情况汇总查询请求。
|
||||
type OperationsRenewalSummaryRequest struct {
|
||||
StartTime string `json:"start_time" query:"start_time" description:"快照日期起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"`
|
||||
EndTime string `json:"end_time" query:"end_time" description:"快照日期结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"`
|
||||
GroupBy string `json:"group_by" query:"group_by" validate:"omitempty,oneof=package_series package_name business_user_group agent shop business_owner" description:"分组维度 (package_series:套餐系列, package_name:套餐名称, business_user_group:用户组, agent:代理, shop:店铺, business_owner:业务员);不传则汇总为一行「全部」"`
|
||||
}
|
||||
|
||||
// OperationsRenewalTrendRequest 是套餐续费情况趋势查询请求。
|
||||
type OperationsRenewalTrendRequest struct {
|
||||
StartTime string `json:"start_time" query:"start_time" description:"快照日期起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"`
|
||||
EndTime string `json:"end_time" query:"end_time" description:"快照日期结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T00:00:00+08:00)"`
|
||||
Granularity string `json:"granularity" query:"granularity" validate:"omitempty,oneof=day month" description:"趋势粒度 (day:按日, month:按月);不传按日"`
|
||||
GroupBy string `json:"group_by" query:"group_by" validate:"omitempty,oneof=package_series package_name business_user_group agent shop business_owner" description:"分组维度;不传则每个期返回一行「全部」"`
|
||||
}
|
||||
|
||||
// OperationsActivationSummaryItem 是设备激活情况的一行指标。
|
||||
// 累计类与其派生指标取所选结束日的快照;结束日无快照时全部为空值(不回退更早快照)。
|
||||
// 分母为零的比率与卡均为空值;新增激活数允许为负,不设零下限。
|
||||
type OperationsActivationSummaryItem struct {
|
||||
GroupValue string `json:"group_value" description:"分组列值(未选择分组维度时为「全部」)"`
|
||||
PurchasedDeviceCount *int64 `json:"purchased_device_count" description:"采购数量:截至快照日系统内未删除的设备数"`
|
||||
ActivatedDeviceCount *int64 `json:"activated_device_count" description:"累计激活数:任一当前有效关联卡已实名的设备数"`
|
||||
ActivationRate *float64 `json:"activation_rate" description:"激活率:累计激活数 / 采购数量,保留两位小数;采购数量为零时为空"`
|
||||
NewActivatedDeviceCount *int64 `json:"new_activated_device_count" description:"新增激活数:结束日累计减去基期累计,允许为负"`
|
||||
OnlineDeviceCount *int64 `json:"online_device_count" description:"累计在网数:已实名且存在有效主套餐的设备数"`
|
||||
ActiveDeviceCount *int64 `json:"active_device_count" description:"活跃用户数:真流量合计大于零的设备数"`
|
||||
TotalRealTrafficGB *float64 `json:"total_real_traffic_gb" description:"累计用量(GB):按 1 GB = 1024 MB 折算,保留两位小数"`
|
||||
PerUserAverageGB *float64 `json:"per_user_average_gb" description:"单用户卡均(GB):累计用量 / 累计在网数,保留两位小数;在网数为零时为空"`
|
||||
ForecastAverageIncludingZeroGB *float64 `json:"forecast_average_including_zero_gb" description:"含零预测卡均(GB):按累计在网数为分母、结束日所在当月年化"`
|
||||
ForecastAverageExcludingZeroGB *float64 `json:"forecast_average_excluding_zero_gb" description:"不含零预测卡均(GB):按活跃用户数为分母、结束日所在当月年化"`
|
||||
}
|
||||
|
||||
// OperationsActivationSummaryResponse 是设备激活情况汇总响应。
|
||||
// HasSnapshot 表示所选结束日是否存在快照;SnapshotDates 是区间内实际命中的快照日期集合。
|
||||
type OperationsActivationSummaryResponse struct {
|
||||
HasSnapshot bool `json:"has_snapshot" description:"结束日是否存在快照;为 false 时累计类与派生指标为空且分组行为空集"`
|
||||
SnapshotDates []string `json:"snapshot_dates" description:"区间内实际命中的快照日期(yyyy-MM-dd)"`
|
||||
GroupBy string `json:"group_by" description:"分组维度编码;空表示未分组"`
|
||||
GroupName string `json:"group_name" description:"分组维度中文名;未分组时为「全部」"`
|
||||
Totals *OperationsActivationSummaryItem `json:"totals" description:"头行合计;结束日无快照时为空"`
|
||||
Items []OperationsActivationSummaryItem `json:"items" description:"分组行;未选择分组维度时只有一行「全部」"`
|
||||
}
|
||||
|
||||
// OperationsActivationTrendPoint 是设备激活情况趋势的一个期点。
|
||||
// 累计类指标取该期最后一个有快照日的快照值;新增激活数取相邻期同口径之差,任一侧无快照时为空。
|
||||
type OperationsActivationTrendPoint struct {
|
||||
Period string `json:"period" description:"期标识(按日为 yyyy-MM-dd,按月为 yyyy-MM)"`
|
||||
OperationsActivationSummaryItem
|
||||
}
|
||||
|
||||
// OperationsActivationTrendResponse 是设备激活情况趋势响应。
|
||||
// 无快照的期不出现;后端只返回数据,图表渲染由前端负责。
|
||||
type OperationsActivationTrendResponse struct {
|
||||
Granularity string `json:"granularity" description:"趋势粒度 (day|month)"`
|
||||
GroupBy string `json:"group_by" description:"分组维度编码;空表示未分组"`
|
||||
GroupName string `json:"group_name" description:"分组维度中文名;未分组时为「全部」"`
|
||||
Points []OperationsActivationTrendPoint `json:"points" description:"趋势点;无快照的期不出现"`
|
||||
}
|
||||
|
||||
// OperationsRenewalSummaryItem 是套餐续费情况的一行指标。
|
||||
// 到期与续费均按资产去重,续费资产恒为到期资产的子集,续费率不超过 100% 由构造保证。
|
||||
type OperationsRenewalSummaryItem struct {
|
||||
GroupValue string `json:"group_value" description:"分组列值(未选择分组维度时为「全部」)"`
|
||||
DueAssetCount *int64 `json:"due_asset_count" description:"到期资产数:未退款主套餐到期日为统计期的资产数(按资产去重)"`
|
||||
RenewedAssetCount *int64 `json:"renewed_asset_count" description:"续费资产数:到期资产中已续费的资产数(按资产去重)"`
|
||||
RenewalRate *float64 `json:"renewal_rate" description:"续费率:续费资产数 / 到期资产数,保留两位小数;到期数为零时为空"`
|
||||
NewUnrenewedAssetCount *int64 `json:"new_unrenewed_asset_count" description:"新增未续费数:到期资产数减续费资产数,不小于零"`
|
||||
}
|
||||
|
||||
// OperationsRenewalSummaryResponse 是套餐续费情况汇总响应。
|
||||
type OperationsRenewalSummaryResponse struct {
|
||||
HasSnapshot bool `json:"has_snapshot" description:"结束日是否存在快照;为 false 时全部指标为空且分组行为空集"`
|
||||
SnapshotDates []string `json:"snapshot_dates" description:"区间内实际命中的快照日期(yyyy-MM-dd)"`
|
||||
GroupBy string `json:"group_by" description:"分组维度编码;空表示未分组"`
|
||||
GroupName string `json:"group_name" description:"分组维度中文名;未分组时为「全部」"`
|
||||
Totals *OperationsRenewalSummaryItem `json:"totals" description:"头行合计;结束日无快照时为空"`
|
||||
Items []OperationsRenewalSummaryItem `json:"items" description:"分组行;未选择分组维度时只有一行「全部」"`
|
||||
}
|
||||
|
||||
// OperationsRenewalTrendPoint 是套餐续费情况趋势的一个期点。
|
||||
// 到期与续费都是期内流式指标:期内同一资产多次到期或多条续费各计一次。
|
||||
type OperationsRenewalTrendPoint struct {
|
||||
Period string `json:"period" description:"期标识(按日为 yyyy-MM-dd,按月为 yyyy-MM)"`
|
||||
OperationsRenewalSummaryItem
|
||||
}
|
||||
|
||||
// OperationsRenewalTrendResponse 是套餐续费情况趋势响应。
|
||||
type OperationsRenewalTrendResponse struct {
|
||||
Granularity string `json:"granularity" description:"趋势粒度 (day|month)"`
|
||||
GroupBy string `json:"group_by" description:"分组维度编码;空表示未分组"`
|
||||
GroupName string `json:"group_name" description:"分组维度中文名;未分组时为「全部」"`
|
||||
Points []OperationsRenewalTrendPoint `json:"points" description:"趋势点;无快照的期不出现"`
|
||||
}
|
||||
|
||||
// ExportOperationsReportRequest 是设备激活情况与套餐续费情况报表的受控导出请求。
|
||||
// 时间边界在创建期冻结为 UTC RFC3339 秒级字符串,执行期只按冻结值严格解析。
|
||||
type ExportOperationsReportRequest struct {
|
||||
Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"`
|
||||
StartTime string `json:"start_time" description:"快照日期起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)"`
|
||||
EndTime string `json:"end_time" description:"快照日期结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T00:00:00+08:00)"`
|
||||
GroupBy string `json:"group_by" description:"分组维度编码;不传则导出唯一一行「全部」"`
|
||||
}
|
||||
97
internal/model/operations_report.go
Normal file
97
internal/model/operations_report.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// OperationsReportSnapshot 运营报表日级快照头行。
|
||||
//
|
||||
// 一个上海自然日一行,同时承担三件事:①「该日是否有快照」的唯一判定;
|
||||
// ②日/月趋势与新增指标的序列来源(O(1) 读取,不必扫明细行);
|
||||
// ③合计行与分组行不变量校验的权威值。整日幂等替换,因此不设软删除列。
|
||||
type OperationsReportSnapshot struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
SnapshotDate time.Time `gorm:"column:snapshot_date;type:date;not null" json:"snapshot_date"`
|
||||
PurchasedDeviceCount int64 `gorm:"column:purchased_device_count;type:bigint;not null;default:0" json:"purchased_device_count"`
|
||||
ActivatedDeviceCount int64 `gorm:"column:activated_device_count;type:bigint;not null;default:0" json:"activated_device_count"`
|
||||
OnlineDeviceCount int64 `gorm:"column:online_device_count;type:bigint;not null;default:0" json:"online_device_count"`
|
||||
ActiveDeviceCount int64 `gorm:"column:active_device_count;type:bigint;not null;default:0" json:"active_device_count"`
|
||||
TotalRealTrafficMB float64 `gorm:"column:total_real_traffic_mb;type:numeric(20,2);not null;default:0" json:"total_real_traffic_mb"`
|
||||
RenewalDueAssetCount int64 `gorm:"column:renewal_due_asset_count;type:bigint;not null;default:0" json:"renewal_due_asset_count"`
|
||||
RenewalRenewedAssetCount int64 `gorm:"column:renewal_renewed_asset_count;type:bigint;not null;default:0" json:"renewal_renewed_asset_count"`
|
||||
GeneratedAt time.Time `gorm:"column:generated_at;type:timestamp;not null" json:"generated_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定运营报表日级快照头行表名。
|
||||
func (OperationsReportSnapshot) TableName() string {
|
||||
return "tb_operations_report_snapshot"
|
||||
}
|
||||
|
||||
// OperationsReportActivationRow 运营报表设备激活快照行。
|
||||
//
|
||||
// 设备粒度,一行对应一台未删除设备,冻结生成时刻的设备属性、归属与四项指标。
|
||||
// 归属(店铺、业务员、用户组与代理的两个取值)是一次性冻结值,不因后续变化被改写。
|
||||
type OperationsReportActivationRow struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
SnapshotDate time.Time `gorm:"column:snapshot_date;type:date;not null" json:"snapshot_date"`
|
||||
DeviceID uint `gorm:"column:device_id;type:bigint;not null" json:"device_id"`
|
||||
VirtualNo string `gorm:"column:virtual_no;type:varchar(100);not null;default:''" json:"virtual_no"`
|
||||
DeviceName string `gorm:"column:device_name;type:varchar(255);not null;default:''" json:"device_name"`
|
||||
DeviceModel string `gorm:"column:device_model;type:varchar(100);not null;default:''" json:"device_model"`
|
||||
Manufacturer string `gorm:"column:manufacturer;type:varchar(255);not null;default:''" json:"manufacturer"`
|
||||
ShopID *uint `gorm:"column:shop_id;type:bigint" json:"shop_id,omitempty"`
|
||||
ShopName string `gorm:"column:shop_name;type:varchar(100);not null;default:''" json:"shop_name"`
|
||||
RootShopID *uint `gorm:"column:root_shop_id;type:bigint" json:"root_shop_id,omitempty"`
|
||||
RootShopName string `gorm:"column:root_shop_name;type:varchar(100);not null;default:''" json:"root_shop_name"`
|
||||
AgentAccountID *uint `gorm:"column:agent_account_id;type:bigint" json:"agent_account_id,omitempty"`
|
||||
AgentAccountName string `gorm:"column:agent_account_name;type:varchar(255);not null;default:''" json:"agent_account_name"`
|
||||
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id;type:bigint" json:"business_owner_account_id,omitempty"`
|
||||
BusinessOwnerName string `gorm:"column:business_owner_name;type:varchar(255);not null;default:''" json:"business_owner_name"`
|
||||
BusinessUserGroupID *uint `gorm:"column:business_user_group_id;type:bigint" json:"business_user_group_id,omitempty"`
|
||||
BusinessUserGroupName string `gorm:"column:business_user_group_name;type:varchar(100);not null;default:''" json:"business_user_group_name"`
|
||||
Purchased bool `gorm:"column:purchased;type:boolean;not null;default:false" json:"purchased"`
|
||||
Realnamed bool `gorm:"column:realnamed;type:boolean;not null;default:false" json:"realnamed"`
|
||||
Online bool `gorm:"column:online;type:boolean;not null;default:false" json:"online"`
|
||||
Active bool `gorm:"column:active;type:boolean;not null;default:false" json:"active"`
|
||||
RealTrafficMB float64 `gorm:"column:real_traffic_mb;type:numeric(20,2);not null;default:0" json:"real_traffic_mb"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定运营报表设备激活快照行表名。
|
||||
func (OperationsReportActivationRow) TableName() string {
|
||||
return "tb_operations_report_activation_row"
|
||||
}
|
||||
|
||||
// OperationsReportRenewalRow 运营报表套餐续费快照行。
|
||||
//
|
||||
// 到期事件粒度,一行对应一条在快照日到期的主套餐使用记录;
|
||||
// 「续费」是该到期资产在生成时刻是否已存在另一条未退款主套餐的事实,因此挂在到期行上。
|
||||
type OperationsReportRenewalRow struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
SnapshotDate time.Time `gorm:"column:snapshot_date;type:date;not null" json:"snapshot_date"`
|
||||
AssetType string `gorm:"column:asset_type;type:varchar(20);not null" json:"asset_type"`
|
||||
AssetID uint `gorm:"column:asset_id;type:bigint;not null" json:"asset_id"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);not null;default:''" json:"asset_identifier"`
|
||||
ExpiredUsageID uint `gorm:"column:expired_usage_id;type:bigint;not null" json:"expired_usage_id"`
|
||||
PackageID uint `gorm:"column:package_id;type:bigint;not null" json:"package_id"`
|
||||
PackageName string `gorm:"column:package_name;type:varchar(255);not null;default:''" json:"package_name"`
|
||||
SeriesID *uint `gorm:"column:series_id;type:bigint" json:"series_id,omitempty"`
|
||||
SeriesName string `gorm:"column:series_name;type:varchar(255);not null;default:''" json:"series_name"`
|
||||
ShopID *uint `gorm:"column:shop_id;type:bigint" json:"shop_id,omitempty"`
|
||||
ShopName string `gorm:"column:shop_name;type:varchar(100);not null;default:''" json:"shop_name"`
|
||||
RootShopID *uint `gorm:"column:root_shop_id;type:bigint" json:"root_shop_id,omitempty"`
|
||||
RootShopName string `gorm:"column:root_shop_name;type:varchar(100);not null;default:''" json:"root_shop_name"`
|
||||
AgentAccountID *uint `gorm:"column:agent_account_id;type:bigint" json:"agent_account_id,omitempty"`
|
||||
AgentAccountName string `gorm:"column:agent_account_name;type:varchar(255);not null;default:''" json:"agent_account_name"`
|
||||
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id;type:bigint" json:"business_owner_account_id,omitempty"`
|
||||
BusinessOwnerName string `gorm:"column:business_owner_name;type:varchar(255);not null;default:''" json:"business_owner_name"`
|
||||
BusinessUserGroupID *uint `gorm:"column:business_user_group_id;type:bigint" json:"business_user_group_id,omitempty"`
|
||||
BusinessUserGroupName string `gorm:"column:business_user_group_name;type:varchar(100);not null;default:''" json:"business_user_group_name"`
|
||||
Renewed bool `gorm:"column:renewed;type:boolean;not null;default:false" json:"renewed"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定运营报表套餐续费快照行表名。
|
||||
func (OperationsReportRenewalRow) TableName() string {
|
||||
return "tb_operations_report_renewal_row"
|
||||
}
|
||||
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')"
|
||||
}
|
||||
141
internal/query/operationsreport/periods.go
Normal file
141
internal/query/operationsreport/periods.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
|
||||
)
|
||||
|
||||
// trendPeriod 是趋势的一个期:期标识、期末快照日与(按月粒度时)期内全部快照日。
|
||||
type trendPeriod struct {
|
||||
Key string
|
||||
RepresentativeDay time.Time
|
||||
Days []time.Time
|
||||
}
|
||||
|
||||
// activationPeriods 返回激活情况趋势的期序列(升序)。
|
||||
//
|
||||
// 按日粒度的期是该区间内的每个快照日;按月粒度的期是每个自然月,期末取该月最后一个快照日。
|
||||
// 无快照的期不出现在结果中。
|
||||
func (q *Query) activationPeriods(ctx context.Context, granularity string, start, end *time.Time) ([]trendPeriod, error) {
|
||||
if granularity == domainreport.GranularityDay {
|
||||
days, err := q.matchedDays(ctx, lowerDay(start), upperDay(end))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
periods := make([]trendPeriod, 0, len(days))
|
||||
for _, day := range days {
|
||||
periods = append(periods, trendPeriod{
|
||||
Key: domainreport.FormatPeriod(granularity, day),
|
||||
RepresentativeDay: day,
|
||||
Days: []time.Time{day},
|
||||
})
|
||||
}
|
||||
return periods, nil
|
||||
}
|
||||
lower, upper := monthSpan(start, end)
|
||||
days, err := q.matchedDays(ctx, lower, upper)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groupMonthPeriods(granularity, days, lowerDay(start), upperDay(end)), nil
|
||||
}
|
||||
|
||||
// previousPeriodRepresentatives 返回每个期的前一期期末快照日;前一期无快照的期不出现在结果中。
|
||||
//
|
||||
// 前一期指日历上的上一自然日或上一自然月,即使它早于请求区间起点也必须读取:
|
||||
// 新增类指标按「期末 − 前一期期末」计算,任一侧无快照时为空。
|
||||
func (q *Query) previousPeriodRepresentatives(ctx context.Context, granularity string,
|
||||
periods []trendPeriod) (map[string]time.Time, error) {
|
||||
result := make(map[string]time.Time, len(periods))
|
||||
if len(periods) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
earliest := domainreport.PeriodOf(granularity, periods[0].RepresentativeDay)
|
||||
for _, period := range periods {
|
||||
start := domainreport.PeriodOf(granularity, period.RepresentativeDay)
|
||||
if start.Before(earliest) {
|
||||
earliest = start
|
||||
}
|
||||
}
|
||||
searchFrom := domainreport.PreviousPeriodStart(granularity, earliest)
|
||||
days, err := q.matchedDays(ctx, &searchFrom, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, period := range periods {
|
||||
periodStart := domainreport.PeriodOf(granularity, period.RepresentativeDay)
|
||||
previousStart := domainreport.PreviousPeriodStart(granularity, periodStart)
|
||||
previousEnd := previousStart
|
||||
if granularity == domainreport.GranularityMonth {
|
||||
previousEnd = endOfMonth(previousStart)
|
||||
}
|
||||
if representative, ok := lastDayWithin(days, previousStart, previousEnd); ok {
|
||||
result[period.Key] = representative
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// monthSpan 把区间换算为覆盖整月的日期范围,供按月趋势读取所需的快照日集合。
|
||||
func monthSpan(start, end *time.Time) (*time.Time, *time.Time) {
|
||||
var lower, upper *time.Time
|
||||
if start != nil {
|
||||
day := domainreport.LowerBoundDay(*start)
|
||||
first := domainreport.PeriodOf(domainreport.GranularityMonth, day)
|
||||
lower = &first
|
||||
}
|
||||
if end != nil {
|
||||
day := domainreport.UpperBoundDay(*end)
|
||||
last := endOfMonth(domainreport.PeriodOf(domainreport.GranularityMonth, day))
|
||||
upper = &last
|
||||
}
|
||||
return lower, upper
|
||||
}
|
||||
|
||||
// groupMonthPeriods 把快照日按月聚合为趋势期。
|
||||
// 期末快照日落在请求区间之外的月份不作为期出现(其快照日仅用于推断前一期)。
|
||||
func groupMonthPeriods(granularity string, days []time.Time, lower, upper *time.Time) []trendPeriod {
|
||||
order := make([]string, 0, len(days))
|
||||
buckets := make(map[string][]time.Time, len(days))
|
||||
for _, day := range days {
|
||||
key := domainreport.FormatPeriod(granularity, day)
|
||||
if _, exists := buckets[key]; !exists {
|
||||
order = append(order, key)
|
||||
}
|
||||
buckets[key] = append(buckets[key], day)
|
||||
}
|
||||
periods := make([]trendPeriod, 0, len(order))
|
||||
for _, key := range order {
|
||||
bucket := buckets[key]
|
||||
representative := bucket[len(bucket)-1]
|
||||
if lower != nil && representative.Before(*lower) {
|
||||
continue
|
||||
}
|
||||
if upper != nil && representative.After(*upper) {
|
||||
continue
|
||||
}
|
||||
periods = append(periods, trendPeriod{Key: key, RepresentativeDay: representative, Days: bucket})
|
||||
}
|
||||
return periods
|
||||
}
|
||||
|
||||
// endOfMonth 返回该月最后一天(上海自然日)。
|
||||
func endOfMonth(monthStart time.Time) time.Time {
|
||||
return time.Date(monthStart.Year(), monthStart.Month()+1, 0, 0, 0, 0, 0, domainreport.ShanghaiLocation())
|
||||
}
|
||||
|
||||
// lastDayWithin 返回闭区间内最后一个存在的快照日。
|
||||
func lastDayWithin(days []time.Time, from, to time.Time) (time.Time, bool) {
|
||||
var found time.Time
|
||||
ok := false
|
||||
for _, day := range days {
|
||||
if day.Before(from) || day.After(to) {
|
||||
continue
|
||||
}
|
||||
found = day
|
||||
ok = true
|
||||
}
|
||||
return found, ok
|
||||
}
|
||||
550
internal/query/operationsreport/query.go
Normal file
550
internal/query/operationsreport/query.go
Normal file
@@ -0,0 +1,550 @@
|
||||
// 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 })
|
||||
}
|
||||
@@ -109,6 +109,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
||||
if handlers.AssetAutoRenewal != nil {
|
||||
registerAssetAutoRenewalRoutes(authGroup, handlers.AssetAutoRenewal, doc, basePath)
|
||||
}
|
||||
if handlers.OperationsReport != nil {
|
||||
registerOperationsReportRoutes(authGroup, handlers.OperationsReport, doc, basePath)
|
||||
}
|
||||
if handlers.ShopPackageBatchAllocation != nil {
|
||||
registerShopPackageBatchAllocationRoutes(authGroup, handlers.ShopPackageBatchAllocation, doc, basePath)
|
||||
}
|
||||
|
||||
84
internal/routes/operations_report.go
Normal file
84
internal/routes/operations_report.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"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/openapi"
|
||||
)
|
||||
|
||||
// registerOperationsReportRoutes 注册运营报表(设备激活情况与套餐续费情况)的查询与受控导出路由。
|
||||
//
|
||||
// 沿用超管/平台路由组级 gate 先例:代理、企业与个人客户账号一律 403,且无权限与目标不存在不形成可枚举差异。
|
||||
// gate 必须挂在功能路径组上:Fiber 的组中间件按路径前缀生效,挂在空路径组上会落到 /api/admin 前缀,
|
||||
// 从而拦截该层其余全部接口。
|
||||
// 路径全部为静态路径(无动态参数),导出子路径比汇总路径更具体,注册顺序不影响匹配。
|
||||
func registerOperationsReportRoutes(router fiber.Router, handler *admin.OperationsReportHandler, doc *openapi.Generator, basePath string) {
|
||||
group := router.Group("/operations-reports", func(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
|
||||
path := basePath + "/operations-reports"
|
||||
|
||||
Register(group, doc, path, "GET", "/activation-summary", handler.ActivationSummary, RouteSpec{
|
||||
Summary: "查询设备激活情况汇总",
|
||||
Description: "只读日报快照:采购数量、累计激活数、激活率、新增激活数、累计在网数、活跃用户数、累计用量与卡均;累计类取结束日快照,结束日无快照时为空且不回退;仅超级管理员与平台账号可访问",
|
||||
Tags: []string{"运营报表"},
|
||||
Input: new(dto.OperationsActivationSummaryRequest),
|
||||
Output: new(dto.OperationsActivationSummaryResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, path, "GET", "/activation-trend", handler.ActivationTrend, RouteSpec{
|
||||
Summary: "查询设备激活情况日/月趋势",
|
||||
Description: "按日每个快照日一点,按月每个自然月一点;累计类取该期最后一个有快照日的快照值,新增激活数取相邻期之差;无快照的期不出现",
|
||||
Tags: []string{"运营报表"},
|
||||
Input: new(dto.OperationsActivationTrendRequest),
|
||||
Output: new(dto.OperationsActivationTrendResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, path, "POST", "/activation-summary/export", handler.ExportActivationSummary, RouteSpec{
|
||||
Summary: "导出设备激活情况",
|
||||
Description: "复用既有异步导出任务:创建时冻结筛选条件、操作者与可见店铺范围,导出列与页面字段一致并含合计行,分母为零写「-」,不含文字总结",
|
||||
Tags: []string{"运营报表"},
|
||||
Body: new(dto.ExportOperationsReportRequest),
|
||||
Output: new(dto.CreateExportTaskResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, path, "GET", "/package-renewal-summary", handler.RenewalSummary, RouteSpec{
|
||||
Summary: "查询套餐续费情况汇总",
|
||||
Description: "只读日报快照:到期资产数、续费资产数、续费率与新增未续费数;到期与续费按资产去重,续费资产为到期资产子集,续费率不超过 100% 由构造保证",
|
||||
Tags: []string{"运营报表"},
|
||||
Input: new(dto.OperationsRenewalSummaryRequest),
|
||||
Output: new(dto.OperationsRenewalSummaryResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, path, "GET", "/package-renewal-trend", handler.RenewalTrend, RouteSpec{
|
||||
Summary: "查询套餐续费情况日/月趋势",
|
||||
Description: "按日每个快照日一点,按月每个自然月一点;到期与续费为期内按资产去重的流式指标;无快照的期不出现",
|
||||
Tags: []string{"运营报表"},
|
||||
Input: new(dto.OperationsRenewalTrendRequest),
|
||||
Output: new(dto.OperationsRenewalTrendResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, path, "POST", "/package-renewal-summary/export", handler.ExportRenewalSummary, RouteSpec{
|
||||
Summary: "导出套餐续费情况",
|
||||
Description: "复用既有异步导出任务:创建时冻结筛选条件、操作者与可见店铺范围,导出列与页面字段一致并含合计行,分母为零写「-」,不含文字总结",
|
||||
Tags: []string{"运营报表"},
|
||||
Body: new(dto.ExportOperationsReportRequest),
|
||||
Output: new(dto.CreateExportTaskResponse),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
88
internal/task/operations_report_snapshot.go
Normal file
88
internal/task/operations_report_snapshot.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
|
||||
operationsreportapp "github.com/break/junhong_cmp_fiber/internal/application/operationsreport"
|
||||
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// OperationsReportSnapshotPayload 是每日运营报表快照任务的载荷。
|
||||
//
|
||||
// 载荷只含单一目标上海自然日。仓库内没有该任务的入队点(不提供补跑接口),
|
||||
// 带载荷的调用只能来自运维侧 asynq 控制台或临时程序,此时目标日由载荷固定。
|
||||
// 定时调度**不带载荷**(asynq 的 Scheduler 只能注册静态 Task,无法按次生成 payload),
|
||||
// 此时目标日 = 处理时刻所在上海自然日的前一天。
|
||||
//
|
||||
// 「重试沿用同一目标日期」的依据不是「沿用同一份载荷」(空载荷会在重试时刻重新推导),
|
||||
// 而是「cron 时点 + 有界重试窗口」:默认重试延迟为 n^4+15+rand(0..29)*(n+1) 秒
|
||||
// (asynq v0.25.1 server.go 的 DefaultRetryDelayFunc,本仓库 pkg/queue/server.go 显式采用),
|
||||
// MaxRetry(3) 的三次重试合计 ≤ 236 秒,叠加 Timeout(30m) 仍远小于一个上海自然日;
|
||||
// 服务端重试路径不经过唯一锁,因此 23 小时去重窗口不会吞掉失败重试。
|
||||
// 调整 cron 时点、MaxRetry 或 Timeout 必须重新评估该前提。
|
||||
type OperationsReportSnapshotPayload struct {
|
||||
SnapshotDate string `json:"snapshot_date"`
|
||||
}
|
||||
|
||||
// OperationsReportSnapshotHandler 每日运营报表快照任务处理器。
|
||||
// 处理器是薄壳:解析目标日期后调用生成用例,口径与幂等全部在用例内闭合。
|
||||
type OperationsReportSnapshotHandler struct {
|
||||
generator *operationsreportapp.Generator
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewOperationsReportSnapshotHandler 创建每日运营报表快照任务处理器。
|
||||
func NewOperationsReportSnapshotHandler(generator *operationsreportapp.Generator, logger *zap.Logger) *OperationsReportSnapshotHandler {
|
||||
return &OperationsReportSnapshotHandler{generator: generator, logger: logger}
|
||||
}
|
||||
|
||||
// Handle 生成当日(或载荷指定的)运营报表日报快照。
|
||||
func (h *OperationsReportSnapshotHandler) Handle(ctx context.Context, task *asynq.Task) error {
|
||||
if h == nil || h.generator == nil {
|
||||
return errors.New(errors.CodeInternalError, "运营报表快照生成用例未配置")
|
||||
}
|
||||
snapshotDate, err := ResolveOperationsReportSnapshotDate(task, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.generator.Generate(ctx, snapshotDate); err != nil {
|
||||
if h.logger != nil {
|
||||
h.logger.Error("运营报表日报快照生成失败",
|
||||
zap.String("snapshot_date", domainreport.FormatSnapshotDay(snapshotDate)),
|
||||
zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveOperationsReportSnapshotDate 解析本次生成的目标上海自然日。
|
||||
//
|
||||
// 载荷为空(定时调度)时取 now 所在上海自然日的前一自然日;
|
||||
// 载荷给出目标日期时必须为 yyyy-MM-dd,非法载荷直接失败,不静默退化为「当天」。
|
||||
func ResolveOperationsReportSnapshotDate(task *asynq.Task, now time.Time) (time.Time, error) {
|
||||
if task != nil {
|
||||
payload := task.Payload()
|
||||
if len(payload) > 0 {
|
||||
var parsed OperationsReportSnapshotPayload
|
||||
if err := sonic.Unmarshal(payload, &parsed); err != nil {
|
||||
return time.Time{}, errors.Wrap(errors.CodeInvalidParam, err, "运营报表快照任务载荷格式不正确")
|
||||
}
|
||||
if parsed.SnapshotDate != "" {
|
||||
day, err := domainreport.ParseSnapshotDay(parsed.SnapshotDate)
|
||||
if err != nil {
|
||||
return time.Time{}, errors.New(errors.CodeInvalidParam,
|
||||
"运营报表快照任务的目标日期必须为 yyyy-MM-dd 格式的上海自然日")
|
||||
}
|
||||
return day, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return domainreport.PreviousDay(now), nil
|
||||
}
|
||||
Reference in New Issue
Block a user