configuration->isTimesheetAllowOverbookingBudget()) { return; } $begin = $value->getBegin(); // we can only work with stopped entries if ($begin === null || $value->getEnd() === null || $value->getUser() === null) { return; } // budgets need only be calculated for billable records if (!$value->isBillable()) { return; } // this validator needs a project to calculate the rates if ($value->getProject() === null) { return; } $id = $value->getId(); // when changing the date via the calendar and/or the API, the duration will not be reset by the // duration calculator (which runs after validation!) so we manually reset the duration before // ------------------------------------------------------------------------ // Old solution (buggy) - duration MAY NOT BE RESET! // this will cause the timesheet to be deleted in the Weekly-QuickEntry-Flow // ------------------------------------------------------------------------ // $timesheet->setDuration(null); // $duration = $timesheet->getDuration(); // another possible solution is cloning the timesheet // $timesheet = clone $timesheet; // $timesheet->setDuration(null); // $duration = $timesheet->getDuration(); $duration = $value->getCalculatedDuration(); $timeRate = $this->rateService->calculate($value); $rate = $timeRate->getRate(); $activityDuration = $duration ?? 0; $activityRate = $rate; $projectDuration = $duration ?? 0; $projectRate = $rate; $customerDuration = $duration ?? 0; $customerRate = $rate; $monthWasChanged = false; if ($id !== null) { $rawData = $this->timesheetRepository->getRawData($id); $activityId = (int) $rawData['activity']; $projectId = (int) $rawData['project']; $customerId = (int) $rawData['customer']; // if an existing entry was updated, but the relevant fields for budget calculation were not touched: do not validate! // this could for example happen when export flag is changed OR if "prevent overbooking" config was recently activated and this is an old entry if ($duration === $rawData['duration'] && $rate === $rawData['rate'] && $value->isBillable() === $rawData['billable'] && $begin->format('Y.m.d') === $rawData['begin']->format('Y.m.d') && $value->getProject()->getId() === $projectId && ($value->getActivity() === null || $value->getActivity()->getId() === $activityId) ) { return; } // the duration of an existing entry could be increased or lowered // only subtract the previously logged data in case the record was billable // if it wasn't billable, then its values are not included in the statistic models used later on if ($rawData['billable']) { if (null !== $value->getActivity() && $activityId === $value->getActivity()->getId()) { $activityDuration -= $rawData['duration']; $activityRate -= $rawData['rate']; } if (null !== $value->getProject()) { if ($projectId === $value->getProject()->getId()) { $projectDuration -= $rawData['duration']; $projectRate -= $rawData['rate']; } if ($customerId === $value->getProject()->getCustomer()->getId()) { $customerDuration -= $rawData['duration']; $customerRate -= $rawData['rate']; } } } $monthWasChanged = $begin->format('Y.m') !== $rawData['begin']->format('Y.m'); } $now = new DateTime('now', $begin->getTimezone()); $recordDate = $begin; if (null !== ($activity = $value->getActivity()) && $activity->hasBudgets()) { $dateTime = $activity->isMonthlyBudget() ? $recordDate : $now; if ($activity->isMonthlyBudget() && $monthWasChanged) { $activityDuration = $duration; } $stat = $this->activityStatisticService->getBudgetStatisticModel($activity, $dateTime); $this->checkBudgets($constraint, $stat, $value, $activityDuration, $activityRate, 'activity'); } if (null !== ($project = $value->getProject())) { if ($project->hasBudgets()) { $dateTime = $project->isMonthlyBudget() ? $recordDate : $now; if ($project->isMonthlyBudget() && $monthWasChanged) { $projectDuration = $duration; } $stat = $this->projectStatisticService->getBudgetStatisticModel($project, $dateTime); $this->checkBudgets($constraint, $stat, $value, $projectDuration, $projectRate, 'project'); } if (null !== ($customer = $project->getCustomer()) && $customer->hasBudgets()) { $dateTime = $customer->isMonthlyBudget() ? $recordDate : $now; if ($customer->isMonthlyBudget() && $monthWasChanged) { $customerDuration = $duration; } $stat = $this->customerStatisticService->getBudgetStatisticModel($customer, $dateTime); $this->checkBudgets($constraint, $stat, $value, $customerDuration, $customerRate, 'customer'); } } } private function checkBudgets(TimesheetBudgetUsed $constraint, BudgetStatisticModel $stat, TimesheetEntity $timesheet, int $duration, float $rate, string $field): bool { $fullRate = ($stat->getBudgetSpent() + $rate); if ($stat->hasBudget() && $fullRate > $stat->getBudget()) { $this->addBudgetViolation($constraint, $timesheet, $field, $stat->getBudget(), $stat->getBudgetSpent()); return true; } $fullDuration = ($stat->getTimeBudgetSpent() + $duration); if ($stat->hasTimeBudget() && $fullDuration > $stat->getTimeBudget()) { $this->addTimeBudgetViolation($constraint, $field, $stat->getTimeBudget(), $stat->getTimeBudgetSpent()); return true; } return false; } private function addBudgetViolation(TimesheetBudgetUsed $constraint, TimesheetEntity $timesheet, string $field, float $budget, float $rate): void { // using the locale of the assigned user is not the best solution, but allows to be independent of the request stack $helper = new LocaleFormatter($this->localeService, $timesheet->getUser()?->getLocale() ?? 'en'); $currency = $timesheet->getProject()->getCustomer()->getCurrency(); $free = $budget - $rate; $free = max($free, 0); $message = $constraint->messageRate; if (!$this->security->isGranted('budget_money', $field)) { $message = $constraint->messagePermission; } $this->context->buildViolation($message) ->atPath($field) ->setTranslationDomain('validators') ->setParameters([ '%used%' => $helper->money($rate, $currency), '%budget%' => $helper->money($budget, $currency), '%free%' => $helper->money($free, $currency) ]) ->addViolation() ; } private function addTimeBudgetViolation(TimesheetBudgetUsed $constraint, string $field, int $budget, int $duration): void { $durationFormat = new Duration(); $free = $budget - $duration; $free = max($free, 0); $message = $constraint->messageTime; if (!$this->security->isGranted('budget_time', $field)) { $message = $constraint->messagePermission; } $this->context->buildViolation($message) ->atPath($field) ->setTranslationDomain('validators') ->setParameters([ '%used%' => $durationFormat->format($duration), '%budget%' => $durationFormat->format($budget), '%free%' => $durationFormat->format($free) ]) ->addViolation() ; } }