feat(helpdesk): add handover protocol management
Implement handover protocols as a new entity in the helpdesk module, allowing users to create fillable protocol records from admin-defined software product schemas. Key additions: - DB migration (helpdesk_handovers table) with tenant scope - HandoverService with status workflow (draft/in_progress/completed/archived) - Three-tier permissions (view/create/manage) - Two-step creation wizard (Stripe-style assistant) - Grid.js list page with search and status filter - Edit/detail page with aside metadata and status controls - Reusable core autocomplete lookup component (app-lookup-field) - Debitor lookup data endpoint for autocomplete - Dynamic form rendering from schema snapshots - 11 PHPUnit tests for HandoverService - DE+EN i18n translations (48 keys each) Also includes: PHPStan fixes (dead code removal, stale baseline cleanup), software product edit title improvement, fieldset simplification, and Stripe-style hover for schema preview. Workflow: HD-HANDOVERS-001 (.agents/runs/HD-HANDOVERS-001/) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,9 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
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 ABILITY_HANDOVERS_VIEW = 'helpdesk.handovers.view';
|
||||
public const ABILITY_HANDOVERS_CREATE = 'helpdesk.handovers.create';
|
||||
public const ABILITY_HANDOVERS_MANAGE = 'helpdesk.handovers.manage';
|
||||
|
||||
public const PERMISSION_ACCESS = 'helpdesk.access';
|
||||
public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
@@ -20,6 +23,12 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
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';
|
||||
/** @api Used in authorize() match for ability resolution */
|
||||
public const PERMISSION_HANDOVERS_VIEW = 'helpdesk.handovers.view';
|
||||
/** @api Used in authorize() match for ability resolution */
|
||||
public const PERMISSION_HANDOVERS_CREATE = 'helpdesk.handovers.create';
|
||||
/** @api Used in authorize() match for ability resolution */
|
||||
public const PERMISSION_HANDOVERS_MANAGE = 'helpdesk.handovers.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService
|
||||
@@ -28,7 +37,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, self::ABILITY_SOFTWARE_PRODUCTS_MANAGE], 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, self::ABILITY_HANDOVERS_VIEW, self::ABILITY_HANDOVERS_CREATE, self::ABILITY_HANDOVERS_MANAGE], true);
|
||||
}
|
||||
|
||||
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||
@@ -44,6 +53,9 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
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,
|
||||
self::ABILITY_HANDOVERS_VIEW => self::PERMISSION_HANDOVERS_VIEW,
|
||||
self::ABILITY_HANDOVERS_CREATE => self::PERMISSION_HANDOVERS_CREATE,
|
||||
self::ABILITY_HANDOVERS_MANAGE => self::PERMISSION_HANDOVERS_MANAGE,
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ 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\HandoverRepository;
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
@@ -104,5 +106,12 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
|
||||
$container->set(SoftwareProductSyncJobHandler::class, static fn (AppContainer $c): SoftwareProductSyncJobHandler => new SoftwareProductSyncJobHandler(
|
||||
$c->get(SoftwareProductSyncService::class)
|
||||
));
|
||||
|
||||
$container->set(HandoverRepository::class, static fn (): HandoverRepository => new HandoverRepository());
|
||||
|
||||
$container->set(HandoverService::class, static fn (AppContainer $c): HandoverService => new HandoverService(
|
||||
$c->get(HandoverRepository::class),
|
||||
$c->get(SoftwareProductService::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,17 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
|
||||
$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();
|
||||
$canViewHandovers = $authorizationService->authorize('helpdesk.handovers.view', $actorContext)->isAllowed();
|
||||
$canCreateHandovers = $authorizationService->authorize('helpdesk.handovers.create', $actorContext)->isAllowed();
|
||||
$canManageHandovers = $authorizationService->authorize('helpdesk.handovers.manage', $actorContext)->isAllowed();
|
||||
} catch (\Throwable) {
|
||||
$canManageSettings = false;
|
||||
$canViewTeam = false;
|
||||
$canViewRiskRadar = false;
|
||||
$canManageSoftwareProducts = false;
|
||||
$canViewHandovers = false;
|
||||
$canCreateHandovers = false;
|
||||
$canManageHandovers = false;
|
||||
}
|
||||
|
||||
return ['helpdesk.nav' => [
|
||||
@@ -34,6 +40,9 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
|
||||
'can_view_team' => $canViewTeam,
|
||||
'can_view_risk_radar' => $canViewRiskRadar,
|
||||
'can_manage_software_products' => $canManageSoftwareProducts,
|
||||
'can_view_handovers' => $canViewHandovers,
|
||||
'can_create_handovers' => $canCreateHandovers,
|
||||
'can_manage_handovers' => $canManageHandovers,
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
class HandoverRepository
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return int|null Inserted ID or null on failure
|
||||
*/
|
||||
public function insert(array $data): ?int
|
||||
{
|
||||
$result = DB::insert(
|
||||
'INSERT INTO helpdesk_handovers '
|
||||
. '(tenant_id, debitor_no, debitor_name, product_code, status, schema_snapshot, field_values, created_by) '
|
||||
. 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
(string) ($data['tenant_id'] ?? 0),
|
||||
(string) ($data['debitor_no'] ?? ''),
|
||||
(string) ($data['debitor_name'] ?? ''),
|
||||
(string) ($data['product_code'] ?? ''),
|
||||
(string) ($data['status'] ?? 'draft'),
|
||||
$data['schema_snapshot'] ?? null,
|
||||
$data['field_values'] ?? null,
|
||||
(string) ($data['created_by'] ?? 0)
|
||||
);
|
||||
|
||||
if ($result === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
|
||||
public function findById(int $tenantId, int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = DB::selectOne(
|
||||
'SELECT * FROM helpdesk_handovers WHERE id = ? AND tenant_id = ? LIMIT 1',
|
||||
(string) $id,
|
||||
(string) $tenantId
|
||||
);
|
||||
|
||||
return $this->normalizeRow($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array{total: int, rows: list<array<string, mixed>>}
|
||||
*/
|
||||
public function listPaged(int $tenantId, array $filters): array
|
||||
{
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
$status = trim((string) ($filters['status'] ?? ''));
|
||||
$productCode = trim((string) ($filters['product_code'] ?? ''));
|
||||
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder(
|
||||
$filters,
|
||||
['id', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by'],
|
||||
'created_at',
|
||||
'desc'
|
||||
);
|
||||
|
||||
$where = ['h.tenant_id = ?'];
|
||||
$params = [(string) $tenantId];
|
||||
|
||||
RepoQuery::addLikeFilter($where, $params, ['h.debitor_name', 'h.debitor_no', 'h.product_code'], $search);
|
||||
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$where[] = 'h.status = ?';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
if ($productCode !== '') {
|
||||
$where[] = 'h.product_code = ?';
|
||||
$params[] = $productCode;
|
||||
}
|
||||
|
||||
$whereSql = ' WHERE ' . implode(' AND ', $where);
|
||||
$total = (int) (DB::selectValue('SELECT COUNT(*) FROM helpdesk_handovers h' . $whereSql, ...$params) ?? 0);
|
||||
|
||||
$rows = DB::select(
|
||||
'SELECT h.*, u.display_name AS created_by_name FROM helpdesk_handovers h'
|
||||
. ' LEFT JOIN users u ON u.id = h.created_by'
|
||||
. $whereSql
|
||||
. sprintf(' ORDER BY h.`%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 updateFieldValues(int $tenantId, int $id, string $fieldValuesJson, int $updatedBy): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'UPDATE helpdesk_handovers SET field_values = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
|
||||
$fieldValuesJson,
|
||||
(string) $updatedBy,
|
||||
(string) $id,
|
||||
(string) $tenantId
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function updateStatus(int $tenantId, int $id, string $status, int $updatedBy): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'UPDATE helpdesk_handovers SET status = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
|
||||
$status,
|
||||
(string) $updatedBy,
|
||||
(string) $id,
|
||||
(string) $tenantId
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
private function normalizeRow(mixed $row): ?array
|
||||
{
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
$item = $row['helpdesk_handovers'] ?? $row;
|
||||
if (!is_array($item) || !isset($item['id'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,34 @@ class SoftwareProductRepository
|
||||
return is_int($result) ? $result : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* List active products that have a handover protocol schema defined.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listWithSchema(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'SELECT code, bc_description, name FROM helpdesk_software_products '
|
||||
. 'WHERE active = 1 AND handover_protocol_schema IS NOT NULL '
|
||||
. 'ORDER BY COALESCE(NULLIF(name, \'\'), bc_description) ASC'
|
||||
);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$item = $row['helpdesk_software_products'] ?? $row;
|
||||
if (is_array($item) && isset($item['code'])) {
|
||||
$result[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function normalizeRow(mixed $row): ?array
|
||||
{
|
||||
if (!is_array($row)) {
|
||||
|
||||
@@ -22,7 +22,6 @@ class BcODataGateway
|
||||
public const ENTITY_TICKET_LOG_LV = 'PBI_LV_SupportTicketLog';
|
||||
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';
|
||||
|
||||
@@ -828,57 +827,6 @@ class BcODataGateway
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contract domains.
|
||||
*
|
||||
* Fetches all domains from the FS_Contract_Domains entity.
|
||||
* Filtering, sorting and pagination are handled PHP-side by the caller.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listDomains(): array
|
||||
{
|
||||
$select = 'No,Customer_No,Customer_Name,URL,State,Administration';
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS)
|
||||
. '?$top=5000'
|
||||
. '&$select=' . rawurlencode($select)
|
||||
. '&$orderby=' . rawurlencode('Customer_Name asc');
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contract lines of type "Domain".
|
||||
*
|
||||
* Returns contract line metadata for domain-type lines,
|
||||
* keyed by DNS number (No field). Used to enrich the domain
|
||||
* list with contract type information (PI_Header_Type).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listDomainContractLines(): array
|
||||
{
|
||||
$filter = "Type eq 'Domain'";
|
||||
$select = 'No,Header_No,PI_Header_Type,PI_Header_Description,PI_Header_State,Line_State';
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_LINES)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=5000'
|
||||
. '&$select=' . rawurlencode($select)
|
||||
. '&$orderby=' . rawurlencode('No asc');
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contract types where Create_SaaS_License is true.
|
||||
*
|
||||
|
||||
292
modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php
Normal file
292
modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
|
||||
|
||||
/** @api Called from pages/helpdesk/handovers action files */
|
||||
class HandoverService
|
||||
{
|
||||
/** @api Used in pages and templates for status checks */
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
/** @api Used in pages and templates for status checks */
|
||||
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||
/** @api Used in pages and templates for status checks */
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
/** @api Used in pages and templates for status checks */
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
/** @api Used in validation */
|
||||
public const ALL_STATUSES = [
|
||||
self::STATUS_DRAFT,
|
||||
self::STATUS_IN_PROGRESS,
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_ARCHIVED,
|
||||
];
|
||||
|
||||
public const PERMISSION_CREATE = 'create';
|
||||
public const PERMISSION_MANAGE = 'manage';
|
||||
|
||||
/**
|
||||
* Status transitions allowed per permission level.
|
||||
* create-level: can set draft, in_progress, completed.
|
||||
* manage-level: can set any status.
|
||||
*/
|
||||
private const ALLOWED_STATUS_BY_PERMISSION = [
|
||||
self::PERMISSION_CREATE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED],
|
||||
self::PERMISSION_MANAGE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED, self::STATUS_ARCHIVED],
|
||||
];
|
||||
|
||||
/**
|
||||
* Statuses in which a create-level user may edit field values.
|
||||
*/
|
||||
private const EDITABLE_STATUSES_CREATE = [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS];
|
||||
|
||||
public function __construct(
|
||||
private readonly HandoverRepository $repository,
|
||||
private readonly SoftwareProductService $softwareProductService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new handover with a schema snapshot from the software product.
|
||||
*
|
||||
* @return array{ok: bool, id: int|null, errors: array<string, string>}
|
||||
*/
|
||||
public function create(
|
||||
int $tenantId,
|
||||
string $debitorNo,
|
||||
string $debitorName,
|
||||
string $productCode,
|
||||
int $userId,
|
||||
): array {
|
||||
$errors = [];
|
||||
|
||||
$debitorNo = trim($debitorNo);
|
||||
if ($debitorNo === '') {
|
||||
$errors['debitor_no'] = t('Debitor number is required');
|
||||
}
|
||||
|
||||
$productCode = trim($productCode);
|
||||
if ($productCode === '') {
|
||||
$errors['product_code'] = t('Software product is required');
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['ok' => false, 'id' => null, 'errors' => $errors];
|
||||
}
|
||||
|
||||
$product = $this->softwareProductService->findByCode($productCode);
|
||||
if ($product === null) {
|
||||
return ['ok' => false, 'id' => null, 'errors' => ['product_code' => t('Software product not found')]];
|
||||
}
|
||||
|
||||
$schemaRaw = $product['handover_protocol_schema'] ?? null;
|
||||
if ($schemaRaw === null || $schemaRaw === '') {
|
||||
return ['ok' => false, 'id' => null, 'errors' => ['product_code' => t('Software product has no handover protocol schema')]];
|
||||
}
|
||||
|
||||
$schema = json_decode($schemaRaw, true);
|
||||
if (!is_array($schema) || empty($schema['fields'])) {
|
||||
return ['ok' => false, 'id' => null, 'errors' => ['product_code' => t('Software product has no handover protocol schema')]];
|
||||
}
|
||||
|
||||
$id = $this->repository->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'debitor_no' => $debitorNo,
|
||||
'debitor_name' => trim($debitorName),
|
||||
'product_code' => $productCode,
|
||||
'status' => self::STATUS_DRAFT,
|
||||
'schema_snapshot' => $schemaRaw,
|
||||
'field_values' => json_encode(new \stdClass(), JSON_FORCE_OBJECT),
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
if ($id === null) {
|
||||
return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to create handover')]];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'id' => $id, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update field values for a handover.
|
||||
*
|
||||
* @param array<string, mixed> $fieldValues
|
||||
* @return array{ok: bool, errors: array<string, string>}
|
||||
*/
|
||||
public function updateFields(
|
||||
int $tenantId,
|
||||
int $id,
|
||||
array $fieldValues,
|
||||
int $userId,
|
||||
string $permissionLevel,
|
||||
): array {
|
||||
$handover = $this->repository->findById($tenantId, $id);
|
||||
if ($handover === null) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
|
||||
}
|
||||
|
||||
if (!$this->canEdit($handover['status'], $permissionLevel)) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('You do not have permission to edit this handover')]];
|
||||
}
|
||||
|
||||
$schema = json_decode((string) ($handover['schema_snapshot'] ?? '{}'), true);
|
||||
$schemaFields = is_array($schema) ? ($schema['fields'] ?? []) : [];
|
||||
|
||||
$validationErrors = $this->validateFieldValues($fieldValues, $schemaFields);
|
||||
if ($validationErrors !== []) {
|
||||
return ['ok' => false, 'errors' => $validationErrors];
|
||||
}
|
||||
|
||||
$json = json_encode($fieldValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$updated = $this->repository->updateFieldValues($tenantId, $id, $json, $userId);
|
||||
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Failed to save')]];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the status of a handover.
|
||||
*
|
||||
* @return array{ok: bool, errors: array<string, string>}
|
||||
*/
|
||||
public function changeStatus(
|
||||
int $tenantId,
|
||||
int $id,
|
||||
string $newStatus,
|
||||
int $userId,
|
||||
string $permissionLevel,
|
||||
): array {
|
||||
if (!in_array($newStatus, self::ALL_STATUSES, true)) {
|
||||
return ['ok' => false, 'errors' => ['status' => t('Invalid status')]];
|
||||
}
|
||||
|
||||
$handover = $this->repository->findById($tenantId, $id);
|
||||
if ($handover === null) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
|
||||
}
|
||||
|
||||
$allowed = self::ALLOWED_STATUS_BY_PERMISSION[$permissionLevel] ?? [];
|
||||
if (!in_array($newStatus, $allowed, true)) {
|
||||
return ['ok' => false, 'errors' => ['status' => t('You do not have permission to set this status')]];
|
||||
}
|
||||
|
||||
$updated = $this->repository->updateStatus($tenantId, $id, $newStatus, $userId);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Failed to update status')]];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, rows: list<array<string, mixed>>}
|
||||
*/
|
||||
public function listPaged(int $tenantId, array $filters): array
|
||||
{
|
||||
return $this->repository->listPaged($tenantId, $filters);
|
||||
}
|
||||
|
||||
public function findById(int $tenantId, int $id): ?array
|
||||
{
|
||||
return $this->repository->findById($tenantId, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user with the given permission level can edit a handover in the given status.
|
||||
*/
|
||||
public function canEdit(string $status, string $permissionLevel): bool
|
||||
{
|
||||
if ($permissionLevel === self::PERMISSION_MANAGE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($permissionLevel === self::PERMISSION_CREATE) {
|
||||
return in_array($status, self::EDITABLE_STATUSES_CREATE, true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of statuses a user with the given permission level can set.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getAllowedStatuses(string $permissionLevel): array
|
||||
{
|
||||
return self::ALLOWED_STATUS_BY_PERMISSION[$permissionLevel] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate field values against the schema snapshot.
|
||||
*
|
||||
* @param array<string, mixed> $fieldValues
|
||||
* @param list<array<string, mixed>> $schemaFields
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function validateFieldValues(array $fieldValues, array $schemaFields): array
|
||||
{
|
||||
$errors = [];
|
||||
$allowedKeys = [];
|
||||
$requiredKeys = [];
|
||||
|
||||
foreach ($schemaFields as $field) {
|
||||
$key = $field['key'] ?? null;
|
||||
if ($key === null) {
|
||||
continue;
|
||||
}
|
||||
$allowedKeys[$key] = true;
|
||||
if (!empty($field['required'])) {
|
||||
$requiredKeys[$key] = $field['label'] ?? $key;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($fieldValues as $key => $value) {
|
||||
if (!isset($allowedKeys[$key])) {
|
||||
$errors[$key] = t('Unknown field: %s', $key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredKeys as $key => $label) {
|
||||
$value = $fieldValues[$key] ?? '';
|
||||
if (is_string($value) && trim($value) === '') {
|
||||
$errors[$key] = t('%s is required', $label);
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the human-readable label for a status.
|
||||
*/
|
||||
public static function statusLabel(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
self::STATUS_DRAFT => t('Draft'),
|
||||
self::STATUS_IN_PROGRESS => t('In progress'),
|
||||
self::STATUS_COMPLETED => t('Completed'),
|
||||
self::STATUS_ARCHIVED => t('Archived'),
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the badge variant for a status.
|
||||
*/
|
||||
public static function statusVariant(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
self::STATUS_DRAFT => 'neutral',
|
||||
self::STATUS_IN_PROGRESS => 'warning',
|
||||
self::STATUS_COMPLETED => 'success',
|
||||
self::STATUS_ARCHIVED => 'neutral',
|
||||
default => 'neutral',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,16 @@ class SoftwareProductService
|
||||
return $this->repository->listPaged($filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* List active products that have a handover protocol schema defined.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listWithSchema(): array
|
||||
{
|
||||
return $this->repository->listWithSchema();
|
||||
}
|
||||
|
||||
private const ALLOWED_FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select'];
|
||||
private const DISPLAY_ONLY_TYPES = ['heading', 'paragraph'];
|
||||
private const MAX_SCHEMA_FIELDS = 50;
|
||||
|
||||
Reference in New Issue
Block a user