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>
774 lines
26 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|