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
142 lines
4.8 KiB
Go
142 lines
4.8 KiB
Go
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
|
||
}
|