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 */ 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 */ 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 */ 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']); } // --- 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); $gateway->method('getMeetingsForCustomer')->with('10254')->willReturn([ ['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']); } }