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
|
||||
}
|
||||
Reference in New Issue
Block a user