Avoids the separate updateFields call after insert by accepting fieldValues in HandoverService::create() and validating/encoding them before the insert. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
309 lines
10 KiB
PHP
309 lines
10 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 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>}
|
|
*/
|
|
/**
|
|
* @param array<string, mixed> $fieldValues
|
|
*/
|
|
public function create(
|
|
int $tenantId,
|
|
string $debitorNo,
|
|
string $debitorName,
|
|
string $productCode,
|
|
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');
|
|
}
|
|
|
|
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,
|
|
'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')]];
|
|
}
|
|
|
|
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',
|
|
};
|
|
}
|
|
}
|