Füge Option für lesbares CSV beim Export hinzu (APS-3)

This commit is contained in:
Marcel Diegelmann 2025-09-08 13:32:34 +02:00
parent b823d7d613
commit c79fc47c1e
17 changed files with 434 additions and 16 deletions

View file

@ -0,0 +1,52 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = {
classes: Array
};
connect() {
this.readableCheckbox = this.element.querySelector("#readable");
if (!this.readableCheckbox) {
return;
}
// Apply the initial visibility state based on the checkbox being checked or not
this.toggleContainers(this.readableCheckbox.checked);
// Add a change event listener to the 'readable' checkbox
this.readableCheckbox.addEventListener("change", (event) => {
// Toggle container visibility when the checkbox value changes
this.toggleContainers(event.target.checked);
});
}
/**
* Toggles the visibility of containers based on the checkbox state.
* Hides specified containers if the checkbox is checked and shows them otherwise.
*
* @param {boolean} isChecked - The current state of the checkbox:
* true if checked (hide elements), false if unchecked (show them).
*/
toggleContainers(isChecked) {
if (!Array.isArray(this.classesValue) || this.classesValue.length === 0) {
return;
}
this.classesValue.forEach((cssClass) => {
const elements = document.querySelectorAll(`.${cssClass}`);
if (!elements.length) {
return;
}
// Update the visibility for each selected element
elements.forEach((element) => {
// If the checkbox is checked, hide the container; otherwise, show it
element.style.display = isChecked ? "none" : "";
});
});
}
}

View file

