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
}

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