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

@@ -23,6 +23,8 @@ class BcODataGateway
public const ENTITY_CONTRACT_LINES = 'FS_Contract_Lines_Test';
public const ENTITY_MEETINGS = 'FS_Debitor_Meetings';
public const ENTITY_CONTRACT_DOMAINS = 'FS_Contract_Domains';
/** @api Used by listContractTypes() */
public const ENTITY_CONTRACT_TYPES = 'FS_Contract_Types';
private const CONNECT_TIMEOUT = 10;
private const REQUEST_TIMEOUT = 30;
@@ -877,6 +879,72 @@ class BcODataGateway
return $this->extractODataValues($response);
}
/**
* Get all contract types where Create_SaaS_License is true.
*
* Tries $filter=Create_SaaS_License eq true first; falls back to
* fetching all types and filtering PHP-side if BC rejects the filter.
*
* Returns trimmed Code + Description. Entries with empty Code are skipped.
*
* @return array<int, array{Code: string, Description: string}>
*/
public function listContractTypes(): array
{
$select = 'Code,Description,Create_SaaS_License';
$filter = 'Create_SaaS_License eq true';
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_TYPES)
. '?$filter=' . rawurlencode($filter)
. '&$top=5000'
. '&$select=' . rawurlencode($select);
$response = $this->request('GET', $url);
// Fallback: fetch all, filter PHP-side if BC rejects the boolean filter
if ($response === null) {
$urlAll = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_TYPES)
. '?$top=5000'
. '&$select=' . rawurlencode($select);
$response = $this->request('GET', $urlAll);
if ($response === null) {
return [];
}
$all = $this->extractODataValues($response);
return $this->normalizeContractTypes(array_filter(
$all,
static fn (array $row): bool => !empty($row['Create_SaaS_License'])
));
}
return $this->normalizeContractTypes($this->extractODataValues($response));
}
/**
* Trim and filter contract type rows: skip empty codes, trim whitespace.
*
* @param array<int, array<string, mixed>> $rows
* @return array<int, array{Code: string, Description: string}>
*/
private function normalizeContractTypes(array $rows): array
{
$result = [];
foreach ($rows as $row) {
$code = trim((string) ($row['Code'] ?? ''));
if ($code === '') {
continue;
}
$result[] = [
'Code' => $code,
'Description' => trim((string) ($row['Description'] ?? '')),
];
}
return $result;
}
/**
* Get a single ticket by ticket number.
*

View File

@@ -0,0 +1,65 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
/** @api Called from pages/helpdesk/software-products action files */
class SoftwareProductService
{
public function __construct(
private readonly SoftwareProductRepository $repository
) {
}
/**
* @return array<string, mixed>|null
*/
public function findByCode(string $code): ?array
{
return $this->repository->findByCode($code);
}
/**
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(array $filters): array
{
return $this->repository->listPaged($filters);
}
/**
* Update the user-managed name field for a software product.
*
* @return array{ok: bool, form: array<string, mixed>, errors: array<string, string>}
*/
public function updateNameByCode(string $code, string $name): array
{
$name = trim($name);
$errors = [];
if (mb_strlen($name) > 255) {
$errors['name'] = t('Name must not exceed 255 characters');
}
$product = $this->repository->findByCode($code);
if ($product === null) {
$errors['code'] = t('Software product not found');
}
$form = array_merge($product ?? [], ['name' => $name]);
if ($errors !== []) {
return ['ok' => false, 'form' => $form, 'errors' => $errors];
}
$updated = $this->repository->updateName($code, $name);
if (!$updated) {
return ['ok' => false, 'form' => $form, 'errors' => ['name' => t('Failed to save')]];
}
$form = $this->repository->findByCode($code) ?? $form;
return ['ok' => true, 'form' => $form, 'errors' => []];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
class SoftwareProductSyncService
{
public function __construct(
private readonly BcODataGateway $bcODataGateway,
private readonly SoftwareProductRepository $repository,
private readonly HelpdeskSettingsGateway $settingsGateway
) {
}
/**
* Sync contract types from BC to the local helpdesk_software_products table.
*
* - Fetches all types with Create_SaaS_License=true
* - Upserts each entry (inserts new, updates bc_description, reactivates soft-deleted)
* - Soft-deletes entries whose code no longer appears in BC
*
* @return array{status: string, error_code: ?string, error_message: ?string, result: array<string, int>}
*/
public function sync(): array
{
if (!$this->settingsGateway->isConfigured()) {
return [
'status' => 'skipped',
'error_code' => 'bc_not_configured',
'error_message' => 'Business Central connection is not configured',
'result' => [],
];
}
try {
$bcTypes = $this->bcODataGateway->listContractTypes();
} catch (\Throwable $e) {
return [
'status' => 'failed',
'error_code' => 'bc_fetch_failed',
'error_message' => mb_substr('BC OData fetch failed: ' . $e->getMessage(), 0, 255),
'result' => [],
];
}
$syncedCodes = [];
$upserted = 0;
foreach ($bcTypes as $type) {
$code = $type['Code'];
$description = $type['Description'];
if ($this->repository->upsertByCode($code, $description)) {
$upserted++;
}
$syncedCodes[] = $code;
}
$deactivated = $this->repository->softDeleteNotInCodes($syncedCodes);
return [
'status' => 'success',
'error_code' => null,
'error_message' => null,
'result' => [
'bc_count' => count($bcTypes),
'upserted' => $upserted,
'deactivated' => $deactivated,
],
];
}
}