Class: PhoneQueue

Inherits:
Object
  • Object
show all
Defined in:
app/models/phone_queue.rb

Overview

One phone queue and the reps who answer it.

This is the only place that knows how queue membership is stored. Today that
is the Switchvox-sourced +employee_phone_statuses.queue_statuses+ JSONB, keyed
by the PBX's queue account id. Switchvox is being retired for Twilio +
TaskRouter, where membership becomes Worker attributes — when that lands,
+#members+ changes and nothing else does.

Constant Summary collapse

REGISTRY =

queue account id => [extension, display name].

Pinned rather than derived from a DISTINCT over queue_call_logs: a queue
with no recent calls has no rows there, so deriving it would silently drop
exactly the quiet queues someone opens this page to investigate.

{
  1141 => [615, 'Operator'],
  1142 => [612, 'Sales'],
  1143 => [613, 'Tech 1'],
  1147 => [600, 'Tech 24x7'],
  1164 => [618, 'Spanish'],
  1204 => [621, 'Management'],
  1225 => [623, 'Accounting'],
  1276 => [630, 'Sales_Homeowner'],
  1277 => [631, 'Sales_Commercial'],
  1278 => [632, 'Sales_BG'],
  1279 => [633, 'Sales_Trade'],
  1280 => [634, 'Sales_Ecommerce']
}.each_value(&:freeze).freeze
ACTIVITY_WINDOW_DAYS =

Window used to judge whether a queue is still in service.

90

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(account_id, extension, name) ⇒ PhoneQueue

Returns a new instance of PhoneQueue.

Parameters:

  • account_id (Integer)

    the PBX's queue account id

  • extension (Integer)

    the dialable queue extension

  • name (String)

    the queue's display name



97
98
99
100
101
# File 'app/models/phone_queue.rb', line 97

def initialize(, extension, name)
  @account_id = 
  @extension = extension
  @name = name
end

Instance Attribute Details

#account_idObject (readonly)

Returns the value of attribute account_id.



34
35
36
# File 'app/models/phone_queue.rb', line 34

def 
  @account_id
end

#extensionObject (readonly)

Returns the value of attribute extension.



34
35
36
# File 'app/models/phone_queue.rb', line 34

def extension
  @extension
end

#nameObject (readonly)

Returns the value of attribute name.



34
35
36
# File 'app/models/phone_queue.rb', line 34

def name
  @name
end

Class Method Details

.activityHash{Integer => Hash}

Traffic per queue in one grouped query, so a page can show every queue's
activity without a query each.

Answers "is this queue still real?" — the registry is hand-maintained, and
queue_call_logs holds another dozen account ids (Tech 2, Customer_Contact,
Sales Overflow…) whose last call was between 2017 and 2025. Note the ids are
reused: 1204 was "Operator Overflow" in 2019 and is "Management" now, so the
registry pins today's meaning and history is read by id, never by name.

Returns:

  • (Hash{Integer => Hash})

    account id => {calls: Integer, last_call_on: Date, nil}



46
47
48
49
50
51
52
53
54
55
# File 'app/models/phone_queue.rb', line 46

def self.activity
  QueueCallLog.where(queue_account_id: REGISTRY.keys)
              .group(:queue_account_id)
              .pluck(
                :queue_account_id,
                Arel.sql("COUNT(*) FILTER (WHERE start_date_cst > CURRENT_DATE - #{ACTIVITY_WINDOW_DAYS})"),
                Arel.sql('MAX(start_date_cst)')
              )
              .to_h { |acct, calls, last| [acct, { calls: calls.to_i, last_call_on: last }] }
end

.allArray<PhoneQueue>

Built fresh on every call. Memoising at class level would pin each
instance — and its cached #members — for the life of the process, so a rep
joining a queue would never show up until a deploy.

Returns:

  • (Array<PhoneQueue>)

    every known queue, alphabetical



78
79
80
# File 'app/models/phone_queue.rb', line 78

def self.all
  REGISTRY.map { |, (extension, name)| new(, extension, name) }.sort_by(&:name)
