2026-04-02 17:48:27 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
|
|
|
|
|
|
|
|
|
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
|
|
|
|
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
2026-04-06 10:29:59 +02:00
|
|
|
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
|
2026-04-02 17:48:27 +02:00
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
|
|
|
|
|
|
class DebitorDetailServiceTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
private function createService(?BcODataGateway $gateway = null, bool $isConfigured = true): DebitorDetailService
|
|
|
|
|
{
|
|
|
|
|
$gateway = $gateway ?? $this->createMock(BcODataGateway::class);
|
2026-04-06 10:29:59 +02:00
|
|
|
$settings = $this->createMock(EffectiveHelpdeskSettingsService::class);
|
2026-04-02 17:48:27 +02:00
|
|
|
$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);
|
2026-04-05 16:32:34 +02:00
|
|
|
$gateway->method('getContactsForCustomerWithMeta')->willReturn([
|
|
|
|
|
'contacts' => $contacts,
|
|
|
|
|
'meta' => [
|
|
|
|
|
'source' => 'primary.company_name',
|
|
|
|
|
'fallback_used' => false,
|
|
|
|
|
'fallback_reason' => '',
|
|
|
|
|
],
|
|
|
|
|
]);
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
$service = $this->createService($gateway);
|
|
|
|
|
$result = $service->loadContacts('10001', 'Test GmbH');
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
$this->assertSame($contacts, $result['contacts']);
|
2026-04-05 16:32:34 +02:00
|
|
|
$this->assertSame('primary.company_name', $result['meta']['source']);
|
|
|
|
|
$this->assertFalse($result['meta']['fallback_used']);
|
2026-04-02 17:48:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testLoadContactsReturnsErrorOnException(): void
|
|
|
|
|
{
|
|
|
|
|
$gateway = $this->createMock(BcODataGateway::class);
|
2026-04-05 16:32:34 +02:00
|
|
|
$gateway->method('getContactsForCustomerWithMeta')->willThrowException(new \RuntimeException('API error'));
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
$service = $this->createService($gateway);
|
|
|
|
|
$result = $service->loadContacts('10001', 'Test GmbH');
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame('API error', $result['error']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 16:32:34 +02:00
|
|
|
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']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 17:48:27 +02:00
|
|
|
// --- 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);
|
2026-04-05 16:32:34 +02:00
|
|
|
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
|
|
|
|
'tickets' => $tickets,
|
|
|
|
|
'meta' => [
|
|
|
|
|
'source' => 'primary.company_contact_name',
|
|
|
|
|
'fallback_used' => false,
|
|
|
|
|
'fallback_reason' => '',
|
|
|
|
|
],
|
|
|
|
|
]);
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
$service = $this->createService($gateway);
|
|
|
|
|
$result = $service->loadTickets('10001', 'Test GmbH');
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
$this->assertSame($tickets, $result['tickets']);
|
2026-04-05 16:32:34 +02:00
|
|
|
$this->assertSame('primary.company_contact_name', $result['meta']['source']);
|
|
|
|
|
$this->assertFalse($result['meta']['fallback_used']);
|
2026-04-02 17:48:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testLoadTicketsReturnsErrorOnException(): void
|
|
|
|
|
{
|
|
|
|
|
$gateway = $this->createMock(BcODataGateway::class);
|
2026-04-05 16:32:34 +02:00
|
|
|
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
$service = $this->createService($gateway);
|
|
|
|
|
$result = $service->loadTickets('10001', 'Test GmbH');
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame('Timeout', $result['error']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 16:32:34 +02:00
|
|
|
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']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
// --- 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']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 21:05:54 +02:00
|
|
|
// --- 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);
|
2026-04-05 16:32:34 +02:00
|
|
|
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
|
|
|
|
'tickets' => $tickets,
|
|
|
|
|
'meta' => [
|
|
|
|
|
'source' => 'primary.company_contact_name',
|
|
|
|
|
'fallback_used' => false,
|
|
|
|
|
'fallback_reason' => '',
|
|
|
|
|
],
|
|
|
|
|
]);
|
2026-04-02 21:05:54 +02:00
|
|
|
|
|
|
|
|
$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);
|
2026-04-05 16:32:34 +02:00
|
|
|
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
|
|
|
|
'tickets' => [],
|
|
|
|
|
'meta' => [
|
|
|
|
|
'source' => 'primary.company_contact_name',
|
|
|
|
|
'fallback_used' => false,
|
|
|
|
|
'fallback_reason' => '',
|
|
|
|
|
],
|
|
|
|
|
]);
|
2026-04-02 21:05:54 +02:00
|
|
|
|
|
|
|
|
$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);
|
2026-04-05 16:32:34 +02:00
|
|
|
$gateway->method('getTicketsForCustomerWithMeta')->willReturn([
|
|
|
|
|
'tickets' => $tickets,
|
|
|
|
|
'meta' => [
|
|
|
|
|
'source' => 'primary.company_contact_name',
|
|
|
|
|
'fallback_used' => false,
|
|
|
|
|
'fallback_reason' => '',
|
|
|
|
|
],
|
|
|
|
|
]);
|
2026-04-02 21:05:54 +02:00
|
|
|
|
|
|
|
|
$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);
|
2026-04-05 16:32:34 +02:00
|
|
|
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
2026-04-02 21:05:54 +02:00
|
|
|
|
|
|
|
|
$service = $this->createService($gateway);
|
|
|
|
|
$result = $service->loadTicketSummary('10001', 'Test GmbH');
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame('Timeout', $result['error']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
// --- loadSupportDashboard() ---
|
|
|
|
|
|
|
|
|
|
public function testLoadSupportDashboardReturnsErrorWhenTicketLoadFails(): void
|
|
|
|
|
{
|
|
|
|
|
$gateway = $this->createMock(BcODataGateway::class);
|
2026-04-05 16:32:34 +02:00
|
|
|
$gateway->method('getTicketsForCustomerWithMeta')->willThrowException(new \RuntimeException('Timeout'));
|
2026-04-04 18:34:03 +02:00
|
|
|
|
|
|
|
|
$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->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->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->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']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 17:48:27 +02:00
|
|
|
// --- 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']);
|
|
|
|
|
}
|
2026-04-04 18:34:03 +02:00
|
|
|
|
|
|
|
|
// --- 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);
|
2026-04-05 16:32:34 +02:00
|
|
|
$this->assertArrayHasKey('raw_value', $indicator);
|
|
|
|
|
$this->assertArrayHasKey('threshold_value', $indicator);
|
|
|
|
|
$this->assertArrayHasKey('unit', $indicator);
|
|
|
|
|
$this->assertArrayHasKey('description', $indicator);
|
2026-04-04 18:34:03 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 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 ---
|
2026-04-05 18:46:54 +02:00
|
|
|
|
|
|
|
|
// --- 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']);
|
|
|
|
|
}
|
2026-04-13 14:13:29 +02:00
|
|
|
|
|
|
|
|
// --- buildMeetingsTimeline() ---
|
|
|
|
|
|
|
|
|
|
public function testBuildMeetingsTimelineReturnsEmptyForNoMeetings(): void
|
|
|
|
|
{
|
|
|
|
|
$result = DebitorDetailService::buildMeetingsTimeline([]);
|
|
|
|
|
|
|
|
|
|
$this->assertSame([], $result['timeline']['upcoming']);
|
|
|
|
|
$this->assertSame([], $result['timeline']['past']);
|
|
|
|
|
$this->assertSame(0, $result['kpis']['total']);
|
|
|
|
|
$this->assertSame(0, $result['kpis']['upcoming_count']);
|
|
|
|
|
$this->assertSame(0, $result['kpis']['days_since_last']);
|
|
|
|
|
$this->assertNull($result['kpis']['last_meeting_date']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testBuildMeetingsTimelineSkipsZeroDateEntries(): void
|
|
|
|
|
{
|
|
|
|
|
$meetings = [
|
|
|
|
|
['Entry_No' => 1, 'Appointment_Date' => '0001-01-01', 'Customer_No' => '10254', 'Comment' => 'Skip me'],
|
|
|
|
|
['Entry_No' => 2, 'Appointment_Date' => '', 'Customer_No' => '10254', 'Comment' => 'Also skip'],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$result = DebitorDetailService::buildMeetingsTimeline($meetings);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(0, $result['kpis']['total']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testBuildMeetingsTimelineSplitsUpcomingAndPast(): void
|
|
|
|
|
{
|
|
|
|
|
$today = date('Y-m-d');
|
|
|
|
|
$futureDate = date('Y-m-d', strtotime('+30 days'));
|
|
|
|
|
$pastDate = date('Y-m-d', strtotime('-10 days'));
|
|
|
|
|
|
|
|
|
|
$meetings = [
|
|
|
|
|
['Entry_No' => 1, 'Appointment_Date' => $futureDate, 'Customer_No' => '10254', 'Customer_Name' => 'Test', 'Salesperson_Code' => 'MER', 'Comment' => 'Future meeting', 'Released_by' => '', 'Release_Date' => '0001-01-01'],
|
|
|
|
|
['Entry_No' => 2, 'Appointment_Date' => $pastDate, 'Customer_No' => '10254', 'Customer_Name' => 'Test', 'Salesperson_Code' => 'MER', 'Comment' => 'Past meeting', 'Released_by' => 'USER', 'Release_Date' => $pastDate],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$result = DebitorDetailService::buildMeetingsTimeline($meetings);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(2, $result['kpis']['total']);
|
|
|
|
|
$this->assertSame(1, $result['kpis']['upcoming_count']);
|
|
|
|
|
$this->assertSame(10, $result['kpis']['days_since_last']);
|
|
|
|
|
$this->assertSame($pastDate, $result['kpis']['last_meeting_date']);
|
|
|
|
|
|
|
|
|
|
$futureMonth = substr($futureDate, 0, 7);
|
|
|
|
|
$pastMonth = substr($pastDate, 0, 7);
|
|
|
|
|
|
|
|
|
|
$this->assertArrayHasKey($futureMonth, $result['timeline']['upcoming']);
|
|
|
|
|
$this->assertArrayHasKey($pastMonth, $result['timeline']['past']);
|
|
|
|
|
|
|
|
|
|
// Verify upcoming entry
|
|
|
|
|
$upcomingEntry = $result['timeline']['upcoming'][$futureMonth][0];
|
|
|
|
|
$this->assertSame('Future meeting', $upcomingEntry['comment']);
|
|
|
|
|
$this->assertFalse($upcomingEntry['is_released']);
|
|
|
|
|
|
|
|
|
|
// Verify past entry
|
|
|
|
|
$pastEntry = $result['timeline']['past'][$pastMonth][0];
|
|
|
|
|
$this->assertSame('Past meeting', $pastEntry['comment']);
|
|
|
|
|
$this->assertTrue($pastEntry['is_released']);
|
|
|
|
|
$this->assertSame($pastDate, $pastEntry['release_date']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testBuildMeetingsTimelineGroupsByMonth(): void
|
|
|
|
|
{
|
|
|
|
|
$meetings = [
|
|
|
|
|
['Entry_No' => 1, 'Appointment_Date' => '2025-03-15', 'Customer_No' => '10254', 'Customer_Name' => 'A', 'Salesperson_Code' => 'MER', 'Comment' => 'A', 'Released_by' => '', 'Release_Date' => '0001-01-01'],
|
|
|
|
|
['Entry_No' => 2, 'Appointment_Date' => '2025-03-20', 'Customer_No' => '10254', 'Customer_Name' => 'B', 'Salesperson_Code' => 'MER', 'Comment' => 'B', 'Released_by' => '', 'Release_Date' => '0001-01-01'],
|
|
|
|
|
['Entry_No' => 3, 'Appointment_Date' => '2025-02-10', 'Customer_No' => '10254', 'Customer_Name' => 'C', 'Salesperson_Code' => 'MER', 'Comment' => 'C', 'Released_by' => 'U', 'Release_Date' => '2025-02-11'],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$result = DebitorDetailService::buildMeetingsTimeline($meetings);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(3, $result['kpis']['total']);
|
|
|
|
|
// All 3 are in the past (2025-02, 2025-03)
|
|
|
|
|
$this->assertArrayHasKey('2025-03', $result['timeline']['past']);
|
|
|
|
|
$this->assertArrayHasKey('2025-02', $result['timeline']['past']);
|
|
|
|
|
$this->assertCount(2, $result['timeline']['past']['2025-03']);
|
|
|
|
|
$this->assertCount(1, $result['timeline']['past']['2025-02']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- loadMeetings() ---
|
|
|
|
|
|
|
|
|
|
public function testLoadMeetingsReturnsErrorForEmptyCustomerNo(): void
|
|
|
|
|
{
|
|
|
|
|
$service = $this->createService();
|
|
|
|
|
$result = $service->loadMeetings('');
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame('Missing customer number', $result['error']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testLoadMeetingsReturnsErrorWhenNotConfigured(): void
|
|
|
|
|
{
|
|
|
|
|
$service = $this->createService(null, false);
|
|
|
|
|
$result = $service->loadMeetings('10254');
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame('BC connection not configured', $result['error']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testLoadMeetingsReturnsTimelineOnSuccess(): void
|
|
|
|
|
{
|
|
|
|
|
$futureDate = date('Y-m-d', strtotime('+7 days'));
|
|
|
|
|
$gateway = $this->createMock(BcODataGateway::class);
|
2026-04-13 19:06:12 +02:00
|
|
|
$gateway->expects($this->any())->method('getMeetingsForCustomer')->with('10254')->willReturn([
|
2026-04-13 14:13:29 +02:00
|
|
|
['Entry_No' => 1, 'Appointment_Date' => $futureDate, 'Customer_No' => '10254', 'Customer_Name' => 'Test GmbH', 'Salesperson_Code' => 'MER', 'Comment' => 'Workshop', 'Released_by' => '', 'Release_Date' => '0001-01-01'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($gateway);
|
|
|
|
|
$result = $service->loadMeetings('10254');
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
$this->assertSame(1, $result['kpis']['total']);
|
|
|
|
|
$this->assertSame(1, $result['kpis']['upcoming_count']);
|
|
|
|
|
$this->assertArrayHasKey('upcoming', $result['timeline']);
|
|
|
|
|
$this->assertArrayHasKey('past', $result['timeline']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testLoadMeetingsReturnsErrorOnGatewayException(): void
|
|
|
|
|
{
|
|
|
|
|
$gateway = $this->createMock(BcODataGateway::class);
|
|
|
|
|
$gateway->method('getMeetingsForCustomer')->willThrowException(new \RuntimeException('Connection timeout'));
|
|
|
|
|
|
|
|
|
|
$service = $this->createService($gateway);
|
|
|
|
|
$result = $service->loadMeetings('10254');
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame('Connection timeout', $result['error']);
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
}
|