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

@@ -32,7 +32,7 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `/health``/ready` | 公开,只返回进程健康/就绪状态 | `internal/routes/health.go` | `openspec/specs/operations-audit/spec.md` | | `/health``/ready` | 公开,只返回进程健康/就绪状态 | `internal/routes/health.go` | `openspec/specs/operations-audit/spec.md` |
| `/api/auth` | 后台账号登录、刷新、登出与当前身份;认证中间件在路由组装配 | `internal/routes/auth.go` | `openspec/specs/identity-access/spec.md` | | `/api/auth` | 后台账号登录、刷新、登出与当前身份;认证中间件在路由组装配 | `internal/routes/auth.go` | `openspec/specs/identity-access/spec.md` |
| `/api/admin` | 后台认证、角色权限、店铺/企业数据范围;业务层仍需资源级校验 | `internal/routes/admin.go` | `identity-access``asset-device``package-lifecycle``order-payment-wallet``operations-audit` | | `/api/admin` | 后台认证、角色权限、店铺/企业数据范围;业务层仍需资源级校验 | `internal/routes/admin.go` | `identity-access``asset-device``package-lifecycle``order-payment-wallet``operations-audit``operations-report` |
| `/api/c/v1` | 个人客户 Token 与资产归属边界 | `internal/routes/personal.go` | `openspec/specs/personal-customer/spec.md` | | `/api/c/v1` | 个人客户 Token 与资产归属边界 | `internal/routes/personal.go` | `openspec/specs/personal-customer/spec.md` |
| `/api/open/v1` | 代理 Open API 独立认证/签名,不复用后台账号权限 | `internal/routes/open.go` | `openspec/specs/agent-open-api/spec.md` | | `/api/open/v1` | 代理 Open API 独立认证/签名,不复用后台账号权限 | `internal/routes/open.go` | `openspec/specs/agent-open-api/spec.md` |
| `/api/callback` | 无登录认证;每类渠道必须在 Handler/Adapter 内验签、解密、校验金额或事件身份 | `internal/routes/order.go``wecom_callback.go` 及运营商回调注册 | `openspec/specs/external-integration/spec.md``order-payment-wallet` | | `/api/callback` | 无登录认证;每类渠道必须在 Handler/Adapter 内验签、解密、校验金额或事件身份 | `internal/routes/order.go``wecom_callback.go` 及运营商回调注册 | `openspec/specs/external-integration/spec.md``order-payment-wallet` |
@@ -72,6 +72,7 @@ HTTP 入参、回调 Body/Header、上传文件和代理签名材料均是不可
| 个人客户 | `routes/personal.go` 及 C 端子路由 | `handler/app``service/client_*|personal_customer|customer_binding`;通知 Application/Query | Token、资产绑定、钱包、支付、Gateway | [`personal-customer`](openspec/specs/personal-customer/spec.md) | | 个人客户 | `routes/personal.go` 及 C 端子路由 | `handler/app``service/client_*|personal_customer|customer_binding`;通知 Application/Query | Token、资产绑定、钱包、支付、Gateway | [`personal-customer`](openspec/specs/personal-customer/spec.md) |
| 代理 Open API | `routes/open.go` | `handler/openapi``service/agent_open_api` | 独立认证、店铺数据范围、卡/套餐/钱包 Store | [`agent-open-api`](openspec/specs/agent-open-api/spec.md) | | 代理 Open API | `routes/open.go` | `handler/openapi``service/agent_open_api` | 独立认证、店铺数据范围、卡/套餐/钱包 Store | [`agent-open-api`](openspec/specs/agent-open-api/spec.md) |
| 轮询、通知、导出、配置与审计调查 | `polling_*.go``notification.go``export_task.go``system_config.go``audit.go`Scheduler/Task/Outbox | `application/notification|systemconfig|auditarchive|outbox``query/audit|notification|outbox|systemconfig`;旧 polling/export Service | Asynq、Outbox、审计库、Integration Log、对象存储 | [`operations-audit`](openspec/specs/operations-audit/spec.md) | | 轮询、通知、导出、配置与审计调查 | `polling_*.go``notification.go``export_task.go``system_config.go``audit.go`Scheduler/Task/Outbox | `application/notification|systemconfig|auditarchive|outbox``query/audit|notification|outbox|systemconfig`;旧 polling/export Service | Asynq、Outbox、审计库、Integration Log、对象存储 | [`operations-audit`](openspec/specs/operations-audit/spec.md) |
| 运营报表(设备激活与套餐续费日报快照) | `routes/operations_report.go`(汇总/趋势/受控导出);异步 `TaskTypeOperationsReportSnapshot` 每日 03:30 | `application/operationsreport`(生成用例)→ `domain/operationsreport`(口径域);读取 `query/operationsreport`;导出 `exporter/operations_report_scene.go` | `tb_operations_report_snapshot``tb_operations_report_activation_row``tb_operations_report_renewal_row`(迁移 `000231`,整日替换);只读适配 `infrastructure/operationsreport` | [`operations-report`](openspec/specs/operations-report/spec.md) |
| 外部集成接点 | 支付/运营商/企微回调Gateway 调用,对象存储、短信 | `internal/gateway``internal/infrastructure/wecom|payment|carriercallback|integrationlog``pkg/alipay|wechat|fuiou|sms|storage` | 第三方网络和凭证;协议事实见 integrations | [`external-integration`](openspec/specs/external-integration/spec.md) | | 外部集成接点 | 支付/运营商/企微回调Gateway 调用,对象存储、短信 | `internal/gateway``internal/infrastructure/wecom|payment|carriercallback|integrationlog``pkg/alipay|wechat|fuiou|sms|storage` | 第三方网络和凭证;协议事实见 integrations | [`external-integration`](openspec/specs/external-integration/spec.md) |
任务 7 会把当前过大的 Spec 索引拆成完整业务能力;拆分后必须同步本表链接。 任务 7 会把当前过大的 Spec 索引拆成完整业务能力;拆分后必须同步本表链接。

View File

@@ -35,6 +35,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil) handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 // 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil) handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil) handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil) handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。

View File

@@ -44,6 +44,8 @@ func generateAdminDocs(outputPath string) error {
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil) handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 // 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil) handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil) handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil) handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。

View File

@@ -1014,6 +1014,21 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
); err != nil { ); err != nil {
return fmt.Errorf("注册每日流量落盘定时任务失败: %w", err) return fmt.Errorf("注册每日流量落盘定时任务失败: %w", err)
} }
// 运营报表日报快照:上海时区 03:30晚于每日流量落盘02:00、套餐临期提醒03:00与 10 秒级到期处理之后。
// 定时调度不带载荷目标日在处理器内按上海时区取前一自然日asynq 的 Scheduler 只能注册静态 Task
// 无法按次生成 payload失败重试沿用同一目标日期的依据是「cron 时点 + 有界重试窗口」
// (默认重试延迟 n^4+15+rand(0..29)*(n+1) 秒MaxRetry(3) 合计 ≤236 秒,叠加 Timeout 仍落在同一上海自然日内)。
// 调整本 cron 时点、MaxRetry 或 Timeout 必须重新评估该前提。
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 30 3 * * *", asynq.NewTask(
constants.TaskTypeOperationsReportSnapshot,
nil,
asynq.MaxRetry(3),
asynq.Timeout(30*time.Minute),
asynq.Unique(23*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeOperationsReportSnapshot)),
)); err != nil {
return fmt.Errorf("注册每日运营报表日报快照生成定时任务失败: %w", err)
}
if !auditArchiveEnabled { if !auditArchiveEnabled {
return nil return nil
} }

View File

@@ -208,7 +208,11 @@
## 2.17 已确认的报表基线 ## 2.17 已确认的报表基线
激活报表的采购数量以成功导入系统的设备数量计算,不另建采购或入库台账。功能上线后每日生成稳定日报快照;上线前日期不提供报表或明确显示无快照数据,不回填历史。累计激活设备严格采用任一当前关联卡已实名的口径;每日快照中的累计在网设备为已实名且存在有效主套餐的设备,活跃设备为该套餐周期内任一卡真流量大于零的设备,用量为设备当前套餐周期内全部关联卡真流量之和。报表可选择设备名称、型号、制造商、用户组、代理、店铺、业务员中的一个分组维度;未选择时汇总为一行。套餐续费按资产去重,统计期内有主套餐到期的资产计一次到期,至少成功续购一次主套餐计一次续费,续费率不超过 100%。日报快照冻结当天店铺、业务员及用户组归属。后端提供日/月趋势汇总数据和异步导出,图表渲染由前端负责。 激活报表的采购数量以成功导入系统的设备数量计算,不另建采购或入库台账。
> **AUG26-015 实施口径标注2026-09-17**:上式「以成功导入系统的设备数量计算」按**系统内实际存在的设备**实施——采购数量 = 截至快照日(上海自然日)系统内未删除的设备数,与 `111.md` §22.4.1「系统录入的设备数量」同读法,且不新建采购或入库台账。不采用「设备导入任务成功行数」(`tb_device_import_task` 中 `operation_type='import'` 且已完成任务的 `success_count` 之和):生产库实测该值为 **474**,而系统内未删除设备为 **18,970**,差额来自老系统迁移脚本直接写入设备、绕过导入任务;按字面口径激活率约 **1,399%**,指标不可用。该口径收敛在一个口径函数内,切换成本为一行。本条其余文字与其余基线确认内容不变。
功能上线后每日生成稳定日报快照;上线前日期不提供报表或明确显示无快照数据,不回填历史。累计激活设备严格采用任一当前关联卡已实名的口径;每日快照中的累计在网设备为已实名且存在有效主套餐的设备,活跃设备为该套餐周期内任一卡真流量大于零的设备,用量为设备当前套餐周期内全部关联卡真流量之和。报表可选择设备名称、型号、制造商、用户组、代理、店铺、业务员中的一个分组维度;未选择时汇总为一行。套餐续费按资产去重,统计期内有主套餐到期的资产计一次到期,至少成功续购一次主套餐计一次续费,续费率不超过 100%。日报快照冻结当天店铺、业务员及用户组归属。后端提供日/月趋势汇总数据和异步导出,图表渲染由前端负责。
## 2.18 已确认的店铺批量换绑 Excel 基线 ## 2.18 已确认的店铺批量换绑 Excel 基线

File diff suppressed because one or more lines are too long

View File

@@ -4483,5 +4483,82 @@
"export-time-filter::三类异步导出的创建期冻结与不扩大范围" "export-time-filter::三类异步导出的创建期冻结与不扩大范围"
], ],
"classification": "behavior" "classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/operations-reports/activation-summary",
"capability": "operations-report",
"requirements": [
"operations-report::激活情况指标口径",
"operations-report::报表维度分组与归属冻结",
"operations-report::报表查询与趋势",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/operations-reports/activation-trend",
"capability": "operations-report",
"requirements": [
"operations-report::激活情况指标口径",
"operations-report::报表维度分组与归属冻结",
"operations-report::报表查询与趋势",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/operations-reports/activation-summary/export",
"capability": "operations-report",
"requirements": [
"operations-report::激活情况指标口径",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/operations-reports/package-renewal-summary",
"capability": "operations-report",
"requirements": [
"operations-report::套餐续费指标口径",
"operations-report::报表维度分组与归属冻结",
"operations-report::报表查询与趋势",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/operations-reports/package-renewal-trend",
"capability": "operations-report",
"requirements": [
"operations-report::套餐续费指标口径",
"operations-report::报表维度分组与归属冻结",
"operations-report::报表查询与趋势",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/operations-reports/package-renewal-summary/export",
"capability": "operations-report",
"requirements": [
"operations-report::套餐续费指标口径",
"operations-report::报表权限、数据范围与导出冻结"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeOperationsReportSnapshot",
"capability": "operations-report",
"requirements": [
"operations-report::每日报表快照与不回填历史"
],
"classification": "behavior"
} }
] ]

View File

@@ -5019,5 +5019,220 @@
], ],
"exit_status": 0 "exit_status": 0
} }
},
{
"capability": "operations-report",
"requirement": "每日报表快照与不回填历史",
"spec": "openspec/specs/operations-report/spec.md",
"entries": [
"constants.TaskTypeOperationsReportSnapshot"
],
"handler_consumer_job": [
"internal/task/operations_report_snapshot.go",
"pkg/queue/handler.go",
"cmd/worker/main.go"
],
"application_service_query": [
"internal/application/operationsreport/generate.go"
],
"domain_state_amount": [
"internal/domain/operationsreport/metrics.go"
],
"store_migration_config": [
"migrations/000231_create_operations_report_snapshot.up.sql",
"migrations/000231_create_operations_report_snapshot.down.sql",
"internal/infrastructure/operationsreport/snapshot_store.go"
],
"verification": {
"command": "rg -n \"TaskTypeOperationsReportSnapshot\" pkg/constants/constants.go pkg/queue/handler.go cmd/worker/main.go internal/task/operations_report_snapshot.go",
"literal_output": [
"pkg/constants/constants.go:100:\tTaskTypeOperationsReportSnapshot = \"operations:report:snapshot\" // 每日运营报表日报快照生成",
"pkg/constants/constants.go:323:\tcase TaskTypeOperationsReportSnapshot:",
"pkg/queue/handler.go:404:\th.mux.HandleFunc(constants.TaskTypeOperationsReportSnapshot, handler.Handle)",
"pkg/queue/handler.go:405:\th.logger.Info(\"注册每日运营报表日报快照生成任务处理器\", zap.String(\"task_type\", constants.TaskTypeOperationsReportSnapshot))",
"cmd/worker/main.go:1018:\t\tconstants.TaskTypeOperationsReportSnapshot,",
"cmd/worker/main.go:1023:\t\tasynq.Queue(constants.QueueForTaskType(constants.TaskTypeOperationsReportSnapshot)),"
],
"exit_status": 0
}
},
{
"capability": "operations-report",
"requirement": "激活情况指标口径",
"spec": "openspec/specs/operations-report/spec.md",
"entries": [
"/api/admin/operations-reports/activation-summary",
"/api/admin/operations-reports/activation-summary/export"
],
"handler_consumer_job": [
"internal/handler/admin/operations_report.go",
"internal/exporter/operations_report_scene.go"
],
"application_service_query": [
"internal/application/operationsreport/generate.go",
"internal/query/operationsreport/aggregate.go"
],
"domain_state_amount": [
"internal/domain/operationsreport/metrics.go",
"internal/domain/operationsreport/purchase.go"
],
"store_migration_config": [
"internal/infrastructure/operationsreport/source.go",
"migrations/000231_create_operations_report_snapshot.up.sql"
],
"verification": {
"command": "rg -n \"MBPerGB|func Ratio|func CardAverageGB|func ForecastCardAverageGB|func PurchaseCountAsOf|ValidMainPackageStatuses\" internal/domain/operationsreport/metrics.go internal/domain/operationsreport/purchase.go",
"literal_output": [
"internal/domain/operationsreport/metrics.go:15:const MBPerGB = 1024",
"internal/domain/operationsreport/metrics.go:100:func Ratio(numerator, denominator int64) (float64, bool) {",
"internal/domain/operationsreport/metrics.go:109:func CardAverageGB(totalRealTrafficMB float64, denominator int64) (float64, bool) {",
"internal/domain/operationsreport/metrics.go:113:\taverage := totalRealTrafficMB / MBPerGB / float64(denominator)",
"internal/domain/operationsreport/metrics.go:120:func ForecastCardAverageGB(totalRealTrafficMB float64, denominator int64, endDate time.Time) (float64, bool) {",
"internal/domain/operationsreport/purchase.go:26:func PurchaseCountAsOf(ctx context.Context, source PurchaseCountSource, snapshotDate time.Time) (int64, error) {",
"internal/domain/operationsreport/purchase.go:34:var ValidMainPackageStatuses = []int{"
],
"exit_status": 0
}
},
{
"capability": "operations-report",
"requirement": "套餐续费指标口径",
"spec": "openspec/specs/operations-report/spec.md",
"entries": [
"/api/admin/operations-reports/package-renewal-summary",
"/api/admin/operations-reports/package-renewal-summary/export"
],
"handler_consumer_job": [
"internal/handler/admin/operations_report.go",
"internal/exporter/operations_report_scene.go"
],
"application_service_query": [
"internal/application/operationsreport/generate.go",
"internal/query/operationsreport/aggregate.go"
],
"domain_state_amount": [
"internal/domain/operationsreport/renewal.go",
"internal/domain/operationsreport/metrics.go"
],
"store_migration_config": [
"internal/infrastructure/operationsreport/source.go",
"migrations/000231_create_operations_report_snapshot.up.sql"
],
"verification": {
"command": "rg -n \"func IsRenewed|RenewalRate|RenewalDueAssetCount|RenewalRenewedAssetCount|due_assets\" internal/domain/operationsreport/renewal.go internal/domain/operationsreport/metrics.go internal/application/operationsreport/generate.go internal/query/operationsreport/aggregate.go",
"literal_output": [
"internal/domain/operationsreport/renewal.go:30:func IsRenewed(expired ExpiredMainUsage, candidates []MainUsageCandidate) bool {",
"internal/domain/operationsreport/metrics.go:148:func RenewalRate(renewed, due int64) (float64, bool) {",
"internal/application/operationsreport/generate.go:140:\t\t\tzap.Int64(\"renewal_due_asset_count\", snapshot.RenewalDueAssetCount),",
"internal/application/operationsreport/generate.go:236:\tsnapshot.RenewalDueAssetCount = int64(len(dueAssets))",
"internal/query/operationsreport/aggregate.go:304:\t\t\"COUNT(DISTINCT asset_type || ':' || asset_id) AS due_assets\","
],
"exit_status": 0
}
},
{
"capability": "operations-report",
"requirement": "报表维度分组与归属冻结",
"spec": "openspec/specs/operations-report/spec.md",
"entries": [
"/api/admin/operations-reports/activation-summary",
"/api/admin/operations-reports/activation-trend",
"/api/admin/operations-reports/package-renewal-summary",
"/api/admin/operations-reports/package-renewal-trend"
],
"handler_consumer_job": [
"internal/handler/admin/operations_report.go"
],
"application_service_query": [
"internal/query/operationsreport/aggregate.go"
],
"domain_state_amount": [
"internal/domain/operationsreport/dimension.go"
],
"store_migration_config": [
"internal/infrastructure/operationsreport/source.go",
"migrations/000231_create_operations_report_snapshot.up.sql"
],
"verification": {
"command": "rg -n \"DimensionAll =|PlaceholderUnset =|RootShopName|AgentAccountID\" internal/domain/operationsreport/dimension.go internal/infrastructure/operationsreport/source.go",
"literal_output": [
"internal/domain/operationsreport/dimension.go:27:const DimensionAll = \"全部\"",
"internal/domain/operationsreport/dimension.go:50:const PlaceholderUnset = \"未设置\"",
"internal/infrastructure/operationsreport/source.go:344:\tRootShopName string",
"internal/infrastructure/operationsreport/source.go:345:\tAgentAccountID *uint",
"internal/infrastructure/operationsreport/source.go:365:\t\tattribute.RootShopName = o.shops[rootID].ShopName",
"internal/infrastructure/operationsreport/source.go:369:\t\t\tattribute.AgentAccountID = &agentID"
],
"exit_status": 0
}
},
{
"capability": "operations-report",
"requirement": "报表查询与趋势",
"spec": "openspec/specs/operations-report/spec.md",
"entries": [
"/api/admin/operations-reports/activation-summary",
"/api/admin/operations-reports/activation-trend",
"/api/admin/operations-reports/package-renewal-summary",
"/api/admin/operations-reports/package-renewal-trend"
],
"handler_consumer_job": [
"internal/handler/admin/operations_report.go"
],
"application_service_query": [
"internal/query/operationsreport/query.go",
"internal/query/operationsreport/periods.go"
],
"domain_state_amount": [
"internal/domain/operationsreport/metrics.go"
],
"store_migration_config": [
"internal/model/dto/operations_report_dto.go"
],
"verification": {
"command": "rg -n \"utils.ParseTimeRange|granularity 只能为 day 或 month|has_snapshot|snapshot_dates\" internal/query/operationsreport/query.go internal/model/dto/operations_report_dto.go",
"literal_output": [
"internal/query/operationsreport/query.go:43:\tstart, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)",
"internal/query/operationsreport/query.go:134:\t\treturn nil, errors.New(errors.CodeInvalidParam, \"granularity 只能为 day 或 month\")",
"internal/model/dto/operations_report_dto.go:54:\tHasSnapshot bool `json:\"has_snapshot\" description:\"结束日是否存在快照;为 false 时累计类与派生指标为空且分组行为空集\"`",
"internal/model/dto/operations_report_dto.go:55:\tSnapshotDates []string `json:\"snapshot_dates\" description:\"区间内实际命中的快照日期yyyy-MM-dd\"`"
],
"exit_status": 0
}
},
{
"capability": "operations-report",
"requirement": "报表权限、数据范围与导出冻结",
"spec": "openspec/specs/operations-report/spec.md",
"entries": [
"/api/admin/operations-reports/activation-summary/export",
"/api/admin/operations-reports/package-renewal-summary/export"
],
"handler_consumer_job": [
"internal/routes/operations_report.go",
"internal/handler/admin/operations_report.go"
],
"application_service_query": [
"internal/service/export_task/service.go"
],
"domain_state_amount": [
"internal/exporter/time_filters.go"
],
"store_migration_config": [
"internal/exporter/registry.go",
"internal/exporter/operations_report_scene.go"
],
"verification": {
"command": "rg -n \"ensureOperationsReportExportAllowed|frozenQueryContext|PlatformManagementForbiddenMessage\" internal/exporter/operations_report_scene.go internal/routes/operations_report.go internal/query/operationsreport/query.go",
"literal_output": [
"internal/exporter/operations_report_scene.go:72:\tif err := ensureOperationsReportExportAllowed(params); err != nil {",
"internal/exporter/operations_report_scene.go:79:\tresponse, err := s.query.ActivationSummary(frozenQueryContext(ctx, params), request)",
"internal/exporter/operations_report_scene.go:289:func frozenQueryContext(ctx context.Context, params ExportParams) context.Context {",
"internal/exporter/operations_report_scene.go:298:func ensureOperationsReportExportAllowed(params ExportParams) error {",
"internal/exporter/operations_report_scene.go:302:\treturn errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)",
"internal/routes/operations_report.go:24:\t\t\treturn errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)"
],
"exit_status": 0
}
} }
] ]

View File

