Files
junhong_cmp_fiber/internal/application/operationsreport/generate.go
break 5e78809b93
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
- 新增成对迁移 000231 与三张快照表:日级头行、设备粒度激活行、到期事件粒度续费行,以快照日期为唯一键
- 新增每日 03:30(Asia/Shanghai)日报快照生成任务与幂等整日替换,失败重试沿用同一目标日
- 新增六条受控入口:两张报表的汇总、日/月趋势与受控导出,配套查询层只读快照事实
- 新增 operations_activation 与 operations_renewal 两个导出场景,创建期冻结筛选与可见店铺范围、派发期冻结表头、执行期只按冻结值复核资格
- 采购数量口径按系统内未删除设备数实施并在 PRD 标注,附实测差额依据
- 同步证据链 requirement-evidence.json 与入口能力矩阵、ARCHITECTURE 与验证记录
- 归档 change add-operations-reports 并新建主 Spec openspec/specs/operations-report/spec.md
2026-09-18 09:42:28 +08:00

247 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package operationsreport 实现运营报表日报快照的生成用例。
//
// 生成 = 读取生成时刻的只读事实(设备、当前有效关联卡、套餐使用记录、店铺与业务员、用户组、
// 套餐与套餐系列)→ 按报表口径域组装三张快照表的行与头行 → 在单事务内整日替换该日全部行并校验不变量。
// 用例本身不依赖 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
}