Files
junhong_cmp_fiber/internal/query/operationsreport/periods.go
break 5e78809b93
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
- 新增成对迁移 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
2026-09-18 09:42:28 +08:00

142 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}