Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

3602: Billable unbilled hours report #195

Merged
merged 23 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dfa0272
Added report data model
jeppekroghitk Jan 17, 2025
c0cf7ed
Added form data model
jeppekroghitk Jan 17, 2025
380bd5a
Added type
jeppekroghitk Jan 17, 2025
c6aab05
Controller progress
jeppekroghitk Jan 17, 2025
21dbe61
Modified method in worklogRepository to return a broader set of data
jeppekroghitk Jan 17, 2025
da2f4c7
Added service
jeppekroghitk Jan 17, 2025
dee4aa8
Corrected use of modified worklog repo method
jeppekroghitk Jan 17, 2025
9fb6d64
Added new report to navigation
jeppekroghitk Jan 17, 2025
e39f741
Added twig for new report
jeppekroghitk Jan 17, 2025
d48cf4a
Updated changelog
jeppekroghitk Jan 17, 2025
bc5ca64
Fix constructor formatting in BillableUnbilledHoursReportController
jeppekroghitk Jan 20, 2025
3fe63d4
Remove unused imports and clean up in BillableUnbilledHoursReportData
jeppekroghitk Jan 20, 2025
85f9d41
Refactor condition check for workerIdentifier in WorklogRepository
jeppekroghitk Jan 20, 2025
6935d6f
Add WorkerRepository and Translator; improve condition checks in Bill…
jeppekroghitk Jan 20, 2025
b748ef5
Update template translations and improve layout in billable_unbilled_…
jeppekroghitk Jan 20, 2025
6df6369
Add Danish translations for billable_unbilled_hours_report messages
jeppekroghitk Jan 20, 2025
457052e
Coding standards
jeppekroghitk Jan 20, 2025
896d167
Added translation
jeppekroghitk Jan 20, 2025
6e7c3c9
Added link to issue
jeppekroghitk Jan 20, 2025
59e0712
Added IS NOT NULL on is_billed for findBillableWorklogsByWorkerAndDat…
jeppekroghitk Jan 20, 2025
5fdcf55
Merge branch 'develop' into feature/3602-unbilled-billable-hours-report
jeppekroghitk Jan 20, 2025
e7ea4fd
Add param to findBillableWorklogsByWorkerAndDateRange in order to fil…
jeppekroghitk Jan 22, 2025
f695c03
Coding standards
jeppekroghitk Jan 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* [PR-195](https://github.com/itk-dev/economics/pull/195)
3602: Added billable unbilled hours report.
* [PR-196](https://github.com/itk-dev/economics/pull/196)
3624: Correctly handling periods when viewing past workload reports.
* [PR-194](https://github.com/itk-dev/economics/pull/194)
Expand Down
64 changes: 64 additions & 0 deletions src/Controller/BillableUnbilledHoursReportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace App\Controller;

use App\Form\BillableUnbilledHoursReportType;
use App\Model\Reports\BillableUnbilledHoursReportFormData;
use App\Service\BillableUnbilledHoursReportService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/reports/billable_unbilled_hours_report')]
#[IsGranted('ROLE_REPORT')]
class BillableUnbilledHoursReportController extends AbstractController
{
public function __construct(
private readonly BillableUnbilledHoursReportService $billableUnbilledHoursReportService,
) {
}

#[Route('/', name: 'app_billable_unbilled_hours_report')]
public function index(Request $request): Response
{
$reportData = null;
$error = null;
$mode = 'billable_unbilled_hours_report';
$reportFormData = new BillableUnbilledHoursReportFormData();

$form = $this->createForm(BillableUnbilledHoursReportType::class, $reportFormData, [
'action' => $this->generateUrl('app_billable_unbilled_hours_report'),
'method' => 'GET',
'attr' => [
'id' => 'sprint_report',
],
'years' => [
(new \DateTime())->modify('-1 year')->format('Y'),
(new \DateTime())->format('Y'),
],
'csrf_protection' => false,
]);

$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
$year = $form->get('year')->getData();

try {
$reportData = $this->billableUnbilledHoursReportService->getBillableUnbilledHoursReport($year);
} catch (\Exception $e) {
$error = $e->getMessage();
}
}

return $this->render('reports/reports.html.twig', [
'controller_name' => 'BillableUnbilledHoursReportController',
'form' => $form,
'error' => $error,
'data' => $reportData,
'mode' => $mode,
]);
}
}
55 changes: 55 additions & 0 deletions src/Form/BillableUnbilledHoursReportType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace App\Form;

use App\Model\Reports\BillableUnbilledHoursReportFormData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class BillableUnbilledHoursReportType extends AbstractType
{
public function __construct(
) {
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
$yearChoices = [];
foreach ($options['years'] as $year) {
$yearChoices[$year] = $year;
}

$builder
->add('year', ChoiceType::class, [
'label' => 'billable_unbilled_hours_report.year',
'label_attr' => ['class' => 'label'],
'attr' => ['class' => 'form-element '],
'help_attr' => ['class' => 'form-help'],
'row_attr' => ['class' => 'form-row'],
'required' => false,
'data' => $yearChoices[date('Y')],
'choices' => $yearChoices,
'placeholder' => null,
])
->add('submit', SubmitType::class, [
'label' => 'billable_unbilled_hours_report.submit',
'attr' => [
'class' => 'hour-report-submit button',
],
]);
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BillableUnbilledHoursReportFormData::class,
'attr' => [
'data-sprint-report-target' => 'form',
],
'years' => null,
]);
}
}
20 changes: 20 additions & 0 deletions src/Model/Reports/BillableUnbilledHoursReportData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Model\Reports;

