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

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