# frozen_string_literal: true # Reporting — KPI + billing rollups computed from the Event stream (Service#report feeds the console). # Pure functions over event_view hashes; nothing is stored derived — every number recomputes from Events # so a filed report is reproducible from a frozen set of Event ids. # # Metric definitions (all documented — these are DELIBERATE choices, not survey-standard): # • calls_handled — inbound calls in the period; `answered` = a human picked up (answered_at present). # • aht_s — Average Handle Time = mean talk time (duration_s) over answered calls. NOTE: talk-time # only until the Dialer exposes hold/wrap-up time (then it becomes full COPC-style AHT). # • fcr — First-Call Resolution, INTERNAL method (NOT survey-FCR; differs from the SQM 69–71% band): # of the FIRST calls (no prior_event_id) that were triaged (resolution set), the share marked # `solved` with NO repeat call from the same person within the RCR window. `partial`/`unsolved` # count as not-resolved. # • repeat_call_rate — share of handled calls that ARE a repeat (prior_event_id set). # • resolution_breakdown — counts of solved/partial/unsolved/untriaged. # • Percentages are SUPPRESSED (nil) below `min_n` handled calls to avoid small-N noise. require "time" module Helpdesk module Report module_function def build(events, from: nil, to: nil, operator: nil, campaign: nil, rcr_window_days: 7, min_n: 5, retainer_czk: nil, whatsapp_surcharge_czk: 100) all = events.map { |e| symbolize(e) } window = rcr_window_days * 86_400 inperiod = all.select do |e| t = ts(e[:started_at]); next false unless t (from.nil? || t >= time(from)) && (to.nil? || t < time(to)) && (operator.nil? || e[:operator] == operator) && (campaign.nil? || e[:campaign] == campaign) end { period: { from: from, to: to }, filter: { operator: operator, campaign: campaign }, rcr_window_days: rcr_window_days, **kpis(inperiod, all, window, min_n), resolution_breakdown: resolution_breakdown(inperiod), surcharges: surcharges(inperiod, whatsapp_surcharge_czk), retainer_czk: retainer_czk, by_operator: group(inperiod, all, window, min_n) { |e| e[:operator] || "unrouted" }, by_campaign: group(inperiod, all, window, min_n) { |e| e[:campaign] || "—" }, } end # --- core KPIs over a set of calls ----------------------------------------- def kpis(calls, universe, window, min_n) total = calls.size answered = calls.count { |e| e[:answered_at] } durs = calls.select { |e| e[:answered_at] && e[:duration_s] }.map { |e| e[:duration_s] } repeats = calls.count { |e| e[:prior_event_id] } firsts = calls.select { |e| e[:prior_event_id].nil? } triaged_firsts = firsts.select { |e| e[:resolution] } fcr_pos = triaged_firsts.count { |e| e[:resolution] == "solved" && !called_back?(e, universe, window) } { calls_handled: total, answered: answered, missed: total - answered, aht_s: durs.empty? ? nil : (durs.sum.to_f / durs.size).round, fcr: pct(fcr_pos, triaged_firsts.size, total, min_n), repeat_call_rate: pct(repeats, total, total, min_n), } end def resolution_breakdown(calls) b = { "solved" => 0, "partial" => 0, "unsolved" => 0, "untriaged" => 0 } calls.each { |e| b[e[:resolution] || "untriaged"] += 1 } b end def surcharges(calls, whatsapp_czk) wa = calls.count { |e| e[:channel] == "whatsapp" } { whatsapp: { count: wa, unit_czk: whatsapp_czk, total_czk: wa * whatsapp_czk } } end def group(calls, universe, window, min_n) calls.group_by { |e| yield(e) }.transform_values do |grp| kpis(grp, universe, window, min_n).merge(resolution_breakdown: resolution_breakdown(grp)) end end # --- helpers --------------------------------------------------------------- # did anyone call back (a later event linking to this one) within the window? def called_back?(call, universe, window) base = ts(call[:started_at]); return false unless base universe.any? do |e| e[:prior_event_id] == call[:id] && (t = ts(e[:started_at])) && t > base && (t - base) <= window end end def pct(num, den, total, min_n) return nil if den.zero? || total < min_n # suppress small-N ((num.to_f / den) * 100).round(1) end def ts(v) = v ? (Time.parse(v.to_s) rescue nil) : nil def time(v) = v.is_a?(Time) ? v : Time.parse(v.to_s) def symbolize(e) = e.is_a?(Hash) ? e.transform_keys(&:to_sym) : e end end