From ef4473de642bc9bb28297b1f0271b1573fb1f4c1 Mon Sep 17 00:00:00 2001 From: fs Date: Tue, 14 Apr 2026 22:16:21 +0200 Subject: [PATCH] feat(helpdesk): add Software Products page with BC contract type sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- modules/helpdesk/i18n/default_de.json | 21 ++- modules/helpdesk/i18n/default_en.json | 21 ++- .../Handler/SoftwareProductSyncJobHandler.php | 35 +++++ .../Helpdesk/HelpdeskAuthorizationPolicy.php | 6 +- .../Helpdesk/HelpdeskContainerRegistrar.php | 20 +++ .../Providers/HelpdeskLayoutProvider.php | 3 + .../Repository/SoftwareProductRepository.php | 146 ++++++++++++++++++ .../Helpdesk/Service/BcODataGateway.php | 68 ++++++++ .../Service/SoftwareProductService.php | 65 ++++++++ .../Service/SoftwareProductSyncService.php | 73 +++++++++ .../003_create_software_products.sql | 14 ++ modules/helpdesk/module.php | 26 +++- .../helpdesk/software-products-data().php | 38 +++++ .../helpdesk/software-products/_form.phtml | 49 ++++++ .../software-products/edit($code).php | 69 +++++++++ .../software-products/edit(default).phtml | 92 +++++++++++ .../software-products/filter-schema.php | 41 +++++ .../helpdesk/software-products/index().php | 48 ++++++ .../software-products/index(default).phtml | 50 ++++++ .../templates/aside-helpdesk-panel.phtml | 10 +- .../Service/SoftwareProductServiceTest.php | 112 ++++++++++++++ .../SoftwareProductSyncServiceTest.php | 126 +++++++++++++++ .../pages/helpdesk-software-products-index.js | 81 ++++++++++ 23 files changed, 1209 insertions(+), 5 deletions(-) create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Handler/SoftwareProductSyncJobHandler.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductSyncService.php create mode 100644 modules/helpdesk/migrations/003_create_software_products.sql create mode 100644 modules/helpdesk/pages/helpdesk/software-products-data().php create mode 100644 modules/helpdesk/pages/helpdesk/software-products/_form.phtml create mode 100644 modules/helpdesk/pages/helpdesk/software-products/edit($code).php create mode 100644 modules/helpdesk/pages/helpdesk/software-products/edit(default).phtml create mode 100644 modules/helpdesk/pages/helpdesk/software-products/filter-schema.php create mode 100644 modules/helpdesk/pages/helpdesk/software-products/index().php create mode 100644 modules/helpdesk/pages/helpdesk/software-products/index(default).phtml create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductSyncServiceTest.php create mode 100644 modules/helpdesk/web/js/pages/helpdesk-software-products-index.js diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 5400c1e..8b24595 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -356,5 +356,24 @@ "Lookup": "Nachschlagen", "Monitoring": "Monitoring", "Contract type": "Vertragsart", - "Customer": "Kunde" + "Customer": "Kunde", + "Software products": "Software-Produkte", + "Software product": "Software-Produkt", + "Search software products...": "Software-Produkte suchen...", + "BC description": "BC-Bezeichnung", + "Product name": "Produktname", + "Code": "Code", + "Active": "Aktiv", + "Inactive": "Inaktiv", + "Last synced": "Zuletzt synchronisiert", + "Modified": "Geändert", + "Edit software product": "Software-Produkt bearbeiten", + "Back to list": "Zurück zur Liste", + "Save & close": "Speichern & schließen", + "Software product updated": "Software-Produkt aktualisiert", + "Software product not found": "Software-Produkt nicht gefunden", + "Product details": "Produktdetails", + "Enter product name...": "Produktname eingeben...", + "Name must not exceed 255 characters": "Name darf maximal 255 Zeichen lang sein", + "Failed to save": "Speichern fehlgeschlagen" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index c32a78b..8ce3ede 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -356,5 +356,24 @@ "Lookup": "Lookup", "Monitoring": "Monitoring", "Contract type": "Contract type", - "Customer": "Customer" + "Customer": "Customer", + "Software products": "Software products", + "Software product": "Software product", + "Search software products...": "Search software products...", + "BC description": "BC description", + "Product name": "Product name", + "Code": "Code", + "Active": "Active", + "Inactive": "Inactive", + "Last synced": "Last synced", + "Modified": "Modified", + "Edit software product": "Edit software product", + "Back to list": "Back to list", + "Save & close": "Save & close", + "Software product updated": "Software product updated", + "Software product not found": "Software product not found", + "Product details": "Product details", + "Enter product name...": "Enter product name...", + "Name must not exceed 255 characters": "Name must not exceed 255 characters", + "Failed to save": "Failed to save" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Handler/SoftwareProductSyncJobHandler.php b/modules/helpdesk/lib/Module/Helpdesk/Handler/SoftwareProductSyncJobHandler.php new file mode 100644 index 0000000..f963889 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Handler/SoftwareProductSyncJobHandler.php @@ -0,0 +1,35 @@ + '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(); + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php index ec1c466..8d2818d 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php @@ -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, }; diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php index e74126b..70028f9 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php @@ -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) + )); } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php index 54a4572..4cec725 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php @@ -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, ]]; } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php new file mode 100644 index 0000000..9064259 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php @@ -0,0 +1,146 @@ +normalizeRow($row); + } + + /** + * @return array{total: int, rows: list>} + */ + 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 $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; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index d54e89b..23ce16e 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -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 + */ + 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> $rows + * @return array + */ + 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. * diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php new file mode 100644 index 0000000..80e8053 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php @@ -0,0 +1,65 @@ +|null + */ + public function findByCode(string $code): ?array + { + return $this->repository->findByCode($code); + } + + /** + * @return array{total: int, rows: list>} + */ + 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, errors: array} + */ + 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' => []]; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductSyncService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductSyncService.php new file mode 100644 index 0000000..96360a8 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductSyncService.php @@ -0,0 +1,73 @@ +} + */ + 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, + ], + ]; + } +} diff --git a/modules/helpdesk/migrations/003_create_software_products.sql b/modules/helpdesk/migrations/003_create_software_products.sql new file mode 100644 index 0000000..45c2e5b --- /dev/null +++ b/modules/helpdesk/migrations/003_create_software_products.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS `helpdesk_software_products` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `code` VARCHAR(30) NOT NULL, + `bc_description` VARCHAR(255) NOT NULL DEFAULT '', + `name` VARCHAR(255) NOT NULL DEFAULT '', + `active` TINYINT(1) NOT NULL DEFAULT 1, + `synced_at` DATETIME NULL DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_hsp_code` (`code`), + KEY `idx_hsp_active` (`active`), + KEY `idx_hsp_synced_at` (`synced_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index 31c07da..839b33e 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -39,6 +39,9 @@ return [ ['path' => 'helpdesk/team-workload-data', 'target' => 'helpdesk/team-workload-data'], ['path' => 'helpdesk/risk-radar', 'target' => 'helpdesk/risk-radar'], ['path' => 'helpdesk/risk-radar-data', 'target' => 'helpdesk/risk-radar-data'], + ['path' => 'helpdesk/software-products', 'target' => 'helpdesk/software-products'], + ['path' => 'helpdesk/software-products-data', 'target' => 'helpdesk/software-products-data'], + ['path' => 'helpdesk/software-products/edit/{code}', 'target' => 'helpdesk/software-products/edit'], ], 'public_paths' => [], @@ -92,6 +95,12 @@ return [ 'active' => true, 'is_system' => true, ], + [ + 'key' => 'helpdesk.software-products.manage', + 'description' => 'Manage helpdesk software products (view and edit product names)', + 'active' => true, + 'is_system' => true, + ], ], 'search_resources' => [], @@ -100,7 +109,22 @@ return [ 'modules/helpdesk/css/helpdesk.css', ], ], - 'scheduler_jobs' => [], + 'scheduler_jobs' => [ + [ + 'job_key' => 'helpdesk_software_product_sync', + 'handler' => \MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler::class, + 'label' => 'Software product sync', + 'description' => 'Syncs contract types with Create_SaaS_License from Business Central to local database', + 'default_enabled' => 1, + 'default_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'], + ], + ], 'layout_context_providers' => [ \MintyPHP\Module\Helpdesk\Providers\HelpdeskLayoutProvider::class, ], diff --git a/modules/helpdesk/pages/helpdesk/software-products-data().php b/modules/helpdesk/pages/helpdesk/software-products-data().php new file mode 100644 index 0000000..5865e46 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/software-products-data().php @@ -0,0 +1,38 @@ +listPaged($filters); + +$rows = $result['rows'] ?? []; +$total = $result['total'] ?? 0; + +$editBaseUrl = lurl('helpdesk/software-products/edit/'); + +$preparedRows = []; +foreach ($rows as $row) { + $code = (string) ($row['code'] ?? ''); + $active = (int) ($row['active'] ?? 1); + + $preparedRows[] = [ + 'code' => $code, + 'bc_description' => (string) ($row['bc_description'] ?? ''), + 'name' => (string) ($row['name'] ?? ''), + 'active' => $active, + 'active_label' => $active ? t('Active') : t('Inactive'), + 'active_variant' => $active ? 'success' : 'neutral', + 'synced_at' => (string) ($row['synced_at'] ?? ''), + 'edit_url' => $code !== '' ? $editBaseUrl . rawurlencode($code) : '', + ]; +} + +gridJsonDataResult($preparedRows, $total); diff --git a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml new file mode 100644 index 0000000..b1238a6 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml @@ -0,0 +1,49 @@ + +
+
+
+ +
+ +
+
+ + + + + + + + + + +
+
+
+ + +
diff --git a/modules/helpdesk/pages/helpdesk/software-products/edit($code).php b/modules/helpdesk/pages/helpdesk/software-products/edit($code).php new file mode 100644 index 0000000..9457e1b --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/software-products/edit($code).php @@ -0,0 +1,69 @@ +findByCode($productCode); + +if ($product === null) { + Flash::error(t('Software product not found'), $closeTarget, 'software_product_not_found'); + Router::redirect($closeTarget); + return; +} + +$errorBag = formErrors(); +$warnings = []; +$form = $product; + +if ($request->isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired'); + Router::redirect($editTarget); + return; +} + +if ($request->isMethod('POST')) { + $name = (string) $request->body('name', ''); + $result = $service->updateNameByCode($productCode, $name); + $form = $result['form'] ?? $form; + $errorBag->merge($result['errors'] ?? []); + + if (($result['ok'] ?? false) && !$errorBag->hasAny()) { + $action = (string) $request->body('action', 'save'); + if ($action === 'save_close') { + Flash::success(t('Software product updated'), $closeTarget, 'software_product_updated'); + Router::redirect($closeTarget); + } else { + Flash::success(t('Software product updated'), $editTarget, 'software_product_updated'); + Router::redirect($editTarget); + } + return; + } +} + +$validationSummaryErrors = $errorBag->toArray(); +$errors = $errorBag->toFlatList(); +$titleText = t('Edit software product'); +Buffer::set('title', $titleText); +Buffer::set('style_groups', json_encode(['helpdesk'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk'], + ['label' => t('Software products'), 'path' => 'helpdesk/software-products'], + ['label' => $titleText], +]; diff --git a/modules/helpdesk/pages/helpdesk/software-products/edit(default).phtml b/modules/helpdesk/pages/helpdesk/software-products/edit(default).phtml new file mode 100644 index 0000000..fa6881d --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/software-products/edit(default).phtml @@ -0,0 +1,92 @@ + +
+
+ + + $titleText ?? t('Edit software product'), + 'backHref' => $closeTarget, + 'backTitle' => t('Back to list'), + 'actions' => [ + [ + 'form' => $formId, + 'name' => 'action', + 'value' => 'save', + 'class' => 'secondary outline', + 'label' => t('Save'), + ], + [ + 'form' => $formId, + 'name' => 'action', + 'value' => 'save_close', + 'class' => 'primary', + 'label' => t('Save & close'), + ], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + + + + +
+
+
    + +
  • + +
+
+
+ + + + + +
+ + +
diff --git a/modules/helpdesk/pages/helpdesk/software-products/filter-schema.php b/modules/helpdesk/pages/helpdesk/software-products/filter-schema.php new file mode 100644 index 0000000..df28d59 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/software-products/filter-schema.php @@ -0,0 +1,41 @@ + [ + 'search' => ['type' => 'string'], + 'active' => ['type' => 'string', 'default' => 'all'], + 'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 100], + 'offset' => ['type' => 'int', 'default' => 0, 'min' => 0], + 'order' => [ + 'type' => 'order', + 'allowed' => ['code', 'bc_description', 'name', 'active', 'synced_at'], + 'default' => 'code', + ], + 'dir' => ['type' => 'dir', 'default' => 'asc'], + ], + 'toolbar' => [ + [ + 'key' => 'search', + 'type' => 'text', + 'label' => 'Search', + 'placeholder' => 'Search software products...', + 'input_id' => 'helpdesk-software-products-search-input', + 'search' => true, + 'query' => ['type' => 'string'], + ], + [ + 'key' => 'active', + 'type' => 'select', + 'label' => 'Status', + 'input_id' => 'helpdesk-software-products-active-filter', + 'default' => 'all', + 'normalize' => 'all_to_empty', + 'allowed' => [ + ['id' => 'all', 'description' => 'All'], + ['id' => '1', 'description' => 'Active'], + ['id' => '0', 'description' => 'Inactive'], + ], + 'label_attributes' => ['data-filter-optional' => true], + ], + ], +]); diff --git a/modules/helpdesk/pages/helpdesk/software-products/index().php b/modules/helpdesk/pages/helpdesk/software-products/index().php new file mode 100644 index 0000000..bb93875 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/software-products/index().php @@ -0,0 +1,48 @@ +queryAll(); + +$filterSchema = require __DIR__ . '/filter-schema.php'; +$listFilterContext = gridBuildListFilterContext($filterSchema, [ + 'query' => $query, + 'search_keys' => ['search'], +]); +$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema']; +$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema']; +$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema']; +$toolbarFilterState = $listFilterContext['toolbarFilterState']; +$clientFilterSchema = $listFilterContext['clientFilterSchema']; +$searchConfig = $listFilterContext['searchConfig']; +$schemaByKey = $listFilterContext['schemaByKey']; +$filterChipMeta = [ + 'search' => [ + 'label' => t('Search'), + 'type' => 'text', + ], + 'active' => [ + 'label' => t('Status'), + 'type' => 'select', + 'default' => (string) ($schemaByKey['active']['default'] ?? 'all'), + 'options' => gridOptionMapFromAllowed((array) ($schemaByKey['active'] ?? [])), + ], +]; + +$settingsGateway = app(HelpdeskSettingsGateway::class); +$isConfigured = $settingsGateway->isConfigured(); + +Buffer::set('title', t('Software products')); +Buffer::set('style_groups', json_encode(['helpdesk'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk'], + ['label' => t('Software products')], +]; diff --git a/modules/helpdesk/pages/helpdesk/software-products/index(default).phtml b/modules/helpdesk/pages/helpdesk/software-products/index(default).phtml new file mode 100644 index 0000000..f4f37ce --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/software-products/index(default).phtml @@ -0,0 +1,50 @@ + + + + + + + + + +
+
+
+ + + + diff --git a/modules/helpdesk/templates/aside-helpdesk-panel.phtml b/modules/helpdesk/templates/aside-helpdesk-panel.phtml index 056405e..9d76ad9 100644 --- a/modules/helpdesk/templates/aside-helpdesk-panel.phtml +++ b/modules/helpdesk/templates/aside-helpdesk-panel.phtml @@ -5,13 +5,15 @@ $helpdeskNav = is_array($layoutNav['helpdesk.nav'] ?? null) ? $layoutNav['helpde $canManageSettings = !empty($helpdeskNav['can_manage_settings']); $canViewTeam = !empty($helpdeskNav['can_view_team']); $canViewRiskRadar = !empty($helpdeskNav['can_view_risk_radar']); +$canManageSoftwareProducts = !empty($helpdeskNav['can_manage_software_products']); $settingsActive = navActive('helpdesk/settings', true); $teamActive = navActive('helpdesk/team', true); $riskRadarActive = navActive('helpdesk/risk-radar', true); $domainsActive = navActive('helpdesk/domains', true); +$softwareProductsActive = navActive('helpdesk/software-products', true); $customersActive = navActive('helpdesk', true); -if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive']) { +if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive']) { $customersActive = ['class' => '', 'aria' => '', 'isActive' => false]; } @@ -59,6 +61,12 @@ $helpdeskNavGroups = [ 'label' => t('Administration'), 'icon' => 'bi-sliders', 'items' => [ + [ + 'label' => t('Software products'), + 'path' => 'helpdesk/software-products', + 'active' => $softwareProductsActive, + 'visible' => $canManageSoftwareProducts, + ], [ 'label' => t('Settings'), 'path' => 'helpdesk/settings', diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php new file mode 100644 index 0000000..125d997 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php @@ -0,0 +1,112 @@ +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']); + } +} diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductSyncServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductSyncServiceTest.php new file mode 100644 index 0000000..48b1ba4 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductSyncServiceTest.php @@ -0,0 +1,126 @@ +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']); + } +} diff --git a/modules/helpdesk/web/js/pages/helpdesk-software-products-index.js b/modules/helpdesk/web/js/pages/helpdesk-software-products-index.js new file mode 100644 index 0000000..e9885e7 --- /dev/null +++ b/modules/helpdesk/web/js/pages/helpdesk-software-products-index.js @@ -0,0 +1,81 @@ +import { initStandardListPage } from '/js/core/app-grid-factory.js'; +import { readPageConfig } from '/js/core/app-page-config.js'; +import { warnOnce } from '/js/core/app-dom.js'; +import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js'; + +const config = readPageConfig('helpdesk-software-products'); +if (config) { + const gridjs = window.gridjs; + if (!gridjs) { + warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed: Grid.js missing', { module: 'helpdesk-software-products', component: 'grid' }); + } else { + const appBase = getAppBase(); + const labels = config.labels || {}; + + const editUrlIndex = 4; + + const gridOptions = { + gridjs, + container: '#helpdesk-software-products-grid', + dataUrl: config.dataUrl || 'helpdesk/software-products-data', + appBase, + columns: [ + { + name: labels.code || 'Code', + sort: true, + formatter: (cell) => { + const code = escapeHtml(cell?.code || ''); + const editUrl = String(cell?.url || '').trim(); + if (editUrl === '') { + return gridjs.html(code); + } + const href = escapeHtml(withCurrentListReturn(editUrl)); + return gridjs.html(`${code}`); + }, + }, + { name: labels.bcDescription || 'BC Description', sort: true }, + { name: labels.name || 'Product Name', sort: true }, + { + name: labels.status || 'Status', + sort: true, + formatter: (cell) => { + const label = escapeHtml(cell?.label || ''); + const variant = cell?.variant || 'neutral'; + return gridjs.html(`${label}`); + }, + }, + { name: 'edit_url', hidden: true }, + ], + sortColumns: ['code', 'bc_description', 'name', 'active', null], + paginationLimit: 20, + language: config.gridLang || {}, + mapData: (data) => (data.data || []).map((row) => [ + { code: row.code || '', url: row.edit_url || '' }, + row.bc_description || '', + row.name || '', + { label: row.active_label || '', variant: row.active_variant || 'neutral' }, + row.edit_url || '', + ]), + search: config.gridSearch || null, + filterSchema: config.filterSchema || [], + urlSync: true, + rowInteraction: { linkColumn: 0 }, + rowDblClick: { + getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '', + }, + }; + + const { gridConfig } = initStandardListPage({ + grid: gridOptions, + filters: { + mode: 'drawer', + chipMeta: config.filterChipMeta || {}, + watchInputs: ['#helpdesk-software-products-search-input'], + }, + }); + + if (!gridConfig || !gridConfig.grid) { + warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed', { module: 'helpdesk-software-products', component: 'grid' }); + } + } +}