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