1
0

feat(helpdesk): add domain linking, bulk actions, and revision polish to handovers

Adds domain selection (cascaded from debitor) to handover creation and edit,
bulk delete with confirmation dialog, and various UI improvements to the
handover wizard and list page. Includes migration for domain columns,
domain-select AJAX endpoint, and updated tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 13:21:12 +02:00
parent 03e15eaf08
commit e7c60468c9
24 changed files with 913 additions and 146 deletions

View File

@@ -53,7 +53,7 @@ class HandoverServiceTest extends TestCase
$repo->expects($this->once())->method('insert')->willReturn(99);
$service = $this->createService($repo, $this->mockProductService());
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', self::USER_ID);
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID);
$this->assertTrue($result['ok']);
$this->assertSame(99, $result['id']);
@@ -63,7 +63,7 @@ class HandoverServiceTest extends TestCase
public function testCreateWithInvalidProductCode(): void
{
$service = $this->createService(productService: $this->mockProductService(exists: false));
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'INVALID', self::USER_ID);
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'INVALID', 'DNS00001', 'example.com', self::USER_ID);
$this->assertFalse($result['ok']);
$this->assertNull($result['id']);
@@ -73,7 +73,7 @@ class HandoverServiceTest extends TestCase
public function testCreateWithProductWithoutSchema(): void
{
$service = $this->createService(productService: $this->mockProductService(hasSchema: false));
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', self::USER_ID);
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID);
$this->assertFalse($result['ok']);
$this->assertNull($result['id']);
@@ -83,7 +83,7 @@ class HandoverServiceTest extends TestCase
public function testCreateWithEmptyDebitorNo(): void
{
$service = $this->createService();
$result = $service->create(self::TENANT_ID, '', 'Acme Corp', 'PROD-A', self::USER_ID);
$result = $service->create(self::TENANT_ID, '', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('debitor_no', $result['errors']);
@@ -217,12 +217,38 @@ class HandoverServiceTest extends TestCase
);
$service = $this->createService($repo, $this->mockProductService(), $revisionService);
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', self::USER_ID);
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID);
$this->assertTrue($result['ok']);
}
public function testCreateRevisionAfterSaveDeterminesChangeType(): void
public function testCreateWithEmptyDomainNo(): void
{
$service = $this->createService(productService: $this->mockProductService());
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', '', 'example.com', self::USER_ID);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('domain_no', $result['errors']);
}
public function testCreatePassesDomainToRepository(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->expects($this->once())
->method('insert')
->with($this->callback(function (array $data): bool {
return ($data['domain_no'] ?? '') === 'DNS00001'
&& ($data['domain_url'] ?? '') === 'example.com';
}))
->willReturn(99);
$service = $this->createService($repo, $this->mockProductService());
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID);
$this->assertTrue($result['ok']);
}
public function testCreateRevisionAfterSaveSetsBothWhenFieldsAndStatusChanged(): void
{
$repo = $this->createMock(HandoverRepository::class);
@@ -240,10 +266,10 @@ class HandoverServiceTest extends TestCase
);
$service = $this->createService($repo, null, $revisionService);
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'in_progress', 'draft', 1, self::USER_ID);
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'in_progress', true, true, 1, self::USER_ID);
}
public function testCreateRevisionAfterSaveFieldsOnlyChangeType(): void
public function testCreateRevisionAfterSaveSetsFieldsWhenOnlyFieldsChanged(): void
{
$repo = $this->createMock(HandoverRepository::class);
@@ -261,18 +287,12 @@ class HandoverServiceTest extends TestCase
);
$service = $this->createService($repo, null, $revisionService);
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', 'draft', 1, self::USER_ID);
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', true, false, 1, self::USER_ID);
}
public function testCreateRevisionForStatusChange(): void
public function testCreateRevisionAfterSaveSetsStatusWhenOnlyStatusChanged(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'in_progress',
'field_values' => '{"name":"Test"}',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
@@ -288,6 +308,67 @@ class HandoverServiceTest extends TestCase
);
$service = $this->createService($repo, null, $revisionService);
$service->createRevisionForStatusChange(self::TENANT_ID, 1, 'completed', 1, self::USER_ID);
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'completed', false, true, 1, self::USER_ID);
}
public function testCreateRevisionAfterSaveDefaultsToFieldsWhenNoContentChanged(): void
{
$repo = $this->createMock(HandoverRepository::class);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
->method('createRevision')
->with(
self::TENANT_ID,
1,
['name' => 'Test'],
'draft',
1,
self::USER_ID,
HandoverRevisionService::CHANGE_TYPE_FIELDS
);
$service = $this->createService($repo, null, $revisionService);
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', false, false, 1, self::USER_ID);
}
// ── Delete tests ─────────────────────────────────────────────────
public function testDeleteByIdsHappyPath(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->expects($this->once())
->method('deleteByIds')
->with(self::TENANT_ID, [1, 2, 3])
->willReturn(3);
$service = $this->createService($repo);
$result = $service->deleteByIds(self::TENANT_ID, [1, 2, 3], HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
$this->assertSame(3, $result['count']);
}
public function testDeleteByIdsDeniedForCreatePermission(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->expects($this->never())->method('deleteByIds');
$service = $this->createService($repo);
$result = $service->deleteByIds(self::TENANT_ID, [1], HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertSame(0, $result['count']);
}
public function testDeleteByIdsWithEmptyArray(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->expects($this->never())->method('deleteByIds');
$service = $this->createService($repo);
$result = $service->deleteByIds(self::TENANT_ID, [], HandoverService::PERMISSION_MANAGE);
$this->assertFalse($result['ok']);
}
}