mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-21 19:01:42 +00:00
Implement DTO-based PartsDataTable with optimized queries
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
This commit is contained in:
parent
0aeae79a53
commit
b5581e6280
6 changed files with 859 additions and 69 deletions
|
|
@ -54,6 +54,8 @@ class TwoStepORMAdapter extends ORMAdapter
|
||||||
|
|
||||||
private \Closure|null $query_modifier = null;
|
private \Closure|null $query_modifier = null;
|
||||||
|
|
||||||
|
private \Closure|null $dto_hydrator = null;
|
||||||
|
|
||||||
public function __construct(?ManagerRegistry $registry = null)
|
public function __construct(?ManagerRegistry $registry = null)
|
||||||
{
|
{
|
||||||
parent::__construct($registry);
|
parent::__construct($registry);
|
||||||
|
|
@ -82,6 +84,10 @@ class TwoStepORMAdapter extends ORMAdapter
|
||||||
$resolver->setDefault('query_modifier', null);
|
$resolver->setDefault('query_modifier', null);
|
||||||
$resolver->setAllowedTypes('query_modifier', ['null', \Closure::class]);
|
$resolver->setAllowedTypes('query_modifier', ['null', \Closure::class]);
|
||||||
|
|
||||||
|
//Add the possibility to use a custom DTO hydrator instead of entity hydration
|
||||||
|
$resolver->setDefault('dto_hydrator', null);
|
||||||
|
$resolver->setAllowedTypes('dto_hydrator', ['null', \Closure::class]);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function afterConfiguration(array $options): void
|
protected function afterConfiguration(array $options): void
|
||||||
|
|
@ -90,6 +96,7 @@ class TwoStepORMAdapter extends ORMAdapter
|
||||||
$this->detailQueryCallable = $options['detail_query'];
|
$this->detailQueryCallable = $options['detail_query'];
|
||||||
$this->use_simple_total = $options['simple_total_query'];
|
$this->use_simple_total = $options['simple_total_query'];
|
||||||
$this->query_modifier = $options['query_modifier'];
|
$this->query_modifier = $options['query_modifier'];
|
||||||
|
$this->dto_hydrator = $options['dto_hydrator'];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function prepareQuery(AdapterQuery $query): void
|
protected function prepareQuery(AdapterQuery $query): void
|
||||||
|
|
@ -189,9 +196,19 @@ class TwoStepORMAdapter extends ORMAdapter
|
||||||
|
|
||||||
$detail_query = $detail_qb->getQuery();
|
$detail_query = $detail_qb->getQuery();
|
||||||
|
|
||||||
//We pass the results of the detail query to the datatable for view rendering
|
// If a DTO hydrator is configured, use array hydration and build DTOs
|
||||||
foreach ($detail_query->getResult() as $item) {
|
if ($this->dto_hydrator !== null) {
|
||||||
yield $item;
|
$arrayResults = $detail_query->getArrayResult();
|
||||||
|
$dtos = $this->dto_hydrator->__invoke($arrayResults);
|
||||||
|
foreach ($dtos as $dto) {
|
||||||
|
yield $dto;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Original behavior: hydrate as entities
|
||||||
|
//We pass the results of the detail query to the datatable for view rendering
|
||||||
|
foreach ($detail_query->getResult() as $item) {
|
||||||
|
yield $item;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
409
src/DataTables/DTO/PartDTO.php
Normal file
409
src/DataTables/DTO/PartDTO.php
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\DataTables\DTO;
|
||||||
|
|
||||||
|
use App\Entity\Parts\ManufacturingStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight DTO for PartsDataTable containing only data needed for table rendering.
|
||||||
|
* This avoids loading full Part entities with all relationships, significantly improving performance.
|
||||||
|
*
|
||||||
|
* The DTO is populated directly from optimized query results, selecting only required fields.
|
||||||
|
*/
|
||||||
|
class PartDTO
|
||||||
|
{
|
||||||
|
/** @var PartLotDTO[] */
|
||||||
|
private array $partLots = [];
|
||||||
|
|
||||||
|
/** @var int[] */
|
||||||
|
private array $attachmentIds = [];
|
||||||
|
|
||||||
|
/** @var array<array{id: int, name: string}> */
|
||||||
|
private array $projects = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
// Core Part fields
|
||||||
|
public readonly int $id,
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly ?string $ipn,
|
||||||
|
public readonly ?string $description,
|
||||||
|
public readonly float $minamount,
|
||||||
|
public readonly ?string $manufacturer_product_number,
|
||||||
|
public readonly ?float $mass,
|
||||||
|
public readonly ?string $gtin,
|
||||||
|
public readonly string $tags,
|
||||||
|
public readonly bool $favorite,
|
||||||
|
public readonly bool $needs_review,
|
||||||
|
public readonly ?\DateTimeInterface $addedDate,
|
||||||
|
public readonly ?\DateTimeInterface $lastModified,
|
||||||
|
public readonly ?ManufacturingStatus $manufacturing_status,
|
||||||
|
|
||||||
|
// Related entity IDs and names (pre-joined for display)
|
||||||
|
public readonly ?int $category_id,
|
||||||
|
public readonly ?string $category_name,
|
||||||
|
public readonly ?int $footprint_id,
|
||||||
|
public readonly ?string $footprint_name,
|
||||||
|
public readonly ?int $manufacturer_id,
|
||||||
|
public readonly ?string $manufacturer_name,
|
||||||
|
public readonly ?int $partUnit_id,
|
||||||
|
public readonly ?string $partUnit_name,
|
||||||
|
public readonly ?string $partUnit_unit,
|
||||||
|
public readonly ?int $partCustomState_id,
|
||||||
|
public readonly ?string $partCustomState_name,
|
||||||
|
public readonly ?int $master_picture_attachment_id,
|
||||||
|
public readonly ?string $master_picture_attachment_filename,
|
||||||
|
public readonly ?string $master_picture_attachment_name,
|
||||||
|
public readonly ?int $footprint_attachment_id,
|
||||||
|
public readonly ?int $builtProject_id,
|
||||||
|
public readonly ?string $builtProject_name,
|
||||||
|
|
||||||
|
// Computed/aggregated fields
|
||||||
|
public readonly float $amountSum,
|
||||||
|
public readonly float $expiredAmountSum,
|
||||||
|
public readonly bool $hasUnknownAmount,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibility methods that match Part entity interface
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFavorite(): bool
|
||||||
|
{
|
||||||
|
return $this->favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isNeedsReview(): bool
|
||||||
|
{
|
||||||
|
return $this->needs_review;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isNotEnoughInstock(): bool
|
||||||
|
{
|
||||||
|
return $this->amountSum < $this->minamount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAmountUnknown(): bool
|
||||||
|
{
|
||||||
|
return $this->hasUnknownAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAmountSum(): float
|
||||||
|
{
|
||||||
|
return $this->amountSum;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExpiredAmountSum(): float
|
||||||
|
{
|
||||||
|
return $this->expiredAmountSum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get built project as a simple object compatible with renderer needs
|
||||||
|
*/
|
||||||
|
public function getBuiltProject(): ?object
|
||||||
|
{
|
||||||
|
if ($this->builtProject_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new class($this->builtProject_id, $this->builtProject_name) {
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly ?string $name,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get part unit as a simple object compatible with renderer needs
|
||||||
|
*/
|
||||||
|
public function getPartUnit(): ?object
|
||||||
|
{
|
||||||
|
if ($this->partUnit_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new class($this->partUnit_id, $this->partUnit_name, $this->partUnit_unit) {
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly ?string $name,
|
||||||
|
private readonly ?string $unit,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUnit(): ?string
|
||||||
|
{
|
||||||
|
return $this->unit;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get category as a simple object compatible with renderer needs
|
||||||
|
*/
|
||||||
|
public function getCategory(): ?object
|
||||||
|
{
|
||||||
|
if ($this->category_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new class($this->category_id, $this->category_name) {
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly ?string $name,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get footprint as a simple object compatible with renderer needs
|
||||||
|
*/
|
||||||
|
public function getFootprint(): ?object
|
||||||
|
{
|
||||||
|
if ($this->footprint_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new class($this->footprint_id, $this->footprint_name) {
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly ?string $name,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get manufacturer as a simple object compatible with renderer needs
|
||||||
|
*/
|
||||||
|
public function getManufacturer(): ?object
|
||||||
|
{
|
||||||
|
if ($this->manufacturer_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new class($this->manufacturer_id, $this->manufacturer_name) {
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly ?string $name,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get part custom state as a simple object compatible with renderer needs
|
||||||
|
*/
|
||||||
|
public function getPartCustomState(): ?object
|
||||||
|
{
|
||||||
|
if ($this->partCustomState_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new class($this->partCustomState_id, $this->partCustomState_name) {
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly ?string $name,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get master picture attachment as a simple object for rendering
|
||||||
|
*/
|
||||||
|
public function getMasterPictureAttachment(): ?object
|
||||||
|
{
|
||||||
|
if ($this->master_picture_attachment_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new class($this->master_picture_attachment_id, $this->master_picture_attachment_name, $this->master_picture_attachment_filename) {
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly ?string $name,
|
||||||
|
private readonly ?string $filename,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFilename(): ?string
|
||||||
|
{
|
||||||
|
return $this->filename;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get footprint's master picture attachment
|
||||||
|
*/
|
||||||
|
public function getFootprintAttachment(): ?object
|
||||||
|
{
|
||||||
|
if ($this->footprint_attachment_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new class($this->footprint_attachment_id) {
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return PartLotDTO[]
|
||||||
|
*/
|
||||||
|
public function getPartLots(): array
|
||||||
|
{
|
||||||
|
return $this->partLots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PartLotDTO[] $partLots
|
||||||
|
*/
|
||||||
|
public function setPartLots(array $partLots): void
|
||||||
|
{
|
||||||
|
$this->partLots = $partLots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get attachment IDs for rendering
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
public function getAttachments(): array
|
||||||
|
{
|
||||||
|
return $this->attachmentIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $attachmentIds
|
||||||
|
*/
|
||||||
|
public function setAttachments(array $attachmentIds): void
|
||||||
|
{
|
||||||
|
$this->attachmentIds = $attachmentIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get projects where this part is used
|
||||||
|
* @return array<array{id: int, name: string}>
|
||||||
|
*/
|
||||||
|
public function getProjects(): array
|
||||||
|
{
|
||||||
|
return $this->projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array{id: int, name: string}> $projects
|
||||||
|
*/
|
||||||
|
public function setProjects(array $projects): void
|
||||||
|
{
|
||||||
|
$this->projects = $projects;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/DataTables/DTO/PartDTOHydrator.php
Normal file
163
src/DataTables/DTO/PartDTOHydrator.php
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\DataTables\DTO;
|
||||||
|
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Entity\Parts\PartLot;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\Query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds PartDTO objects from database query results.
|
||||||
|
* Handles the hydration of lightweight DTOs instead of full Part entities.
|
||||||
|
*/
|
||||||
|
class PartDTOHydrator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build PartDTO objects from a query result set.
|
||||||
|
* Expects results from the optimized detail query that selects specific fields.
|
||||||
|
*
|
||||||
|
* @param array $queryResults Array of results from the detail query
|
||||||
|
* @return PartDTO[]
|
||||||
|
*/
|
||||||
|
public function hydrateFromQueryResults(array $queryResults): array
|
||||||
|
{
|
||||||
|
$dtos = [];
|
||||||
|
$partLotsGrouped = [];
|
||||||
|
$attachmentsGrouped = [];
|
||||||
|
$projectsGrouped = [];
|
||||||
|
|
||||||
|
// First pass: Group related data by part ID
|
||||||
|
foreach ($queryResults as $row) {
|
||||||
|
$partId = $row['id'];
|
||||||
|
|
||||||
|
// Group part lots by part ID
|
||||||
|
if (isset($row['partLot_id']) && $row['partLot_id'] !== null) {
|
||||||
|
if (!isset($partLotsGrouped[$partId])) {
|
||||||
|
$partLotsGrouped[$partId] = [];
|
||||||
|
}
|
||||||
|
$lotKey = $row['partLot_id'];
|
||||||
|
if (!isset($partLotsGrouped[$partId][$lotKey])) {
|
||||||
|
$partLotsGrouped[$partId][$lotKey] = new PartLotDTO(
|
||||||
|
id: $row['partLot_id'],
|
||||||
|
storage_location_id: $row['storageLocation_id'] ?? null,
|
||||||
|
storage_location_name: $row['storageLocation_name'] ?? null,
|
||||||
|
storage_location_fullPath: $row['storageLocation_fullPath'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group attachments by part ID
|
||||||
|
if (isset($row['attachment_id']) && $row['attachment_id'] !== null) {
|
||||||
|
if (!isset($attachmentsGrouped[$partId])) {
|
||||||
|
$attachmentsGrouped[$partId] = [];
|
||||||
|
}
|
||||||
|
$attachmentsGrouped[$partId][$row['attachment_id']] = $row['attachment_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group projects by part ID
|
||||||
|
if (isset($row['project_id']) && $row['project_id'] !== null) {
|
||||||
|
if (!isset($projectsGrouped[$partId])) {
|
||||||
|
$projectsGrouped[$partId] = [];
|
||||||
|
}
|
||||||
|
$projectKey = $row['project_id'];
|
||||||
|
if (!isset($projectsGrouped[$partId][$projectKey])) {
|
||||||
|
$projectsGrouped[$partId][$projectKey] = [
|
||||||
|
'id' => $row['project_id'],
|
||||||
|
'name' => $row['project_name'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: Create DTOs (one per part, using first row's data)
|
||||||
|
$processedParts = [];
|
||||||
|
foreach ($queryResults as $row) {
|
||||||
|
$partId = $row['id'];
|
||||||
|
|
||||||
|
// Skip if we've already processed this part
|
||||||
|
if (isset($processedParts[$partId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$processedParts[$partId] = true;
|
||||||
|
|
||||||
|
$dto = new PartDTO(
|
||||||
|
id: $row['id'],
|
||||||
|
name: $row['name'],
|
||||||
|
ipn: $row['ipn'] ?? null,
|
||||||
|
description: $row['description'] ?? null,
|
||||||
|
minamount: $row['minamount'] ?? 0.0,
|
||||||
|
manufacturer_product_number: $row['manufacturer_product_number'] ?? null,
|
||||||
|
mass: $row['mass'] ?? null,
|
||||||
|
gtin: $row['gtin'] ?? null,
|
||||||
|
tags: $row['tags'] ?? '',
|
||||||
|
favorite: $row['favorite'] ?? false,
|
||||||
|
needs_review: $row['needs_review'] ?? false,
|
||||||
|
addedDate: $row['addedDate'] ?? null,
|
||||||
|
lastModified: $row['lastModified'] ?? null,
|
||||||
|
manufacturing_status: $row['manufacturing_status'] ?? null,
|
||||||
|
category_id: $row['category_id'] ?? null,
|
||||||
|
category_name: $row['category_name'] ?? null,
|
||||||
|
footprint_id: $row['footprint_id'] ?? null,
|
||||||
|
footprint_name: $row['footprint_name'] ?? null,
|
||||||
|
manufacturer_id: $row['manufacturer_id'] ?? null,
|
||||||
|
manufacturer_name: $row['manufacturer_name'] ?? null,
|
||||||
|
partUnit_id: $row['partUnit_id'] ?? null,
|
||||||
|
partUnit_name: $row['partUnit_name'] ?? null,
|
||||||
|
partUnit_unit: $row['partUnit_unit'] ?? null,
|
||||||
|
partCustomState_id: $row['partCustomState_id'] ?? null,
|
||||||
|
partCustomState_name: $row['partCustomState_name'] ?? null,
|
||||||
|
master_picture_attachment_id: $row['master_picture_attachment_id'] ?? null,
|
||||||
|
master_picture_attachment_filename: $row['master_picture_attachment_filename'] ?? null,
|
||||||
|
master_picture_attachment_name: $row['master_picture_attachment_name'] ?? null,
|
||||||
|
footprint_attachment_id: $row['footprint_attachment_id'] ?? null,
|
||||||
|
builtProject_id: $row['builtProject_id'] ?? null,
|
||||||
|
builtProject_name: $row['builtProject_name'] ?? null,
|
||||||
|
amountSum: $row['amountSum'] ?? 0.0,
|
||||||
|
expiredAmountSum: $row['expiredAmountSum'] ?? 0.0,
|
||||||
|
hasUnknownAmount: $row['hasUnknownAmount'] ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attach grouped data
|
||||||
|
if (isset($partLotsGrouped[$partId])) {
|
||||||
|
$dto->setPartLots(array_values($partLotsGrouped[$partId]));
|
||||||
|
}
|
||||||
|
if (isset($attachmentsGrouped[$partId])) {
|
||||||
|
$dto->setAttachments(array_values($attachmentsGrouped[$partId]));
|
||||||
|
}
|
||||||
|
if (isset($projectsGrouped[$partId])) {
|
||||||
|
$dto->setProjects(array_values($projectsGrouped[$partId]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$dtos[] = $dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dtos;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/DataTables/DTO/PartLotDTO.php
Normal file
71
src/DataTables/DTO/PartLotDTO.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\DataTables\DTO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight data structure representing a Part Lot for table display.
|
||||||
|
* Contains only essential fields needed for rendering storage locations.
|
||||||
|
*/
|
||||||
|
readonly class PartLotDTO
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public ?int $storage_location_id,
|
||||||
|
public ?string $storage_location_name,
|
||||||
|
public ?string $storage_location_fullPath,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStorageLocation(): ?object
|
||||||
|
{
|
||||||
|
if ($this->storage_location_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a simple object with needed methods for rendering
|
||||||
|
return new class($this->storage_location_id, $this->storage_location_name, $this->storage_location_fullPath) {
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $id,
|
||||||
|
private readonly ?string $name,
|
||||||
|
private readonly ?string $fullPath,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFullPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->fullPath;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\DataTables\Helpers;
|
namespace App\DataTables\Helpers;
|
||||||
|
|
||||||
|
use App\DataTables\DTO\PartDTO;
|
||||||
use App\Entity\Parts\StorageLocation;
|
use App\Entity\Parts\StorageLocation;
|
||||||
use App\Entity\ProjectSystem\Project;
|
use App\Entity\ProjectSystem\Project;
|
||||||
use App\Entity\Attachments\Attachment;
|
use App\Entity\Attachments\Attachment;
|
||||||
|
|
@ -31,6 +32,7 @@ use App\Services\Attachments\AttachmentURLGenerator;
|
||||||
use App\Services\Attachments\PartPreviewGenerator;
|
use App\Services\Attachments\PartPreviewGenerator;
|
||||||
use App\Services\EntityURLGenerator;
|
use App\Services\EntityURLGenerator;
|
||||||
use App\Services\Formatters\AmountFormatter;
|
use App\Services\Formatters\AmountFormatter;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,10 +46,11 @@ class PartDataTableHelper
|
||||||
private readonly EntityURLGenerator $entityURLGenerator,
|
private readonly EntityURLGenerator $entityURLGenerator,
|
||||||
private readonly TranslatorInterface $translator,
|
private readonly TranslatorInterface $translator,
|
||||||
private readonly AmountFormatter $amountFormatter,
|
private readonly AmountFormatter $amountFormatter,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function renderName(Part $context): string
|
public function renderName($context): string
|
||||||
{
|
{
|
||||||
$icon = '';
|
$icon = '';
|
||||||
|
|
||||||
|
|
@ -60,22 +63,62 @@ class PartDataTableHelper
|
||||||
$icon = sprintf('<i class="fa-solid fa-ambulance fa-fw me-1" title="%s"></i>',
|
$icon = sprintf('<i class="fa-solid fa-ambulance fa-fw me-1" title="%s"></i>',
|
||||||
$this->translator->trans('part.needs_review.badge'));
|
$this->translator->trans('part.needs_review.badge'));
|
||||||
}
|
}
|
||||||
if ($context->getBuiltProject() instanceof Project) {
|
if ($context->getBuiltProject() !== null) {
|
||||||
$icon = sprintf('<i class="fa-solid fa-box-archive fa-fw me-1" title="%s"></i>',
|
$icon = sprintf('<i class="fa-solid fa-box-archive fa-fw me-1" title="%s"></i>',
|
||||||
$this->translator->trans('part.info.projectBuildPart.hint').': '.$context->getBuiltProject()->getName());
|
$this->translator->trans('part.info.projectBuildPart.hint').': '.$context->getBuiltProject()->getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For DTO, create a Part proxy for URL generation
|
||||||
|
$partForUrl = $context;
|
||||||
|
if ($context instanceof PartDTO) {
|
||||||
|
$partForUrl = $this->entityManager->getReference(Part::class, $context->getId());
|
||||||
|
}
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'<a href="%s">%s%s</a>',
|
'<a href="%s">%s%s</a>',
|
||||||
$this->entityURLGenerator->infoURL($context),
|
$this->entityURLGenerator->infoURL($partForUrl),
|
||||||
$icon,
|
$icon,
|
||||||
htmlspecialchars($context->getName())
|
htmlspecialchars($context->getName())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function renderPicture(Part $context): string
|
public function renderPicture($context): string
|
||||||
{
|
{
|
||||||
|
// For DTO, we already have the attachment info, so we can create a lightweight attachment object
|
||||||
|
if ($context instanceof PartDTO) {
|
||||||
|
$preview_attachment = null;
|
||||||
|
|
||||||
|
// First check if part has a master picture attachment
|
||||||
|
if ($context->master_picture_attachment_id !== null) {
|
||||||
|
$preview_attachment = $this->entityManager->getReference(Attachment::class, $context->master_picture_attachment_id);
|
||||||
|
}
|
||||||
|
// Otherwise check if footprint has a master picture attachment
|
||||||
|
elseif ($context->footprint_attachment_id !== null) {
|
||||||
|
$preview_attachment = $this->entityManager->getReference(Attachment::class, $context->footprint_attachment_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$preview_attachment instanceof Attachment) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For DTO we have the name and filename pre-loaded, but we need to access them from the full attachment
|
||||||
|
// Since we're using getReference, we'll get a proxy that will load data on access
|
||||||
|
$title = htmlspecialchars($context->master_picture_attachment_name ?? '');
|
||||||
|
if ($context->master_picture_attachment_filename) {
|
||||||
|
$title .= ' ('.htmlspecialchars($context->master_picture_attachment_filename).')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'<img alt="%s" src="%s" data-thumbnail="%s" class="%s" data-title="%s" data-controller="elements--hoverpic">',
|
||||||
|
'Part image',
|
||||||
|
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment),
|
||||||
|
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'),
|
||||||
|
'hoverpic part-table-image',
|
||||||
|
$title
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original behavior for Part entities
|
||||||
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context);
|
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context);
|
||||||
if (!$preview_attachment instanceof Attachment) {
|
if (!$preview_attachment instanceof Attachment) {
|
||||||
return '';
|
return '';
|
||||||
|
|
@ -96,26 +139,40 @@ class PartDataTableHelper
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function renderStorageLocations(Part $context): string
|
public function renderStorageLocations($context): string
|
||||||
{
|
{
|
||||||
$tmp = [];
|
$tmp = [];
|
||||||
|
|
||||||
|
// For DTO, part lots are already PartLotDTO objects
|
||||||
foreach ($context->getPartLots() as $lot) {
|
foreach ($context->getPartLots() as $lot) {
|
||||||
//Ignore lots without storelocation
|
//Ignore lots without storelocation
|
||||||
if (!$lot->getStorageLocation() instanceof StorageLocation) {
|
$storageLocation = $lot->getStorageLocation();
|
||||||
|
if ($storageLocation === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For DTO, we need to create a StorageLocation reference for URL generation
|
||||||
|
$storageLocationForUrl = $storageLocation;
|
||||||
|
if (!($storageLocation instanceof StorageLocation)) {
|
||||||
|
// The lot DTO returns a simple object, we need a proper reference
|
||||||
|
$storageLocationForUrl = $this->entityManager->getReference(
|
||||||
|
StorageLocation::class,
|
||||||
|
$storageLocation->getId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$tmp[] = sprintf(
|
$tmp[] = sprintf(
|
||||||
'<a href="%s" title="%s">%s</a>',
|
'<a href="%s" title="%s">%s</a>',
|
||||||
$this->entityURLGenerator->listPartsURL($lot->getStorageLocation()),
|
$this->entityURLGenerator->listPartsURL($storageLocationForUrl),
|
||||||
htmlspecialchars($lot->getStorageLocation()->getFullPath()),
|
htmlspecialchars($storageLocation->getFullPath() ?? ''),
|
||||||
htmlspecialchars($lot->getStorageLocation()->getName())
|
htmlspecialchars($storageLocation->getName() ?? '')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return implode('<br>', $tmp);
|
return implode('<br>', $tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function renderAmount(Part $context): string
|
public function renderAmount($context): string
|
||||||
{
|
{
|
||||||
$amount = $context->getAmountSum();
|
$amount = $context->getAmountSum();
|
||||||
$expiredAmount = $context->getExpiredAmountSum();
|
$expiredAmount = $context->getExpiredAmountSum();
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ use App\DataTables\Column\RowClassColumn;
|
||||||
use App\DataTables\Column\SelectColumn;
|
use App\DataTables\Column\SelectColumn;
|
||||||
use App\DataTables\Column\SIUnitNumberColumn;
|
use App\DataTables\Column\SIUnitNumberColumn;
|
||||||
use App\DataTables\Column\TagsColumn;
|
use App\DataTables\Column\TagsColumn;
|
||||||
|
use App\DataTables\DTO\PartDTO;
|
||||||
|
use App\DataTables\DTO\PartDTOHydrator;
|
||||||
use App\DataTables\Filters\PartFilter;
|
use App\DataTables\Filters\PartFilter;
|
||||||
use App\DataTables\Filters\PartSearchFilter;
|
use App\DataTables\Filters\PartSearchFilter;
|
||||||
use App\DataTables\Helpers\ColumnSortHelper;
|
use App\DataTables\Helpers\ColumnSortHelper;
|
||||||
|
|
@ -47,6 +49,7 @@ use App\Services\EntityURLGenerator;
|
||||||
use App\Services\Formatters\AmountFormatter;
|
use App\Services\Formatters\AmountFormatter;
|
||||||
use App\Settings\BehaviorSettings\TableSettings;
|
use App\Settings\BehaviorSettings\TableSettings;
|
||||||
use Doctrine\ORM\AbstractQuery;
|
use Doctrine\ORM\AbstractQuery;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
|
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
|
||||||
use Omines\DataTablesBundle\Column\TextColumn;
|
use Omines\DataTablesBundle\Column\TextColumn;
|
||||||
|
|
@ -68,6 +71,8 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly ColumnSortHelper $csh,
|
private readonly ColumnSortHelper $csh,
|
||||||
private readonly TableSettings $tableSettings,
|
private readonly TableSettings $tableSettings,
|
||||||
|
private readonly PartDTOHydrator $partDTOHydrator,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +96,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
$this->csh
|
$this->csh
|
||||||
//Color the table rows depending on the review and favorite status
|
//Color the table rows depending on the review and favorite status
|
||||||
->add('row_color', RowClassColumn::class, [
|
->add('row_color', RowClassColumn::class, [
|
||||||
'render' => function ($value, Part $context): string {
|
'render' => function ($value, $context): string {
|
||||||
if ($context->isNeedsReview()) {
|
if ($context->isNeedsReview()) {
|
||||||
return 'table-secondary';
|
return 'table-secondary';
|
||||||
}
|
}
|
||||||
|
|
@ -106,11 +111,11 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
->add('picture', TextColumn::class, [
|
->add('picture', TextColumn::class, [
|
||||||
'label' => '',
|
'label' => '',
|
||||||
'className' => 'no-colvis',
|
'className' => 'no-colvis',
|
||||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context),
|
'render' => fn($value, $context) => $this->partDataTableHelper->renderPicture($context),
|
||||||
], visibility_configurable: false)
|
], visibility_configurable: false)
|
||||||
->add('name', TextColumn::class, [
|
->add('name', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.name'),
|
'label' => $this->translator->trans('part.table.name'),
|
||||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
|
'render' => fn($value, $context) => $this->partDataTableHelper->renderName($context),
|
||||||
'orderField' => 'NATSORT(part.name)'
|
'orderField' => 'NATSORT(part.name)'
|
||||||
])
|
])
|
||||||
->add('id', TextColumn::class, [
|
->add('id', TextColumn::class, [
|
||||||
|
|
@ -142,17 +147,17 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
'label' => $this->translator->trans('part.table.storeLocations'),
|
'label' => $this->translator->trans('part.table.storeLocations'),
|
||||||
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
|
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
|
||||||
'orderField' => 'NATSORT(MIN(_storelocations.name))',
|
'orderField' => 'NATSORT(MIN(_storelocations.name))',
|
||||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
|
'render' => fn($value, $context) => $this->partDataTableHelper->renderStorageLocations($context),
|
||||||
], alias: 'storage_location')
|
], alias: 'storage_location')
|
||||||
|
|
||||||
->add('amount', TextColumn::class, [
|
->add('amount', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.amount'),
|
'label' => $this->translator->trans('part.table.amount'),
|
||||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
|
'render' => fn($value, $context) => $this->partDataTableHelper->renderAmount($context),
|
||||||
'orderField' => 'amountSum'
|
'orderField' => 'amountSum'
|
||||||
])
|
])
|
||||||
->add('minamount', TextColumn::class, [
|
->add('minamount', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.minamount'),
|
'label' => $this->translator->trans('part.table.minamount'),
|
||||||
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format(
|
'render' => fn($value, $context): string => htmlspecialchars($this->amountFormatter->format(
|
||||||
$value,
|
$value,
|
||||||
$context->getPartUnit()
|
$context->getPartUnit()
|
||||||
)),
|
)),
|
||||||
|
|
@ -160,7 +165,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
->add('partUnit', TextColumn::class, [
|
->add('partUnit', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.partUnit'),
|
'label' => $this->translator->trans('part.table.partUnit'),
|
||||||
'orderField' => 'NATSORT(_partUnit.name)',
|
'orderField' => 'NATSORT(_partUnit.name)',
|
||||||
'render' => function ($value, Part $context): string {
|
'render' => function ($value, $context): string {
|
||||||
$partUnit = $context->getPartUnit();
|
$partUnit = $context->getPartUnit();
|
||||||
if ($partUnit === null) {
|
if ($partUnit === null) {
|
||||||
return '';
|
return '';
|
||||||
|
|
@ -177,7 +182,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
->add('partCustomState', TextColumn::class, [
|
->add('partCustomState', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.partCustomState'),
|
'label' => $this->translator->trans('part.table.partCustomState'),
|
||||||
'orderField' => 'NATSORT(_partCustomState.name)',
|
'orderField' => 'NATSORT(_partCustomState.name)',
|
||||||
'render' => function($value, Part $context): string {
|
'render' => function($value, $context): string {
|
||||||
$partCustomState = $context->getPartCustomState();
|
$partCustomState = $context->getPartCustomState();
|
||||||
|
|
||||||
if ($partCustomState === null) {
|
if ($partCustomState === null) {
|
||||||
|
|
@ -202,7 +207,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
->add('manufacturing_status', EnumColumn::class, [
|
->add('manufacturing_status', EnumColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.manufacturingStatus'),
|
'label' => $this->translator->trans('part.table.manufacturingStatus'),
|
||||||
'class' => ManufacturingStatus::class,
|
'class' => ManufacturingStatus::class,
|
||||||
'render' => function (?ManufacturingStatus $status, Part $context): string {
|
'render' => function (?ManufacturingStatus $status, $context): string {
|
||||||
if ($status === null) {
|
if ($status === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +238,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
if ($this->security->isGranted('read', Project::class)) {
|
if ($this->security->isGranted('read', Project::class)) {
|
||||||
$this->csh->add('projects', TextColumn::class, [
|
$this->csh->add('projects', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('project.labelp'),
|
'label' => $this->translator->trans('project.labelp'),
|
||||||
'render' => function ($value, Part $context): string {
|
'render' => function ($value, $context): string {
|
||||||
//Only show the first 5 projects names
|
//Only show the first 5 projects names
|
||||||
$projects = $context->getProjects();
|
$projects = $context->getProjects();
|
||||||
$tmp = "";
|
$tmp = "";
|
||||||
|
|
@ -241,8 +246,19 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
$max = 5;
|
$max = 5;
|
||||||
|
|
||||||
for ($i = 0; $i < min($max, count($projects)); $i++) {
|
for ($i = 0; $i < min($max, count($projects)); $i++) {
|
||||||
$url = $this->urlGenerator->infoURL($projects[$i]);
|
$project = $projects[$i];
|
||||||
$tmp .= sprintf('<a href="%s">%s</a>', $url, htmlspecialchars($projects[$i]->getName()));
|
|
||||||
|
// For DTO, projects are arrays with id and name
|
||||||
|
if (is_array($project)) {
|
||||||
|
$projectProxy = $this->entityManager->getReference(Project::class, $project['id']);
|
||||||
|
$url = $this->urlGenerator->infoURL($projectProxy);
|
||||||
|
$tmp .= sprintf('<a href="%s">%s</a>', $url, htmlspecialchars($project['name']));
|
||||||
|
} else {
|
||||||
|
// For Part entity, projects are Project objects
|
||||||
|
$url = $this->urlGenerator->infoURL($project);
|
||||||
|
$tmp .= sprintf('<a href="%s">%s</a>', $url, htmlspecialchars($project->getName()));
|
||||||
|
}
|
||||||
|
|
||||||
if ($i < count($projects) - 1) {
|
if ($i < count($projects) - 1) {
|
||||||
$tmp .= ", ";
|
$tmp .= ", ";
|
||||||
}
|
}
|
||||||
|
|
@ -260,8 +276,22 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
$this->csh
|
$this->csh
|
||||||
->add('edit', IconLinkColumn::class, [
|
->add('edit', IconLinkColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.edit'),
|
'label' => $this->translator->trans('part.table.edit'),
|
||||||
'href' => fn($value, Part $context) => $this->urlGenerator->editURL($context),
|
'href' => function ($value, $context) {
|
||||||
'disabled' => fn($value, Part $context) => !$this->security->isGranted('edit', $context),
|
// For DTO, get a Part reference for URL generation
|
||||||
|
if ($context instanceof PartDTO) {
|
||||||
|
$partProxy = $this->entityManager->getReference(Part::class, $context->getId());
|
||||||
|
return $this->urlGenerator->editURL($partProxy);
|
||||||
|
}
|
||||||
|
return $this->urlGenerator->editURL($context);
|
||||||
|
},
|
||||||
|
'disabled' => function ($value, $context) {
|
||||||
|
// For DTO, get a Part reference for permission check
|
||||||
|
if ($context instanceof PartDTO) {
|
||||||
|
$partProxy = $this->entityManager->getReference(Part::class, $context->getId());
|
||||||
|
return !$this->security->isGranted('edit', $partProxy);
|
||||||
|
}
|
||||||
|
return !$this->security->isGranted('edit', $context);
|
||||||
|
},
|
||||||
'title' => $this->translator->trans('part.table.edit.title'),
|
'title' => $this->translator->trans('part.table.edit.title'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -285,6 +315,8 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
new SearchCriteriaProvider(),
|
new SearchCriteriaProvider(),
|
||||||
],
|
],
|
||||||
'query_modifier' => $this->addJoins(...),
|
'query_modifier' => $this->addJoins(...),
|
||||||
|
// Use DTO hydration instead of full entity loading
|
||||||
|
'dto_hydrator' => fn(array $results) => $this->partDTOHydrator->hydrateFromQueryResults($results),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,59 +344,100 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
$ids = array_map(static fn($row) => $row['id'], $filter_results);
|
$ids = array_map(static fn($row) => $row['id'], $filter_results);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* In this query we take the IDs which were filtered, paginated and sorted in the filter query, and fetch the
|
* Optimized query that selects only specific fields needed for table rendering.
|
||||||
* full entities.
|
* Instead of loading full Part entities, we select scalar values and build lightweight DTOs.
|
||||||
* We can do complex fetch joins, as we do not need to filter or sort here (which would kill the performance).
|
* This significantly reduces memory usage and improves performance.
|
||||||
* The only condition should be for the IDs.
|
|
||||||
* It is important that elements are ordered the same way, as the IDs are passed, or ordering will be wrong.
|
|
||||||
*
|
*
|
||||||
* We do not require the subqueries like amountSum here, as it is not used to render the table (and only for sorting)
|
* We compute aggregated amounts (amountSum, expiredAmountSum, hasUnknownAmount) using subqueries
|
||||||
|
* to avoid complex PHP iteration.
|
||||||
*/
|
*/
|
||||||
$builder
|
$builder
|
||||||
->select('part')
|
// Core Part fields
|
||||||
->addSelect('category')
|
->select('part.id AS id')
|
||||||
->addSelect('footprint')
|
->addSelect('part.name AS name')
|
||||||
->addSelect('manufacturer')
|
->addSelect('part.ipn AS ipn')
|
||||||
->addSelect('partUnit')
|
->addSelect('part.description AS description')
|
||||||
->addSelect('partCustomState')
|
->addSelect('part.minamount AS minamount')
|
||||||
->addSelect('master_picture_attachment')
|
->addSelect('part.manufacturer_product_number AS manufacturer_product_number')
|
||||||
->addSelect('footprint_attachment')
|
->addSelect('part.mass AS mass')
|
||||||
->addSelect('partLots')
|
->addSelect('part.gtin AS gtin')
|
||||||
->addSelect('orderdetails')
|
->addSelect('part.tags AS tags')
|
||||||
->addSelect('attachments')
|
->addSelect('part.favorite AS favorite')
|
||||||
->addSelect('storelocations')
|
->addSelect('part.needs_review AS needs_review')
|
||||||
|
->addSelect('part.addedDate AS addedDate')
|
||||||
|
->addSelect('part.lastModified AS lastModified')
|
||||||
|
->addSelect('part.manufacturing_status AS manufacturing_status')
|
||||||
|
|
||||||
|
// Related entity IDs and names
|
||||||
|
->addSelect('category.id AS category_id')
|
||||||
|
->addSelect('category.name AS category_name')
|
||||||
|
->addSelect('footprint.id AS footprint_id')
|
||||||
|
->addSelect('footprint.name AS footprint_name')
|
||||||
|
->addSelect('manufacturer.id AS manufacturer_id')
|
||||||
|
->addSelect('manufacturer.name AS manufacturer_name')
|
||||||
|
->addSelect('partUnit.id AS partUnit_id')
|
||||||
|
->addSelect('partUnit.name AS partUnit_name')
|
||||||
|
->addSelect('partUnit.unit AS partUnit_unit')
|
||||||
|
->addSelect('partCustomState.id AS partCustomState_id')
|
||||||
|
->addSelect('partCustomState.name AS partCustomState_name')
|
||||||
|
->addSelect('master_picture_attachment.id AS master_picture_attachment_id')
|
||||||
|
->addSelect('master_picture_attachment.filename AS master_picture_attachment_filename')
|
||||||
|
->addSelect('master_picture_attachment.name AS master_picture_attachment_name')
|
||||||
|
->addSelect('footprint_attachment.id AS footprint_attachment_id')
|
||||||
|
->addSelect('builtProject.id AS builtProject_id')
|
||||||
|
->addSelect('builtProject.name AS builtProject_name')
|
||||||
|
|
||||||
|
// Part lots for storage locations
|
||||||
|
->addSelect('partLots.id AS partLot_id')
|
||||||
|
->addSelect('storelocations.id AS storageLocation_id')
|
||||||
|
->addSelect('storelocations.name AS storageLocation_name')
|
||||||
|
->addSelect('storelocations.full_path AS storageLocation_fullPath')
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
->addSelect('attachments.id AS attachment_id')
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
->addSelect('projects.id AS project_id')
|
||||||
|
->addSelect('projects.name AS project_name')
|
||||||
|
|
||||||
|
// Computed/aggregated amounts using subqueries
|
||||||
|
->addSelect('(
|
||||||
|
SELECT COALESCE(SUM(pl_sum.amount), 0.0)
|
||||||
|
FROM ' . PartLot::class . ' pl_sum
|
||||||
|
WHERE pl_sum.part = part.id
|
||||||
|
AND pl_sum.instock_unknown = false
|
||||||
|
AND (pl_sum.expiration_date IS NULL OR pl_sum.expiration_date > CURRENT_DATE())
|
||||||
|
) AS amountSum')
|
||||||
|
->addSelect('(
|
||||||
|
SELECT COALESCE(SUM(pl_exp.amount), 0.0)
|
||||||
|
FROM ' . PartLot::class . ' pl_exp
|
||||||
|
WHERE pl_exp.part = part.id
|
||||||
|
AND pl_exp.instock_unknown = false
|
||||||
|
AND pl_exp.expiration_date IS NOT NULL
|
||||||
|
AND pl_exp.expiration_date <= CURRENT_DATE()
|
||||||
|
) AS expiredAmountSum')
|
||||||
|
->addSelect('(
|
||||||
|
SELECT CASE WHEN COUNT(pl_unk.id) > 0 THEN true ELSE false END
|
||||||
|
FROM ' . PartLot::class . ' pl_unk
|
||||||
|
WHERE pl_unk.part = part.id
|
||||||
|
AND pl_unk.instock_unknown = true
|
||||||
|
) AS hasUnknownAmount')
|
||||||
|
|
||||||
->from(Part::class, 'part')
|
->from(Part::class, 'part')
|
||||||
->leftJoin('part.category', 'category')
|
->leftJoin('part.category', 'category')
|
||||||
->leftJoin('part.master_picture_attachment', 'master_picture_attachment')
|
|
||||||
->leftJoin('part.partLots', 'partLots')
|
|
||||||
->leftJoin('partLots.storage_location', 'storelocations')
|
|
||||||
->leftJoin('part.footprint', 'footprint')
|
->leftJoin('part.footprint', 'footprint')
|
||||||
->leftJoin('footprint.master_picture_attachment', 'footprint_attachment')
|
->leftJoin('footprint.master_picture_attachment', 'footprint_attachment')
|
||||||
->leftJoin('part.manufacturer', 'manufacturer')
|
->leftJoin('part.manufacturer', 'manufacturer')
|
||||||
->leftJoin('part.orderdetails', 'orderdetails')
|
|
||||||
->leftJoin('orderdetails.supplier', 'suppliers')
|
|
||||||
->leftJoin('part.attachments', 'attachments')
|
|
||||||
->leftJoin('part.partUnit', 'partUnit')
|
->leftJoin('part.partUnit', 'partUnit')
|
||||||
->leftJoin('part.partCustomState', 'partCustomState')
|
->leftJoin('part.partCustomState', 'partCustomState')
|
||||||
->leftJoin('part.parameters', 'parameters')
|
->leftJoin('part.master_picture_attachment', 'master_picture_attachment')
|
||||||
|
->leftJoin('part.builtProject', 'builtProject')
|
||||||
|
->leftJoin('part.partLots', 'partLots')
|
||||||
|
->leftJoin('partLots.storage_location', 'storelocations')
|
||||||
|
->leftJoin('part.attachments', 'attachments')
|
||||||
|
->leftJoin('part.projects', 'projects')
|
||||||
->where('part.id IN (:ids)')
|
->where('part.id IN (:ids)')
|
||||||
->setParameter('ids', $ids)
|
->setParameter('ids', $ids);
|
||||||
|
|
||||||
//We have to group by all elements, or only the first sub elements of an association is fetched! (caused issue #190)
|
|
||||||
->addGroupBy('part')
|
|
||||||
->addGroupBy('partLots')
|
|
||||||
->addGroupBy('category')
|
|
||||||
->addGroupBy('master_picture_attachment')
|
|
||||||
->addGroupBy('storelocations')
|
|
||||||
->addGroupBy('footprint')
|
|
||||||
->addGroupBy('footprint_attachment')
|
|
||||||
->addGroupBy('manufacturer')
|
|
||||||
->addGroupBy('orderdetails')
|
|
||||||
->addGroupBy('suppliers')
|
|
||||||
->addGroupBy('attachments')
|
|
||||||
->addGroupBy('partUnit')
|
|
||||||
->addGroupBy('partCustomState')
|
|
||||||
->addGroupBy('parameters');
|
|
||||||
|
|
||||||
//Get the results in the same order as the IDs were passed
|
//Get the results in the same order as the IDs were passed
|
||||||
FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');
|
FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue