Files
junhong_cmp_fiber/internal/infrastructure/operationsreport/source.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

600 lines
23 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 是运营报表日报快照的只读数据访问适配。
//
// 只做读取与投影:设备与设备属性、当前有效卡绑定、卡实名状态、套餐使用记录、
// 店铺与业务员、业务用户组、套餐与套餐系列。所有口径为生成时刻的读数,
// 生成后由快照行冻结,历史结果不随实时状态漂移。
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
}