feat(helpdesk): add dashboards, communication feed, settings UI and fix routing
Add support/sales/controlling dashboards with KPIs, trend charts and risk indicators. Add debitor communication timeline, contact filters, system recommendations engine, and configurable controlling risk rules. Rename search→index to fix query-string preservation on back navigation. Remove fake escalation rate metric, add KPI info tooltips, and switch trend chart colors to red (created) / green (closed) for clarity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BcODataGatewayTest extends TestCase
|
||||
{
|
||||
private function createGatewayWithSettings(bool $isConfigured = true): BcODataGateway
|
||||
private function createGatewayWithSettings(bool $isConfigured = true, ?SessionStoreInterface $sessionStore = null): BcODataGateway
|
||||
{
|
||||
$settings = $this->createMock(HelpdeskSettingsGateway::class);
|
||||
$settings->method('isConfigured')->willReturn($isConfigured);
|
||||
@@ -24,10 +24,12 @@ class BcODataGatewayTest extends TestCase
|
||||
$settings->method('getBasicPassword')->willReturn('testpass');
|
||||
|
||||
$oauthTokenService = $this->createMock(HelpdeskOAuthTokenService::class);
|
||||
$sessionStore = $this->createMock(SessionStoreInterface::class);
|
||||
$sessionStore->method('all')->willReturn([
|
||||
'current_tenant' => ['id' => 1],
|
||||
]);
|
||||
if ($sessionStore === null) {
|
||||
$sessionStore = $this->createMock(SessionStoreInterface::class);
|
||||
$sessionStore->method('all')->willReturn([
|
||||
'current_tenant' => ['id' => 1],
|
||||
]);
|
||||
}
|
||||
|
||||
return new BcODataGateway($settings, $oauthTokenService, $sessionStore);
|
||||
}
|
||||
@@ -74,6 +76,20 @@ class BcODataGatewayTest extends TestCase
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetContractsForCustomerReturnsEmptyForEmptyNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getContractsForCustomer('');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetEscalationTicketsForCustomerReturnsEmptyForEmptyNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getEscalationTicketsForCustomer('');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testSearchCustomersThrowsWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
@@ -95,6 +111,48 @@ class BcODataGatewayTest extends TestCase
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetContractsForCustomerReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getContractsForCustomer('10610');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetEscalationDefinitionsReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getEscalationDefinitions();
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetEscalationDefinitionsReturnsCachedValuesWhenAvailable(): void
|
||||
{
|
||||
$sessionStore = $this->createMock(SessionStoreInterface::class);
|
||||
$sessionStore->method('all')->willReturn([
|
||||
'current_tenant' => ['id' => 7],
|
||||
]);
|
||||
$sessionStore->method('get')->with('module.helpdesk.debitor.v1.escalation-definitions.7')->willReturn([
|
||||
'values' => [
|
||||
['Code' => 'MAX', 'Target_Time' => 'P1DT0H0M0.0S'],
|
||||
],
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
$sessionStore->expects($this->never())->method('set');
|
||||
|
||||
$gateway = $this->createGatewayWithSettings(true, $sessionStore);
|
||||
$results = $gateway->getEscalationDefinitions();
|
||||
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertSame('MAX', $results[0]['Code']);
|
||||
}
|
||||
|
||||
public function testGetEscalationTicketsForCustomerReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getEscalationTicketsForCustomer('10610');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testTestConnectionReturnsNotOkWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
@@ -193,4 +251,39 @@ class BcODataGatewayTest extends TestCase
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$gateway->listCustomers('', 'Berlin)', 10, 0, 'Name', 'asc');
|
||||
}
|
||||
|
||||
public function testGetContractLinesForCustomerReturnsEmptyForEmptyNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getContractLinesForCustomer('');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetContractLinesForCustomerReturnsEmptyForWhitespace(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getContractLinesForCustomer(' ');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetTicketsForControllingReturnsEmptyForBlankCustomerNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getTicketsForControlling('');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetTicketsForControllingReturnsEmptyForWhitespace(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getTicketsForControlling(' ');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetTicketsForControllingReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getTicketsForControlling('10610');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DebitorCacheControlTest extends TestCase
|
||||
{
|
||||
public function testParseRefreshFlagRecognizesTruthyValues(): void
|
||||
{
|
||||
$this->assertTrue(DebitorCacheControl::parseRefreshFlag('1'));
|
||||
$this->assertTrue(DebitorCacheControl::parseRefreshFlag('true'));
|
||||
$this->assertTrue(DebitorCacheControl::parseRefreshFlag('YES'));
|
||||
$this->assertTrue(DebitorCacheControl::parseRefreshFlag(true));
|
||||
}
|
||||
|
||||
public function testParseRefreshFlagReturnsFalseForNonTruthyValues(): void
|
||||
{
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag(''));
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag('0'));
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag('false'));
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag('nope'));
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag(false));
|
||||
$this->assertFalse(DebitorCacheControl::parseRefreshFlag(null));
|
||||
}
|
||||
|
||||
public function testResolveTenantScopeUsesTenantIdOrGlobal(): void
|
||||
{
|
||||
$this->assertSame('9', DebitorCacheControl::resolveTenantScope([
|
||||
'current_tenant' => ['id' => 9],
|
||||
]));
|
||||
|
||||
$this->assertSame('global', DebitorCacheControl::resolveTenantScope([]));
|
||||
}
|
||||
|
||||
public function testInvalidateDebitorCachesRemovesDebitorAndTenantSharedKeys(): void
|
||||
{
|
||||
$sessionStore = $this->createMock(SessionStoreInterface::class);
|
||||
$sessionStore->expects($this->exactly(13))
|
||||
->method('remove')
|
||||
->with($this->callback(static function (string $key): bool {
|
||||
return str_contains($key, '.13.10259')
|
||||
|| $key === 'module.helpdesk.debitor.v1.escalation-definitions.13';
|
||||
}));
|
||||
|
||||
DebitorCacheControl::invalidateDebitorCaches($sessionStore, '13', '10259', true);
|
||||
}
|
||||
|
||||
public function testContractLinesKeyFormat(): void
|
||||
{
|
||||
$key = DebitorCacheControl::contractLinesKey('5', '10254');
|
||||
$this->assertSame('module.helpdesk.debitor.v2.contract-lines.5.10254', $key);
|
||||
}
|
||||
|
||||
public function testAllDebitorCacheKeysIncludesContractLinesKey(): void
|
||||
{
|
||||
$keys = DebitorCacheControl::allDebitorCacheKeys('5', '10254');
|
||||
$this->assertContains('module.helpdesk.debitor.v2.contract-lines.5.10254', $keys);
|
||||
}
|
||||
|
||||
public function testControllingTicketsKeyFormat(): void
|
||||
{
|
||||
$key = DebitorCacheControl::controllingTicketsKey('7', '10610');
|
||||
$this->assertSame('module.helpdesk.debitor.v2.controlling-tickets.7.10610', $key);
|
||||
}
|
||||
|
||||
public function testAllDebitorCacheKeysIncludesControllingKey(): void
|
||||
{
|
||||
$keys = DebitorCacheControl::allDebitorCacheKeys('7', '10610');
|
||||
$this->assertContains('module.helpdesk.debitor.v2.controlling-tickets.7.10610', $keys);
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,86 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertSame('Timeout', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadContracts() ---
|
||||
|
||||
public function testLoadContractsReturnsFalseForEmptyCustomerNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadContracts('');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadContractsReturnsFalseWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadContracts('10610');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadContractsReturnsNormalizedEntriesAndSummary(): void
|
||||
{
|
||||
$contracts = [
|
||||
[
|
||||
'No' => 'V0002',
|
||||
'Periodic_Invoicing_Type' => 'INTRANET',
|
||||
'Description' => 'Intranet',
|
||||
'State' => 'Aktiv',
|
||||
'Next_Invoicing_Date' => '2026-06-01',
|
||||
'Sell_to_Cust_No' => '10610',
|
||||
'Sell_to_Cust_Name' => 'Aero-Dienst GmbH',
|
||||
'Bill_to_Cust_No' => '10610',
|
||||
'Bill_to_Cust_Name' => 'Aero-Dienst GmbH',
|
||||
'TotalPayoffAmount' => 1200,
|
||||
'No_of_Active_Position' => 12,
|
||||
'Support_Hours' => 0.5,
|
||||
],
|
||||
[
|
||||
'No' => 'V0001',
|
||||
'Periodic_Invoicing_Type' => 'WEBHOSTING',
|
||||
'Description' => 'Hosting',
|
||||
'State' => 'Vorbereitung',
|
||||
'Next_Invoicing_Date' => '2026-05-01',
|
||||
'Sell_to_Cust_No' => '10610',
|
||||
'Sell_to_Cust_Name' => 'Aero-Dienst GmbH',
|
||||
'Bill_to_Cust_No' => '10610',
|
||||
'Bill_to_Cust_Name' => 'Aero-Dienst GmbH',
|
||||
'TotalPayoffAmount' => 600,
|
||||
'No_of_Active_Position' => 2,
|
||||
'Support_Hours' => 0.2,
|
||||
],
|
||||
[
|
||||
'No' => 'V9999',
|
||||
'Bill_to_Cust_No' => '99999',
|
||||
'Sell_to_Cust_No' => '99999',
|
||||
],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContractsForCustomer')->willReturn($contracts);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContracts('10610');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertCount(2, $result['entries']);
|
||||
$this->assertSame('V0002', $result['entries'][0]['contract_no']);
|
||||
$this->assertSame(2, $result['summary']['total_contracts']);
|
||||
$this->assertSame(1, $result['summary']['active_contracts']);
|
||||
$this->assertSame('INTRANET', $result['summary']['product_types'][0]['type']);
|
||||
}
|
||||
|
||||
public function testLoadContractsReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContractsForCustomer')->willThrowException(new \RuntimeException('Contracts endpoint failed'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContracts('10610');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('Contracts endpoint failed', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadTicketSummary() ---
|
||||
|
||||
public function testLoadTicketSummaryReturnsFalseForEmptyCustomerNo(): void
|
||||
@@ -342,6 +422,146 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertSame('Timeout', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadSupportDashboard() ---
|
||||
|
||||
public function testLoadSupportDashboardReturnsErrorWhenTicketLoadFails(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadSupportDashboard('10001', 'Test GmbH');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('Timeout', $result['error']);
|
||||
}
|
||||
|
||||
public function testBuildSupportDashboardCalculatesHealthAndActions(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
$staleDate = $now->modify('-72 hours')->format('Y-m-d\\TH:i:00\\Z');
|
||||
$freshDate = $now->modify('-6 hours')->format('Y-m-d\\TH:i:00\\Z');
|
||||
$midDate = $now->modify('-30 hours')->format('Y-m-d\\TH:i:00\\Z');
|
||||
|
||||
$tickets = [
|
||||
['No' => 'T001', 'Ticket_State' => 'Offen', 'Support_User_Name' => 'NKS', 'Last_Activity_Date' => $staleDate],
|
||||
['No' => 'T002', 'Ticket_State' => 'In Bearbeitung', 'Support_User_Name' => '', 'Last_Activity_Date' => $freshDate],
|
||||
['No' => 'T003', 'Ticket_State' => 'Open', 'Support_User_Name' => 'ABC', 'Last_Activity_Date' => $midDate],
|
||||
['No' => 'T004', 'Ticket_State' => 'Closed', 'Support_User_Name' => 'ABC', 'Last_Activity_Date' => $staleDate],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSupportDashboard($tickets, 48);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(4, $result['health']['total_tickets']);
|
||||
$this->assertSame(3, $result['health']['open_tickets']);
|
||||
$this->assertSame(1, $result['health']['critical_tickets']);
|
||||
$this->assertIsInt($result['health']['oldest_open_age_hours']);
|
||||
$this->assertGreaterThanOrEqual(71, $result['health']['oldest_open_age_hours']);
|
||||
$this->assertNull($result['health']['escalation_count']);
|
||||
$this->assertFalse($result['health']['escalation_available']);
|
||||
|
||||
$this->assertSame(48, $result['meta']['critical_threshold_hours']);
|
||||
$this->assertSame(4, $result['meta']['tickets_considered']);
|
||||
|
||||
$this->assertCount(3, $result['actions']);
|
||||
$actionTypes = array_map(static fn (array $action): string => (string) $action['type'], $result['actions']);
|
||||
$this->assertSame(
|
||||
['critical_stale', 'unassigned_support', 'oldest_open_followup'],
|
||||
$actionTypes
|
||||
);
|
||||
}
|
||||
|
||||
public function testBuildSupportDashboardReturnsNoActionsWhenNoOpenTickets(): void
|
||||
{
|
||||
$tickets = [
|
||||
['No' => 'T001', 'Ticket_State' => 'Closed', 'Last_Activity_Date' => '2026-03-10T08:00:00Z'],
|
||||
['No' => 'T002', 'Ticket_State' => 'Erledigt', 'Last_Activity_Date' => '2026-03-10T08:00:00Z'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSupportDashboard($tickets, 48);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(0, $result['health']['open_tickets']);
|
||||
$this->assertSame(0, $result['health']['critical_tickets']);
|
||||
$this->assertNull($result['health']['oldest_open_age_hours']);
|
||||
$this->assertSame([], $result['actions']);
|
||||
}
|
||||
|
||||
// --- loadEscalationHealth() ---
|
||||
|
||||
public function testLoadEscalationHealthReturnsFalseForEmptyCustomerNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadEscalationHealth('', []);
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadEscalationHealthReturnsFalseWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadEscalationHealth('10610', []);
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadEscalationHealthCalculatesOverdueEscalations(): void
|
||||
{
|
||||
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
$tooOld = $now->modify('-30 hours')->format('Y-m-d\\TH:i:00\\Z');
|
||||
$fresh = $now->modify('-2 hours')->format('Y-m-d\\TH:i:00\\Z');
|
||||
|
||||
$tickets = [
|
||||
['No' => 'T001', 'Ticket_State' => 'Offen', 'Last_Activity_Date' => $tooOld],
|
||||
['No' => 'T002', 'Ticket_State' => 'Open', 'Last_Activity_Date' => $fresh],
|
||||
['No' => 'T003', 'Ticket_State' => 'Closed', 'Last_Activity_Date' => $tooOld],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getEscalationDefinitions')->willReturn([
|
||||
['Code' => 'NORMAL', 'Target_Time' => 'P1DT0H0M0.0S'],
|
||||
['Code' => 'MAX', 'Target_Time' => 'P1DT0H0M0.0S'],
|
||||
]);
|
||||
$gateway->method('getEscalationTicketsForCustomer')->willReturn([
|
||||
['No' => 'T001', 'Escalation_Code' => 'NORMAL'],
|
||||
['No' => 'T002', 'Escalation_Code' => 'MAX'],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadEscalationHealth('10610', $tickets);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(2, $result['tickets_considered']);
|
||||
$this->assertSame(2, $result['tickets_matched']);
|
||||
$this->assertSame(1, $result['overdue_count']);
|
||||
$this->assertSame(2, $result['definitions_count']);
|
||||
$this->assertCount(1, $result['entries']);
|
||||
$this->assertSame('T001', $result['entries'][0]['ticket_no']);
|
||||
$this->assertSame('NORMAL', $result['entries'][0]['escalation_code']);
|
||||
$this->assertGreaterThan(0, $result['entries'][0]['overdue_seconds']);
|
||||
}
|
||||
|
||||
public function testBuildSupportDashboardUsesProvidedEscalationHealth(): void
|
||||
{
|
||||
$tickets = [
|
||||
['No' => 'T001', 'Ticket_State' => 'Offen', 'Last_Activity_Date' => '2026-03-10T08:00:00Z'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSupportDashboard($tickets, 48, [
|
||||
'ok' => true,
|
||||
'overdue_count' => 4,
|
||||
'tickets_considered' => 6,
|
||||
'tickets_matched' => 5,
|
||||
'definitions_count' => 15,
|
||||
]);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(4, $result['health']['escalation_count']);
|
||||
$this->assertTrue($result['health']['escalation_available']);
|
||||
$this->assertSame(6, $result['meta']['escalation_tickets_considered']);
|
||||
$this->assertSame(5, $result['meta']['escalation_tickets_matched']);
|
||||
$this->assertSame(15, $result['meta']['escalation_definitions_count']);
|
||||
}
|
||||
|
||||
// --- loadTicketLog() ---
|
||||
|
||||
public function testLoadTicketLogReturnsFalseForEmptyTicketNo(): void
|
||||
@@ -386,4 +606,450 @@ class DebitorDetailServiceTest extends TestCase
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('API error', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadSalesDashboard() ---
|
||||
|
||||
public function testLoadSalesDashboardReturnsErrorForEmptyCustomerNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadSalesDashboard('', 'Firma');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadSalesDashboardReturnsErrorWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadSalesDashboard('10254', 'Firma');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadSalesDashboardReturnsKpisAndContracts(): void
|
||||
{
|
||||
$contracts = [
|
||||
[
|
||||
'No' => 'V100',
|
||||
'State' => 'Aktiv',
|
||||
'Periodic_Invoicing_Type' => 'WA',
|
||||
'Description' => 'Wartungsvertrag',
|
||||
'Bill_to_Cust_No' => '10254',
|
||||
'Sell_to_Cust_No' => '10254',
|
||||
'MontlyAmount_InvoicingDate' => 500.0,
|
||||
'Support_Hours' => 10,
|
||||
'Next_Invoicing_Date' => '2026-05-01',
|
||||
'Starting_Date' => '2024-01-01',
|
||||
'No_of_Active_Position' => 3,
|
||||
],
|
||||
[
|
||||
'No' => 'V101',
|
||||
'State' => 'Aktiv',
|
||||
'Periodic_Invoicing_Type' => 'FB',
|
||||
'Description' => 'Hosting',
|
||||
'Bill_to_Cust_No' => '10254',
|
||||
'Sell_to_Cust_No' => '10254',
|
||||
'MontlyAmount_InvoicingDate' => 250.0,
|
||||
'Support_Hours' => 5,
|
||||
'Next_Invoicing_Date' => '2026-04-15',
|
||||
'Starting_Date' => '2023-06-01',
|
||||
'No_of_Active_Position' => 2,
|
||||
],
|
||||
];
|
||||
$lines = [
|
||||
[
|
||||
'Header_No' => 'V100',
|
||||
'Line_No' => 10000,
|
||||
'Description' => 'Server-Wartung',
|
||||
'Type' => 'Artikel',
|
||||
'Quantity' => 1,
|
||||
'Unit_Price' => 500,
|
||||
'Line_Amount' => 500,
|
||||
'Line_State' => 'Aktiv',
|
||||
],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContractsForCustomer')->willReturn($contracts);
|
||||
$gateway->method('getContractLinesForCustomer')->willReturn($lines);
|
||||
$gateway->method('getContactsForCustomer')->willReturn([]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadSalesDashboard('10254', 'Musterfirma');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(2, $result['kpis']['active_contracts']);
|
||||
$this->assertSame(750.0, $result['kpis']['monthly_volume']);
|
||||
$this->assertSame(15.0, $result['kpis']['support_hours']);
|
||||
$this->assertSame('2026-04-15', $result['kpis']['next_invoicing_date']);
|
||||
$this->assertCount(2, $result['contracts']);
|
||||
// Find contract V100 which has lines
|
||||
$v100 = null;
|
||||
foreach ($result['contracts'] as $c) {
|
||||
if ($c['contract_no'] === 'V100') {
|
||||
$v100 = $c;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($v100);
|
||||
$this->assertCount(1, $v100['lines']);
|
||||
}
|
||||
|
||||
// --- buildSalesDashboard() ---
|
||||
|
||||
public function testBuildSalesDashboardKpisOnlyCountActiveContracts(): void
|
||||
{
|
||||
$contracts = [
|
||||
['No' => 'V1', 'State' => 'Aktiv', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'MontlyAmount_InvoicingDate' => 100, 'Support_Hours' => 5, 'Next_Invoicing_Date' => '2026-05-01'],
|
||||
['No' => 'V2', 'State' => 'Beendet', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'MontlyAmount_InvoicingDate' => 200, 'Support_Hours' => 10, 'Next_Invoicing_Date' => '2025-01-01'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSalesDashboard($contracts, [], '10');
|
||||
|
||||
$this->assertSame(1, $result['kpis']['active_contracts']);
|
||||
$this->assertSame(100.0, $result['kpis']['monthly_volume']);
|
||||
$this->assertSame(5.0, $result['kpis']['support_hours']);
|
||||
}
|
||||
|
||||
public function testBuildSalesDashboardFiltersOldEndedContracts(): void
|
||||
{
|
||||
$contracts = [
|
||||
['No' => 'V1', 'State' => 'Beendet', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'Next_Invoicing_Date' => '2020-01-01', 'Starting_Date' => '2019-01-01'],
|
||||
['No' => 'V2', 'State' => 'Beendet', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'Next_Invoicing_Date' => '2025-06-01', 'Starting_Date' => '2024-01-01'],
|
||||
['No' => 'V3', 'State' => 'Aktiv', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'Next_Invoicing_Date' => '2026-05-01'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSalesDashboard($contracts, [], '10');
|
||||
|
||||
$contractNos = array_column($result['contracts'], 'contract_no');
|
||||
$this->assertNotContains('V1', $contractNos);
|
||||
$this->assertContains('V2', $contractNos);
|
||||
$this->assertContains('V3', $contractNos);
|
||||
}
|
||||
|
||||
public function testBuildSalesDashboardGroupsLinesByContract(): void
|
||||
{
|
||||
$contracts = [
|
||||
['No' => 'V1', 'State' => 'Aktiv', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10'],
|
||||
['No' => 'V2', 'State' => 'Aktiv', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10'],
|
||||
];
|
||||
$lines = [
|
||||
['Header_No' => 'V1', 'Line_No' => 10000, 'Description' => 'Line A', 'Type' => 'Artikel', 'Quantity' => 1, 'Unit_Price' => 10, 'Line_Amount' => 10, 'Line_State' => 'Aktiv'],
|
||||
['Header_No' => 'V1', 'Line_No' => 20000, 'Description' => 'Line B', 'Type' => 'Wartung', 'Quantity' => 2, 'Unit_Price' => 20, 'Line_Amount' => 40, 'Line_State' => 'Aktiv'],
|
||||
['Header_No' => 'V2', 'Line_No' => 10000, 'Description' => 'Line C', 'Type' => 'Artikel', 'Quantity' => 1, 'Unit_Price' => 50, 'Line_Amount' => 50, 'Line_State' => 'Aktiv'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSalesDashboard($contracts, $lines, '10');
|
||||
|
||||
$this->assertCount(2, $result['contracts']);
|
||||
$v1 = $result['contracts'][0]['contract_no'] === 'V1' ? $result['contracts'][0] : $result['contracts'][1];
|
||||
$v2 = $result['contracts'][0]['contract_no'] === 'V2' ? $result['contracts'][0] : $result['contracts'][1];
|
||||
$this->assertCount(2, $v1['lines']);
|
||||
$this->assertCount(1, $v2['lines']);
|
||||
}
|
||||
|
||||
public function testBuildSalesDashboardSortsActiveFirst(): void
|
||||
{
|
||||
$contracts = [
|
||||
['No' => 'V1', 'State' => 'Beendet', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'Next_Invoicing_Date' => '2025-06-01'],
|
||||
['No' => 'V2', 'State' => 'Aktiv', 'Bill_to_Cust_No' => '10', 'Sell_to_Cust_No' => '10', 'Next_Invoicing_Date' => '2026-05-01'],
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildSalesDashboard($contracts, [], '10');
|
||||
|
||||
$this->assertSame('V2', $result['contracts'][0]['contract_no']);
|
||||
$this->assertSame('V1', $result['contracts'][1]['contract_no']);
|
||||
}
|
||||
|
||||
// --- buildControllingDashboard() ---
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function closedTicket(string $no, int $createdHoursAgo, int $resolutionHours): array
|
||||
{
|
||||
$now = time();
|
||||
$createdTs = $now - ($createdHoursAgo * 3600);
|
||||
$activityTs = $createdTs + ($resolutionHours * 3600);
|
||||
|
||||
return [
|
||||
'No' => $no,
|
||||
'Customer_No' => '10610',
|
||||
'Ticket_State' => 'Erledigt',
|
||||
'Process_Stage' => 40,
|
||||
'Created_On' => gmdate('Y-m-d\\TH:i:s\\Z', $createdTs),
|
||||
'Last_Activity_Date' => gmdate('Y-m-d\\TH:i:s\\Z', $activityTs),
|
||||
'Escalation_Code' => '',
|
||||
'Support_User_Name' => 'NKS',
|
||||
'Category_1_Code' => 'BUG',
|
||||
'Process_Code' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function openTicket(string $no, int $createdHoursAgo, string $supportUser = 'NKS', string $escalationCode = ''): array
|
||||
{
|
||||
$now = time();
|
||||
$createdTs = $now - ($createdHoursAgo * 3600);
|
||||
|
||||
return [
|
||||
'No' => $no,
|
||||
'Customer_No' => '10610',
|
||||
'Ticket_State' => 'Offen',
|
||||
'Process_Stage' => 10,
|
||||
'Created_On' => gmdate('Y-m-d\\TH:i:s\\Z', $createdTs),
|
||||
'Last_Activity_Date' => gmdate('Y-m-d\\TH:i:s\\Z', $createdTs),
|
||||
'Escalation_Code' => $escalationCode,
|
||||
'Support_User_Name' => $supportUser,
|
||||
'Category_1_Code' => 'SUPPORT',
|
||||
'Process_Code' => '',
|
||||
];
|
||||
}
|
||||
|
||||
private static function defaultRiskConfig(): array
|
||||
{
|
||||
return [
|
||||
'version' => 1,
|
||||
'rules' => [
|
||||
'ticket_volume_increase' => ['enabled' => true, 'threshold_percent' => 30],
|
||||
'avg_resolution_high' => ['enabled' => true, 'threshold_hours' => 72],
|
||||
'open_aging' => ['enabled' => true, 'threshold_hours' => 168],
|
||||
'unassigned_ratio' => ['enabled' => true, 'threshold_percent' => 20],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// --- KPI 1: Volume ---
|
||||
|
||||
public function testBuildControllingDashboardReturnsEmptyStateForNoTickets(): void
|
||||
{
|
||||
$result = DebitorDetailService::buildControllingDashboard([], 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(0, $result['kpis']['volume']);
|
||||
$this->assertSame(0, $result['kpis']['volume_prev']);
|
||||
$this->assertSame(0, $result['kpis']['volume_delta']);
|
||||
$this->assertNull($result['kpis']['close_rate']);
|
||||
$this->assertNull($result['kpis']['resolution_hours']);
|
||||
$this->assertSame(0, $result['kpis']['backlog_open']);
|
||||
$this->assertSame(90, $result['meta']['period_days']);
|
||||
$this->assertSame(0, $result['meta']['created_in_period']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardVolumeCounts(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 24),
|
||||
self::openTicket('T002', 48),
|
||||
self::closedTicket('T003', 72, 12),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(3, $result['kpis']['volume']);
|
||||
$this->assertSame(0, $result['kpis']['volume_prev']);
|
||||
$this->assertSame(3, $result['kpis']['volume_delta']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardVolumeDeltaWithPrevPeriod(): void
|
||||
{
|
||||
$tickets = [
|
||||
// 3 in current period (within 90 days)
|
||||
self::openTicket('T001', 24),
|
||||
self::openTicket('T002', 48),
|
||||
self::closedTicket('T003', 72, 12),
|
||||
// 5 in previous period (90-180 days ago)
|
||||
self::closedTicket('TP1', 24 * 95, 10),
|
||||
self::closedTicket('TP2', 24 * 100, 10),
|
||||
self::closedTicket('TP3', 24 * 110, 10),
|
||||
self::closedTicket('TP4', 24 * 120, 10),
|
||||
self::closedTicket('TP5', 24 * 130, 10),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(3, $result['kpis']['volume']);
|
||||
$this->assertSame(5, $result['kpis']['volume_prev']);
|
||||
$this->assertSame(-2, $result['kpis']['volume_delta']);
|
||||
}
|
||||
|
||||
// --- KPI 2: Close-Rate ---
|
||||
|
||||
public function testBuildControllingDashboardCloseRate(): void
|
||||
{
|
||||
// 5 created, 3 closed in period → 3/5 = 0.6
|
||||
$tickets = [
|
||||
self::openTicket('T001', 24),
|
||||
self::openTicket('T002', 48),
|
||||
self::closedTicket('T003', 72, 12),
|
||||
self::closedTicket('T004', 48, 6),
|
||||
self::closedTicket('T005', 24, 4),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(0.6, $result['kpis']['close_rate']);
|
||||
$this->assertSame(5, $result['meta']['created_in_period']);
|
||||
$this->assertSame(3, $result['meta']['closed_in_period']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardCloseRateNullWhenNoCreated(): void
|
||||
{
|
||||
$result = DebitorDetailService::buildControllingDashboard([], 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertNull($result['kpis']['close_rate']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardCloseRateCountsOldTicketsClosedInPeriod(): void
|
||||
{
|
||||
// Ticket created 200h ago (in period), closed 10h after creation (still in period)
|
||||
// Plus a ticket created 400h ago (before 90d) that was closed 10h ago (in period)
|
||||
$tickets = [
|
||||
self::closedTicket('T001', 200, 10), // created in period, closed in period
|
||||
self::closedTicket('T002', 2200, 10), // created before period, closed ~2190h ago — NOT in period
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
// Only T001 created in period
|
||||
$this->assertSame(1, $result['kpis']['volume']);
|
||||
// T001 closed in period, T002 closed long ago — only T001
|
||||
$this->assertSame(1, $result['meta']['closed_in_period']);
|
||||
$this->assertSame(1.0, $result['kpis']['close_rate']);
|
||||
}
|
||||
|
||||
// --- KPI 3: Resolution Time ---
|
||||
|
||||
public function testBuildControllingDashboardResolutionMedianForClosedInPeriod(): void
|
||||
{
|
||||
// 3 tickets closed in period with resolution times: 10h, 50h, 30h
|
||||
// All created in period too — sorted: 10, 30, 50 → median: 30
|
||||
$tickets = [
|
||||
self::closedTicket('T001', 48, 10),
|
||||
self::closedTicket('T002', 48, 50),
|
||||
self::closedTicket('T003', 48, 30),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(30.0, $result['kpis']['resolution_hours']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardResolutionNullWhenNoClosed(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 24),
|
||||
self::openTicket('T002', 48),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertNull($result['kpis']['resolution_hours']);
|
||||
}
|
||||
|
||||
public function testBuildControllingDashboardResolutionPrevPeriod(): void
|
||||
{
|
||||
// Current period: one ticket, 20h resolution
|
||||
$tickets = [
|
||||
self::closedTicket('T001', 48, 20),
|
||||
];
|
||||
// Previous period: one ticket, 80h resolution
|
||||
$tickets[] = self::closedTicket('TP1', 24 * 100, 80);
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(20.0, $result['kpis']['resolution_hours']);
|
||||
$this->assertSame(80.0, $result['kpis']['resolution_hours_prev']);
|
||||
}
|
||||
|
||||
// --- KPI 4: Open Backlog ---
|
||||
|
||||
public function testBuildControllingDashboardBacklog(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 24, 'NKS'), // assigned, fresh
|
||||
self::openTicket('T002', 200, ''), // unassigned, >48h → critical
|
||||
self::openTicket('T003', 60, 'ABC'), // assigned, >48h → critical
|
||||
self::closedTicket('T004', 48, 10), // closed, not in backlog
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(3, $result['kpis']['backlog_open']);
|
||||
$this->assertSame(1, $result['kpis']['backlog_unassigned']);
|
||||
$this->assertSame(2, $result['kpis']['backlog_critical']);
|
||||
}
|
||||
|
||||
// --- Risk ---
|
||||
|
||||
public function testBuildControllingDashboardEvaluatesRiskRules(): void
|
||||
{
|
||||
$tickets = [];
|
||||
|
||||
// 8 tickets in current period
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$tickets[] = self::closedTicket('TC' . $i, 24 * $i, 10);
|
||||
}
|
||||
$tickets[] = self::openTicket('TO1', 200, 'NKS', 'MAX');
|
||||
$tickets[] = self::openTicket('TO2', 24, '', 'MAX');
|
||||
$tickets[] = self::openTicket('TO3', 48, 'ABC', 'HOCH');
|
||||
|
||||
// 2 tickets in previous period
|
||||
$tickets[] = self::closedTicket('TP1', 24 * 100, 10);
|
||||
$tickets[] = self::closedTicket('TP2', 24 * 110, 10);
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertGreaterThanOrEqual(3, $result['risk_summary']['triggered']);
|
||||
$this->assertSame(4, $result['risk_summary']['total']);
|
||||
$this->assertSame('high', $result['risk_summary']['level']);
|
||||
|
||||
$this->assertCount(4, $result['risk_indicators']);
|
||||
foreach ($result['risk_indicators'] as $indicator) {
|
||||
$this->assertArrayHasKey('rule_id', $indicator);
|
||||
$this->assertArrayHasKey('triggered', $indicator);
|
||||
$this->assertArrayHasKey('value', $indicator);
|
||||
$this->assertArrayHasKey('threshold', $indicator);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trend buckets ---
|
||||
|
||||
public function testBuildControllingDashboardBucketsByMonth(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 24),
|
||||
self::openTicket('T002', 24 * 20, 'NKS'),
|
||||
self::openTicket('T003', 24 * 50, 'NKS'),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertGreaterThanOrEqual(3, count($result['trend']));
|
||||
|
||||
foreach ($result['trend'] as $bucket) {
|
||||
$this->assertArrayHasKey('label', $bucket);
|
||||
$this->assertArrayHasKey('created', $bucket);
|
||||
$this->assertArrayHasKey('closed', $bucket);
|
||||
}
|
||||
|
||||
$totalCreated = array_sum(array_column($result['trend'], 'created'));
|
||||
$this->assertSame($result['meta']['created_in_period'], $totalCreated);
|
||||
}
|
||||
|
||||
// --- Contract metrics ---
|
||||
|
||||
// --- Efficiency ---
|
||||
|
||||
public function testBuildControllingDashboardEfficiencyMetrics(): void
|
||||
{
|
||||
$tickets = [
|
||||
self::openTicket('T001', 48, 'NKS'),
|
||||
self::openTicket('T002', 24, ''),
|
||||
self::closedTicket('T003', 72, 20),
|
||||
];
|
||||
|
||||
$result = DebitorDetailService::buildControllingDashboard($tickets, 90, self::defaultRiskConfig());
|
||||
|
||||
$this->assertSame(20.0, $result['efficiency']['resolution_hours']);
|
||||
$this->assertSame(1, $result['efficiency']['unassigned_count']);
|
||||
$this->assertIsInt($result['efficiency']['oldest_open_hours']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,4 +192,190 @@ class HelpdeskSettingsGatewayTest extends TestCase
|
||||
$gateway = $this->createGateway($settings);
|
||||
$this->assertFalse($gateway->setOAuthTokenEndpoint('http://login.microsoftonline.com/token'));
|
||||
}
|
||||
|
||||
public function testGetSystemRecommendationsConfigEnvelopeReturnsDefaultsWhenMissing(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnMap([
|
||||
[HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, null],
|
||||
]);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$envelope = $gateway->getSystemRecommendationsConfigEnvelope();
|
||||
|
||||
$this->assertSame('default', $envelope['source']);
|
||||
$this->assertSame(5, $envelope['config']['max_items']);
|
||||
$this->assertTrue($envelope['config']['rules']['stale_open']['enabled']);
|
||||
}
|
||||
|
||||
public function testGetSystemRecommendationsConfigEnvelopeFallsBackOnInvalidJson(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnMap([
|
||||
[HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, '{invalid'],
|
||||
]);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$envelope = $gateway->getSystemRecommendationsConfigEnvelope();
|
||||
|
||||
$this->assertSame('fallback_invalid_json', $envelope['source']);
|
||||
$this->assertSame(5, $envelope['config']['max_items']);
|
||||
}
|
||||
|
||||
public function testGetSystemRecommendationsConfigNormalizesValues(): void
|
||||
{
|
||||
$rawConfig = json_encode([
|
||||
'version' => 99,
|
||||
'max_items' => 99,
|
||||
'rules' => [
|
||||
'high_risk_aging' => [
|
||||
'enabled' => true,
|
||||
'priority' => '120',
|
||||
'codes' => 'MAX, HOCH,',
|
||||
'min_age_hours' => '12',
|
||||
],
|
||||
'customer_backlog' => [
|
||||
'enabled' => '0',
|
||||
'priority' => 0,
|
||||
'min_open_tickets' => -1,
|
||||
],
|
||||
],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnMap([
|
||||
[HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, $rawConfig],
|
||||
]);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$config = $gateway->getSystemRecommendationsConfig();
|
||||
|
||||
$this->assertSame(20, $config['max_items']);
|
||||
$this->assertSame(['MAX', 'HOCH'], $config['rules']['high_risk_aging']['codes']);
|
||||
$this->assertSame(12, $config['rules']['high_risk_aging']['min_age_hours']);
|
||||
$this->assertFalse($config['rules']['customer_backlog']['enabled']);
|
||||
$this->assertSame(1, $config['rules']['customer_backlog']['priority']);
|
||||
$this->assertSame(1, $config['rules']['customer_backlog']['min_open_tickets']);
|
||||
}
|
||||
|
||||
public function testSetSystemRecommendationsConfigPersistsNormalizedJson(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->once())
|
||||
->method('set')
|
||||
->with(
|
||||
HelpdeskSettingsGateway::KEY_SYSTEM_RECOMMENDATIONS_CONFIG,
|
||||
$this->callback(static function (mixed $value): bool {
|
||||
if (!is_string($value) || $value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
if (!is_array($decoded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($decoded['version'] ?? null) === 1
|
||||
&& ($decoded['max_items'] ?? null) === 1
|
||||
&& (($decoded['rules']['high_risk_aging']['codes'] ?? []) === ['MAX', 'HOCH']);
|
||||
}),
|
||||
'helpdesk.system_recommendations_config'
|
||||
)
|
||||
->willReturn(true);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$result = $gateway->setSystemRecommendationsConfig([
|
||||
'max_items' => -10,
|
||||
'rules' => [
|
||||
'high_risk_aging' => [
|
||||
'codes' => '',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
// --- Controlling Risk Config ---
|
||||
|
||||
public function testGetControllingRiskConfigEnvelopeReturnsDefaultWhenNotSet(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnMap([
|
||||
[HelpdeskSettingsGateway::KEY_CONTROLLING_RISK_CONFIG, null],
|
||||
]);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$envelope = $gateway->getControllingRiskConfigEnvelope();
|
||||
|
||||
$this->assertSame('default', $envelope['source']);
|
||||
$this->assertSame(1, $envelope['config']['version']);
|
||||
$this->assertTrue($envelope['config']['rules']['ticket_volume_increase']['enabled']);
|
||||
$this->assertSame(30, $envelope['config']['rules']['ticket_volume_increase']['threshold_percent']);
|
||||
$this->assertSame(72, $envelope['config']['rules']['avg_resolution_high']['threshold_hours']);
|
||||
}
|
||||
|
||||
public function testSetControllingRiskConfigNormalizesValues(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->once())
|
||||
->method('set')
|
||||
->with(
|
||||
HelpdeskSettingsGateway::KEY_CONTROLLING_RISK_CONFIG,
|
||||
$this->callback(static function (mixed $value): bool {
|
||||
if (!is_string($value) || $value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
if (!is_array($decoded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($decoded['version'] ?? null) === 1
|
||||
&& ($decoded['rules']['ticket_volume_increase']['threshold_percent'] ?? null) === 50;
|
||||
}),
|
||||
'helpdesk.controlling_risk_config'
|
||||
)
|
||||
->willReturn(true);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$result = $gateway->setControllingRiskConfig([
|
||||
'rules' => [
|
||||
'ticket_volume_increase' => ['threshold_percent' => 50],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function testNormalizeControllingRiskConfigEnforcesMinMax(): void
|
||||
{
|
||||
$rawConfig = json_encode([
|
||||
'version' => 1,
|
||||
'rules' => [
|
||||
'ticket_volume_increase' => ['enabled' => true, 'threshold_percent' => 999],
|
||||
'avg_resolution_high' => ['enabled' => true, 'threshold_hours' => 0],
|
||||
'open_aging' => ['enabled' => true, 'threshold_hours' => 5000],
|
||||
'unassigned_ratio' => ['enabled' => true, 'threshold_percent' => 101],
|
||||
],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnMap([
|
||||
[HelpdeskSettingsGateway::KEY_CONTROLLING_RISK_CONFIG, $rawConfig],
|
||||
]);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$config = $gateway->getControllingRiskConfig();
|
||||
|
||||
// ticket_volume_increase: 999 > 200 → clamped to max 200
|
||||
$this->assertSame(200, $config['rules']['ticket_volume_increase']['threshold_percent']);
|
||||
// avg_resolution_high: 0 < 1 → clamped to min 1
|
||||
$this->assertSame(1, $config['rules']['avg_resolution_high']['threshold_hours']);
|
||||
// open_aging: 5000 > 2160 → clamped to max 2160
|
||||
$this->assertSame(2160, $config['rules']['open_aging']['threshold_hours']);
|
||||
// unassigned_ratio: 101 > 100 → clamped to max 100
|
||||
$this->assertSame(100, $config['rules']['unassigned_ratio']['threshold_percent']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SystemRecommendationEngineTest extends TestCase
|
||||
{
|
||||
public function testBuildRecommendationsAppliesRulesSortsAndLimits(): void
|
||||
{
|
||||
$engine = new SystemRecommendationEngine();
|
||||
$config = [
|
||||
'max_items' => 5,
|
||||
'rules' => [
|
||||
'escalation_overdue' => ['enabled' => true, 'priority' => 100, 'min_overdue_minutes' => 0],
|
||||
'high_risk_aging' => ['enabled' => true, 'priority' => 90, 'codes' => ['MAX', 'HOCH'], 'min_age_hours' => 8],
|
||||
'unassigned_open' => ['enabled' => true, 'priority' => 80, 'min_age_hours' => 2],
|
||||
'stale_open' => ['enabled' => true, 'priority' => 70, 'min_age_hours' => 48],
|
||||
'customer_backlog' => ['enabled' => true, 'priority' => 60, 'min_open_tickets' => 3],
|
||||
],
|
||||
];
|
||||
|
||||
$tickets = [
|
||||
$this->openTicket('T001', 80, 'USR1'),
|
||||
$this->openTicket('T002', 10, 'USR2'),
|
||||
$this->openTicket('T003', 6, ''),
|
||||
$this->openTicket('T004', 60, 'USR3'),
|
||||
$this->openTicket('T005', 5, 'USR4'),
|
||||
['No' => 'T999', 'Ticket_State' => 'Closed', 'Last_Activity_Date' => $this->isoHoursAgo(120)],
|
||||
];
|
||||
|
||||
$escalationHealth = [
|
||||
'ok' => true,
|
||||
'entries' => [
|
||||
['ticket_no' => 'T001', 'escalation_code' => 'MAX', 'overdue_seconds' => 72000],
|
||||
['ticket_no' => 'T004', 'escalation_code' => 'NORMAL', 'overdue_seconds' => 600],
|
||||
['ticket_no' => 'T001', 'escalation_code' => 'MAX', 'overdue_seconds' => 72000], // duplicate
|
||||
],
|
||||
'matches' => [
|
||||
['ticket_no' => 'T001', 'escalation_code' => 'MAX', 'age_hours' => 80, 'is_overdue' => true],
|
||||
['ticket_no' => 'T002', 'escalation_code' => 'HOCH', 'age_hours' => 10, 'is_overdue' => false],
|
||||
['ticket_no' => 'T004', 'escalation_code' => 'NORMAL', 'age_hours' => 60, 'is_overdue' => true],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $engine->buildRecommendations($tickets, $escalationHealth, $config);
|
||||
|
||||
$this->assertCount(5, $result['recommendations']);
|
||||
$this->assertSame('escalation_overdue', $result['recommendations'][0]['rule_id']);
|
||||
$this->assertSame('T001', $result['recommendations'][0]['ticket_no']);
|
||||
$this->assertSame('escalation_overdue', $result['recommendations'][1]['rule_id']);
|
||||
$this->assertSame('high_risk_aging', $result['recommendations'][2]['rule_id']);
|
||||
$this->assertSame('helpdesk.recommendation.high_risk_aging', $result['recommendations'][2]['message_key']);
|
||||
$this->assertSame(
|
||||
['escalation_overdue', 'high_risk_aging', 'unassigned_open', 'stale_open', 'customer_backlog'],
|
||||
$result['meta']['applied_rules']
|
||||
);
|
||||
$this->assertTrue($result['meta']['escalation_data_available']);
|
||||
}
|
||||
|
||||
public function testBuildRecommendationsSkipsEscalationRulesWhenUnavailable(): void
|
||||
{
|
||||
$engine = new SystemRecommendationEngine();
|
||||
$config = [
|
||||
'max_items' => 10,
|
||||
'rules' => [
|
||||
'escalation_overdue' => ['enabled' => true, 'priority' => 100, 'min_overdue_minutes' => 0],
|
||||
'high_risk_aging' => ['enabled' => true, 'priority' => 90, 'codes' => ['MAX', 'HOCH'], 'min_age_hours' => 8],
|
||||
'unassigned_open' => ['enabled' => true, 'priority' => 80, 'min_age_hours' => 2],
|
||||
'stale_open' => ['enabled' => true, 'priority' => 70, 'min_age_hours' => 48],
|
||||
'customer_backlog' => ['enabled' => true, 'priority' => 60, 'min_open_tickets' => 1],
|
||||
],
|
||||
];
|
||||
|
||||
$tickets = [
|
||||
$this->openTicket('T010', 72, ''),
|
||||
];
|
||||
|
||||
$result = $engine->buildRecommendations($tickets, null, $config);
|
||||
$ruleIds = array_map(static fn (array $item): string => (string) $item['rule_id'], $result['recommendations']);
|
||||
|
||||
$this->assertFalse($result['meta']['escalation_data_available']);
|
||||
$this->assertNotContains('escalation_overdue', $ruleIds);
|
||||
$this->assertNotContains('high_risk_aging', $ruleIds);
|
||||
$this->assertContains('unassigned_open', $ruleIds);
|
||||
$this->assertContains('stale_open', $ruleIds);
|
||||
$this->assertContains('customer_backlog', $ruleIds);
|
||||
}
|
||||
|
||||
public function testBuildRecommendationsCreatesBacklogRecommendationWithOldestTicket(): void
|
||||
{
|
||||
$engine = new SystemRecommendationEngine();
|
||||
$config = [
|
||||
'max_items' => 5,
|
||||
'rules' => [
|
||||
'escalation_overdue' => ['enabled' => false, 'priority' => 100, 'min_overdue_minutes' => 0],
|
||||
'high_risk_aging' => ['enabled' => false, 'priority' => 90, 'codes' => ['MAX', 'HOCH'], 'min_age_hours' => 8],
|
||||
'unassigned_open' => ['enabled' => false, 'priority' => 80, 'min_age_hours' => 2],
|
||||
'stale_open' => ['enabled' => false, 'priority' => 70, 'min_age_hours' => 48],
|
||||
'customer_backlog' => ['enabled' => true, 'priority' => 60, 'min_open_tickets' => 3],
|
||||
],
|
||||
];
|
||||
|
||||
$tickets = [
|
||||
$this->openTicket('T100', 12, 'A'),
|
||||
$this->openTicket('T101', 44, 'B'),
|
||||
$this->openTicket('T102', 9, 'C'),
|
||||
];
|
||||
|
||||
$result = $engine->buildRecommendations($tickets, null, $config);
|
||||
|
||||
$this->assertCount(1, $result['recommendations']);
|
||||
$this->assertSame('customer_backlog', $result['recommendations'][0]['rule_id']);
|
||||
$this->assertSame('T101', $result['recommendations'][0]['ticket_no']);
|
||||
$this->assertSame(3, $result['recommendations'][0]['message_params']['open_tickets']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function openTicket(string $ticketNo, int $ageHours, string $supportUserName): array
|
||||
{
|
||||
return [
|
||||
'No' => $ticketNo,
|
||||
'Ticket_State' => 'Open',
|
||||
'Support_User_Name' => $supportUserName,
|
||||
'Last_Activity_Date' => $this->isoHoursAgo($ageHours),
|
||||
];
|
||||
}
|
||||
|
||||
private function isoHoursAgo(int $hours): string
|
||||
{
|
||||
$dt = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
$dt = $dt->modify('-' . max(0, $hours) . ' hours');
|
||||
|
||||
return $dt->format('Y-m-d\\TH:i:00\\Z');
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ class TicketCommunicationServiceTest extends TestCase
|
||||
|
||||
$odataGateway = $this->createMock(BcODataGateway::class);
|
||||
$odataGateway->method('getTicketsForCustomer')->with('10001', 'Test GmbH')->willReturn($tickets);
|
||||
$odataGateway->method('getContactsForCustomer')->with('', 'Test GmbH')->willReturn([]);
|
||||
$odataGateway->method('getTicketLog')->willReturnCallback(static function (string $ticketNo): array {
|
||||
return [[
|
||||
'Entry_No' => 1,
|
||||
@@ -133,6 +134,53 @@ class TicketCommunicationServiceTest extends TestCase
|
||||
$this->assertContains('T12', $ticketNos);
|
||||
}
|
||||
|
||||
public function testLoadDebitorCommunicationFeedResolvesContactActorsOncePerDebitor(): void
|
||||
{
|
||||
$soapGateway = $this->createMock(BcSoapGateway::class);
|
||||
$soapGateway->method('getTicketCommunicationText')->willReturn([
|
||||
'ok' => false,
|
||||
'soap_used' => false,
|
||||
'return_value' => 400,
|
||||
'communication_text' => '',
|
||||
'message_entries' => '',
|
||||
'error' => 'SOAP unavailable',
|
||||
]);
|
||||
|
||||
$odataGateway = $this->createMock(BcODataGateway::class);
|
||||
$odataGateway->method('getTicketsForCustomer')->with('10001', 'Test GmbH')->willReturn([
|
||||
['No' => 'T1', 'Last_Activity_Date' => '2026-04-10T08:00:00Z', 'Created_On' => '2026-04-09T08:00:00Z'],
|
||||
['No' => 'T2', 'Last_Activity_Date' => '2026-04-09T08:00:00Z', 'Created_On' => '2026-04-08T08:00:00Z'],
|
||||
]);
|
||||
$odataGateway->expects($this->once())
|
||||
->method('getContactsForCustomer')
|
||||
->with('', 'Test GmbH')
|
||||
->willReturn([
|
||||
['No' => 'KT002967', 'Name' => 'Felix Schneider'],
|
||||
]);
|
||||
$odataGateway->expects($this->never())
|
||||
->method('getTicket');
|
||||
$odataGateway->method('getTicketLog')->willReturn([
|
||||
[
|
||||
'Entry_No' => 1,
|
||||
'Created_By' => 'KT002967',
|
||||
'Created_By_Type' => 'Contact',
|
||||
'Creation_Date' => '2026-04-03',
|
||||
'Creation_Time' => '07:59:00.000',
|
||||
'Type' => 'Nachricht',
|
||||
'State_Subtype' => '',
|
||||
'Record_ID' => 'Hallo',
|
||||
],
|
||||
]);
|
||||
|
||||
$service = new TicketCommunicationService($soapGateway, $odataGateway);
|
||||
$result = $service->loadDebitorCommunicationFeed('10001', 'Test GmbH', 2, 40, 250);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertNotEmpty($result['entries']);
|
||||
$actors = array_unique(array_map(static fn (array $entry): string => (string) ($entry['actor'] ?? ''), $result['entries']));
|
||||
$this->assertSame(['Felix Schneider'], $actors);
|
||||
}
|
||||
|
||||
public function testLoadTicketCommunicationFeedRespectsEntryLimit(): void
|
||||
{
|
||||
$soapGateway = $this->createMock(BcSoapGateway::class);
|
||||
|
||||
Reference in New Issue
Block a user