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
+7 -4
View File
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 documentation consolidation checkpoint, prepared as `docs: consolidate parity execution tracker`.
- Latest implementation checkpoint: committed baseline `aa159f6 feat(reports): align bot count strategies`.
- Latest documentation/tooling checkpoint: this tracker update records the committed P3.45 report bot raw-count strategy parity plus the P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline, lands the known Hermes plan families into this document, and keeps this file as the active follow-up plan that supersedes `.hermes/plans/*`.
- Current tracking checkpoint: 2026-06-07 report summary aggregate parity checkpoint, prepared as `feat(reports): align summary aggregates`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(reports): align summary aggregates`.
- Latest documentation/tooling checkpoint: this tracker update records P3.46 report summary aggregate parity plus the committed P3.45/P3.44/P3.43/P3.42/P3.41/P3.40 reports baseline, lands the known Hermes plan families into this document, and keeps this file as the active follow-up plan that supersedes `.hermes/plans/*`.
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
- Worktree status at this implementation checkpoint: report bot metric count strategies from `reference/chatwoot/app/services/reports/raw_data_source.rb`, `ReportMetricRegistry`, and `spec/builders/v2/reports/timeseries/report_builder_spec.rb` are aligned for v2 report timeseries. `bot_handoffs_count` now counts distinct non-null `conversation_id` values per bucket, and `bot_resolutions_count` now excludes conversations that also have a bot handoff in the same scoped range before bucketing. P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: API v2 report summary responses now match inspected Chatwoot `Api::V2::Accounts::ReportsController#summary/#bot_summary`, `V2::Reports::Conversations::MetricBuilder`, `Reports::RawDataSource#aggregate`, and `ReportMetricRegistry` behavior. `/reports/summary` returns raw Chatwoot metric keys plus `previous`, scoped by current/previous ranges, report type/id, and business-hours average selection. `/reports/bot_summary` uses aggregate raw count strategies: `bot_handoffs_count` counts distinct non-null conversations, while `bot_resolutions_count` counts event rows after excluding conversations with bot handoffs in the same scoped range. P3.45 report bot metric timeseries count strategies, P3.44 live additive rollup parity, P3.43 rollup/backfill timezone parity, P3.42 timeseries timezone parity, P3.41 CSV/download parity, and P3.40 raw JSON/date parity for report, live-report, and summary-report endpoints remain implemented. Profile MFA, Facebook callbacks, account WhatsApp calls, onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for deeper report data-source/metric-builder drift or the next reused-frontend mismatch, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence.
- `go test ./...` passes when run outside the restricted socket sandbox; focused Facebook callback handler/router/route-parity tests pass in the sandbox.
- Route dump succeeds with `967` registered routes after profile MFA route tracking.
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 0 | P3.46 report summary aggregate parity | Implemented for reused v2 reports summary clients: GoChat now follows inspected Chatwoot `Api::V2::Accounts::ReportsController#build_summary`, `V2::Reports::Conversations::MetricBuilder#summary/#bot_summary`, `Reports::RawDataSource#aggregate`, and `Reports::ReportMetricRegistry`. `/reports/summary` returns raw Chatwoot metric keys with a `previous` object instead of the local `{ metrics: [...] }` rollup envelope, uses current/previous Unix-second ranges, honors report `type/id` filters, and switches average values to `value_in_business_hours` when requested. `/reports/bot_summary` now applies aggregate raw count strategies: handoffs are distinct non-null conversations, and bot resolutions count event rows after excluding handoff conversations in the same scoped range. | Keep in Review; reopen from B12 reports summary smoke, fresh reference evidence for rollup-backed `Reports::DataSource.for`, invalid timezone/range controller behavior, or summary dimension scope drift for team/label/report-type filters. | Focused Analytics service bot-summary tests, Analytics handler summary tests, escalated full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.45 report bot raw-count strategy parity | Implemented for reused v2 reports chart clients: GoChat now follows inspected Chatwoot `Reports::RawDataSource#reporting_event_count_scope`, `Reports::ReportMetricRegistry`, and v2 timeseries specs for bot metrics. `bot_handoffs_count` uses the `distinct_conversation` strategy and ignores nil conversation IDs, so duplicate handoff rows for the same conversation count once per bucket. `bot_resolutions_count` uses the `exclude_bot_handoffs` strategy and removes conversations that also have a bot handoff in the same scoped range before timeseries bucketing. Dimension scopes reuse the existing account/inbox/agent/team/label filters. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for aggregate-value endpoints beyond the current timeseries route, data-source scope behavior that differs for team/label dimensions, or future `ReportMetricRegistry` count strategies. | Focused Analytics service bot-timeseries tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.44 reporting live additive rollup parity | Implemented for live reporting-event freshness: GoChat now follows inspected Chatwoot `ReportingEventListener#safe_rollup`, `ReportingEvents::RollupService#upsert_rollups`, and `ReportingEvents::EventMetricRegistry` behavior when `AnalyticsService.RecordEvent` persists a raw event. Valid account `reporting_timezone` gates the live write, event `created_at` determines the local rollup date, account/agent/inbox dimensions are emitted only when present, known event metrics expand to Chatwoot rollup metrics, unknown raw events emit no rollup rows, and conflicts add incoming `count`, `sum_value`, and `sum_value_business_hours` to the existing unique row. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for dispatcher/listener event creation paths that bypass `AnalyticsService.RecordEvent`, Sidekiq retry semantics beyond swallowed live-rollup errors, or report metric registry additions beyond the inspected reference event registry. | Focused Reporting live rollup/repository tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.43 reporting rollup/backfill timezone parity | Implemented for reused report data freshness paths: GoChat now follows inspected Chatwoot `ReportingEvents::RollupService`, `ReportingEvents::BackfillService`, reporting-event service specs, and `Account#reporting_timezone` validation behavior by persisting/serializing `reporting_timezone`, treating blank or invalid values as a rollup-write skip, converting local report dates into UTC windows through the account reporting timezone, querying reporting events by `created_at >= start AND created_at < end`, skipping nil agent/inbox dimensions instead of falling back to the account id, zeroing count-metric sum fields, and counting distinct `conversation_bot_handoff` conversations per account/agent/inbox dimension. | Keep in Review; reopen from B12 reports smoke or fresh reference evidence for ActiveSupport timezone-name aliases beyond IANA names, data-source rollup read feature flags, or report metric registry drift beyond the inspected rollup/backfill contract. | Focused Reporting rollup/backfill/service tests, focused Analytics/Account handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
@@ -284,6 +285,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(reports): align summary aggregates` | Advances P3.46 report summary aggregate parity by matching Chatwoot `Api::V2::Accounts::ReportsController#build_summary`, `V2::Reports::Conversations::MetricBuilder`, `Reports::RawDataSource#aggregate`, and `ReportMetricRegistry`. GoChat `/reports/summary` now returns raw current metric keys plus `previous`, computes current/previous range aggregates from persisted conversations/messages/reporting events, honors report `type/id` dimensions and business-hours averages, and makes `bot_summary` aggregate counts follow the same `distinct_conversation` and `exclude_bot_handoffs` raw count strategies as the current timeseries path. | `go test ./internal/service -run 'Analytics.*Summary\|BotSummary\|BotTimeseries\|AnalyticsReports' -count=1`; `go test ./internal/handler/api/v1 -run AnalyticsHandler -count=1`; escalated full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.46 moves to Review for current summary aggregate evidence; continue Phase 2/3 drift audit for report builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `docs: consolidate parity execution tracker` | Documentation-only checkpoint requested before continuing implementation. It records the already-committed `aa159f6 feat(reports): align bot count strategies` implementation baseline, adds a one-page tracking dashboard, and makes the next-turn execution rule explicit: Phase 2/3 drift audit first unless the full B12 live stack is available. | `git diff --check`. No Go code, migrations, or route artifacts changed. | Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from this tracker; `.hermes/plans/*` remain source notes only. |
| `feat(reports): align bot count strategies` | Advances P3.45 report bot raw-count strategy parity by matching Chatwoot `Reports::RawDataSource#reporting_event_count_scope`, `Reports::ReportMetricRegistry`, and v2 report timeseries specs. GoChat now applies `distinct_conversation` for `bot_handoffs_count`, ignores nil handoff conversation IDs, and applies `exclude_bot_handoffs` for `bot_resolutions_count` before bucketing so conversations that both resolve and hand off are not double counted. | `go test ./internal/service -run 'Analytics.*Timeseries\|BotTimeseries\|Reporting.*Rollup\|Backfill' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.45 moves to Review for current bot metric raw-count evidence; continue Phase 2/3 drift audit for aggregate-value endpoints, report data-source/metric-builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
| `feat(reports): add live rollup upserts` | Advances P3.44 reporting live additive rollup parity by matching Chatwoot `ReportingEventListener#safe_rollup`, `ReportingEvents::RollupService`, and `ReportingEvents::EventMetricRegistry`. GoChat now applies rollups immediately after `AnalyticsService.RecordEvent` persists the raw reporting event, computes the local rollup date from the account `reporting_timezone`, emits only known event-registry metrics, and additively upserts unique rollup rows so repeated live events increment existing `count`, `sum_value`, and `sum_value_business_hours`. | `go test ./internal/service ./internal/repository -run 'Reporting.*Rollup\|Backfill\|Analytics\|ReportingEventsRollup' -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.44 moves to Review for current live-rollup evidence; continue Phase 2/3 drift audit for event-listener paths bypassing `AnalyticsService.RecordEvent`, report data-source/metric-builder depth, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
@@ -2545,3 +2547,4 @@ Verification milestone gates:
- 2026-06-06: P3.44 reporting live additive rollup checkpoint prepared as `feat(reports): add live rollup upserts`; audited Chatwoot reporting event listener safe-rollup path, rollup service additive `upsert_all`, and event metric registry. GoChat now applies live rollups from `AnalyticsService.RecordEvent` after raw event persistence, uses account reporting timezone for the event-created local date, ignores unknown raw metrics, and additively upserts existing rollup rows instead of waiting for lazy daily recompute. Focused reporting live rollup/repository tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.45 report bot raw-count strategy checkpoint prepared as `feat(reports): align bot count strategies`; audited Chatwoot `Reports::RawDataSource`, `Reports::ReportMetricRegistry`, and v2 timeseries bot-resolution specs. GoChat `bot_handoffs_count` timeseries now counts distinct non-null conversations per bucket, while `bot_resolutions_count` excludes conversations that also handed off in the scoped range before bucketing. Focused Analytics timeseries tests, escalated full `go test ./...`, and `git diff --check` passed. No route artifacts change.
- 2026-06-07: Documentation checkpoint prepared as `docs: consolidate parity execution tracker`; confirms `aa159f6 feat(reports): align bot count strategies` is already committed before new implementation work, adds the one-page tracking dashboard, keeps `.hermes/plans/*` as source notes only, and makes Phase 2/3 drift audit the next default action unless the full B12 live stack is available. Verification for this docs-only checkpoint: `git diff --check`.
- 2026-06-07: P3.46 report summary aggregate checkpoint prepared as `feat(reports): align summary aggregates`; audited Chatwoot v2 reports controller summary builder, conversation metric builder, raw data-source aggregate, metric registry, and reused dashboard `reports.js` summary/bot-summary callers. GoChat `/reports/summary` now returns raw Chatwoot metric keys plus `previous`, derives current/previous aggregates from persisted rows, honors report `type/id` filters and business-hours averages, and fixes `/reports/bot_summary` aggregate counting so handoffs are distinct non-null conversations while bot resolutions count event rows after excluding handoff conversations. Focused Analytics service and handler tests plus escalated full `go test ./...` passed; `git diff --check` passed. No route artifacts change.
+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
}