diff --git a/CHANGELOG.md b/CHANGELOG.md index d38b3b6f6..4088f0aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-264](https://github.com/itk-dev/economics/pull/264) + Added cybersecurity report. +* [PR-265](https://github.com/itk-dev/economics/pull/265) + Add version to issue during sync. + ## [3.0.0] - 2026-01-20 * [PR-262](https://github.com/itk-dev/economics/pull/262) diff --git a/assets/styles/app.css b/assets/styles/app.css index 4fd0eafe5..4870a3810 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -148,6 +148,10 @@ @apply grid gap-3 mb-6 md:grid-cols-6; } + #cybersecurity_report { + @apply grid gap-3 mb-6 md:grid-cols-6; + } + .form-default { @apply grid gap-3 mb-6 md:grid-cols-6; } diff --git a/src/Controller/CybersecurityReportController.php b/src/Controller/CybersecurityReportController.php new file mode 100644 index 000000000..7790ff79b --- /dev/null +++ b/src/Controller/CybersecurityReportController.php @@ -0,0 +1,74 @@ +query->all('cybersecurity_report'); + + if (!empty($requestData['dataProvider'])) { + $dataProvider = $this->dataProviderRepository->find($requestData['dataProvider']); + } elseif (null !== $this->defaultDataProvider) { + $dataProvider = $this->dataProviderRepository->find($this->defaultDataProvider); + } + + $form = $this->createForm(CybersecurityReportType::class, $reportFormData, [ + // Since this is only a filtering form, csrf is not needed. + 'csrf_protection' => false, + 'action' => $this->generateUrl('app_cybersecurity_report'), + 'method' => 'GET', + 'attr' => [ + 'id' => 'cybersecurity_report', + ], + 'data_provider' => $dataProvider, + 'versionTitle' => $versionTitle, + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $versionTitle = $form->get('versionTitle')->getData() ?? null; + $fromDate = $form->get('fromDate')->getData() ?? null; + $toDate = $form->get('toDate')->getData() ?? null; + + if ($versionTitle && $fromDate && $toDate) { + $reportData = $this->cybersecurityReportService->getCybersecurityReport($fromDate, $toDate, $versionTitle); + } + } + + return $this->render('reports/reports.html.twig', [ + 'controller_name' => 'CybersecurityReportController', + 'form' => $form, + 'data' => $reportData, + 'mode' => 'cybersecurity_report', + ]); + } +} diff --git a/src/Form/CybersecurityReportType.php b/src/Form/CybersecurityReportType.php new file mode 100644 index 000000000..7a4139d84 --- /dev/null +++ b/src/Form/CybersecurityReportType.php @@ -0,0 +1,107 @@ +dataProviderRepository->findAll(); + $defaultProvider = $this->dataProviderRepository->find($this->defaultDataProvider); + + if (null === $defaultProvider && count($dataProviders) > 0) { + $defaultProvider = $dataProviders[0]; + } + + $builder + ->add('dataProvider', EntityType::class, [ + 'class' => DataProvider::class, + 'required' => false, + 'label' => 'cybersecurity_report.data_provider', + 'label_attr' => ['class' => 'label'], + 'placeholder' => 'cybersecurity_report.select_data_provider', + 'attr' => [ + 'class' => 'form-element', + ], + 'help' => 'cybersecurity_report.data_provider_helptext', + 'data' => $defaultProvider, + 'choices' => $dataProviders, + ]) + ->add('versionTitle', ChoiceType::class, [ + 'choices' => [ + self::DEFAULT_CYBERSECURITY_MILESTONE => self::DEFAULT_CYBERSECURITY_MILESTONE, + ], + 'required' => true, + 'attr' => [ + 'class' => 'form-element', + ], + 'row_attr' => ['class' => 'form-row form-choices'], + 'label' => 'cybersecurity_report.versionTitle', + 'label_attr' => ['class' => 'label'], + 'data' => self::DEFAULT_CYBERSECURITY_MILESTONE, + ]) + ->add('fromDate', DateType::class, [ + 'widget' => 'single_text', + 'input' => 'datetime', + 'required' => false, + 'label' => 'cybersecurity_report.from_date', + 'label_attr' => ['class' => 'label'], + 'by_reference' => true, + 'data' => $options['fromDate'] ?? $this->cybersecurityReportService->getDefaultFromDate(), + 'attr' => [ + 'class' => 'form-element', + ], + ]) + ->add('toDate', DateType::class, [ + 'widget' => 'single_text', + 'input' => 'datetime', + 'required' => false, + 'label' => 'cybersecurity_report.to_date', + 'label_attr' => ['class' => 'label'], + 'data' => $options['toDate'] ?? $this->cybersecurityReportService->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' => CybersecurityReportFormData::class, + ]) + ->setRequired('data_provider') + ->setRequired('versionTitle'); + } +} diff --git a/src/Model/DataProvider/DataProviderIssueData.php b/src/Model/DataProvider/DataProviderIssueData.php index c06e54f14..744452219 100644 --- a/src/Model/DataProvider/DataProviderIssueData.php +++ b/src/Model/DataProvider/DataProviderIssueData.php @@ -21,6 +21,7 @@ public function __construct( public ?\DateTimeInterface $fetchTime, public ?string $url, public ?\DateTimeInterface $sourceModifiedDate, + public ?string $versionId, ) { } } diff --git a/src/Model/Reports/CybersecurityProjectData.php b/src/Model/Reports/CybersecurityProjectData.php new file mode 100644 index 000000000..90027fbf8 --- /dev/null +++ b/src/Model/Reports/CybersecurityProjectData.php @@ -0,0 +1,18 @@ + + */ + public array $projects = []; + + public float $totalSpent = 0.0; +} diff --git a/src/Model/Reports/CybersecurityReportFormData.php b/src/Model/Reports/CybersecurityReportFormData.php new file mode 100644 index 000000000..79eb14698 --- /dev/null +++ b/src/Model/Reports/CybersecurityReportFormData.php @@ -0,0 +1,13 @@ +getResult(); } + + public function issuesContainingVersionTitle(string $versionTitle): array + { + return $this->createQueryBuilder('issue') + ->select('DISTINCT issue') + ->innerJoin('issue.versions', 'version') + ->andWhere('version.name = :versionTitle') + ->setParameter('versionTitle', $versionTitle) + ->getQuery() + ->getResult(); + } } diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 203b6f615..e32371083 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -3,6 +3,7 @@ namespace App\Repository; use App\Entity\Project; +use App\Entity\ServiceAgreement; use App\Model\Invoices\ProjectFilterData; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; @@ -105,4 +106,17 @@ public function getProjectTrackerIdsByDataProviders(array $dataProviders) return $qb->getQuery()->getSingleColumnResult(); } + + public function getProjectIdsWithCybersecurityAgreement(): array + { + $result = $this->_em->createQueryBuilder() + ->select('DISTINCT p.id') + ->from(ServiceAgreement::class, 'sa') + ->innerJoin('sa.project', 'p') + ->where('sa.cybersecurityAgreement IS NOT NULL') + ->getQuery() + ->getScalarResult(); + + return array_column($result, 'id'); + } } diff --git a/src/Repository/WorklogRepository.php b/src/Repository/WorklogRepository.php index b0c0e09d7..c69938995 100644 --- a/src/Repository/WorklogRepository.php +++ b/src/Repository/WorklogRepository.php @@ -294,4 +294,34 @@ public function getWorklogsAttachedToInvoiceInDateRange(\DateTimeInterface $peri 'paginator' => $paginator, ]; } + + /** + * Get worklogs for a given issue, optionally restricted by period. + * + * @param int $issueId + * @param ?\DateTimeInterface $fromDate + * @param ?\DateTimeInterface $toDate + * + * @return Worklog[] + */ + public function getWorklogsByIssueAndPeriod(int $issueId, ?\DateTimeInterface $fromDate, ?\DateTimeInterface $toDate): array + { + $qb = $this->createQueryBuilder('w') + ->andWhere('w.issue = :issue') // <-- use the entity relation + ->setParameter('issue', $issueId); // Doctrine can accept the ID for a ManyToOne + + if ($fromDate) { + $qb->andWhere('w.started >= :fromDate') + ->setParameter('fromDate', $fromDate->format('Y-m-d 00:00:00')); + } + + if ($toDate) { + $qb->andWhere('w.started <= :toDate') + ->setParameter('toDate', $toDate->format('Y-m-d 23:59:59')); + } + + $qb->orderBy('w.started', 'ASC'); + + return $qb->getQuery()->getResult(); + } } diff --git a/src/Service/CybersecurityReportService.php b/src/Service/CybersecurityReportService.php new file mode 100644 index 000000000..7c4229ea0 --- /dev/null +++ b/src/Service/CybersecurityReportService.php @@ -0,0 +1,126 @@ +projectRepository->getProjectIdsWithCybersecurityAgreement() + ); + + // Fetch issues that have the version with the given title + $issues = $this->issueRepository->issuesContainingVersionTitle($versionTitle); + + foreach ($issues as $issue) { + // Fetch worklogs for this issue restricted to the period + $worklogs = $this->worklogRepository->getWorklogsByIssueAndPeriod( + $issue->getId(), + $fromDate, + $toDate + ); + + // Sum total time spent (seconds → hours) + $totalTicketSpent = array_reduce( + $worklogs, + fn (float $carry, Worklog $w) => $carry + ($w->getTimeSpentSeconds() * self::SECONDS_TO_HOURS), + 0 + ); + + // Skip tickets with no time logged + if (0 === $totalTicketSpent) { + continue; + } + + $projectEntity = $issue->getProject(); + $projectId = $projectEntity->getId(); + $projectName = $projectEntity->getName(); + + // Create project entry once + if (!isset($report->projects[$projectName])) { + $projectData = new CybersecurityProjectData($projectName); + $projectData->hasCybersecurityAgreement = + isset($projectIdsWithAgreement[$projectId]); + + $report->projects[$projectName] = $projectData; + } + + // Create worklog DTOs + $worklogData = array_map( + fn (Worklog $w) => new CybersecurityWorklogData( + (int) $w->getId(), + $w->getTimeSpentSeconds() * self::SECONDS_TO_HOURS, + $w->getDescription(), + $w->getWorker() + ), + $worklogs + ); + + // Create ticket DTO + $ticket = new CybersecurityTicketData( + $issue->getId(), + $issue->getProjectTrackerId(), + $issue->getName(), + $totalTicketSpent, + $issue->getLinkToIssue(), + $worklogData + ); + + // Attach ticket to project + $project = $report->projects[$projectName]; + $project->tickets[] = $ticket; + $project->totalSpent += $totalTicketSpent; + + $report->totalSpent += $totalTicketSpent; + } + + return $report; + } + + /** + * @throws \DateMalformedStringException + */ + public function getDefaultFromDate(): \DateTime + { + $fromDate = new \DateTime(); + $fromDate->modify('first day of this month'); + + return $fromDate; + } + + /** + * @throws \DateMalformedStringException + */ + public function getDefaultToDate(): \DateTime + { + $fromDate = new \DateTime(); + $fromDate->modify('last day of this month'); + + return $fromDate; + } +} diff --git a/src/Service/DataProviderService.php b/src/Service/DataProviderService.php index 597188052..dedb51d99 100644 --- a/src/Service/DataProviderService.php +++ b/src/Service/DataProviderService.php @@ -173,6 +173,14 @@ public function upsertIssue(DataProviderIssueData $upsertIssueData): void $issue->addEpic($epic); } + + if (!empty($upsertIssueData->versionId)) { + $version = $this->getVersion($upsertIssueData->versionId, $dataProvider); + + if ($version) { + $issue->addVersion($version); + } + } $issue->setResolutionDate($upsertIssueData->resolutionDate); $issue->setStatus($upsertIssueData->status); $issue->setPlanHours($upsertIssueData->plannedHours); diff --git a/src/Service/LeantimeApiService.php b/src/Service/LeantimeApiService.php index 7715b8a09..d0c1ff3e1 100644 --- a/src/Service/LeantimeApiService.php +++ b/src/Service/LeantimeApiService.php @@ -276,6 +276,7 @@ private function getIssueUpsertFromResult(object $result, int $dataProviderId, \ $fetchDate, $this->linkToTicket($projectTrackerId, $dataProviderUrl), $this->getLeanDateTime($result->modified), + $result->milestoneId ); } diff --git a/templates/components/navigation.html.twig b/templates/components/navigation.html.twig index f1fa8b43c..2ef388363 100644 --- a/templates/components/navigation.html.twig +++ b/templates/components/navigation.html.twig @@ -37,6 +37,7 @@ {{ include('components/navigation-item.html.twig', {title: 'navigation.forecast_report'|trans, role: 'ROLE_REPORT', route: path('app_forecast_report')}) }} {{ include('components/navigation-item.html.twig', {title: 'navigation.billable_unbilled_hours_report'|trans, role: 'ROLE_REPORT', route: path('app_billable_unbilled_hours_report')}) }} {{ include('components/navigation-item.html.twig', {title: 'navigation.invoicing_rate_report'|trans, role: 'ROLE_REPORT', route: path('app_invoicing_rate_report')}) }} + {{ include('components/navigation-item.html.twig', {title: 'navigation.cybersecurity_report'|trans, role: 'ROLE_REPORT', route: path('app_cybersecurity_report')}) }} {{ include('components/navigation-item.html.twig', {title: 'navigation.management_report'|trans, role: 'ROLE_REPORT', route: path('app_management_reports_create')}) }} {{ include('components/navigation-item.html.twig', {title: 'navigation.subscription'|trans, role: 'ROLE_REPORT', route: path('app_subscription_index')}) }} diff --git a/templates/reports/cybersecurity_report.html.twig b/templates/reports/cybersecurity_report.html.twig new file mode 100644 index 000000000..152351b4b --- /dev/null +++ b/templates/reports/cybersecurity_report.html.twig @@ -0,0 +1,130 @@ +
| + {{ 'cybersecurity_report.project'|trans }} + | ++ {{ 'cybersecurity_report.logged_hours'|trans }} + | ++ {{ 'cybersecurity_report.has_cybersecurity_agreement'|trans }} + | +
|---|---|---|
|
+
+
+ {{ project.projectName }}
+
+
+
+
+
+ |
+
+ + {{ project.totalSpent }} + | ++ {% if project.hasCybersecurityAgreement %} + {{ 'cybersecurity_report.has_cybersecurity_agreement_true'|trans }} + {% endif %} + | +
|
+
+
+ {{ ticket.headline }}
+
+ #{{ ticket.projectTrackerId }}
+
+
+
+ {% if ticket.worklogs|length > 0 %}
+
+ {% endif %}
+
+ |
+
+ + {{ ticket.totalSpent }} + | ++ + | +
|
+
+ {{ worklog.description ?: '—' }} 🧑
+
+ |
+ + {{ worklog.hours }} + | ++ + | +
| + {{ 'hour_report.total'|trans }} + | ++ {{ data.totalSpent }} + | +