@ -124,7 +124,7 @@ class AssemblyBOMEntry extends AbstractDBElement implements UniqueValidatableInt
*/
#[ORM\ManyToOne(targetEntity: Assembly::class, inversedBy: 'bom_entries')]
#[ORM\JoinColumn(name: 'id_assembly', nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', ])]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?Assembly $assembly = null;
/**
@ -146,7 +146,7 @@ class AssemblyBOMEntry extends AbstractDBElement implements UniqueValidatableInt
#[AssemblyInvalidBomEntry]
#[ORM\ManyToOne(targetEntity: Assembly::class)]
#[ORM\JoinColumn(name: 'id_referenced_assembly', nullable: true, onDelete: 'SET NULL')]
#[Groups(['bom_entry:read', 'bom_entry:write', ])]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?Assembly $referencedAssembly = null;
/**
@ -158,7 +158,7 @@ class AssemblyBOMEntry extends AbstractDBElement implements UniqueValidatableInt
)]
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(name: 'id_project', nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', ])]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?Project $project = null;
/**

View file

@ -22,8 +22,19 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\ProjectSystem\Project;
use App\Helpers\FilenameSanatizer;
use App\Serializer\APIPlatform\SkippableItemNormalizer;
use Symfony\Component\OptionsResolver\OptionsResolver;
@ -65,6 +76,9 @@ class EntityExporter
$resolver->setDefault('include_children', false);
$resolver->setAllowedTypes('include_children', 'bool');
$resolver->setDefault('readable', false);
$resolver->setAllowedTypes('readable', 'bool');
}
/**
@ -222,15 +236,50 @@ class EntityExporter
$entities = [$entities];
}
//Do the serialization with the given options
$serialized_data = $this->exportEntities($entities, $options);
if ($request->get('readable', false)) {
// Map entity classes to export functions
$entityExportMap = [
AttachmentType::class => fn($entities) => $this->exportReadable($entities, AttachmentType::class),
Category::class => fn($entities) => $this->exportReadable($entities, Category::class),
Project::class => fn($entities) => $this->exportReadable($entities, Project::class),
Assembly::class => fn($entities) => $this->exportReadable($entities, Assembly::class),
Supplier::class => fn($entities) => $this->exportReadable($entities, Supplier::class),
Manufacturer::class => fn($entities) => $this->exportReadable($entities, Manufacturer::class),
StorageLocation::class => fn($entities) => $this->exportReadable($entities, StorageLocation::class),
Footprint::class => fn($entities) => $this->exportReadable($entities, Footprint::class),
Currency::class => fn($entities) => $this->exportReadable($entities, Currency::class),
MeasurementUnit::class => fn($entities) => $this->exportReadable($entities, MeasurementUnit::class),
LabelProfile::class => fn($entities) => $this->exportReadable($entities, LabelProfile::class, false),
];
$response = new Response($serialized_data);
// Determine the type of the entity
$type = null;
foreach ($entities as $entity) {
$entityClass = get_class($entity);
if (isset($entityExportMap[$entityClass])) {
$type = $entityClass;
break;
}
}
//Resolve the format
$optionsResolver = new OptionsResolver();
$this->configureOptions($optionsResolver);
$options = $optionsResolver->resolve($options);
// Generate the response
$response = isset($entityExportMap[$type])
? new Response($entityExportMap[$type]($entities))
: new Response('');
$options['format'] = 'csv';
$options['level'] = 'readable';
} else {
//Do the serialization with the given options
$serialized_data = $this->exportEntities($entities, $options);
$response = new Response($serialized_data);
//Resolve the format
$optionsResolver = new OptionsResolver();
$this->configureOptions($optionsResolver);
$options = $optionsResolver->resolve($options);
}
//Determine the content type for the response
@ -277,4 +326,232 @@ class EntityExporter
return $response;
}
/**
* Exports data for multiple entity types in a readable CSV format.
*
* @param array $entities The entities to export.
* @param string $type The type of entities ('category', 'project', 'assembly', 'attachmentType', 'supplier').
* @return string The generated CSV content as a string.
*/
public function exportReadable(array $entities, string $type, bool $isHierarchical = true): string
{
//Define headers and entity-specific processing logic
$defaultProcessEntity = fn($entity, $depth) => [
'Id' => $entity->getId(),
'ParentId' => $entity->getParent()?->getId() ?? '',
'NameHierarchical' => str_repeat('--', $depth) . ' ' . $entity->getName(),
'Name' => $entity->getName(),
'FullName' => $this->getFullName($entity),
];
$config = [
AttachmentType::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
Category::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
Project::class => [
'header' => [
'Id', 'ParentId', 'Type', 'ProjectNameHierarchical', 'ProjectName', 'ProjectFullName',
'BomQuantity', 'BomPartId', 'BomPartIpn', 'BomPartName', 'BomName', 'BomPartDescription', 'BomMountNames'
],
'processEntity' => fn($entity, $depth) => [
'ProjectId' => $entity->getId(),
'ParentProjectId' => $entity->getParent()?->getId() ?? '',
'Type' => 'project',
'ProjectNameHierarchical' => str_repeat('--', $depth) . ' ' . $entity->getName(),
'ProjectName' => $entity->getName(),
'ProjectFullName' => $this->getFullName($entity),
'BomQuantity' => '-',
'BomPartId' => '-',
'BomPartIpn' => '-',
'BomPartName' => '-',
'BomName' => '-',
'BomPartDescription' => '-',
'BomMountNames' => '-',
],
'processBomEntries' => fn($entity, $depth) => array_map(fn($bomEntry) => [
'Id' => $entity->getId(),
'ParentId' => '',
'Type' => 'project_bom_entry',
'ProjectNameHierarchical' => str_repeat('--', $depth) . '> ' . $entity->getName(),
'ProjectName' => $entity->getName(),
'ProjectFullName' => $this->getFullName($entity),
'BomQuantity' => $bomEntry->getQuantity() ?? '',
'BomPartId' => $bomEntry->getPart()?->getId() ?? '',
'BomPartIpn' => $bomEntry->getPart()?->getIpn() ?? '',
'BomPartName' => $bomEntry->getPart()?->getName() ?? '',
'BomName' => $bomEntry->getName() ?? '',
'BomPartDescription' => $bomEntry->getPart()?->getDescription() ?? '',
'BomMountNames' => $bomEntry->getMountNames(),
], $entity->getBomEntries()->toArray()),
],
Assembly::class => [
'header' => [
'Id', 'ParentId', 'Type', 'AssemblyIpn', 'AssemblyNameHierarchical', 'AssemblyName',
'AssemblyFullName', 'BomQuantity', 'BomPartId', 'BomPartIpn', 'BomPartName', 'BomName', 'BomPartDescription',
'BomMountNames', 'BomReferencedAssemblyId', 'BomReferencedAssemblyIpn', 'BomReferencedAssemblyFullName'
],
'processEntity' => fn($entity, $depth) => [
'Id' => $entity->getId(),
'ParentId' => $entity->getParent()?->getId() ?? '',
'Type' => 'assembly',
'AssemblyIpn' => $entity->getIpn(),
'AssemblyNameHierarchical' => str_repeat('--', $depth) . ' ' . $entity->getName(),
'AssemblyName' => $entity->getName(),
'AssemblyFullName' => $this->getFullName($entity),
'BomQuantity' => '-',
'BomPartId' => '-',
'BomPartIpn' => '-',
'BomPartName' => '-',
'BomName' => '-',
'BomPartDescription' => '-',
'BomMountNames' => '-',
'BomReferencedAssemblyId' => '-',
'BomReferencedAssemblyIpn' => '-',
'BomReferencedAssemblyFullName' => '-',
],
'processBomEntries' => fn($entity, $depth) => array_map(fn($bomEntry) => [
'Id' => $entity->getId(),
'ParentId' => '',
'Type' => 'assembly_bom_entry',
'AssemblyIpn' => $entity->getIpn(),
'AssemblyNameHierarchical' => str_repeat('--', $depth) . '> ' . $entity->getName(),
'AssemblyName' => $entity->getName(),
'AssemblyFullName' => $this->getFullName($entity),
'BomQuantity' => $bomEntry->getQuantity() ?? '',
'BomPartId' => $bomEntry->getPart()?->getId() ?? '',
'BomPartIpn' => $bomEntry->getPart()?->getIpn() ?? '',
'BomPartName' => $bomEntry->getPart()?->getName() ?? '',
'BomName' => $bomEntry->getName() ?? '',
'BomPartDescription' => $bomEntry->getPart()?->getDescription() ?? '',
'BomMountNames' => $bomEntry->getMountNames(),
'BomReferencedAssemblyId' => $bomEntry->getReferencedAssembly()?->getId() ?? '',
'BomReferencedAssemblyIpn' => $bomEntry->getReferencedAssembly()?->getIpn() ?? '',
'BomReferencedAssemblyFullName' => $this->getFullName($bomEntry->getReferencedAssembly() ?? null),
], $entity->getBomEntries()->toArray()),
],
Supplier::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
Manufacturer::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
StorageLocation::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
Footprint::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
Currency::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
MeasurementUnit::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
LabelProfile::class => [
'header' => ['Id', 'SupportedElement', 'Name'],
'processEntity' => fn(LabelProfile $entity, $depth) => [
'Id' => $entity->getId(),
'SupportedElement' => $entity->getOptions()->getSupportedElement()->name,
'Name' => $entity->getName(),
],
],
];
//Get configuration for the entity type
$entityConfig = $config[$type] ?? null;
if (!$entityConfig) {
return '';
}
//Initialize CSV data with the header
$csvData = [];
$csvData[] = $entityConfig['header'];
$relevantEntities = $entities;
if ($isHierarchical) {
//Filter root entities (those without parents)
$relevantEntities = array_filter($entities, fn($entity) => $entity->getParent() === null);
if (count($relevantEntities) === 0 && count($entities) > 0) {
//If no root entities are found, then we need to add all entities
$relevantEntities = $entities;
}
}
//Sort root entities alphabetically by `name`
usort($relevantEntities, fn($a, $b) => strnatcasecmp($a->getName(), $b->getName()));
//Recursive function to process an entity and its children
$processEntity = function ($entity, &$csvData, $depth = 0) use (&$processEntity, $entityConfig, $isHierarchical) {
//Add main entity data to CSV
$csvData[] = $entityConfig['processEntity']($entity, $depth);
//Process BOM entries if applicable
if (isset($entityConfig['processBomEntries'])) {
$bomRows = $entityConfig['processBomEntries']($entity, $depth);
foreach ($bomRows as $bomRow) {
$csvData[] = $bomRow;
}
}
if ($isHierarchical) {
//Retrieve children, sort alphabetically, then process them
$children = $entity->getChildren()->toArray();
usort($children, fn($a, $b) => strnatcasecmp($a->getName(), $b->getName()));
foreach ($children as $childEntity) {
$processEntity($childEntity, $csvData, $depth + 1);
}
}
};
//Start processing with root entities
foreach ($relevantEntities as $rootEntity) {
$processEntity($rootEntity, $csvData);
}
//Generate CSV string
$output = '';
foreach ($csvData as $line) {
$output .= implode(';', $line) . "\n"; // Use a semicolon as the delimiter
}
return $output;
}
/**
* Constructs the full hierarchical name of an object by traversing
* through its parent objects and concatenating their names using
* a specified separator.
*
* @param AttachmentType|Category|Project|Assembly|Supplier|Manufacturer|StorageLocation|Footprint|Currency|MeasurementUnit|LabelProfile|null $object The object whose full name is to be constructed. If null, the result will be an empty string.
* @param string $separator The string used to separate the names of the objects in the full hierarchy.
*
* @return string The full hierarchical name constructed by concatenating the names of the object and its parents.
*/
private function getFullName(AttachmentType|Category|Project|Assembly|Supplier|Manufacturer|StorageLocation|Footprint|Currency|MeasurementUnit|LabelProfile|null $object, string $separator = '->'): string
{
$fullNameParts = [];
while ($object !== null) {
array_unshift($fullNameParts, $object->getName());
$object = $object->getParent();
}
return implode($separator, $fullNameParts);
}
}

