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