@@ -0,0 +1,246 @@
// Package operationsreport 实现运营报表日报快照的生成用例。
//
// 生成 = 读取生成时刻的只读事实(设备、当前有效关联卡、套餐使用记录、店铺与业务员、用户组、
// 套餐与套餐系列)→ 按报表口径域组装三张快照表的行与头行 → 在单事务内整日替换该日全部行并校验不变量。
// 用例本身不依赖 GORM、Fiber、Redis 或 Asynq读写分别由 FactsReader 与 SnapshotWriter 端口提供。
package operationsreport
import (
"context"
"time"
"go.uber.org/zap"
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"
)
// DeviceActivationFact 是一台未删除设备在生成时刻的冻结事实。
//
// 归属(店铺、业务员、用户组与代理的两个取值)是生成时刻取值,之后不再重新解析:
// 历史快照行不因后续归属变化被改写。
type DeviceActivationFact struct {
DeviceID uint
VirtualNo string
DeviceName string
DeviceModel string
Manufacturer string
ShopID *uint
ShopName string
RootShopID *uint
RootShopName string
AgentAccountID *uint
AgentAccountName string
BusinessOwnerAccountID *uint
BusinessOwnerName string
BusinessUserGroupID *uint
BusinessUserGroupName string
Purchased bool
Realnamed bool
Online bool
Active bool
RealTrafficMB float64
}
// RenewalFact 是一条到期事实及其续费判定结果在生成时刻的冻结事实。
type RenewalFact struct {
AssetType string
AssetID uint
AssetIdentifier string
ExpiredUsageID uint
PackageID uint
PackageName string
SeriesID *uint
SeriesName string
ShopID *uint
ShopName string
RootShopID *uint
RootShopName string
AgentAccountID *uint
AgentAccountName string
BusinessOwnerAccountID *uint
BusinessOwnerName string
BusinessUserGroupID *uint
BusinessUserGroupName string
Renewed bool
}
// DayFacts 是一个快照日的全部源事实。
type DayFacts struct {
Devices []DeviceActivationFact
Renewals []RenewalFact
}
// FactsReader 读取生成一份日报快照所需的只读事实。
type FactsReader interface {
// CountUndeletedDevicesAsOf 是采购数量口径的取值端口,只被 domain.PurchaseCountAsOf 调用。
CountUndeletedDevicesAsOf(ctx context.Context, snapshotDate time.Time) (int64, error)
// LoadDayFacts 读取该快照日的设备事实与到期事实。
LoadDayFacts(ctx context.Context, snapshotDate time.Time) (*DayFacts, error)
}
// SnapshotWriter 整日替换日报快照。
type SnapshotWriter interface {
// ReplaceDay 在单事务内先删除该日三张快照表的全部行,再整日写入头行与两类明细行,并校验不变量。
// 任一步失败必须回滚,使该日不残留部分口径。
ReplaceDay(ctx context.Context, snapshotDate time.Time, snapshot model.OperationsReportSnapshot,
activations []model.OperationsReportActivationRow, renewals []model.OperationsReportRenewalRow) error
}
// Generator 生成某一天的运营报表日报快照。
type Generator struct {
reader FactsReader
writer SnapshotWriter
logger *zap.Logger
}
// NewGenerator 创建日报快照生成用例。
func NewGenerator(reader FactsReader, writer SnapshotWriter, logger *zap.Logger) *Generator {
return &Generator{reader: reader, writer: writer, logger: logger}
}
// Generate 生成指定上海自然日的日报快照。
// 同一日期重复生成的结果等于最后一次执行的结果:整日替换保证不产生重复行或第二套口径。
func (g *Generator) Generate(ctx context.Context, snapshotDate time.Time) error {
if g == nil || g.reader == nil || g.writer == nil {
return errors.New(errors.CodeInternalError, "运营报表快照生成用例未配置")
}
day := domainreport.SnapshotDay(snapshotDate)
// 采购数量经唯一的采购数量口径函数取值(设计 D6
purchasedCount, err := domainreport.PurchaseCountAsOf(ctx, g.reader, day)
if err != nil {
return err
}
facts, err := g.reader.LoadDayFacts(ctx, day)
if err != nil {
return err
}
if facts == nil {
facts = &DayFacts{}
}
generatedAt := time.Now().UTC()
snapshot, activations, renewals, err := BuildSnapshotRows(day, generatedAt, purchasedCount, facts)
if err != nil {
return err
}
if err := g.writer.ReplaceDay(ctx, day, snapshot, activations, renewals); err != nil {
return err
}
if g.logger != nil {
g.logger.Info("运营报表日报快照生成完成",
zap.String("snapshot_date", domainreport.FormatSnapshotDay(day)),
zap.Int64("purchased_device_count", snapshot.PurchasedDeviceCount),
zap.Int64("activated_device_count", snapshot.ActivatedDeviceCount),
zap.Int64("online_device_count", snapshot.OnlineDeviceCount),
zap.Int64("active_device_count", snapshot.ActiveDeviceCount),
zap.Int64("renewal_due_asset_count", snapshot.RenewalDueAssetCount),
zap.Int64("renewal_renewed_asset_count", snapshot.RenewalRenewedAssetCount),
)
}
return nil
}
// BuildSnapshotRows 把生成时刻的事实组装为头行与两类明细行。
//
// 头行值是明细行的汇总值(不是另一套独立读数),因此
// 「同一快照日期、任一受支持维度下分组行各指标之和等于头行值」由构造保证;
// 到期资产数与续费资产数按资产去重,续费资产恒为到期资产的子集。
func BuildSnapshotRows(day time.Time, generatedAt time.Time, purchasedCount int64, facts *DayFacts) (
model.OperationsReportSnapshot, []model.OperationsReportActivationRow, []model.OperationsReportRenewalRow, error) {
activations := make([]model.OperationsReportActivationRow, 0, len(facts.Devices))
snapshot := model.OperationsReportSnapshot{
SnapshotDate: day,
GeneratedAt: generatedAt,
}
for _, fact := range facts.Devices {
if fact.Purchased {
snapshot.PurchasedDeviceCount++
}
if fact.Realnamed {
snapshot.ActivatedDeviceCount++
}
if fact.Online {
snapshot.OnlineDeviceCount++
}
if fact.Active {
snapshot.ActiveDeviceCount++
}
snapshot.TotalRealTrafficMB = domainreport.Round2(snapshot.TotalRealTrafficMB + fact.RealTrafficMB)
activations = append(activations, model.OperationsReportActivationRow{
SnapshotDate: day,
DeviceID: fact.DeviceID,
VirtualNo: fact.VirtualNo,
DeviceName: fact.DeviceName,
DeviceModel: fact.DeviceModel,
Manufacturer: fact.Manufacturer,
ShopID: fact.ShopID,
ShopName: fact.ShopName,
RootShopID: fact.RootShopID,
RootShopName: fact.RootShopName,
AgentAccountID: fact.AgentAccountID,
AgentAccountName: fact.AgentAccountName,
BusinessOwnerAccountID: fact.BusinessOwnerAccountID,
BusinessOwnerName: fact.BusinessOwnerName,
BusinessUserGroupID: fact.BusinessUserGroupID,
BusinessUserGroupName: fact.BusinessUserGroupName,
Purchased: fact.Purchased,
Realnamed: fact.Realnamed,
Online: fact.Online,
Active: fact.Active,
RealTrafficMB: domainreport.Round2(fact.RealTrafficMB),
})
}
if snapshot.PurchasedDeviceCount != purchasedCount {
// 采购数量口径函数与明细行必须描述同一总体;不一致说明本次读取跨越了设备增删,
// 与其写入一套自相矛盾的快照,不如让该日失败(重试沿用同一目标日期,不产生部分口径)。
return model.OperationsReportSnapshot{}, nil, nil, errors.New(errors.CodeDatabaseError,
"运营报表快照的采购数量与设备事实不一致,本次生成已终止")
}
renewals := make([]model.OperationsReportRenewalRow, 0, len(facts.Renewals))
dueAssets := make(map[assetKey]struct{}, len(facts.Renewals))
renewedAssets := make(map[assetKey]struct{}, len(facts.Renewals))
for _, fact := range facts.Renewals {
key := assetKey{AssetType: fact.AssetType, AssetID: fact.AssetID}
dueAssets[key] = struct{}{}
if fact.Renewed {
renewedAssets[key] = struct{}{}
}
renewals = append(renewals, model.OperationsReportRenewalRow{
SnapshotDate: day,
AssetType: fact.AssetType,
AssetID: fact.AssetID,
AssetIdentifier: fact.AssetIdentifier,
ExpiredUsageID: fact.ExpiredUsageID,
PackageID: fact.PackageID,
PackageName: fact.PackageName,
SeriesID: fact.SeriesID,
SeriesName: fact.SeriesName,
ShopID: fact.ShopID,
ShopName: fact.ShopName,
RootShopID: fact.RootShopID,
RootShopName: fact.RootShopName,
AgentAccountID: fact.AgentAccountID,
AgentAccountName: fact.AgentAccountName,
BusinessOwnerAccountID: fact.BusinessOwnerAccountID,
BusinessOwnerName: fact.BusinessOwnerName,
BusinessUserGroupID: fact.BusinessUserGroupID,
BusinessUserGroupName: fact.BusinessUserGroupName,
Renewed: fact.Renewed,
})
}
snapshot.RenewalDueAssetCount = int64(len(dueAssets))
snapshot.RenewalRenewedAssetCount = int64(len(renewedAssets))
return snapshot, activations, renewals, nil
}
// assetKey 是续费指标的资产去重键(沿用既有载体类型取值)。
type assetKey struct {
AssetType string
AssetID uint
}

View File

@@ -34,6 +34,7 @@ import (
h5PopupQuery "github.com/break/junhong_cmp_fiber/internal/query/h5popup" h5PopupQuery "github.com/break/junhong_cmp_fiber/internal/query/h5popup"
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration" integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification" notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert" packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling" priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling"
@@ -189,6 +190,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit) popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit)
popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB) popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB)
packageTrafficAlertQuery := packagetrafficalertquery.NewQuery(deps.DB) packageTrafficAlertQuery := packagetrafficalertquery.NewQuery(deps.DB)
operationsReportQuery := operationsreportquery.NewQuery(deps.DB)
return &Handlers{ return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate), Auth: authHandler.NewHandler(svc.Auth, validate),
@@ -288,6 +290,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord), PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate), PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate),
AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(svc.AssetAutoRenewal, validate), AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(svc.AssetAutoRenewal, validate),
OperationsReport: admin.NewOperationsReportHandler(operationsReportQuery, svc.ExportTask, validate),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation), ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing), ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant), ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),

View File

