Class: Report::CallRecordingGaps

Inherits:
Object
  • Object
show all
Defined in:
app/services/report/call_recording_gaps.rb

Overview

Daily reconciliation report: of yesterday's connected (TALKING) call legs that
SHOULD have a recording, which are MISSING one ("gaps").

A gap is a TALKING CallLogEvent with a real caller-id + at least
MIN_RECORDABLE_SECONDS of talk time — the same recordability test
Phone::CallLogEventToRecordMatcher applies — that the matcher could not link
to a CallRecord (so call_record_id stays nil).

Each gap is then classified, because "unmatched leg" is NOT the same as "lost
audio" — a transferred call has several CDR legs but one recording, so a sibling
leg legitimately holds the audio:

  • STATUS_ABSENT — no recording for this call exists in the archive at all.
    These are the real "audio never reached R2 / was never
    imported" gaps and the number the report leads with.
  • STATUS_UNLINKED — a recording for this call IS in the archive but isn't
    linked to this particular leg (e.g. a transfer/ring
    sibling claimed it). A reconciliation artifact, not lost
    audio.

The matcher runs every 15 min after each call-log import, so the links are
already current; this report just classifies yesterday's recordable legs.
Mirrors AccountDailyActivityReport: for_scheduled_run + America/Chicago day
window + memoized data the mailer view reads.

Defined Under Namespace

Classes: Gap

Constant Summary collapse

TZ =
'America/Chicago'
STATUS_ABSENT =

No recording for this call exists in the archive — genuinely missing audio.

:absent
STATUS_UNLINKED =

A recording exists in the archive but is linked to a different leg of the
same call (transfer/consult sibling), not this one.

:unlinked
NON_RECORDED_EXTENSIONS =

Agent extensions that are NOT recorded, so their TALKING legs must not count
as gaps. 602 = "BPO 24x7", an outsourced 24/7 answering service whose calls
are handled (and recorded, if at all) by the BPO, never in Heatwave —
verified 0% recording coverage over 45 days (33 legs, 0 recordings). Without
this, a quiet weekend whose only call is a BPO call reads as a 100% gap.

[602].freeze
MIN_RECORDABLE_SECONDS =

Sub-second-grade connects (1s of talk: answered-and-immediately-dropped) carry
no usable audio — Switchvox emits an empty/invalid WAV that the importer skips,
so no recording can ever exist. Counting them as recordable just manufactures a
permanent "missing audio" gap. Floor talk time at this many seconds.

2

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(date:) ⇒ CallRecordingGaps

Returns a new instance of CallRecordingGaps.

Parameters:

  • date (Date)

    the calendar day (America/Chicago) to reconcile



65
66
67
68
69
70
# File 'app/services/report/call_recording_gaps.rb', line 65

def initialize(date:)
  @date = date
  tz = Time.find_zone(TZ)
  @window_start = tz.local(date.year, date.month, date.day).beginning_of_day
  @window_end = @window_start.end_of_day
end

Instance Attribute Details

#dateDate (readonly)

Returns the day being reconciled.

Returns:

  • (Date)

    the day being reconciled



60
61
62
# File 'app/services/report/call_recording_gaps.rb', line 60

def date
  @date
end

#window_endActiveSupport::TimeWithZone (readonly)

Returns:

  • (ActiveSupport::TimeWithZone)


62
63
64
# File 'app/services/report/call_recording_gaps.rb', line 62

def window_end
  @window_end
end

#window_startActiveSupport::TimeWithZone (readonly)

Returns:

  • (ActiveSupport::TimeWithZone)


62
63
64
# File 'app/services/report/call_recording_gaps.rb', line 62

def window_start
  @window_start
end

Class Method Details

.for_scheduled_runReport::CallRecordingGaps

Previous calendar day in America/Chicago — the scheduled default.



57
# File 'app/services/report/call_recording_gaps.rb', line 57

def self.for_scheduled_run = new(date: Time.find_zone(TZ).now.to_date - 1)

Instance Method Details

#absent_countInteger

Returns:

  • (Integer)


96
# File 'app/services/report/call_recording_gaps.rb', line 96

def absent_count = gaps_absent.size

#absent_rateFloat

Share of recordable calls with NO archived audio — the genuine-loss rate.

Returns:

  • (Float)

    percentage 0.0–100.0



111
# File 'app/services/report/call_recording_gaps.rb', line 111

def absent_rate = expected.zero? ? 0.0 : (absent_count.to_f / expected * 100).round(1)

#coverage_rateFloat

Share of recordable calls that DO have a recording.

Returns:

  • (Float)

    percentage 0.0–100.0



103
# File 'app/services/report/call_recording_gaps.rb', line 103

def coverage_rate = expected.zero? ? 100.0 : (matched.to_f / expected * 100).round(1)

#expectedInteger

Recordable TALKING legs we expected to have a recording.

Returns:

  • (Integer)


74
# File 'app/services/report/call_recording_gaps.rb', line 74

def expected = data[:expected]

#gap_countInteger

Returns:

  • (Integer)


85
# File 'app/services/report/call_recording_gaps.rb', line 85

def gap_count = gaps.size

#gap_rateFloat

Share of recordable calls that are missing a recording.

Returns:

  • (Float)

    percentage 0.0–100.0



107
# File 'app/services/report/call_recording_gaps.rb', line 107

def gap_rate = expected.zero? ? 0.0 : (gap_count.to_f / expected * 100).round(1)

#gapsArray<Gap>

...the ones that did NOT — the gaps, sorted by time.

Returns:



82
# File 'app/services/report/call_recording_gaps.rb', line 82

def gaps = data[:gaps]

#gaps_absentArray<Gap>

Gaps with no recording anywhere in the archive — genuinely missing audio.

Returns:



89
# File 'app/services/report/call_recording_gaps.rb', line 89

def gaps_absent = gaps.select { |g| g.status == STATUS_ABSENT }

#gaps_unlinkedArray<Gap>

Gaps whose audio IS archived but linked to a sibling leg of the same call.

Returns:



93
# File 'app/services/report/call_recording_gaps.rb', line 93

def gaps_unlinked = gaps.select { |g| g.status == STATUS_UNLINKED }

#matchedInteger

...of those, the count that linked to a CallRecord.

Returns:

  • (Integer)


78
# File 'app/services/report/call_recording_gaps.rb', line 78

def matched = data[:matched]

#to_csvString

CSV of the gaps for the email attachment.

Returns:

  • (String)


115
116
117
118
119
120
121
122
123
124
# File 'app/services/report/call_recording_gaps.rb', line 115

def to_csv
  require 'csv'
  CSV.generate do |csv|
    csv << %w[occurred_at extension from to direction duration cdr_call_id status]
    gaps.each do |g|
      csv << [g.occurred_at&.to_fs(:db), g.extension, g.from, g.to, g.direction, g.duration,
              g.cdr_call_id, g.status]
    end
  end
end

#unlinked_countInteger

Returns:

  • (Integer)


99
# File 'app/services/report/call_recording_gaps.rb', line 99

def unlinked_count = gaps_unlinked.size