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:
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Handler;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService;
|
||||
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
|
||||
|
||||
class SoftwareProductSyncJobHandler implements ScheduledJobHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SoftwareProductSyncService $syncService
|
||||
) {
|
||||
}
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'label' => 'Software product sync',
|
||||
'description' => 'Syncs contract types with Create_SaaS_License from Business Central to local database',
|
||||
'default_enabled' => 1,
|
||||
'default_timezone' => defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'Europe/Berlin',
|
||||
'default_schedule_type' => 'daily',
|
||||
'default_schedule_interval' => 1,
|
||||
'default_schedule_time' => '02:00',
|
||||
'default_schedule_weekdays_csv' => null,
|
||||
'default_catchup_once' => 1,
|
||||
'allowed_schedule_types' => ['daily', 'weekly'],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(?int $actorUserId): array
|
||||
{
|
||||
return $this->syncService->sync();
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,14 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
public const ABILITY_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
public const ABILITY_TEAM_WORKLOAD = 'helpdesk.team-workload.view';
|
||||
public const ABILITY_RISK_RADAR = 'helpdesk.risk-radar.view';
|
||||
public const ABILITY_SOFTWARE_PRODUCTS_MANAGE = 'helpdesk.software-products.manage';
|
||||
|
||||
public const PERMISSION_ACCESS = 'helpdesk.access';
|
||||
public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
public const PERMISSION_TEAM_WORKLOAD = 'helpdesk.team-workload.view';
|
||||
public const PERMISSION_RISK_RADAR = 'helpdesk.risk-radar.view';
|
||||
/** @api Used in authorize() match for ability resolution */
|
||||
public const PERMISSION_SOFTWARE_PRODUCTS_MANAGE = 'helpdesk.software-products.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService
|
||||
@@ -25,7 +28,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
|
||||
public function supports(string $ability): bool
|
||||
{
|
||||
return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD, self::ABILITY_RISK_RADAR], true);
|
||||
return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD, self::ABILITY_RISK_RADAR, self::ABILITY_SOFTWARE_PRODUCTS_MANAGE], true);
|
||||
}
|
||||
|
||||
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||
@@ -40,6 +43,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE,
|
||||
self::ABILITY_TEAM_WORKLOAD => self::PERMISSION_TEAM_WORKLOAD,
|
||||
self::ABILITY_RISK_RADAR => self::PERMISSION_RISK_RADAR,
|
||||
self::ABILITY_SOFTWARE_PRODUCTS_MANAGE => self::PERMISSION_SOFTWARE_PRODUCTS_MANAGE,
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,8 +15,12 @@ use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskTenantSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Repository\HelpdeskTenantSettingsRepository;
|
||||
use MintyPHP\Module\Helpdesk\Repository\HelpdeskTokenRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
|
||||
use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService;
|
||||
use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
|
||||
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
|
||||
use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler;
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
@@ -84,5 +88,21 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
|
||||
));
|
||||
|
||||
$container->set(SystemRecommendationEngine::class, static fn (): SystemRecommendationEngine => new SystemRecommendationEngine());
|
||||
|
||||
$container->set(SoftwareProductRepository::class, static fn (): SoftwareProductRepository => new SoftwareProductRepository());
|
||||
|
||||
$container->set(SoftwareProductSyncService::class, static fn (AppContainer $c): SoftwareProductSyncService => new SoftwareProductSyncService(
|
||||
$c->get(BcODataGateway::class),
|
||||
$c->get(SoftwareProductRepository::class),
|
||||
$c->get(HelpdeskSettingsGateway::class)
|
||||
));
|
||||
|
||||
$container->set(SoftwareProductService::class, static fn (AppContainer $c): SoftwareProductService => new SoftwareProductService(
|
||||
$c->get(SoftwareProductRepository::class)
|
||||
));
|
||||
|
||||
$container->set(SoftwareProductSyncJobHandler::class, static fn (AppContainer $c): SoftwareProductSyncJobHandler => new SoftwareProductSyncJobHandler(
|
||||
$c->get(SoftwareProductSyncService::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +21,19 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
|
||||
$canManageSettings = $authorizationService->authorize('helpdesk.settings.manage', $actorContext)->isAllowed();
|
||||
$canViewTeam = $authorizationService->authorize('helpdesk.team-workload.view', $actorContext)->isAllowed();
|
||||
$canViewRiskRadar = $authorizationService->authorize('helpdesk.risk-radar.view', $actorContext)->isAllowed();
|
||||
$canManageSoftwareProducts = $authorizationService->authorize('helpdesk.software-products.manage', $actorContext)->isAllowed();
|
||||
} catch (\Throwable) {
|
||||
$canManageSettings = false;
|
||||
$canViewTeam = false;
|
||||
$canViewRiskRadar = false;
|
||||
$canManageSoftwareProducts = false;
|
||||
}
|
||||
|
||||
return ['helpdesk.nav' => [
|
||||
'can_manage_settings' => $canManageSettings,
|
||||
'can_view_team' => $canViewTeam,
|
||||
'can_view_risk_radar' => $canViewRiskRadar,
|
||||
'can_manage_software_products' => $canManageSoftwareProducts,
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
class SoftwareProductRepository
|
||||
{
|
||||
/**
|
||||
* Insert or update a software product by code.
|
||||
*
|
||||
* Updates bc_description and synced_at on conflict. Reactivates soft-deleted entries.
|
||||
* Never touches the user-managed name field.
|
||||
*/
|
||||
public function upsertByCode(string $code, string $bcDescription): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'INSERT INTO helpdesk_software_products (code, bc_description, synced_at, active) VALUES (?, ?, NOW(), 1) '
|
||||
. 'ON DUPLICATE KEY UPDATE bc_description = VALUES(bc_description), synced_at = VALUES(synced_at), active = 1',
|
||||
$code,
|
||||
$bcDescription
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function findByCode(string $code): ?array
|
||||
{
|
||||
$code = trim($code);
|
||||
if ($code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = DB::selectOne(
|
||||
'SELECT * FROM helpdesk_software_products WHERE code = ? LIMIT 1',
|
||||
$code
|
||||
);
|
||||
|
||||
return $this->normalizeRow($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, rows: list<array<string, mixed>>}
|
||||
*/
|
||||
public function listPaged(array $filters): array
|
||||
{
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
$active = trim((string) ($filters['active'] ?? ''));
|
||||
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder(
|
||||
$filters,
|
||||
['code', 'bc_description', 'name', 'active', 'synced_at'],
|
||||
'code',
|
||||
'asc'
|
||||
);
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
RepoQuery::addLikeFilter($where, $params, ['code', 'bc_description', 'name'], $search);
|
||||
|
||||
if (in_array($active, ['0', '1'], true)) {
|
||||
$where[] = 'active = ?';
|
||||
$params[] = $active;
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' WHERE ' . implode(' AND ', $where)) : '';
|
||||
$total = (int) (DB::selectValue('SELECT COUNT(*) FROM helpdesk_software_products' . $whereSql, ...$params) ?? 0);
|
||||
|
||||
$rows = DB::select(
|
||||
'SELECT * FROM helpdesk_software_products'
|
||||
. $whereSql
|
||||
. sprintf(' ORDER BY `%s` %s LIMIT ? OFFSET ?', $order, $dir),
|
||||
...array_merge($params, [(string) $limit, (string) $offset])
|
||||
);
|
||||
|
||||
$normalized = [];
|
||||
if (is_array($rows)) {
|
||||
foreach ($rows as $row) {
|
||||
$item = $this->normalizeRow($row);
|
||||
if ($item !== null) {
|
||||
$normalized[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => $normalized,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateName(string $code, string $name): bool
|
||||
{
|
||||
if (trim($code) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = DB::update(
|
||||
'UPDATE helpdesk_software_products SET name = ? WHERE code = ?',
|
||||
$name,
|
||||
$code
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete entries whose code is NOT in the given list of active BC codes.
|
||||
*
|
||||
* @param list<string> $activeCodes Codes that still exist in BC
|
||||
* @return int Number of deactivated rows
|
||||
*/
|
||||
public function softDeleteNotInCodes(array $activeCodes): int
|
||||
{
|
||||
if ($activeCodes === []) {
|
||||
// Deactivate all if BC returned no codes
|
||||
$result = DB::update('UPDATE helpdesk_software_products SET active = 0 WHERE active = 1');
|
||||
|
||||
return is_int($result) ? $result : 0;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($activeCodes), '?'));
|
||||
$result = DB::update(
|
||||
'UPDATE helpdesk_software_products SET active = 0 WHERE active = 1 AND code NOT IN (' . $placeholders . ')',
|
||||
...$activeCodes
|
||||
);
|
||||
|
||||
return is_int($result) ? $result : 0;
|
||||
}
|
||||
|
||||
private function normalizeRow(mixed $row): ?array
|
||||
{
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
$item = $row['helpdesk_software_products'] ?? $row;
|
||||
if (!is_array($item) || !isset($item['id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
@@ -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