@@ -84,6 +84,7 @@ type Handlers struct {
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
PackageTrafficAlert *admin.PackageTrafficAlertHandler PackageTrafficAlert *admin.PackageTrafficAlertHandler
AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler
OperationsReport *admin.OperationsReportHandler
ClientWechat *app.ClientWechatHandler ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler SystemConfig *admin.SystemConfigHandler

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

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

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

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

View File

@@ -0,0 +1,334 @@
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 = "合计"
// OperationsActivationDataSource 设备激活情况报表导出数据源。
//
// 导出列与页面展示字段一致并包含合计行,不含任何文字总结;
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
// 本场景只对超级管理员与平台账号开放:受控入口已做角色门禁,这里再按任务内冻结的账号类型复核一次,
// 阻止通过通用导出入口以代理身份创建本场景任务后读到运营报表数据。
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{
response.GroupName, "采购数量", "累计激活数", "激活率", "新增激活数",
"累计在网数", "活跃用户数", "累计用量(GB)", "单用户卡均(GB)", "含零预测卡均(GB)", "不含零预测卡均(GB)",
}
rows := make([][]string, 0, len(response.Items)+1)
for _, item := range response.Items {
rows = append(rows, []string{
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 套餐续费情况报表导出数据源。
//
// 导出列与页面展示字段一致并包含合计行,不含任何文字总结;
// 行集合与汇总查询完全一致:两侧共用同一个查询实现,因此筛选与口径不会漂移。
// 本场景同样按任务内冻结的账号类型复核导出资格。
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{response.GroupName, "到期资产数", "续费资产数", "续费率", "新增未续费数"}
rows := make([][]string, 0, len(response.Items)+1)
for _, item := range response.Items {
rows = append(rows, []string{
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)
}

View File

@@ -39,6 +39,8 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
NewCommissionRecordDataSource(db), NewCommissionRecordDataSource(db),
NewPackageTrafficAlertDataSource(db), NewPackageTrafficAlertDataSource(db),
NewExpiringAssetDataSource(db), NewExpiringAssetDataSource(db),
NewOperationsActivationDataSource(db),
NewOperationsRenewalDataSource(db),
) )
} }
@@ -77,7 +79,9 @@ func IsSupportedScene(scene string) bool {
constants.ExportTaskSceneExchange, constants.ExportTaskSceneExchange,
constants.ExportTaskSceneCommissionRecord, constants.ExportTaskSceneCommissionRecord,
constants.ExportTaskScenePackageTrafficAlert, constants.ExportTaskScenePackageTrafficAlert,
constants.ExportTaskSceneExpiringAsset: constants.ExportTaskSceneExpiringAsset,
constants.ExportTaskSceneOperationsActivation,
constants.ExportTaskSceneOperationsRenewal:
return true return true
default: default:
return false return false

View File

@@ -25,6 +25,9 @@ var timeFilterScenes = map[string]struct{}{
constants.ExportTaskSceneCommissionRecord: {}, constants.ExportTaskSceneCommissionRecord: {},
constants.ExportTaskSceneExpiringAsset: {}, constants.ExportTaskSceneExpiringAsset: {},
constants.ExportTaskScenePackageTrafficAlert: {}, constants.ExportTaskScenePackageTrafficAlert: {},
// 运营报表的两个导出场景在创建期冻结 start_time/end_time 与分组维度,执行期只按冻结值严格解析。
constants.ExportTaskSceneOperationsActivation: {},
constants.ExportTaskSceneOperationsRenewal: {},
} }
// legacyTimeFilterKeys 是受影响场景必须拒绝的旧时间筛选键。 // legacyTimeFilterKeys 是受影响场景必须拒绝的旧时间筛选键。

View File

@@ -0,0 +1,172 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
exportTaskService "github.com/break/junhong_cmp_fiber/internal/service/export_task"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// OperationsReportHandler 运营报表 Handler。
// 查询与导出只对超级管理员与平台账号开放路由组已有角色门禁Handler 不做任何跳过业务校验的分支。
type OperationsReportHandler struct {
query *operationsreportquery.Query
exportService *exportTaskService.Service
validator *validator.Validate
}
// NewOperationsReportHandler 创建运营报表 Handler。
func NewOperationsReportHandler(query *operationsreportquery.Query,
exportService *exportTaskService.Service, validator *validator.Validate) *OperationsReportHandler {
return &OperationsReportHandler{query: query, exportService: exportService, validator: validator}
}
// ActivationSummary 查询设备激活情况汇总。
// GET /api/admin/operations-reports/activation-summary
func (h *OperationsReportHandler) ActivationSummary(c *fiber.Ctx) error {
var req dto.OperationsActivationSummaryRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询设备激活情况汇总参数不合法", &req, err))
}
result, err := h.query.ActivationSummary(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ActivationTrend 查询设备激活情况日/月趋势。
// GET /api/admin/operations-reports/activation-trend
func (h *OperationsReportHandler) ActivationTrend(c *fiber.Ctx) error {
var req dto.OperationsActivationTrendRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询设备激活情况趋势参数不合法", &req, err))
}
result, err := h.query.ActivationTrend(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// RenewalSummary 查询套餐续费情况汇总。
// GET /api/admin/operations-reports/package-renewal-summary
func (h *OperationsReportHandler) RenewalSummary(c *fiber.Ctx) error {
var req dto.OperationsRenewalSummaryRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐续费情况汇总参数不合法", &req, err))
}
result, err := h.query.RenewalSummary(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// RenewalTrend 查询套餐续费情况日/月趋势。
// GET /api/admin/operations-reports/package-renewal-trend
func (h *OperationsReportHandler) RenewalTrend(c *fiber.Ctx) error {
var req dto.OperationsRenewalTrendRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐续费情况趋势参数不合法", &req, err))
}
result, err := h.query.RenewalTrend(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ExportActivationSummary 创建设备激活情况报表异步导出任务。
// POST /api/admin/operations-reports/activation-summary/export
// 受控入口:创建时冻结筛选条件、操作者与可见店铺范围,非法时间在创建期拒绝。
func (h *OperationsReportHandler) ExportActivationSummary(c *fiber.Ctx) error {
request, err := h.parseExportRequest(c, constants.ExportTaskSceneOperationsActivation, domainreport.IsActivationDimension,
"导出设备激活情况参数不合法")
if err != nil {
return err
}
return h.createExportTask(c, constants.ExportTaskSceneOperationsActivation, request)
}
// ExportRenewalSummary 创建套餐续费情况报表异步导出任务。
// POST /api/admin/operations-reports/package-renewal-summary/export
// 受控入口:创建时冻结筛选条件、操作者与可见店铺范围,非法时间在创建期拒绝。
func (h *OperationsReportHandler) ExportRenewalSummary(c *fiber.Ctx) error {
request, err := h.parseExportRequest(c, constants.ExportTaskSceneOperationsRenewal, domainreport.IsRenewalDimension,
"导出套餐续费情况参数不合法")
if err != nil {
return err
}
return h.createExportTask(c, constants.ExportTaskSceneOperationsRenewal, request)
}
// createExportTask 复用既有导出任务创建路径:创建期冻结操作者、可见店铺范围与筛选快照。
func (h *OperationsReportHandler) createExportTask(c *fiber.Ctx, scene string, request dto.ExportOperationsReportRequest) error {
createRequest := dto.CreateExportTaskRequest{
Scene: scene,
Format: request.Format,
Query: map[string]interface{}{"filters": exportOperationsReportFilters(request)},
}
result, err := h.exportService.CreateTask(c.UserContext(), &createRequest)
if err != nil {
return err
}
return response.Success(c, result)
}
// parseExportRequest 解析并校验受控导出请求。
// 时间边界在创建期用统一严格解析器校验(格式非法或开始晚于结束一律拒绝),
// 分组维度必须属于该报表支持的维度集合。
func (h *OperationsReportHandler) parseExportRequest(c *fiber.Ctx, scene string,
isDimension func(string) bool, message string) (dto.ExportOperationsReportRequest, error) {
var request dto.ExportOperationsReportRequest
if err := c.BodyParser(&request); err != nil {
return request, errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&request); err != nil {
return request, errors.New(errors.CodeInvalidParam, validation.Message(message, &request, err))
}
if _, _, err := utils.ParseTimeRange(request.StartTime, request.EndTime); err != nil {
return request, err
}
if request.GroupBy != "" && !isDimension(request.GroupBy) {
return request, errors.New(errors.CodeInvalidParam, "不支持的分组维度 "+request.GroupBy)
}
return request, nil
}
// exportOperationsReportFilters 把导出请求转换为导出任务的筛选快照。
// 时间边界按统一契约冻结为纯字符串值,创建期由导出任务服务规范化为 UTC RFC3339 秒级;
// 分组维度恒定冻结(未选择时为空串),执行期不再重新解释请求。
func exportOperationsReportFilters(request dto.ExportOperationsReportRequest) map[string]interface{} {
filters := make(map[string]interface{}, 3)
if request.StartTime != "" {
filters["start_time"] = request.StartTime
}
if request.EndTime != "" {
filters["end_time"] = request.EndTime
}
filters["group_by"] = request.GroupBy
return filters
}

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
}

View File

@@ -4,7 +4,7 @@ import "time"
// CreateExportTaskRequest 创建导出任务请求。 // CreateExportTaskRequest 创建导出任务请求。
type CreateExportTaskRequest struct { type CreateExportTaskRequest struct {
Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产)"` Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset operations_activation operations_renewal" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表)"`
Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"` Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"`
Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象可选);时间筛选固定使用 filters.start_time 与 filters.end_time取值必须为带显式时区的 RFC3339 秒级时间"` Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象可选);时间筛选固定使用 filters.start_time 与 filters.end_time取值必须为带显式时区的 RFC3339 秒级时间"`
} }
@@ -22,7 +22,7 @@ type CreateExportTaskResponse struct {
type ListExportTaskRequest struct { type ListExportTaskRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产)"` Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset operations_activation operations_renewal" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"` Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"` StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"` EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
@@ -33,7 +33,7 @@ type ExportTaskItem struct {
ID uint `json:"id" description:"任务ID"` ID uint `json:"id" description:"任务ID"`
TaskID uint `json:"task_id" description:"任务ID"` TaskID uint `json:"task_id" description:"任务ID"`
TaskNo string `json:"task_no" description:"任务编号"` TaskNo string `json:"task_no" description:"任务编号"`
Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警)"` Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, operations_activation:设备激活情况报表, operations_renewal:套餐续费情况报表)"`
Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"` Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"`
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"` Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
StatusName string `json:"status_name" description:"任务状态名称(中文)"` StatusName string `json:"status_name" description:"任务状态名称(中文)"`

View File

@@ -0,0 +1,120 @@
package dto
// OperationsActivationSummaryRequest 是设备激活情况汇总查询请求。
// 时间筛选只接受带显式时区的 RFC3339 秒级时间与闭区间;快照日期按「零点落入区间」判定。
type OperationsActivationSummaryRequest struct {
StartTime string `json:"start_time" query:"start_time" description:"快照日期起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"快照日期结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
GroupBy string `json:"group_by" query:"group_by" validate:"omitempty,oneof=device_name device_model manufacturer business_user_group agent shop business_owner" description:"分组维度 (device_name:设备名称, device_model:设备型号, manufacturer:制造商, business_user_group:用户组, agent:代理, shop:店铺, business_owner:业务员);不传则汇总为一行「全部」"`
}
// OperationsActivationTrendRequest 是设备激活情况趋势查询请求。
type OperationsActivationTrendRequest struct {
StartTime string `json:"start_time" query:"start_time" description:"快照日期起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"快照日期结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T00:00:00+08:00"`
Granularity string `json:"granularity" query:"granularity" validate:"omitempty,oneof=day month" description:"趋势粒度 (day:按日, month:按月);不传按日"`
GroupBy string `json:"group_by" query:"group_by" validate:"omitempty,oneof=device_name device_model manufacturer business_user_group agent shop business_owner" description:"分组维度;不传则每个期返回一行「全部」"`
}
// OperationsRenewalSummaryRequest 是套餐续费情况汇总查询请求。
type OperationsRenewalSummaryRequest struct {
StartTime string `json:"start_time" query:"start_time" description:"快照日期起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"快照日期结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
GroupBy string `json:"group_by" query:"group_by" validate:"omitempty,oneof=package_series package_name business_user_group agent shop business_owner" description:"分组维度 (package_series:套餐系列, package_name:套餐名称, business_user_group:用户组, agent:代理, shop:店铺, business_owner:业务员);不传则汇总为一行「全部」"`
}
// OperationsRenewalTrendRequest 是套餐续费情况趋势查询请求。
type OperationsRenewalTrendRequest struct {
StartTime string `json:"start_time" query:"start_time" description:"快照日期起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"快照日期结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T00:00:00+08:00"`
Granularity string `json:"granularity" query:"granularity" validate:"omitempty,oneof=day month" description:"趋势粒度 (day:按日, month:按月);不传按日"`
GroupBy string `json:"group_by" query:"group_by" validate:"omitempty,oneof=package_series package_name business_user_group agent shop business_owner" description:"分组维度;不传则每个期返回一行「全部」"`
}
// OperationsActivationSummaryItem 是设备激活情况的一行指标。
// 累计类与其派生指标取所选结束日的快照;结束日无快照时全部为空值(不回退更早快照)。
// 分母为零的比率与卡均为空值;新增激活数允许为负,不设零下限。
type OperationsActivationSummaryItem struct {
GroupValue string `json:"group_value" description:"分组列值(未选择分组维度时为「全部」)"`
PurchasedDeviceCount *int64 `json:"purchased_device_count" description:"采购数量:截至快照日系统内未删除的设备数"`
ActivatedDeviceCount *int64 `json:"activated_device_count" description:"累计激活数:任一当前有效关联卡已实名的设备数"`
ActivationRate *float64 `json:"activation_rate" description:"激活率:累计激活数 / 采购数量,保留两位小数;采购数量为零时为空"`
NewActivatedDeviceCount *int64 `json:"new_activated_device_count" description:"新增激活数:结束日累计减去基期累计,允许为负"`
OnlineDeviceCount *int64 `json:"online_device_count" description:"累计在网数:已实名且存在有效主套餐的设备数"`
ActiveDeviceCount *int64 `json:"active_device_count" description:"活跃用户数:真流量合计大于零的设备数"`
TotalRealTrafficGB *float64 `json:"total_real_traffic_gb" description:"累计用量(GB):按 1 GB = 1024 MB 折算,保留两位小数"`
PerUserAverageGB *float64 `json:"per_user_average_gb" description:"单用户卡均(GB):累计用量 / 累计在网数,保留两位小数;在网数为零时为空"`
ForecastAverageIncludingZeroGB *float64 `json:"forecast_average_including_zero_gb" description:"含零预测卡均(GB):按累计在网数为分母、结束日所在当月年化"`
ForecastAverageExcludingZeroGB *float64 `json:"forecast_average_excluding_zero_gb" description:"不含零预测卡均(GB):按活跃用户数为分母、结束日所在当月年化"`
}
// OperationsActivationSummaryResponse 是设备激活情况汇总响应。
// HasSnapshot 表示所选结束日是否存在快照SnapshotDates 是区间内实际命中的快照日期集合。
type OperationsActivationSummaryResponse struct {
HasSnapshot bool `json:"has_snapshot" description:"结束日是否存在快照;为 false 时累计类与派生指标为空且分组行为空集"`
SnapshotDates []string `json:"snapshot_dates" description:"区间内实际命中的快照日期yyyy-MM-dd"`
GroupBy string `json:"group_by" description:"分组维度编码;空表示未分组"`
GroupName string `json:"group_name" description:"分组维度中文名;未分组时为「全部」"`
Totals *OperationsActivationSummaryItem `json:"totals" description:"头行合计;结束日无快照时为空"`
Items []OperationsActivationSummaryItem `json:"items" description:"分组行;未选择分组维度时只有一行「全部」"`
}
// OperationsActivationTrendPoint 是设备激活情况趋势的一个期点。
// 累计类指标取该期最后一个有快照日的快照值;新增激活数取相邻期同口径之差,任一侧无快照时为空。
type OperationsActivationTrendPoint struct {
Period string `json:"period" description:"期标识(按日为 yyyy-MM-dd按月为 yyyy-MM"`
OperationsActivationSummaryItem
}
// OperationsActivationTrendResponse 是设备激活情况趋势响应。
// 无快照的期不出现;后端只返回数据,图表渲染由前端负责。
type OperationsActivationTrendResponse struct {
Granularity string `json:"granularity" description:"趋势粒度 (day|month)"`
GroupBy string `json:"group_by" description:"分组维度编码;空表示未分组"`
GroupName string `json:"group_name" description:"分组维度中文名;未分组时为「全部」"`
Points []OperationsActivationTrendPoint `json:"points" description:"趋势点;无快照的期不出现"`
}
// OperationsRenewalSummaryItem 是套餐续费情况的一行指标。
// 到期与续费均按资产去重,续费资产恒为到期资产的子集,续费率不超过 100% 由构造保证。
type OperationsRenewalSummaryItem struct {
GroupValue string `json:"group_value" description:"分组列值(未选择分组维度时为「全部」)"`
DueAssetCount *int64 `json:"due_asset_count" description:"到期资产数:未退款主套餐到期日为统计期的资产数(按资产去重)"`
RenewedAssetCount *int64 `json:"renewed_asset_count" description:"续费资产数:到期资产中已续费的资产数(按资产去重)"`
RenewalRate *float64 `json:"renewal_rate" description:"续费率:续费资产数 / 到期资产数,保留两位小数;到期数为零时为空"`
NewUnrenewedAssetCount *int64 `json:"new_unrenewed_asset_count" description:"新增未续费数:到期资产数减续费资产数,不小于零"`
}
// OperationsRenewalSummaryResponse 是套餐续费情况汇总响应。
type OperationsRenewalSummaryResponse struct {
HasSnapshot bool `json:"has_snapshot" description:"结束日是否存在快照;为 false 时全部指标为空且分组行为空集"`
SnapshotDates []string `json:"snapshot_dates" description:"区间内实际命中的快照日期yyyy-MM-dd"`
GroupBy string `json:"group_by" description:"分组维度编码;空表示未分组"`
GroupName string `json:"group_name" description:"分组维度中文名;未分组时为「全部」"`
Totals *OperationsRenewalSummaryItem `json:"totals" description:"头行合计;结束日无快照时为空"`
Items []OperationsRenewalSummaryItem `json:"items" description:"分组行;未选择分组维度时只有一行「全部」"`
}
// OperationsRenewalTrendPoint 是套餐续费情况趋势的一个期点。
// 到期与续费都是期内流式指标:期内同一资产多次到期或多条续费各计一次。
type OperationsRenewalTrendPoint struct {
Period string `json:"period" description:"期标识(按日为 yyyy-MM-dd按月为 yyyy-MM"`
OperationsRenewalSummaryItem
}
// OperationsRenewalTrendResponse 是套餐续费情况趋势响应。
type OperationsRenewalTrendResponse struct {
Granularity string `json:"granularity" description:"趋势粒度 (day|month)"`
GroupBy string `json:"group_by" description:"分组维度编码;空表示未分组"`
GroupName string `json:"group_name" description:"分组维度中文名;未分组时为「全部」"`
Points []OperationsRenewalTrendPoint `json:"points" description:"趋势点;无快照的期不出现"`
}
// ExportOperationsReportRequest 是设备激活情况与套餐续费情况报表的受控导出请求。
// 时间边界在创建期冻结为 UTC RFC3339 秒级字符串,执行期只按冻结值严格解析。
type ExportOperationsReportRequest struct {
Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"`
StartTime string `json:"start_time" description:"快照日期起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" description:"快照日期结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T00:00:00+08:00"`
GroupBy string `json:"group_by" description:"分组维度编码;不传则导出唯一一行「全部」"`
}

View File

@@ -0,0 +1,97 @@
package model
import "time"
// OperationsReportSnapshot 运营报表日级快照头行。
//
// 一个上海自然日一行,同时承担三件事:①「该日是否有快照」的唯一判定;
// ②日/月趋势与新增指标的序列来源O(1) 读取,不必扫明细行);
// ③合计行与分组行不变量校验的权威值。整日幂等替换,因此不设软删除列。
type OperationsReportSnapshot struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
SnapshotDate time.Time `gorm:"column:snapshot_date;type:date;not null" json:"snapshot_date"`
PurchasedDeviceCount int64 `gorm:"column:purchased_device_count;type:bigint;not null;default:0" json:"purchased_device_count"`
ActivatedDeviceCount int64 `gorm:"column:activated_device_count;type:bigint;not null;default:0" json:"activated_device_count"`
OnlineDeviceCount int64 `gorm:"column:online_device_count;type:bigint;not null;default:0" json:"online_device_count"`
ActiveDeviceCount int64 `gorm:"column:active_device_count;type:bigint;not null;default:0" json:"active_device_count"`
TotalRealTrafficMB float64 `gorm:"column:total_real_traffic_mb;type:numeric(20,2);not null;default:0" json:"total_real_traffic_mb"`
RenewalDueAssetCount int64 `gorm:"column:renewal_due_asset_count;type:bigint;not null;default:0" json:"renewal_due_asset_count"`
RenewalRenewedAssetCount int64 `gorm:"column:renewal_renewed_asset_count;type:bigint;not null;default:0" json:"renewal_renewed_asset_count"`
GeneratedAt time.Time `gorm:"column:generated_at;type:timestamp;not null" json:"generated_at"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
}
// TableName 指定运营报表日级快照头行表名。
func (OperationsReportSnapshot) TableName() string {
return "tb_operations_report_snapshot"
}
// OperationsReportActivationRow 运营报表设备激活快照行。
//
// 设备粒度,一行对应一台未删除设备,冻结生成时刻的设备属性、归属与四项指标。
// 归属(店铺、业务员、用户组与代理的两个取值)是一次性冻结值,不因后续变化被改写。
type OperationsReportActivationRow struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
SnapshotDate time.Time `gorm:"column:snapshot_date;type:date;not null" json:"snapshot_date"`
DeviceID uint `gorm:"column:device_id;type:bigint;not null" json:"device_id"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(100);not null;default:''" json:"virtual_no"`
DeviceName string `gorm:"column:device_name;type:varchar(255);not null;default:''" json:"device_name"`
DeviceModel string `gorm:"column:device_model;type:varchar(100);not null;default:''" json:"device_model"`
Manufacturer string `gorm:"column:manufacturer;type:varchar(255);not null;default:''" json:"manufacturer"`
ShopID *uint `gorm:"column:shop_id;type:bigint" json:"shop_id,omitempty"`
ShopName string `gorm:"column:shop_name;type:varchar(100);not null;default:''" json:"shop_name"`
RootShopID *uint `gorm:"column:root_shop_id;type:bigint" json:"root_shop_id,omitempty"`
RootShopName string `gorm:"column:root_shop_name;type:varchar(100);not null;default:''" json:"root_shop_name"`
AgentAccountID *uint `gorm:"column:agent_account_id;type:bigint" json:"agent_account_id,omitempty"`
AgentAccountName string `gorm:"column:agent_account_name;type:varchar(255);not null;default:''" json:"agent_account_name"`
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id;type:bigint" json:"business_owner_account_id,omitempty"`
BusinessOwnerName string `gorm:"column:business_owner_name;type:varchar(255);not null;default:''" json:"business_owner_name"`
BusinessUserGroupID *uint `gorm:"column:business_user_group_id;type:bigint" json:"business_user_group_id,omitempty"`
BusinessUserGroupName string `gorm:"column:business_user_group_name;type:varchar(100);not null;default:''" json:"business_user_group_name"`
Purchased bool `gorm:"column:purchased;type:boolean;not null;default:false" json:"purchased"`
Realnamed bool `gorm:"column:realnamed;type:boolean;not null;default:false" json:"realnamed"`
Online bool `gorm:"column:online;type:boolean;not null;default:false" json:"online"`
Active bool `gorm:"column:active;type:boolean;not null;default:false" json:"active"`
RealTrafficMB float64 `gorm:"column:real_traffic_mb;type:numeric(20,2);not null;default:0" json:"real_traffic_mb"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
}
// TableName 指定运营报表设备激活快照行表名。
func (OperationsReportActivationRow) TableName() string {
return "tb_operations_report_activation_row"
}
// OperationsReportRenewalRow 运营报表套餐续费快照行。
//
// 到期事件粒度,一行对应一条在快照日到期的主套餐使用记录;
// 「续费」是该到期资产在生成时刻是否已存在另一条未退款主套餐的事实,因此挂在到期行上。
type OperationsReportRenewalRow struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
SnapshotDate time.Time `gorm:"column:snapshot_date;type:date;not null" json:"snapshot_date"`
AssetType string `gorm:"column:asset_type;type:varchar(20);not null" json:"asset_type"`
AssetID uint `gorm:"column:asset_id;type:bigint;not null" json:"asset_id"`
AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);not null;default:''" json:"asset_identifier"`
ExpiredUsageID uint `gorm:"column:expired_usage_id;type:bigint;not null" json:"expired_usage_id"`
PackageID uint `gorm:"column:package_id;type:bigint;not null" json:"package_id"`
PackageName string `gorm:"column:package_name;type:varchar(255);not null;default:''" json:"package_name"`
SeriesID *uint `gorm:"column:series_id;type:bigint" json:"series_id,omitempty"`
SeriesName string `gorm:"column:series_name;type:varchar(255);not null;default:''" json:"series_name"`
ShopID *uint `gorm:"column:shop_id;type:bigint" json:"shop_id,omitempty"`
ShopName string `gorm:"column:shop_name;type:varchar(100);not null;default:''" json:"shop_name"`
RootShopID *uint `gorm:"column:root_shop_id;type:bigint" json:"root_shop_id,omitempty"`
RootShopName string `gorm:"column:root_shop_name;type:varchar(100);not null;default:''" json:"root_shop_name"`
AgentAccountID *uint `gorm:"column:agent_account_id;type:bigint" json:"agent_account_id,omitempty"`
AgentAccountName string `gorm:"column:agent_account_name;type:varchar(255);not null;default:''" json:"agent_account_name"`
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id;type:bigint" json:"business_owner_account_id,omitempty"`
BusinessOwnerName string `gorm:"column:business_owner_name;type:varchar(255);not null;default:''" json:"business_owner_name"`
BusinessUserGroupID *uint `gorm:"column:business_user_group_id;type:bigint" json:"business_user_group_id,omitempty"`
BusinessUserGroupName string `gorm:"column:business_user_group_name;type:varchar(100);not null;default:''" json:"business_user_group_name"`
Renewed bool `gorm:"column:renewed;type:boolean;not null;default:false" json:"renewed"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
}
// TableName 指定运营报表套餐续费快照行表名。
func (OperationsReportRenewalRow) TableName() string {
return "tb_operations_report_renewal_row"
}

View File

@@ -0,0 +1,375 @@
package operationsreport
import (
"context"
"strconv"
"strings"
"time"
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// groupKey 是一个分组行在结果中的唯一键value 是展示值identity 是实体键。
// 同一展示值下的不同实体不会被合并(例如两个店铺同名为「未设置」仍各占一行)。
type groupKey struct {
value string
identity string
}
// activationMetrics 是一行(头行或分组聚合)的激活指标原始值。
type activationMetrics struct {
PurchasedDeviceCount int64
ActivatedDeviceCount int64
OnlineDeviceCount int64
ActiveDeviceCount int64
TotalRealTrafficMB float64
}
// metricsFromHead 由快照头行构造激活指标原始值。
func metricsFromHead(head model.OperationsReportSnapshot) activationMetrics {
return activationMetrics{
PurchasedDeviceCount: head.PurchasedDeviceCount,
ActivatedDeviceCount: head.ActivatedDeviceCount,
OnlineDeviceCount: head.OnlineDeviceCount,
ActiveDeviceCount: head.ActiveDeviceCount,
TotalRealTrafficMB: head.TotalRealTrafficMB,
}
}
// item 按报表口径组装一行激活指标。
// 分母为零的比率与卡均为空值;累计用量按 1 GB = 1024 MB 折算;
// 预测卡均的当月口径取所选结束日所在上海自然月。
func (m activationMetrics) item(groupValue string, endDay time.Time) dto.OperationsActivationSummaryItem {
purchased := m.PurchasedDeviceCount
activated := m.ActivatedDeviceCount
online := m.OnlineDeviceCount
active := m.ActiveDeviceCount
item := dto.OperationsActivationSummaryItem{
GroupValue: groupValue,
PurchasedDeviceCount: &purchased,
ActivatedDeviceCount: &activated,
OnlineDeviceCount: &online,
ActiveDeviceCount: &active,
}
if rate, ok := domainreport.Ratio(activated, purchased); ok {
item.ActivationRate = &rate
}
trafficGB := domainreport.Round2(m.TotalRealTrafficMB / domainreport.MBPerGB)
item.TotalRealTrafficGB = &trafficGB
if average, ok := domainreport.CardAverageGB(m.TotalRealTrafficMB, online); ok {
item.PerUserAverageGB = &average
}
if forecast, ok := domainreport.ForecastCardAverageGB(m.TotalRealTrafficMB, online, endDay); ok {
item.ForecastAverageIncludingZeroGB = &forecast
}
if forecast, ok := domainreport.ForecastCardAverageGB(m.TotalRealTrafficMB, active, endDay); ok {
item.ForecastAverageExcludingZeroGB = &forecast
}
return item
}
// renewalMetrics 是一组(头行或分组聚合)的到期与续费资产数。
type renewalMetrics struct {
DueAssets int64
RenewedAssets int64
}
// renewal 按报表口径组装一行续费指标。
// 续费率分母为零时为空值;新增未续费数为到期数减续费数,不小于零。
func (m renewalMetrics) item(groupValue string) dto.OperationsRenewalSummaryItem {
due := m.DueAssets
renewed := m.RenewedAssets
unrenewed := due - renewed
if unrenewed < 0 {
unrenewed = 0
}
item := dto.OperationsRenewalSummaryItem{
GroupValue: groupValue,
DueAssetCount: &due,
RenewedAssetCount: &renewed,
NewUnrenewedAssetCount: &unrenewed,
}
if rate, ok := domainreport.RenewalRate(renewed, due); ok {
item.RenewalRate = &rate
}
return item
}
// renewalMetricsFromHead 由快照头行构造续费指标原始值。
func renewalMetricsFromHead(head model.OperationsReportSnapshot) renewalMetrics {
return renewalMetrics{DueAssets: head.RenewalDueAssetCount, RenewedAssets: head.RenewalRenewedAssetCount}
}
// aggregateKeyRow 是一次分组聚合查询的原始投影。
// 只填充当前维度与当前报表实际选择的列,其余列保持零值。
type aggregateKeyRow struct {
Period string `gorm:"column:period"`
SnapshotDay string `gorm:"column:snapshot_day"`
DeviceName string `gorm:"column:device_name"`
DeviceModel string `gorm:"column:device_model"`
Manufacturer string `gorm:"column:manufacturer"`
BusinessUserGroupID *uint `gorm:"column:business_user_group_id"`
BusinessUserGroupName string `gorm:"column:business_user_group_name"`
AgentAccountID *uint `gorm:"column:agent_account_id"`
AgentAccountName string `gorm:"column:agent_account_name"`
RootShopID *uint `gorm:"column:root_shop_id"`
RootShopName string `gorm:"column:root_shop_name"`
ShopID *uint `gorm:"column:shop_id"`
ShopName string `gorm:"column:shop_name"`
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
BusinessOwnerName string `gorm:"column:business_owner_name"`
SeriesID *uint `gorm:"column:series_id"`
SeriesName string `gorm:"column:series_name"`
PackageName string `gorm:"column:package_name"`
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"`
DueAssets int64 `gorm:"column:due_assets"`
RenewedAssets int64 `gorm:"column:renewed_assets"`
}
// activationKeyColumns 返回激活情况维度在快照行上的分组键列。
// 实体 ID 列一并进入分组键,避免同展示名不同实体被合并。
func activationKeyColumns(dimension string) []string {
switch dimension {
case domainreport.DimensionDeviceName:
return []string{"device_name"}
case domainreport.DimensionDeviceModel:
return []string{"device_model"}
case domainreport.DimensionManufacturer:
return []string{"manufacturer"}
case domainreport.DimensionBusinessUserGroup:
return []string{"business_user_group_id", "business_user_group_name"}
case domainreport.DimensionAgent:
return []string{"agent_account_id", "agent_account_name", "root_shop_id", "root_shop_name"}
case domainreport.DimensionShop:
return []string{"shop_id", "shop_name"}
case domainreport.DimensionBusinessOwner:
return []string{"business_owner_account_id", "business_owner_name"}
default:
return nil
}
}
// renewalKeyColumns 返回套餐续费维度在快照行上的分组键列。
func renewalKeyColumns(dimension string) []string {
switch dimension {
case domainreport.DimensionPackageSeries:
return []string{"series_id", "series_name"}
case domainreport.DimensionPackageName:
return []string{"package_name"}
case domainreport.DimensionBusinessUserGroup:
return []string{"business_user_group_id", "business_user_group_name"}
case domainreport.DimensionAgent:
return []string{"agent_account_id", "agent_account_name", "root_shop_id", "root_shop_name"}
case domainreport.DimensionShop:
return []string{"shop_id", "shop_name"}
case domainreport.DimensionBusinessOwner:
return []string{"business_owner_account_id", "business_owner_name"}
default:
return nil
}
}
// groupKeyFor 由聚合行与维度编码推导分组键。
//
// 「代理」维度映射为单一取值:优先取代理账号(归属一级代理店铺的 user_type=3 账号),
// 账号缺失时退回一级代理店铺名称;两者都缺失时为固定占位。
func groupKeyFor(dimension string, row aggregateKeyRow) groupKey {
switch dimension {
case domainreport.DimensionDeviceName:
return textKey("device_name", row.DeviceName)
case domainreport.DimensionDeviceModel:
return textKey("device_model", row.DeviceModel)
case domainreport.DimensionManufacturer:
return textKey("manufacturer", row.Manufacturer)
case domainreport.DimensionBusinessUserGroup:
return idKey("business_user_group", formatOptionalUint(row.BusinessUserGroupID), row.BusinessUserGroupName)
case domainreport.DimensionAgent:
if row.AgentAccountID != nil {
return groupKey{
value: domainreport.TextOrPlaceholder(row.AgentAccountName),
identity: "agent_account:" + strconv.FormatUint(uint64(*row.AgentAccountID), 10),
}
}
if row.RootShopID != nil {
return groupKey{
value: domainreport.TextOrPlaceholder(row.RootShopName),
identity: "agent_shop:" + strconv.FormatUint(uint64(*row.RootShopID), 10),
}
}
return groupKey{value: domainreport.PlaceholderUnset, identity: "agent:none"}
case domainreport.DimensionShop:
return idKey("shop", formatOptionalUint(row.ShopID), row.ShopName)
case domainreport.DimensionBusinessOwner:
return idKey("business_owner", formatOptionalUint(row.BusinessOwnerAccountID), row.BusinessOwnerName)
case domainreport.DimensionPackageSeries:
return idKey("package_series", formatOptionalUint(row.SeriesID), row.SeriesName)
case domainreport.DimensionPackageName:
return textKey("package_name", row.PackageName)
default:
return groupKey{value: domainreport.DimensionAll}
}
}
// textKey 以文本值本身作为实体键。
func textKey(prefix, value string) groupKey {
return groupKey{value: domainreport.TextOrPlaceholder(value), identity: prefix + ":" + value}
}
// idKey 以实体 ID 作为实体键ID 缺失时退回文本值。
func idKey(prefix, id, value string) groupKey {
if id == "" {
return groupKey{value: domainreport.TextOrPlaceholder(value), identity: prefix + ":name:" + value}
}
return groupKey{value: domainreport.TextOrPlaceholder(value), identity: prefix + ":" + id}
}
// activationDayGroups 按(快照日,分组维度)聚合激活指标。
// 只读设备激活快照行,不回查实时事实;数据范围按快照行的店铺列施加。
func (q *Query) activationDayGroups(ctx context.Context, days []time.Time, dimension string,
scope []uint) (map[string]map[groupKey]activationMetrics, error) {
result := make(map[string]map[groupKey]activationMetrics, len(days))
if len(days) == 0 {
return result, nil
}
selects := []string{"to_char(snapshot_date, 'YYYY-MM-DD') AS snapshot_day"}
groupColumns := []string{"snapshot_date"}
for _, column := range activationKeyColumns(dimension) {
selects = append(selects, column)
groupColumns = append(groupColumns, column)
}
selects = append(selects, activationAggregateSelects()...)
query := q.db.WithContext(ctx).Table("tb_operations_report_activation_row").
Select(strings.Join(selects, ", ")).
Where("snapshot_date IN ?", uniqueDays(days)).
Group(strings.Join(groupColumns, ", "))
if len(scope) > 0 {
query = query.Where("shop_id IN ?", scope)
}
var rows []aggregateKeyRow
if err := query.Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "聚合运营报表设备激活快照行失败")
}
for _, row := range rows {
day := row.SnapshotDay
if _, exists := result[day]; !exists {
result[day] = make(map[groupKey]activationMetrics)
}
result[day][groupKeyFor(dimension, row)] = activationMetrics{
PurchasedDeviceCount: row.PurchasedDeviceCount,
ActivatedDeviceCount: row.ActivatedDeviceCount,
OnlineDeviceCount: row.OnlineDeviceCount,
ActiveDeviceCount: row.ActiveDeviceCount,
TotalRealTrafficMB: row.TotalRealTrafficMB,
}
}
return result, nil
}
// activationAggregateSelects 返回激活指标的聚合表达式。
func activationAggregateSelects() []string {
return []string{
"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",
}
}
// renewalsAggregate 聚合续费指标(到期与续费均按资产去重)。
//
// periodExpr 为空表示整段合计(汇总查询);否则按其分组,键与 domain.FormatPeriod 一致。
// 期内的资产去重在 SQL 层完成,因此按月趋势的同一资产多次到期只计一次。
func (q *Query) renewalsAggregate(ctx context.Context, lower, upper *time.Time, periodExpr, dimension string,
scope []uint) (map[string]map[groupKey]renewalMetrics, error) {
selects := make([]string, 0, 4)
groupColumns := make([]string, 0, 4)
if periodExpr != "" {
selects = append(selects, periodExpr+" AS period")
groupColumns = append(groupColumns, periodExpr)
}
for _, column := range renewalKeyColumns(dimension) {
selects = append(selects, column)
groupColumns = append(groupColumns, column)
}
selects = append(selects,
"COUNT(DISTINCT asset_type || ':' || asset_id) AS due_assets",
"COUNT(DISTINCT asset_type || ':' || asset_id) FILTER (WHERE renewed) AS renewed_assets")
query := q.db.WithContext(ctx).Table("tb_operations_report_renewal_row").Select(strings.Join(selects, ", "))
if lower != nil {
query = query.Where("snapshot_date >= ?::date", domainreport.FormatSnapshotDay(*lower))
}
if upper != nil {
query = query.Where("snapshot_date <= ?::date", domainreport.FormatSnapshotDay(*upper))
}
if len(scope) > 0 {
query = query.Where("shop_id IN ?", scope)
}
if len(groupColumns) > 0 {
query = query.Group(strings.Join(groupColumns, ", "))
}
var rows []aggregateKeyRow
if err := query.Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "聚合运营报表套餐续费快照行失败")
}
result := make(map[string]map[groupKey]renewalMetrics, len(rows))
if periodExpr == "" {
result[""] = make(map[groupKey]renewalMetrics)
}
for _, row := range rows {
key := groupKeyFor(dimension, row)
if !rowHasGroup(row, dimension) {
key = groupKey{value: domainreport.DimensionAll}
}
if _, exists := result[row.Period]; !exists {
result[row.Period] = make(map[groupKey]renewalMetrics)
}
result[row.Period][key] = renewalMetrics{DueAssets: row.DueAssets, RenewedAssets: row.RenewedAssets}
}
return result, nil
}
// rowHasGroup 判断聚合行是否携带分组维度取值(未分组时为 false
func rowHasGroup(row aggregateKeyRow, dimension string) bool {
switch dimension {
case domainreport.DimensionPackageSeries:
return row.SeriesID != nil || row.SeriesName != ""
case domainreport.DimensionPackageName:
return row.PackageName != ""
case domainreport.DimensionBusinessUserGroup:
return row.BusinessUserGroupID != nil || row.BusinessUserGroupName != ""
case domainreport.DimensionAgent:
return row.AgentAccountID != nil || row.RootShopID != nil || row.RootShopName != ""
case domainreport.DimensionShop:
return row.ShopID != nil || row.ShopName != ""
case domainreport.DimensionBusinessOwner:
return row.BusinessOwnerAccountID != nil || row.BusinessOwnerName != ""
default:
return false
}
}
// formatOptionalUint 输出可空 ID 的文本形式。
func formatOptionalUint(value *uint) string {
if value == nil {
return ""
}
return strconv.FormatUint(uint64(*value), 10)
}
// periodExpr 返回趋势期在 SQL 中的表达式,键与 domain.FormatPeriod 一致。
func periodExpr(granularity string) string {
if granularity == domainreport.GranularityMonth {
return "to_char(snapshot_date, 'YYYY-MM')"
}
return "to_char(snapshot_date, 'YYYY-MM-DD')"
}

View File

