Files
awo-hamburg-intranet/module/tasks/service/AssignmentResolverService.php
Moritz Weinmann e2837b3288 Adapt main_contact_link queries to use main_contact_department
The new tasks and knowledgecenter_update modules were built against a
main_contact_link table that does not exist in this system. All 7 active
query sites now target main_contact_department (same column names,
active_d → active).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 10:22:20 +02:00

774 lines
26 KiB
PHP

<?php
declare(strict_types=1);
class AssignmentResolverService
{
private TaskDefinitionRepository $definitionRepo;
private TaskRuleRepository $ruleRepo;
private TaskAssignmentRepository $assignmentRepo;
private TaskSubmissionRepository $submissionRepo;
private TaskCertificateRepository $certificateRepo;
private CycleCalculatorService $cycleCalculator;
private TaskChangeLogRepository $changeLogRepo;
public function __construct(
TaskDefinitionRepository $definitionRepo,
TaskRuleRepository $ruleRepo,
TaskAssignmentRepository $assignmentRepo,
TaskSubmissionRepository $submissionRepo,
TaskCertificateRepository $certificateRepo,
CycleCalculatorService $cycleCalculator,
TaskChangeLogRepository $changeLogRepo
) {
$this->definitionRepo = $definitionRepo;
$this->ruleRepo = $ruleRepo;
$this->assignmentRepo = $assignmentRepo;
$this->submissionRepo = $submissionRepo;
$this->certificateRepo = $certificateRepo;
$this->cycleCalculator = $cycleCalculator;
$this->changeLogRepo = $changeLogRepo;
}
public function materializeAssignmentsForTask(int $taskId, ?string $cycleKey = null): int
{
$task = $this->definitionRepo->findById($taskId);
if ($task === null || (int) $task['active'] !== 1) {
return 0;
}
if ($this->isCompletionBasedRecurringTask($task)) {
return $this->materializeCompletionBasedAssignmentsForTask($task, new DateTimeImmutable());
}
$cycle = $this->cycleCalculator->resolveCurrentCycle($task, new DateTimeImmutable());
if ($cycle === []) {
return 0;
}
if ($cycleKey !== null && trim($cycleKey) !== '') {
$cycle['cycle_key'] = trim($cycleKey);
}
$resolvedCycleKey = (string) ($cycle['cycle_key'] ?? '');
if ($resolvedCycleKey === '') {
return 0;
}
$dueAt = $this->cycleCalculator->resolveDueAt($task, $cycle);
$cycleStartAt = $this->formatDateTime($cycle['cycle_start_at'] ?? null);
$cycleEndAt = $this->formatDateTime($cycle['cycle_end_at'] ?? null);
$targets = $this->resolveTargets((int) $task['id']);
$graceUntil = $this->calculateGraceUntil((int) $task['id']);
$inserted = 0;
foreach ($targets as $contactId => $source) {
$assignmentId = $this->assignmentRepo->createIfNotExists(
(int) $task['id'],
(int) $contactId,
$resolvedCycleKey,
$source,
$cycleStartAt,
$cycleEndAt,
$dueAt,
$graceUntil
);
if ($assignmentId > 0) {
$inserted++;
}
}
return $inserted;
}
public function materializeAssignmentsForAllActiveTasks(): int
{
$tasks = $this->definitionRepo->listActive();
$total = 0;
foreach ($tasks as $task) {
$total += $this->materializeAssignmentsForTask((int) $task['id']);
}
return $total;
}
public function syncAssignmentsForTask(
int $taskId,
bool $apply,
string $scope = 'current_cycle',
array $auditContext = []
): array
{
if ($scope !== 'current_cycle') {
throw new InvalidArgumentException('Ungültiger Scope. V1 unterstützt nur current_cycle.');
}
$task = $this->definitionRepo->findById($taskId);
if ($task === null || (int) ($task['active'] ?? 0) !== 1) {
return $this->buildSyncStats(
$taskId,
'',
0,
0,
0,
0,
0,
0,
0,
0,
0,
$apply
);
}
if ($this->isCompletionBasedRecurringTask($task)) {
return $this->syncCompletionBasedAssignmentsForTask($task, $apply, $auditContext);
}
$cycle = $this->cycleCalculator->resolveCurrentCycle($task, new DateTimeImmutable());
$cycleKey = trim((string) ($cycle['cycle_key'] ?? ''));
if ($cycleKey === '') {
return $this->buildSyncStats(
$taskId,
'',
0,
0,
0,
0,
0,
0,
0,
0,
0,
$apply
);
}
$dueAt = $this->cycleCalculator->resolveDueAt($task, $cycle);
$cycleStartAt = $this->formatDateTime($cycle['cycle_start_at'] ?? null);
$cycleEndAt = $this->formatDateTime($cycle['cycle_end_at'] ?? null);
$targets = $this->resolveTargets((int) $task['id']);
$graceUntil = $this->calculateGraceUntil($taskId);
$existingAssignments = $this->assignmentRepo->listByTaskAndCycleKey($taskId, $cycleKey);
$existingByContact = [];
foreach ($existingAssignments as $assignment) {
$contactId = (int) ($assignment['main_contact_id'] ?? 0);
if ($contactId <= 0) {
continue;
}
$existingByContact[$contactId] = $assignment;
}
$targetContactIds = array_values(array_unique(array_map('intval', array_keys($targets))));
$targetContactIds = array_values(array_filter($targetContactIds, static function (int $contactId): bool {
return $contactId > 0;
}));
$createContactIds = [];
$keepContactIds = [];
foreach ($targetContactIds as $contactId) {
if (isset($existingByContact[$contactId])) {
$keepContactIds[] = $contactId;
continue;
}
$createContactIds[] = $contactId;
}
$staleAssignments = [];
foreach ($existingByContact as $contactId => $assignment) {
if (isset($targets[$contactId])) {
continue;
}
$staleAssignments[] = $assignment;
}
$staleAssignmentIds = $this->collectAssignmentIds($staleAssignments);
$protectedCompletedMap = [];
foreach ($staleAssignments as $assignment) {
$assignmentId = (int) ($assignment['id'] ?? 0);
if ($assignmentId <= 0) {
continue;
}
if ((string) ($assignment['status'] ?? '') === 'completed') {
$protectedCompletedMap[$assignmentId] = true;
}
}
$protectedCertificateIds = $this->certificateRepo->listAssignmentIdsWithCertificates($staleAssignmentIds);
$protectedCertificateMap = array_fill_keys($protectedCertificateIds, true);
$removeAssignmentIds = [];
foreach ($staleAssignmentIds as $assignmentId) {
if (isset($protectedCompletedMap[$assignmentId]) || isset($protectedCertificateMap[$assignmentId])) {
continue;
}
$removeAssignmentIds[] = $assignmentId;
}
$removeWithSubmissionsIds = $this->submissionRepo->listAssignmentIdsWithSubmissions($removeAssignmentIds);
$removeWithSubmissionsMap = array_fill_keys($removeWithSubmissionsIds, true);
$stats = $this->buildSyncStats(
$taskId,
$cycleKey,
count($targetContactIds),
count($existingByContact),
count($createContactIds),
count($keepContactIds),
count($staleAssignmentIds),
count($protectedCompletedMap),
count($protectedCertificateMap),
count($removeAssignmentIds),
count($removeWithSubmissionsMap),
$apply
);
if (!$apply) {
return $stats;
}
TaskCertDb::beginTransaction();
try {
foreach ($targetContactIds as $contactId) {
$source = (string) ($targets[$contactId] ?? 'rule');
if (!in_array($source, ['rule', 'override_include', 'admin_manual'], true)) {
$source = 'rule';
}
$this->assignmentRepo->createIfNotExists(
$taskId,
$contactId,
$cycleKey,
$source,
$cycleStartAt,
$cycleEndAt,
$dueAt,
$graceUntil
);
}
$this->assignmentRepo->deleteByIds($removeAssignmentIds);
$auth = TaskCertAuthContext::fromGlobals();
$changedBy = $auth->isLoggedIn() ? $auth->contactId() : 0;
if (array_key_exists('changed_by', $auditContext)) {
$changedBy = max(0, (int) $auditContext['changed_by']);
}
$diff = $stats;
$trigger = trim((string) ($auditContext['trigger'] ?? (PHP_SAPI === 'cli' ? 'cron_cli' : 'admin_ui')));
if ($trigger !== '') {
$diff['trigger'] = $trigger;
}
$jobRunId = trim((string) ($auditContext['job_run_id'] ?? ''));
if ($jobRunId !== '') {
$diff['job_run_id'] = $jobRunId;
}
$this->changeLogRepo->create(
$taskId,
'sync_assignments_apply',
$changedBy,
null,
null,
$diff
);
TaskCertDb::commit();
} catch (Throwable $throwable) {
TaskCertDb::rollback();
throw $throwable;
}
return $stats;
}
public function syncAssignmentsForAllActiveTasks(
bool $apply,
string $scope = 'current_cycle',
array $auditContext = []
): array
{
if ($scope !== 'current_cycle') {
throw new InvalidArgumentException('Ungültiger Scope. V1 unterstützt nur current_cycle.');
}
$results = [];
foreach ($this->definitionRepo->listActive() as $task) {
$taskId = (int) ($task['id'] ?? 0);
if ($taskId <= 0) {
continue;
}
$results[] = $this->syncAssignmentsForTask($taskId, $apply, $scope, $auditContext);
}
return $results;
}
private function isCompletionBasedRecurringTask(array $task): bool
{
return (string) ($task['frequency_type'] ?? 'once') === 'recurring'
&& (string) ($task['recurrence_basis'] ?? 'calendar') === 'completion';
}
private function materializeCompletionBasedAssignmentsForTask(array $task, DateTimeImmutable $now): int
{
$taskId = (int) ($task['id'] ?? 0);
if ($taskId <= 0) {
return 0;
}
$targets = $this->resolveTargets($taskId);
if ($targets === []) {
return 0;
}
$targetContactIds = array_values(array_unique(array_map('intval', array_keys($targets))));
$targetContactIds = array_values(array_filter($targetContactIds, static function (int $contactId): bool {
return $contactId > 0;
}));
if ($targetContactIds === []) {
return 0;
}
$latestAssignments = $this->assignmentRepo->listLatestByTaskForContacts($taskId, $targetContactIds);
$latestByContact = [];
foreach ($latestAssignments as $assignment) {
$contactId = (int) ($assignment['main_contact_id'] ?? 0);
if ($contactId > 0) {
$latestByContact[$contactId] = $assignment;
}
}
$graceUntil = $this->calculateGraceUntil($taskId);
$inserted = 0;
foreach ($targetContactIds as $contactId) {
$source = (string) ($targets[$contactId] ?? 'rule');
if (!in_array($source, ['rule', 'override_include', 'admin_manual'], true)) {
$source = 'rule';
}
$cycle = [];
if (!isset($latestByContact[$contactId])) {
$cycle = $this->cycleCalculator->resolveInitialCompletionCycle($task, $now);
} else {
$latest = $latestByContact[$contactId];
if ((string) ($latest['status'] ?? '') !== 'completed') {
continue;
}
$cycle = $this->cycleCalculator->resolveNextCompletionCycle($task, $latest);
}
if ($cycle === [] || !($cycle['cycle_start_at'] ?? null) instanceof DateTimeImmutable) {
continue;
}
/** @var DateTimeImmutable $cycleStartAt */
$cycleStartAt = $cycle['cycle_start_at'];
if ($cycleStartAt > $now) {
continue;
}
$resolvedCycleKey = trim((string) ($cycle['cycle_key'] ?? ''));
if ($resolvedCycleKey === '') {
continue;
}
$dueAt = $this->cycleCalculator->resolveDueAt($task, $cycle);
$assignmentId = $this->assignmentRepo->createIfNotExists(
$taskId,
$contactId,
$resolvedCycleKey,
$source,
$this->formatDateTime($cycle['cycle_start_at'] ?? null),
$this->formatDateTime($cycle['cycle_end_at'] ?? null),
$dueAt,
$graceUntil
);
if ($assignmentId > 0) {
$inserted++;
}
}
return $inserted;
}
private function syncCompletionBasedAssignmentsForTask(
array $task,
bool $apply,
array $auditContext = []
): array {
$taskId = (int) ($task['id'] ?? 0);
if ($taskId <= 0) {
return $this->buildSyncStats(0, 'rolling', 0, 0, 0, 0, 0, 0, 0, 0, 0, $apply);
}
$now = new DateTimeImmutable();
$targets = $this->resolveTargets($taskId);
$targetContactIds = array_values(array_unique(array_map('intval', array_keys($targets))));
$targetContactIds = array_values(array_filter($targetContactIds, static function (int $contactId): bool {
return $contactId > 0;
}));
$latestAssignments = $this->assignmentRepo->listLatestByTaskForContacts($taskId, $targetContactIds);
$latestByContact = [];
foreach ($latestAssignments as $assignment) {
$contactId = (int) ($assignment['main_contact_id'] ?? 0);
if ($contactId > 0) {
$latestByContact[$contactId] = $assignment;
}
}
$createPlans = [];
foreach ($targetContactIds as $contactId) {
$cycle = [];
if (!isset($latestByContact[$contactId])) {
$cycle = $this->cycleCalculator->resolveInitialCompletionCycle($task, $now);
} else {
$latest = $latestByContact[$contactId];
if ((string) ($latest['status'] ?? '') !== 'completed') {
continue;
}
$cycle = $this->cycleCalculator->resolveNextCompletionCycle($task, $latest);
}
if ($cycle === [] || !($cycle['cycle_start_at'] ?? null) instanceof DateTimeImmutable) {
continue;
}
/** @var DateTimeImmutable $cycleStartAt */
$cycleStartAt = $cycle['cycle_start_at'];
if ($cycleStartAt > $now) {
continue;
}
$resolvedCycleKey = trim((string) ($cycle['cycle_key'] ?? ''));
if ($resolvedCycleKey === '') {
continue;
}
$source = (string) ($targets[$contactId] ?? 'rule');
if (!in_array($source, ['rule', 'override_include', 'admin_manual'], true)) {
$source = 'rule';
}
$createPlans[] = [
'contact_id' => $contactId,
'source' => $source,
'cycle' => $cycle,
];
}
$allAssignments = $this->assignmentRepo->listByTask($taskId);
$staleContactMap = [];
foreach ($allAssignments as $assignment) {
$contactId = (int) ($assignment['main_contact_id'] ?? 0);
if ($contactId <= 0 || isset($targets[$contactId])) {
continue;
}
$staleContactMap[$contactId] = true;
}
$staleContactIds = array_keys($staleContactMap);
$staleAssignments = $this->assignmentRepo->listRemovableOpenByTaskAndContacts($taskId, $staleContactIds);
$staleAssignmentIds = $this->collectAssignmentIds($staleAssignments);
$protectedCompletedMap = [];
foreach ($staleAssignments as $assignment) {
$assignmentId = (int) ($assignment['id'] ?? 0);
if ($assignmentId <= 0) {
continue;
}
if ((string) ($assignment['status'] ?? '') === 'completed') {
$protectedCompletedMap[$assignmentId] = true;
}
}
$protectedCertificateIds = $this->certificateRepo->listAssignmentIdsWithCertificates($staleAssignmentIds);
$protectedCertificateMap = array_fill_keys($protectedCertificateIds, true);
$removeAssignmentIds = [];
foreach ($staleAssignmentIds as $assignmentId) {
if (isset($protectedCompletedMap[$assignmentId]) || isset($protectedCertificateMap[$assignmentId])) {
continue;
}
$removeAssignmentIds[] = $assignmentId;
}
$removeWithSubmissionsIds = $this->submissionRepo->listAssignmentIdsWithSubmissions($removeAssignmentIds);
$removeWithSubmissionsMap = array_fill_keys($removeWithSubmissionsIds, true);
$stats = $this->buildSyncStats(
$taskId,
'rolling',
count($targetContactIds),
count($latestByContact),
count($createPlans),
count($latestByContact),
count($staleAssignmentIds),
count($protectedCompletedMap),
count($protectedCertificateMap),
count($removeAssignmentIds),
count($removeWithSubmissionsMap),
$apply
);
if (!$apply) {
return $stats;
}
TaskCertDb::beginTransaction();
try {
foreach ($createPlans as $createPlan) {
$cycle = is_array($createPlan['cycle'] ?? null) ? $createPlan['cycle'] : [];
$resolvedCycleKey = trim((string) ($cycle['cycle_key'] ?? ''));
if ($resolvedCycleKey === '') {
continue;
}
$dueAt = $this->cycleCalculator->resolveDueAt($task, $cycle);
$this->assignmentRepo->createIfNotExists(
$taskId,
(int) ($createPlan['contact_id'] ?? 0),
$resolvedCycleKey,
(string) ($createPlan['source'] ?? 'rule'),
$this->formatDateTime($cycle['cycle_start_at'] ?? null),
$this->formatDateTime($cycle['cycle_end_at'] ?? null),
$dueAt
);
}
$this->assignmentRepo->deleteByIds($removeAssignmentIds);
$auth = TaskCertAuthContext::fromGlobals();
$changedBy = $auth->isLoggedIn() ? $auth->contactId() : 0;
if (array_key_exists('changed_by', $auditContext)) {
$changedBy = max(0, (int) $auditContext['changed_by']);
}
$diff = $stats;
$trigger = trim((string) ($auditContext['trigger'] ?? (PHP_SAPI === 'cli' ? 'cron_cli' : 'admin_ui')));
if ($trigger !== '') {
$diff['trigger'] = $trigger;
}
$jobRunId = trim((string) ($auditContext['job_run_id'] ?? ''));
if ($jobRunId !== '') {
$diff['job_run_id'] = $jobRunId;
}
$this->changeLogRepo->create(
$taskId,
'sync_assignments_apply',
$changedBy,
null,
null,
$diff
);
TaskCertDb::commit();
} catch (Throwable $throwable) {
TaskCertDb::rollback();
throw $throwable;
}
return $stats;
}
private function resolveTargets(int $taskId): array
{
$rules = array_values(array_filter(
$this->ruleRepo->getTargetRulesByTask($taskId),
static fn(array $rule): bool => (int) ($rule['active'] ?? 1) === 1
));
$overrides = $this->ruleRepo->getTargetOverridesByTask($taskId);
$scopes = $this->loadActiveContactScopes();
$targets = [];
foreach ($scopes as $contactId => $scopeRows) {
if ($rules === []) {
continue;
}
if ($this->matchesAnyRule($scopeRows, $rules)) {
$targets[$contactId] = 'rule';
}
}
foreach ($overrides as $override) {
$contactId = (int) ($override['main_contact_id'] ?? 0);
$type = (string) ($override['override_type'] ?? '');
if ($contactId <= 0) {
continue;
}
if ($type === 'include') {
$targets[$contactId] = 'override_include';
}
if ($type === 'exclude') {
unset($targets[$contactId]);
}
}
return $targets;
}
private function loadActiveContactScopes(): array
{
$rows = TaskCertDb::fetchAll(
'SELECT
c.id AS main_contact_id,
c.master_mandant_id,
l.main_mandant_id,
l.main_department_id,
l.main_role_id,
l.active
FROM main_contact c
LEFT JOIN main_contact_department l ON l.main_contact_id = c.id
WHERE c.active = 1'
);
$scopes = [];
foreach ($rows as $row) {
if (isset($row['active']) && $row['active'] !== null && (int) $row['active'] !== 1) {
continue;
}
$contactId = (int) $row['main_contact_id'];
if (!isset($scopes[$contactId])) {
$scopes[$contactId] = [];
}
$scopes[$contactId][] = [
'main_mandant_id' => (int) ($row['main_mandant_id'] ?: $row['master_mandant_id']),
'main_department_id' => (int) ($row['main_department_id'] ?? 0),
'main_role_id' => (int) ($row['main_role_id'] ?? 0),
];
}
return $scopes;
}
private function matchesAnyRule(array $scopeRows, array $rules): bool
{
foreach ($rules as $rule) {
$ruleMandant = (int) ($rule['main_mandant_id'] ?? 0);
$ruleDepartment = (int) ($rule['main_department_id'] ?? 0);
$ruleRole = (int) ($rule['main_role_id'] ?? 0);
foreach ($scopeRows as $scope) {
$mandantMatch = $ruleMandant === 0 || $ruleMandant === (int) $scope['main_mandant_id'];
$departmentMatch = $ruleDepartment === 0 || $ruleDepartment === (int) $scope['main_department_id'];
$roleMatch = $ruleRole === 0 || $ruleRole === (int) $scope['main_role_id'];
if ($mandantMatch && $departmentMatch && $roleMatch) {
return true;
}
}
}
return false;
}
private function formatDateTime($value): ?string
{
if ($value instanceof DateTimeImmutable) {
return $value->format('Y-m-d H:i:s');
}
if (!is_string($value) || trim($value) === '') {
return null;
}
try {
return (new DateTimeImmutable($value))->format('Y-m-d H:i:s');
} catch (Throwable $throwable) {
return null;
}
}
private function calculateGraceUntil(int $taskId): ?string
{
$policy = $this->ruleRepo->getReminderPolicyByTask($taskId);
$graceDays = max(0, (int) ($policy['grace_days'] ?? 7));
if ($graceDays <= 0) {
return null;
}
try {
$now = new DateTimeImmutable();
return $now->modify('+' . $graceDays . ' days')->format('Y-m-d H:i:s');
} catch (Throwable $throwable) {
return null;
}
}
private function collectAssignmentIds(array $assignments): array
{
$ids = [];
foreach ($assignments as $assignment) {
$assignmentId = (int) ($assignment['id'] ?? 0);
if ($assignmentId <= 0) {
continue;
}
$ids[] = $assignmentId;
}
$ids = array_values(array_unique($ids));
sort($ids);
return $ids;
}
private function buildSyncStats(
int $taskId,
string $cycleKey,
int $targetCount,
int $existingCount,
int $createCount,
int $updateCount,
int $staleCount,
int $protectedCompletedCount,
int $protectedCertificateCount,
int $removeCount,
int $removeWithSubmissionsCount,
bool $applied
): array {
return [
'task_id' => $taskId,
'cycle_key' => $cycleKey,
'target_count' => $targetCount,
'existing_count' => $existingCount,
'create_count' => $createCount,
'update_count' => $updateCount,
'stale_count' => $staleCount,
'protected_completed_count' => $protectedCompletedCount,
'protected_certificate_count' => $protectedCertificateCount,
'remove_count' => $removeCount,
'remove_with_submissions_count' => $removeWithSubmissionsCount,
'applied' => $applied,
];
}
}