docs: introduce Mercury Notebook Deliverable Pattern
This commit is contained in:
137
studies/202607_CTM_GenesysCX/tests/test_appendix4.py
Normal file
137
studies/202607_CTM_GenesysCX/tests/test_appendix4.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Appendix-4 corrected business case — hand-check acceptance numbers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc import appendix4 as a4
|
||||
from tokencalc.defaults import CTM_DEFAULT_SITES, DEFAULT_METERS, DEFAULT_PRICING
|
||||
|
||||
SITES = list(CTM_DEFAULT_SITES)
|
||||
|
||||
|
||||
def test_verbatim_crossfoots_to_slide_totals():
|
||||
df = a4.verbatim_dataframe()
|
||||
for r, expect in a4.SLIDE_TOTALS["regional_3yr"].items():
|
||||
got = df.loc[df.region == r, "three_yr"].sum()
|
||||
assert abs(got - expect) <= a4.crossfoot_tolerance(expect), r
|
||||
for c, expect in a4.SLIDE_TOTALS["capability_3yr"].items():
|
||||
got = df.loc[df.capability == c, "three_yr"].sum()
|
||||
assert abs(got - expect) <= a4.crossfoot_tolerance(expect), c
|
||||
assert abs(df["three_yr"].sum() - a4.SLIDE_TOTALS["total_3yr"]) <= \
|
||||
a4.crossfoot_tolerance(a4.SLIDE_TOTALS["total_3yr"])
|
||||
|
||||
|
||||
def test_benefits_phase_on_the_deck_schedule():
|
||||
_, _, benefit_rollout = a4.build_rollouts(SITES)
|
||||
long = a4.benefits_by_year(benefit_rollout)
|
||||
by_year = long.groupby("year")["benefit"].sum()
|
||||
assert by_year[2026] == 0.0, "2026 must be $0 under Genesys's own schedule"
|
||||
# Scaling at the finest grain reproduces every verbatim 3-yr value exactly.
|
||||
for (region, cap), (_, three_yr) in a4.VERBATIM_BENEFITS.items():
|
||||
got = long.query("region == @region and capability == @cap")["benefit"].sum()
|
||||
assert got == pytest.approx(three_yr)
|
||||
|
||||
|
||||
def test_ramp_zeroes_year_one_licences():
|
||||
assert a4.licence_costs_by_year(12) == {2026: 0.0, 2027: 4_300_000.0,
|
||||
2028: 4_300_000.0}
|
||||
assert a4.licence_costs_by_year(0)[2026] == 4_300_000.0
|
||||
assert a4.licence_costs_by_year(18)[2027] == pytest.approx(4_300_000 * 6 / 12)
|
||||
# The order form's ramp is 6 months — licences bill from July 2026.
|
||||
assert a4.DEFAULT_RAMP_MONTHS == 6
|
||||
assert a4.licence_costs_by_year() == {2026: 2_150_000.0, 2027: 4_300_000.0,
|
||||
2028: 4_300_000.0}
|
||||
|
||||
|
||||
def test_current_state_run_off():
|
||||
cs = a4.current_state_inputs(SITES)
|
||||
assert cs["annual_cost"].sum() == pytest.approx(7_300_000)
|
||||
by_year = a4.current_costs_by_year(cs)
|
||||
assert by_year == {2026: pytest.approx(7_300_000),
|
||||
2027: pytest.approx(7_300_000), 2028: 0.0}
|
||||
cs.loc["NA", "contract_termination"] = dt.date(2028, 6, 30)
|
||||
assert a4.current_costs_by_year(cs)[2028] == pytest.approx(
|
||||
cs.loc["NA", "annual_cost"] * 6 / 12)
|
||||
|
||||
|
||||
def test_token_hand_checks():
|
||||
token_ro, email_ro, _ = a4.build_rollouts(SITES)
|
||||
core, email = a4.build_scopes(SITES, copilot_includes_asia=False)
|
||||
meters = {**DEFAULT_METERS, "Email AI (Auto-Respond)": a4.autorespond_meter(0.05)}
|
||||
long = a4.token_costs_by_year(SITES, meters, DEFAULT_PRICING,
|
||||
a4.claim_scenario(0.255), core, email,
|
||||
token_ro, email_ro)
|
||||
# STA 2028: NAM/AUZ/EMEA × 12 months + ASIA × 10 months, by hand.
|
||||
sta = long.query("cost_line == 'Speech & Text Analytics [named]'")
|
||||
assert sta.query("year == 2028")["annual_cost"].sum() == pytest.approx(715_800)
|
||||
# Agent Copilot 2028 (ASIA off): 1,490 users × 40 tokens × 12 months.
|
||||
cp = long.query("cost_line == 'Agent Copilot [named]' and year == 2028")
|
||||
assert cp["annual_cost"].sum() == pytest.approx(1_490 * 40 * 12)
|
||||
# Rule 1: Copilot covers AI Summary at Copilot sites.
|
||||
assert (long.query("cost_line == 'AI Summary & Insights'")["annual_cost"] == 0).all()
|
||||
# Nothing is live in 2026.
|
||||
assert long.query("year == 2026")["annual_cost"].sum() == 0
|
||||
# PR NAM steady-month tokens.
|
||||
assert math.ceil(
|
||||
1_214_358 * DEFAULT_METERS["Predictive Routing"].tokens_per_unit) == 71_433
|
||||
|
||||
|
||||
def test_impl_costs_reconcile_with_v2_doc():
|
||||
_, impl_y, kb_y, steady_y = a4.build_impl_costs(SITES, "mid", 225.0,
|
||||
include_kb=True)
|
||||
assert sum(impl_y.values()) == pytest.approx(5_850 * 225) # V2's "$1.3M"
|
||||
assert sum(kb_y.values()) == pytest.approx(1_000 * 225)
|
||||
assert steady_y == {2026: 0.0, 2027: pytest.approx(700 * 225),
|
||||
2028: pytest.approx(700 * 225)}
|
||||
# Impl spend is fully booked by each region's implementation month.
|
||||
assert a4.impl_year_fractions(18) == pytest.approx([12 / 18, 6 / 18, 0.0])
|
||||
assert a4.impl_year_fractions(27) == pytest.approx([12 / 27, 12 / 27, 3 / 27])
|
||||
|
||||
|
||||
def test_case_flows_and_kpis():
|
||||
benefits = {2026: 0.0, 2027: 2_000_000.0, 2028: 12_000_000.0}
|
||||
costs = {2026: 10_000_000.0, 2027: 13_000_000.0, 2028: 8_000_000.0}
|
||||
inc, net = a4.case_flows(costs, benefits)
|
||||
assert inc == {2026: pytest.approx(2_700_000),
|
||||
2027: pytest.approx(5_700_000),
|
||||
2028: pytest.approx(700_000)}
|
||||
for y in a4.YEARS:
|
||||
assert net[y] == pytest.approx(benefits[y] - inc[y])
|
||||
kpis = a4.case_kpis(inc, net)
|
||||
assert kpis["benefits_3yr"] == pytest.approx(sum(benefits.values()))
|
||||
assert kpis["net_3yr"] == pytest.approx(sum(net.values()))
|
||||
assert kpis["roi"] == pytest.approx(kpis["net_3yr"] / kpis["incremental_cost_3yr"])
|
||||
assert kpis["discount_rate"] == 0.135
|
||||
# Net cost saving → ROI undefined.
|
||||
inc2 = {y: -1.0 for y in a4.YEARS}
|
||||
net2 = {y: benefits[y] + 1.0 for y in a4.YEARS}
|
||||
assert a4.case_kpis(inc2, net2)["roi"] is None
|
||||
|
||||
|
||||
def test_contracted_overlays_verbatim():
|
||||
assert a4.tco("ccaas_annual") == 3_200_000 # signed contract
|
||||
assert a4.TCO_VERBATIM["ccaas_annual"] == 4_300_000 # deck record intact
|
||||
assert a4.tco("current_annual") == a4.TCO_VERBATIM["current_annual"]
|
||||
assert a4.licence_costs_by_year(12, a4.tco("ccaas_annual"))[2027] == 3_200_000
|
||||
|
||||
|
||||
def test_sow_milestones_and_managed_services():
|
||||
assert a4.PS_CONTRACTED_TOTAL == pytest.approx(2_025_446.48)
|
||||
for m in a4.PS_MILESTONES: # amounts match the shares
|
||||
assert m["amount"] == pytest.approx(m["share"] * a4.PS_CONTRACTED_TOTAL,
|
||||
abs=0.01)
|
||||
ps = a4.ps_costs_by_year(contracted=True) # 50/50 across 2026-27
|
||||
assert ps[2026] == pytest.approx(607_633.94 + 405_089.30 + 167_000)
|
||||
assert ps[2027] == pytest.approx(607_633.94 + 405_089.30)
|
||||
assert ps[2028] == 0.0
|
||||
# The deck's verbatim year-1 lump stays intact for the as-pitched frame.
|
||||
assert a4.ps_costs_by_year() == {2026: 2_567_000, 2027: 0.0, 2028: 0.0}
|
||||
# Managed services bill from the month after MCX go-live (Sep 30 → Oct).
|
||||
ms = a4.managed_services_by_year()
|
||||
assert ms[2026] == pytest.approx(410_918.40 * 3 / 12)
|
||||
assert ms[2027] == pytest.approx(410_918.40)
|
||||
assert ms[2028] == pytest.approx(410_918.40)
|
||||
237
studies/202607_CTM_GenesysCX/tests/test_benefit_model.py
Normal file
237
studies/202607_CTM_GenesysCX/tests/test_benefit_model.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""Benefit engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.benefit_model import (
|
||||
calculate_acw_summarization_benefit,
|
||||
calculate_email_ai_benefit,
|
||||
calculate_total_benefit,
|
||||
calculate_va_deflection_benefit,
|
||||
)
|
||||
from tokencalc.defaults import CTM_DEFAULT_FEATURE_SCOPES, CTM_DEFAULT_SITES
|
||||
from tokencalc.inputs import WORKING_SECONDS_PER_YEAR, FeatureScope, SiteInput
|
||||
from tokencalc.scenarios import BENEFIT_PARAMS
|
||||
|
||||
ALL_SITES = [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
|
||||
|
||||
def _small_site() -> SiteInput:
|
||||
return SiteInput(
|
||||
"Small", "US", agents=10, supervisors=1,
|
||||
voice_volume_monthly=10_000, email_volume_monthly=1_000,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=60,
|
||||
fully_loaded_agent_cost_annual=74_880, # → $0.01/second exactly
|
||||
fully_loaded_supervisor_cost_annual=95_000,
|
||||
)
|
||||
|
||||
|
||||
def test_acw_benefit_hand_check():
|
||||
"""10,000 calls × 12 × 70% eligible × 60s ACW × 40% reduction ×
|
||||
50% Y1 realization × $0.01/s = $10,080."""
|
||||
site = _small_site()
|
||||
assert site.agent_cost_per_second == pytest.approx(0.01)
|
||||
df = calculate_acw_summarization_benefit(
|
||||
[site], FeatureScope("Agent Copilot", ["Small"]), "realistic", year=1,
|
||||
)
|
||||
expected = 10_000 * 12 * 0.70 * 60 * 0.40 * 0.50 * 0.01
|
||||
assert df["annual_value"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_email_benefit_split():
|
||||
site = _small_site()
|
||||
df = calculate_email_ai_benefit(
|
||||
[site], FeatureScope("Email AI (Auto-Respond)", ["Small"]),
|
||||
"realistic", year=1,
|
||||
)
|
||||
# Auto-Suggest is not a separate line — it lives inside Agent Copilot.
|
||||
lines = set(df["benefit_line"])
|
||||
assert lines == {"Email Auto-Respond (displaced handling)"}
|
||||
# auto-respond: 1,000×12 × 20% × 600s × 50% × $0.01 = $7,200
|
||||
respond = df[df["benefit_line"].str.contains("Respond")]["annual_value"].sum()
|
||||
assert respond == pytest.approx(7_200)
|
||||
|
||||
|
||||
def test_scenarios_produce_distinct_benefits():
|
||||
totals = {
|
||||
name: calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, name, year=2
|
||||
)["annual_value"].sum()
|
||||
for name in ("floor", "realistic", "stretch")
|
||||
}
|
||||
assert totals["floor"] < totals["realistic"] < totals["stretch"]
|
||||
|
||||
|
||||
def test_claim_exceeds_realistic():
|
||||
realistic = calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=1,
|
||||
params="realistic",
|
||||
)["annual_value"].sum()
|
||||
claim = calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=1,
|
||||
params="claim",
|
||||
)["annual_value"].sum()
|
||||
assert claim > realistic
|
||||
|
||||
|
||||
def test_benefits_ramp_by_year():
|
||||
by_year = [
|
||||
calculate_total_benefit(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=y
|
||||
)["annual_value"].sum()
|
||||
for y in (1, 2, 3)
|
||||
]
|
||||
assert by_year[0] < by_year[1] < by_year[2]
|
||||
|
||||
|
||||
def test_zero_volume_site_is_safe():
|
||||
site = SiteInput(
|
||||
"Empty", "US", agents=0, supervisors=0,
|
||||
voice_volume_monthly=0, email_volume_monthly=0,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=0,
|
||||
fully_loaded_agent_cost_annual=0,
|
||||
fully_loaded_supervisor_cost_annual=0,
|
||||
)
|
||||
df = calculate_total_benefit(
|
||||
[site], [FeatureScope("Agent Copilot", ["Empty"])], "realistic", year=1,
|
||||
)
|
||||
assert df["annual_value"].sum() == 0
|
||||
|
||||
|
||||
def test_working_seconds_constant():
|
||||
assert WORKING_SECONDS_PER_YEAR == 2_080 * 3_600
|
||||
|
||||
|
||||
# ── Virtual Agent deflection tests ───────────────────────────────────────────
|
||||
|
||||
def test_va_bot_deflection_hand_check():
|
||||
"""Voice Bot: 10,000 calls/mo × 12 × 35% bot_rate × 300s AHT
|
||||
× 50% Y1 realization × realization_factor × $0.01/s.
|
||||
|
||||
realistic realization_factor = 0.70 × 0.80 × (1 − 0.05) = 0.532
|
||||
"""
|
||||
site = _small_site()
|
||||
df = calculate_va_deflection_benefit(
|
||||
[site],
|
||||
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
|
||||
"realistic",
|
||||
year=1,
|
||||
params="realistic",
|
||||
)
|
||||
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
|
||||
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
|
||||
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
|
||||
real_factor = completion * labour * (1.0 - callback)
|
||||
expected = (
|
||||
10_000 * 12 # annual calls
|
||||
* 0.35 # bot deflection rate
|
||||
* 300 # AHT seconds
|
||||
* 0.50 # Y1 scenario realization
|
||||
* real_factor # completion × labour × (1 − callback)
|
||||
* 0.01 # labour rate per second
|
||||
)
|
||||
assert df["annual_value"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_va_agentic_deflection_uses_residual():
|
||||
"""Agentic VA must operate on the residual (1 − bot_rate) call pool,
|
||||
not the full volume.
|
||||
|
||||
With bot_rate=0.35 and va_rate=0.15:
|
||||
residual = 10,000 × (1 − 0.35) = 6,500 calls/mo
|
||||
va_deflected = 6,500 × 0.15 = 975 calls/mo
|
||||
"""
|
||||
site = _small_site()
|
||||
df = calculate_va_deflection_benefit(
|
||||
[site],
|
||||
FeatureScope("Agentic Virtual Agent", ["Small"], deflection_target=0.15),
|
||||
"realistic",
|
||||
year=1,
|
||||
params="realistic",
|
||||
)
|
||||
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
|
||||
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
|
||||
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
|
||||
real_factor = completion * labour * (1.0 - callback)
|
||||
# realistic scenario: voice_bot_deflection = 0.35
|
||||
bot_rate = 0.35
|
||||
va_rate = 0.15
|
||||
expected = (
|
||||
10_000 * 12 # annual calls
|
||||
* (1.0 - bot_rate) * va_rate # residual × va_rate (layered)
|
||||
* 300 # AHT seconds
|
||||
* 0.50 # Y1 scenario realization
|
||||
* real_factor
|
||||
* 0.01
|
||||
)
|
||||
assert df["annual_value"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_va_no_double_count():
|
||||
"""Combined bot + VA benefit must be less than the naive additive sum.
|
||||
|
||||
Naive (wrong): volume × (bot_rate + va_rate) × AHT × ...
|
||||
Correct (layered): volume × (bot_rate + (1−bot_rate)×va_rate) × AHT × ...
|
||||
|
||||
With bot=35%, va=15%:
|
||||
naive total deflection = 50%
|
||||
layered total deflection = 35% + 65%×15% = 44.75%
|
||||
"""
|
||||
site = _small_site()
|
||||
bot_scope = FeatureScope("Voice Bot", ["Small"], deflection_target=0.35)
|
||||
va_scope = FeatureScope("Agentic Virtual Agent", ["Small"], deflection_target=0.15)
|
||||
|
||||
bot_df = calculate_va_deflection_benefit([site], bot_scope, "realistic", year=1)
|
||||
va_df = calculate_va_deflection_benefit([site], va_scope, "realistic", year=1)
|
||||
combined = bot_df["annual_value"].sum() + va_df["annual_value"].sum()
|
||||
|
||||
# Naive additive (the old broken model): both on full volume
|
||||
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
|
||||
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
|
||||
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
|
||||
real_factor = completion * labour * (1.0 - callback)
|
||||
naive = (
|
||||
10_000 * 12 * (0.35 + 0.15) * 300 * 0.50 * real_factor * 0.01
|
||||
)
|
||||
assert combined < naive, (
|
||||
f"Combined layered benefit ({combined:.2f}) should be less than "
|
||||
f"naive additive ({naive:.2f}) — double-count not fixed"
|
||||
)
|
||||
|
||||
# Also verify the exact layered total
|
||||
layered_deflection = 0.35 + (1.0 - 0.35) * 0.15 # = 0.4475
|
||||
expected_combined = (
|
||||
10_000 * 12 * layered_deflection * 300 * 0.50 * real_factor * 0.01
|
||||
)
|
||||
assert combined == pytest.approx(expected_combined)
|
||||
|
||||
|
||||
def test_va_claim_params_reproduce_no_haircut():
|
||||
"""params='claim' must apply zero haircuts (all factors = 1.0),
|
||||
reproducing the original Genesys ROI-doc assumption."""
|
||||
site = _small_site()
|
||||
df_claim = calculate_va_deflection_benefit(
|
||||
[site],
|
||||
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
|
||||
"realistic",
|
||||
year=1,
|
||||
params="claim",
|
||||
)
|
||||
df_realistic = calculate_va_deflection_benefit(
|
||||
[site],
|
||||
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
|
||||
"realistic",
|
||||
year=1,
|
||||
params="realistic",
|
||||
)
|
||||
# claim should be strictly higher (no haircuts applied)
|
||||
assert df_claim["annual_value"].sum() > df_realistic["annual_value"].sum()
|
||||
|
||||
# claim realization_factor = 1.0 × 1.0 × (1 − 0.0) = 1.0
|
||||
expected_claim = 10_000 * 12 * 0.35 * 300 * 0.50 * 1.0 * 0.01
|
||||
assert df_claim["annual_value"].sum() == pytest.approx(expected_claim)
|
||||
117
studies/202607_CTM_GenesysCX/tests/test_business_case.py
Normal file
117
studies/202607_CTM_GenesysCX/tests/test_business_case.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Business case maths + exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.business_case import build_business_case, npv, payback_years
|
||||
from tokencalc.defaults import (
|
||||
CTM_DEFAULT_FEATURE_SCOPES,
|
||||
CTM_DEFAULT_SITES,
|
||||
CTM_DEFAULT_TAKEOUTS,
|
||||
DEFAULT_METERS,
|
||||
DEFAULT_PRICING,
|
||||
)
|
||||
from tokencalc.exports import (
|
||||
export_excel,
|
||||
scenario_state_from_json,
|
||||
scenario_state_to_json,
|
||||
)
|
||||
|
||||
|
||||
def test_npv_hand_check():
|
||||
"""100/yr for 3 years @ 8%: 92.593 + 85.734 + 79.383 = 257.710."""
|
||||
assert npv([100, 100, 100], 0.08) == pytest.approx(257.710, abs=0.001)
|
||||
|
||||
|
||||
def test_payback_interpolation():
|
||||
# -100 in Y1, +200 in Y2 → breakeven halfway through Y2 = 1.5 years
|
||||
assert payback_years([-100, 200, 0]) == pytest.approx(1.5)
|
||||
assert payback_years([-100, -100, -100]) is None
|
||||
assert payback_years([50, 50, 50]) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def _case(scenario="realistic", **kw):
|
||||
return build_business_case(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, DEFAULT_METERS,
|
||||
DEFAULT_PRICING, CTM_DEFAULT_TAKEOUTS, scenario, **kw,
|
||||
)
|
||||
|
||||
|
||||
def test_business_case_shape():
|
||||
case = _case()
|
||||
assert set(case) == {
|
||||
"cost_by_year", "benefit_by_year", "takeouts_by_year",
|
||||
"net_by_year", "cumulative_net", "npv",
|
||||
"payback_period_years", "roi_3yr",
|
||||
}
|
||||
for key in ("cost_by_year", "benefit_by_year", "net_by_year"):
|
||||
assert {"Y1", "Y2", "Y3"} <= set(case[key].columns)
|
||||
|
||||
|
||||
def test_net_consistency():
|
||||
"""NET row must equal benefits + takeouts − costs, per year."""
|
||||
case = _case()
|
||||
nb = case["net_by_year"].set_index("line")
|
||||
for y in ("Y1", "Y2", "Y3"):
|
||||
assert nb.loc["NET", y] == pytest.approx(
|
||||
nb.loc["TOTAL BENEFITS", y]
|
||||
+ nb.loc["TOTAL TAKEOUTS", y]
|
||||
- nb.loc["TOTAL COSTS", y]
|
||||
)
|
||||
# cumulative is a running sum of NET
|
||||
assert nb.loc["Cumulative net", "Y3"] == pytest.approx(
|
||||
sum(nb.loc["NET", y] for y in ("Y1", "Y2", "Y3"))
|
||||
)
|
||||
|
||||
|
||||
def test_npv_matches_net_rows():
|
||||
case = _case()
|
||||
nb = case["net_by_year"].set_index("line")
|
||||
net = [nb.loc["NET", y] for y in ("Y1", "Y2", "Y3")]
|
||||
assert case["npv"] == pytest.approx(npv(net, 0.08))
|
||||
|
||||
|
||||
def test_three_scenarios_distinct():
|
||||
npvs = {s: _case(s)["npv"] for s in ("floor", "realistic", "stretch")}
|
||||
assert len({round(v) for v in npvs.values()}) == 3
|
||||
assert npvs["floor"] < npvs["realistic"] < npvs["stretch"]
|
||||
|
||||
|
||||
def test_implementation_amortization():
|
||||
base = _case()
|
||||
with_impl = _case(implementation_cost=900_000)
|
||||
nb, nb2 = (
|
||||
c["net_by_year"].set_index("line") for c in (base, with_impl)
|
||||
)
|
||||
for y in ("Y1", "Y2", "Y3"):
|
||||
assert nb2.loc["TOTAL COSTS", y] == pytest.approx(
|
||||
nb.loc["TOTAL COSTS", y] + 300_000
|
||||
)
|
||||
|
||||
|
||||
def test_excel_export_readable(tmp_path):
|
||||
case = _case()
|
||||
path = export_excel(
|
||||
{
|
||||
"Business Case": case["net_by_year"],
|
||||
"Costs": case["cost_by_year"],
|
||||
"Benefits": case["benefit_by_year"],
|
||||
},
|
||||
tmp_path / "ctm.xlsx",
|
||||
)
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(path)
|
||||
assert set(wb.sheetnames) == {"Business Case", "Costs", "Benefits"}
|
||||
|
||||
|
||||
def test_scenario_json_roundtrip(tmp_path):
|
||||
p = tmp_path / "state.json"
|
||||
scenario_state_to_json(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_TAKEOUTS, CTM_DEFAULT_FEATURE_SCOPES, p
|
||||
)
|
||||
sites, takeouts, scopes, _rollout = scenario_state_from_json(p)
|
||||
assert [s.site_name for s in sites] == [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
assert takeouts[0].annual_cost == CTM_DEFAULT_TAKEOUTS[0].annual_cost
|
||||
assert scopes[0].adoption_curve == CTM_DEFAULT_FEATURE_SCOPES[0].adoption_curve
|
||||
188
studies/202607_CTM_GenesysCX/tests/test_cost_model.py
Normal file
188
studies/202607_CTM_GenesysCX/tests/test_cost_model.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Cost engine — including the spec's acceptance numbers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.cost_model import (
|
||||
calculate_consumption_ai_cost,
|
||||
calculate_per_user_ai_cost,
|
||||
calculate_platform_license_cost,
|
||||
calculate_total_cost,
|
||||
)
|
||||
from tokencalc.defaults import (
|
||||
CONTRACTED_NAMED_USERS,
|
||||
CTM_DEFAULT_FEATURE_SCOPES,
|
||||
CTM_DEFAULT_SITES,
|
||||
DEFAULT_METERS,
|
||||
DEFAULT_PRICING,
|
||||
)
|
||||
from tokencalc.inputs import FeatureScope, SiteInput
|
||||
from tokencalc.scenarios import get_scenario
|
||||
|
||||
ALL_SITES = [s.site_name for s in CTM_DEFAULT_SITES]
|
||||
|
||||
|
||||
def _scope(feature, sites=None, **kw):
|
||||
return FeatureScope(feature, sites or ALL_SITES, **kw)
|
||||
|
||||
|
||||
def test_default_sites_match_contracted_users():
|
||||
assert sum(s.named_users for s in CTM_DEFAULT_SITES) == CONTRACTED_NAMED_USERS
|
||||
|
||||
|
||||
def test_sta_acceptance_number():
|
||||
"""2,088 users × 30 tokens × 12 months × $1 = $751,680."""
|
||||
df = calculate_per_user_ai_cost(
|
||||
CTM_DEFAULT_SITES, _scope("Speech & Text Analytics [named]"),
|
||||
DEFAULT_METERS["Speech & Text Analytics [named]"], DEFAULT_PRICING,
|
||||
)
|
||||
assert df["annual_cost"].sum() == pytest.approx(751_680)
|
||||
|
||||
|
||||
def test_agent_copilot_acceptance_number():
|
||||
"""2,088 users × 40 tokens × 12 months × $1 = $1,002,240."""
|
||||
df = calculate_per_user_ai_cost(
|
||||
CTM_DEFAULT_SITES, _scope("Agent Copilot [named]"),
|
||||
DEFAULT_METERS["Agent Copilot [named]"], DEFAULT_PRICING,
|
||||
)
|
||||
assert df["annual_cost"].sum() == pytest.approx(1_002_240)
|
||||
|
||||
|
||||
def test_ai_translate_not_active_before_phase():
|
||||
"""AI Translate (consumption meter) produces zero cost before its phase."""
|
||||
scenario = get_scenario("realistic")
|
||||
apac_sites = [s.site_name for s in CTM_DEFAULT_SITES if s.region_pricing == "APAC"]
|
||||
df = calculate_consumption_ai_cost(
|
||||
CTM_DEFAULT_SITES,
|
||||
_scope("AI Translate", apac_sites, phase=3),
|
||||
DEFAULT_METERS["AI Translate"], scenario, DEFAULT_PRICING, year=2,
|
||||
)
|
||||
assert df["annual_cost"].sum() == 0
|
||||
|
||||
|
||||
def test_copilot_covers_supervisor_summary():
|
||||
"""Rule 1: AI Summary cost is zero at Copilot sites."""
|
||||
scenario = get_scenario("realistic")
|
||||
total = calculate_total_cost(
|
||||
CTM_DEFAULT_SITES,
|
||||
[
|
||||
_scope("Agent Copilot [named]"),
|
||||
_scope("AI Summary & Insights"),
|
||||
],
|
||||
DEFAULT_METERS, DEFAULT_PRICING, scenario, year=1,
|
||||
include_platform=False,
|
||||
)
|
||||
summary_row = total[total["cost_line"] == "AI Summary & Insights"].iloc[0]
|
||||
assert summary_row["annual_cost"] == 0
|
||||
# Without Copilot the same line costs real money.
|
||||
total2 = calculate_total_cost(
|
||||
CTM_DEFAULT_SITES,
|
||||
[_scope("AI Summary & Insights")],
|
||||
DEFAULT_METERS, DEFAULT_PRICING, scenario, year=1,
|
||||
include_platform=False,
|
||||
)
|
||||
assert total2[total2["cost_line"] == "AI Summary & Insights"].iloc[0][
|
||||
"annual_cost"
|
||||
] > 0
|
||||
|
||||
|
||||
def test_consumption_tokens_rounded_up_monthly():
|
||||
"""Rule 2: ceil on monthly site token totals."""
|
||||
site = SiteInput(
|
||||
"Tiny", "US", agents=5, supervisors=0,
|
||||
voice_volume_monthly=100, email_volume_monthly=0,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=60,
|
||||
fully_loaded_agent_cost_annual=65_000,
|
||||
fully_loaded_supervisor_cost_annual=95_000,
|
||||
)
|
||||
# realistic: 100 calls × 35% × 1.5 min = 52.5 min × (1/17) = 3.088
|
||||
# tokens × 70% Y1 ramp applied to units → 36.75 min → 2.16 tokens → ceil 3
|
||||
df = calculate_consumption_ai_cost(
|
||||
[site], FeatureScope("Voice Bot", ["Tiny"]),
|
||||
DEFAULT_METERS["Voice Bot"], "realistic", DEFAULT_PRICING, year=1,
|
||||
)
|
||||
assert df.iloc[0]["tokens_monthly"] == 3
|
||||
assert df.iloc[0]["annual_cost"] == pytest.approx(3 * 12 * 1.0)
|
||||
|
||||
|
||||
def test_predictive_routing_consumption():
|
||||
"""1,700 calls/mo ÷ 17 per token = 100 tokens/mo → $1,200/yr (year 2, no ramp)."""
|
||||
site = SiteInput(
|
||||
"Tiny", "US", agents=5, supervisors=0,
|
||||
voice_volume_monthly=1_700, email_volume_monthly=0,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=60,
|
||||
fully_loaded_agent_cost_annual=65_000,
|
||||
fully_loaded_supervisor_cost_annual=95_000,
|
||||
)
|
||||
df = calculate_consumption_ai_cost(
|
||||
[site], FeatureScope("Predictive Routing", ["Tiny"]),
|
||||
DEFAULT_METERS["Predictive Routing"], "realistic", DEFAULT_PRICING, year=2,
|
||||
)
|
||||
assert df.iloc[0]["tokens_monthly"] == 100
|
||||
assert df.iloc[0]["annual_cost"] == pytest.approx(1_200)
|
||||
|
||||
|
||||
def test_predictive_routing_eligibility_and_total_cost():
|
||||
"""eligibility_pct halves the routed volume; total_cost handles the scope."""
|
||||
site = SiteInput(
|
||||
"Tiny", "US", agents=5, supervisors=0,
|
||||
voice_volume_monthly=1_700, email_volume_monthly=0,
|
||||
chat_volume_monthly=0, sms_volume_monthly=0,
|
||||
voice_aht_seconds=300, email_aht_seconds=600,
|
||||
chat_aht_seconds=480, voice_acw_seconds=60,
|
||||
fully_loaded_agent_cost_annual=65_000,
|
||||
fully_loaded_supervisor_cost_annual=95_000,
|
||||
)
|
||||
scope = FeatureScope("Predictive Routing", ["Tiny"], eligibility_pct=0.5)
|
||||
df = calculate_consumption_ai_cost(
|
||||
[site], scope, DEFAULT_METERS["Predictive Routing"], "realistic",
|
||||
DEFAULT_PRICING, year=2,
|
||||
)
|
||||
assert df.iloc[0]["tokens_monthly"] == 50
|
||||
total = calculate_total_cost(
|
||||
[site], [scope], DEFAULT_METERS, DEFAULT_PRICING, "realistic", 2,
|
||||
include_platform=False,
|
||||
)
|
||||
pr_row = total[total["cost_line"] == "Predictive Routing"].iloc[0]
|
||||
assert pr_row["annual_cost"] == pytest.approx(50 * 12 * 1.0)
|
||||
|
||||
|
||||
def test_regional_pricing_not_hardcoded():
|
||||
pricing = dict(DEFAULT_PRICING)
|
||||
from tokencalc.meters import TokenPricing
|
||||
|
||||
pricing["APAC"] = TokenPricing(region="APAC", list_rate_per_token=2.0)
|
||||
apac_site = next(s for s in CTM_DEFAULT_SITES if s.region_pricing == "APAC")
|
||||
df = calculate_per_user_ai_cost(
|
||||
[apac_site], _scope("Speech & Text Analytics [named]", [apac_site.site_name]),
|
||||
DEFAULT_METERS["Speech & Text Analytics [named]"], pricing,
|
||||
)
|
||||
expected = apac_site.named_users * 30 * 12 * 2.0
|
||||
assert df["annual_cost"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_year1_consumption_ramp_default_70pct():
|
||||
sc = get_scenario("realistic")
|
||||
assert sc.cost_realization(1) == pytest.approx(0.70)
|
||||
assert sc.cost_realization(2) == 1.0
|
||||
|
||||
|
||||
def test_platform_license_cost():
|
||||
df = calculate_platform_license_cost(CTM_DEFAULT_SITES)
|
||||
expected = CONTRACTED_NAMED_USERS * 111.28 * 12
|
||||
assert df["annual_cost"].sum() == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_total_cost_default_scopes_runs_all_years():
|
||||
for year in (1, 2, 3):
|
||||
df = calculate_total_cost(
|
||||
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES,
|
||||
DEFAULT_METERS, DEFAULT_PRICING, "realistic", year,
|
||||
)
|
||||
assert (df["annual_cost"] >= 0).all()
|
||||
assert {"cost_line", "scope", "annual_cost", "confidence"} <= set(df.columns)
|
||||
92
studies/202607_CTM_GenesysCX/tests/test_meters.py
Normal file
92
studies/202607_CTM_GenesysCX/tests/test_meters.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Meter catalogue integrity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc.defaults import DEFAULT_METERS, DEFAULT_PRICING
|
||||
from tokencalc.meters import Confidence, MeterType, TokenMeter, TokenPricing
|
||||
|
||||
|
||||
def test_all_spec_meters_present():
|
||||
expected = {
|
||||
# Voice / Bot
|
||||
"Voice Bot", "Digital Bot",
|
||||
# Virtual Agent
|
||||
"Virtual Agent (legacy)", "Agentic Virtual Agent",
|
||||
# Agent Copilot (named + concurrent)
|
||||
"Agent Copilot [named]", "Agent Copilot [concurrent]",
|
||||
# AI Quality / Analytics
|
||||
"AI Scoring", "AI Summary & Insights",
|
||||
# Speech & Text Analytics (named + concurrent)
|
||||
"Speech & Text Analytics [named]", "Speech & Text Analytics [concurrent]",
|
||||
# Routing
|
||||
"Predictive Routing",
|
||||
# Messaging
|
||||
"Direct Messaging", "Social Listening", "Social Responses",
|
||||
# Language
|
||||
"AI Translate",
|
||||
# Genesys Cloud Copilot
|
||||
"Genesys Cloud Copilot",
|
||||
# Email AI (rate TBD; Auto-Suggest is inside Agent Copilot)
|
||||
"Email AI (Auto-Respond)",
|
||||
}
|
||||
assert expected == set(DEFAULT_METERS)
|
||||
|
||||
|
||||
def test_confirmed_rates():
|
||||
m = DEFAULT_METERS
|
||||
assert m["Voice Bot"].units_per_token == 17
|
||||
assert m["Voice Bot"].tokens_per_unit == pytest.approx(0.0588, abs=1e-3)
|
||||
assert m["Digital Bot"].units_per_token == 51
|
||||
assert m["Agentic Virtual Agent"].tokens_per_unit == 1.2
|
||||
assert m["AI Summary & Insights"].tokens_per_unit == 0.02
|
||||
assert m["Direct Messaging"].units_per_token == 400
|
||||
# Named variants
|
||||
assert m["Speech & Text Analytics [named]"].tokens_per_unit == 30
|
||||
assert m["Speech & Text Analytics [concurrent]"].tokens_per_unit == 45
|
||||
assert m["Agent Copilot [named]"].tokens_per_unit == 40
|
||||
assert m["Agent Copilot [concurrent]"].tokens_per_unit == 60
|
||||
# AI Translate is now a confirmed consumption meter
|
||||
assert m["AI Translate"].tokens_per_unit == 0.5
|
||||
assert m["AI Translate"].units_per_token == 2
|
||||
assert m["AI Translate"].confidence is Confidence.CONFIRMED
|
||||
# New meters
|
||||
assert m["AI Scoring"].units_per_token == 20
|
||||
assert m["Predictive Routing"].units_per_token == 17
|
||||
assert m["Genesys Cloud Copilot"].units_per_token == 20
|
||||
|
||||
|
||||
def test_unknown_meters_flagged():
|
||||
unknown = {f for f, m in DEFAULT_METERS.items() if m.confidence is Confidence.UNKNOWN}
|
||||
assert unknown == {"Email AI (Auto-Respond)"}
|
||||
assert Confidence.UNKNOWN.icon == "🔴"
|
||||
assert Confidence.CONFIRMED.icon == "🟢"
|
||||
|
||||
|
||||
def test_inverse_consistency_validated():
|
||||
with pytest.raises(ValueError, match="not inverses"):
|
||||
TokenMeter(
|
||||
feature="Bad", meter_type=MeterType.PER_MINUTE,
|
||||
units_per_token=10, tokens_per_unit=0.5,
|
||||
confidence=Confidence.ESTIMATED, notes="",
|
||||
)
|
||||
|
||||
|
||||
def test_every_confirmed_meter_has_source_url():
|
||||
for m in DEFAULT_METERS.values():
|
||||
if m.confidence is Confidence.CONFIRMED:
|
||||
assert m.source_url, f"{m.feature} missing source URL"
|
||||
|
||||
|
||||
def test_pricing_effective_rate():
|
||||
p = TokenPricing(region="US", list_rate_per_token=1.0,
|
||||
contracted_rate_per_token=0.85)
|
||||
assert p.effective_rate(use_contracted=False) == 1.0
|
||||
assert p.effective_rate(use_contracted=True) == 0.85
|
||||
# no contracted rate → falls back to list
|
||||
assert DEFAULT_PRICING["US"].effective_rate(use_contracted=True) == 1.0
|
||||
|
||||
|
||||
def test_all_regions_priced():
|
||||
assert set(DEFAULT_PRICING) == {"US", "EU", "AU", "APAC"}
|
||||
92
studies/202607_CTM_GenesysCX/tests/test_migration_wfm.py
Normal file
92
studies/202607_CTM_GenesysCX/tests/test_migration_wfm.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Migration + WFM (no-AI) scenario — hand-check acceptance numbers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tokencalc import appendix4 as a4
|
||||
from tokencalc import migration_wfm as mw
|
||||
from tokencalc.defaults import CTM_DEFAULT_SITES
|
||||
|
||||
SITES = list(CTM_DEFAULT_SITES)
|
||||
|
||||
|
||||
def _default_benefit_rollout():
|
||||
_, _, benefit_rollout = a4.build_rollouts(SITES)
|
||||
return benefit_rollout
|
||||
|
||||
|
||||
def test_wfm_scope_and_verbatim_total():
|
||||
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
|
||||
assert set(ben["capability"]) == {"WFM"}
|
||||
assert set(ben["region"]) == set(mw.DEFAULT_WFM_REGIONS)
|
||||
assert "EMEA" not in set(ben["region"]), "EMEA WFM is out of scope"
|
||||
# NA $0 (migration) + ANZ $1.4M + ASIA $914K — verbatim, exact.
|
||||
assert ben["benefit"].sum() == pytest.approx(2_314_000)
|
||||
|
||||
|
||||
def test_wfm_phasing_on_deck_schedule():
|
||||
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
|
||||
by_year = ben.groupby("year")["benefit"].sum()
|
||||
assert by_year[2026] == 0.0
|
||||
# ANZ realizes Dec 2027 (1 of 13 live months lands in 2027).
|
||||
assert by_year[2027] == pytest.approx(1_400_000 / 13)
|
||||
assert by_year[2028] == pytest.approx(2_314_000 - 1_400_000 / 13)
|
||||
|
||||
|
||||
def test_runrate_saving_annual():
|
||||
# (7.3M − 4.3M) licence + 1.3M ANZ + 1.6M ASIA + 0 NA = 5.9M.
|
||||
assert mw.wfm_annual_runrate() == pytest.approx(2_900_000)
|
||||
assert mw.runrate_saving_annual() == pytest.approx(5_900_000)
|
||||
assert mw.runrate_saving_annual(regions=["NA"]) == pytest.approx(3_000_000)
|
||||
assert mw.runrate_saving_annual(licence_annual=4_800_000,
|
||||
regions=[]) == pytest.approx(2_500_000)
|
||||
# Contracted frame: managed services stay in the run-rate forever.
|
||||
assert mw.runrate_saving_annual(
|
||||
a4.tco("ccaas_annual"), managed_annual=a4.MANAGED_SERVICES_ANNUAL
|
||||
) == pytest.approx(6_589_081.60)
|
||||
|
||||
|
||||
def _default_wfm_benefits_by_year():
|
||||
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
|
||||
return {y: float(ben.loc[ben.year == y, "benefit"].sum()) for y in a4.YEARS}
|
||||
|
||||
|
||||
def test_breakeven_extrapolates_past_window():
|
||||
# Deck frame: deck licence rate (6-month ramp), verbatim PS lump,
|
||||
# no managed services.
|
||||
cs = a4.current_state_inputs(SITES)
|
||||
cur = a4.current_costs_by_year(cs)
|
||||
lic = a4.licence_costs_by_year()
|
||||
ps = a4.ps_costs_by_year()
|
||||
total = {y: cur[y] + lic[y] + ps[y] for y in a4.YEARS}
|
||||
inc, net = a4.case_flows(total, _default_wfm_benefits_by_year())
|
||||
assert sum(net.values()) == pytest.approx(-3_703_000, abs=1_000)
|
||||
label = mw.runrate_breakeven_label(net, mw.runrate_saving_annual())
|
||||
assert label == "44 months (~Aug 2029, extrapolated)"
|
||||
|
||||
|
||||
def test_contracted_frame_with_sow_and_managed_services():
|
||||
# Contracted frame: signed licence rate, SOW PS milestones, managed
|
||||
# services from MCX go-live — the case the notebook leads with.
|
||||
cs = a4.current_state_inputs(SITES)
|
||||
cur = a4.current_costs_by_year(cs)
|
||||
lic = a4.licence_costs_by_year(annual=a4.tco("ccaas_annual"))
|
||||
ps = a4.ps_costs_by_year(contracted=True)
|
||||
man = a4.managed_services_by_year()
|
||||
total = {y: cur[y] + lic[y] + ps[y] + man[y] for y in a4.YEARS}
|
||||
inc, net = a4.case_flows(total, _default_wfm_benefits_by_year())
|
||||
assert sum(net.values()) == pytest.approx(-1_503_013, abs=1_000)
|
||||
runrate = mw.runrate_saving_annual(
|
||||
a4.tco("ccaas_annual"), managed_annual=a4.MANAGED_SERVICES_ANNUAL)
|
||||
label = mw.runrate_breakeven_label(net, runrate)
|
||||
assert label == "39 months (~Mar 2029, extrapolated)"
|
||||
|
||||
|
||||
def test_breakeven_defers_in_window_and_guards_zero_runrate():
|
||||
positive = {2026: 1_000_000.0, 2027: 0.0, 2028: 0.0}
|
||||
assert mw.runrate_breakeven_label(positive, 5_900_000) == \
|
||||
a4.payback_label(positive)
|
||||
negative = {2026: -1_000_000.0, 2027: 0.0, 2028: 0.0}
|
||||
assert mw.runrate_breakeven_label(negative, 0.0) == \
|
||||
"never at current run-rate"
|
||||
15
studies/202607_CTM_GenesysCX/tests/test_staging.py
Normal file
15
studies/202607_CTM_GenesysCX/tests/test_staging.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
|
||||
|
||||
from tokencalc import staging
|
||||
|
||||
|
||||
def test_backstage_prints_only_off_stage(monkeypatch, capsys):
|
||||
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
|
||||
assert not staging.on_stage()
|
||||
staging.backstage("visible")
|
||||
assert capsys.readouterr().out == "visible\n"
|
||||
|
||||
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
|
||||
assert staging.on_stage()
|
||||
staging.backstage("hidden")
|
||||
assert capsys.readouterr().out == ""
|
||||
Reference in New Issue
Block a user