end

.find(account_id) ⇒ PhoneQueue?

Parameters:

  • account_id (Integer, String, nil)

Returns:



84
85
86
# File 'app/models/phone_queue.rb', line 84

def self.find()
  all.find { |queue| queue. == .to_i }
end

.for_extension(extension) ⇒ PhoneQueue?

Parameters:

  • extension (Integer, String, nil)

Returns:



90
91
92
# File 'app/models/phone_queue.rb', line 90

def self.for_extension(extension)
  all.find { |queue| queue.extension == extension.to_i }
end

.member_countsHash{Integer => Integer}

Roster size per queue in one grouped query.

Returns:

  • (Hash{Integer => Integer})

    account id => member count



60
61
62
63
64
65
66
67
68
69
70
71
# File 'app/models/phone_queue.rb', line 60

def self.member_counts
  counts = Hash.new(0)
  # Scoped to active employees to match #members. A departed rep whose phone
  # status was never cleaned up would otherwise be counted as roster, hiding
  # the "taking calls, no roster" warning while coverage stayed empty.
  EmployeePhoneStatus.where.not(queue_statuses: nil)
                     .where(employee_id: Employee.active_employees.select(:id))
                     .pluck(:queue_statuses).each do |statuses|
    (statuses || {}).each_key { |key| counts[key.to_i] += 1 }
  end
  counts
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Returns true when +other+ is the same queue.

Parameters:

  • other (Object)

Returns:

  • (Boolean)

    true when +other+ is the same queue



146
# File 'app/models/phone_queue.rb', line 146

def ==(other) = other.is_a?(PhoneQueue) && other. == 

#hashInteger

Namespaced so a queue never collides with a bare Integer in a Hash or Set.

Returns:

  • (Integer)


152
# File 'app/models/phone_queue.rb', line 152

def hash = [self.class, ].hash

#membersArray<Employee>

Reps the PBX will ring for this queue, whether or not they are currently
logged in — a logged-off member is still roster, and the coverage table
says so rather than pretending they don't exist.

Returns:



108
109
110
111
112
113
114
# File 'app/models/phone_queue.rb', line 108

def members
  @members ||= Employee.active_employees
                       .where(id: statuses_for_queue.map(&:employee_id))
                       .includes(:employee_work_schedules, :employee_phone_status, :employee_record)
                       .order(:full_name)
                       .to_a
end

#other_queue_countsHash{Integer => Integer}

How many other queues each member also answers, for the dedicated/shared
split. A rep in six queues is not six reps of capacity.

Returns:

  • (Hash{Integer => Integer})

    employee id => count of other queues



135
136
137
138
139
# File 'app/models/phone_queue.rb', line 135

def other_queue_counts
  @other_queue_counts ||= EmployeePhoneStatus
                          .where(employee_id: members.map(&:id))
                          .to_h { |eps| [eps.employee_id, (queue_keys(eps) - [.to_s]).size] }
end

#to_paramString

Returns the account id, so routes read /queue_coverage?queue_id=1143.

Returns:

  • (String)

    the account id, so routes read /queue_coverage?queue_id=1143



142
# File 'app/models/phone_queue.rb', line 142

def to_param = .to_s

#unlisted_answerers(since: 90) ⇒ Array<Employee>

Employee ids seen answering this queue in +since+ that the roster doesn't
claim. Usually overflow or a redirect rather than real membership, so they
are surfaced as a note and never folded into the headcount — merging them
would inflate coverage.

Parameters:

  • since (Integer) (defaults to: 90)

    lookback in days

Returns:



123
124
125
126
127
128
129
# File 'app/models/phone_queue.rb', line 123

def unlisted_answerers(since: 90)
  ids = QueueCallLog.where(queue_account_id: )
                    .where(start_date_cst: since.days.ago.to_date..)
                    .where.not(member_party_id: nil)
                    .distinct.pluck(:member_party_id)
  Employee.where(id: ids - members.map(&:id)).order(:full_name).to_a
end