[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} */ /** * @param array $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 $fieldValues * @return array{ok: bool, errors: array} */ 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} */ 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 $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} */ 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} */ 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} */ 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>} */ 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 $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 */ public function getAllowedStatuses(string $permissionLevel): array { return self::ALLOWED_STATUS_BY_PERMISSION[$permissionLevel] ?? []; } /** * Validate field values against the schema snapshot. * * @param array $fieldValues * @param list> $schemaFields * @return array */ 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', }; } }