Files
breadcrumb-the-shire/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php
fs ea786f5341 feat(helpdesk): add multi-tenant BC connection settings with risk radar refinements
Introduce per-tenant override for helpdesk BC connection config.
New table helpdesk_tenant_settings stores tenant-specific credentials
with encrypted secrets (AES-256-GCM). EffectiveHelpdeskSettingsService
resolves global vs tenant config per request. Settings UI extended with
tenant override tab, toggle, and dual editing mode.

All runtime consumers (OData, SOAP, OAuth) read through the new
resolver. Token cache flushed on any config change. Default behavior
unchanged — tenants use global config until override explicitly enabled.

Also includes risk radar UI refinements: Stripe-style card redesign
with accent borders and score pills, removal of redundant KPI tiles
and search, and filtering of zero-score entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:29:59 +02:00

1249 lines
49 KiB
PHP

<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
use PHPUnit\Framework\TestCase;
class DebitorDetailServiceTest extends TestCase
{
private function createService(?BcODataGateway $gateway = null, bool $isConfigured = true): DebitorDetailService
{
$gateway = $gateway ?? $this->createMock(BcODataGateway::class);
$settings = $this->createMock(EffectiveHelpdeskSettingsService::class);
$settings->method('isConfigured')->willReturn($isConfigured);
return new DebitorDetailService($gateway, $settings);
}
// --- loadDetail() (legacy) ---
public function testLoadDetailReturnsNotFoundForEmptyCustomerNo(): void
{
$service = $this->createService();
$result = $service->loadDetail('');
$this->assertSame('not_found', $result['status']);
}
public function testLoadDetailReturnsNotConfiguredWhenBcNotConfigured(): void
{
$service = $this->createService(null, false);
$result = $service->loadDetail('10254');
$this->assertSame('not_configured', $result['status']);
}
public function testLoadDetailReturnsNotFoundWhenCustomerMissing(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->expects($this->once())->method('getCustomer')->with('99999')->willReturn(null);
$service = $this->createService($gateway);
$result = $service->loadDetail('99999');
$this->assertSame('not_found', $result['status']);
}
public function testLoadDetailReturnsSuccessWithAllData(): void
{
$customer = ['No' => '10254', 'Name' => 'Musterfirma GmbH'];
$contacts = [['No' => 'KT0001', 'Name' => 'Max Mustermann']];
$tickets = [['No' => 'T0001', 'Description' => 'Problem X']];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->expects($this->once())->method('getCustomer')->with('10254')->willReturn($customer);
$gateway->expects($this->once())->method('getContactsForCustomer')->with('10254', 'Musterfirma GmbH')->willReturn($contacts);
$gateway->expects($this->once())->method('getTicketsForCustomer')->with('10254', 'Musterfirma GmbH')->willReturn($tickets);
$service = $this->createService($gateway);
$result = $service->loadDetail('10254');
$this->assertSame('success', $result['status']);
$this->assertSame($customer, $result['customer']);
$this->assertSame($contacts, $result['contacts']);
$this->assertSame($tickets, $result['tickets']);
}
public function testLoadDetailReturnsErrorOnBcConnectionFailure(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getCustomer')->willThrowException(new \RuntimeException('timeout'));
$service = $this->createService($gateway);
$result = $service->loadDetail('10254');
$this->assertSame('error', $result['status']);
}
public function testLoadDetailStillSucceedsWhenContactsFail(): void
{
$customer = ['No' => '10254', 'Name' => 'Musterfirma GmbH'];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->expects($this->once())->method('getCustomer')->with('10254')->willReturn($customer);
$gateway->method('getContactsForCustomer')->willThrowException(new \RuntimeException('contacts fail'));
$gateway->method('getTicketsForCustomer')->willReturn([]);
$service = $this->createService($gateway);
$result = $service->loadDetail('10254');
$this->assertSame('success', $result['status']);
$this->assertSame([], $result['contacts']);
$this->assertSame('contacts fail', $result['contactsError']);
}
public function testLoadDetailStillSucceedsWhenTicketsFail(): void
{
$customer = ['No' => '10254', 'Name' => 'Musterfirma GmbH'];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->expects($this->once())->method('getCustomer')->with('10254')->willReturn($customer);
$gateway->method('getContactsForCustomer')->willReturn([]);
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('tickets fail'));
$service = $this->createService($gateway);
$result = $service->loadDetail('10254');
$this->assertSame('success', $result['status']);
$this->assertSame([], $result['tickets']);
$this->assertSame('tickets fail', $result['ticketsError']);
}
// --- loadCustomer() ---
public function testLoadCustomerReturnsNotFoundForEmptyNo(): void
{
$service = $this->createService();
$result = $service->loadCustomer('');
$this->assertSame('not_found', $result['status']);
}
public function testLoadCustomerReturnsNotConfiguredWhenNotConfigured(): void
{
$service = $this->createService(null, false);
$result = $service->loadCustomer('10001');
$this->assertSame('not_configured', $result['status']);
}
public function testLoadCustomerReturnsNotFoundWhenCustomerNull(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getCustomer')->willReturn(null);
$service = $this->createService($gateway);
$result = $service->loadCustomer('99999');
$this->assertSame('not_found', $result['status']);
}
public function testLoadCustomerReturnsSuccessWithCustomerData(): void
{
$customer = ['No' => '10001', 'Name' => 'Test GmbH', 'Support_Type' => 'Premium'];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getCustomer')->willReturn($customer);
$service = $this->createService($gateway);
$result = $service->loadCustomer('10001');
$this->assertSame('success', $result['status']);
$this->assertSame($customer, $result['customer']);
$this->assertArrayNotHasKey('contacts', $result);
$this->assertArrayNotHasKey('tickets', $result);
}
public function testLoadCustomerReturnsErrorOnException(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getCustomer')->willThrowException(new \RuntimeException('Connection failed'));
$service = $this->createService($gateway);
$result = $service->loadCustomer('10001');
$this->assertSame('error', $result['status']);
$this->assertSame('BC connection failed', $result['error']);
}
// --- loadContacts() ---
public function testLoadContactsReturnsFalseForEmptyCustomerNo(): void
{
$service = $this->createService();
$result = $service->loadContacts('', 'Test GmbH');
$this->assertFalse($result['ok']);
}
public function testLoadContactsReturnsFalseForEmptyCustomerName(): void
{
$service = $this->createService();
$result = $service->loadContacts('10001', '');
$this->assertFalse($result['ok']);
}
public function testLoadContactsReturnsFalseWhenNotConfigured(): void
{
$service = $this->createService(null, false);
$result = $service->loadContacts('10001', 'Test GmbH');
$this->assertFalse($result['ok']);
}
public function testLoadContactsReturnsOkWithContacts(): void
{
$contacts = [['No' => 'KT001', 'Name' => 'Max Mustermann']];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getContactsForCustomerWithMeta')->willReturn([
'contacts' => $contacts,
'meta' => [
'source' => 'primary.company_name',
'fallback_used' => false,
'fallback_reason' => '',
],
]);
$service = $this->createService($gateway);
$result = $service->loadContacts('10001', 'Test GmbH');
$this->assertTrue($result['ok']);
$this->assertSame($contacts, $result['contacts']);
$this->assertSame('primary.company_name', $result['meta']['source']);
$this->assertFalse($result['meta']['fallback_used']);
}
public function testLoadContactsReturnsErrorOnException(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getContactsForCustomerWithMeta')->willThrowException(new \RuntimeException('API error'));
$service = $this->createService($gateway);
$result = $service->loadContacts('10001', 'Test GmbH');
$this->assertFalse($result['ok']);
$this->assertSame('API error', $result['error']);
}
public function testLoadContactsExposesFallbackMeta(): void
{
$contacts = [['No' => 'KT001', 'Name' => 'Fallback Contact']];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getContactsForCustomerWithMeta')->willReturn([
'contacts' => $contacts,
'meta' => [
'source' => 'fallback.integration_customer_no',
'fallback_used' => true,
'fallback_reason' => 'primary_query_failed',
],
]);
$service = $this->createService($gateway);
$result = $service->loadContacts('10001', 'Müller & Söhne GmbH');
$this->assertTrue($result['ok']);
$this->assertSame($contacts, $result['contacts']);
$this->assertSame('fallback.integration_customer_no', $result['meta']['source']);
$this->assertTrue($result['meta']['fallback_used']);
$this->assertSame('primary_query_failed', $result['meta']['fallback_reason']);
}
// --- loadTickets() ---
public function testLoadTicketsReturnsFalseForEmptyCustomerNo(): void
{
$service = $this->createService();
$result = $service->loadTickets('', 'Test GmbH');
$this->assertFalse($result['ok']);
}
public function testLoadTicketsReturnsFalseForEmptyCustomerName(): void
{
$service = $this->createService();
$result = $service->loadTickets('10001', '');
$this->assertFalse($result['ok']);
}
public function testLoadTicketsReturnsFalseWhenNotConfigured(): void
{
$service = $this->createService(null, false);
$result = $service->loadTickets('10001', 'Test GmbH');
$this->assertFalse($result['ok']);
}
public function testLoadTicketsReturnsOkWithTickets(): void
{
$tickets = [['No' => 'T001', 'Description' => 'Test ticket', 'Ticket_State' => 'Open']];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
'tickets' => $tickets,
'meta' => [
'source' => 'primary.company_contact_name',
'fallback_used' => false,
'fallback_reason' => '',
],
]);
$service = $this->createService($gateway);
$result = $service->loadTickets('10001', 'Test GmbH');
$this->assertTrue($result['ok']);
$this->assertSame($tickets, $result['tickets']);
$this->assertSame('primary.company_contact_name', $result['meta']['source']);
$this->assertFalse($result['meta']['fallback_used']);
}
public function testLoadTicketsReturnsErrorOnException(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
$service = $this->createService($gateway);
$result = $service->loadTickets('10001', 'Test GmbH');
$this->assertFalse($result['ok']);
$this->assertSame('Timeout', $result['error']);
}
public function testLoadTicketsExposesFallbackMeta(): void
{
$tickets = [['No' => 'T001', 'Description' => '', 'Ticket_State' => 'Open']];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
'tickets' => $tickets,
'meta' => [
'source' => 'fallback.customer_no_lv',
'fallback_used' => true,
'fallback_reason' => 'primary_empty',
],
]);
$service = $this->createService($gateway);
$result = $service->loadTickets('10001', 'Müller & Söhne GmbH');
$this->assertTrue($result['ok']);
$this->assertSame($tickets, $result['tickets']);
$this->assertSame('fallback.customer_no_lv', $result['meta']['source']);
$this->assertTrue($result['meta']['fallback_used']);
$this->assertSame('primary_empty', $result['meta']['fallback_reason']);
}
// --- 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
{
$service = $this->createService();
$result = $service->loadTicketSummary('', 'Test GmbH');
$this->assertFalse($result['ok']);
}
public function testLoadTicketSummaryReturnsFalseWhenNotConfigured(): void
{
$service = $this->createService(null, false);
$result = $service->loadTicketSummary('10001', 'Test GmbH');
$this->assertFalse($result['ok']);
}
public function testLoadTicketSummaryReturnsExactCounts(): void
{
$tickets = [
['No' => 'T001', 'Ticket_State' => 'Offen', 'Last_Activity_Date' => '2026-03-15T10:00:00Z'],
['No' => 'T002', 'Ticket_State' => 'In Bearbeitung', 'Last_Activity_Date' => '2026-03-20T14:30:00Z'],
['No' => 'T003', 'Ticket_State' => 'Erledigt', 'Last_Activity_Date' => '2026-03-10T08:00:00Z'],
['No' => 'T004', 'Ticket_State' => 'Closed', 'Last_Activity_Date' => '2026-02-01T12:00:00Z'],
['No' => 'T005', 'Ticket_State' => 'Open', 'Last_Activity_Date' => '2026-03-25T16:45:00Z'],
];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
'tickets' => $tickets,
'meta' => [
'source' => 'primary.company_contact_name',
'fallback_used' => false,
'fallback_reason' => '',
],
]);
$service = $this->createService($gateway);
$result = $service->loadTicketSummary('10001', 'Test GmbH');
$this->assertTrue($result['ok']);
$this->assertSame(5, $result['total']);
$this->assertSame(3, $result['open']); // Offen, In Bearbeitung, Open
$this->assertSame('2026-03-25T16:45:00Z', $result['last_activity']);
}
public function testLoadTicketSummaryHandlesEmptyTicketList(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
'tickets' => [],
'meta' => [
'source' => 'primary.company_contact_name',
'fallback_used' => false,
'fallback_reason' => '',
],
]);
$service = $this->createService($gateway);
$result = $service->loadTicketSummary('10001', 'Test GmbH');
$this->assertTrue($result['ok']);
$this->assertSame(0, $result['total']);
$this->assertSame(0, $result['open']);
$this->assertSame('', $result['last_activity']);
}
public function testLoadTicketSummaryIgnoresNullDateEntries(): void
{
$tickets = [
['No' => 'T001', 'Ticket_State' => 'Offen', 'Last_Activity_Date' => '0001-01-01T00:00:00Z'],
['No' => 'T002', 'Ticket_State' => 'Offen', 'Last_Activity_Date' => ''],
];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
'tickets' => $tickets,
'meta' => [
'source' => 'primary.company_contact_name',
'fallback_used' => false,
'fallback_reason' => '',
],
]);
$service = $this->createService($gateway);
$result = $service->loadTicketSummary('10001', 'Test GmbH');
$this->assertTrue($result['ok']);
$this->assertSame(2, $result['open']);
$this->assertSame('', $result['last_activity']);
}
public function testLoadTicketSummaryReturnsErrorOnException(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
$service = $this->createService($gateway);
$result = $service->loadTicketSummary('10001', 'Test GmbH');
$this->assertFalse($result['ok']);
$this->assertSame('Timeout', $result['error']);
}
// --- loadSupportDashboard() ---
public function testLoadSupportDashboardReturnsErrorWhenTicketLoadFails(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getTicketsForCustomerWithMeta')->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
{
$service = $this->createService();
$result = $service->loadTicketLog('');
$this->assertFalse($result['ok']);
}
public function testLoadTicketLogReturnsFalseWhenNotConfigured(): void
{
$service = $this->createService(null, false);
$result = $service->loadTicketLog('T001');
$this->assertFalse($result['ok']);
}
public function testLoadTicketLogReturnsOkWithLog(): void
{
$log = [
['Entry_No' => 1, 'Type' => 'Nachricht', 'Created_By' => 'NKS', 'Support_Ticket_No' => 'T001'],
['Entry_No' => 2, 'Type' => 'Status', 'Created_By' => 'NKS', 'Support_Ticket_No' => 'T001'],
];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getTicketLog')->willReturn($log);
$service = $this->createService($gateway);
$result = $service->loadTicketLog('T001');
$this->assertTrue($result['ok']);
$this->assertSame($log, $result['log']);
}
public function testLoadTicketLogReturnsErrorOnException(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('getTicketLog')->willThrowException(new \RuntimeException('API error'));
$service = $this->createService($gateway);
$result = $service->loadTicketLog('T001');
$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('raw_value', $indicator);
$this->assertArrayHasKey('threshold_value', $indicator);
$this->assertArrayHasKey('unit', $indicator);
$this->assertArrayHasKey('description', $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 ---
// --- buildTeamWorkloadDashboard() ---
/**
* @return array<string, mixed>
*/
private static function teamTicket(string $no, string $supportUser, int $createdHoursAgo, bool $closed = false): array
{
$now = time();
$createdTs = $now - ($createdHoursAgo * 3600);
$activityTs = $createdTs + (($closed ? 2 : 0) * 3600);
return [
'No' => $no,
'Customer_No' => '10610',
'Ticket_State' => $closed ? 'Erledigt' : 'Offen',
'Process_Stage' => $closed ? 40 : 10,
'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' => $supportUser,
'Category_1_Code' => '',
'Process_Code' => '',
];
}
public function testBuildTeamWorkloadGroupsByUser(): void
{
$tickets = [
self::teamTicket('T1', 'Max Müller', 10),
self::teamTicket('T2', 'Max Müller', 20),
self::teamTicket('T3', 'Anna Schmidt', 5),
];
$result = DebitorDetailService::buildTeamWorkloadDashboard($tickets, 90);
$this->assertSame(3, $result['kpis']['open_tickets']);
$this->assertSame(0, $result['kpis']['unassigned']);
$this->assertCount(2, $result['members']);
$this->assertSame(2, $result['meta']['unique_users']);
$max = $result['members'][0];
$this->assertSame('max müller', $max['support_user']);
$this->assertSame('Max Müller', $max['display_name']);
$this->assertSame(2, $max['open_tickets']);
$anna = $result['members'][1];
$this->assertSame(1, $anna['open_tickets']);
}
public function testBuildTeamWorkloadEmptyTickets(): void
{
$result = DebitorDetailService::buildTeamWorkloadDashboard([], 30);
$this->assertSame(0, $result['kpis']['open_tickets']);
$this->assertSame(0, $result['kpis']['unassigned']);
$this->assertSame(0, $result['kpis']['avg_age_hours']);
$this->assertSame(0, $result['kpis']['resolved_in_period']);
$this->assertSame([], $result['members']);
$this->assertSame(0, $result['meta']['unique_users']);
}
public function testBuildTeamWorkloadUnassignedAlwaysLast(): void
{
$tickets = [
self::teamTicket('T1', '', 10),
self::teamTicket('T2', '', 20),
self::teamTicket('T3', 'Anna Schmidt', 5),
];
$result = DebitorDetailService::buildTeamWorkloadDashboard($tickets, 90);
$this->assertSame(2, $result['kpis']['unassigned']);
$last = end($result['members']);
$this->assertSame('', $last['support_user']);
$this->assertSame(2, $last['open_tickets']);
}
public function testBuildTeamWorkloadResolvedInPeriod(): void
{
$tickets = [
self::teamTicket('T1', 'Max', 10, true),
self::teamTicket('T2', 'Max', 800, true),
self::teamTicket('T3', 'Max', 5),
];
$result = DebitorDetailService::buildTeamWorkloadDashboard($tickets, 30);
$this->assertSame(1, $result['kpis']['open_tickets']);
$this->assertSame(1, $result['kpis']['resolved_in_period']);
$max = $result['members'][0];
$this->assertSame(1, $max['open_tickets']);
$this->assertSame(1, $max['resolved_in_period']);
}
public function testBuildTeamWorkloadCriticalTickets(): void
{
$tickets = [
self::teamTicket('T1', 'Max', 72),
self::teamTicket('T2', 'Max', 10),
];
$result = DebitorDetailService::buildTeamWorkloadDashboard($tickets, 90);
$max = $result['members'][0];
$this->assertSame(1, $max['critical_tickets']);
}
public function testBuildTeamWorkloadCaseInsensitiveGrouping(): void
{
$tickets = [
self::teamTicket('T1', 'Max Müller', 10),
self::teamTicket('T2', 'max müller', 5),
];
$result = DebitorDetailService::buildTeamWorkloadDashboard($tickets, 90);
$this->assertCount(1, $result['members']);
$this->assertSame(2, $result['members'][0]['open_tickets']);
$this->assertSame('Max Müller', $result['members'][0]['display_name']);
}
}