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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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' => []];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user