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:
137
internal/domain/operationsreport/dimension.go
Normal file
137
internal/domain/operationsreport/dimension.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package operationsreport
|
||||
|
||||
// 报表分组维度:激活情况表七项、套餐续费表六项。
|
||||
// 只支持单一分组维度,不支持同时按多个维度分组;未选择维度时汇总为一行,分组列值为「全部」。
|
||||
const (
|
||||
// DimensionDeviceName 表示设备名称维度。
|
||||
DimensionDeviceName = "device_name"
|
||||
// DimensionDeviceModel 表示设备型号维度。
|
||||
DimensionDeviceModel = "device_model"
|
||||
// DimensionManufacturer 表示制造商维度。
|
||||
DimensionManufacturer = "manufacturer"
|
||||
// DimensionBusinessUserGroup 表示用户组维度。
|
||||
DimensionBusinessUserGroup = "business_user_group"
|
||||
// DimensionAgent 表示代理维度。
|
||||
DimensionAgent = "agent"
|
||||
// DimensionShop 表示店铺维度。
|
||||
DimensionShop = "shop"
|
||||
// DimensionBusinessOwner 表示业务员维度。
|
||||
DimensionBusinessOwner = "business_owner"
|
||||
// DimensionPackageSeries 表示套餐系列维度。
|
||||
DimensionPackageSeries = "package_series"
|
||||
// DimensionPackageName 表示套餐名称维度。
|
||||
DimensionPackageName = "package_name"
|
||||
)
|
||||
|
||||
// DimensionAll 是未选择分组维度时唯一汇总行的分组列值。
|
||||
const DimensionAll = "全部"
|
||||
|
||||
// 趋势粒度取值:只表达粒度,不引入月份参数。
|
||||
const (
|
||||
// GranularityDay 表示按日趋势。
|
||||
GranularityDay = "day"
|
||||
// GranularityMonth 表示按月趋势。
|
||||
GranularityMonth = "month"
|
||||
)
|
||||
|
||||
// NormalizeGranularity 归一趋势粒度,缺省为按日;返回 false 表示取值不受支持。
|
||||
func NormalizeGranularity(value string) (string, bool) {
|
||||
switch value {
|
||||
case "", GranularityDay:
|
||||
return GranularityDay, true
|
||||
case GranularityMonth:
|
||||
return GranularityMonth, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// PlaceholderUnset 是分组取值为空时的固定占位展示。
|
||||
const PlaceholderUnset = "未设置"
|
||||
|
||||
// activationDimensions 是有序的激活情况分组维度与其中文名。
|
||||
var activationDimensions = []struct {
|
||||
Code string
|
||||
Name string
|
||||
}{
|
||||
{DimensionDeviceName, "设备名称"},
|
||||
{DimensionDeviceModel, "设备型号"},
|
||||
{DimensionManufacturer, "制造商"},
|
||||
{DimensionBusinessUserGroup, "用户组"},
|
||||
{DimensionAgent, "代理"},
|
||||
{DimensionShop, "店铺"},
|
||||
{DimensionBusinessOwner, "业务员"},
|
||||
}
|
||||
|
||||
// renewalDimensions 是有序的套餐续费分组维度与其中文名。
|
||||
var renewalDimensions = []struct {
|
||||
Code string
|
||||
Name string
|
||||
}{
|
||||
{DimensionPackageSeries, "套餐系列"},
|
||||
{DimensionPackageName, "套餐名称"},
|
||||
{DimensionBusinessUserGroup, "用户组"},
|
||||
{DimensionAgent, "代理"},
|
||||
{DimensionShop, "店铺"},
|
||||
{DimensionBusinessOwner, "业务员"},
|
||||
}
|
||||
|
||||
// ActivationDimensionCodes 返回激活情况支持的维度编码(按展示顺序)。
|
||||
func ActivationDimensionCodes() []string {
|
||||
codes := make([]string, 0, len(activationDimensions))
|
||||
for _, dimension := range activationDimensions {
|
||||
codes = append(codes, dimension.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// RenewalDimensionCodes 返回套餐续费支持的维度编码(按展示顺序)。
|
||||
func RenewalDimensionCodes() []string {
|
||||
codes := make([]string, 0, len(renewalDimensions))
|
||||
for _, dimension := range renewalDimensions {
|
||||
codes = append(codes, dimension.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// ActivationDimensionName 返回激活情况维度的中文名;不支持时返回 false。
|
||||
func ActivationDimensionName(code string) (string, bool) {
|
||||
return dimensionName(activationDimensions, code)
|
||||
}
|
||||
|
||||
// RenewalDimensionName 返回套餐续费维度的中文名;不支持时返回 false。
|
||||
func RenewalDimensionName(code string) (string, bool) {
|
||||
return dimensionName(renewalDimensions, code)
|
||||
}
|
||||
|
||||
// IsActivationDimension 判断是否为受支持的激活情况维度。
|
||||
func IsActivationDimension(code string) bool {
|
||||
_, ok := ActivationDimensionName(code)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsRenewalDimension 判断是否为受支持的套餐续费维度。
|
||||
func IsRenewalDimension(code string) bool {
|
||||
_, ok := RenewalDimensionName(code)
|
||||
return ok
|
||||
}
|
||||
|
||||
func dimensionName(dimensions []struct {
|
||||
Code string
|
||||
Name string
|
||||
}, code string) (string, bool) {
|
||||
for _, dimension := range dimensions {
|
||||
if dimension.Code == code {
|
||||
return dimension.Name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// TextOrPlaceholder 返回非空文本,为空时返回固定占位。
|
||||
func TextOrPlaceholder(value string) string {
|
||||
if value == "" {
|
||||
return PlaceholderUnset
|
||||
}
|
||||
return value
|
||||
}
|
||||
150
internal/domain/operationsreport/metrics.go
Normal file
150
internal/domain/operationsreport/metrics.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Package operationsreport 是运营报表(设备激活与套餐续费)的口径域。
|
||||
//
|
||||
// 本包只表达可复现的口径与纯计算:真流量换算、比率与卡均、分母为零语义、
|
||||
// 预测卡均的当月口径、采购数量口径入口与续费判定规则。
|
||||
// 不依赖 Fiber、GORM、Redis、Asynq 或任何外部 SDK,也不做任何读写。
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MBPerGB 是报表域自持的流量换算常量:1 GB = 1024 MB。
|
||||
// 与 internal/domain/carrierthreshold 的换算同值同源;不为一个换算常数建立跨域依赖。
|
||||
const MBPerGB = 1024
|
||||
|
||||
// shanghaiLocation 是报表口径使用的上海时区(固定 +08:00)。
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// ShanghaiLocation 返回报表口径使用的上海时区。
|
||||
func ShanghaiLocation() *time.Location {
|
||||
return shanghaiLocation
|
||||
}
|
||||
|
||||
// SnapshotDay 把任意时刻归一为它所在的上海自然日零点。
|
||||
// 报表的一切跨日比较都使用上海自然日,不使用服务器本地时区。
|
||||
func SnapshotDay(value time.Time) time.Time {
|
||||
local := value.In(shanghaiLocation)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
|
||||
}
|
||||
|
||||
// PreviousDay 返回给定上海自然日的前一自然日零点。
|
||||
func PreviousDay(day time.Time) time.Time {
|
||||
return SnapshotDay(day).AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
// ParseSnapshotDay 解析 yyyy-MM-dd 形式的上海自然日。
|
||||
func ParseSnapshotDay(value string) (time.Time, error) {
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, shanghaiLocation)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// FormatSnapshotDay 输出上海自然日的 yyyy-MM-dd 文本。
|
||||
func FormatSnapshotDay(day time.Time) string {
|
||||
return SnapshotDay(day).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// FormatMonthPeriod 输出上海自然月的 yyyy-MM 文本。
|
||||
func FormatMonthPeriod(day time.Time) string {
|
||||
return SnapshotDay(day).Format("2006-01")
|
||||
}
|
||||
|
||||
// LowerBoundDay 按落界规则返回区间起点入选的最早快照日期。
|
||||
//
|
||||
// 落界规则(设计 D10):快照日期 D 入选,当且仅当 D 的零点(+08:00)落在请求区间内。
|
||||
// 因此起点恰好落在零点时当日入选,否则从次日起入选。
|
||||
func LowerBoundDay(start time.Time) time.Time {
|
||||
day := SnapshotDay(start)
|
||||
if start.After(day) {
|
||||
return day.AddDate(0, 0, 1)
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
// UpperBoundDay 按落界规则返回区间终点入选的最晚快照日期。
|
||||
func UpperBoundDay(end time.Time) time.Time {
|
||||
return SnapshotDay(end)
|
||||
}
|
||||
|
||||
// PeriodOf 返回给定快照日期所属的趋势期标识:按日为上海自然日,按月为该月首日。
|
||||
func PeriodOf(granularity string, day time.Time) time.Time {
|
||||
normalized := SnapshotDay(day)
|
||||
if granularity == GranularityMonth {
|
||||
return time.Date(normalized.Year(), normalized.Month(), 1, 0, 0, 0, 0, shanghaiLocation)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// FormatPeriod 输出趋势期标识:按日为 yyyy-MM-dd,按月为 yyyy-MM。
|
||||
func FormatPeriod(granularity string, day time.Time) string {
|
||||
if granularity == GranularityMonth {
|
||||
return FormatMonthPeriod(day)
|
||||
}
|
||||
return FormatSnapshotDay(day)
|
||||
}
|
||||
|
||||
// PreviousPeriodStart 返回给定期起始日所属期的前一期起始日(按日减一天,按月减一个月)。
|
||||
func PreviousPeriodStart(granularity string, periodStart time.Time) time.Time {
|
||||
if granularity == GranularityMonth {
|
||||
return periodStart.AddDate(0, -1, 0)
|
||||
}
|
||||
return periodStart.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
// Ratio 计算比率并按两位小数取整;分母不大于零时不可计算,返回 false(空值语义)。
|
||||
// 比率不设上限:设备删除或迁移可使激活率超过 100%,如实呈现。
|
||||
func Ratio(numerator, denominator int64) (float64, bool) {
|
||||
if denominator <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return Round2(float64(numerator) / float64(denominator)), true
|
||||
}
|
||||
|
||||
// CardAverageGB 计算卡均用量(GB):累计真流量折算 GB 后除以分母设备数。
|
||||
// 分母不大于零时不可计算,返回 false(空值语义)。
|
||||
func CardAverageGB(totalRealTrafficMB float64, denominator int64) (float64, bool) {
|
||||
if denominator <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
average := totalRealTrafficMB / MBPerGB / float64(denominator)
|
||||
return Round2(average), true
|
||||
}
|
||||
|
||||
// ForecastCardAverageGB 计算预测卡均(GB):先按卡均口径得出日均,再按结束日所在上海自然月年化。
|
||||
// 「当月」= 所选结束日所在上海自然月;已过天数 = 结束日日期号;当月总天数 = 该月自然日数。
|
||||
// 分母不大于零时不可计算,返回 false(空值语义)。
|
||||
func ForecastCardAverageGB(totalRealTrafficMB float64, denominator int64, endDate time.Time) (float64, bool) {
|
||||
average, ok := CardAverageGB(totalRealTrafficMB, denominator)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
elapsedDays, totalDays := MonthElapsedAndTotalDays(endDate)
|
||||
if elapsedDays <= 0 || totalDays <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return Round2(average * float64(totalDays) / float64(elapsedDays)), true
|
||||
}
|
||||
|
||||
// MonthElapsedAndTotalDays 返回结束日所在上海自然月的已过天数与当月总天数。
|
||||
// 已过天数按结束日的日期号取值(不区分当月剩余天数),当月总天数取该自然月的实际天数。
|
||||
func MonthElapsedAndTotalDays(endDate time.Time) (int, int) {
|
||||
day := SnapshotDay(endDate)
|
||||
totalDays := time.Date(day.Year(), day.Month()+1, 0, 0, 0, 0, 0, shanghaiLocation).Day()
|
||||
return day.Day(), totalDays
|
||||
}
|
||||
|
||||
// Round2 按两位小数四舍五入。
|
||||
func Round2(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
// RenewalRate 计算续费率:续费资产数除以到期资产数。
|
||||
// 分母为零时不可计算,返回 false(空值语义,导出写「-」)。
|
||||
// 分子为分母子集,因此续费率不超过 100% 由构造保证,不做任何截断或钳制。
|
||||
func RenewalRate(renewed, due int64) (float64, bool) {
|
||||
return Ratio(renewed, due)
|
||||
}
|
||||
37
internal/domain/operationsreport/purchase.go
Normal file
37
internal/domain/operationsreport/purchase.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// PurchaseCountSource 是采购数量口径的取值端口。
|
||||
// 由基础设施层实现为只读查询(截至快照日系统内未删除的设备数)。
|
||||
type PurchaseCountSource interface {
|
||||
CountUndeletedDevicesAsOf(ctx context.Context, snapshotDate time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// PurchaseCountAsOf 返回截至快照日的采购数量,是采购数量口径的**唯一实现点**(设计 D6)。
|
||||
//
|
||||
// 采用口径:采购数量 = 截至快照日(上海自然日)系统内未删除的设备数,
|
||||
// 与 `111.md` §22.4.1「系统录入的设备数量」同读法,不新建采购或入库台账。
|
||||
//
|
||||
// 被拒绝的字面口径:`tb_device_import_task` 中 `operation_type='import'` 且已完成任务的
|
||||
// `success_count` 之和。生产库实测该值为 474,而系统内未删除设备为 18,970;
|
||||
// 差额来自老系统迁移脚本直接写入设备表、绕过导入任务,按字面口径激活率约 1,399%,指标不可用。
|
||||
//
|
||||
// 切换口径只需替换本函数体内的取值方式(一行),调用方与快照表结构都不需要改动。
|
||||
func PurchaseCountAsOf(ctx context.Context, source PurchaseCountSource, snapshotDate time.Time) (int64, error) {
|
||||
return source.CountUndeletedDevicesAsOf(ctx, snapshotDate)
|
||||
}
|
||||
|
||||
// ValidMainPackageStatuses 是「有效主套餐」的状态集合:生效中与已用完。
|
||||
// 「有效」的完整口径为:主套餐(master_usage_id IS NULL)、状态属于本集合、未退款(refund_id IS NULL),
|
||||
// 既有先例见 internal/query/packageexpiry/list.go、internal/query/assetautorenewal/query.go
|
||||
// 与 internal/infrastructure/packagetrafficalert/scanner.go。
|
||||
var ValidMainPackageStatuses = []int{
|
||||
constants.PackageUsageStatusActive,
|
||||
constants.PackageUsageStatusDepleted,
|
||||
}
|
||||
43
internal/domain/operationsreport/renewal.go
Normal file
43
internal/domain/operationsreport/renewal.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package operationsreport
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// ExpiredMainUsage 是一条到期事实:快照日(上海自然日)等于其到期日的主套餐使用记录。
|
||||
type ExpiredMainUsage struct {
|
||||
UsageID uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// MainUsageCandidate 是同资产上参与续费判定的主套餐记录投影。
|
||||
// 调用方必须只传入未退款(refund_id IS NULL)的主套餐(master_usage_id IS NULL)记录。
|
||||
type MainUsageCandidate struct {
|
||||
UsageID uint
|
||||
Status int
|
||||
ActivatedAt *time.Time
|
||||
}
|
||||
|
||||
// IsRenewed 判定该到期事实是否已续费(设计 D9):
|
||||
// 存在**另一条**未退款主套餐记录,其生效时间晚于本条到期时间,或处于待生效状态。
|
||||
//
|
||||
// 候选中属于本条记录自身的项不参与判定;判定的结果挂在到期行上,
|
||||
// 使续费资产集合恒为到期资产集合的子集,续费率不超过 100% 由构造保证,不做任何截断或钳制。
|
||||
func IsRenewed(expired ExpiredMainUsage, candidates []MainUsageCandidate) bool {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.UsageID == expired.UsageID {
|
||||
continue
|
||||
}
|
||||
if candidate.Status == constants.PackageUsageStatusPending {
|
||||
return true
|
||||
}
|
||||
if candidate.ActivatedAt != nil && candidate.ActivatedAt.After(expired.ExpiresAt) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user