View file

@ -1,6 +1,6 @@
<form class="form-horizontal" method="post" action="{{ path }}" data-turbo="false" data-turbo-frame="_top">
<form class="form-horizontal" method="post" action="{{ path }}" data-turbo="false" data-turbo-frame="_top" {{ stimulus_controller('elements/toggle_visibility', {classes: ['format', 'level', 'include_children']}) }}>
<div class="row">
<div class="row format">
<label class="col-form-label col-md-3">{% trans %}export.format{% endtrans %}</label>
<div class="col-md-9">
<select class="form-select" name="format">
@ -12,7 +12,7 @@
</div>
</div>
<div class="row mt-2">
<div class="row mt-2 level">
<label class="col-form-label col-md-3">{% trans %}export.level{% endtrans %}</label>
<div class="col-md-9">
<select class="form-select" name="level">
@ -23,7 +23,7 @@
</div>
</div>
<div class="row mt-2">
<div class="row mt-2 include_children">
<div class="offset-md-3 col-sm">
<div class="form-check">
<input class="form-check-input form-check-input" name="include_children" id="include_children" type="checkbox" checked>
@ -34,6 +34,17 @@
</div>
</div>
<div class="row mt-2">
<div class="offset-md-3 col-sm">
<div class="form-check">
<input class="form-check-input form-check-input" name="readable" id="readable" type="checkbox" data-action="change->toggle-visibility#toggle">
<label class="form-check-label form-check-label" for="readable">
{% trans %}export.readable{% endtrans %}
</label>
</div>
</div>
</div>
<div class="row mt-2">
<div class="offset-sm-3 col-sm">
<button type="submit" class="btn btn-primary">{% trans %}export.btn{% endtrans %}</button>

