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
600 lines
23 KiB
Go
600 lines
23 KiB
Go
// 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
|
||
}
|