feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
- 新增成对迁移 000231 与三张快照表:日级头行、设备粒度激活行、到期事件粒度续费行,以快照日期为唯一键 - 新增每日 03:30(Asia/Shanghai)日报快照生成任务与幂等整日替换,失败重试沿用同一目标日 - 新增六条受控入口:两张报表的汇总、日/月趋势与受控导出,配套查询层只读快照事实 - 新增 operations_activation 与 operations_renewal 两个导出场景,创建期冻结筛选与可见店铺范围、派发期冻结表头、执行期只按冻结值复核资格 - 采购数量口径按系统内未删除设备数实施并在 PRD 标注,附实测差额依据 - 同步证据链 requirement-evidence.json 与入口能力矩阵、ARCHITECTURE 与验证记录 - 归档 change add-operations-reports 并新建主 Spec openspec/specs/operations-report/spec.md
This commit is contained in:
141
internal/query/operationsreport/periods.go
Normal file
141
internal/query/operationsreport/periods.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user