feat(reports): align summary aggregates

This commit is contained in:
2026-06-07 00:44:48 +08:00
parent 5333a356e9
commit ea56eb16c2
6 changed files with 261 additions and 33 deletions
+41 -1
View File
@@ -119,7 +119,7 @@ func TestAnalyticsReportsUsePersistedConversationMessageAndEventRows(t *testing.
assert.Equal(t, int64(1), summaryMap["resolutions_count"])
assert.Equal(t, 1800.0, summaryMap["avg_first_response_time"])
botSummary, err := svc.GetBotSummary(context.Background(), account.ID, since, until)
botSummary, err := svc.GetBotSummary(context.Background(), account.ID, since, until, "account", 0)
require.NoError(t, err)
assert.Equal(t, int64(1), botSummary.BotResolutionsCount)
assert.Equal(t, int64(0), botSummary.BotHandoffsCount)
@@ -241,3 +241,43 @@ func TestAnalyticsBotTimeseriesUsesRawCountStrategies(t *testing.T) {
require.Len(t, handoffPoints, 1)
assert.Equal(t, float64(2), handoffPoints[0].Value)
}
func TestAnalyticsBotSummaryUsesAggregateRawCountStrategies(t *testing.T) {
db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t)
since := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC)
until := since.Add(24 * time.Hour)
resolvedOnly := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
doubleCounted := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
handoffOnly := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
require.NoError(t, db.Create(resolvedOnly).Error)
require.NoError(t, db.Create(doubleCounted).Error)
require.NoError(t, db.Create(handoffOnly).Error)
seed := func(name string, conversationID *uint, minute int) {
require.NoError(t, db.Create(&model.ReportingEvent{
Base: model.Base{CreatedAt: since.Add(time.Duration(minute) * time.Minute)},
AccountID: account.ID,
Name: name,
ConversationID: conversationID,
InboxID: &inbox.ID,
UserID: &user.ID,
EventStartTime: since,
EventEndTime: since.Add(time.Duration(minute) * time.Minute),
}).Error)
}
seed("conversation_bot_resolved", &resolvedOnly.ID, 10)
seed("conversation_bot_resolved", &resolvedOnly.ID, 11)
seed("conversation_bot_resolved", &doubleCounted.ID, 20)
seed("conversation_bot_resolved", nil, 21)
seed("conversation_bot_handoff", &doubleCounted.ID, 30)
seed("conversation_bot_handoff", &handoffOnly.ID, 40)
seed("conversation_bot_handoff", &handoffOnly.ID, 50)
seed("conversation_bot_handoff", nil, 60)
summary, err := svc.GetBotSummary(context.Background(), account.ID, since, until, "account", 0)
require.NoError(t, err)
assert.Equal(t, int64(3), summary.BotResolutionsCount)
assert.Equal(t, int64(2), summary.BotHandoffsCount)
assert.NotNil(t, summary.Previous)
assert.Equal(t, int64(0), summary.Previous.BotResolutionsCount)
}
+140 -21
View File
@@ -439,34 +439,153 @@ func (s *AnalyticsService) groupedLiveConversationMetrics(ctx context.Context, a
return result, nil
}
func (s *AnalyticsService) botSummaryCounts(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) {
db, err := s.analyticsDB()
func (s *AnalyticsService) botSummaryCounts(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint) (*BotSummaryResponse, error) {
result := &BotSummaryResponse{}
resolutions, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, "exclude_bot_handoffs")
if err != nil {
return nil, err
}
var handoffIDs []uint
if err := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until).
Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil {
return nil, err
}
result := &BotSummaryResponse{}
handoffQ := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until)
if err := handoffQ.Distinct("conversation_id").Count(&result.BotHandoffsCount).Error; err != nil {
return nil, err
}
resolvedQ := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, since, until)
if len(handoffIDs) > 0 {
resolvedQ = resolvedQ.Where("conversation_id IS NULL OR conversation_id NOT IN ?", handoffIDs)
}
if err := resolvedQ.Distinct("conversation_id").Count(&result.BotResolutionsCount).Error; err != nil {
handoffs, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, "distinct_conversation")
if err != nil {
return nil, err
}
result.BotResolutionsCount = resolutions
result.BotHandoffsCount = handoffs
return result, nil
}
func (s *AnalyticsService) reportSummaryCounts(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, businessHours bool) (*ReportSummaryResponse, error) {
conversations, err := s.aggregateConversationCount(ctx, accountID, since, until, reportType, id, false)
if err != nil {
return nil, err
}
incoming, err := s.aggregateMessageCount(ctx, accountID, since, until, reportType, id, model.MessageTypeIncoming)
if err != nil {
return nil, err
}
outgoing, err := s.aggregateMessageCount(ctx, accountID, since, until, reportType, id, model.MessageTypeOutgoing)
if err != nil {
return nil, err
}
resolutions, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_resolved", model.MetricNameResolutionTime}, "")
if err != nil {
return nil, err
}
firstResponse, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{model.MetricNameFirstResponse}, businessHours)
if err != nil {
return nil, err
}
resolutionTime, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{"conversation_resolved", model.MetricNameResolutionTime}, businessHours)
if err != nil {
return nil, err
}
replyTime, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{model.MetricNameReplyTime}, businessHours)
if err != nil {
return nil, err
}
return &ReportSummaryResponse{
ConversationsCount: conversations,
IncomingMessagesCount: incoming,
OutgoingMessagesCount: outgoing,
AvgFirstResponseTime: firstResponse,
AvgResolutionTime: resolutionTime,
ResolutionsCount: resolutions,
ReplyTime: replyTime,
}, nil
}
func (s *AnalyticsService) aggregateConversationCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, resolved bool) (int64, error) {
db, err := s.analyticsDB()
if err != nil {
return 0, err
}
q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID)
if resolved {
q = q.Where("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until)
} else {
q = q.Where("created_at >= ? AND created_at < ?", since, until)
}
q = applyConversationDimension(q, reportType, id)
var count int64
if err := q.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *AnalyticsService) aggregateMessageCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, messageType model.MessageType) (int64, error) {
db, err := s.analyticsDB()
if err != nil {
return 0, err
}
q := db.WithContext(ctx).Model(&model.Message{}).Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(messageType), since, until)
q = applyMessageDimension(q, reportType, id)
var count int64
if err := q.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *AnalyticsService) aggregateEventAverage(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, names []string, businessHours bool) (float64, error) {
db, err := s.analyticsDB()
if err != nil {
return 0, err
}
valueColumn := "value"
if businessHours {
valueColumn = "value_in_business_hours"
}
var row struct {
Value float64
Count int64
}
q := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Select("COALESCE(SUM("+valueColumn+"), 0) AS value, COUNT(*) AS count").
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until)
q = applyEventDimension(q, reportType, id)
if err := q.Scan(&row).Error; err != nil {
return 0, err
}
if row.Count == 0 {
return 0, nil
}
return row.Value / float64(row.Count), nil
}
func (s *AnalyticsService) aggregateEventCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, names []string, countStrategy string) (int64, error) {
db, err := s.analyticsDB()
if err != nil {
return 0, err
}
q := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until)
q = applyEventDimension(q, reportType, id)
if countStrategy == "exclude_bot_handoffs" {
var handoffIDs []uint
handoffQ := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until)
handoffQ = applyEventDimension(handoffQ, reportType, id)
if err := handoffQ.Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil {
return 0, err
}
if len(handoffIDs) > 0 {
q = q.Where("conversation_id IS NULL OR conversation_id NOT IN ?", handoffIDs)
}
}
var count int64
if countStrategy == "distinct_conversation" {
if err := q.Where("conversation_id IS NOT NULL").Distinct("conversation_id").Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
if err := q.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accountID uint, reportType string) (interface{}, error) {
switch reportType {
case "account":
@@ -556,7 +675,7 @@ func (s *AnalyticsService) botMetrics(ctx context.Context, accountID uint, since
return nil, err
}
}
summary, err := s.botSummaryCounts(ctx, accountID, since, until)
summary, err := s.botSummaryCounts(ctx, accountID, since, until, "account", 0)
if err != nil {
return nil, err
}
+32 -3
View File
@@ -82,6 +82,20 @@ type ConversationMetrics struct {
PendingCount int64 `json:"pending"`
}
// ReportSummaryResponse is the Chatwoot v2 reports/summary payload.
// Reference: V2::Reports::Conversations::MetricBuilder#summary plus
// Api::V2::Accounts::ReportsController#build_summary.
type ReportSummaryResponse struct {
ConversationsCount int64 `json:"conversations_count"`
IncomingMessagesCount int64 `json:"incoming_messages_count"`
OutgoingMessagesCount int64 `json:"outgoing_messages_count"`
AvgFirstResponseTime float64 `json:"avg_first_response_time"`
AvgResolutionTime float64 `json:"avg_resolution_time"`
ResolutionsCount int64 `json:"resolutions_count"`
ReplyTime float64 `json:"reply_time"`
Previous *ReportSummaryResponse `json:"previous,omitempty"`
}
// GroupedConversationMetric holds conversation metrics grouped by team_id or assignee_id.
// Reference: Chatwoot live_reports#grouped_conversation_metrics
type GroupedConversationMetric struct {
@@ -243,6 +257,21 @@ func (s *AnalyticsService) GetGroupedConversationMetrics(ctx context.Context, ac
return s.groupedLiveConversationMetrics(ctx, accountID, groupBy)
}
// GetReportSummary returns the Chatwoot v2 report summary with previous-period data.
func (s *AnalyticsService) GetReportSummary(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, businessHours bool) (*ReportSummaryResponse, error) {
current, err := s.reportSummaryCounts(ctx, accountID, since, until, reportType, id, businessHours)
if err != nil {
return nil, err
}
previousSince := since.Add(-(until.Sub(since)))
previous, err := s.reportSummaryCounts(ctx, accountID, previousSince, since, reportType, id, businessHours)
if err != nil {
return nil, err
}
current.Previous = previous
return current, nil
}
// RecordEvent records a new reporting event.
func (s *AnalyticsService) RecordEvent(ctx context.Context, event *model.ReportingEvent) error {
if err := s.eventRepo.Create(ctx, event); err != nil {
@@ -367,13 +396,13 @@ func (s *AnalyticsService) groupEventsByDimension(events []model.ReportingEvent,
// GetBotSummary returns bot-level summary metrics.
// Reference: Chatwoot reports#bot_summary
func (s *AnalyticsService) GetBotSummary(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) {
current, err := s.botSummaryCounts(ctx, accountID, since, until)
func (s *AnalyticsService) GetBotSummary(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint) (*BotSummaryResponse, error) {
current, err := s.botSummaryCounts(ctx, accountID, since, until, reportType, id)
if err != nil {
return nil, err
}
previousSince := since.Add(-(until.Sub(since)))
previous, err := s.botSummaryCounts(ctx, accountID, previousSince, since)
previous, err := s.botSummaryCounts(ctx, accountID, previousSince, since, reportType, id)
if err != nil {
return nil, err
}