@@ -0,0 +1,141 @@
package operationsreport
import (
"context"
"time"
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
)
// trendPeriod 是趋势的一个期:期标识、期末快照日与(按月粒度时)期内全部快照日。
type trendPeriod struct {
Key string
RepresentativeDay time.Time
Days []time.Time
}
// activationPeriods 返回激活情况趋势的期序列(升序)。
//
// 按日粒度的期是该区间内的每个快照日;按月粒度的期是每个自然月,期末取该月最后一个快照日。
// 无快照的期不出现在结果中。
func (q *Query) activationPeriods(ctx context.Context, granularity string, start, end *time.Time) ([]trendPeriod, error) {
if granularity == domainreport.GranularityDay {
days, err := q.matchedDays(ctx, lowerDay(start), upperDay(end))
if err != nil {
return nil, err
}
periods := make([]trendPeriod, 0, len(days))
for _, day := range days {
periods = append(periods, trendPeriod{
Key: domainreport.FormatPeriod(granularity, day),
RepresentativeDay: day,
Days: []time.Time{day},
})
}
return periods, nil
}
lower, upper := monthSpan(start, end)
days, err := q.matchedDays(ctx, lower, upper)
if err != nil {
return nil, err
}
return groupMonthPeriods(granularity, days, lowerDay(start), upperDay(end)), nil
}
// previousPeriodRepresentatives 返回每个期的前一期期末快照日;前一期无快照的期不出现在结果中。
//
// 前一期指日历上的上一自然日或上一自然月,即使它早于请求区间起点也必须读取:
// 新增类指标按「期末 前一期期末」计算,任一侧无快照时为空。
func (q *Query) previousPeriodRepresentatives(ctx context.Context, granularity string,
periods []trendPeriod) (map[string]time.Time, error) {
result := make(map[string]time.Time, len(periods))
if len(periods) == 0 {
return result, nil
}
earliest := domainreport.PeriodOf(granularity, periods[0].RepresentativeDay)
for _, period := range periods {
start := domainreport.PeriodOf(granularity, period.RepresentativeDay)
if start.Before(earliest) {
earliest = start
}
}
searchFrom := domainreport.PreviousPeriodStart(granularity, earliest)
days, err := q.matchedDays(ctx, &searchFrom, nil)
if err != nil {
return nil, err
}
for _, period := range periods {
periodStart := domainreport.PeriodOf(granularity, period.RepresentativeDay)
previousStart := domainreport.PreviousPeriodStart(granularity, periodStart)
previousEnd := previousStart
if granularity == domainreport.GranularityMonth {
previousEnd = endOfMonth(previousStart)
}
if representative, ok := lastDayWithin(days, previousStart, previousEnd); ok {
result[period.Key] = representative
}
}
return result, nil
}
// monthSpan 把区间换算为覆盖整月的日期范围,供按月趋势读取所需的快照日集合。
func monthSpan(start, end *time.Time) (*time.Time, *time.Time) {
var lower, upper *time.Time
if start != nil {
day := domainreport.LowerBoundDay(*start)
first := domainreport.PeriodOf(domainreport.GranularityMonth, day)
lower = &first
}
if end != nil {
day := domainreport.UpperBoundDay(*end)
last := endOfMonth(domainreport.PeriodOf(domainreport.GranularityMonth, day))
upper = &last
}
return lower, upper
}
// groupMonthPeriods 把快照日按月聚合为趋势期。
// 期末快照日落在请求区间之外的月份不作为期出现(其快照日仅用于推断前一期)。
func groupMonthPeriods(granularity string, days []time.Time, lower, upper *time.Time) []trendPeriod {
order := make([]string, 0, len(days))
buckets := make(map[string][]time.Time, len(days))
for _, day := range days {
key := domainreport.FormatPeriod(granularity, day)
if _, exists := buckets[key]; !exists {
order = append(order, key)
}
buckets[key] = append(buckets[key], day)
}
periods := make([]trendPeriod, 0, len(order))
for _, key := range order {
bucket := buckets[key]
representative := bucket[len(bucket)-1]
if lower != nil && representative.Before(*lower) {
continue
}
if upper != nil && representative.After(*upper) {
continue
}
periods = append(periods, trendPeriod{Key: key, RepresentativeDay: representative, Days: bucket})
}
return periods
}
// endOfMonth 返回该月最后一天(上海自然日)。
func endOfMonth(monthStart time.Time) time.Time {
return time.Date(monthStart.Year(), monthStart.Month()+1, 0, 0, 0, 0, 0, domainreport.ShanghaiLocation())
}
// lastDayWithin 返回闭区间内最后一个存在的快照日。
func lastDayWithin(days []time.Time, from, to time.Time) (time.Time, bool) {
var found time.Time
ok := false
for _, day := range days {
if day.Before(from) || day.After(to) {
continue
}
found = day
ok = true
}
return found, ok
}

View File

@@ -0,0 +1,550 @@
// Package operationsreport 提供运营报表(设备激活情况与套餐续费情况)的只读投影。
//
// 查询只读三张日报快照表,不回查设备、卡、套餐使用等实时事实;
// 累计类指标一律取所选结束日的快照,结束日无快照时不回退更早快照;
// 时间筛选复用统一时间筛选契约的严格解析器(带时区的 RFC3339 秒级、闭区间),
// 快照日期按「零点(+08:00落入区间」判定。
package operationsreport
import (
"context"
"sort"
"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/internal/model/dto"
"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"
)
// Query 查询运营报表日报快照。
type Query struct {
db *gorm.DB
}
// NewQuery 创建运营报表查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db}
}
// ActivationSummary 查询设备激活情况汇总。
//
// 累计类指标取所选结束日的快照;新增激活数按结束日与基期累计之差计算;
// 未选择分组维度时只返回一行「全部」,与头行合计完全一致。
func (q *Query) ActivationSummary(ctx context.Context, request dto.OperationsActivationSummaryRequest) (*dto.OperationsActivationSummaryResponse, error) {
if err := q.ready(ctx); err != nil {
return nil, err
}
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return nil, err
}
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.ActivationDimensionName)
if err != nil {
return nil, err
}
days, err := q.matchedDays(ctx, lowerDay(start), upperDay(end))
if err != nil {
return nil, err
}
response := &dto.OperationsActivationSummaryResponse{
SnapshotDates: formatDays(days),
GroupBy: dimension,
GroupName: groupName,
Items: []dto.OperationsActivationSummaryItem{},
}
endDay, hasSelectedEnd := selectedEndDay(end, days)
if !hasSelectedEnd {
return response, nil
}
heads, err := q.headRows(ctx, []time.Time{endDay})
if err != nil {
return nil, err
}
head, ok := heads[dayKey(endDay)]
if !ok {
// 所选结束日无快照:累计类与派生指标一律为空、分组行为空集,绝不就近回退更早快照。
return response, nil
}
response.HasSnapshot = true
baseDay, hasBase := summaryBaseDay(start, days)
var base *activationMetrics
if hasBase {
baseHeads, err := q.headRows(ctx, []time.Time{baseDay})
if err != nil {
return nil, err
}
if row, exists := baseHeads[dayKey(baseDay)]; exists {
metrics := metricsFromHead(row)
base = &metrics
}
}
// 可见店铺范围SubordinateShopIDs只在代理账号上计算而本能力只放行超管与平台账号
// 因此 scope 恒为空Totals 取头行即等于分组行之和;若未来放开给带店铺范围的账号,
// Totals 与导出合计行都必须同步收敛到按 scope 的聚合,不能继续读全库头行。
endMetrics := metricsFromHead(head)
totals := endMetrics.item(domainreport.DimensionAll, endDay)
totals.NewActivatedDeviceCount = newActivatedDeviceCount(endMetrics.ActivatedDeviceCount, base)
response.Totals = &totals
if dimension == "" && len(snapshotScope(ctx)) == 0 {
response.Items = append(response.Items, totals)
return response, nil
}
groups, err := q.activationDayGroups(ctx, []time.Time{endDay}, dimension, snapshotScope(ctx))
if err != nil {
return nil, err
}
var baseGroups map[groupKey]activationMetrics
if base != nil {
baseDayGroups, err := q.activationDayGroups(ctx, []time.Time{baseDay}, dimension, snapshotScope(ctx))
if err != nil {
return nil, err
}
baseGroups = baseDayGroups[dayKey(baseDay)]
}
endGroups := groups[dayKey(endDay)]
items := make([]dto.OperationsActivationSummaryItem, 0, len(endGroups))
for key, metrics := range endGroups {
item := metrics.item(key.value, endDay)
baseMetrics, hasBaseMetrics := baseGroups[key]
item.NewActivatedDeviceCount = newActivatedDeviceCount(metrics.ActivatedDeviceCount,
activationMetricsPointer(baseMetrics, hasBaseMetrics))
items = append(items, item)
}
sortActivationItems(items)
response.Items = items
return response, nil
}
// ActivationTrend 查询设备激活情况趋势。
//
// 按日每个快照日一点,按月每个自然月一点;累计类指标取该期最后一个有快照日的快照值;
// 新增激活数取相邻期同口径之差,任一侧无快照时为空;无快照的期不出现。
func (q *Query) ActivationTrend(ctx context.Context, request dto.OperationsActivationTrendRequest) (*dto.OperationsActivationTrendResponse, error) {
if err := q.ready(ctx); err != nil {
return nil, err
}
granularity, ok := domainreport.NormalizeGranularity(request.Granularity)
if !ok {
return nil, errors.New(errors.CodeInvalidParam, "granularity 只能为 day 或 month")
}
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return nil, err
}
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.ActivationDimensionName)
if err != nil {
return nil, err
}
periods, err := q.activationPeriods(ctx, granularity, start, end)
if err != nil {
return nil, err
}
response := &dto.OperationsActivationTrendResponse{
Granularity: granularity,
GroupBy: dimension,
GroupName: groupName,
Points: []dto.OperationsActivationTrendPoint{},
}
if len(periods) == 0 {
return response, nil
}
scope := snapshotScope(ctx)
pointDays := make([]time.Time, 0, len(periods)*2)
seen := make(map[string]struct{}, len(periods)*2)
baseDayOfPeriod := make(map[string]time.Time, len(periods))
for _, period := range periods {
pointDays = appendUniqueDay(pointDays, seen, period.RepresentativeDay)
}
previousDays, err := q.previousPeriodRepresentatives(ctx, granularity, periods)
if err != nil {
return nil, err
}
for _, period := range periods {
if base, exists := previousDays[period.Key]; exists {
baseDayOfPeriod[period.Key] = base
pointDays = appendUniqueDay(pointDays, seen, base)
}
}
metricsByDay := make(map[string]map[groupKey]activationMetrics, len(pointDays))
if dimension == "" && len(scope) == 0 {
heads, err := q.headRows(ctx, pointDays)
if err != nil {
return nil, err
}
for _, day := range pointDays {
head, exists := heads[dayKey(day)]
if !exists {
continue
}
metricsByDay[dayKey(day)] = map[groupKey]activationMetrics{
{value: domainreport.DimensionAll}: metricsFromHead(head),
}
}
} else {
metricsByDay, err = q.activationDayGroups(ctx, pointDays, dimension, scope)
if err != nil {
return nil, err
}
}
for _, period := range periods {
element, exists := metricsByDay[dayKey(period.RepresentativeDay)]
if !exists {
continue
}
baseElement := metricsByDay[dayKey(baseDayOfPeriod[period.Key])]
keys := sortedMetricsKeys(element)
for _, key := range keys {
metrics := element[key]
item := metrics.item(key.value, period.RepresentativeDay)
baseMetrics, hasBaseMetrics := baseElement[key]
item.NewActivatedDeviceCount = newActivatedDeviceCount(metrics.ActivatedDeviceCount,
activationMetricsPointer(baseMetrics, hasBaseMetrics))
response.Points = append(response.Points, dto.OperationsActivationTrendPoint{
Period: period.Key,
OperationsActivationSummaryItem: item,
})
}
}
return response, nil
}
// RenewalSummary 查询套餐续费情况汇总。
//
// 到期与续费都是统计期内的流式指标,按资产去重;同一资产在统计期内多次到期各计一次;
// 续费资产集合是到期资产集合的子集,续费率不超过 100% 由构造保证。
func (q *Query) RenewalSummary(ctx context.Context, request dto.OperationsRenewalSummaryRequest) (*dto.OperationsRenewalSummaryResponse, error) {
if err := q.ready(ctx); err != nil {
return nil, err
}
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return nil, err
}
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.RenewalDimensionName)
if err != nil {
return nil, err
}
days, err := q.matchedDays(ctx, lowerDay(start), upperDay(end))
if err != nil {
return nil, err
}
response := &dto.OperationsRenewalSummaryResponse{
SnapshotDates: formatDays(days),
GroupBy: dimension,
GroupName: groupName,
Items: []dto.OperationsRenewalSummaryItem{},
}
endDay, hasSelectedEnd := selectedEndDay(end, days)
if !hasSelectedEnd {
return response, nil
}
heads, err := q.headRows(ctx, []time.Time{endDay})
if err != nil {
return nil, err
}
head, ok := heads[dayKey(endDay)]
if !ok {
// 所选结束日无快照:全部指标为空且分组行为空集,绝不就近回退更早快照。
return response, nil
}
response.HasSnapshot = true
scope := snapshotScope(ctx)
if len(days) == 1 && dimension == "" && len(scope) == 0 {
// 单日且不分组时直接用头行作为合计,读取量为 O(1)。
item := renewalMetricsFromHead(head).item(domainreport.DimensionAll)
response.Totals = &item
response.Items = append(response.Items, item)
return response, nil
}
lower, upper := days[0], endDay
groups, err := q.renewalsAggregate(ctx, &lower, &upper, "", dimension, scope)
if err != nil {
return nil, err
}
totalItem := groups[""][groupKey{value: domainreport.DimensionAll}].item(domainreport.DimensionAll)
response.Totals = &totalItem
if dimension == "" {
response.Items = append(response.Items, totalItem)
return response, nil
}
items := make([]dto.OperationsRenewalSummaryItem, 0, len(groups[""]))
for key, metrics := range groups[""] {
items = append(items, metrics.item(key.value))
}
sortRenewalItems(items)
response.Items = items
return response, nil
}
// RenewalTrend 查询套餐续费情况趋势。
//
// 按日每个快照日一点,按月每个自然月一点;到期与续费为期内按资产去重的流式指标;
// 无快照的期不出现。
func (q *Query) RenewalTrend(ctx context.Context, request dto.OperationsRenewalTrendRequest) (*dto.OperationsRenewalTrendResponse, error) {
if err := q.ready(ctx); err != nil {
return nil, err
}
granularity, ok := domainreport.NormalizeGranularity(request.Granularity)
if !ok {
return nil, errors.New(errors.CodeInvalidParam, "granularity 只能为 day 或 month")
}
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return nil, err
}
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.RenewalDimensionName)
if err != nil {
return nil, err
}
response := &dto.OperationsRenewalTrendResponse{
Granularity: granularity,
GroupBy: dimension,
GroupName: groupName,
Points: []dto.OperationsRenewalTrendPoint{},
}
lower, upper := lowerDay(start), upperDay(end)
if lower != nil && upper != nil && lower.After(*upper) {
return response, nil
}
groups, err := q.renewalsAggregate(ctx, lower, upper, periodExpr(granularity), dimension, snapshotScope(ctx))
if err != nil {
return nil, err
}
periodKeys := make([]string, 0, len(groups))
for key := range groups {
periodKeys = append(periodKeys, key)
}
sort.Strings(periodKeys)
for _, periodKey := range periodKeys {
element := groups[periodKey]
keys := sortedMetricsKeys(element)
for _, key := range keys {
response.Points = append(response.Points, dto.OperationsRenewalTrendPoint{
Period: periodKey,
OperationsRenewalSummaryItem: element[key].item(key.value),
})
}
}
return response, nil
}
// ready 校验查询已装配,并要求调用者为超级管理员或平台账号;其他账号统一按资源不可见处理。
func (q *Query) ready(ctx context.Context) error {
if q == nil || q.db == nil {
return errors.New(errors.CodeServiceUnavailable, "运营报表查询尚未配置")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return nil
}
// snapshotScope 返回请求人的可见店铺范围;为空表示不受限。
func snapshotScope(ctx context.Context) []uint {
return middleware.GetSubordinateShopIDs(ctx)
}
// matchedDays 返回闭区间内实际命中的快照日期(升序);两端缺省表示该端不限。
func (q *Query) matchedDays(ctx context.Context, lower, upper *time.Time) ([]time.Time, error) {
query := q.db.WithContext(ctx).Table("tb_operations_report_snapshot").Select("snapshot_date")
if lower != nil {
query = query.Where("snapshot_date >= ?::date", domainreport.FormatSnapshotDay(*lower))
}
if upper != nil {
query = query.Where("snapshot_date <= ?::date", domainreport.FormatSnapshotDay(*upper))
}
var rows []time.Time
if err := query.Order("snapshot_date ASC").Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营报表快照日期失败")
}
// DATE 列读回是 UTC 零点,统一归一为上海自然日零点,
// 使一切跨日比较与期归属使用同一时间表示(避免 UTC 零点与东八区零点相差 8 小时)。
days := make([]time.Time, 0, len(rows))
for _, row := range rows {
days = append(days, domainreport.SnapshotDay(row))
}
return days, nil
}
// headRows 批量读取指定快照日的头行,键为 yyyy-MM-dd。
func (q *Query) headRows(ctx context.Context, days []time.Time) (map[string]model.OperationsReportSnapshot, error) {
result := make(map[string]model.OperationsReportSnapshot, len(days))
if len(days) == 0 {
return result, nil
}
var rows []model.OperationsReportSnapshot
if err := q.db.WithContext(ctx).Model(&model.OperationsReportSnapshot{}).
Where("snapshot_date IN ?", uniqueDays(days)).
Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营报表快照头行失败")
}
for _, row := range rows {
result[domainreport.FormatSnapshotDay(row.SnapshotDate)] = row
}
return result, nil
}
// uniqueDays 去除重复快照日后返回。
func uniqueDays(days []time.Time) []time.Time {
seen := make(map[string]struct{}, len(days))
result := make([]time.Time, 0, len(days))
for _, day := range days {
key := dayKey(day)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
result = append(result, day)
}
return result
}
// lowerDay 把区间起点换算为最早入选的快照日期;起点缺省表示不限。
func lowerDay(start *time.Time) *time.Time {
if start == nil {
return nil
}
day := domainreport.LowerBoundDay(*start)
return &day
}
// upperDay 把区间终点换算为最晚入选的快照日期;终点缺省表示不限。
func upperDay(end *time.Time) *time.Time {
if end == nil {
return nil
}
day := domainreport.UpperBoundDay(*end)
return &day
}
// selectedEndDay 返回累计类指标必须取用的「所选结束日」。
//
// 传了 end_time 时一律取落界规则给出的那一天domain.UpperBoundDay即该时刻所在上海自然日
// 该日无快照就按无快照作答,绝不就近回退到区间内更早的快照;
// 未传 end_time 时才退化为区间内最后一个命中快照日。
func selectedEndDay(end *time.Time, days []time.Time) (time.Time, bool) {
// 区间内没有任何命中快照日时不得再往前走:同日亚日区间(起点晚于该日零点)下
// 该日零点并不落在区间内,该日不得入选,否则 RenewalSummary 会取空切片的 days[0]。
if len(days) == 0 {
return time.Time{}, false
}
if end != nil {
return domainreport.UpperBoundDay(*end), true
}
return days[len(days)-1], true
}
// summaryBaseDay 返回汇总查询的基期快照日:传入区间起点时按「起始日的前一自然日」取值,
// 未传起点时取结束日之前最近的一个快照日。基期无快照时新增类指标为空。
func summaryBaseDay(start *time.Time, days []time.Time) (time.Time, bool) {
if len(days) == 0 {
return time.Time{}, false
}
endDay := days[len(days)-1]
if start != nil {
return domainreport.LowerBoundDay(*start).AddDate(0, 0, -1), true
}
for index := len(days) - 1; index >= 0; index-- {
if days[index].Before(endDay) {
return days[index], true
}
}
return time.Time{}, false
}
// resolveGroupDimension 校验并归一分组维度。
// 未选择维度时分组列为「全部」;维度不受支持时按参数非法拒绝。
func resolveGroupDimension(code string, resolve func(string) (string, bool)) (string, string, error) {
if code == "" {
return "", domainreport.DimensionAll, nil
}
name, ok := resolve(code)
if !ok {
return "", "", errors.New(errors.CodeInvalidParam, "不支持的分组维度 "+code)
}
return code, name, nil
}
// newActivatedDeviceCount 计算新增激活数:结束日累计减去基期累计。
// 基期缺失或基期无快照时为空;不做零下限截断,实名逆转导致的新增为负数如实返回。
func newActivatedDeviceCount(current int64, base *activationMetrics) *int64 {
if base == nil {
return nil
}
value := current - base.ActivatedDeviceCount
return &value
}
// activationMetricsPointer 按存在性返回指标指针,用于区分「基期无该分组」与「基期该分组为零」。
func activationMetricsPointer(metrics activationMetrics, exists bool) *activationMetrics {
if !exists {
return nil
}
return &metrics
}
// formatDays 输出快照日期文本集合。
func formatDays(days []time.Time) []string {
result := make([]string, 0, len(days))
for _, day := range days {
result = append(result, dayKey(day))
}
return result
}
// dayKey 把快照日归一为上海自然日的 yyyy-MM-dd 文本。
// 查询侧的快照日一律归一为上海自然日零点,因此这里按上海自然日取键。
func dayKey(day time.Time) string {
return domainreport.FormatSnapshotDay(day)
}
// appendUniqueDay 按日期键去重追加。
func appendUniqueDay(target []time.Time, seen map[string]struct{}, day time.Time) []time.Time {
key := dayKey(day)
if _, exists := seen[key]; exists {
return target
}
seen[key] = struct{}{}
return append(target, day)
}
// sortedMetricsKeys 按分组值排序分组键,保证行序可复现。
func sortedMetricsKeys[T any](element map[groupKey]T) []groupKey {
keys := make([]groupKey, 0, len(element))
for key := range element {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].value != keys[j].value {
return keys[i].value < keys[j].value
}
return keys[i].identity < keys[j].identity
})
return keys
}
// sortActivationItems 按分组值与维度名排序激活情况分组行。
func sortActivationItems(items []dto.OperationsActivationSummaryItem) {
sort.Slice(items, func(i, j int) bool { return items[i].GroupValue < items[j].GroupValue })
}
// sortRenewalItems 按分组值排序套餐续费分组行。
func sortRenewalItems(items []dto.OperationsRenewalSummaryItem) {
sort.Slice(items, func(i, j int) bool { return items[i].GroupValue < items[j].GroupValue })
}

View File

@@ -109,6 +109,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.AssetAutoRenewal != nil { if handlers.AssetAutoRenewal != nil {
registerAssetAutoRenewalRoutes(authGroup, handlers.AssetAutoRenewal, doc, basePath) registerAssetAutoRenewalRoutes(authGroup, handlers.AssetAutoRenewal, doc, basePath)
} }
if handlers.OperationsReport != nil {
registerOperationsReportRoutes(authGroup, handlers.OperationsReport, doc, basePath)
}
if handlers.ShopPackageBatchAllocation != nil { if handlers.ShopPackageBatchAllocation != nil {
registerShopPackageBatchAllocationRoutes(authGroup, handlers.ShopPackageBatchAllocation, doc, basePath) registerShopPackageBatchAllocationRoutes(authGroup, handlers.ShopPackageBatchAllocation, doc, basePath)
} }

View File

@@ -0,0 +1,84 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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/openapi"
)
// registerOperationsReportRoutes 注册运营报表(设备激活情况与套餐续费情况)的查询与受控导出路由。
//
// 沿用超管/平台路由组级 gate 先例:代理、企业与个人客户账号一律 403且无权限与目标不存在不形成可枚举差异。
// gate 必须挂在功能路径组上Fiber 的组中间件按路径前缀生效,挂在空路径组上会落到 /api/admin 前缀,
// 从而拦截该层其余全部接口。
// 路径全部为静态路径(无动态参数),导出子路径比汇总路径更具体,注册顺序不影响匹配。
func registerOperationsReportRoutes(router fiber.Router, handler *admin.OperationsReportHandler, doc *openapi.Generator, basePath string) {
group := router.Group("/operations-reports", func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return c.Next()
})
path := basePath + "/operations-reports"
Register(group, doc, path, "GET", "/activation-summary", handler.ActivationSummary, RouteSpec{
Summary: "查询设备激活情况汇总",
Description: "只读日报快照:采购数量、累计激活数、激活率、新增激活数、累计在网数、活跃用户数、累计用量与卡均;累计类取结束日快照,结束日无快照时为空且不回退;仅超级管理员与平台账号可访问",
Tags: []string{"运营报表"},
Input: new(dto.OperationsActivationSummaryRequest),
Output: new(dto.OperationsActivationSummaryResponse),
Auth: true,
})
Register(group, doc, path, "GET", "/activation-trend", handler.ActivationTrend, RouteSpec{
Summary: "查询设备激活情况日/月趋势",
Description: "按日每个快照日一点,按月每个自然月一点;累计类取该期最后一个有快照日的快照值,新增激活数取相邻期之差;无快照的期不出现",
Tags: []string{"运营报表"},
Input: new(dto.OperationsActivationTrendRequest),
Output: new(dto.OperationsActivationTrendResponse),
Auth: true,
})
Register(group, doc, path, "POST", "/activation-summary/export", handler.ExportActivationSummary, RouteSpec{
Summary: "导出设备激活情况",
Description: "复用既有异步导出任务:创建时冻结筛选条件、操作者与可见店铺范围,导出列与页面字段一致并含合计行,分母为零写「-」,不含文字总结",
Tags: []string{"运营报表"},
Body: new(dto.ExportOperationsReportRequest),
Output: new(dto.CreateExportTaskResponse),
Auth: true,
})
Register(group, doc, path, "GET", "/package-renewal-summary", handler.RenewalSummary, RouteSpec{
Summary: "查询套餐续费情况汇总",
Description: "只读日报快照:到期资产数、续费资产数、续费率与新增未续费数;到期与续费按资产去重,续费资产为到期资产子集,续费率不超过 100% 由构造保证",
Tags: []string{"运营报表"},
Input: new(dto.OperationsRenewalSummaryRequest),
Output: new(dto.OperationsRenewalSummaryResponse),
Auth: true,
})
Register(group, doc, path, "GET", "/package-renewal-trend", handler.RenewalTrend, RouteSpec{
Summary: "查询套餐续费情况日/月趋势",
Description: "按日每个快照日一点,按月每个自然月一点;到期与续费为期内按资产去重的流式指标;无快照的期不出现",
Tags: []string{"运营报表"},
Input: new(dto.OperationsRenewalTrendRequest),
Output: new(dto.OperationsRenewalTrendResponse),
Auth: true,
})
Register(group, doc, path, "POST", "/package-renewal-summary/export", handler.ExportRenewalSummary, RouteSpec{
Summary: "导出套餐续费情况",
Description: "复用既有异步导出任务:创建时冻结筛选条件、操作者与可见店铺范围,导出列与页面字段一致并含合计行,分母为零写「-」,不含文字总结",
Tags: []string{"运营报表"},
Body: new(dto.ExportOperationsReportRequest),
Output: new(dto.CreateExportTaskResponse),
Auth: true,
})
}

