feat(helpdesk): add Software Products page with BC contract type sync

Add a new Software-Produkte feature to the helpdesk module that syncs
contract types (Create_SaaS_License=true) from BC OData into a local
database table with a nightly scheduler job, providing a Grid.js list
page and detail/edit page for managing custom product names.

- DB migration 003: helpdesk_software_products table (code UNIQUE key)
- BcODataGateway: FS_Contract_Types entity with SaaS filter + fallback
- SoftwareProductRepository: upsert, listPaged, softDelete, updateName
- SoftwareProductSyncService: fetch → upsert → soft-delete lifecycle
- SoftwareProductSyncJobHandler: daily at 02:00 via scheduler platform
- SoftwareProductService: web UI business logic with validation
- Permission: helpdesk.software-products.manage (nav-gated)
- List page: Grid.js with Code, BC Description, Name, Status columns
- Detail page: Code/BC Description read-only, Name editable, PRG pattern
- i18n: de + en translation keys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 22:16:21 +02:00
parent d472026df4
commit ef4473de64
23 changed files with 1209 additions and 5 deletions

View File

@@ -0,0 +1,112 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
use PHPUnit\Framework\TestCase;
class SoftwareProductServiceTest extends TestCase
{
private function createService(?SoftwareProductRepository $repository = null): SoftwareProductService
{
$repository = $repository ?? $this->createMock(SoftwareProductRepository::class);
return new SoftwareProductService($repository);
}
public function testFindByCodeReturnsNullForMissingProduct(): void
{
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->method('findByCode')->willReturn(null);
$service = $this->createService($repository);
$this->assertNull($service->findByCode('NONEXISTENT'));
}
public function testFindByCodeReturnsProduct(): void
{
$product = ['id' => 1, 'code' => 'INFO', 'name' => 'Infopoints', 'active' => 1];
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->method('findByCode')->willReturn($product);
$service = $this->createService($repository);
$result = $service->findByCode('INFO');
$this->assertSame('INFO', $result['code']);
}
public function testUpdateNameByCodeReturnsErrorForMissingProduct(): void
{
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->method('findByCode')->willReturn(null);
$service = $this->createService($repository);
$result = $service->updateNameByCode('NONEXISTENT', 'Test');
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('code', $result['errors']);
}
public function testUpdateNameByCodeTrimsWhitespace(): void
{
$product = ['id' => 1, 'code' => 'INFO', 'name' => '', 'active' => 1];
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->method('findByCode')->willReturn($product);
$repository->expects($this->once())
->method('updateName')
->with('INFO', 'Trimmed Name')
->willReturn(true);
$service = $this->createService($repository);
$result = $service->updateNameByCode('INFO', ' Trimmed Name ');
$this->assertTrue($result['ok']);
$this->assertEmpty($result['errors']);
}
public function testUpdateNameByCodeRejectsOverlongName(): void
{
$product = ['id' => 1, 'code' => 'INFO', 'name' => '', 'active' => 1];
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->method('findByCode')->willReturn($product);
$repository->expects($this->never())->method('updateName');
$service = $this->createService($repository);
$result = $service->updateNameByCode('INFO', str_repeat('A', 256));
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('name', $result['errors']);
}
public function testUpdateNameByCodeAllowsEmptyName(): void
{
$product = ['id' => 1, 'code' => 'INFO', 'name' => 'Old', 'active' => 1];
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->method('findByCode')->willReturn($product);
$repository->expects($this->once())
->method('updateName')
->with('INFO', '')
->willReturn(true);
$service = $this->createService($repository);
$result = $service->updateNameByCode('INFO', '');
$this->assertTrue($result['ok']);
}
public function testListPagedDelegatesToRepository(): void
{
$expected = ['total' => 2, 'rows' => [['code' => 'A'], ['code' => 'B']]];
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->expects($this->once())
->method('listPaged')
->with(['search' => 'test'])
->willReturn($expected);
$service = $this->createService($repository);
$result = $service->listPaged(['search' => 'test']);
$this->assertSame(2, $result['total']);
$this->assertCount(2, $result['rows']);
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService;
use PHPUnit\Framework\TestCase;
class SoftwareProductSyncServiceTest extends TestCase
{
private function createService(
?BcODataGateway $gateway = null,
?SoftwareProductRepository $repository = null,
bool $isConfigured = true
): SoftwareProductSyncService {
$gateway = $gateway ?? $this->createMock(BcODataGateway::class);
$repository = $repository ?? $this->createMock(SoftwareProductRepository::class);
$settingsGateway = $this->createMock(HelpdeskSettingsGateway::class);
$settingsGateway->method('isConfigured')->willReturn($isConfigured);
return new SoftwareProductSyncService($gateway, $repository, $settingsGateway);
}
public function testSyncSkipsWhenNotConfigured(): void
{
$service = $this->createService(isConfigured: false);
$result = $service->sync();
$this->assertSame('skipped', $result['status']);
$this->assertSame('bc_not_configured', $result['error_code']);
}
public function testSyncReturnsFailedOnGatewayException(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('listContractTypes')
->willThrowException(new \RuntimeException('Connection refused'));
$service = $this->createService($gateway);
$result = $service->sync();
$this->assertSame('failed', $result['status']);
$this->assertSame('bc_fetch_failed', $result['error_code']);
$this->assertStringContainsString('Connection refused', $result['error_message']);
}
public function testSyncUpsertsAndSoftDeletesCorrectly(): void
{
$bcTypes = [
['Code' => 'INFO', 'Description' => 'Infopoints'],
['Code' => 'WEBSITE', 'Description' => 'Saas-Gebuehren Website'],
];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('listContractTypes')->willReturn($bcTypes);
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->expects($this->exactly(2))
->method('upsertByCode')
->willReturnCallback(function (string $code, string $desc): bool {
$this->assertContains($code, ['INFO', 'WEBSITE']);
return true;
});
$repository->expects($this->once())
->method('softDeleteNotInCodes')
->with(['INFO', 'WEBSITE'])
->willReturn(1);
$service = $this->createService($gateway, $repository);
$result = $service->sync();
$this->assertSame('success', $result['status']);
$this->assertNull($result['error_code']);
$this->assertSame(2, $result['result']['bc_count']);
$this->assertSame(2, $result['result']['upserted']);
$this->assertSame(1, $result['result']['deactivated']);
}
public function testSyncHandlesEmptyBcResponse(): void
{
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('listContractTypes')->willReturn([]);
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->expects($this->never())->method('upsertByCode');
$repository->expects($this->once())
->method('softDeleteNotInCodes')
->with([])
->willReturn(3);
$service = $this->createService($gateway, $repository);
$result = $service->sync();
$this->assertSame('success', $result['status']);
$this->assertSame(0, $result['result']['bc_count']);
$this->assertSame(0, $result['result']['upserted']);
$this->assertSame(3, $result['result']['deactivated']);
}
public function testSyncIsIdempotent(): void
{
$bcTypes = [
['Code' => 'INFO', 'Description' => 'Infopoints'],
];
$gateway = $this->createMock(BcODataGateway::class);
$gateway->method('listContractTypes')->willReturn($bcTypes);
$repository = $this->createMock(SoftwareProductRepository::class);
$repository->method('upsertByCode')->willReturn(true);
$repository->method('softDeleteNotInCodes')->willReturn(0);
$service = $this->createService($gateway, $repository);
$result1 = $service->sync();
$result2 = $service->sync();
$this->assertSame('success', $result1['status']);
$this->assertSame('success', $result2['status']);
$this->assertSame($result1['result']['bc_count'], $result2['result']['bc_count']);
}
}