package service import ( "context" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/gorm" ) // ReportingBackfillService backfills missing rollup data for a given account and date. // Reference: Chatwoot app/services/reporting_events/backfill_service.rb (142行) // // It aggregates raw ReportingEvent rows into ReportingEventsRollup rows per dimension // (account, agent, inbox) per metric, exactly matching Chatwoot's SQL semantics: // - COUNT/SUM/DURATION aggregation per (dimension_type, dimension_id, metric) // - DISTINCT_COUNT for bot_handoff events // - Business-hours value tracking type ReportingBackfillService struct { eventRepo *repository.ReportingEventRepo rollupRepo *repository.ReportingEventsRollupRepo } // NewReportingBackfillService creates a new backfill service. func NewReportingBackfillService(eventRepo *repository.ReportingEventRepo, rollupRepo *repository.ReportingEventsRollupRepo) *ReportingBackfillService { return &ReportingBackfillService{eventRepo: eventRepo, rollupRepo: rollupRepo} } // DimensionSpec defines a rollup dimension with its group column. type DimensionSpec struct { Type model.DimensionType GroupColumn string // empty for account dimension } // BackfillDimensions mirrors Chatwoot DIMENSIONS constant. var BackfillDimensions = []DimensionSpec{ {Type: model.DimensionAccount, GroupColumn: ""}, {Type: model.DimensionAgent, GroupColumn: "user_id"}, {Type: model.DimensionInbox, GroupColumn: "inbox_id"}, } // DistinctCountEvents mirrors Chatwoot DISTINCT_COUNT_EVENTS. // These events require COUNT(DISTINCT conversation_id) instead of simple SUM(count). var DistinctCountEvents = []string{ "conversation_bot_handoff", } // BackfillDate performs backfill for a single account on a single date. // 1. Build replacement rollups for the account reporting timezone date // 2. Delete existing rollups for that date and insert replacements transactionally func (s *ReportingBackfillService) BackfillDate(ctx context.Context, accountID uint, date time.Time) error { db := reportingServiceDB(s.eventRepo, s.rollupRepo) loc, enabled, err := accountReportingLocation(ctx, db, accountID) if err != nil { return err } if !enabled { return nil } startUTC, endUTC, rollupDate := utcBoundariesForReportingDate(date, loc) rollupRows, err := s.buildRollupRows(ctx, accountID, rollupDate, startUTC, endUTC) if err != nil { applogger.L().Errorf("BackfillDate build rollup rows: %v", err) return err } if db == nil { return nil } return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Unscoped().Where("account_id = ? AND date = ?", accountID, rollupDate).Delete(&model.ReportingEventsRollup{}).Error; err != nil { applogger.L().Errorf("BackfillDate delete existing rollups: %v", err) return err } if len(rollupRows) == 0 { return nil } if err := tx.CreateInBatches(rollupRows, 100).Error; err != nil { applogger.L().Errorf("BackfillDate bulk insert: %v", err) return err } return nil }) } // BackfillRange performs backfill for a date range (inclusive). func (s *ReportingBackfillService) BackfillRange(ctx context.Context, accountID uint, startDate, endDate time.Time) error { for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) { if err := s.BackfillDate(ctx, accountID, d); err != nil { applogger.L().Errorf("BackfillRange date=%s: %v", d.Format("2006-01-02"), err) // Continue with next date rather than failing the entire range } } return nil } // RollupAggregate represents a grouped aggregate from raw events. type RollupAggregate struct { DimensionType model.DimensionType DimensionID uint Metric model.RollupMetric Count int64 SumValue float64 SumBizHours float64 } func (s *ReportingBackfillService) buildRollupRows(ctx context.Context, accountID uint, date time.Time, startUTC, endUTC time.Time) ([]model.ReportingEventsRollup, error) { events, err := s.eventRepo.FindByAccountIDAndTimeRange(ctx, accountID, startUTC, endUTC) if err != nil { return nil, err } return rollupRowsFromAggregates(accountID, date, aggregateReportingEvents(accountID, events)), nil }