View file

@ -351,6 +351,12 @@
<target>Exportovat všechny prvky</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>Čitelné CSV</target>
</segment>
</unit>
<unit id="M.rXmnA" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>

View file

@ -351,6 +351,12 @@
<target>Eksportér alle elementer</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>Læsbar CSV</target>
</segment>
</unit>
<unit id="zPSdxU4" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>

View file

@ -1010,6 +1010,12 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
<target>Unterelemente auch exportieren</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>Lesbares CSV</target>
</segment>
</unit>
<unit id="Pdmfku8" name="export.btn">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\_export_form.html.twig:39</note>

View file

@ -228,6 +228,12 @@
<target>Εξαγωγή όλων των στοιχείων</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>Αναγνώσιμο CSV</target>
</segment>
</unit>
<unit id="zPSdxU4" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>

View file

@ -351,6 +351,12 @@
<target>Export all elements</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>Readable CSV</target>
</segment>
</unit>
<unit id="M.rXmnA" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>

View file

@ -351,6 +351,12 @@
<target>Exportar todos los elementos</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>CSV legible</target>
</segment>
</unit>
<unit id="M.rXmnA" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>

View file

@ -320,6 +320,12 @@
<target>Exporter tous les éléments</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>CSV lisible</target>
</segment>
</unit>
<unit id="zPSdxU4" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>
@ -9822,13 +9828,13 @@ exemple de ville</target>
</target>
</segment>
</unit>
<unit id="bH5Qi1Z" name="assembly.bom_import.template.csv.exptected_columns">
<unit id="aK4i2aT" name="assembly.bom_import.template.csv.exptected_columns">
<segment>
<source>assembly.bom_import.template.csv.exptected_columns</source>
<target>Colonnes possibles :</target>
</segment>
</unit>
<unit id="NIcfgj84" name="assembly.bom_import.template.csv.table">
<unit id="a8UhiwR" name="assembly.bom_import.template.csv.table">
<segment>
<source>assembly.bom_import.template.csv.table</source>
<target>

View file

@ -351,6 +351,12 @@
<target>Esportare tutti gli elementi</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>CSV leggibile</target>
</segment>
</unit>
<unit id="M.rXmnA" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>

View file

@ -320,6 +320,12 @@
<target>すべてエクスポートする</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>読みやすいCSV</target>
</segment>
</unit>
<unit id="zPSdxU4" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>

View file

@ -351,6 +351,12 @@
<target>Exporteer alle elementen</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>Leesbare CSV</target>
</segment>
</unit>
<unit id="zPSdxU4" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>

View file

@ -351,6 +351,12 @@
<target>Eksportuj wszystkie elementy</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>Czytelny CSV</target>
</segment>
</unit>
<unit id="M.rXmnA" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>

View file

@ -351,6 +351,12 @@
<target>Экспортировать всё</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>Читаемый CSV</target>
</segment>
</unit>
<unit id="M.rXmnA" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>

View file

@ -351,6 +351,12 @@
<target>导出所有元素</target>
</segment>
</unit>
<unit id="a5h8ltP" name="export.readable">
<segment state="translated">
<source>export.readable</source>
<target>可读的 CSV</target>
</segment>
</unit>
<unit id="zPSdxU4" name="mass_creation.help">
<notes>
<note category="file-source" priority="1">Part-DB1\templates\AdminPages\EntityAdminBase.html.twig:185</note>