use Doctrine\Common\Collections\ArrayCollection;

class BillableUnbilledHoursReportData
{
public string $id;

/** @var ArrayCollection<string, mixed> */
public ArrayCollection $projectData;
public array $projectTotals;
public int|float $totalHoursForAllProjects;

public function __construct()
{
$this->projectData = new ArrayCollection();
}
}
8 changes: 8 additions & 0 deletions src/Model/Reports/BillableUnbilledHoursReportFormData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace App\Model\Reports;

class BillableUnbilledHoursReportFormData
{
public int $year;
}
33 changes: 18 additions & 15 deletions src/Repository/WorklogRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public function findWorklogsByWorkerAndDateRange(string $workerIdentifier, \Date
->getQuery()->getResult();
}

public function findBillableWorklogsByWorkerAndDateRange(string $workerIdentifier, \DateTime $dateFrom, \DateTime $dateTo)
public function findBillableWorklogsByWorkerAndDateRange(\DateTime $dateFrom, \DateTime $dateTo, ?string $workerIdentifier = null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking something along these lines:

public function findBillableWorklogsByWorkerAndDateRange(\DateTime $dateFrom, \DateTime $dateTo, ?string $workerIdentifier = null, bool? $isBilled = null)
{
  ...
 
  if ($isBilled !== null) {
    $qb->andWhere($qb->expr()->eq('worklog.isBilled', ':isBilled'))
    ->setParameter('isBilled', $isBilled);
  }

}        

And then in BillableUnbilledHoursReportService call

$billableWorklogs = $this->worklogRepository->findBillableWorklogsByWorkerAndDateRange($dateFrom, $dateTo, null, true);

Then the

   if (false === $billableWorklog->isBilled()) {

would not be needed in BillableUnbilledHoursReportService. And you would process fewer entities.

{
$nonBillableEpics = NonBillableEpicsEnum::getAsArray();
$nonBillableVersions = NonBillableVersionsEnum::getAsArray();
Expand All @@ -125,29 +125,32 @@ public function findBillableWorklogsByWorkerAndDateRange(string $workerIdentifie
->leftJoin('issue.epics', 'epic')
->leftJoin('issue.versions', 'version');

return $qb
->where($qb->expr()->between('worklog.started', ':dateFrom', ':dateTo'))
->andWhere('worklog.worker = :worker')
$qb->where($qb->expr()->between('worklog.started', ':dateFrom', ':dateTo'))
->andWhere($qb->expr()->andX(
$qb->expr()->eq('project.isBillable', '1'),
$qb->expr()->eq('project.isBillable', '1')
))
// notIn will only work if the string it is checked against is not null
->andWhere($qb->expr()->orX(
$qb->expr()->isNull('epic.title'),
$qb->expr()->notIn('epic.title', ':nonBillableEpics'),
$qb->expr()->notIn('epic.title', ':nonBillableEpics')
))
->andWhere($qb->expr()->orX(
$qb->expr()->isNull('version.name'),
$qb->expr()->notIn('version.name', ':nonBillableVersions')
))
->setParameters([
'worker' => $workerIdentifier,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'nonBillableEpics' => array_values($nonBillableEpics),
'nonBillableVersions' => array_values($nonBillableVersions),
])
->getQuery()->getResult();
->andWhere($qb->expr()->isNotNull('worklog.isBilled'));

// Add the worker condition only when provided
if (null !== $workerIdentifier) {
$qb->andWhere('worklog.worker = :worker')
->setParameter('worker', $workerIdentifier);
}

return $qb->setParameters([
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'nonBillableEpics' => array_values($nonBillableEpics),
'nonBillableVersions' => array_values($nonBillableVersions),
])->getQuery()->getResult();
}

public function findBilledWorklogsByWorkerAndDateRange(string $workerIdentifier, \DateTime $dateFrom, \DateTime $dateTo)
Expand Down
82 changes: 82 additions & 0 deletions src/Service/BillableUnbilledHoursReportService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace App\Service;

use App\Model\Reports\BillableUnbilledHoursReportData;
use App\Repository\WorkerRepository;
use App\Repository\WorklogRepository;
use Symfony\Contracts\Translation\TranslatorInterface;

class BillableUnbilledHoursReportService
{
private const SECONDS_TO_HOURS = 1 / 3600;

public function __construct(
private readonly WorklogRepository $worklogRepository,
private readonly DateTimeHelper $dateTimeHelper,
private readonly WorkerRepository $workerRepository,
private readonly TranslatorInterface $translator,
) {
}

public function getBillableUnbilledHoursReport(
int $year,
): BillableUnbilledHoursReportData {
$billableUnbilledHoursReportData = new BillableUnbilledHoursReportData();
['dateFrom' => $dateFrom, 'dateTo' => $dateTo] = $this->dateTimeHelper->getFirstAndLastDateOfYear($year);

$billableWorklogs = $this->worklogRepository->findBillableWorklogsByWorkerAndDateRange($dateFrom, $dateTo);

$projectData = [];
$projectTotals = [];
$totalHoursForAllProjects = 0;

foreach ($billableWorklogs as $billableWorklog) {
if (false === $billableWorklog->isBilled()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of this you should add a parameter "$isBilled = null" to findBillableWorklogsByWorkerAndDateRange repository method that only applies if $isBilled is not null. That would limit the number of entities you get from the database and thereby limit the memory use and execution time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great idea. I don't see a situation where it is relevant to get is_billed=null from findBillableWorklogsByWorkerAndDateRange at all, so i added a filter for this in the select.

I still want to be able to get billable worklogs, even after they are billed.

$projectName = $billableWorklog->getProject()->getName();
$issueName = $billableWorklog->getIssue()->getName();

// Initialize issue data if not already set
if (!isset($projectData[$projectName][$issueName])) {
$projectData[$projectName][$issueName] = [
'worklogs' => [],
'totalHours' => 0,
'id' => $billableWorklog->getIssue()->getProjectTrackerId(),
'linkToIssue' => $billableWorklog->getIssue()->getLinkToIssue(),
];
}

$workerIdentifier = $billableWorklog->getWorker();
$workerName = $this->workerRepository->findOneBy(['email' => $workerIdentifier])?->getName() ?? '';

// Add the worklog to the issue
$projectData[$projectName][$issueName]['worklogs'][] = [
'worker' => !empty($workerName) ? $workerName : $this->translator->trans('billable_unbilled_hours_report.no_worker'),
'description' => !empty($billableWorklog->getDescription()) ? $billableWorklog->getDescription() : $this->translator->trans('billable_unbilled_hours_report.no_description'),
'hours' => $billableWorklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS,
];

// Increment the issue total hours
$projectData[$projectName][$issueName]['totalHours'] += $billableWorklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS;

// Initialize project total if not already set
if (!isset($projectTotals[$projectName])) {
$projectTotals[$projectName] = 0;
}

// Add to the project total hours
$projectTotals[$projectName] += $billableWorklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS;

// Add to the global total hours
$totalHoursForAllProjects += $billableWorklog->getTimeSpentSeconds() * self::SECONDS_TO_HOURS;
}
}

// Add project data, project totals, and global total to the report data
$billableUnbilledHoursReportData->projectData->add($projectData);
$billableUnbilledHoursReportData->projectTotals = $projectTotals;
$billableUnbilledHoursReportData->totalHoursForAllProjects = $totalHoursForAllProjects;

return $billableUnbilledHoursReportData;
}
}
2 changes: 1 addition & 1 deletion src/Service/InvoicingRateReportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ private function getWorklogs(InvoicingRateReportViewModeEnum $viewMode, string $
return match ($viewMode) {
InvoicingRateReportViewModeEnum::SUMMARY => [
$this->worklogRepository->findWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
$this->worklogRepository->findBillableWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
$this->worklogRepository->findBillableWorklogsByWorkerAndDateRange($dateFrom, $dateTo, $workerIdentifier),
$this->worklogRepository->findBilledWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
],
};
Expand Down
2 changes: 1 addition & 1 deletion src/Service/WorkloadReportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ private function getWorklogs(ViewModeEnum $viewMode, string $workerIdentifier, \
{
return match ($viewMode) {
ViewModeEnum::WORKLOAD => $this->worklogRepository->findWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
ViewModeEnum::BILLABLE => $this->worklogRepository->findBillableWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
ViewModeEnum::BILLABLE => $this->worklogRepository->findBillableWorklogsByWorkerAndDateRange($dateFrom, $dateTo, $workerIdentifier),
ViewModeEnum::BILLED => $this->worklogRepository->findBilledWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo),
};
}
Expand Down
1 change: 1 addition & 0 deletions templates/components/navigation.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<ul class="navigation-item-submenu">
{{ 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.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.hour_report'|trans, role: 'ROLE_REPORT', route: path('app_hour_report')}) }}
{{ include('components/navigation-item.html.twig', {title: 'navigation.workload_report'|trans, role: 'ROLE_REPORT', route: path('app_workload_report')}) }}
{{ include('components/navigation-item.html.twig', {title: 'navigation.invoicing_rate_report'|trans, role: 'ROLE_REPORT', route: path('app_invoicing_rate_report')}) }}
Expand Down
Loading
Loading