feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
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:
2026-09-18 09:42:28 +08:00
parent 6333f4ad13
commit 5e78809b93
46 changed files with 5580 additions and 101 deletions

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