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
+25 -2
View File
@@ -121,6 +121,18 @@ func parseReportTimezoneOffset(c *gin.Context) (float64, bool) {
return 0, true
}
func parseOptionalReportID(c *gin.Context) (uint, bool) {
if rawID := c.Query("id"); rawID != "" {
parsed, err := strconv.ParseUint(rawID, 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return 0, false
}
return uint(parsed), true
}
return 0, true
}
func parseConversationTrafficRange(c *gin.Context) (time.Time, time.Time, float64, bool) {
timezoneOffset, ok := parseReportTimezoneOffset(c)
if !ok {
@@ -210,8 +222,15 @@ func (h *AnalyticsHandler) Summary(c *gin.Context) {
if !ok {
return
}
if _, ok := parseReportTimezoneOffset(c); !ok {
return
}
id, ok := parseOptionalReportID(c)
if !ok {
return
}
result, err := h.svc.GetSummary(c.Request.Context(), accountID, since, until)
result, err := h.svc.GetReportSummary(c.Request.Context(), accountID, since, until, c.DefaultQuery("type", "account"), id, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Summary report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate summary report")
@@ -370,8 +389,12 @@ func (h *AnalyticsHandler) BotSummary(c *gin.Context) {
if !ok {
return
}
id, ok := parseOptionalReportID(c)
if !ok {
return
}
result, err := h.svc.GetBotSummary(c.Request.Context(), accountID, since, until)
result, err := h.svc.GetBotSummary(c.Request.Context(), accountID, since, until, c.DefaultQuery("type", "account"), id)
if err != nil {
applogger.L().Errorf("Bot summary report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate bot summary")
@@ -145,16 +145,24 @@ func (s *AnalyticsHandlerTestSuite) TestSummary_EmptyData() {
var body map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.NotContains(body, "success")
s.NotNil(body["metrics"])
s.Equal(float64(0), body["conversations_count"])
s.Equal(float64(0), body["incoming_messages_count"])
s.Equal(float64(0), body["outgoing_messages_count"])
s.NotNil(body["previous"])
}
func (s *AnalyticsHandlerTestSuite) TestSummary_WithData() {
// Seed a reporting event
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusResolved), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}}
s.Require().NoError(s.db.Create(&conv).Error)
outgoing := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeOutgoing), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}}
s.Require().NoError(s.db.Create(&outgoing).Error)
ev := model.ReportingEvent{
Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")},
AccountID: s.accountID,
Name: "first_response",
Value: 120.5,
ValueInBusinessHours: 60.0,
ConversationID: &conv.ID,
EventStartTime: parseTime("2025-01-15T10:00:00Z"),
EventEndTime: parseTime("2025-01-15T10:02:00Z"),
}
@@ -165,6 +173,12 @@ func (s *AnalyticsHandlerTestSuite) TestSummary_WithData() {
"/api/v1/accounts/1/reports/summary?since=2025-01-01T00:00:00Z&until=2025-02-01T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var body map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.Equal(float64(1), body["conversations_count"])
s.Equal(float64(1), body["outgoing_messages_count"])
s.Equal(120.5, body["avg_first_response_time"])
s.Contains(body, "previous")
}
func (s *AnalyticsHandlerTestSuite) TestIndex_TimeseriesWithData() {
+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
}