Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions includes/Models/AgentCapacity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
<?php

namespace Escalated\Models;

use Escalated\Escalated;

/**
* Per-agent, per-channel concurrent-ticket capacity.
*
* Tracks how many open tickets an agent is carrying (current_count) against
* their configured ceiling (max_concurrent) so routing can avoid overloading.
* Mirrors the Laravel AgentCapacity model.
*/
class AgentCapacity
{
/**
* Default concurrent-ticket ceiling for a freshly created row.
*/
const DEFAULT_MAX_CONCURRENT = 10;

/**
* Get the table name.
*
* @return string
*/
public static function table()
{
return Escalated::table('agent_capacity');
}

// ---------------------------------------------------------------------
// Pure helpers (no database)
// ---------------------------------------------------------------------

/**
* Whether an agent at the given load has headroom for another ticket.
*
* @param int $current_count
* @param int $max_concurrent
* @return bool
*/
public static function has_capacity($current_count, $max_concurrent)
{
return (int) $current_count < (int) $max_concurrent;
}

/**
* Current load as a percentage of the ceiling. A zero (or negative)
* ceiling is treated as fully loaded.
*
* @param int $current_count
* @param int $max_concurrent
* @return float
*/
public static function load_percentage($current_count, $max_concurrent)
{
if ((int) $max_concurrent <= 0) {
return 100.0;
}

return round(((int) $current_count / (int) $max_concurrent) * 100, 1);
}

// ---------------------------------------------------------------------
// Database access
// ---------------------------------------------------------------------

/**
* Find the capacity row for a user on a channel.
*
* @param int|string $user_id
* @param string $channel
* @return object|null
*/
public static function for_user($user_id, $channel = 'default')
{
global $wpdb;
$table = static::table();

return $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE user_id = %d AND channel = %s",
$user_id,
$channel
)
);
}

/**
* Find the capacity row for a user/channel, creating it with defaults
* (ceiling 10, count 0) if it does not yet exist.
*
* @param int|string $user_id
* @param string $channel
* @return object|null
*/
public static function find_or_create($user_id, $channel = 'default')
{
$existing = static::for_user($user_id, $channel);
if ($existing) {
return $existing;
}

static::create([
'user_id' => $user_id,
'channel' => $channel,
'max_concurrent' => self::DEFAULT_MAX_CONCURRENT,
'current_count' => 0,
]);

return static::for_user($user_id, $channel);
}

/**
* Create a new capacity row.
*
* @return int|false Inserted ID or false on failure.
*/
public static function create(array $data)
{
global $wpdb;
$table = static::table();
$now = current_time('mysql');

$data['created_at'] = $now;
$data['updated_at'] = $now;

$result = $wpdb->insert($table, $data);

return $result !== false ? $wpdb->insert_id : false;
}

/**
* Update a capacity row.
*
* @param int $id
* @return bool
*/
public static function update($id, array $data)
{
global $wpdb;
$table = static::table();

$data['updated_at'] = current_time('mysql');

return $wpdb->update($table, $data, ['id' => $id]) !== false;
}

/**
* Increment the running load for a user/channel.
*
* @param int|string $user_id
* @param string $channel
* @return void
*/
public static function increment($user_id, $channel = 'default')
{
$row = static::find_or_create($user_id, $channel);
if ($row) {
static::update($row->id, ['current_count' => (int) $row->current_count + 1]);
}
}

/**
* Decrement the running load for a user/channel (never below zero).
*
* @param int|string $user_id
* @param string $channel
* @return void
*/
public static function decrement($user_id, $channel = 'default')
{
$row = static::find_or_create($user_id, $channel);
if ($row && (int) $row->current_count > 0) {
static::update($row->id, ['current_count' => (int) $row->current_count - 1]);
}
}

/**
* Get all capacity rows, ordered by agent then channel.
*
* @return array
*/
public static function all()
{
global $wpdb;
$table = static::table();

return $wpdb->get_results(
"SELECT * FROM {$table} ORDER BY user_id ASC, channel ASC"
) ?: [];
}
}
66 changes: 66 additions & 0 deletions includes/Services/CapacityService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

namespace Escalated\Services;

use Escalated\Models\AgentCapacity;