View File

@@ -0,0 +1,88 @@
package task
import (
"context"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
operationsreportapp "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/errors"
)
// OperationsReportSnapshotPayload 是每日运营报表快照任务的载荷。
//
// 载荷只含单一目标上海自然日。仓库内没有该任务的入队点(不提供补跑接口),
// 带载荷的调用只能来自运维侧 asynq 控制台或临时程序,此时目标日由载荷固定。
// 定时调度**不带载荷**asynq 的 Scheduler 只能注册静态 Task无法按次生成 payload
// 此时目标日 = 处理时刻所在上海自然日的前一天。
//
// 「重试沿用同一目标日期」的依据不是「沿用同一份载荷」(空载荷会在重试时刻重新推导),
// 而是「cron 时点 + 有界重试窗口」:默认重试延迟为 n^4+15+rand(0..29)*(n+1) 秒
// asynq v0.25.1 server.go 的 DefaultRetryDelayFunc本仓库 pkg/queue/server.go 显式采用),
// MaxRetry(3) 的三次重试合计 ≤ 236 秒,叠加 Timeout(30m) 仍远小于一个上海自然日;
// 服务端重试路径不经过唯一锁,因此 23 小时去重窗口不会吞掉失败重试。
// 调整 cron 时点、MaxRetry 或 Timeout 必须重新评估该前提。
type OperationsReportSnapshotPayload struct {
SnapshotDate string `json:"snapshot_date"`
}
// OperationsReportSnapshotHandler 每日运营报表快照任务处理器。
// 处理器是薄壳:解析目标日期后调用生成用例,口径与幂等全部在用例内闭合。
type OperationsReportSnapshotHandler struct {
generator *operationsreportapp.Generator
logger *zap.Logger
}
// NewOperationsReportSnapshotHandler 创建每日运营报表快照任务处理器。
func NewOperationsReportSnapshotHandler(generator *operationsreportapp.Generator, logger *zap.Logger) *OperationsReportSnapshotHandler {
return &OperationsReportSnapshotHandler{generator: generator, logger: logger}
}
// Handle 生成当日(或载荷指定的)运营报表日报快照。
func (h *OperationsReportSnapshotHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.generator == nil {
return errors.New(errors.CodeInternalError, "运营报表快照生成用例未配置")
}
snapshotDate, err := ResolveOperationsReportSnapshotDate(task, time.Now())
if err != nil {
return err
}
if err := h.generator.Generate(ctx, snapshotDate); err != nil {
if h.logger != nil {
h.logger.Error("运营报表日报快照生成失败",
zap.String("snapshot_date", domainreport.FormatSnapshotDay(snapshotDate)),
zap.Error(err))
}
return err
}
return nil
}
// ResolveOperationsReportSnapshotDate 解析本次生成的目标上海自然日。
//
// 载荷为空(定时调度)时取 now 所在上海自然日的前一自然日;
// 载荷给出目标日期时必须为 yyyy-MM-dd非法载荷直接失败不静默退化为「当天」。
func ResolveOperationsReportSnapshotDate(task *asynq.Task, now time.Time) (time.Time, error) {
if task != nil {
payload := task.Payload()
if len(payload) > 0 {
var parsed OperationsReportSnapshotPayload
if err := sonic.Unmarshal(payload, &parsed); err != nil {
return time.Time{}, errors.Wrap(errors.CodeInvalidParam, err, "运营报表快照任务载荷格式不正确")
}
if parsed.SnapshotDate != "" {
day, err := domainreport.ParseSnapshotDay(parsed.SnapshotDate)
if err != nil {
return time.Time{}, errors.New(errors.CodeInvalidParam,
"运营报表快照任务的目标日期必须为 yyyy-MM-dd 格式的上海自然日")
}
return day, nil
}
}
}
return domainreport.PreviousDay(now), nil
}

View File

@@ -0,0 +1,32 @@
-- 回滚运营报表日报快照三张表,与 up 严格成对。
-- 删除顺序与 up 的创建顺序严格倒序:先删索引,再按依赖无关的表逐一删除
-- (三张表之间没有外键与视图,删除顺序不产生级联影响;表内 CHECK 约束随表一并移除)。
--
-- 不可逆说明:本迁移的 down 会删除全部已生成的日报快照。
-- 快照是「截至某个上海自然日」的冻结读数,其源事实(实名状态可逆转、到期时间可被改写)
-- 无法事后重算,因此 down 只在确认不需要历史报表时执行。
--
-- 守卫:存在已生成的快照行时直接阻断回滚,避免误删已经无法重建的历史报表事实。
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM tb_operations_report_snapshot LIMIT 1) THEN
RAISE EXCEPTION '已存在运营报表日报快照,拒绝回滚以避免丢失无法重建的历史报表事实';
END IF;
END $$;
LOCK TABLE tb_operations_report_snapshot IN ACCESS EXCLUSIVE MODE;
LOCK TABLE tb_operations_report_activation_row IN ACCESS EXCLUSIVE MODE;
LOCK TABLE tb_operations_report_renewal_row IN ACCESS EXCLUSIVE MODE;
DROP INDEX IF EXISTS idx_operations_report_renewal_series;
DROP INDEX IF EXISTS idx_operations_report_renewal_shop;
DROP INDEX IF EXISTS uk_operations_report_renewal_row;
DROP TABLE IF EXISTS tb_operations_report_renewal_row;
DROP INDEX IF EXISTS idx_operations_report_activation_shop;
DROP INDEX IF EXISTS uk_operations_report_activation_row;
DROP TABLE IF EXISTS tb_operations_report_activation_row;
DROP INDEX IF EXISTS uk_operations_report_snapshot_date;
DROP TABLE IF EXISTS tb_operations_report_snapshot;

View File

