diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f5bdda7..4e8ff305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-175](https://github.com/itk-dev/economics/pull/175) + 2617: Added forecast report. * [PR-173](https://github.com/itk-dev/economics/pull/173) 2663: Workload report loading speed improvement. * [PR-167](https://github.com/itk-dev/economics/pull/167) diff --git a/reports.md b/reports.md new file mode 100644 index 00000000..35b3f1ab --- /dev/null +++ b/reports.md @@ -0,0 +1,33 @@ +# Reports in Economics + +Overview of the various reports found in Economics. + +## Reports + +### Sprint rapport + +This report offers an overview of a project with its name, version, and work hours. It details hours +spent and remaining. If applicable, project budget details are included. The report shows tables with +work hours breakdown for each task in current and future sprints. Thus, giving a view of project progress. + +### Ledelsesrapport + +This financial report provides an itemized overview of an organization's invoices by year and +quarter. It offers yearly totals and individual quarter breakdowns. There's functionality to export +data. The report timeframe can be adjusted, aiding comprehensive financial analysis. + +### Forecast rapport + +This report presents invoiced and recorded work hours for each project and its issues. A +hierarchical view for clarity is available. It indicates unbilled hours and provides an overall total. + +### Timerapport + +This report offers an overview of estimated and logged work hours grouped by project tags. Each tag +represents a project and the detailed hours per ticket. The report concludes with totals. + +### Normtidsrapport + +This workforce report provides a detailed breakdown of individual workloads over different +periods. It presents each worker's total workload and specific contribution percentages each period. +The report also includes an average workload percentage. diff --git a/src/Controller/ForecastReportController.php b/src/Controller/ForecastReportController.php new file mode 100644 index 00000000..bd721639 --- /dev/null +++ b/src/Controller/ForecastReportController.php @@ -0,0 +1,60 @@ +createForm(ForecastReportType::class, $reportFormData, [ + 'action' => $this->generateUrl('app_forecast_report'), + 'method' => 'GET', + 'attr' => [ + 'id' => 'sprint_report', + ], + 'csrf_protection' => false, + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $fromDate = $form->get('dateFrom')->getData(); + $toDate = $form->get('dateTo')->getData(); + + $reportData = $this->forecastReportService->getForecastReport($fromDate, $toDate); + } + + return $this->render('reports/reports.html.twig', [ + 'controller_name' => 'ForecastReportController', + 'form' => $form, + 'error' => $error, + 'data' => $reportData, + 'mode' => $mode, + ]); + } +} diff --git a/src/Form/ForecastReportType.php b/src/Form/ForecastReportType.php new file mode 100644 index 00000000..165be48b --- /dev/null +++ b/src/Form/ForecastReportType.php @@ -0,0 +1,65 @@ +add('dateFrom', DateType::class, [ + 'widget' => 'single_text', + 'input' => 'datetime', + 'required' => false, + 'label' => 'hour_report.from_date', + 'label_attr' => ['class' => 'label'], + 'by_reference' => true, + 'data' => $options['fromDate'] ?? $this->forecastReportService->getDefaultFromDate(), + 'attr' => [ + 'class' => 'form-element', + ], + ]) + ->add('dateTo', DateType::class, [ + 'widget' => 'single_text', + 'input' => 'datetime', + 'required' => false, + 'label' => 'hour_report.to_date', + 'label_attr' => ['class' => 'label'], + 'data' => $options['fromDate'] ?? $this->forecastReportService->getDefaultToDate(), + 'by_reference' => true, + 'attr' => [ + 'class' => 'form-element', + ], + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'workload_report.submit', + 'attr' => [ + 'class' => 'hour-report-submit button', + ], + ] + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => ForecastReportFormData::class, + 'attr' => [ + 'data-sprint-report-target' => 'form', + ], + ]); + } +} diff --git a/src/Model/Reports/ForecastReportData.php b/src/Model/Reports/ForecastReportData.php new file mode 100644 index 00000000..a13aa9d2 --- /dev/null +++ b/src/Model/Reports/ForecastReportData.php @@ -0,0 +1,18 @@ + */ + public ArrayCollection $projects; + + public function __construct() + { + $this->projects = new ArrayCollection(); + } +} diff --git a/src/Model/Reports/ForecastReportFormData.php b/src/Model/Reports/ForecastReportFormData.php new file mode 100644 index 00000000..031806f1 --- /dev/null +++ b/src/Model/Reports/ForecastReportFormData.php @@ -0,0 +1,9 @@ + */ + public array $versions = []; + + public function __construct(string $issueTag) + { + $this->issueTag = $issueTag; + } +} diff --git a/src/Model/Reports/ForecastReportIssueVersionData.php b/src/Model/Reports/ForecastReportIssueVersionData.php new file mode 100644 index 00000000..60bf7910 --- /dev/null +++ b/src/Model/Reports/ForecastReportIssueVersionData.php @@ -0,0 +1,18 @@ + */ + public array $worklogs = []; + + public function __construct(string $issueVersion) + { + $this->issueVersion = $issueVersion; + } +} diff --git a/src/Model/Reports/ForecastReportProjectData.php b/src/Model/Reports/ForecastReportProjectData.php new file mode 100644 index 00000000..eb0650bc --- /dev/null +++ b/src/Model/Reports/ForecastReportProjectData.php @@ -0,0 +1,18 @@ + */ + public array $issues = []; + + public function __construct(string $projectId) + { + $this->projectId = $projectId; + } +} diff --git a/src/Model/Reports/ForecastReportWorklogData.php b/src/Model/Reports/ForecastReportWorklogData.php new file mode 100644 index 00000000..8ca4252f --- /dev/null +++ b/src/Model/Reports/ForecastReportWorklogData.php @@ -0,0 +1,19 @@ +getQuery()->getResult(); } + + /** + * @throws \Exception + */ + public function getWorklogsAttachedToInvoiceInDateRange(\DateTimeInterface $periodStart, \DateTimeInterface $periodEnd, int $page = 1, int $pageSize = 50): array + { + $from = new \DateTimeImmutable($periodStart->format('Y-m-d').' 00:00:00'); + $to = new \DateTimeImmutable($periodEnd->format('Y-m-d').' 23:59:59'); + + $query = $this->createQueryBuilder('worklog') + ->leftJoin(Issue::class, 'issue', 'WITH', 'worklog.issue = issue.id') + ->leftJoin(Project::class, 'project', 'WITH', 'issue.project = project.id') + ->where('worklog.invoiceEntry IS NOT NULL') + ->andWhere('worklog.started BETWEEN :from AND :to') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->getQuery() + ->setFirstResult(($page - 1) * $pageSize) + ->setMaxResults($pageSize); + + $paginator = new Paginator($query, true); + + $totalItemCount = count($paginator); + $pagesCount = ceil($totalItemCount / $pageSize); + + return [ + 'total_count' => $totalItemCount, + 'pages_count' => $pagesCount, + 'current_page' => $page, + 'page_size' => $pageSize, + 'paginator' => $paginator, + ]; + } } diff --git a/src/Service/ForecastReportService.php b/src/Service/ForecastReportService.php new file mode 100644 index 00000000..f67128d6 --- /dev/null +++ b/src/Service/ForecastReportService.php @@ -0,0 +1,188 @@ +workerRepository->findAll(), function ($carry, $worker) { + $carry[$worker->getEmail()] = $worker->getName() ?? '[no worker]'; + + return $carry; + }, []); + + do { + $invoiceAttachedWorklogs = $this->worklogRepository->getWorklogsAttachedToInvoiceInDateRange($fromDate, $toDate, $page, self::PAGE_SIZE); + + foreach ($invoiceAttachedWorklogs['paginator'] as $worklog) { + // Loop through each worklog + $projectId = $worklog->getProject()->getId(); + + if (!$projectId) { + throw new \Exception('Project id is null'); + } + // If the project isn't already in the forecast, add it + if (!isset($forecastReportData->projects[$projectId])) { + $newForecastReportProjectData = new ForecastReportProjectData($projectId); + $newForecastReportProjectData->projectName = $worklog->getProject()?->getName() ?? '[no project name]'; + $forecastReportData->projects[$projectId] = $newForecastReportProjectData; + } + // Get current project from forecast + $currentProject = $forecastReportData->projects[$projectId]; + + if (!$currentProject) { + throw new \Exception('Project instance was not found'); + } + + // Calculate worklog time in hours + $worklogTime = ($worklog->getTimeSpentSeconds() / 3600); + + // Check if worklog is billed + $isWorklogBilled = $worklog->isBilled(); + + // Tally up total project hours based on whether the worklog is billed + $currentProject->invoiced += $worklogTime; + if ($isWorklogBilled) { + $currentProject->invoicedAndRecorded += $worklogTime; + } + + // Get issue details from worklog + $issueId = $worklog->getIssue()->getProjectTrackerKey(); + $issueLink = $worklog->getIssue()->getLinkToIssue(); + $issueTag = $worklog->getIssue()->getEpicName() ?: '[no tag]'; + + // Add issue in the project if it does not exist + if (!isset($currentProject->issues[$issueTag])) { + $currentProject->issues[$issueTag] = new ForecastReportIssueData($issueTag); + $currentProject->issues[$issueTag]->issueId = $issueId; + $currentProject->issues[$issueTag]->issueLink = $issueLink; + } + + // Get current issue from project + $currentIssue = $currentProject->issues[$issueTag]; + + // Add up the invoiced hours to the current issue + $currentIssue->invoiced += $worklogTime; + if ($isWorklogBilled) { + $currentIssue->invoicedAndRecorded += $worklogTime; + } + + // Get version details from issue + $issueVersions = $worklog->getIssue()->getVersions(); + $issueVersion = count($issueVersions) > 0 ? implode(', ', array_map(function ($version) { return $version->getName(); }, $issueVersions->toArray())) : '[no version]'; + + $issueVersionIdentifier = $issueTag.$issueVersion; + + // Add version entry in the issue if it does not exist + if (!isset($currentIssue->versions[$issueVersion])) { + $currentIssue->versions[$issueVersion] = new ForecastReportIssueVersionData($issueVersion); + $currentIssue->versions[$issueVersion]->issueVersionIdentifier = $issueVersionIdentifier; + } + + // Get the current version from issue + $currentVersion = $currentIssue->versions[$issueVersion]; + + // Add up invoiced hours in current version + $currentVersion->invoiced += $worklogTime; + + // If worklog is billed, add it to the recorded hours as well + if ($isWorklogBilled) { + $currentVersion->invoicedAndRecorded += $worklogTime; + } + + // Get worklog details + $worklogId = $worklog->getId(); + $workerEmail = $worklog->getWorker(); + $workerName = $workerNameMapping[$workerEmail] ?? '[no worker]'; + $description = $worklog->getDescription(); + + // Add worklog entry in the version if it does not exist + if (!isset($currentVersion->worklogs[$worklogId])) { + $currentVersion->worklogs[$worklogId] = new ForecastReportWorklogData($worklogId, $description); + $currentVersion->worklogs[$worklogId]->worker = $workerName; + $currentVersion->worklogs[$worklogId]->description = $description; + } + + // Get the current worklog from the version + $currentWorklog = $currentVersion->worklogs[$worklogId]; + + // Add up invoiced hours in the current worklog + $currentWorklog->invoiced += $worklogTime; + + // If worklog is billed, add it to the recorded hours as well + if ($isWorklogBilled) { + $currentWorklog->invoicedAndRecorded += $worklogTime; + } + + // Add up grand totals for the entire forecast + $forecastReportData->totalInvoiced += $worklogTime; + if ($isWorklogBilled) { + $forecastReportData->totalInvoicedAndRecorded += $worklogTime; + } + } + $this->entityManager->clear(); + ++$page; + } while ($page <= $invoiceAttachedWorklogs['pages_count']); + + // Return populated forecast report data + return $forecastReportData; + } + + /** + * Gets the first day of the last month. + * + * @return \DateTime The default from date + */ + public function getDefaultFromDate(): \DateTime + { + $fromDate = new \DateTime(); + $fromDate->modify('first day of last month'); + + return $fromDate; + } + + /** + * Gets the last day of the last month. + * + * @return \DateTime The default "to" date + */ + public function getDefaultToDate(): \DateTime + { + $fromDate = new \DateTime(); + $fromDate->modify('last day of last month'); + + return $fromDate; + } +} diff --git a/templates/components/navigation.html.twig b/templates/components/navigation.html.twig index 7341a2f7..076e9c8c 100644 --- a/templates/components/navigation.html.twig +++ b/templates/components/navigation.html.twig @@ -31,6 +31,7 @@
diff --git a/templates/reports/forecast_report.html.twig b/templates/reports/forecast_report.html.twig new file mode 100644 index 00000000..ffce3cbf --- /dev/null +++ b/templates/reports/forecast_report.html.twig @@ -0,0 +1,129 @@ +{{ 'forecast_report.projects'|trans }} | ++ {{ 'forecast_report.invoiced_hours'|trans }} + | ++ {{ 'forecast_report.invoiced_recorded_hours'|trans }} + | ++ {{ 'forecast_report.missing_hours'|trans }} + | +
---|---|---|---|
+
+
+ {{ project.projectName }}
+
+
+
+ |
+ + {{ project.invoiced }} + | ++ {{ project.invoicedAndRecorded }} + | ++ {{ project.invoiced - project.invoicedAndRecorded }} + | +
+
+
+ {{ issueId }}
+
+
+
+ |
+ + {{ issue.invoiced }} + | ++ {{ issue.invoicedAndRecorded }} + | ++ {{ issue.invoiced - issue.invoicedAndRecorded }} + | +
+
+
+ {{ versionName }}
+
+
+
+ |
+ + {{ version.invoiced }} + | ++ {{ version.invoicedAndRecorded }} + | ++ {{ version.invoiced - version.invoicedAndRecorded }} + | +
+
+
+ {{ worklog.worker }} [{{ issue.issueId }}]
+ {{ worklog.description }} + + |
+ + {{ worklog.invoiced }} + | ++ {{ worklog.invoicedAndRecorded }} + | ++ {{ worklog.invoiced - worklog.invoicedAndRecorded }} + | +
{{ 'forecast_report.total'|trans }} | +{{ data.totalInvoiced }} | +{{ data.totalInvoicedAndRecorded }} | +{{ data.totalInvoiced - data.totalInvoicedAndRecorded }} | +