Files
junhong_cmp_fiber/internal/exporter/operations_report_scene.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

346 lines
13 KiB
Go

package exporter
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// 导出空值占位:分母为零的比率与卡均、以及无快照时的空指标一律写「-」。
const operationsReportEmptyValue = "-"
// 合计行的分组列值。
const operationsReportTotalGroup = "合计"
// 序号列表头;分组行按导出展示顺序从 1 连续递增,合计行留空不写入任何数字或文字。
const operationsReportSerialHeader = "序号"
// OperationsActivationDataSource 设备激活情况报表导出数据源。
//
// 导出列与页面展示字段一致并包含合计行,首列为「序号」:分组行按展示顺序从 1 连续递增,
// 合计行的序号列留空且其余各列的既有表示不变;导出不含任何文字总结;
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
// 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再按任务内冻结的账号类型复核一次,
// 阻止通过通用导出入口以代理身份创建本场景任务后读到运营报表数据。
type OperationsActivationDataSource struct {
query *operationsreportquery.Query
}
// NewOperationsActivationDataSource 创建设备激活情况报表导出数据源。
func NewOperationsActivationDataSource(db *gorm.DB) *OperationsActivationDataSource {
return &OperationsActivationDataSource{query: operationsreportquery.NewQuery(db)}
}
// Scene 返回导出场景编码。
func (s *OperationsActivationDataSource) Scene() string {
return constants.ExportTaskSceneOperationsActivation
}
// Count 统计导出行数(分组行 + 合计行)。
func (s *OperationsActivationDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
result, err := s.build(ctx, params)
if err != nil {
return 0, err
}
return len(result.rows), nil
}
// Headers 返回设备激活情况导出表头。
func (s *OperationsActivationDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
result, err := s.build(ctx, params)
if err != nil {
return nil, err
}
return result.headers, nil
}
// Fetch 按 offset/limit 返回设备激活情况导出行。
func (s *OperationsActivationDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
result, err := s.build(ctx, params)
if err != nil {
return nil, err
}
return sliceOperationsReportRows(result.rows, offset, limit), nil
}
// build 构造导出表头与全部行(分组行 + 合计行)。
func (s *OperationsActivationDataSource) build(ctx context.Context, params ExportParams) (*operationsReportResult, error) {
if err := ensureOperationsReportExportAllowed(params); err != nil {
return nil, err
}
request, err := activationExportRequest(params)
if err != nil {
return nil, err
}
response, err := s.query.ActivationSummary(frozenQueryContext(ctx, params), request)
if err != nil {
return nil, err
}
headers := []string{
operationsReportSerialHeader, response.GroupName, "采购数量", "累计激活数", "激活率", "新增激活数",
"累计在网数", "活跃用户数", "累计用量(GB)", "单用户卡均(GB)", "含零预测卡均(GB)", "不含零预测卡均(GB)",
}
rows := make([][]string, 0, len(response.Items)+1)
for index, item := range response.Items {
rows = append(rows, []string{
strconv.Itoa(index + 1),
item.GroupValue,
formatOptionalInt64(item.PurchasedDeviceCount),
formatOptionalInt64(item.ActivatedDeviceCount),
formatOptionalFloat(item.ActivationRate),
formatOptionalInt64(item.NewActivatedDeviceCount),
formatOptionalInt64(item.OnlineDeviceCount),
formatOptionalInt64(item.ActiveDeviceCount),
formatOptionalFloat(item.TotalRealTrafficGB),
formatOptionalFloat(item.PerUserAverageGB),
formatOptionalFloat(item.ForecastAverageIncludingZeroGB),
formatOptionalFloat(item.ForecastAverageExcludingZeroGB),
})
}
if response.Totals != nil {
total := response.Totals
rows = append(rows, []string{
// 合计行序号列留空,其余各列沿用既有表示。
"",
operationsReportTotalGroup,
formatOptionalInt64(total.PurchasedDeviceCount),
formatOptionalInt64(total.ActivatedDeviceCount),
formatOptionalFloat(total.ActivationRate),
formatOptionalInt64(total.NewActivatedDeviceCount),
formatOptionalInt64(total.OnlineDeviceCount),
formatOptionalInt64(total.ActiveDeviceCount),
formatOptionalFloat(total.TotalRealTrafficGB),
formatOptionalFloat(total.PerUserAverageGB),
formatOptionalFloat(total.ForecastAverageIncludingZeroGB),
formatOptionalFloat(total.ForecastAverageExcludingZeroGB),
})
}
return &operationsReportResult{headers: headers, rows: rows}, nil
}
// OperationsRenewalDataSource 套餐续费情况报表导出数据源。
//
// 导出列与页面展示字段一致并包含合计行,首列为「序号」:分组行按展示顺序从 1 连续递增,
// 合计行的序号列留空且其余各列的既有表示不变;导出不含任何文字总结;
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
// 本场景同样按任务内冻结的账号类型复核导出资格。
type OperationsRenewalDataSource struct {
query *operationsreportquery.Query
}
// NewOperationsRenewalDataSource 创建套餐续费情况报表导出数据源。
func NewOperationsRenewalDataSource(db *gorm.DB) *OperationsRenewalDataSource {
return &OperationsRenewalDataSource{query: operationsreportquery.NewQuery(db)}
}
// Scene 返回导出场景编码。
func (s *OperationsRenewalDataSource) Scene() string {
return constants.ExportTaskSceneOperationsRenewal
}
// Count 统计导出行数(分组行 + 合计行)。
func (s *OperationsRenewalDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
result, err := s.build(ctx, params)
if err != nil {
return 0, err
}
return len(result.rows), nil
}
// Headers 返回套餐续费情况导出表头。
func (s *OperationsRenewalDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
result, err := s.build(ctx, params)
if err != nil {
return nil, err
}
return result.headers, nil
}
// Fetch 按 offset/limit 返回套餐续费情况导出行。
func (s *OperationsRenewalDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
result, err := s.build(ctx, params)
if err != nil {
return nil, err
}
return sliceOperationsReportRows(result.rows, offset, limit), nil
}
// build 构造导出表头与全部行(分组行 + 合计行)。
func (s *OperationsRenewalDataSource) build(ctx context.Context, params ExportParams) (*operationsReportResult, error) {
if err := ensureOperationsReportExportAllowed(params); err != nil {
return nil, err
}
request, err := renewalExportRequest(params)
if err != nil {
return nil, err
}
response, err := s.query.RenewalSummary(frozenQueryContext(ctx, params), request)
if err != nil {
return nil, err
}
headers := []string{operationsReportSerialHeader, response.GroupName, "到期资产数", "续费资产数", "续费率", "新增未续费数"}
rows := make([][]string, 0, len(response.Items)+1)
for index, item := range response.Items {
rows = append(rows, []string{
strconv.Itoa(index + 1),
item.GroupValue,
formatOptionalInt64(item.DueAssetCount),
formatOptionalInt64(item.RenewedAssetCount),
formatOptionalFloat(item.RenewalRate),
formatOptionalInt64(item.NewUnrenewedAssetCount),
})
}
if response.Totals != nil {
total := response.Totals
rows = append(rows, []string{
// 合计行序号列留空,其余各列沿用既有表示。
"",
operationsReportTotalGroup,
formatOptionalInt64(total.DueAssetCount),
formatOptionalInt64(total.RenewedAssetCount),
formatOptionalFloat(total.RenewalRate),
formatOptionalInt64(total.NewUnrenewedAssetCount),
})
}
return &operationsReportResult{headers: headers, rows: rows}, nil
}
// operationsReportResult 是一次导出构造的表头与全部行。
type operationsReportResult struct {
headers []string
rows [][]string
}
// activationExportRequest 把任务冻结的筛选快照还原为汇总查询请求。
// 时间边界只按统一严格解析器解析冻结值,非法值返回错误由调用方落任务失败。
func activationExportRequest(params ExportParams) (dto.OperationsActivationSummaryRequest, error) {
start, end, err := frozenOperationsReportRange(params.Filters)
if err != nil {
return dto.OperationsActivationSummaryRequest{}, err
}
groupBy, err := frozenOperationsReportGroupBy(params.Filters)
if err != nil {
return dto.OperationsActivationSummaryRequest{}, err
}
return dto.OperationsActivationSummaryRequest{StartTime: start, EndTime: end, GroupBy: groupBy}, nil
}
// renewalExportRequest 把任务冻结的筛选快照还原为汇总查询请求。
func renewalExportRequest(params ExportParams) (dto.OperationsRenewalSummaryRequest, error) {
start, end, err := frozenOperationsReportRange(params.Filters)
if err != nil {
return dto.OperationsRenewalSummaryRequest{}, err
}
groupBy, err := frozenOperationsReportGroupBy(params.Filters)
if err != nil {
return dto.OperationsRenewalSummaryRequest{}, err
}
return dto.OperationsRenewalSummaryRequest{StartTime: start, EndTime: end, GroupBy: groupBy}, nil
}
// operationsReportGroupByKey 是冻结筛选中的分组维度键。
// 未选择分组维度时冻结为空串,导出仍然只有唯一一行「全部」,因此空值也按已冻结处理。
const operationsReportGroupByKey = "group_by"
// frozenOperationsReportGroupBy 读取冻结的分组维度;键缺失或空串都表示未选择分组维度。
func frozenOperationsReportGroupBy(filters map[string]any) (string, error) {
value, exists := filters[operationsReportGroupByKey]
if !exists || value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return "", errors.New(errors.CodeInvalidParam, "导出筛选的分组维度格式不正确")
}
return text, nil
}
// frozenOperationsReportRange 读取冻结的时间边界并复用统一严格解析器校验格式与顺序。
func frozenOperationsReportRange(filters map[string]any) (string, string, error) {
start, err := frozenOperationsReportTime(filters, exportTimeFilterStartKey)
if err != nil {
return "", "", err
}
end, err := frozenOperationsReportTime(filters, exportTimeFilterEndKey)
if err != nil {
return "", "", err
}
if _, _, err := utils.ParseTimeRange(start, end); err != nil {
return "", "", err
}
return start, end, nil
}
// frozenOperationsReportTime 读取单个冻结的时间边界值。
func frozenOperationsReportTime(filters map[string]any, key string) (string, error) {
value, exists := filters[key]
if !exists || value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return "", utils.TimeFilterFormatError(key)
}
if text == "" {
return "", nil
}
return text, nil
}
// frozenQueryContext 以任务内冻结的账号类型与可见店铺范围构造查询上下文。
// 执行期不读取当前请求上下文:创建后的角色、店铺归属或筛选变化都不会扩大或收紧已建任务的数据集。
func frozenQueryContext(ctx context.Context, params ExportParams) context.Context {
return middleware.SetUserContext(ctx, &middleware.UserContextInfo{
UserType: params.UserType,
SubordinateShopIDs: params.ScopeShopIDs,
})
}
// ensureOperationsReportExportAllowed 只允许超级管理员与平台账号使用运营报表导出场景。
// 判定依据是任务内冻结的账号类型,不读取当前请求上下文,因此创建后角色变化不会放宽或收紧已建任务。
func ensureOperationsReportExportAllowed(params ExportParams) error {
if params.UserType == constants.UserTypeSuperAdmin || params.UserType == constants.UserTypePlatform {
return nil
}
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
// sliceOperationsReportRows 按 offset/limit 切分导出行。
func sliceOperationsReportRows(rows [][]string, offset, limit int) [][]string {
if offset < 0 {
offset = 0
}
if limit <= 0 || offset >= len(rows) {
return [][]string{}
}
end := offset + limit
if end > len(rows) {
end = len(rows)
}
return rows[offset:end]
}
// formatOptionalInt64 输出可选整数,为空写「-」。
func formatOptionalInt64(value *int64) string {
if value == nil {
return operationsReportEmptyValue
}
return strconv.FormatInt(*value, 10)
}
// formatOptionalFloat 输出可选小数(保留两位),为空写「-」。
func formatOptionalFloat(value *float64) string {
if value == nil {
return operationsReportEmptyValue
}
return strconv.FormatFloat(*value, 'f', 2, 64)
}