feat(helpdesk): add debitor meetings tab with upcoming/past meeting display

Adds a meetings section to the debitor detail page, fetching meeting data from BC OData API with cache control support. Includes timeline UI for upcoming and past meetings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 14:13:29 +02:00
parent 4e359fe659
commit 42efdb8102
12 changed files with 679 additions and 15 deletions

View File

@@ -38,7 +38,7 @@ class DebitorCacheControlTest extends TestCase
public function testInvalidateDebitorCachesRemovesDebitorAndTenantSharedKeys(): void
{
$sessionStore = $this->createMock(SessionStoreInterface::class);
$sessionStore->expects($this->exactly(13))
$sessionStore->expects($this->exactly(14))
->method('remove')
->with($this->callback(static function (string $key): bool {
return str_contains($key, '.13.10259')

View File

@@ -1245,4 +1245,134 @@ class DebitorDetailServiceTest extends TestCase
$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']);
}
}