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 @@ +
+ + + + + + + + + + + {% for projectId, project in data.projects %} + + + + + + + + {% for issueId, issue in project.issues %} + + + + + + + {% for versionName, version in issue.versions %} + + + + + + + {% for worklogId, worklog in version.worklogs %} + + + + + + + {% endfor %} + {% endfor %} + {% endfor %} + {% endfor %} + + + + + + + +
{{ '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 }}
+
diff --git a/templates/reports/reports.html.twig b/templates/reports/reports.html.twig index 35cc31c0..af383810 100644 --- a/templates/reports/reports.html.twig +++ b/templates/reports/reports.html.twig @@ -22,11 +22,13 @@ {% endif %} {{ form_start(form) }} - {{ form_row(form.dataProvider) }} + {% if form.dataProvider is defined %} + {{ form_row(form.dataProvider) }} + {% endif %} {{ form_end(form) }} {% if data is not empty %} -
+
{% if mode is defined %} {{ include('reports/' ~ mode ~ '.html.twig') }} {% endif %} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index e06bc996..1066f15b 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -56,6 +56,7 @@ navigation: management_report: "Ledelsesrapport" planning_users: 'Brugere' planning_projects: 'Projekter' + forecast_report: "Forecast" hour_report: "Timerapport" workload_report: "Normtidsrapport" worker: "Medarbejdere" @@ -776,3 +777,11 @@ subscription: edit: 'Rediger' monthly: 'Månedligt' quarterly: 'Kvartalsvist' + +forecast_report: + title: 'Forecast rapport' + projects: 'Projekter' + invoiced_hours: 'Fakturerede timer' + invoiced_recorded_hours: 'Bogførte timer' + missing_hours: 'Rest timer' + total: 'Total'