// 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 }