mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-20 10:21:32 +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 $dto_hydrator = null;
|
||||
|
||||
public function __construct(?ManagerRegistry $registry = null)
|
||||
{
|
||||
parent::__construct($registry);
|
||||
|
|
@ -82,6 +84,10 @@ class TwoStepORMAdapter extends ORMAdapter
|
|||
$resolver->setDefault('query_modifier', null);
|
||||
$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
|
||||
|
|
@ -90,6 +96,7 @@ class TwoStepORMAdapter extends ORMAdapter
|
|||
$this->detailQueryCallable = $options['detail_query'];
|
||||
$this->use_simple_total = $options['simple_total_query'];
|
||||
$this->query_modifier = $options['query_modifier'];
|
||||
$this->dto_hydrator = $options['dto_hydrator'];
|
||||
}
|
||||
|
||||
protected function prepareQuery(AdapterQuery $query): void
|
||||
|
|
@ -189,9 +196,19 @@ class TwoStepORMAdapter extends ORMAdapter
|
|||
|
||||
$detail_query = $detail_qb->getQuery();
|
||||
|
||||
//We pass the results of the detail query to the datatable for view rendering
|
||||
foreach ($detail_query->getResult() as $item) {
|
||||
yield $item;
|
||||
// If a DTO hydrator is configured, use array hydration and build DTOs
|
||||
if ($this->dto_hydrator !== null) {
|
||||
$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;
|
||||
|
||||
use App\DataTables\DTO\PartDTO;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
|
|
@ -31,6 +32,7 @@ use App\Services\Attachments\AttachmentURLGenerator;
|
|||
use App\Services\Attachments\PartPreviewGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
|
|
@ -44,10 +46,11 @@ class PartDataTableHelper
|
|||
private readonly EntityURLGenerator $entityURLGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly AmountFormatter $amountFormatter,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function renderName(Part $context): string
|
||||
public function renderName($context): string
|
||||
{
|
||||
$icon = '';
|
||||
|
||||
|
|
@ -60,22 +63,62 @@ class PartDataTableHelper
|
|||
$icon = sprintf('<i class="fa-solid fa-ambulance fa-fw me-1" title="%s"></i>',
|
||||
$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>',
|
||||
$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(
|
||||
'<a href="%s">%s%s</a>',
|
||||
$this->entityURLGenerator->infoURL($context),
|
||||
$this->entityURLGenerator->infoURL($partForUrl),
|
||||
$icon,
|
||||
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);
|
||||
if (!$preview_attachment instanceof Attachment) {
|
||||
return '';
|
||||
|
|
@ -96,26 +139,40 @@ class PartDataTableHelper
|
|||
);
|
||||
}
|
||||
|
||||
public function renderStorageLocations(Part $context): string
|
||||
public function renderStorageLocations($context): string
|
||||
{
|
||||
$tmp = [];
|
||||
|
||||
// For DTO, part lots are already PartLotDTO objects
|
||||
foreach ($context->getPartLots() as $lot) {
|
||||
//Ignore lots without storelocation
|
||||
if (!$lot->getStorageLocation() instanceof StorageLocation) {
|
||||
$storageLocation = $lot->getStorageLocation();
|
||||
if ($storageLocation === null) {
|
||||
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(
|
||||
'<a href="%s" title="%s">%s</a>',
|
||||
$this->entityURLGenerator->listPartsURL($lot->getStorageLocation()),
|
||||
htmlspecialchars($lot->getStorageLocation()->getFullPath()),
|
||||
htmlspecialchars($lot->getStorageLocation()->getName())
|
||||
$this->entityURLGenerator->listPartsURL($storageLocationForUrl),
|
||||
htmlspecialchars($storageLocation->getFullPath() ?? ''),
|
||||
htmlspecialchars($storageLocation->getName() ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
return implode('<br>', $tmp);
|
||||
}
|
||||
|
||||
public function renderAmount(Part $context): string
|
||||
public function renderAmount($context): string
|
||||
{
|
||||
$amount = $context->getAmountSum();
|
||||
$expiredAmount = $context->getExpiredAmountSum();
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ use App\DataTables\Column\RowClassColumn;
|
|||
use App\DataTables\Column\SelectColumn;
|
||||
use App\DataTables\Column\SIUnitNumberColumn;
|
||||
use App\DataTables\Column\TagsColumn;
|
||||
use App\DataTables\DTO\PartDTO;
|
||||
use App\DataTables\DTO\PartDTOHydrator;
|
||||
use App\DataTables\Filters\PartFilter;
|
||||
use App\DataTables\Filters\PartSearchFilter;
|
||||
use App\DataTables\Helpers\ColumnSortHelper;
|
||||
|
|
@ -47,6 +49,7 @@ use App\Services\EntityURLGenerator;
|
|||
use App\Services\Formatters\AmountFormatter;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
use Doctrine\ORM\AbstractQuery;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
|
||||
use Omines\DataTablesBundle\Column\TextColumn;
|
||||
|
|
@ -68,6 +71,8 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
private readonly Security $security,
|
||||
private readonly ColumnSortHelper $csh,
|
||||
private readonly TableSettings $tableSettings,
|
||||
private readonly PartDTOHydrator $partDTOHydrator,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +96,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
$this->csh
|
||||
//Color the table rows depending on the review and favorite status
|
||||
->add('row_color', RowClassColumn::class, [
|
||||
'render' => function ($value, Part $context): string {
|
||||
'render' => function ($value, $context): string {
|
||||
if ($context->isNeedsReview()) {
|
||||
return 'table-secondary';
|
||||
}
|
||||
|
|
@ -106,11 +111,11 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
->add('picture', TextColumn::class, [
|
||||
'label' => '',
|
||||
'className' => 'no-colvis',
|
||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context),
|
||||
'render' => fn($value, $context) => $this->partDataTableHelper->renderPicture($context),
|
||||
], visibility_configurable: false)
|
||||
->add('name', TextColumn::class, [
|
||||
'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)'
|
||||
])
|
||||
->add('id', TextColumn::class, [
|
||||
|
|
@ -142,17 +147,17 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
'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
|
||||
'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')
|
||||
|
||||
->add('amount', TextColumn::class, [
|
||||
'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'
|
||||
])
|
||||
->add('minamount', TextColumn::class, [
|
||||
'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,
|
||||
$context->getPartUnit()
|
||||
)),
|
||||
|
|
@ -160,7 +165,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
->add('partUnit', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.partUnit'),
|
||||
'orderField' => 'NATSORT(_partUnit.name)',
|
||||
'render' => function ($value, Part $context): string {
|
||||
'render' => function ($value, $context): string {
|
||||
$partUnit = $context->getPartUnit();
|
||||
if ($partUnit === null) {
|
||||
return '';
|
||||
|
|
@ -177,7 +182,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
->add('partCustomState', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.partCustomState'),
|
||||
'orderField' => 'NATSORT(_partCustomState.name)',
|
||||
'render' => function($value, Part $context): string {
|
||||
'render' => function($value, $context): string {
|
||||
$partCustomState = $context->getPartCustomState();
|
||||
|
||||
if ($partCustomState === null) {
|
||||
|
|
@ -202,7 +207,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
->add('manufacturing_status', EnumColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.manufacturingStatus'),
|
||||
'class' => ManufacturingStatus::class,
|
||||
'render' => function (?ManufacturingStatus $status, Part $context): string {
|
||||
'render' => function (?ManufacturingStatus $status, $context): string {
|
||||
if ($status === null) {
|
||||
return '';
|
||||
}
|
||||
|
|
@ -233,7 +238,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
if ($this->security->isGranted('read', Project::class)) {
|
||||
$this->csh->add('projects', TextColumn::class, [
|
||||
'label' => $this->translator->trans('project.labelp'),
|
||||
'render' => function ($value, Part $context): string {
|
||||
'render' => function ($value, $context): string {
|
||||
//Only show the first 5 projects names
|
||||
$projects = $context->getProjects();
|
||||
$tmp = "";
|
||||
|
|
@ -241,8 +246,19 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
$max = 5;
|
||||
|
||||
for ($i = 0; $i < min($max, count($projects)); $i++) {
|
||||
$url = $this->urlGenerator->infoURL($projects[$i]);
|
||||
$tmp .= sprintf('<a href="%s">%s</a>', $url, htmlspecialchars($projects[$i]->getName()));
|
||||
$project = $projects[$i];
|
||||
|
||||
// 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) {
|
||||
$tmp .= ", ";
|
||||
}
|
||||
|
|
@ -260,8 +276,22 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
$this->csh
|
||||
->add('edit', IconLinkColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.edit'),
|
||||
'href' => fn($value, Part $context) => $this->urlGenerator->editURL($context),
|
||||
'disabled' => fn($value, Part $context) => !$this->security->isGranted('edit', $context),
|
||||
'href' => function ($value, $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'),
|
||||
]);
|
||||
|
||||
|
|
@ -285,6 +315,8 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
new SearchCriteriaProvider(),
|
||||
],
|
||||
'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);
|
||||
|
||||
/*
|
||||
* In this query we take the IDs which were filtered, paginated and sorted in the filter query, and fetch the
|
||||
* full entities.
|
||||
* We can do complex fetch joins, as we do not need to filter or sort here (which would kill the 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.
|
||||
* Optimized query that selects only specific fields needed for table rendering.
|
||||
* Instead of loading full Part entities, we select scalar values and build lightweight DTOs.
|
||||
* This significantly reduces memory usage and improves performance.
|
||||
*
|
||||
* 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
|
||||
->select('part')
|
||||
->addSelect('category')
|
||||
->addSelect('footprint')
|
||||
->addSelect('manufacturer')
|
||||
->addSelect('partUnit')
|
||||
->addSelect('partCustomState')
|
||||
->addSelect('master_picture_attachment')
|
||||
->addSelect('footprint_attachment')
|
||||
->addSelect('partLots')
|
||||
->addSelect('orderdetails')
|
||||
->addSelect('attachments')
|
||||
->addSelect('storelocations')
|
||||
// Core Part fields
|
||||
->select('part.id AS id')
|
||||
->addSelect('part.name AS name')
|
||||
->addSelect('part.ipn AS ipn')
|
||||
->addSelect('part.description AS description')
|
||||
->addSelect('part.minamount AS minamount')
|
||||
->addSelect('part.manufacturer_product_number AS manufacturer_product_number')
|
||||
->addSelect('part.mass AS mass')
|
||||
->addSelect('part.gtin AS gtin')
|
||||
->addSelect('part.tags AS tags')
|
||||
->addSelect('part.favorite AS favorite')
|
||||
->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')
|
||||
->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('footprint.master_picture_attachment', 'footprint_attachment')
|
||||
->leftJoin('part.manufacturer', 'manufacturer')
|
||||
->leftJoin('part.orderdetails', 'orderdetails')
|
||||
->leftJoin('orderdetails.supplier', 'suppliers')
|
||||
->leftJoin('part.attachments', 'attachments')
|
||||
->leftJoin('part.partUnit', 'partUnit')
|
||||
->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)')
|
||||
->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');
|
||||
->setParameter('ids', $ids);
|
||||
|
||||
//Get the results in the same order as the IDs were passed
|
||||
FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue