diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8ff305..1706787f 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-178](https://github.com/itk-dev/economics/pull/178) + 2597: Workload report expansion. * [PR-175](https://github.com/itk-dev/economics/pull/175) 2617: Added forecast report. * [PR-173](https://github.com/itk-dev/economics/pull/173) diff --git a/migrations/Version20241217124134.php b/migrations/Version20241217124134.php new file mode 100644 index 00000000..dc9be5ee --- /dev/null +++ b/migrations/Version20241217124134.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE epic (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE issue_epic (issue_id INT NOT NULL, epic_id INT NOT NULL, INDEX IDX_412E98BD5E7AA58C (issue_id), INDEX IDX_412E98BD6B71E00E (epic_id), PRIMARY KEY(issue_id, epic_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE issue_epic ADD CONSTRAINT FK_412E98BD5E7AA58C FOREIGN KEY (issue_id) REFERENCES issue (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE issue_epic ADD CONSTRAINT FK_412E98BD6B71E00E FOREIGN KEY (epic_id) REFERENCES epic (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE version ADD is_billable TINYINT(1) NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE issue_epic DROP FOREIGN KEY FK_412E98BD5E7AA58C'); + $this->addSql('ALTER TABLE issue_epic DROP FOREIGN KEY FK_412E98BD6B71E00E'); + $this->addSql('DROP TABLE epic'); + $this->addSql('DROP TABLE issue_epic'); + $this->addSql('ALTER TABLE version DROP is_billable'); + } +} diff --git a/src/Command/SyncWorklogsCommand.php b/src/Command/SyncWorklogsCommand.php index 4363b577..8ea37cf2 100644 --- a/src/Command/SyncWorklogsCommand.php +++ b/src/Command/SyncWorklogsCommand.php @@ -54,11 +54,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->dataSynchronizationService->syncWorklogsForProject($project->getId(), function ($i, $length) use ($io) { if (0 == $i) { $io->progressStart($length); - } elseif ($i >= $length - 1) { - $io->progressFinish(); - } else { + } + + if ($i < $length) { $io->progressAdvance(); } + + if ($i == $length - 1) { + $io->progressFinish(); + } }, $dataProvider); $io->writeln(''); diff --git a/src/Controller/WorkloadReportController.php b/src/Controller/WorkloadReportController.php index afe58179..279a9045 100644 --- a/src/Controller/WorkloadReportController.php +++ b/src/Controller/WorkloadReportController.php @@ -6,7 +6,6 @@ use App\Model\Reports\WorkloadReportFormData; use App\Model\Reports\WorkloadReportPeriodTypeEnum as PeriodTypeEnum; use App\Model\Reports\WorkloadReportViewModeEnum as ViewModeEnum; -use App\Repository\DataProviderRepository; use App\Service\WorkloadReportService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -19,7 +18,6 @@ class WorkloadReportController extends AbstractController { public function __construct( - private readonly DataProviderRepository $dataProviderRepository, private readonly WorkloadReportService $workloadReportService, ) { } @@ -43,23 +41,14 @@ public function index(Request $request): Response $form->handleRequest($request); - $requestData = $request->query->all('workload_report'); + if ($form->isSubmitted() && $form->isValid()) { + $viewPeriodType = $form->get('viewPeriodType')->getData() ?? PeriodTypeEnum::WEEK; + $viewMode = $form->get('viewMode')->getData() ?? ViewModeEnum::WORKLOAD; - if (!empty($requestData['dataProvider'])) { - $dataProvider = $this->dataProviderRepository->find($requestData['dataProvider']); - - if ($form->isSubmitted() && $form->isValid()) { - $selectedDataProvider = $form->get('dataProvider')->getData() ?? $dataProvider; - $viewPeriodType = $form->get('viewPeriodType')->getData() ?? PeriodTypeEnum::WEEK; - $viewMode = $form->get('viewMode')->getData() ?? ViewModeEnum::WORKLOAD; - - if ($selectedDataProvider) { - try { - $reportData = $this->workloadReportService->getWorkloadReport($viewPeriodType, $viewMode); - } catch (\Exception $e) { - $error = $e->getMessage(); - } - } + try { + $reportData = $this->workloadReportService->getWorkloadReport($viewPeriodType, $viewMode); + } catch (\Exception $e) { + $error = $e->getMessage(); } } diff --git a/src/Entity/Epic.php b/src/Entity/Epic.php new file mode 100644 index 00000000..d711986d --- /dev/null +++ b/src/Entity/Epic.php @@ -0,0 +1,68 @@ +issues = new ArrayCollection(); + } + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 255)] + private ?string $title = null; + + #[ORM\ManyToMany(targetEntity: Issue::class, mappedBy: 'epics')] + private Collection $issues; + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + /** + * @return Collection + */ + public function getIssues(): Collection + { + return $this->issues; + } + + public function addIssue(Issue $issue): self + { + if (!$this->issues->contains($issue)) { + $this->issues->add($issue); + } + + return $this; + } + + public function removeIssue(Issue $issues): self + { + $this->issues->removeElement($issues); + + return $this; + } +} diff --git a/src/Entity/Issue.php b/src/Entity/Issue.php index 80a556ce..272377b8 100644 --- a/src/Entity/Issue.php +++ b/src/Entity/Issue.php @@ -43,6 +43,8 @@ class Issue extends AbstractBaseEntity #[ORM\Column(length: 255, nullable: true)] private ?string $epicName = null; + #[ORM\ManyToMany(targetEntity: Epic::class, inversedBy: 'issues')] + private Collection $epics; #[ORM\ManyToMany(targetEntity: Version::class, inversedBy: 'issues')] private Collection $versions; @@ -79,6 +81,7 @@ public function __construct() $this->versions = new ArrayCollection(); $this->worklogs = new ArrayCollection(); $this->products = new ArrayCollection(); + $this->epics = new ArrayCollection(); } public function getName(): ?string @@ -201,6 +204,30 @@ public function removeVersion(Version $version): self return $this; } + /** + * @return Collection + */ + public function getEpics(): Collection + { + return $this->epics; + } + + public function addEpic(Epic $epic): self + { + if (!$this->epics->contains($epic)) { + $this->epics->add($epic); + } + + return $this; + } + + public function removeEpic(Epic $epic): self + { + $this->epics->removeElement($epic); + + return $this; + } + /** * @return Collection */ diff --git a/src/Entity/IssueEpic.php b/src/Entity/IssueEpic.php new file mode 100644 index 00000000..d49b507d --- /dev/null +++ b/src/Entity/IssueEpic.php @@ -0,0 +1,17 @@ +issues = new ArrayCollection(); @@ -101,4 +104,16 @@ public function removeIssue(Issue $issue): self return $this; } + + public function isBillable(): ?bool + { + return $this->isBillable; + } + + public function setIsBillable(bool $isBillable): static + { + $this->isBillable = $isBillable; + + return $this; + } } diff --git a/src/Enum/NonBillableEpicsEnum.php b/src/Enum/NonBillableEpicsEnum.php new file mode 100644 index 00000000..4763b881 --- /dev/null +++ b/src/Enum/NonBillableEpicsEnum.php @@ -0,0 +1,24 @@ + + */ + public static function getAsArray(): array + { + return array_reduce( + self::cases(), + static fn (array $choices, NonBillableEpicsEnum $type) => $choices + [$type->name => $type->value], + [], + ); + } +} diff --git a/src/Enum/NonBillableVersionsEnum.php b/src/Enum/NonBillableVersionsEnum.php new file mode 100644 index 00000000..4d8eba31 --- /dev/null +++ b/src/Enum/NonBillableVersionsEnum.php @@ -0,0 +1,20 @@ + + */ + public static function getAsArray(): array + { + return array_reduce( + self::cases(), + static fn (array $choices, NonBillableVersionsEnum $type) => $choices + [$type->name => $type->value], + [], + ); + } +} diff --git a/src/Form/WorkloadReportType.php b/src/Form/WorkloadReportType.php index 48e70a62..04992881 100644 --- a/src/Form/WorkloadReportType.php +++ b/src/Form/WorkloadReportType.php @@ -2,12 +2,9 @@ namespace App\Form; -use App\Entity\DataProvider; use App\Model\Reports\WorkloadReportFormData; use App\Model\Reports\WorkloadReportPeriodTypeEnum as PeriodTypeEnum; use App\Model\Reports\WorkloadReportViewModeEnum; -use App\Repository\DataProviderRepository; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; @@ -17,32 +14,12 @@ class WorkloadReportType extends AbstractType { public function __construct( - private readonly DataProviderRepository $dataProviderRepository, - private readonly ?string $defaultDataProvider, ) { } public function buildForm(FormBuilderInterface $builder, array $options): void { - $dataProviders = $this->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' => 'workload_report.select_data_provider', - 'label_attr' => ['class' => 'label'], - 'attr' => [ - 'class' => 'form-element', - ], - 'data' => $defaultProvider, - 'choices' => $dataProviders, - ]) ->add('viewMode', EnumType::class, [ 'required' => false, 'label' => 'workload_report.select_viewmode', diff --git a/src/Interface/DataProviderServiceInterface.php b/src/Interface/DataProviderServiceInterface.php index fa304bdd..70bc1cca 100644 --- a/src/Interface/DataProviderServiceInterface.php +++ b/src/Interface/DataProviderServiceInterface.php @@ -6,7 +6,6 @@ use App\Model\Invoices\ClientData; use App\Model\Invoices\PagedResult; use App\Model\Invoices\ProjectDataCollection; -use App\Model\Invoices\WorklogDataCollection; use App\Model\Planning\PlanningData; use App\Model\SprintReport\SprintReportData; use App\Model\SprintReport\SprintReportProjects; @@ -36,5 +35,5 @@ public function getSprintReportVersions(string $projectId): SprintReportVersions public function getProjectDataCollection(): ProjectDataCollection; - public function getWorklogDataCollection(string $projectId): WorklogDataCollection; + public function getWorklogDataForProjectPaged(string $projectId, int $startAt = 0, $maxResults = 50): PagedResult; } diff --git a/src/Model/Reports/WorkloadReportData.php b/src/Model/Reports/WorkloadReportData.php index 267899c7..9ab57c69 100644 --- a/src/Model/Reports/WorkloadReportData.php +++ b/src/Model/Reports/WorkloadReportData.php @@ -13,12 +13,16 @@ class WorkloadReportData /** @var ArrayCollection */ public ArrayCollection $workers; public int $currentPeriodNumeric; + public ArrayCollection $periodAverages; + public float $totalAverage; public function __construct(string $viewmode) { $this->viewmode = $viewmode; $this->period = new ArrayCollection(); $this->workers = new ArrayCollection(); + $this->periodAverages = new ArrayCollection(); + $this->totalAverage = 0; } /** diff --git a/src/Model/Reports/WorkloadReportFormData.php b/src/Model/Reports/WorkloadReportFormData.php index 95f156fe..af91844e 100644 --- a/src/Model/Reports/WorkloadReportFormData.php +++ b/src/Model/Reports/WorkloadReportFormData.php @@ -2,11 +2,8 @@ namespace App\Model\Reports; -use App\Entity\DataProvider; - class WorkloadReportFormData { - public DataProvider $dataProvider; public WorkloadReportPeriodTypeEnum $viewPeriodType; public WorkloadReportViewModeEnum $viewMode; } diff --git a/src/Model/Reports/WorkloadReportViewModeEnum.php b/src/Model/Reports/WorkloadReportViewModeEnum.php index 85cb0b06..f290ac09 100644 --- a/src/Model/Reports/WorkloadReportViewModeEnum.php +++ b/src/Model/Reports/WorkloadReportViewModeEnum.php @@ -7,14 +7,16 @@ enum WorkloadReportViewModeEnum: string implements TranslatableInterface { - case WORKLOAD = 'workload_percentage_logged'; - case BILLABLE = 'billable_percentage_logged'; + case WORKLOAD = 'workload'; + case BILLABLE = 'billable'; + case BILLED = 'billed'; public function trans(TranslatorInterface $translator, ?string $locale = null): string { return match ($this) { self::WORKLOAD => $translator->trans('workload_report_view_mode_enum.workload.label', locale: $locale), self::BILLABLE => $translator->trans('workload_report_view_mode_enum.billable.label', locale: $locale), + self::BILLED => $translator->trans('workload_report_view_mode_enum.billed.label', locale: $locale), }; } } diff --git a/src/Repository/EpicRepository.php b/src/Repository/EpicRepository.php new file mode 100644 index 00000000..e3e3dc5c --- /dev/null +++ b/src/Repository/EpicRepository.php @@ -0,0 +1,41 @@ + + * + * @method Epic|null find($id, $lockMode = null, $lockVersion = null) + * @method Epic|null findOneBy(array $criteria, array $orderBy = null) + * @method Epic[] findAll() + * @method Epic[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class EpicRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Epic::class); + } + + public function save(Epic $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Epic $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Repository/VersionRepository.php b/src/Repository/VersionRepository.php index 19195d95..96f77776 100644 --- a/src/Repository/VersionRepository.php +++ b/src/Repository/VersionRepository.php @@ -5,6 +5,7 @@ use App\Entity\Version; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +use Knp\Component\Pager\PaginatorInterface; /** * @extends ServiceEntityRepository @@ -16,7 +17,7 @@ */ class VersionRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) + public function __construct(ManagerRegistry $registry, private readonly PaginatorInterface $paginator) { parent::__construct($registry, Version::class); } diff --git a/src/Repository/WorklogRepository.php b/src/Repository/WorklogRepository.php index d108ddaf..76160a0b 100644 --- a/src/Repository/WorklogRepository.php +++ b/src/Repository/WorklogRepository.php @@ -6,7 +6,8 @@ use App\Entity\Issue; use App\Entity\Project; use App\Entity\Worklog; -use App\Enum\BillableKindsEnum; +use App\Enum\NonBillableEpicsEnum; +use App\Enum\NonBillableVersionsEnum; use App\Model\Invoices\InvoiceEntryWorklogsFilterData; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\Tools\Pagination\Paginator; @@ -114,23 +115,74 @@ public function findWorklogsByWorkerAndDateRange(string $workerIdentifier, \Date public function findBillableWorklogsByWorkerAndDateRange(string $workerIdentifier, \DateTime $dateFrom, \DateTime $dateTo) { + $nonBillableEpics = NonBillableEpicsEnum::getAsArray(); + $nonBillableVersions = NonBillableVersionsEnum::getAsArray(); + $qb = $this->createQueryBuilder('worklog'); - $qb->leftJoin(Project::class, 'project', 'WITH', 'project.id = worklog.project'); + $qb->leftJoin(Project::class, 'project', 'WITH', 'project.id = worklog.project') + ->leftJoin('worklog.issue', 'issue') + ->leftJoin('issue.epics', 'epic') + ->leftJoin('issue.versions', 'version'); return $qb ->where($qb->expr()->between('worklog.started', ':dateFrom', ':dateTo')) ->andWhere('worklog.worker = :worker') - ->andWhere($qb->expr()->in('worklog.kind', ':billableKinds')) + ->andWhere($qb->expr()->andX( + $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'), + )) + ->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(); + } + + public function findBilledWorklogsByWorkerAndDateRange(string $workerIdentifier, \DateTime $dateFrom, \DateTime $dateTo) + { + $nonBillableEpics = NonBillableEpicsEnum::getAsArray(); + $nonBillableVersions = NonBillableVersionsEnum::getAsArray(); + + $qb = $this->createQueryBuilder('worklog'); + + $qb->leftJoin(Project::class, 'project', 'WITH', 'project.id = worklog.project') + ->leftJoin('worklog.issue', 'issue') + ->leftJoin('issue.epics', 'epic') + ->leftJoin('issue.versions', 'version'); + + return $qb + ->where($qb->expr()->between('worklog.started', ':dateFrom', ':dateTo')) + ->andWhere('worklog.worker = :worker') + ->andWhere($qb->expr()->andX( $qb->expr()->eq('worklog.isBilled', '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'), + )) + ->andWhere($qb->expr()->orX( + $qb->expr()->isNull('version.name'), + $qb->expr()->notIn('version.name', ':nonBillableVersions') )) ->setParameters([ 'worker' => $workerIdentifier, 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, - 'billableKinds' => array_values(BillableKindsEnum::getAsArray()), + 'nonBillableEpics' => array_values($nonBillableEpics), + 'nonBillableVersions' => array_values($nonBillableVersions), ]) ->getQuery()->getResult(); } diff --git a/src/Service/DataSynchronizationService.php b/src/Service/DataSynchronizationService.php index 1d5c53b9..2ce5f8f9 100644 --- a/src/Service/DataSynchronizationService.php +++ b/src/Service/DataSynchronizationService.php @@ -5,6 +5,7 @@ use App\Entity\Account; use App\Entity\Client; use App\Entity\DataProvider; +use App\Entity\Epic; use App\Entity\Invoice; use App\Entity\Issue; use App\Entity\Project; @@ -18,6 +19,7 @@ use App\Repository\AccountRepository; use App\Repository\ClientRepository; use App\Repository\DataProviderRepository; +use App\Repository\EpicRepository; use App\Repository\InvoiceRepository; use App\Repository\IssueRepository; use App\Repository\ProjectRepository; @@ -45,6 +47,7 @@ public function __construct( private readonly DataProviderService $dataProviderService, private readonly DataProviderRepository $dataProviderRepository, private readonly WorkerRepository $workerRepository, + private readonly EpicRepository $epicRepository, ) { } @@ -271,6 +274,26 @@ public function syncIssuesForProject(int $projectId, ?callable $progressCallback } } + $epicArray = explode(',', $issueDatum->epicName); + + foreach ($epicArray as $epicTitle) { + if (empty($epicTitle)) { + continue; + } + $epic = $this->epicRepository->findOneBy([ + 'title' => $epicTitle, + ]); + + if (!$epic) { + $epic = new Epic(); + $epic->setTitle($epicTitle); + $this->entityManager->persist($epic); + $this->entityManager->flush(); + } + + $issue->addEpic($epic); + } + if (null !== $progressCallback) { $progressCallback($issuesProcessed, $total); ++$issuesProcessed; @@ -296,9 +319,7 @@ public function syncIssuesForProject(int $projectId, ?callable $progressCallback public function syncWorklogsForProject(int $projectId, ?callable $progressCallback = null, DataProvider $dataProvider): void { $dataProviderId = $dataProvider->getId(); - $service = $this->dataProviderService->getService($dataProvider); - $project = $this->projectRepository->find($projectId); if (!$project) { @@ -311,17 +332,6 @@ public function syncWorklogsForProject(int $projectId, ?callable $progressCallba throw new EconomicsException($this->translator->trans('exception.project_tracker_id_not_set')); } - // Some worklogs may have been deleted in the source. - // Mark all project worklogs that are NOT - // - // * billed - // * invoiced - // - // as candidates for deletion. - // - // Due to flushing below, we store only this worklogs IDs - // (if we load worklog entities, they will become detached when flushing). - /** @var array $worklogsToDeleteIds */ $worklogsToDeleteIds = array_map( static fn (Worklog $worklog) => $worklog->getId(), array_filter( @@ -329,101 +339,105 @@ public function syncWorklogsForProject(int $projectId, ?callable $progressCallba static fn (Worklog $worklog): bool => !$worklog->isBilled() || null === $worklog->getInvoiceEntry() ) ); - // Index by ID + + // Ensure no null values before array_combine + $worklogsToDeleteIds = array_filter($worklogsToDeleteIds, static fn ($id) => null !== $id); + $worklogsToDeleteIds = array_combine($worklogsToDeleteIds, $worklogsToDeleteIds); - $worklogData = $service->getWorklogDataCollection($projectTrackerId); $worklogsAdded = 0; - foreach ($worklogData->worklogData as $worklogDatum) { - $project = $this->projectRepository->find($projectId); + $startAt = 0; + do { + $dataProvider = $this->dataProviderRepository->find($dataProviderId); + $project = $this->projectRepository->find($projectId); if (!$project) { throw new EconomicsException($this->translator->trans('exception.project_not_found')); } - $issue = !empty($worklogDatum->projectTrackerIssueId) ? $this->issueRepository->findOneBy([ - 'projectTrackerId' => $worklogDatum->projectTrackerIssueId, - 'dataProvider' => $dataProvider, - ]) : null; - - if (null === $issue) { - // A worklog should always have an issue, so ignore the worklog. - continue; - } - - $worklog = $this->worklogRepository->findOneBy([ - 'worklogId' => $worklogDatum->projectTrackerId, - 'dataProvider' => $dataProvider, - ]); - - if (!$worklog) { - $worklog = new Worklog(); + $pagedWorklogData = $service->getWorklogDataForProjectPaged($projectTrackerId, $startAt, self::MAX_RESULTS); + $total = $pagedWorklogData->total; + $counter = 0; - $dataProvider = $this->dataProviderRepository->find($dataProviderId); + foreach ($pagedWorklogData->items as $worklogDatum) { + $project = $this->projectRepository->find($projectId); + if (!$project) { + throw new EconomicsException($this->translator->trans('exception.project_not_found')); + } - $worklog->setDataProvider($dataProvider); + $issue = !empty($worklogDatum->projectTrackerIssueId) ? $this->issueRepository->findOneBy([ + 'projectTrackerId' => $worklogDatum->projectTrackerIssueId, + 'dataProvider' => $dataProvider, + ]) : null; - $this->entityManager->persist($worklog); - } + if (null === $issue) { + continue; + } - $worklog - ->setWorklogId($worklogDatum->projectTrackerId) - ->setDescription($worklogDatum->comment) - ->setWorker($worklogDatum->worker) - ->setStarted($worklogDatum->started) - ->setProjectTrackerIssueId($worklogDatum->projectTrackerIssueId) - ->setTimeSpentSeconds($worklogDatum->timeSpentSeconds) - ->setTimeSpentSeconds($worklogDatum->timeSpentSeconds) - ->setIssue($issue) - ->setKind(BillableKindsEnum::tryFrom($worklogDatum->kind)); - - if (null != $worklog->getProjectTrackerIssueId()) { - $issue = $this->issueRepository->findOneBy(['projectTrackerId' => $worklog->getProjectTrackerIssueId()]); - $worklog->setIssue($issue); - } + $worklog = $this->worklogRepository->findOneBy([ + 'worklogId' => $worklogDatum->projectTrackerId, + 'dataProvider' => $dataProvider, + ]); - if (!$worklog->isBilled() && $worklogDatum->projectTrackerIsBilled) { - $worklog->setIsBilled(true); - $worklog->setBilledSeconds($worklogDatum->timeSpentSeconds); - } + if (!$worklog) { + $worklog = new Worklog(); + $worklog->setDataProvider($dataProvider); + $this->entityManager->persist($worklog); + } - $project->addWorklog($worklog); - // Keep the worklog. - $worklogId = $worklog->getId(); + $worklog + ->setWorklogId($worklogDatum->projectTrackerId) + ->setDescription($worklogDatum->comment) + ->setWorker($worklogDatum->worker) + ->setStarted($worklogDatum->started) + ->setProjectTrackerIssueId($worklogDatum->projectTrackerIssueId) + ->setTimeSpentSeconds($worklogDatum->timeSpentSeconds) + ->setIssue($issue) + ->setKind(BillableKindsEnum::tryFrom($worklogDatum->kind)); + + if (null != $worklog->getProjectTrackerIssueId()) { + $issue = $this->issueRepository->findOneBy(['projectTrackerId' => $worklog->getProjectTrackerIssueId()]); + $worklog->setIssue($issue); + } - if (null !== $worklogId) { - unset($worklogsToDeleteIds[$worklogId]); - } + if (!$worklog->isBilled() && $worklogDatum->projectTrackerIsBilled) { + $worklog->setIsBilled(true); + $worklog->setBilledSeconds($worklogDatum->timeSpentSeconds); + } - if (null !== $progressCallback) { - $progressCallback($worklogsAdded, count($worklogData->worklogData)); + $project->addWorklog($worklog); + $worklogId = $worklog->getId(); - ++$worklogsAdded; - } + if (null !== $worklogId) { + unset($worklogsToDeleteIds[$worklogId]); + } - $workerEmail = $worklog->getWorker(); + if (null !== $progressCallback) { + $progressCallback($worklogsAdded, $total); + ++$worklogsAdded; + } - if ($workerEmail && filter_var($workerEmail, FILTER_VALIDATE_EMAIL)) { - $worker = $this->workerRepository->findOneBy(['email' => $workerEmail]); + $workerEmail = $worklog->getWorker(); - if (!$worker) { - $worker = new Worker(); - $worker->setEmail($workerEmail); + if ($workerEmail && filter_var($workerEmail, FILTER_VALIDATE_EMAIL)) { + $worker = $this->workerRepository->findOneBy(['email' => $workerEmail]); - $this->entityManager->persist($worker); - $this->entityManager->flush(); + if (!$worker) { + $worker = new Worker(); + $worker->setEmail($workerEmail); + $this->entityManager->persist($worker); + } } - } - // Flush and clear for each batch. - if (0 === $worklogsAdded % self::BATCH_SIZE) { + ++$counter; $this->entityManager->flush(); - $this->entityManager->clear(); } - } - // Remove leftover worklogs from project and remove the worklogs. + $startAt += $pagedWorklogData->maxResults; + } while ($startAt < $total); + $worklogsToDelete = $this->worklogRepository->findBy(['id' => $worklogsToDeleteIds]); + foreach ($worklogsToDelete as $worklog) { $project->removeWorklog($worklog); $this->entityManager->remove($worklog); diff --git a/src/Service/JiraApiService.php b/src/Service/JiraApiService.php index 301edcf4..20ebf81b 100644 --- a/src/Service/JiraApiService.php +++ b/src/Service/JiraApiService.php @@ -783,6 +783,14 @@ public function getIssueDataCollection(string $projectId): IssueDataCollection throw new ApiServiceException('Method not implemented', 501); } + /** + * @throws ApiServiceException + */ + public function getworklogdataforprojectpaged(string $projectId, $startAt = 0, $maxResults = 50): PagedResult + { + throw new ApiServiceException('Method not implemented', 501); + } + /** * @throws ApiServiceException */ diff --git a/src/Service/LeantimeApiService.php b/src/Service/LeantimeApiService.php index 50141060..cb1d7d27 100644 --- a/src/Service/LeantimeApiService.php +++ b/src/Service/LeantimeApiService.php @@ -307,9 +307,10 @@ public function getSprintReportVersions(string $projectId): SprintReportVersions return $sprintReportVersions; } - public function getWorklogDataCollection(string $projectId): WorklogDataCollection + public function getWorklogDataForProjectPaged(string $projectId, $startAt = 0, $maxResults = 50): PagedResult { $worklogDataCollection = new WorklogDataCollection(); + $worklogs = $this->getProjectWorklogs($projectId); $workersData = $this->request(self::API_PATH_JSONRPC, 'POST', 'leantime.rpc.users.getAll'); @@ -320,10 +321,10 @@ public function getWorklogDataCollection(string $projectId): WorklogDataCollecti return $carry; }, []); - // Filter out all worklogs that do not belong to the project. - // TODO: Remove filter when worklogs are filtered correctly by projectId in the API. $worklogs = array_filter($worklogs, fn ($worklog) => $worklog->projectId == $projectId); + $total = count($worklogs); + foreach ($worklogs as $worklog) { $worklogData = new WorklogData(); if (isset($worklog->ticketId)) { @@ -340,7 +341,8 @@ public function getWorklogDataCollection(string $projectId): WorklogDataCollecti } } - return $worklogDataCollection; + // Return a new PagedResult instance + return new PagedResult($worklogDataCollection->worklogData->toArray(), $startAt, $maxResults, $total); } /** diff --git a/src/Service/WorkloadReportService.php b/src/Service/WorkloadReportService.php index edbc6c22..44951639 100644 --- a/src/Service/WorkloadReportService.php +++ b/src/Service/WorkloadReportService.php @@ -48,6 +48,8 @@ public function getWorkloadReport(PeriodTypeEnum $viewPeriodType = PeriodTypeEnu $currentPeriodReached = false; $expectedWorkloadSum = 0; $loggedHoursSum = 0; + $periodSums = []; + $periodCounts = []; foreach ($periods as $period) { // Add current period match-point (current week-number, month-number etc.) @@ -91,6 +93,14 @@ public function getWorkloadReport(PeriodTypeEnum $viewPeriodType = PeriodTypeEnu // Add percentage result to worker for current period. $workloadReportWorker->loggedPercentage->set($period, $roundedLoggedPercentage); + + // Increment the sum and count for this period + $periodSums[$period] = ($periodSums[$period] ?? 0) + $roundedLoggedPercentage; + $periodCounts[$period] = ($periodCounts[$period] ?? 0) + 1; + + // Calculate and set the average for this period + $average = round($periodSums[$period] / $periodCounts[$period], 2); + $workloadReportData->periodAverages->set($period, $average); } $workloadReportWorker->average = $expectedWorkloadSum > 0 ? round($loggedHoursSum / $expectedWorkloadSum * 100, 2) : 0; @@ -98,6 +108,19 @@ public function getWorkloadReport(PeriodTypeEnum $viewPeriodType = PeriodTypeEnu $workloadReportData->workers->add($workloadReportWorker); } + // Calculate and set the total average + $numberOfPeriods = count($workloadReportData->periodAverages); + + // Calculate the sum of period averages + $averageSum = array_reduce($workloadReportData->periodAverages->toArray(), function ($carry, $item) { + return $carry + $item; + }, 0); + + // Calculate the total average of averages + if ($numberOfPeriods > 0) { + $workloadReportData->totalAverage = round($averageSum / $numberOfPeriods, 2); + } + return $workloadReportData; } @@ -191,6 +214,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::BILLED => $this->worklogRepository->findBilledWorklogsByWorkerAndDateRange($workerIdentifier, $dateFrom, $dateTo), }; } } diff --git a/templates/reports/workload_report.html.twig b/templates/reports/workload_report.html.twig index 71c00283..0a28d2fe 100644 --- a/templates/reports/workload_report.html.twig +++ b/templates/reports/workload_report.html.twig @@ -60,12 +60,27 @@ {{ week ~ '%' }} {% endfor %} - + {{ worker.average ~ '%' }} {% endfor %} + + + + {{ 'workload_report.average'|trans }} + + + {% for periodNumeric, period in data.period %} + + {{ data.periodAverages[periodNumeric] ~ '%' }} + + {% endfor %} + {{ data.totalAverage ~ '%' }} + +
{{ 'workload_report.hidden-entries'|trans }}: diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index 1066f15b..4466de95 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -756,6 +756,8 @@ workload_report_view_mode_enum: label: 'Loggede timer' billable: label: 'Fakturerbare timer' + billed: + label: 'Fakturerede timer' subscription: title: 'Dine abonnementer'