@@ -0,0 +1,173 @@
-- 运营报表:新增设备激活与套餐续费日报快照的三张事实表(迭代 AUG26-015设计 D2/D3
-- 背景:本次报表的「累计激活数」依赖「截至统计日任一当前有效关联卡已实名」,
-- 而 tb_iot_card.real_name_status 是可逆转的当前状态、first_realname_at 只记首次且不再更新,
-- 仓库内不存在任何 as-of 实名查询,实时 Query 在事实层面不成立;
-- 到期时间本身也非不可变事实(后台可改单条使用记录的到期时间),
-- 因此报表的唯一样本是每日落盘、整日替换的本地快照。
--
-- 设计选择:
-- 1. 采用设备粒度事实行D3不采用「快照日期 × 维度类型 × 维度值」预聚合立方:
-- 维度立方无法支持「以维度 A 筛选、以维度 B 分组」,而 PRD 明确禁止回填历史,
-- 一旦事后需要该能力即为永久缺口。设备粒度约 1.9 万行/日,每日一次整日删除+插入为秒级操作。
-- 2. 三张表都不设软删除列D2快照是整日幂等替换的事实表
-- deleted_at 会与「快照日期唯一」冲突,整日替换需要物理删除。
-- 3. 不设外键:与既有表一致,关联由应用层显式维护(本能力只写入、只按快照日期读取)。
-- 4. 头行承担三件事:①该日是否有快照的唯一判定;②日/月趋势与新增指标的序列来源
-- O(1) 读取,不必扫明细行);③合计行与分组行不变量校验的权威值。
-- 可验证不变量:同一快照日期、任一受支持维度下,分组行各指标之和等于头行对应指标;
-- renewal_renewed_asset_count <= renewal_due_asset_count。
-- 5. 明细行同时冻结归属的两个候选取值D11沿上级店铺上溯至根的一级代理店铺、
-- 以及归属该店铺的 user_type=3 代理账号;查询侧映射为单一「代理」维度。
-- 冻结时机是快照生成执行时刻D12历史行不因后续归属变化被改写。
-- 6. 迁移资产与本地购买资产同口径D9迁移写入的主套餐记录含老系统到期时间
-- 会真实产生到期事件与续费事件,不额外排除。
-- 7. 快照列的字符宽度按来源列宽度取值(如 virtual_no/asset_identifier 取 tb_device.virtual_no 的 100、
-- series_name 取 tb_package_series.series_name 的 255避免快照写入时静默截断来源事实。
CREATE TABLE tb_operations_report_snapshot (
id BIGSERIAL PRIMARY KEY,
snapshot_date DATE NOT NULL,
purchased_device_count BIGINT NOT NULL DEFAULT 0,
activated_device_count BIGINT NOT NULL DEFAULT 0,
online_device_count BIGINT NOT NULL DEFAULT 0,
active_device_count BIGINT NOT NULL DEFAULT 0,
total_real_traffic_mb NUMERIC(20,2) NOT NULL DEFAULT 0,
renewal_due_asset_count BIGINT NOT NULL DEFAULT 0,
renewal_renewed_asset_count BIGINT NOT NULL DEFAULT 0,
generated_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 一日一行:既是「该日是否有快照」的唯一判定,也是整日替换的替换键。
CREATE UNIQUE INDEX uk_operations_report_snapshot_date ON tb_operations_report_snapshot (snapshot_date);
CREATE TABLE tb_operations_report_activation_row (
id BIGSERIAL PRIMARY KEY,
snapshot_date DATE NOT NULL,
device_id BIGINT NOT NULL,
virtual_no VARCHAR(100) NOT NULL DEFAULT '',
device_name VARCHAR(255) NOT NULL DEFAULT '',
device_model VARCHAR(100) NOT NULL DEFAULT '',
manufacturer VARCHAR(255) NOT NULL DEFAULT '',
shop_id BIGINT,
shop_name VARCHAR(100) NOT NULL DEFAULT '',
root_shop_id BIGINT,
root_shop_name VARCHAR(100) NOT NULL DEFAULT '',
agent_account_id BIGINT,
agent_account_name VARCHAR(255) NOT NULL DEFAULT '',
business_owner_account_id BIGINT,
business_owner_name VARCHAR(255) NOT NULL DEFAULT '',
business_user_group_id BIGINT,
business_user_group_name VARCHAR(100) NOT NULL DEFAULT '',
purchased BOOLEAN NOT NULL DEFAULT FALSE,
realnamed BOOLEAN NOT NULL DEFAULT FALSE,
online BOOLEAN NOT NULL DEFAULT FALSE,
active BOOLEAN NOT NULL DEFAULT FALSE,
real_traffic_mb NUMERIC(20,2) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uk_operations_report_activation_row ON tb_operations_report_activation_row (snapshot_date, device_id);
CREATE INDEX idx_operations_report_activation_shop ON tb_operations_report_activation_row (snapshot_date, shop_id);
CREATE TABLE tb_operations_report_renewal_row (
id BIGSERIAL PRIMARY KEY,
snapshot_date DATE NOT NULL,
asset_type VARCHAR(20) NOT NULL,
asset_id BIGINT NOT NULL,
asset_identifier VARCHAR(100) NOT NULL DEFAULT '',
expired_usage_id BIGINT NOT NULL,
package_id BIGINT NOT NULL,
package_name VARCHAR(255) NOT NULL DEFAULT '',
series_id BIGINT,
series_name VARCHAR(255) NOT NULL DEFAULT '',
shop_id BIGINT,
shop_name VARCHAR(100) NOT NULL DEFAULT '',
root_shop_id BIGINT,
root_shop_name VARCHAR(100) NOT NULL DEFAULT '',
agent_account_id BIGINT,
agent_account_name VARCHAR(255) NOT NULL DEFAULT '',
business_owner_account_id BIGINT,
business_owner_name VARCHAR(255) NOT NULL DEFAULT '',
business_user_group_id BIGINT,
business_user_group_name VARCHAR(100) NOT NULL DEFAULT '',
renewed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT ck_operations_report_renewal_row_asset_type CHECK (asset_type IN ('device', 'single_card'))
);
-- 到期事件粒度:同一到期使用记录在同一快照日至多一行。
CREATE UNIQUE INDEX uk_operations_report_renewal_row ON tb_operations_report_renewal_row (snapshot_date, expired_usage_id);
CREATE INDEX idx_operations_report_renewal_shop ON tb_operations_report_renewal_row (snapshot_date, shop_id);
CREATE INDEX idx_operations_report_renewal_series ON tb_operations_report_renewal_row (snapshot_date, series_id);
COMMENT ON TABLE tb_operations_report_snapshot IS '运营报表日级快照头行:一个上海自然日一行,承载「该日是否有快照」与全部指标的权威值,整日幂等替换';
COMMENT ON COLUMN tb_operations_report_snapshot.id IS '主键';
COMMENT ON COLUMN tb_operations_report_snapshot.snapshot_date IS '快照日期(上海自然日),唯一键;整日替换的替换键';
COMMENT ON COLUMN tb_operations_report_snapshot.purchased_device_count IS '采购数量:截至快照日系统内未删除的设备数(口径收敛在单一函数内,见设计 D6';
COMMENT ON COLUMN tb_operations_report_snapshot.activated_device_count IS '累计激活数:任一当前有效关联卡已实名的设备数;实名可逆转,允许下降';
COMMENT ON COLUMN tb_operations_report_snapshot.online_device_count IS '累计在网数:已实名且存在有效主套餐(主套餐、生效或已用完、未退款)的设备数';
COMMENT ON COLUMN tb_operations_report_snapshot.active_device_count IS '活跃用户数:真流量合计大于零的设备数';
COMMENT ON COLUMN tb_operations_report_snapshot.total_real_traffic_mb IS '累计用量设备自身与其当前有效关联卡的当前有效使用记录的真已用量之和MB';
COMMENT ON COLUMN tb_operations_report_snapshot.renewal_due_asset_count IS '到期资产数:未退款主套餐到期日为快照日的资产数(按资产去重)';
COMMENT ON COLUMN tb_operations_report_snapshot.renewal_renewed_asset_count IS '续费资产数:到期资产中已续费的资产数(按资产去重,恒为到期资产数的子集)';
COMMENT ON COLUMN tb_operations_report_snapshot.generated_at IS '快照生成时刻(本次整日替换的执行时刻)';
COMMENT ON COLUMN tb_operations_report_snapshot.created_at IS '创建时间';
COMMENT ON COLUMN tb_operations_report_snapshot.updated_at IS '最近更新时间';
COMMENT ON TABLE tb_operations_report_activation_row IS '运营报表设备激活快照行:设备粒度,一行对应一台未删除设备,冻结其生成时刻的归属与指标';
COMMENT ON COLUMN tb_operations_report_activation_row.id IS '主键';
COMMENT ON COLUMN tb_operations_report_activation_row.snapshot_date IS '快照日期(上海自然日)';
COMMENT ON COLUMN tb_operations_report_activation_row.device_id IS '设备IDtb_device.id';
COMMENT ON COLUMN tb_operations_report_activation_row.virtual_no IS '设备虚拟号快照';
COMMENT ON COLUMN tb_operations_report_activation_row.device_name IS '设备名称快照(生成时刻取值,机型维度的分组列)';
COMMENT ON COLUMN tb_operations_report_activation_row.device_model IS '设备型号快照(生成时刻取值)';
COMMENT ON COLUMN tb_operations_report_activation_row.manufacturer IS '制造商快照(生成时刻取值)';
COMMENT ON COLUMN tb_operations_report_activation_row.shop_id IS '生成时刻设备所属店铺IDNULL-设备无店铺归属';
COMMENT ON COLUMN tb_operations_report_activation_row.shop_name IS '生成时刻店铺名称快照';
COMMENT ON COLUMN tb_operations_report_activation_row.root_shop_id IS '生成时刻沿上级店铺上溯至根的一级代理店铺IDNULL-无店铺归属';
COMMENT ON COLUMN tb_operations_report_activation_row.root_shop_name IS '生成时刻一级代理店铺名称快照';
COMMENT ON COLUMN tb_operations_report_activation_row.agent_account_id IS '生成时刻归属该一级代理店铺的代理账号IDuser_type=3NULL-无代理账号';
COMMENT ON COLUMN tb_operations_report_activation_row.agent_account_name IS '生成时刻代理账号名称快照';
COMMENT ON COLUMN tb_operations_report_activation_row.business_owner_account_id IS '生成时刻店铺业务员账号IDNULL-店铺无业务员';
COMMENT ON COLUMN tb_operations_report_activation_row.business_owner_name IS '生成时刻业务员账号名称快照';
COMMENT ON COLUMN tb_operations_report_activation_row.business_user_group_id IS '生成时刻业务员所属业务用户组ID按既有实时推导口径取值后冻结NULL-无用户组';
COMMENT ON COLUMN tb_operations_report_activation_row.business_user_group_name IS '生成时刻业务用户组名称快照';
COMMENT ON COLUMN tb_operations_report_activation_row.purchased IS '采购标记:该设备计入采购数量(当前口径下未删除设备恒为 true保留逐行标记以便口径切换时分组行之和仍等于头行值';
COMMENT ON COLUMN tb_operations_report_activation_row.realnamed IS '累计激活标记任一当前有效关联卡bind_status=1 且未删除)已实名(卡未删除且 real_name_status=1';
COMMENT ON COLUMN tb_operations_report_activation_row.online IS '累计在网标记:已实名且存在有效主套餐(主套餐、状态生效或已用完、未退款、未删除)';
COMMENT ON COLUMN tb_operations_report_activation_row.active IS '活跃标记:该设备自身与其当前有效关联卡的当前有效使用记录真已用量合计大于零';
COMMENT ON COLUMN tb_operations_report_activation_row.real_traffic_mb IS '真流量合计MB设备自身 + 当前有效关联卡的当前有效使用记录的 data_usage_mb不含卡级全生命周期、自然月累计、通道读数、虚量与展示量';
COMMENT ON COLUMN tb_operations_report_activation_row.created_at IS '创建时间';
COMMENT ON TABLE tb_operations_report_renewal_row IS '运营报表套餐续费快照行:到期事件粒度,一行对应一条在快照日到期的主套餐使用记录';
COMMENT ON COLUMN tb_operations_report_renewal_row.id IS '主键';
COMMENT ON COLUMN tb_operations_report_renewal_row.snapshot_date IS '快照日期(上海自然日),即该条主套餐的到期日';
COMMENT ON COLUMN tb_operations_report_renewal_row.asset_type IS '资产类型 device-设备 single_card-单卡,沿用既有载体类型取值';
COMMENT ON COLUMN tb_operations_report_renewal_row.asset_id IS '资产IDtb_device.id 或 tb_iot_card.id';
COMMENT ON COLUMN tb_operations_report_renewal_row.asset_identifier IS '资产标识快照:设备取虚拟号,单卡取 ICCID';
COMMENT ON COLUMN tb_operations_report_renewal_row.expired_usage_id IS '到期的主套餐使用记录IDtb_package_usage.id与快照日构成唯一键';
COMMENT ON COLUMN tb_operations_report_renewal_row.package_id IS '到期主套餐商品ID';
COMMENT ON COLUMN tb_operations_report_renewal_row.package_name IS '套餐名称快照(取自使用记录或商品)';
COMMENT ON COLUMN tb_operations_report_renewal_row.series_id IS '套餐系列IDNULL-无系列';
COMMENT ON COLUMN tb_operations_report_renewal_row.series_name IS '套餐系列名称快照';
COMMENT ON COLUMN tb_operations_report_renewal_row.shop_id IS '生成时刻资产所属店铺IDNULL-无店铺归属';
COMMENT ON COLUMN tb_operations_report_renewal_row.shop_name IS '生成时刻店铺名称快照';
COMMENT ON COLUMN tb_operations_report_renewal_row.root_shop_id IS '生成时刻沿上级店铺上溯至根的一级代理店铺ID';
COMMENT ON COLUMN tb_operations_report_renewal_row.root_shop_name IS '生成时刻一级代理店铺名称快照';
COMMENT ON COLUMN tb_operations_report_renewal_row.agent_account_id IS '生成时刻归属该一级代理店铺的代理账号IDuser_type=3';
COMMENT ON COLUMN tb_operations_report_renewal_row.agent_account_name IS '生成时刻代理账号名称快照';
COMMENT ON COLUMN tb_operations_report_renewal_row.business_owner_account_id IS '生成时刻店铺业务员账号ID';
COMMENT ON COLUMN tb_operations_report_renewal_row.business_owner_name IS '生成时刻业务员账号名称快照';
COMMENT ON COLUMN tb_operations_report_renewal_row.business_user_group_id IS '生成时刻业务员所属业务用户组ID按既有实时推导口径取值后冻结';
COMMENT ON COLUMN tb_operations_report_renewal_row.business_user_group_name IS '生成时刻业务用户组名称快照';
COMMENT ON COLUMN tb_operations_report_renewal_row.renewed IS '续费标记:该到期资产存在另一条未退款主套餐记录,其生效时间晚于本条到期时间或处于待生效状态';
COMMENT ON COLUMN tb_operations_report_renewal_row.created_at IS '创建时间';
COMMENT ON INDEX uk_operations_report_snapshot_date IS '快照日期唯一键:一日一行,整日幂等替换的替换键';
COMMENT ON INDEX uk_operations_report_activation_row IS '设备粒度激活行唯一键:同一快照日一台设备至多一行';
COMMENT ON INDEX idx_operations_report_activation_shop IS '激活行按快照日期与店铺查询索引';
COMMENT ON INDEX uk_operations_report_renewal_row IS '到期事件粒度续费行唯一键:同一快照日一条到期使用记录至多一行';
COMMENT ON INDEX idx_operations_report_renewal_shop IS '续费行按快照日期与店铺查询索引';
COMMENT ON INDEX idx_operations_report_renewal_series IS '续费行按快照日期与套餐系列分组索引';

View File

@@ -1,27 +0,0 @@
## Decisions
- 只读 Query 分别以卡首次激活成功事实和续购套餐实际生效事实为权威源;不以当前订单/资产状态反推历史。
- 激活按卡去重,续费同时计算订单计数和资产去重计数;金额始终聚合分。
- 先施加请求人数据范围,再关联设备、套餐、用户组、店铺、业务员维度;缺失维度用占位值保留事实。
- 导出复用同一 Query 并保存筛选、范围、时区和口径版本快照。
## 查询与导出契约
### 激活情况统计
- `GET /operations-reports/activations`:仅超级管理员、平台用户;请求 `start_time``end_time` 必填,上海时区左闭右开,及可选 `device_type``package_id``business_user_group_id``shop_id``business_owner_account_id``group_by`。先应用既有资产/店铺范围,再以卡 `activated_at` 的首次成功激活事实过滤和聚合;同一卡在区间内至多贡献 1。
- 响应返回统计边界、分组维度代码/名称、`activation_count`;关联的设备、套餐、用户组、店铺或业务员物理缺失时返回固定“未知/已删除”占位,不用当前资产状态、退款、换货或取消结果排除历史激活。
### 套餐续费情况统计
- `GET /operations-reports/package-renewals`:筛选与分组维度同激活报表。权威事实是续购订单支付成功且对应主套餐使用记录实际生效;新购、加油包、失败/关闭支付、仅支付成功未生效均排除。
- 每个分组返回 `renewal_order_count``renewal_asset_count`(按资产去重)、`received_renewal_amount`(分)。同一资产多笔生效续购增加订单数和金额但只增加一次资产数;金额从订单冻结实收金额读取,禁止由套餐当前售价反算。
### 权限与导出
- 不在调用者数据范围内的 `shop_id`、资产或维度筛选返回既有无权/空集合语义,不返回越权聚合。时间缺失、格式无效、开始不早于结束或不支持的 `group_by` 返回稳定参数错误,不执行聚合。
- `POST /operations-reports/activations/export``/package-renewals/export` 创建异步任务保存请求人、报表类型、规范化筛选、上海时区、口径版本、授权范围和创建时间。Worker 复用相同 Query生成的列与页面指标一致并记录文件、行数、完成时间或安全失败摘要后续角色/店铺变化不得扩大范围。
## Verification
验证跨日边界、首次激活去重、退款后激活保留、续购未生效排除、多次续费、维度缺失、权限和导出一致性。

View File

@@ -1,25 +0,0 @@
## Scope
- 迭代编号:`AUG26-015`
## Why
首期运营分析需有统一、可导出的激活和套餐续费统计,避免把订单、退款、钱包等不同行为混成一个泛化报表。
## What Changes
- 新增按首次激活成功时间统计的激活情况统计表。
- 新增按套餐续购实际生效时间统计的套餐续费情况统计表。
- 固化设备、套餐、用户组、店铺、业务员维度、金额/数量口径、权限与导出快照。
## Capabilities
### New Capabilities
- `operations-report`: 激活与套餐续费运营报表。
### Modified Capabilities
- 无。
## Impact
影响资产/卡激活、套餐使用、订单、店铺/业务员维度、导出和数据权限查询。

View File

@@ -1,22 +0,0 @@
## ADDED Requirements
### Requirement: 激活情况统计报表
系统 SHALL 提供激活情况统计表,按上海时区、卡实际首次激活成功时间统计。查询必须支持时间范围及设备类型、套餐、用户组、店铺、业务员维度筛选和分组;返回激活数量、各维度名称及“未知/已删除”历史维度占位值。取消、退款、换货或当前资产状态变化不得改写已发生的首次激活事实;同一卡仅计一次首次成功激活。
#### Scenario: 已激活资产后续退款
- **WHEN** 卡在统计区间内首次激活成功,后续套餐退款或资产状态变化
- **THEN** 系统仍按首次激活时间计入激活数量,不重复或撤销该历史激活事实
### Requirement: 套餐续费情况统计报表
系统 SHALL 提供套餐续费情况统计表,按上海时区、套餐续购订单支付成功并使套餐续期生效的时间统计。查询支持时间范围及设备类型、套餐、用户组、店铺、业务员维度筛选和分组;返回续费订单数、续费资产数、实收续费金额(分)和各维度名称。新购、加油包购买、失败/关闭支付和未生效续购不计入续费;同一资产在区间内多次成功续费按订单数累计,资产数按资产去重。
#### Scenario: 续购支付成功但套餐未生效
- **WHEN** 续购支付成功但套餐生效事务尚未完成或最终失败
- **THEN** 系统不将该订单计入套餐续费统计
### Requirement: 报表权限、导出与口径快照
仅超级管理员和平台用户 SHALL 查询或导出报表,并按既有数据范围过滤店铺、代理和资产。时间范围必填、按左闭右开区间解释;导出冻结请求人、筛选条件、时区、统计口径版本和授权范围,记录操作人、导出时间、筛选条件及导出结果。导出列与页面对应指标一致,异步执行不得因后续权限变化扩大数据范围。
#### Scenario: 无权范围筛选
- **WHEN** 请求人筛选其无权访问的店铺
- **THEN** 系统拒绝请求或按既有范围规则不返回该店铺聚合值,不泄露任何统计结果

View File

@@ -1,12 +0,0 @@
## Purpose
为 2026 年 8 月迭代提供独立、可验证的 激活与套餐续费日报 行为契约,避免与既有模块的兼容行为混淆。
## ADDED Requirements
### Requirement: 激活与套餐续费日报
系统 SHALL 自功能上线后每日生成激活和套餐续费稳定日报快照,不回填上线前历史。查询支持日/月趋势、单一业务维度分组及异步导出,并按已确认的采购、激活、在网、活跃和续费率口径计算。
#### Scenario: 规则命中
- **WHEN** 业务请求或任务满足本需求定义的前置条件
- **THEN** 系统按上述规则完成处理、保留可追溯事实,并拒绝与状态、权限或幂等约束冲突的重复操作

View File

@@ -1,9 +0,0 @@
## 1. 两张报表
- [ ] 1.1 确认首次激活成功和续购实际生效的权威表、终态/时间字段及维度关联。
- [ ] 1.2 实现激活情况统计:时间范围、五类维度、卡去重、历史维度占位和数据范围。
- [ ] 1.3 实现套餐续费统计:生效门槛、订单数/资产数/实收金额和数据范围。
- [ ] 1.4 实现同 Query 导出及筛选、时区、口径、权限快照和操作审计;更新路由/OpenAPI。
## 2. 验证
- [ ] 2.1 验证首次激活、多次续费、退款、未生效续购、跨日、维度缺失、权限和导出一致性。
- [ ] 2.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-operations-reports --strict``openspec doctor --json`;自动化测试按项目决策为 N/A。

View File

@@ -0,0 +1,268 @@
## Context
本设计只补充推进方案所需的事实与约束;动机见 `proposal.md`「Why」行为义务见 `specs/operations-report/spec.md`
现状约束(全部为可复现事实,行号取本次修订时现读版本):
- **没有历史时点实名能力**`tb_iot_card.real_name_status` 是当前状态列(取值 `0`/`1``pkg/constants/iot.go:45-46`),且可逆转(`migrations/000179_add_iot_card_realname_reversal_state.up.sql``first_realname_at` 仅在首次置位(`internal/domain/cardobservation/realname.go:71``internal/application/cardobservation/apply.go:142-143`),实名逆转后再实名不会更新。仓库内不存在任何 as-of 实名查询。因此「截至统计日已实名」只能靠每日快照冻结。
- **设备实名与设备激活均为运行时派生**:设备表不存实名列,口径为「任一当前有效绑定卡已实名」,完整 `EXISTS` 子查询既有实现见 `internal/store/postgres/device_store.go:227-246`(带 `b.bind_status=1 AND b.deleted_at IS NULL AND c.deleted_at IS NULL AND c.real_name_status=1`)。同口径另有 `internal/exporter/device_scene.go:189-212`。**近似口径,本 Change 不采用**`internal/application/assetautorenewal/renew.go:556-565``internal/service/package/activation_service.go:618-640` 的同名判定缺少 `deleted_at IS NULL`,与上述完整口径不一致;本 Change 一律采用带 `deleted_at IS NULL` 的完整口径。
- **设备—卡当前有效关联**`tb_device_sim_binding``internal/model/device_sim_binding.go:11-25`),有效条件 `bind_status=1 AND deleted_at IS NULL`,部分唯一索引 `(device_id, slot_position)``migrations/archive/000019_fix_device_sim_binding_constraints.up.sql:7-9`),一设备最多 4 卡、一卡可绑多设备。
- **有效套餐与主套餐**:主套餐 = `master_usage_id IS NULL`;有效 = 未删除、`master_usage_id IS NULL``refund_id IS NULL``status IN (1,2)`(既有先例 `internal/query/packageexpiry/list.go:167``:195``internal/query/assetautorenewal/query.go:284``:434``internal/infrastructure/packagetrafficalert/scanner.go:20``:53`)。**不可照抄**`internal/store/postgres/package_usage_store.go:63-72``GetCurrentMainPackage` 不带 `refund_id IS NULL`,本 Change 不采用该处口径。
- **真流量权威列**`tb_package_usage.data_usage_mb`(使用记录在当前重置周期内的真已用量,`internal/model/package.go:74`),由扣减路径写入(`internal/service/package/usage_service.go:156``:158`);周期字段 `data_reset_cycle`/`last_reset_at`/`next_reset_at``internal/model/package.go:95-97`)。**禁止**使用 `tb_iot_card.data_usage_mb`(全生命周期,`internal/model/iot_card.go:32``internal/domain/cardobservation/traffic.go:41-74`)、`current_month_usage_mb``last_gateway_reading_mb`(通道累计读数)以及 `virtual_total_mb_snapshot`/`display_gain_ratio_snapshot`(虚量与展示量)。
- **不重复计数的既有机制**:卡载体没有生效套餐时,流量增量回退扣减到设备载体(`internal/service/package/usage_service.go:294-331`),每次增量只落在一条使用记录上。
- **归属链路**`tb_device.shop_id``tb_shop.business_owner_account_id``internal/model/shop.go:16`)→ `tb_account``tb_business_user_group_member.account_id``tb_business_user_group``internal/model/business_user_group.go:24,33-37`);用户组为实时推导、禁止冗余写入店铺(`openspec/specs/business-user-group/spec.md`)。`tb_shop` 无代理列,只有 `parent_id`注释「NULL 表示一级代理」)与 `level`
- **到期事实无独立记录表**:只在 `tb_package_usage.expires_at` 与状态流转上(`internal/polling/package_activation_handler.go:241-271`,每 10 秒调度,非 Asynq 任务);到期时间可被后台改写(`PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/expires-at`)。
- **续购无独立订单类型**`tb_order.order_type` 只有 `single_card`/`device``internal/model/order.go:89-92`);续购表现为同一载体新增一条主套餐使用记录(人工 `internal/service/order/service.go:2559`、自动 `internal/application/assetautorenewal/renew.go:461`),既有续购判定见 `internal/service/package/priority.go:36-52`
- **调度与幂等范式**:单例 `asynq.Scheduler``cmd/worker/main.go:851`),周期任务集中注册于 `cmd/worker/main.go:879`;可复制模板为每日流量落盘(常量 `pkg/constants/constants.go:91` → 处理器 `internal/task/daily_traffic_flush.go:38` → 注册 `pkg/queue/handler.go:387-392` → cron `cmd/worker/main.go:1005-1015`)。既有幂等手段:`asynq.Unique`、Redis 锁、条件更新状态机、快照表 `ON CONFLICT`
- **导出既有骨架**:场景常量 `pkg/constants/constants.go:384-404`、数据源注册表 `internal/exporter/registry.go:29-42`、支持场景判定 `:68-80`、DTO 联合白名单 `internal/model/dto/export_task_dto.go`(两处)、严格时间场景集合 `internal/exporter/time_filters.go:21-28`;创建期冻结筛选与可见店铺范围 `internal/service/export_task/service.go:106-130`,派发期冻结表头 `internal/store/postgres/export_task_store.go:149`,执行期店铺范围 `internal/exporter/filter_helpers.go:16`
- **门禁**`scripts/context-health.sh:53-73` 强制主 Spec 的 Requirement 证据链与入口—能力—Requirement 矩阵双向一致,因此新增 Requirement、6 条 HTTP 入口与 1 条异步入口必须同步两份 JSON。
生产库只读实测(`pro_main`2026-09-17用于规模与口径判定
| 事实 | 实测值 |
| --- | --- |
| `tb_device`(未删除) | 18,970 |
| 其中 `device_name` / `device_model` / `manufacturer` 去重 | 17 / 21 / 6 |
| 设备「任一当前关联卡已实名」 | 6,630 |
| `tb_device_import_task``operation_type='import'` 且已完成)`SUM(success_count)` | **474** |
| `tb_shop`未删除level1 / level2 | 1,0311,023 / 8 |
| `tb_account``user_type=3` 且带 `shop_id` | 1,031 |
| `tb_iot_card` / `tb_device_sim_binding` / `tb_package_usage` | 95,588 / 36,412 / 69,841 |
| `tb_package_usage_daily_record` | 541,178 行2026-05-08 起 128 天 |
| `tb_card_daily_usage` | 0 行,且实际列为旧结构(`card_id`/`usage_date`/`total_data_usage` |
## Goals / Non-Goals
**Goals:**
- 以一份不可变的日报快照支撑两张报表的全部指标、分组、趋势与导出,历史结果不随实时状态漂移。
- 口径逐项可追溯到既有权威列,且每项都能用一条可复现的查询验证。
- 与既有导出任务、审计、权限和数据范围机制零冲突复用。
**Non-Goals设计层边界**
- 不新增到期台账、采购台账或归属历史表;不从审计与可靠事件载荷重建历史实名序列。
- 不做跨维度筛选(「以维度 A 筛选、以维度 B 分组」);只做单一分组维度 + 同期筛选。
- 不做金额指标、不做图表渲染、不做自然语言总结的后端渲染。
- 不修复 `tb_card_daily_usage` 的结构漂移,不以该表为任何数据源。
- 不改造既有导出的记录粒度、列定义与冻结语义。
## Decisions
### D1 能力归一:单一能力 + 每日快照
`operations-report` 为唯一能力,删除并行的「按卡首次激活时间实时 Query」方案与 `operations-reporting` 目录。依据:`real_name_status` 可逆转且无 as-of 查询(见 Context实时 Query 无法表达「截至统计日」,且各指标来源互不相同(实名、套餐、真流量、到期),必然出现跨表实时拼接导致的历史漂移。
### D2 三张快照表(成对迁移 `migrations/000231_create_operations_report_snapshot.{up,down}.sql`
快照表是幂等替换的事实表:**不设软删除列**(整日替换需要物理删除,`deleted_at` 会与「快照日期唯一」冲突)。
```sql
-- 1. 日级头行:一日一行
CREATE TABLE tb_operations_report_snapshot (
id BIGSERIAL PRIMARY KEY,
snapshot_date DATE NOT NULL, -- 上海自然日
purchased_device_count BIGINT NOT NULL DEFAULT 0,
activated_device_count BIGINT NOT NULL DEFAULT 0,
online_device_count BIGINT NOT NULL DEFAULT 0,
active_device_count BIGINT NOT NULL DEFAULT 0,
total_real_traffic_mb NUMERIC(20,2) NOT NULL DEFAULT 0,
renewal_due_asset_count BIGINT NOT NULL DEFAULT 0,
renewal_renewed_asset_count BIGINT NOT NULL DEFAULT 0,
generated_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uk_operations_report_snapshot_date ON tb_operations_report_snapshot (snapshot_date);
-- 2. 设备粒度激活行
CREATE TABLE tb_operations_report_activation_row (
id BIGSERIAL PRIMARY KEY,
snapshot_date DATE NOT NULL,
device_id BIGINT NOT NULL,
virtual_no VARCHAR(64) NOT NULL DEFAULT '',
device_name VARCHAR(255) NOT NULL DEFAULT '',
device_model VARCHAR(100) NOT NULL DEFAULT '',
manufacturer VARCHAR(255) NOT NULL DEFAULT '',
shop_id BIGINT,
shop_name VARCHAR(100) NOT NULL DEFAULT '',
root_shop_id BIGINT, -- 沿 parent_id 上溯至根的一级代理店铺
root_shop_name VARCHAR(100) NOT NULL DEFAULT '',
agent_account_id BIGINT, -- 归属该店铺的代理账号user_type=3
agent_account_name VARCHAR(255) NOT NULL DEFAULT '',
business_owner_account_id BIGINT,
business_owner_name VARCHAR(255) NOT NULL DEFAULT '',
business_user_group_id BIGINT,
business_user_group_name VARCHAR(100) NOT NULL DEFAULT '',
purchased BOOLEAN NOT NULL DEFAULT FALSE,
realnamed BOOLEAN NOT NULL DEFAULT FALSE,
online BOOLEAN NOT NULL DEFAULT FALSE,
active BOOLEAN NOT NULL DEFAULT FALSE,
real_traffic_mb NUMERIC(20,2) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uk_operations_report_activation_row ON tb_operations_report_activation_row (snapshot_date, device_id);
CREATE INDEX idx_operations_report_activation_shop ON tb_operations_report_activation_row (snapshot_date, shop_id);
-- 3. 到期事件粒度续费行
CREATE TABLE tb_operations_report_renewal_row (
id BIGSERIAL PRIMARY KEY,
snapshot_date DATE NOT NULL,
asset_type VARCHAR(20) NOT NULL, -- 沿用既有载体类型取值 device / single_card
asset_id BIGINT NOT NULL,
asset_identifier VARCHAR(64) NOT NULL DEFAULT '',
expired_usage_id BIGINT NOT NULL, -- 到期的主套餐使用记录
package_id BIGINT NOT NULL,
package_name VARCHAR(255) NOT NULL DEFAULT '',
series_id BIGINT,
series_name VARCHAR(100) NOT NULL DEFAULT '',
shop_id BIGINT,
shop_name VARCHAR(100) NOT NULL DEFAULT '',
root_shop_id BIGINT,
root_shop_name VARCHAR(100) NOT NULL DEFAULT '',
agent_account_id BIGINT,
agent_account_name VARCHAR(255) NOT NULL DEFAULT '',
business_owner_account_id BIGINT,
business_owner_name VARCHAR(255) NOT NULL DEFAULT '',
business_user_group_id BIGINT,
business_user_group_name VARCHAR(100) NOT NULL DEFAULT '',
renewed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uk_operations_report_renewal_row ON tb_operations_report_renewal_row (snapshot_date, expired_usage_id);
CREATE INDEX idx_operations_report_renewal_shop ON tb_operations_report_renewal_row (snapshot_date, shop_id);
CREATE INDEX idx_operations_report_renewal_series ON tb_operations_report_renewal_row (snapshot_date, series_id);
```
头行承担三件事,缺一不可:①「该日是否有快照」的唯一判定;②日/月趋势与新增指标的序列来源O(1) 读取,不必扫明细行);③合计行与顶部文字总结的权威值。**可验证不变量**:同一快照日期、任一受支持维度下,分组行各指标之和等于头行对应指标;`renewal_renewed_asset_count <= renewal_due_asset_count`
### D3 采用设备粒度事实行,不采用维度立方
备选(拒绝):按 `(快照日期, 维度类型, 维度值)` 预聚合。代价对比用实测规模计算:设备粒度约 **1.9 万行/日 ≈ 700 万行/年**;维度立方按 `设备名称(17) + 型号(21) + 制造商(6) + 店铺(1,031) + 业务员(1,031) + 用户组 + 代理(1,031)`**3.3 千行/日**,仅相差约 6 倍。
拒绝理由是不对称的风险:维度立方只支持「同一维度的筛选与分组」,无法支持「以维度 A 筛选、以维度 B 分组」;而 PRD 明确禁止回填历史,一旦事后需要该能力即为**永久缺口**。设备粒度每日一次全量删除+插入(约 1.9 万行)[INFERENCE] 为秒级操作,绝对成本可接受,且使「分组行之和 = 头行值」「导出与页面同口径」天然成立。
### D4 快照生成任务类型、payload、调度、幂等、重跑
- 新增任务类型常量 `TaskTypeOperationsReportSnapshot``operations:report:snapshot`payload **只含单一目标上海自然日**;处理器为薄壳,调用生成用例。
- 寄存器与队列映射按既有范式(`pkg/queue/handler.go` 注册 + `QueueForTaskType`)。
- 调度:`CRON_TZ=Asia/Shanghai 30 3 * * *`,目标日 = 上海自然日**前一自然日**时间点晚于每日流量落盘02:00、套餐临期提醒03:00与到期处理10 秒级轮询)之后,使当日源事实已稳定。
- 幂等 = **唯一键 + 整日替换 + 去重窗口**:单事务内先 `DELETE` 该日三表全部行再整体写入,并加 `asynq.Unique(23*time.Hour)`。同日重复执行的结果等于最后一次执行的结果,不产生第二套口径。
- 失败与重跑:失败交既有任务重试机制(`MaxRetry`),重试沿用同一目标日期;某日始终未成功则当日无任何行,表现为「无快照数据」,不产生部分口径。
### D5 无快照表达、不回填与只读边界
- 响应含「是否有快照」标记与实际命中的快照日期集合。
- 累计类指标一律取所选**结束日**的快照;结束日无快照则累计值与派生指标为空,**不得就近回退更早快照**;新增类指标任一侧无快照即为空;分组行同理返回空集。
- 不回填:无任何按日期区间批量生成入口,调度只发前一日,不提供补跑接口。
- 只读边界:查询与导出只允许读三张快照表,不得回查设备、卡、套餐使用等实时表。
### D6 采购数量口径(须 PRD 标注)
采用口径:**采购数量 = 截至快照日(上海自然日)系统内未删除的设备数**。与 `111.md` §22.4.1「系统录入的设备数量」一致,且不新建采购或入库台账。
字面口径(拒绝):`tb_device_import_task``operation_type='import'` 且已完成任务的 `success_count` 之和。生产实测 **474**,而系统内未删除设备 **18,970**;差额来自老系统迁移脚本**直接写入设备表、绕过导入任务**`scripts/migration/lib/sql_builder.py:712``scripts/migration/config/mapping.yaml:11` `migration_user_id: 1`)。字面口径下激活率约 **1,399%**,指标不可用。
该口径实现收敛在**一个口径函数**内,切换成本为一行;同一标注同步写入 PRD §2.17。
### D7 激活指标口径与边界
- 累计激活数 = 快照时点「设备任一当前有效关联卡已实名」的设备数,复用既有 `EXISTS` 判定口径(见 Context
- 累计在网数 = 已实名且存在有效主套餐(主套餐、状态为生效或已用完、未退款)的设备数。
- 活跃用户数 = 该设备自身与当前有效关联卡当前有效使用记录的真已用量之和大于零的设备数。
- 累计用量 = 同一合计值折算 GB1 GB = 1024 MB
- **实名可逆转边界**:累计激活数可下降、新增激活数可为负;不得取零下限,不得改用首次实名时间。
- 分母为零:接口返回空,导出写「-」;比率与卡均保留两位小数;**激活率不设上限**(设备删除或迁移可使其超过 100%如实呈现PRD 只对续费率设上限)。
- 预测卡均的「当月」= 所选结束日所在上海自然月;已过天数 = 结束日日期号;当月总天数 = 该月自然日数。
### D8 真流量口径、禁止来源与不重复计数
权威列 `tb_package_usage.data_usage_mb`;取值集合 = 该设备自身的当前有效使用记录 该设备当前有效关联卡的当前有效使用记录。不重复计数的依据:卡载体没有生效套餐时增量回退扣减到设备载体(`internal/service/package/usage_service.go:294-331`),每次增量只落在一条记录上。
备选(拒绝):对日记录表按「设备当前套餐周期窗口」求和。缺点是系统性低估迁移资产——迁移把老系统累计真用量写在 `data_usage_mb` 上,而日记录序列自 2026-05-08 起才有,窗口求和取不到该基线。
GB 换算:在报表域内自持常量 `MBPerGB = 1024`,与 `internal/domain/carrierthreshold/threshold.go:19` 同值同源;不为一个换算常数建立与阈值域的跨域依赖。
### D9 续费:到期日锚定与「不超过 100%」的构造保证
- 到期事实 = 未退款主套餐的到期日(上海自然日)等于快照日。
- 续费事实 = 该到期资产存在另一条**未退款**主套餐记录,其生效时间晚于本条到期时间,或处于待生效状态。
- 到期数与续费数均按资产去重;分子为分母子集 ⇒ 续费率 ≤ 100% 由构造保证。备选(拒绝):按「任意成功续购」独立计数再截断到 100%,会掩盖真实数据并破坏分子分母可比性。
- 新增未续费数 = 到期数 续费数。
- **到期日必须在快照时冻结**:到期时间不是不可变事实,后台可修改单条使用记录的到期时间(`PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/expires-at`),不改写快照即产生历史漂移。
- 迁移资产同样按本地事实计入:迁移产生了真实的主套餐记录与到期时间,不再区分来源。
### D10 时间参数裁决
固定为 `start_time`/`end_time`:带显式时区的 RFC3339 秒级、闭区间,复用第 16 项(`openspec/specs/export-time-filter/spec.md`)的共享严格解析器;**不引入自然日参数**,避免同一能力出现第二套时间约定。
**落界规则(写死)**:快照日期 D 入选,当且仅当 D 的零点(+08:00落在请求区间内请求人以整日边界表达区间。趋势用 `granularity=day|month` 表达粒度,不引入月份参数。
### D11 代理维度:同时冻结两个取值
`tb_shop` 无代理列,店铺即代理店铺(生产 1,023 个一级店铺 + 8 个二级店铺,代理账号 1,031 个且全部带 `shop_id`),两种读法在生产近乎同集。因此快照行**同时冻结**「一级代理店铺(沿上级上溯至根)及其名称」与「归属该店铺的代理账号及其名称」,查询侧映射为单一「代理」维度。理由:禁止回填历史,同时冻结是零回填可切换的最低成本。
### D12 归属冻结时机与已知限制
冻结时机 = 快照生成执行时刻,一次性冻结店铺、业务员、用户组与代理取值;**整日替换**保证同一日的行始终来自最近一次执行的同一时刻,不出现同日行混合口径。历史快照行不得因后续归属变化被改写。
已知限制(登记):归属是**生成时刻**的归属,不是逻辑「当天日终」的归属。系统没有归属历史表,无法事后重建日终归属,属既有事实边界;上线后若该差异被业务认定为不可接受,须另立 Change 新增归属变更历史。
### D13 查询、趋势与导出
- 六个受控入口(汇总 ×2、趋势 ×2、导出 ×2导出使用受控端点而非通用导出创建端点避免把「导出与列表同筛选」降级为前端约定。
- 执行期只读快照表并施加冻结店铺范围;`Count`/`Fetch` 用同一查询构造,避免列表与导出漂移。
- 导出列等于页面字段并含合计行;分母为零写「-」;不包含顶部文字总结(文字总结由前端按合计值渲染)。
- 场景级角色自检Worker 无请求上下文,导出场景必须按任务内冻结的账号类型复核资格(先例:达量预警导出的场景级门禁)。
- 数据范围:按请求人可见店铺过滤快照行;越权与不存在统一不可见。
### D14 权限与审计
- 路由组级门禁(超级管理员或平台)**挂在功能前缀**上不得挂在后台根组上——Fiber 的 `Group(prefix, handler)` 会把处理器落为前缀上的 USE 处理器并前置执行,从而拦截其后注册的路由(该历史缺陷已在 `398a5e4` 修复,范式见 `internal/routes/asset_auto_renewal.go:19-24`)。
- 查询不写统一审计事件:无业务事实变化,不产生 Audit Event、Domain Ledger、Integration Log 或 Outbox 任一事实。
- 导出复用既有导出任务审计动作与写入器,不新增动作码;导出任务行本身保存筛选、创建时间、操作者、结果文件与行数,满足可追溯要求。
## Risks / Trade-offs
- [设备粒度快照表约 700 万行/年,随年限增长] → 唯一键为 `(snapshot_date, device_id)`,删除与查询均按 `snapshot_date` 前缀;`tb_package_usage_daily_record` 已有 541 万行同类数据,规模可接受;如未来需要压缩,另立 Change 增加冷热分层,不改口径。
- [归属冻结为生成时刻而非日终] → 已在 D12 显式登记;对日报指标影响限于当日发生归属变更的设备,且历史行不被改写。
- [采购数量口径若被改回字面读法] → 激活率将失真(实测 1,399%);已把口径收敛到单一口径函数并在 PRD §2.17 标注实测数字与依据,切换成本为一行。
- [快照日期落界规则与直觉不同] → 以整日边界表达区间的写法必须由前端遵守spec 以「两端均等于某日零点」与「非法格式拒绝」两个场景固定行为。
- [迁移资产的到期与续费按本地事实计入] → 迁移写入的主套餐记录含老系统到期时间,会真实产生到期事件与续费事件;这是本地事实的真实读数,不额外排除,已在 D9 登记。
- [导出列与页面字段一致性无自动断言] → 沿用仓库现状(逐场景人工复刻 + 注释登记),以「同筛选同口径」场景与导出冒烟验证覆盖。
## Migration Plan
- 新增成对迁移 `000231`(三张表 + 唯一键 + 索引),无数据回填、无运行时开关;回滚按 `.down.sql` 删除三张表。
- 发布顺序:先迁移、后发布 Worker 与 API快照只在上线后的自然日开始产生上线前日期天然无行。
- 回滚策略:回滚二进制后快照表保留但不被读取;`.down.sql` 仅在确认不需要历史快照时执行。
- 上线后门禁命令:`gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go`(连续两次结果一致)、`openspec validate add-operations-reports --strict``openspec doctor --json``./scripts/context-health.sh`;自动化测试按项目决策为 N/A。
## 已知差异与后续候选
1. **实收续费金额指标**PRD §2.17 未要求,本次不实现,登记为后续候选(若产品需要,须先定义金额口径与冻结来源)。
2. **既有日流量记录表结构漂移**`tb_card_daily_usage` 在两个环境均为旧结构(`card_id`/`usage_date`/`total_data_usage`/`carrier_id`)且为 0 行,而模型声明为 `iot_card_id`/`date`/`usage_mb`;原因见 `migrations/000097_create_card_daily_usage.up.sql:1``CREATE TABLE IF NOT EXISTS` 对已存在表无效)。**属既有缺陷,本次不修**,且本能力不以该表为数据源。
3. **归属日终快照缺失**:见 D12需独立 Change 才能提供日终归属。
4. **跨维度筛选不支持**:见 D3当前只支持单一分组维度 + 同期筛选。
## 实施登记2026-09-17
实施阶段对上文裁决的落地解释与已实施偏差,登记供归档与复核;本文 D1D14 的口径均未改动。
1. **在网口径的取数范围**D7 只写「存在有效主套餐」,实现取「该设备自身 其当前有效关联卡」两条载体上的有效主套餐(与 D8 的真流量取值集合同范围)。理由:三个指标(实名、在网、真流量)由同一批事实派生,取同一范围可避免同一设备在不同指标上使用不同载体集合。备选窄口径(只认设备自身载体上的主套餐)未采用,若产品要求需另立改动。
2. **代理维度的取值优先级**D11 要求同时冻结两个候选取值并映射为单一维度,实现取「代理账号(归属一级代理店铺的 user_type=3 账号,多账号取编号最小者)」为展示值,账号缺失时退回「一级代理店铺」,两者都缺失时为固定占位「未设置」。两个取值在同一行的两个候选列上同时存在,切换读法不改快照结构。
3. **快照列宽度按来源列取值**D2 的 `virtual_no VARCHAR(64)``asset_identifier VARCHAR(64)``series_name VARCHAR(100)` 分别放宽为 100 / 100 / 255`tb_device.virtual_no``tb_iot_card.iccid``tb_package_series.series_name` 的来源宽度对齐,避免快照写入静默截断来源事实。列名与语义不变。
4. **比率与卡均的数值形态**D7 的「保留两位小数」按**比值**01实现接口返回 `0.05``1.00` 这类值,导出列的比率列名不带「(%)」(页面与导出使用同一数值,避免导出与页面出现两种刻度)。续费率 100% 对应返回 `1.00`
5. **汇总的基期选择**D5「新增类指标任一侧无快照即为空」的落地规则为——传入 `start_time` 时基期取「起始日的前一自然日」(与统一时间契约要求的整日边界写法一致);未传 `start_time` 时基期取「结束日之前最近的一个有快照日」。趋势按「期」取基期:按日为前一自然日、按月为上一自然月,前一期无快照时新增为空。
6. **多日区间的分组行与合计**激活情况的累计与分组行一律取结束日快照因此分组行之和恒等于头行值续费情况的到期与续费在统计期内按资产去重单日恒等成立多日区间内若同一资产在期内发生归属变更其分组之和可能大于合计同资产落在两个分组此差异属归属冻结口径D12的既定后果。
7. **定时调度的目标日来源与重试依据asynq v0.25.1 事实)**`Scheduler.Register(cronspec, task, opts...)``asynq@v0.25.1/scheduler.go:208`)在注册时固定单个静态 `Task``Task.payload` 私有且无 setter`PreEnqueueFunc``scheduler.go:154`)无返回值,因此 D4「payload 只含单一目标上海自然日」无法在 cron 侧字面落地:定时调度**不带载荷**,处理器按上海时区取 `PreviousDay(now)`;仓库内**没有**该任务的入队点(无补跑接口),带 `{"snapshot_date":"YYYY-MM-DD"}` 载荷的调用只能来自运维侧 asynq 控制台或临时程序,此时目标日由载荷固定。「失败重试沿用同一目标日期」的依据是**cron 时点 + 有界重试窗口**,不是「沿用同一份载荷」:默认重试延迟 `DefaultRetryDelayFunc``asynq@v0.25.1/server.go:400-406`,本仓库 `pkg/queue/server.go:44` 显式采用)为 `n^4 + 15 + rand(0..29)*(n+1)` 秒,`MaxRetry(3)` 的 n=0/1/2 三次重试合计 ≤ 236 秒,叠加 `Timeout(30m)` 的极端上界约 2h05m恒落在 03:30 之后的同一上海自然日内。唯一锁只在客户端入队侧生效(`asynq@v0.25.1/client.go:380-382``asynq@v0.25.1/internal/rdb/rdb.go:158-176`),服务端重试路径(`processor.go:349-372 → internal/rdb/rdb.go:804-837`)不含 unique key因此失败重试不会被 23 小时去重窗口吞掉;成功经 `Done` 释放锁(`rdb.go:338-344`),永久失败留锁至 TTL 过期(次日 02:30 到期03:30 入队有 1 小时余量)。调整 cron 时点、`MaxRetry``Timeout` 必须重新评估该前提(已在 `cmd/worker/main.go` 注册处与任务处理器注释中写明)。**边界**:同一目标日期的重跑若落在前一次成功后的 23 小时窗口内,会被唯一键判为重复而整任务跳过(无第二套口径);需要窗口内重跑时须等服务端锁过期或先删除该任务。
8. **生成期读取与写入事务边界**:读取源事实在写入事务之前完成,写入阶段(删除该日三表行 → 整体写入 → 不变量校验)在单事务内闭合。采购数量与设备事实分两次读取,若两者跨越了设备增删导致不一致,本次生成直接失败(不写入自相矛盾的快照),由既有重试以同一目标日期重跑。
9. **测试环境验证**:本次验证在 `junhong_cmp_test` 上以带 `AUG26015` 标记的临时 fixture 执行(创建后即删除,测试库恢复原状),覆盖冒烟(三表行数、七维度不变量、续费子集、同日重复执行、三个唯一键冲突)与 spec 的全部场景;自动化测试按项目决策为 N/A临时验证程序未留在仓库。
10. **down 守卫与 dirty 恢复**`migrations/000231_*.down.sql` 在存在快照行时拒绝回滚(先例 `migrations/000230_add_asset_auto_renewal_attempt.down.sql`):快照是「截至某日」的冻结读数,其源事实(实名可逆转、到期时间可改写)无法事后重算,因此删除前必须显式确认。**副作用与恢复**:守卫触发时 golang-migrate 会把 `schema_migrations` 置为前一版本并标记 `dirty=t`(实测 `230|dirty=t`),此时表结构未变而版本号已回退,必须执行 `./scripts/migrate.sh force 231` 复原后才能继续迁移。**运维口径:有快照数据时不要执行 down。** 对象集与 up 严格成对down 删除 3 张表 + 3 个主键索引 + 3 个唯一索引 + 3 个普通索引 + 3 个序列 = 15 个对象up 后完全还原。
11. **as-of 采购数量的剩余语义**`CountUndeletedDevicesAsOf` 与设备明细行查询使用**同一谓词**「`created_at` 早于快照日次日 00:00上海墙钟`deleted_at IS NULL`」,两者不一致会让「采购数量 = 明细行数」的不变量立即失败。as-of 维度是设备创建时间;**删除维度按生成时刻判定**,即不重建「该日是否已删除」的历史(系统没有删除历史表)。`tb_device.created_at``tb_package_usage.expires_at` 都是 naive timestamp 列仓库约定naive 列存上海墙钟),因此边界以 `yyyy-MM-dd HH:mm:ss` 文本与 `::date` 比较传入,不用 `time.Time`、也不用 `AT TIME ZONE`,避免按 UTC 编码或按 UTC 解释而差一天。
12. **已知限制(当前不可达)**:汇总的 `Totals` 恒取全库头行,与按可见店铺范围聚合的 `Items` 不对称——`SubordinateShopIDs` 只在代理账号上计算,而本能力只放行超级管理员与平台账号,因此该差异当前不可达,不为它新增无法验证的分支;若未来把该 Query 复用于带店铺范围的账号,`Totals` 与导出合计行必须同步收敛到 scope 聚合(已在 `internal/query/operationsreport/query.go` 就地注释)。
## Open Questions
无。口径歧义(采购数量、代理维度、时间参数形态)已在 D6/D10/D11 裁决并落地,不阻塞任务拆分。

View File

@@ -0,0 +1,52 @@
## Scope
- 迭代编号:`AUG26-015`(需求依据 PRD §2.17「已确认的报表基线」;字段与追溯细节见 `111.md` §22
## Why
首期运营分析需要统一的设备激活与套餐续费统计,且历史报表不得因实时状态变化而口径漂移。当前规划把同一能力写成两套互相冲突的方案(按卡首次激活时间的实时 Query 与每日日报快照),指标集、维度集、时间语义与续费口径均与 PRD §2.17 不一致,无法直接实施。
此外,本系统不存在「截至某历史时点是否已实名」的查询能力,`tb_iot_card.real_name_status` 是可逆转的当前状态、`first_realname_at` 只记首次且不再更新,因此实时 Query 方案在事实层面不成立,每日快照是唯一可行解。
## What Changes
- **能力归一**:删除并行方案与 `operations-reporting` 能力,只保留单一能力 `operations-report`;唯一方案为每日日报快照。
- 新增三张快照表:日级头行(一日一行、快照日期唯一)、设备粒度激活行、到期事件粒度续费行,并新增每日生成任务。
- 上线前日期不提供报表、不回填历史;不提供按日期区间批量生成快照的入口。
- 查询与导出只读快照表,不回查设备、卡、套餐使用等实时表;累计类指标一律取所选结束日的快照,结束日无快照时为空且不就近回退。
- 激活指标:采购数量取截至快照日系统内未删除的设备数;累计激活数取「任一当前有效关联卡已实名」的设备数;累计在网数取已实名且存在有效主套餐的设备数;活跃用户数取真流量合计大于零的设备数;累计用量取设备自身与当前有效关联卡当前有效使用记录的真已用量之和并折算 GB。
- 续费指标:以到期日锚定,到期数与续费数按资产去重,续费资产为到期资产的子集,续费率不超过 100% 由构造保证。
- 维度与归属:单分组维度(激活表七项、续费表六项),未选择时汇总为一行;快照行冻结店铺、业务员、用户组,并同时冻结「代理」的两个候选取值。
- 新增六个受控入口:两张报表各自的汇总、趋势与异步导出。
- 时间筛选统一为 `start_time`/`end_time`(带显式时区的 RFC3339 秒级、闭区间),快照日期按「零点落入区间」判定;趋势以 `granularity=day|month` 表达。
不破坏既有行为:全部为新增能力,未引入 **BREAKING** 变更。
## 非目标
- 不改既有列表、导出、套餐、钱包、订单逻辑;不复用第 12/13/14/15 项报表或导出的粒度与冻结口径。
- 不引入金额口径:本次不含实收续费金额,登记为后续候选。
- 不建采购或入库台账。
- 不回填历史,不提供补跑接口与区间批量生成入口。
- 后端不做图表渲染与自然语言文字总结渲染,只提供数据与合计值。
- 不修既有日流量记录表的结构漂移(独立 Change 处理,本次不以其为数据源)。
- 不新增、不修改既有导出任务表结构,不改导出三段式流水线。
## Capabilities
### New Capabilities
- `operations-report`: 设备激活与套餐续费日报快照的生成、冻结口径、单维度分组与合计、日/月趋势、异步导出与权限数据范围。
### Modified Capabilities
- 无。既有 `export-task``operations-audit``identity-access` 的行为不变;导出复用既有导出任务与既有审计动作,不新增动作码。
## Impact
- Schema新增成对迁移与三张快照表含唯一键与索引
- Worker新增快照生成任务类型、处理器注册、队列映射与每日调度上海时区
- HTTP新增后台路由与 Handler需同步运行时文档装配与离线文档生成入口的占位装配。
- 导出新增两个导出场景常量、两个数据源实现、注册表与支持场景判定、DTO 联合类型白名单、严格时间场景判定,以及两个受控导出端点。
- 权限与审计:新增路由组级门禁(挂功能前缀);查询不写统一审计事件;导出复用既有导出任务审计。
- 文档与证据链:更新架构导航,并同步 `docs/verification/context-reset/` 的两份 JSON行为 Requirement 证据链与入口—能力—Requirement 矩阵,含 6 条 HTTP 入口与 1 条异步入口)。

View File

@@ -0,0 +1,185 @@
## Purpose
为 2026 年 8 月迭代的「报表管理」提供可验证行为契约:每日冻结设备激活与套餐续费日报快照,报表查询、日/月趋势与异步导出只读该快照,上线前日期无快照且不回填历史。
## ADDED Requirements
### Requirement: 每日报表快照与不回填历史
系统 SHALL 在每个上海自然日结束后为前一自然日生成一份日报快照,覆盖设备激活情况与套餐续费情况两组指标。快照 MUST 以快照日期为唯一键一日一份;同一日期重复生成 MUST 整日替换三张快照表的当日行,其结果 MUST 等于最后一次执行的结果MUST NOT 产生重复行或第二套口径。
系统 MUST NOT 提供按日期区间批量生成快照的入口MUST NOT 回填上线前日期。查询与导出 MUST 只使用快照事实MUST NOT 回查设备、卡、套餐使用等实时事实。
#### Scenario: 上线前日期无快照且不回退
- **WHEN** 请求人选定的快照日期没有任何快照
- **THEN** 系统返回「无快照」标记与空的实际命中快照日期集合累计类指标及其派生指标为空MUST NOT 用更早日期的快照或实时事实代替
#### Scenario: 同日重复生成口径不变
- **WHEN** 同一快照日期被再次生成(任务重试或受控重跑)
- **THEN** 该日全部快照行先被整日删除再整日写入,行数与指标值等于单次生成的结果
#### Scenario: 生成失败与重试
- **WHEN** 某日快照生成失败并进入既有任务重试
- **THEN** 重试仍以同一目标日期生成,成功前该日不残留部分口径,失败摘要不泄露内部细节
#### Scenario: 上线后形成连续序列
- **WHEN** 功能上线后连续自然日正常生成
- **THEN** 每个已上线日期各存在一份快照,构成可推导新增指标的连续序列
### Requirement: 激活情况指标口径
系统 SHALL 按下列口径在快照中记录设备激活指标:
- 采购数量 MUST 为截至快照日系统内未删除的设备数。
- 累计激活数 MUST 为「任一当前有效关联卡已实名」的设备数。
- 激活率 MUST 为累计激活数除以采购数量;采购数量为零时该指标不可计算。
- 新增激活数 MUST 为快照日累计激活数减去前一快照日累计激活数。
- 累计在网数 MUST 为已实名且存在有效主套餐的设备数,有效主套餐 MUST 为主套餐、状态为生效或已用完且未退款。
- 活跃用户数 MUST 为真流量合计大于零的设备数。
- 累计用量 MUST 为该设备自身与当前有效关联卡的当前有效使用记录的真已用量之和,并以 1 GB = 1024 MB 折算为 GB。
- 单用户卡均 MUST 为累计用量除以累计在网数;含零预测卡均 MUST 为累计用量除以累计在网数再按当月已过天数与当月总天数年化;不含零预测卡均 MUST 以活跃用户数为分母按同一方式年化。
累计类指标 MUST 取所选结束日的快照。实名状态可逆转,因此累计激活数 MAY 下降、新增激活数 MAY 为负;系统 MUST NOT 以零为下限MUST NOT 改用首次实名时间代替当前实名状态。
真流量 MUST 只取套餐使用记录的真已用量MUST NOT 使用卡级全生命周期累计、自然月累计、运营商通道累计读数、虚用量或展示用量。
#### Scenario: 设备多卡仅一卡已实名
- **WHEN** 某设备当前有效关联多张卡且仅其中一张已实名
- **THEN** 该设备计入累计激活数一次
#### Scenario: 实名逆转导致累计下降
- **WHEN** 已实名设备在后续快照日不再满足任一关联卡已实名
- **THEN** 当日累计激活数低于前一日,且新增激活数为负数
#### Scenario: 分母为零
- **WHEN** 采购数量为零
- **THEN** 激活率在接口中为空值,在导出中写作「-」,其余比率与卡均同样处理
#### Scenario: 真流量来源受控
- **WHEN** 某设备的关联卡同时存在全生命周期累计、自然月累计与通道累计读数
- **THEN** 累计用量与活跃用户数只按套餐使用记录的真已用量计算,不因上述读数改变
### Requirement: 套餐续费指标口径
系统 SHALL 以到期日锚定记录套餐续费指标:
- 到期事实 MUST 为未退款主套餐的到期日(上海自然日)等于快照日的记录。
- 续费事实 MUST 为该到期资产存在另一条未退款主套餐记录,其生效时间晚于本条到期时间,或处于待生效状态。
- 到期数与续费数 MUST 按资产去重;同一资产在同一统计期内多次到期或多次续费 MUST 各计一次。
- 续费率 MUST 为续费数除以到期数;新增未续费数 MUST 为到期数减续费数。
- 续费资产集合 MUST 为到期资产集合的子集,使续费率不超过 100% 由构造保证;系统 MUST NOT 通过截断或钳制掩盖真实数据。
到期日与相关套餐事实 MUST 在生成快照时冻结,后续到期时间被修改 MUST NOT 改写已生成快照。自外部系统迁移进入本地事实的资产 MUST 与本地购买资产采用同一口径。
#### Scenario: 到期当天存在待生效后续主套餐
- **WHEN** 某资产主套餐在快照日到期且当天已存在待生效的后续主套餐
- **THEN** 该资产计一次到期并计一次续费,续费率为 100%
#### Scenario: 同一资产期内两次到期
- **WHEN** 某资产在同一统计期内有两条主套餐分别到期
- **THEN** 到期数与续费数各计该资产一次
#### Scenario: 到期时间在快照后被修改
- **WHEN** 某主套餐的到期时间在快照生成后被修改
- **THEN** 已生成快照的到期日与全部指标保持不变
#### Scenario: 新增未续费数
- **WHEN** 某统计期到期资产数为 N、其中续费资产数为 M
- **THEN** 新增未续费数为 N 减 M且不小于零
### Requirement: 报表维度分组与归属冻结
系统 SHALL 支持单一分组维度:激活情况表按设备名称、设备型号、制造商、用户组、代理、店铺、业务员中的一个维度分组,套餐续费情况表按套餐系列、套餐名称、用户组、代理、店铺、业务员中的一个维度分组;未选择分组维度时 MUST 汇总为一行且分组列值为「全部」。系统 MUST NOT 同时按多个维度分组。
快照行 MUST 在生成时冻结设备所属店铺、店铺业务员与业务员所属用户组(用户组 MUST 按既有实时推导口径取值后冻结),并 MUST 同时冻结代理的两个取值:沿上级店铺上溯至根的代理店铺及其名称、归属该店铺的代理账号及其名称;查询侧 MUST 将二者映射为单一「代理」维度。设备名称、设备型号与制造商 MUST 取生成时的设备取值,为空时以固定占位展示。
历史快照行 MUST NOT 因后续店铺、业务员、用户组或代理变化被改写。同一快照日期、同一维度下,分组行各指标之和 MUST 等于该日头行指标值。
#### Scenario: 未选择分组维度
- **WHEN** 请求未指定分组维度
- **THEN** 系统返回一行汇总结果,分组列值为「全部」
#### Scenario: 快照后归属变更
- **WHEN** 某设备在快照生成后变更所属店铺、业务员或用户组
- **THEN** 已生成快照行的店铺、业务员与用户组保持生成时的取值
#### Scenario: 分组行之和等于头行值
- **WHEN** 以任一受支持维度分组查询同一快照日期
- **THEN** 各组指标之和等于该日头行指标值
### Requirement: 报表查询与趋势
系统 SHALL 提供汇总与趋势查询。时间筛选用 `start_time``end_time` 两个可选参数,取值 MUST 为带显式时区的 RFC3339 秒级时间,区间 MUST 为闭区间(含两端),解析结果 MUST 归一为 UTC 瞬时;非法格式与开始晚于结束 MUST 以既有参数非法错误码拒绝MUST NOT 提供宽松格式兼容或运行时开关。
快照日期入选规则 MUST 为:快照日期 D 入选,当且仅当 D 的零点(+08:00落在请求区间内请求人 MUST 以整日边界表达区间。
趋势查询 MUST 以 `granularity` 取值 `day``month` 表达粒度MUST NOT 接受月份参数:按日每个快照日一点,按月每个自然月一点;累计类指标 MUST 取该期最后一个有快照日的快照值,新增类指标 MUST 取相邻期同口径之差;无快照的期 MUST NOT 出现在结果中。系统 MUST 只返回数据,图表渲染 MUST 由前端负责。
#### Scenario: 闭区间含两端
- **WHEN** 请求的开始时间与结束时间均等于某快照日零点
- **THEN** 该快照日入选结果
#### Scenario: 非法时间参数
- **WHEN** 请求携带无时区时间、date-only、空格分隔时间或开始晚于结束的参数
- **THEN** 系统以参数非法错误码拒绝,且不返回任何行
#### Scenario: 按月趋势跳过无快照月份
- **WHEN** 请求按月趋势且区间内某自然月没有快照
- **THEN** 该月不出现在趋势结果中
#### Scenario: 累计取期末快照
- **WHEN** 请求自定义时间段且区间内多个日期存在快照
- **THEN** 累计类指标取结束日快照值,新增激活数按结束日与起始日前一日的累计差值计算
### Requirement: 报表权限、数据范围与导出冻结
仅超级管理员与平台用户 SHALL 查询或导出报表;其余用户类型 MUST 被拒绝,且无权限与目标不存在 MUST NOT 形成可枚举差异。
查询与导出 MUST 按请求人可见店铺范围过滤快照行;越权与不存在 MUST 统一按资源不可见处理。
导出 MUST 复用既有异步导出任务:创建时冻结筛选条件、操作者与可见店铺范围,派发时冻结表头,执行期 MUST 只读快照表并施加冻结的店铺范围。导出列 MUST 与页面展示字段一致并包含合计行;分母为零的比率或卡均 MUST 写作「-」;导出 MUST NOT 包含顶部文字总结。执行期 MUST 按任务内冻结的账号类型复核导出资格MUST NOT 因创建者角色、店铺归属或筛选条件变化扩大数据集或重新解释筛选。
#### Scenario: 无权用户类型请求报表
- **WHEN** 代理、企业或个人客户账号请求报表或创建报表导出
- **THEN** 系统拒绝请求且不返回任何统计结果,不区分无权限与不存在
#### Scenario: 导出与列表同筛选同口径
- **WHEN** 以同一筛选条件调用汇总查询并创建导出
- **THEN** 导出行集合与汇总结果一致,列与页面展示字段一致且包含合计行
#### Scenario: 创建后权限变化
- **WHEN** 导出任务创建后创建者的可见店铺范围发生变化再执行该任务
- **THEN** 导出结果仍不超过创建时冻结的范围与筛选条件
#### Scenario: 执行期账号类型复核
- **WHEN** 导出任务执行时任务内冻结的账号类型不是超级管理员或平台用户
- **THEN** 任务失败并记录安全失败摘要,不产出数据文件
## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。
`GET /api/admin/operations-reports/activation-summary`(激活情况汇总);`GET /api/admin/operations-reports/activation-trend`(激活情况日/月趋势);`POST /api/admin/operations-reports/activation-summary/export`(创建激活情况导出任务);`GET /api/admin/operations-reports/package-renewal-summary`(套餐续费汇总);`GET /api/admin/operations-reports/package-renewal-trend`(套餐续费日/月趋势);`POST /api/admin/operations-reports/package-renewal-summary/export`(创建套餐续费导出任务)。

View File

@@ -0,0 +1,59 @@
## 1. 表与模型
- [x] 1.1 新增成对迁移 `migrations/000231_create_operations_report_snapshot.up.sql` / `.down.sql`:日级头行表(`snapshot_date` 唯一)、设备粒度激活行表(唯一键 `(snapshot_date, device_id)`)、到期事件粒度续费行表(唯一键 `(snapshot_date, expired_usage_id)`),含全部列、唯一索引、`(snapshot_date, shop_id)``(snapshot_date, series_id)` 索引与列注释,不设软删除列
- [x] 1.2 新增 GORM 模型与表名映射(三张表),字段与迁移列逐一对齐
- [x] 1.3 在隔离库执行 `up → down → up` 并核对三表结构与索引与迁移一致
## 2. 口径域与快照生成
- [x] 2.1 新增报表口径域GB 换算常量1 GB = 1024 MB域内自持、比率与卡均计算、分母为零语义空值、预测卡均的当月口径结束日所在上海自然月、已过天数、当月总天数
- [x] 2.2 实现采购数量口径函数(截至快照日系统内未删除设备数),收敛为单点可切换(设计 D6
- [x] 2.3 实现激活口径:设备「任一当前有效关联卡已实名」、有效主套餐(主套餐 + 生效或已用完 + 未退款)、真流量合计与活跃判定;累计激活数允许下降、新增激活数允许为负
- [x] 2.4 实现真流量取值集合(设备自身 + 当前有效关联卡的当前有效使用记录的真已用量)与禁止来源校验(不得取卡级全生命周期累计、自然月累计、通道读数、虚量与展示量)
- [x] 2.5 实现续费口径:到期日锚定、接续主套餐判定(未退款、生效时间晚于本条到期或待生效)、按资产去重、分子为分母子集
- [x] 2.6 新增生成用例Application单事务内先删该日三表行再整体写入写入头行与两类明细行并校验不变量分组行之和等于头行值、续费资产数不大于到期资产数
- [x] 2.7 新增只读数据访问适配(基础设施):设备与设备属性、当前有效卡绑定、卡实名状态、套餐使用记录、店铺与业务员、业务用户组、套餐与套餐系列
- [x] 2.8 新增任务类型常量、任务处理器与队列映射,并在 `pkg/queue/handler.go` 注册
- [x] 2.9 注册每日调度 `CRON_TZ=Asia/Shanghai 30 3 * * *`payload 只含单一目标上海自然日,并加去重窗口;确认时间点在到期处理与既有落盘、临期提醒之后
- [x] 2.10 确认无任何按日期区间批量生成入口与补跑接口;失败重试沿用同一目标日期
## 3. 查询与趋势
- [x] 3.1 新增只读查询:单日/闭区间汇总(单分组维度、同期筛选、合计、头行合计),只读三张快照表
- [x] 3.2 新增趋势查询:`granularity=day|month`,累计取期末快照、新增取相邻期之差,无快照的期不出现
- [x] 3.3 新增请求与响应 DTO时间参数复用共享严格解析器带时区 RFC3339 秒级、闭区间、非法即拒实现快照日期落界规则D 零点落入区间),响应含是否有快照标记与实际命中快照日期集合
- [x] 3.4 实现结束日无快照时的空值语义:累计类与派生指标为空、分组行返回空集、不回退更早快照
- [x] 3.5 实现可见店铺范围过滤(按快照行店铺列),越权与不存在统一不可见
- [x] 3.6 新增 Handler四个查询入口两张报表的汇总与趋势错误交全局 ErrorHandler响应用 `pkg/response`
## 4. 导出
- [x] 4.1 新增两个导出场景常量(设备激活情况、套餐续费情况)与显示名称映射:本仓库没有独立的「场景名 → 中文显示名」映射表,场景显示名只落在常量行内注释与导出任务 DTO 的两处 description 文案(白名单与文案改动见 4.4
- [x] 4.2 新增两个数据源实现(`Scene`/`Count`/`Headers`/`Fetch`):只读快照表、施加冻结店铺范围、`Count``Fetch` 同筛选构造、列等于页面字段并含合计行、分母为零写「-」、不含文字总结
- [x] 4.3 在导出注册表登记两个数据源,并补齐支持场景判定列表
- [x] 4.4 在导出任务 DTO 的联合类型白名单两处加入新场景名
- [x] 4.5 判定并登记严格时间场景集合是否纳入新场景(若不纳入须在设计登记理由)
- [x] 4.6 新增两个受控导出端点 Handler创建期冻结筛选、操作者与可见店铺范围非法时间在创建期拒绝
- [x] 4.7 实现导出场景级角色自检(按任务内冻结的账号类型复核超管或平台),不通过时任务失败并写安全失败摘要
## 5. 权限与审计
- [x] 5.1 新增路由组级门禁,挂在功能前缀上(超管或平台),复用既有拒绝文案,确认未挂在后台根组
- [x] 5.2 核对并登记查询不写统一审计事件(无业务事实变化,四类事实均不涉及)
- [x] 5.3 核对导出复用既有导出任务审计动作与写入器,不新增动作码;确认任务行保存筛选、创建时间、操作者、结果文件与行数
## 6. 路由与文档
- [x] 6.1 新增路由注册文件6 个端点含 RouteSpec 元数据)并在后台路由装配中挂载,静态路径先于动态路径
- [x] 6.2 同步 Handler 装配六处bootstrap Handlers 结构体、bootstrap 真实装配、文档工厂、运行时文档装配、离线文档生成入口与路由注册文件
- [x] 6.3 运行 `go run cmd/gendocs/main.go` 连续两次,确认输出一致
- [x] 6.4 更新 `ARCHITECTURE.md` 模块导航与 Spec 索引
- [x] 6.5 同步 `docs/verification/context-reset/` 两份 JSON行为 Requirement 证据链6 条 Requirement 的入口/用例/持久化/验证命令与入口—能力—Requirement 矩阵6 条 HTTP 入口 + 1 条异步入口)
## 7. 验证
- [x] 7.1 结构验证:`gofmt -w``go build ./cmd/api ./cmd/worker``openspec validate add-operations-reports --strict``openspec doctor --json`
- [x] 7.2 冒烟:在隔离环境手工入队快照任务,核对三表行数、头行与分组行不变量(分组行之和等于头行值、续费资产数不大于到期资产数)、同任务重复执行结果不变
- [x] 7.3 场景验证(导出产物行级内容、列等于页面字段、无文字总结、分母为零写「-」已由真实运行验证;唯一未达项为「同一任务变更冻结权限类型前后的产物行数对照」,原因见验证记录 E-2上线前日期无快照且不回退同日重复执行口径不变实名逆转导致累计下降且新增为负设备多卡仅一卡已实名计一次到期当天存在待生效后续主套餐计一次到期一次续费且续费率为 100%;同一资产期内两次到期各计一次;采购为零时激活率为空且导出为「-」;快照后归属变更不改写历史行;越权与不存在统一不可见;导出与列表同筛选同口径且创建后权限变化不扩大范围
- [x] 7.4 越权与规模确认(导出执行期冻结账号类型自检已由真实运行验证,见验证记录 33代理、企业与个人客户账号请求报表被拒绝核对导出任务执行期账号类型自检生效
- [x] 7.5 记录自动化测试按项目决策为 N/A并保留冒烟命令与输出作为验证证据`*_test.go`;证据为 `docs/verification/add-operations-reports-verification.md`(原始命令与原始输出),口径与解释登记于 design「实施登记2026-09-17

View File

@@ -0,0 +1,187 @@
# operations-report Specification
## Purpose
为 2026 年 8 月迭代的「报表管理」提供可验证行为契约:每日冻结设备激活与套餐续费日报快照,报表查询、日/月趋势与异步导出只读该快照,上线前日期无快照且不回填历史。
## Requirements
### Requirement: 每日报表快照与不回填历史
系统 SHALL 在每个上海自然日结束后为前一自然日生成一份日报快照,覆盖设备激活情况与套餐续费情况两组指标。快照 MUST 以快照日期为唯一键一日一份;同一日期重复生成 MUST 整日替换三张快照表的当日行,其结果 MUST 等于最后一次执行的结果MUST NOT 产生重复行或第二套口径。
系统 MUST NOT 提供按日期区间批量生成快照的入口MUST NOT 回填上线前日期。查询与导出 MUST 只使用快照事实MUST NOT 回查设备、卡、套餐使用等实时事实。
#### Scenario: 上线前日期无快照且不回退
- **WHEN** 请求人选定的快照日期没有任何快照
- **THEN** 系统返回「无快照」标记与空的实际命中快照日期集合累计类指标及其派生指标为空MUST NOT 用更早日期的快照或实时事实代替
#### Scenario: 同日重复生成口径不变
- **WHEN** 同一快照日期被再次生成(任务重试或受控重跑)
- **THEN** 该日全部快照行先被整日删除再整日写入,行数与指标值等于单次生成的结果
#### Scenario: 生成失败与重试
- **WHEN** 某日快照生成失败并进入既有任务重试
- **THEN** 重试仍以同一目标日期生成,成功前该日不残留部分口径,失败摘要不泄露内部细节
#### Scenario: 上线后形成连续序列
- **WHEN** 功能上线后连续自然日正常生成
- **THEN** 每个已上线日期各存在一份快照,构成可推导新增指标的连续序列
### Requirement: 激活情况指标口径
系统 SHALL 按下列口径在快照中记录设备激活指标:
- 采购数量 MUST 为截至快照日系统内未删除的设备数。
- 累计激活数 MUST 为「任一当前有效关联卡已实名」的设备数。
- 激活率 MUST 为累计激活数除以采购数量;采购数量为零时该指标不可计算。
- 新增激活数 MUST 为快照日累计激活数减去前一快照日累计激活数。
- 累计在网数 MUST 为已实名且存在有效主套餐的设备数,有效主套餐 MUST 为主套餐、状态为生效或已用完且未退款。
- 活跃用户数 MUST 为真流量合计大于零的设备数。
- 累计用量 MUST 为该设备自身与当前有效关联卡的当前有效使用记录的真已用量之和,并以 1 GB = 1024 MB 折算为 GB。
- 单用户卡均 MUST 为累计用量除以累计在网数;含零预测卡均 MUST 为累计用量除以累计在网数再按当月已过天数与当月总天数年化;不含零预测卡均 MUST 以活跃用户数为分母按同一方式年化。
累计类指标 MUST 取所选结束日的快照。实名状态可逆转,因此累计激活数 MAY 下降、新增激活数 MAY 为负;系统 MUST NOT 以零为下限MUST NOT 改用首次实名时间代替当前实名状态。
真流量 MUST 只取套餐使用记录的真已用量MUST NOT 使用卡级全生命周期累计、自然月累计、运营商通道累计读数、虚用量或展示用量。
#### Scenario: 设备多卡仅一卡已实名
- **WHEN** 某设备当前有效关联多张卡且仅其中一张已实名
- **THEN** 该设备计入累计激活数一次
#### Scenario: 实名逆转导致累计下降
- **WHEN** 已实名设备在后续快照日不再满足任一关联卡已实名
- **THEN** 当日累计激活数低于前一日,且新增激活数为负数
#### Scenario: 分母为零
- **WHEN** 采购数量为零
- **THEN** 激活率在接口中为空值,在导出中写作「-」,其余比率与卡均同样处理
#### Scenario: 真流量来源受控
- **WHEN** 某设备的关联卡同时存在全生命周期累计、自然月累计与通道累计读数
- **THEN** 累计用量与活跃用户数只按套餐使用记录的真已用量计算,不因上述读数改变
### Requirement: 套餐续费指标口径
系统 SHALL 以到期日锚定记录套餐续费指标:
- 到期事实 MUST 为未退款主套餐的到期日(上海自然日)等于快照日的记录。
- 续费事实 MUST 为该到期资产存在另一条未退款主套餐记录,其生效时间晚于本条到期时间,或处于待生效状态。
- 到期数与续费数 MUST 按资产去重;同一资产在同一统计期内多次到期或多次续费 MUST 各计一次。
- 续费率 MUST 为续费数除以到期数;新增未续费数 MUST 为到期数减续费数。
- 续费资产集合 MUST 为到期资产集合的子集,使续费率不超过 100% 由构造保证;系统 MUST NOT 通过截断或钳制掩盖真实数据。
到期日与相关套餐事实 MUST 在生成快照时冻结,后续到期时间被修改 MUST NOT 改写已生成快照。自外部系统迁移进入本地事实的资产 MUST 与本地购买资产采用同一口径。
#### Scenario: 到期当天存在待生效后续主套餐
- **WHEN** 某资产主套餐在快照日到期且当天已存在待生效的后续主套餐
- **THEN** 该资产计一次到期并计一次续费,续费率为 100%
#### Scenario: 同一资产期内两次到期
- **WHEN** 某资产在同一统计期内有两条主套餐分别到期
- **THEN** 到期数与续费数各计该资产一次
#### Scenario: 到期时间在快照后被修改
- **WHEN** 某主套餐的到期时间在快照生成后被修改
- **THEN** 已生成快照的到期日与全部指标保持不变
#### Scenario: 新增未续费数
- **WHEN** 某统计期到期资产数为 N、其中续费资产数为 M
- **THEN** 新增未续费数为 N 减 M且不小于零
### Requirement: 报表维度分组与归属冻结
系统 SHALL 支持单一分组维度:激活情况表按设备名称、设备型号、制造商、用户组、代理、店铺、业务员中的一个维度分组,套餐续费情况表按套餐系列、套餐名称、用户组、代理、店铺、业务员中的一个维度分组;未选择分组维度时 MUST 汇总为一行且分组列值为「全部」。系统 MUST NOT 同时按多个维度分组。
快照行 MUST 在生成时冻结设备所属店铺、店铺业务员与业务员所属用户组(用户组 MUST 按既有实时推导口径取值后冻结),并 MUST 同时冻结代理的两个取值:沿上级店铺上溯至根的代理店铺及其名称、归属该店铺的代理账号及其名称;查询侧 MUST 将二者映射为单一「代理」维度。设备名称、设备型号与制造商 MUST 取生成时的设备取值,为空时以固定占位展示。
历史快照行 MUST NOT 因后续店铺、业务员、用户组或代理变化被改写。同一快照日期、同一维度下,分组行各指标之和 MUST 等于该日头行指标值。
#### Scenario: 未选择分组维度
- **WHEN** 请求未指定分组维度
- **THEN** 系统返回一行汇总结果,分组列值为「全部」
#### Scenario: 快照后归属变更
- **WHEN** 某设备在快照生成后变更所属店铺、业务员或用户组
- **THEN** 已生成快照行的店铺、业务员与用户组保持生成时的取值
#### Scenario: 分组行之和等于头行值
- **WHEN** 以任一受支持维度分组查询同一快照日期
- **THEN** 各组指标之和等于该日头行指标值
### Requirement: 报表查询与趋势
系统 SHALL 提供汇总与趋势查询。时间筛选用 `start_time``end_time` 两个可选参数,取值 MUST 为带显式时区的 RFC3339 秒级时间,区间 MUST 为闭区间(含两端),解析结果 MUST 归一为 UTC 瞬时;非法格式与开始晚于结束 MUST 以既有参数非法错误码拒绝MUST NOT 提供宽松格式兼容或运行时开关。
快照日期入选规则 MUST 为:快照日期 D 入选,当且仅当 D 的零点(+08:00落在请求区间内请求人 MUST 以整日边界表达区间。
趋势查询 MUST 以 `granularity` 取值 `day``month` 表达粒度MUST NOT 接受月份参数:按日每个快照日一点,按月每个自然月一点;累计类指标 MUST 取该期最后一个有快照日的快照值,新增类指标 MUST 取相邻期同口径之差;无快照的期 MUST NOT 出现在结果中。系统 MUST 只返回数据,图表渲染 MUST 由前端负责。
#### Scenario: 闭区间含两端
- **WHEN** 请求的开始时间与结束时间均等于某快照日零点
- **THEN** 该快照日入选结果
#### Scenario: 非法时间参数
- **WHEN** 请求携带无时区时间、date-only、空格分隔时间或开始晚于结束的参数
- **THEN** 系统以参数非法错误码拒绝,且不返回任何行
#### Scenario: 按月趋势跳过无快照月份
- **WHEN** 请求按月趋势且区间内某自然月没有快照
- **THEN** 该月不出现在趋势结果中
#### Scenario: 累计取期末快照
- **WHEN** 请求自定义时间段且区间内多个日期存在快照
- **THEN** 累计类指标取结束日快照值,新增激活数按结束日与起始日前一日的累计差值计算
### Requirement: 报表权限、数据范围与导出冻结
仅超级管理员与平台用户 SHALL 查询或导出报表;其余用户类型 MUST 被拒绝,且无权限与目标不存在 MUST NOT 形成可枚举差异。
查询与导出 MUST 按请求人可见店铺范围过滤快照行;越权与不存在 MUST 统一按资源不可见处理。
导出 MUST 复用既有异步导出任务:创建时冻结筛选条件、操作者与可见店铺范围,派发时冻结表头,执行期 MUST 只读快照表并施加冻结的店铺范围。导出列 MUST 与页面展示字段一致并包含合计行;分母为零的比率或卡均 MUST 写作「-」;导出 MUST NOT 包含顶部文字总结。执行期 MUST 按任务内冻结的账号类型复核导出资格MUST NOT 因创建者角色、店铺归属或筛选条件变化扩大数据集或重新解释筛选。
#### Scenario: 无权用户类型请求报表
- **WHEN** 代理、企业或个人客户账号请求报表或创建报表导出
- **THEN** 系统拒绝请求且不返回任何统计结果,不区分无权限与不存在
#### Scenario: 导出与列表同筛选同口径
- **WHEN** 以同一筛选条件调用汇总查询并创建导出
- **THEN** 导出行集合与汇总结果一致,列与页面展示字段一致且包含合计行
#### Scenario: 创建后权限变化
- **WHEN** 导出任务创建后创建者的可见店铺范围发生变化再执行该任务
- **THEN** 导出结果仍不超过创建时冻结的范围与筛选条件
#### Scenario: 执行期账号类型复核
- **WHEN** 导出任务执行时任务内冻结的账号类型不是超级管理员或平台用户
- **THEN** 任务失败并记录安全失败摘要,不产出数据文件
## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。
`GET /api/admin/operations-reports/activation-summary`(激活情况汇总);`GET /api/admin/operations-reports/activation-trend`(激活情况日/月趋势);`POST /api/admin/operations-reports/activation-summary/export`(创建激活情况导出任务);`GET /api/admin/operations-reports/package-renewal-summary`(套餐续费汇总);`GET /api/admin/operations-reports/package-renewal-trend`(套餐续费日/月趋势);`POST /api/admin/operations-reports/package-renewal-summary/export`(创建套餐续费导出任务)。

View File

@@ -97,6 +97,7 @@ const (
TaskTypeRefundChannelRecovery = "refund:channel:recovery" // 渠道原路退款结果恢复与查询 TaskTypeRefundChannelRecovery = "refund:channel:recovery" // 渠道原路退款结果恢复与查询
TaskTypeRefundCommissionRecovery = "refund:commission:recovery" // 退款佣金回溯后处理补偿 TaskTypeRefundCommissionRecovery = "refund:commission:recovery" // 退款佣金回溯后处理补偿
TaskTypePackageTrafficAlertScan = "package:traffic:alert:scan" // 每日套餐真流量达量预警扫描 TaskTypePackageTrafficAlertScan = "package:traffic:alert:scan" // 每日套餐真流量达量预警扫描
TaskTypeOperationsReportSnapshot = "operations:report:snapshot" // 每日运营报表日报快照生成
// 运营商通道流量阈值任务类型(由 Asynq Scheduler 调度) // 运营商通道流量阈值任务类型(由 Asynq Scheduler 调度)
TaskTypeCarrierThresholdCycle = "carrier_threshold:cycle" // 通道阈值周期处理:跨期解锁与条件复机 TaskTypeCarrierThresholdCycle = "carrier_threshold:cycle" // 通道阈值周期处理:跨期解锁与条件复机
@@ -319,6 +320,9 @@ func QueueForTaskType(taskType string) string {
case TaskTypePackageTrafficAlertScan: case TaskTypePackageTrafficAlertScan:
// 与套餐临期扫描同队列:轻量只读扫描,复用既有已监听队列,不新增队列与权重。 // 与套餐临期扫描同队列:轻量只读扫描,复用既有已监听队列,不新增队列与权重。
return QueueDataCleanup return QueueDataCleanup
case TaskTypeOperationsReportSnapshot:
// 与套餐临期扫描同队列:每日一次的快照整日替换(秒级),复用既有已监听队列,不新增队列与权重。
return QueueDataCleanup
case TaskTypeDailyTrafficFlush: case TaskTypeDailyTrafficFlush:
return QueueDailyTrafficFlush return QueueDailyTrafficFlush
case TaskTypeAuditDailyArchive, TaskTypeIntegrationDailyArchive, TaskTypeAuditDailyRetention: case TaskTypeAuditDailyArchive, TaskTypeIntegrationDailyArchive, TaskTypeAuditDailyRetention:
@@ -401,6 +405,10 @@ const (
ExportTaskScenePackageTrafficAlert = "package_traffic_alert" ExportTaskScenePackageTrafficAlert = "package_traffic_alert"
// ExportTaskSceneExpiringAsset 表示临期资产导出场景,一行对应一项资产,取当前生效主套餐最终到期时间。 // ExportTaskSceneExpiringAsset 表示临期资产导出场景,一行对应一项资产,取当前生效主套餐最终到期时间。
ExportTaskSceneExpiringAsset = "expiring_asset" ExportTaskSceneExpiringAsset = "expiring_asset"
// ExportTaskSceneOperationsActivation 表示设备激活情况报表导出场景,导出行与页面分组行同口径并含合计行。
ExportTaskSceneOperationsActivation = "operations_activation"
// ExportTaskSceneOperationsRenewal 表示套餐续费情况报表导出场景,导出行与页面分组行同口径并含合计行。
ExportTaskSceneOperationsRenewal = "operations_renewal"
) )
// ExportTaskInvalidTimeFilterMessage 是导出任务冻结的时间边界非法时写入 error_message 的安全失败摘要。 // ExportTaskInvalidTimeFilterMessage 是导出任务冻结的时间边界非法时写入 error_message 的安全失败摘要。

View File

@@ -84,6 +84,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(nil, nil), PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(nil, nil),
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil), PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil),
AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(nil, nil), AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(nil, nil),
OperationsReport: admin.NewOperationsReportHandler(nil, nil, nil),
ClientWechat: app.NewClientWechatHandler(nil, nil, nil), ClientWechat: app.NewClientWechatHandler(nil, nil, nil),
SuperAdmin: admin.NewSuperAdminHandler(nil), SuperAdmin: admin.NewSuperAdminHandler(nil),
SystemConfig: admin.NewSystemConfigHandler(nil, nil), SystemConfig: admin.NewSystemConfigHandler(nil, nil),

View File

@@ -6,6 +6,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
operationsReportApp "github.com/break/junhong_cmp_fiber/internal/application/operationsreport"
packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry" packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry"
packageTrafficAlertApp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert" packageTrafficAlertApp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/internal/exporter" "github.com/break/junhong_cmp_fiber/internal/exporter"
@@ -14,6 +15,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notification "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" notification "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
operationsReportInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/operationsreport"
packageExpiryInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/packageexpiry" packageExpiryInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/packageexpiry"
packageTrafficAlertInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/packagetrafficalert" packageTrafficAlertInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/internal/polling" "github.com/break/junhong_cmp_fiber/internal/polling"
@@ -92,6 +94,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
h.registerPackageTrafficAlertScanHandler() h.registerPackageTrafficAlertScanHandler()
h.registerAutoPurchaseHandler() h.registerAutoPurchaseHandler()
h.registerDailyTrafficFlushHandler() h.registerDailyTrafficFlushHandler()
h.registerOperationsReportSnapshotHandler()
h.logger.Info("所有任务处理器注册完成") h.logger.Info("所有任务处理器注册完成")
return h.mux return h.mux
@@ -391,6 +394,17 @@ func (h *Handler) registerDailyTrafficFlushHandler() {
h.logger.Info("注册每日流量落盘任务处理器", zap.String("task_type", constants.TaskTypeDailyTrafficFlush)) h.logger.Info("注册每日流量落盘任务处理器", zap.String("task_type", constants.TaskTypeDailyTrafficFlush))
} }
// registerOperationsReportSnapshotHandler 注册运营报表日报快照生成任务处理器。
// 生成用例依赖只读数据源与整日替换写入适配,二者都在此按需装配。
func (h *Handler) registerOperationsReportSnapshotHandler() {
source := operationsReportInfra.NewSource(h.db)
store := operationsReportInfra.NewSnapshotStore(h.db)
generator := operationsReportApp.NewGenerator(source, store, h.logger)
handler := task.NewOperationsReportSnapshotHandler(generator, h.logger)
h.mux.HandleFunc(constants.TaskTypeOperationsReportSnapshot, handler.Handle)
h.logger.Info("注册每日运营报表日报快照生成任务处理器", zap.String("task_type", constants.TaskTypeOperationsReportSnapshot))
}
// GetMux 获取 ServeMux用于启动 Worker 服务器) // GetMux 获取 ServeMux用于启动 Worker 服务器)
func (h *Handler) GetMux() *asynq.ServeMux { func (h *Handler) GetMux() *asynq.ServeMux {
return h.mux return h.mux