1
0
Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php
aminovfariz c32a0f8c31 feat(helpdesk): Übergabe assignment workflow
- New statuses: under_review
- New DB fields: assigned_to, assigned_by, assigned_at, due_date (migration 012)
- HandoverNotificationService: emails on assign and review-request
- HandoverService: assign(), submitForReview(), resendNotification()
- Assign page: manager selects employee + due date, resend notification
- Create wizard: manager can assign during creation (step 1)
- Edit page: "Submit for review" button for assignee, assign link for manager
- List page: open/closed tabs, non-managers see only their assigned handovers
- Email templates: handover_assigned + handover_review_requested (de/en)
- i18n keys added for de and en

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:35:03 +02:00

507 lines
17 KiB
PHP

<?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 Submitted by assignee, awaiting manager review */
public const STATUS_UNDER_REVIEW = 'under_review';
/** @api Used in validation */
public const ALL_STATUSES = [
self::STATUS_DRAFT,
self::STATUS_IN_PROGRESS,
self::STATUS_UNDER_REVIEW,
self::STATUS_COMPLETED,
self::STATUS_ARCHIVED,
];
public const PERMISSION_CREATE = 'create';
public const PERMISSION_MANAGE = 'manage';
/**
* Status transitions allowed per permission level.
* create-level: draft → in_progress → under_review.
* manage-level: any status.
*/
private const ALLOWED_STATUS_BY_PERMISSION = [
self::PERMISSION_CREATE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_UNDER_REVIEW],
self::PERMISSION_MANAGE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_UNDER_REVIEW, 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,
private readonly ?HandoverRevisionService $revisionService = null,
private readonly ?HandoverNotificationService $notificationService = null,
) {
}
/**
* Create a new handover with a schema snapshot from the software product.
*
* @return array{ok: bool, id: int|null, errors: array<string, string>}
*/
/**
* @param array<string, mixed> $fieldValues
*/
public function create(
int $tenantId,
string $debitorNo,
string $debitorName,
string $productCode,
string $domainNo,
string $domainUrl,
int $userId,
array $fieldValues = [],
): 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');
}
$domainNo = trim($domainNo);
if ($domainNo === '') {
$errors['domain_no'] = t('Please select a domain');
}
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')]];
}
// Validate field values against schema if provided
if ($fieldValues !== []) {
$validationErrors = $this->validateFieldValues($fieldValues, $schema['fields']);
if ($validationErrors !== []) {
return ['ok' => false, 'id' => null, 'errors' => $validationErrors];
}
}
$fieldValuesJson = $fieldValues !== []
? json_encode($fieldValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
: json_encode(new \stdClass(), JSON_FORCE_OBJECT);
$id = $this->repository->insert([
'tenant_id' => $tenantId,
'debitor_no' => $debitorNo,
'debitor_name' => trim($debitorName),
'product_code' => $productCode,
'domain_no' => $domainNo,
'domain_url' => trim($domainUrl),
'status' => self::STATUS_DRAFT,
'schema_snapshot' => $schemaRaw,
'field_values' => $fieldValuesJson,
'created_by' => $userId,
]);
if ($id === null) {
return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to create handover')]];
}
// Create initial revision (revision 1)
$this->revisionService?->createRevision(
$tenantId,
$id,
$fieldValues,
self::STATUS_DRAFT,
(int) ($schema['version'] ?? 1),
$userId,
HandoverRevisionService::CHANGE_TYPE_INITIAL
);
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' => []];
}
/**
* Create a revision snapshot after a combined save (fields + optional status change).
* Call this from the action after both updateFields and changeStatus have succeeded.
*
* @param array<string, mixed> $fieldValues The final field values
*/
public function createRevisionAfterSave(
int $tenantId,
int $handoverId,
array $fieldValues,
string $status,
bool $fieldsChanged,
bool $statusChanged,
int $schemaVersion,
int $userId,
): void {
if ($this->revisionService === null) {
return;
}
$changeType = HandoverRevisionService::CHANGE_TYPE_FIELDS;
if ($fieldsChanged && $statusChanged) {
$changeType = HandoverRevisionService::CHANGE_TYPE_BOTH;
} elseif ($statusChanged) {
$changeType = HandoverRevisionService::CHANGE_TYPE_STATUS;
}
$this->revisionService->createRevision(
$tenantId,
$handoverId,
$fieldValues,
$status,
$schemaVersion,
$userId,
$changeType
);
}
/**
* Assign a handover to a user and optionally set a due date.
* Sends notification email to the assignee.
*
* @return array{ok: bool, errors: array<string, string>}
*/
public function assign(
int $tenantId,
int $id,
int $assignedTo,
int $assignedBy,
?string $dueDate,
string $permissionLevel,
): array {
if ($permissionLevel !== self::PERMISSION_MANAGE) {
return ['ok' => false, 'errors' => ['general' => t('Only managers can assign handovers')]];
}
$handover = $this->repository->findById($tenantId, $id);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
if ($assignedTo <= 0) {
return ['ok' => false, 'errors' => ['assigned_to' => t('Please select an employee')]];
}
$updated = $this->repository->assign($tenantId, $id, $assignedTo, $assignedBy, $dueDate, $assignedBy);
if (!$updated) {
return ['ok' => false, 'errors' => ['general' => t('Failed to assign handover')]];
}
// Transition to in_progress if still draft
if ($handover['status'] === self::STATUS_DRAFT) {
$this->repository->updateStatus($tenantId, $id, self::STATUS_IN_PROGRESS, $assignedBy);
}
$updatedHandover = $this->repository->findById($tenantId, $id);
$this->notificationService?->notifyAssigned($updatedHandover ?? $handover);
return ['ok' => true, 'errors' => []];
}
/**
* Submit handover for review (assignee action).
* Changes status to under_review and notifies managers.
*
* @return array{ok: bool, errors: array<string, string>}
*/
public function submitForReview(
int $tenantId,
int $id,
int $userId,
string $permissionLevel,
): array {
$handover = $this->repository->findById($tenantId, $id);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
// Only the assignee or a manager may submit for review
$assignedTo = (int) ($handover['assigned_to'] ?? 0);
$isAssignee = $assignedTo === $userId;
$isManager = $permissionLevel === self::PERMISSION_MANAGE;
if (!$isAssignee && !$isManager) {
return ['ok' => false, 'errors' => ['general' => t('You are not assigned to this handover')]];
}
$currentStatus = (string) ($handover['status'] ?? '');
if (!in_array($currentStatus, [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS], true)) {
return ['ok' => false, 'errors' => ['status' => t('Handover cannot be submitted for review in its current status')]];
}
$updated = $this->repository->updateStatus($tenantId, $id, self::STATUS_UNDER_REVIEW, $userId);
if (!$updated) {
return ['ok' => false, 'errors' => ['general' => t('Failed to update status')]];
}
$this->revisionService?->createRevision(
$tenantId, $id, [], self::STATUS_UNDER_REVIEW, 1, $userId, HandoverRevisionService::CHANGE_TYPE_STATUS
);
$updatedHandover = $this->repository->findById($tenantId, $id);
$this->notificationService?->notifyReviewRequested($updatedHandover ?? $handover);
return ['ok' => true, 'errors' => []];
}
/**
* Resend assignment notification email.
*
* @return array{ok: bool, errors: array<string, string>}
*/
public function resendNotification(int $tenantId, int $id, string $permissionLevel): array
{
if ($permissionLevel !== self::PERMISSION_MANAGE) {
return ['ok' => false, 'errors' => ['general' => t('Only managers can resend notifications')]];
}
$handover = $this->repository->findById($tenantId, $id);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
if (empty($handover['assigned_to'])) {
return ['ok' => false, 'errors' => ['general' => t('Handover has no assignee')]];
}
$this->notificationService?->notifyAssigned($handover);
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);
}
/**
* @param list<int> $ids
* @return array{ok: bool, count: int, error?: string}
*/
public function deleteByIds(int $tenantId, array $ids, string $permissionLevel): array
{
if ($permissionLevel !== self::PERMISSION_MANAGE) {
return ['ok' => false, 'count' => 0, 'error' => t('Only managers can delete handovers')];
}
if ($ids === []) {
return ['ok' => false, 'count' => 0, 'error' => t('No handovers selected')];
}
$count = $this->repository->deleteByIds($tenantId, $ids);
return ['ok' => true, 'count' => $count];
}
/**
* 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_UNDER_REVIEW => t('Under review'),
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_UNDER_REVIEW => 'info',
self::STATUS_COMPLETED => 'success',
self::STATUS_ARCHIVED => 'neutral',
default => 'neutral',
};
}
}