/**
* Tracks per-agent, per-channel concurrent-ticket load so routing can avoid
* overloading agents. Mirrors the Laravel CapacityService: a capacity row is
* created on demand (default ceiling 10, count 0) and the running count is
* incremented on assignment / decremented on release.
*/
class CapacityService
{
/**
* Whether the agent can accept another ticket on the given channel.
*
* @param int|string $user_id
* @param string $channel
* @return bool
*/
public function can_accept_ticket($user_id, $channel = 'default')
{
$row = AgentCapacity::find_or_create($user_id, $channel);

if (! $row) {
return false;
}

return AgentCapacity::has_capacity($row->current_count, $row->max_concurrent);
}

/**
* Increment the agent's running load.
*
* @param int|string $user_id
* @param string $channel
* @return void
*/
public function increment_load($user_id, $channel = 'default')
{
AgentCapacity::increment($user_id, $channel);
}

/**
* Decrement the agent's running load (never below zero).
*
* @param int|string $user_id
* @param string $channel
* @return void
*/
public function decrement_load($user_id, $channel = 'default')
{
AgentCapacity::decrement($user_id, $channel);
}

/**
* All capacity rows for the admin view.
*
* @return array
*/
public function all_capacities()
{
return AgentCapacity::all();
}
}
14 changes: 14 additions & 0 deletions includes/class-activator.php
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,20 @@ private static function create_tables(): void
KEY subject_lookup (subject_type, subject_id)
) $charset_collate;";
dbDelta($sql);

// 31. escalated_agent_capacity
$sql = "CREATE TABLE {$prefix}agent_capacity (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
channel VARCHAR(64) NOT NULL DEFAULT 'default',
max_concurrent INT UNSIGNED NOT NULL DEFAULT 10,
current_count INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
PRIMARY KEY (id),
UNIQUE KEY user_channel (user_id, channel)
) $charset_collate;";
dbDelta($sql);
}

/**
Expand Down
81 changes: 81 additions & 0 deletions tests/Test_Capacity_Service.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

/**
* Tests for agent capacity tracking.
*
* Pure-function tests for has_capacity / load_percentage; the
* can_accept_ticket / increment / decrement flow is exercised via a live
* wpdb (the test suite's mysql harness).
*/

use Escalated\Models\AgentCapacity;
use Escalated\Services\CapacityService;

class Test_Capacity_Service extends WP_UnitTestCase
{
// ---------------------------------------------------------------------
// has_capacity (pure)
// ---------------------------------------------------------------------

public function test_has_capacity_when_below_ceiling()
{
$this->assertTrue(AgentCapacity::has_capacity(2, 3));
}

public function test_no_capacity_when_at_or_over_ceiling()
{
$this->assertFalse(AgentCapacity::has_capacity(3, 3));
$this->assertFalse(AgentCapacity::has_capacity(4, 3));
}

// ---------------------------------------------------------------------
// load_percentage (pure)
// ---------------------------------------------------------------------

public function test_load_percentage()
{
$this->assertEquals(30.0, AgentCapacity::load_percentage(3, 10));
$this->assertEquals(25.0, AgentCapacity::load_percentage(2, 8));
}

public function test_load_percentage_zero_ceiling_is_full()
{
$this->assertEquals(100.0, AgentCapacity::load_percentage(0, 0));
}

// ---------------------------------------------------------------------
// Service flow (live wpdb)
// ---------------------------------------------------------------------

public function test_can_accept_increment_and_decrement()
{
$service = new CapacityService;
$user_id = 4242;

// Fresh agent (default ceiling 10) has capacity.
$this->assertTrue($service->can_accept_ticket($user_id));

// Fill to the ceiling.
for ($i = 0; $i < 10; $i++) {
$service->increment_load($user_id);
}
$this->assertFalse($service->can_accept_ticket($user_id));

// Releasing one frees capacity again.
$service->decrement_load($user_id);
$this->assertTrue($service->can_accept_ticket($user_id));
}

public function test_decrement_never_goes_negative()
{
$service = new CapacityService;
$user_id = 4343;

// Decrementing a fresh (zero) row keeps it at zero.
$service->decrement_load($user_id);

$row = AgentCapacity::for_user($user_id);
$this->assertNotNull($row);
$this->assertEquals(0, (int) $row->current_count);
}
}