-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from 21 commits
dfa0272
c0cf7ed
380bd5a
c6aab05
21dbe61
da2f4c7
dee4aa8
9fb6d64
e39f741
d48cf4a
bc5ca64
3fe63d4
85f9d41
6935d6f
b748ef5
6df6369
457052e
896d167
6e7c3c9
59e0712
5fdcf55
e7ea4fd
f695c03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||
]); | ||
} | ||
} |
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, | ||
]); | ||
} | ||
} |
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(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
<?php | ||
|
||
namespace App\Model\Reports; | ||
|
||
class BillableUnbilledHoursReportFormData | ||
{ | ||
public int $year; | ||
} |
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()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} | ||
} |
There was a problem hiding this comment.
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:
And then in BillableUnbilledHoursReportService call
Then the
would not be needed in BillableUnbilledHoursReportService. And you would process fewer entities.