Füge Baugruppen hinzu

This commit is contained in:
Marcel Diegelmann 2025-10-17 12:25:10 +02:00
parent 98b8c5b788
commit 5bd0741fba
128 changed files with 23830 additions and 109 deletions

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Command\Migrations;
use App\Entity\AssemblySystem\Assembly;
use Symfony\Component\Console\Attribute\AsCommand;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractNamedDBElement;
@ -88,6 +89,7 @@ class ConvertBBCodeCommand extends Command
AttachmentType::class => ['comment'],
StorageLocation::class => ['comment'],
Project::class => ['comment'],
Assembly::class => ['comment'],
Category::class => ['comment'],
Manufacturer::class => ['comment'],
MeasurementUnit::class => ['comment'],

View file

@ -0,0 +1,80 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\Controller\AdminPages;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Parameters\AssemblyParameter;
use App\Form\AdminPages\AssemblyAdminForm;
use App\Services\ImportExportSystem\EntityExporter;
use App\Services\ImportExportSystem\EntityImporter;
use App\Services\Trees\StructuralElementRecursionHelper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route(path: '/assembly')]
class AssemblyAdminController extends BaseAdminController
{
protected string $entity_class = Assembly::class;
protected string $twig_template = 'admin/assembly_admin.html.twig';
protected string $form_class = AssemblyAdminForm::class;
protected string $route_base = 'assembly';
protected string $attachment_class = AssemblyAttachment::class;
protected ?string $parameter_class = AssemblyParameter::class;
#[Route(path: '/{id}', name: 'assembly_delete', methods: ['DELETE'])]
public function delete(Request $request, Assembly $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse
{
return $this->_delete($request, $entity, $recursionHelper);
}
#[Route(path: '/{id}/edit/{timestamp}', name: 'assembly_edit', requirements: ['id' => '\d+'])]
#[Route(path: '/{id}/edit', requirements: ['id' => '\d+'])]
public function edit(Assembly $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response
{
return $this->_edit($entity, $request, $em, $timestamp);
}
#[Route(path: '/new', name: 'assembly_new')]
#[Route(path: '/{id}/clone', name: 'assembly_clone')]
#[Route(path: '/')]
public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?Assembly $entity = null): Response
{
return $this->_new($request, $em, $importer, $entity);
}
#[Route(path: '/export', name: 'assembly_export_all')]
public function exportAll(EntityManagerInterface $em, EntityExporter $exporter, Request $request): Response
{
return $this->_exportAll($em, $exporter, $request);
}
#[Route(path: '/{id}/export', name: 'assembly_export')]
public function exportEntity(Assembly $entity, EntityExporter $exporter, Request $request): Response
{
return $this->_exportEntity($entity, $exporter, $request);
}
}

View file

@ -23,6 +23,8 @@ declare(strict_types=1);
namespace App\Controller\AdminPages;
use App\DataTables\LogDataTable;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\AttachmentUpload;
@ -193,6 +195,15 @@ abstract class BaseAdminController extends AbstractController
$entity->setMasterPictureAttachment(null);
}
if ($entity instanceof Assembly) {
/* Replace ipn placeholder with the IPN information if applicable.
* The '%%ipn%%' placeholder is automatically inserted into the Name property,
* depending on CREATE_ASSEMBLY_USE_IPN_PLACEHOLDER_IN_NAME, when creating a new one,
* to avoid having to insert it manually */
$entity->setName(str_ireplace('%%ipn%%', $entity->getIpn() ?? '', $entity->getName()));
}
$this->commentHelper->setMessage($form['log_comment']->getData());
$em->persist($entity);
@ -287,6 +298,15 @@ abstract class BaseAdminController extends AbstractController
$new_entity->setMasterPictureAttachment(null);
}
if ($new_entity instanceof Assembly) {
/* Replace ipn placeholder with the IPN information if applicable.
* The '%%ipn%%' placeholder is automatically inserted into the Name property,
* depending on CREATE_ASSEMBLY_USE_IPN_PLACEHOLDER_IN_NAME, when creating a new one,
* to avoid having to insert it manually */
$new_entity->setName(str_ireplace('%%ipn%%', $new_entity->getIpn() ?? '', $new_entity->getName()));
}
$this->commentHelper->setMessage($form['log_comment']->getData());
$em->persist($new_entity);
$em->flush();
@ -450,6 +470,10 @@ abstract class BaseAdminController extends AbstractController
return $this->redirectToRoute($this->route_base.'_edit', ['id' => $entity->getID()]);
}
} else {
if ($entity instanceof Assembly) {
$this->markReferencedBomEntry($entity);
}
if ($entity instanceof AbstractStructuralDBElement) {
$parent = $entity->getParent();
@ -497,4 +521,16 @@ abstract class BaseAdminController extends AbstractController
return $exporter->exportEntityFromRequest($entity, $request);
}
private function markReferencedBomEntry(Assembly $referencedAssembly): void
{
$bomEntries = $this->entityManager->getRepository(AssemblyBOMEntry::class)->findBy(['referencedAssembly' => $referencedAssembly]);
foreach ($bomEntries as $entry) {
$entry->setReferencedAssembly(null);
$entry->setName($referencedAssembly->getName(). ' DELETED');
$this->entityManager->persist($entry);
}
}
}

View file

@ -0,0 +1,318 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Controller;
use App\DataTables\AssemblyBomEntriesDataTable;
use App\DataTables\AssemblyDataTable;
use App\DataTables\ErrorDataTable;
use App\DataTables\Filters\AssemblyFilter;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Part;
use App\Exceptions\InvalidRegexException;
use App\Form\AssemblySystem\AssemblyAddPartsType;
use App\Form\Filters\AssemblyFilterType;
use App\Services\ImportExportSystem\BOMImporter;
use App\Services\Trees\NodesListBuilder;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\ORM\EntityManagerInterface;
use League\Csv\SyntaxError;
use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
#[Route(path: '/assembly')]
class AssemblyController extends AbstractController
{
public function __construct(
private readonly DataTableFactory $dataTableFactory,
private readonly TranslatorInterface $translator,
private readonly NodesListBuilder $nodesListBuilder
) {
}
#[Route(path: '/list', name: 'assemblies_list')]
public function showAll(Request $request): Response
{
return $this->showListWithFilter($request,'assemblies/lists/all_list.html.twig');
}
/**
* Common implementation for the part list pages.
* @param Request $request The request to parse
* @param string $template The template that should be rendered
* @param callable|null $filter_changer A function that is called with the filter object as parameter. This function can be used to customize the filter
* @param callable|null $form_changer A function that is called with the form object as parameter. This function can be used to customize the form
* @param array $additonal_template_vars Any additional template variables that should be passed to the template
* @param array $additional_table_vars Any additional variables that should be passed to the table creation
*/
protected function showListWithFilter(Request $request, string $template, ?callable $filter_changer = null, ?callable $form_changer = null, array $additonal_template_vars = [], array $additional_table_vars = []): Response
{
$this->denyAccessUnlessGranted('@assemblies.read');
$formRequest = clone $request;
$formRequest->setMethod('GET');
$filter = new AssemblyFilter($this->nodesListBuilder);
if($filter_changer !== null){
$filter_changer($filter);
}
$filterForm = $this->createForm(AssemblyFilterType::class, $filter, ['method' => 'GET']);
if($form_changer !== null) {
$form_changer($filterForm);
}
$filterForm->handleRequest($formRequest);
$table = $this->dataTableFactory->createFromType(
AssemblyDataTable::class,
array_merge(['filter' => $filter], $additional_table_vars),
['lengthMenu' => AssemblyDataTable::LENGTH_MENU]
)
->handleRequest($request);
if ($table->isCallback()) {
try {
try {
return $table->getResponse();
} catch (DriverException $driverException) {
if ($driverException->getCode() === 1139) {
//Convert the driver exception to InvalidRegexException so it has the same handler as for SQLite
throw InvalidRegexException::fromDriverException($driverException);
} else {
throw $driverException;
}
}
} catch (InvalidRegexException $exception) {
$errors = $this->translator->trans('assembly.table.invalid_regex').': '.$exception->getReason();
$request->request->set('order', []);
return ErrorDataTable::errorTable($this->dataTableFactory, $request, $errors);
}
}
return $this->render($template, array_merge([
'datatable' => $table,
'filterForm' => $filterForm->createView(),
], $additonal_template_vars));
}
#[Route(path: '/{id}/info', name: 'assembly_info', requirements: ['id' => '\d+'])]
public function info(Assembly $assembly, Request $request): Response
{
$this->denyAccessUnlessGranted('read', $assembly);
$table = $this->dataTableFactory->createFromType(AssemblyBomEntriesDataTable::class, ['assembly' => $assembly])
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->render('assemblies/info/info.html.twig', [
'datatable' => $table,
'assembly' => $assembly,
]);
}
#[Route(path: '/{id}/import_bom', name: 'assembly_import_bom', requirements: ['id' => '\d+'])]
public function importBOM(Request $request, EntityManagerInterface $entityManager, Assembly $assembly,
BOMImporter $BOMImporter, ValidatorInterface $validator): Response
{
$this->denyAccessUnlessGranted('edit', $assembly);
$builder = $this->createFormBuilder();
$builder->add('file', FileType::class, [
'label' => 'import.file',
'required' => true,
'attr' => [
'accept' => '.csv, .json'
]
]);
$builder->add('type', ChoiceType::class, [
'label' => 'assembly.bom_import.type',
'required' => true,
'choices' => [
'assembly.bom_import.type.json' => 'json',
'assembly.bom_import.type.csv' => 'csv',
'assembly.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
'assembly.bom_import.type.kicad_schematic' => 'kicad_schematic',
]
]);
$builder->add('clear_existing_bom', CheckboxType::class, [
'label' => 'assembly.bom_import.clear_existing_bom',
'required' => false,
'data' => false,
'help' => 'assembly.bom_import.clear_existing_bom.help',
]);
$builder->add('submit', SubmitType::class, [
'label' => 'import.btn',
]);
$form = $builder->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Clear existing entries if requested
if ($form->get('clear_existing_bom')->getData()) {
$assembly->getBomEntries()->clear();
$entityManager->flush();
}
try {
$importerResult = $BOMImporter->importFileIntoAssembly($form->get('file')->getData(), $assembly, [
'type' => $form->get('type')->getData(),
]);
//Validate the assembly entries
$errors = $validator->validateProperty($assembly, 'bom_entries');
//If no validation errors occured, save the changes and redirect to edit page
if (count ($errors) === 0 && $importerResult->getViolations()->count() === 0) {
$entries = $importerResult->getBomEntries();
$this->addFlash('success', t('assembly.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
return $this->redirectToRoute('assembly_edit', ['id' => $assembly->getID()]);
}
//Show validation errors
$this->addFlash('error', t('assembly.bom_import.flash.invalid_entries'));
} catch (\UnexpectedValueException|\RuntimeException|SyntaxError $e) {
$this->addFlash('error', t('assembly.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
}
}
$jsonTemplate = [
[
"quantity" => 1.0,
"name" => $this->translator->trans('assembly.bom_import.template.entry.name'),
"part" => [
"id" => null,
"ipn" => $this->translator->trans('assembly.bom_import.template.entry.part.ipn'),
"mpnr" => $this->translator->trans('assembly.bom_import.template.entry.part.mpnr'),
"name" => $this->translator->trans('assembly.bom_import.template.entry.part.name'),
"description" => null,
"manufacturer" => [
"id" => null,
"name" => $this->translator->trans('assembly.bom_import.template.entry.part.manufacturer.name')
],
"category" => [
"id" => null,
"name" => $this->translator->trans('assembly.bom_import.template.entry.part.category.name')
]
]
]
];
return $this->render('assemblies/import_bom.html.twig', [
'assembly' => $assembly,
'jsonTemplate' => $jsonTemplate,
'form' => $form,
'validationErrors' => $errors ?? null,
'importerErrors' => isset($importerResult) ? $importerResult->getViolations() : null,
]);
}
#[Route(path: '/add_parts', name: 'assembly_add_parts_no_id')]
#[Route(path: '/{id}/add_parts', name: 'assembly_add_parts', requirements: ['id' => '\d+'])]
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Assembly $assembly): Response
{
if($assembly instanceof Assembly) {
$this->denyAccessUnlessGranted('edit', $assembly);
} else {
$this->denyAccessUnlessGranted('@assemblies.edit');
}
$form = $this->createForm(AssemblyAddPartsType::class, null, [
'assembly' => $assembly,
]);
//Preset the BOM entries with the selected parts, when the form was not submitted yet
$preset_data = new ArrayCollection();
foreach (explode(',', (string) $request->get('parts', '')) as $part_id) {
//Skip empty part IDs. Postgres seems to be especially sensitive to empty strings, as it does not allow them in integer columns
if ($part_id === '') {
continue;
}
$part = $entityManager->getRepository(Part::class)->find($part_id);
if (null !== $part) {
//If there is already a BOM entry for this part, we use this one (we edit it then)
$bom_entry = $entityManager->getRepository(AssemblyBOMEntry::class)->findOneBy([
'assembly' => $assembly,
'part' => $part
]);
if ($bom_entry !== null) {
$preset_data->add($bom_entry);
} else { //Otherwise create an empty one
$entry = new AssemblyBOMEntry();
$entry->setAssembly($assembly);
$entry->setPart($part);
$preset_data->add($entry);
}
}
}
$form['bom_entries']->setData($preset_data);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$target_assembly = $assembly ?? $form->get('assembly')->getData();
//Ensure that we really have acces to the selected assembly
$this->denyAccessUnlessGranted('edit', $target_assembly);
$data = $form->getData();
$bom_entries = $data['bom_entries'];
foreach ($bom_entries as $bom_entry){
$target_assembly->addBOMEntry($bom_entry);
}
$entityManager->flush();
//If a redirect query parameter is set, redirect to this page
if ($request->query->get('_redirect')) {
return $this->redirect($request->query->get('_redirect'));
}
//Otherwise just show the assembly info page
return $this->redirectToRoute('assembly_info', ['id' => $target_assembly->getID()]);
}
return $this->render('assemblies/add_parts.html.twig', [
'assembly' => $assembly,
'form' => $form,
]);
}
}

View file

@ -46,14 +46,16 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
#[Route(path: '/project')]
class ProjectController extends AbstractController
{
public function __construct(private readonly DataTableFactory $dataTableFactory)
{
public function __construct(
private readonly DataTableFactory $dataTableFactory,
private readonly TranslatorInterface $translator,
) {
}
#[Route(path: '/{id}/info', name: 'project_info', requirements: ['id' => '\d+'])]
@ -147,6 +149,8 @@ class ProjectController extends AbstractController
'label' => 'project.bom_import.type',
'required' => true,
'choices' => [
'project.bom_import.type.json' => 'json',
'project.bom_import.type.csv' => 'csv',
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
'project.bom_import.type.generic_csv' => 'generic_csv',
@ -189,17 +193,20 @@ class ProjectController extends AbstractController
}
// For PCB imports, proceed directly
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
$importerResult = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
'type' => $import_type,
]);
// Validate the project entries
$errors = $validator->validateProperty($project, 'bom_entries');
// If no validation errors occurred, save the changes and redirect to edit page
if (count($errors) === 0) {
//If no validation errors occurred, save the changes and redirect to edit page
if (count($errors) === 0 && $importerResult->getViolations()->count() === 0) {
$entries = $importerResult->getBomEntries();
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
}
@ -211,10 +218,29 @@ class ProjectController extends AbstractController
}
}
$jsonTemplate = [
[
"quantity" => 1.0,
"name" => $this->translator->trans('project.bom_import.template.entry.name'),
"part" => [
"id" => null,
"ipn" => $this->translator->trans('project.bom_import.template.entry.part.ipn'),
"mpnr" => $this->translator->trans('project.bom_import.template.entry.part.mpnr'),
"name" => $this->translator->trans('project.bom_import.template.entry.part.name'),
"manufacturer" => [
"id" => null,
"name" => $this->translator->trans('project.bom_import.template.entry.part.manufacturer.name')
],
]
]
];
return $this->render('projects/import_bom.html.twig', [
'project' => $project,
'jsonTemplate' => $jsonTemplate,
'form' => $form,
'errors' => $errors ?? null,
'validationErrors' => $errors ?? null,
'importerErrors' => isset($importerResult) ? $importerResult->getViolations() : null,
]);
}
@ -395,7 +421,7 @@ class ProjectController extends AbstractController
}
// Import with field mapping and priorities (validation already passed)
$entries = $BOMImporter->stringToBOMEntries($file_content, [
$entries = $BOMImporter->stringToBOMEntries($project, $file_content, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\AssemblySystem\Assembly;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
@ -129,4 +130,17 @@ class TreeController extends AbstractController
return new JsonResponse($tree);
}
#[Route(path: '/assembly/{id}', name: 'tree_assembly')]
#[Route(path: '/assemblies', name: 'tree_assembly_root')]
public function assemblyTree(?Assembly $assembly = null): JsonResponse
{
if ($this->isGranted('@assemblies.read')) {
$tree = $this->treeGenerator->getTreeView(Assembly::class, $assembly, 'assemblies');
} else {
return new JsonResponse("Access denied", Response::HTTP_FORBIDDEN);
}
return new JsonResponse($tree);
}
}

View file

@ -22,8 +22,10 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Parameters\AbstractParameter;
use App\Settings\MiscSettings\IpnSuggestSettings;
use App\Services\Attachments\AssemblyPreviewGenerator;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
@ -54,6 +56,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use InvalidArgumentException;
/**
* In this controller the endpoints for the typeaheads are collected.
@ -113,19 +116,22 @@ class TypeaheadController extends AbstractController
'group' => GroupParameter::class,
'measurement_unit' => MeasurementUnitParameter::class,
'currency' => Currency::class,
default => throw new \InvalidArgumentException('Invalid parameter type: '.$type),
default => throw new InvalidArgumentException('Invalid parameter type: '.$type),
};
}
#[Route(path: '/parts/search/{query}', name: 'typeahead_parts')]
public function parts(EntityManagerInterface $entityManager, PartPreviewGenerator $previewGenerator,
AttachmentURLGenerator $attachmentURLGenerator, string $query = ""): JsonResponse
{
public function parts(
EntityManagerInterface $entityManager,
PartPreviewGenerator $previewGenerator,
AttachmentURLGenerator $attachmentURLGenerator,
string $query = ""
): JsonResponse {
$this->denyAccessUnlessGranted('@parts.read');
$repo = $entityManager->getRepository(Part::class);
$partRepository = $entityManager->getRepository(Part::class);
$parts = $repo->autocompleteSearch($query, 100);
$parts = $partRepository->autocompleteSearch($query, 100);
$data = [];
foreach ($parts as $part) {
@ -145,12 +151,50 @@ class TypeaheadController extends AbstractController
'footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '',
'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'),
'image' => $preview_url,
];
];
}
return new JsonResponse($data);
}
#[Route(path: '/assemblies/search/{query}', name: 'typeahead_assemblies')]
public function assemblies(
EntityManagerInterface $entityManager,
AssemblyPreviewGenerator $assemblyPreviewGenerator,
AttachmentURLGenerator $attachmentURLGenerator,
string $query = ""
): JsonResponse {
$this->denyAccessUnlessGranted('@assemblies.read');
$result = [];
$assemblyRepository = $entityManager->getRepository(Assembly::class);
$assemblies = $assemblyRepository->autocompleteSearch($query, 100);
foreach ($assemblies as $assembly) {
$preview_attachment = $assemblyPreviewGenerator->getTablePreviewAttachment($assembly);
if($preview_attachment instanceof Attachment) {
$preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm');
} else {
$preview_url = '';
}
/** @var Assembly $assembly */
$result[] = [
'id' => $assembly->getID(),
'name' => $assembly->getName(),
'category' => '',
'footprint' => '',
'description' => mb_strimwidth($assembly->getDescription(), 0, 127, '...'),
'image' => $preview_url,
];
}
return new JsonResponse($result);
}
#[Route(path: '/parameters/{type}/search/{query}', name: 'typeahead_parameters', requirements: ['type' => '.+'])]
public function parameters(string $type, EntityManagerInterface $entityManager, string $query = ""): JsonResponse
{

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parts\PartCustomState;
@ -50,7 +51,7 @@ class DataStructureFixtures extends Fixture implements DependentFixtureInterface
public function load(ObjectManager $manager): void
{
//Reset autoincrement
$types = [AttachmentType::class, Project::class, Category::class, Footprint::class, Manufacturer::class,
$types = [AttachmentType::class, Project::class, Assembly::class, Category::class, Footprint::class, Manufacturer::class,
MeasurementUnit::class, StorageLocation::class, Supplier::class, PartCustomState::class];
foreach ($types as $type) {

View file

@ -58,6 +58,7 @@ class GroupFixtures extends Fixture
$users->setName('users');
$this->permission_presets->applyPreset($users, PermissionPresetsHelper::PRESET_EDITOR);
$this->addDevicesPermissions($users);
$this->addAssemblyPermissions($users);
$this->setReference(self::USERS, $users);
$manager->persist($users);
@ -69,4 +70,9 @@ class GroupFixtures extends Fixture
$this->permissionManager->setAllOperationsOfPermission($group, 'projects', true);
}
private function addAssemblyPermissions(Group $group): void
{
$this->permissionManager->setAllOperationsOfPermission($group, 'assemblies', true);
}
}

View file

@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\AssemblyDataTableHelper;
use App\DataTables\Helpers\ColumnSortHelper;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Services\Formatters\AmountFormatter;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class AssemblyBomEntriesDataTable implements DataTableTypeInterface
{
public function __construct(
private readonly TranslatorInterface $translator,
private readonly PartDataTableHelper $partDataTableHelper,
private readonly AssemblyDataTableHelper $assemblyDataTableHelper,
private readonly AmountFormatter $amountFormatter,
private readonly ColumnSortHelper $csh,
private readonly TableSettings $tableSettings,
) {
}
public function configure(DataTable $dataTable, array $options): void
{
$this->csh
->add('picture', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, AssemblyBOMEntry $context) {
if(!$context->getPart() instanceof Part) {
return '';
}
return $this->partDataTableHelper->renderPicture($context->getPart());
},
])
->add('id', TextColumn::class, [
'label' => $this->translator->trans('part.table.id'),
])
->add('quantity', TextColumn::class, [
'label' => $this->translator->trans('assembly.bom.quantity'),
'className' => 'text-center',
'orderField' => 'bom_entry.quantity',
'render' => function ($value, AssemblyBOMEntry $context): float|string {
//If we have a non-part entry, only show the rounded quantity
if (!$context->getPart() instanceof Part) {
return round($context->getQuantity());
}
//Otherwise use the unit of the part to format the quantity
return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
},
])
->add('name', TextColumn::class, [
'label' => $this->translator->trans('part.table.name'),
'orderField' => 'NATSORT(part.name)',
'render' => function ($value, AssemblyBOMEntry $context) {
if(!$context->getPart() instanceof Part && !$context->getReferencedAssembly() instanceof Assembly) {
return htmlspecialchars((string) $context->getName());
}
$tmp = $context->getName();
if ($context->getPart() !== null) {
$tmp = $this->partDataTableHelper->renderName($context->getPart());
$tmp = $this->translator->trans('part.table.name.value.for_part', ['%value%' => $tmp]);
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
} elseif ($context->getReferencedAssembly() !== null) {
$tmp = $this->assemblyDataTableHelper->renderName($context->getReferencedAssembly());
$tmp = $this->translator->trans('part.table.name.value.for_assembly', ['%value%' => $tmp]);
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
}
return $tmp;
},
])
->add('ipn', TextColumn::class, [
'label' => $this->translator->trans('part.table.ipn'),
'orderField' => 'NATSORT(part.ipn)',
'render' => function ($value, AssemblyBOMEntry $context) {
if($context->getPart() instanceof Part) {
return $context->getPart()->getIpn();
} elseif($context->getReferencedAssembly() instanceof Assembly) {
return $context->getReferencedAssembly()->getIpn();
}
return '';
}
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('part.table.description'),
'data' => function (AssemblyBOMEntry $context) {
if ($context->getPart() instanceof Part) {
return $context->getPart()->getDescription();
} elseif ($context->getReferencedAssembly() instanceof Assembly) {
return $context->getReferencedAssembly()->getDescription();
}
//For non-part BOM entries show the comment field
return $context->getComment();
},
])
->add('category', EntityColumn::class, [
'label' => $this->translator->trans('part.table.category'),
'property' => 'part.category',
'orderField' => 'NATSORT(category.name)',
])
->add('footprint', EntityColumn::class, [
'property' => 'part.footprint',
'label' => $this->translator->trans('part.table.footprint'),
'orderField' => 'NATSORT(footprint.name)',
])
->add('manufacturer', EntityColumn::class, [
'property' => 'part.manufacturer',
'label' => $this->translator->trans('part.table.manufacturer'),
'orderField' => 'NATSORT(manufacturer.name)',
])
->add('mountnames', TextColumn::class, [
'label' => 'assembly.bom.mountnames',
'render' => function ($value, AssemblyBOMEntry $context) {
$html = '';
foreach (explode(',', $context->getMountnames()) as $mountname) {
$html .= sprintf('<span class="badge badge-secondary bg-secondary">%s</span> ', htmlspecialchars($mountname));
}
return $html;
},
])
->add('designator', TextColumn::class, [
'label' => 'assembly.bom.designator',
'render' => function ($value, AssemblyBOMEntry $context) {
return htmlspecialchars($context->getDesignator());
},
])
->add('instockAmount', TextColumn::class, [
'label' => 'assembly.bom.instockAmount',
'visible' => false,
'render' => function ($value, AssemblyBOMEntry $context) {
if ($context->getPart() !== null) {
return $this->partDataTableHelper->renderAmount($context->getPart());
}
return '';
}
])
->add('storageLocations', TextColumn::class, [
'label' => 'part.table.storeLocations',
'visible' => false,
'render' => function ($value, AssemblyBOMEntry $context) {
if ($context->getPart() !== null) {
return $this->partDataTableHelper->renderStorageLocations($context->getPart());
}
return '';
}
])
->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.addedDate'),
])
->add('lastModified', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.lastModified'),
]);
//Apply the user configured order and visibility and add the columns to the table
$this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->tableSettings->assembliesBomDefaultColumns,
"TABLE_ASSEMBLIES_BOM_DEFAULT_COLUMNS");
$dataTable->addOrderBy('name');
$dataTable->createAdapter(ORMAdapter::class, [
'entity' => Attachment::class,
'query' => function (QueryBuilder $builder) use ($options): void {
$this->getQuery($builder, $options);
},
'criteria' => [
function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options);
},
new SearchCriteriaProvider(),
],
]);
}
private function getQuery(QueryBuilder $builder, array $options): void
{
$builder->select('bom_entry')
->addSelect('part')
->from(AssemblyBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
->leftJoin('bom_entry.referencedAssembly', 'referencedAssembly')
->leftJoin('part.category', 'category')
->leftJoin('part.footprint', 'footprint')
->leftJoin('part.manufacturer', 'manufacturer')
->where('bom_entry.assembly = :assembly')
->setParameter('assembly', $options['assembly'])
;
}
private function buildCriteria(QueryBuilder $builder, array $options): void
{
}
}

View file

@ -0,0 +1,250 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\DataTables;
use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Column\SelectColumn;
use App\DataTables\Filters\AssemblyFilter;
use App\DataTables\Filters\AssemblySearchFilter;
use App\DataTables\Helpers\AssemblyDataTableHelper;
use App\DataTables\Helpers\ColumnSortHelper;
use App\Doctrine\Helpers\FieldHelper;
use App\Entity\AssemblySystem\Assembly;
use App\Services\EntityURLGenerator;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
final class AssemblyDataTable implements DataTableTypeInterface
{
const LENGTH_MENU = [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]];
public function __construct(
private readonly EntityURLGenerator $urlGenerator,
private readonly TranslatorInterface $translator,
private readonly AssemblyDataTableHelper $assemblyDataTableHelper,
private readonly Security $security,
private readonly ColumnSortHelper $csh,
private readonly TableSettings $tableSettings,
) {
}
public function configureOptions(OptionsResolver $optionsResolver): void
{
$optionsResolver->setDefaults([
'filter' => null,
'search' => null
]);
$optionsResolver->setAllowedTypes('filter', [AssemblyFilter::class, 'null']);
$optionsResolver->setAllowedTypes('search', [AssemblySearchFilter::class, 'null']);
}
public function configure(DataTable $dataTable, array $options): void
{
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
$this->csh
->add('select', SelectColumn::class, visibility_configurable: false)
->add('picture', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => fn($value, Assembly $context) => $this->assemblyDataTableHelper->renderPicture($context),
], visibility_configurable: false)
->add('name', TextColumn::class, [
'label' => $this->translator->trans('assembly.table.name'),
'render' => fn($value, Assembly $context) => $this->assemblyDataTableHelper->renderName($context),
'orderField' => 'NATSORT(assembly.name)'
])
->add('id', TextColumn::class, [
'label' => $this->translator->trans('assembly.table.id'),
])
->add('ipn', TextColumn::class, [
'label' => $this->translator->trans('assembly.table.ipn'),
'orderField' => 'NATSORT(assembly.ipn)'
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('assembly.table.description'),
])
->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('assembly.table.addedDate'),
])
->add('lastModified', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('assembly.table.lastModified'),
]);
//Add a assembly column to list where the assembly is used as referenced assembly as bom-entry, when the user has the permission to see the assemblies
if ($this->security->isGranted('read', Assembly::class)) {
$this->csh->add('referencedAssemblies', TextColumn::class, [
'label' => $this->translator->trans('assembly.referencedAssembly.labelp'),
'render' => function ($value, Assembly $context): string {
$assemblies = $context->getAllReferencedAssembliesRecursive($context);
$max = 5;
$tmp = "";
for ($i = 0; $i < min($max, count($assemblies)); $i++) {
$tmp .= $this->assemblyDataTableHelper->renderName($assemblies[$i]);
if ($i < count($assemblies) - 1) {
$tmp .= ", ";
}
}
if (count($assemblies) > $max) {
$tmp .= ", + ".(count($assemblies) - $max);
}
return $tmp;
}
]);
}
$this->csh
->add('edit', IconLinkColumn::class, [
'label' => $this->translator->trans('assembly.table.edit'),
'href' => fn($value, Assembly $context) => $this->urlGenerator->editURL($context),
'disabled' => fn($value, Assembly $context) => !$this->security->isGranted('edit', $context),
'title' => $this->translator->trans('assembly.table.edit.title'),
]);
//Apply the user configured order and visibility and add the columns to the table
$this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->tableSettings->assembliesDefaultColumns,
"TABLE_ASSEMBLIES_DEFAULT_COLUMNS");
$dataTable->addOrderBy('name')
->createAdapter(TwoStepORMAdapter::class, [
'filter_query' => $this->getFilterQuery(...),
'detail_query' => $this->getDetailQuery(...),
'entity' => Assembly::class,
'hydrate' => AbstractQuery::HYDRATE_OBJECT,
//Use the simple total query, as we just want to get the total number of assemblies without any conditions
//For this the normal query would be pretty slow
'simple_total_query' => true,
'criteria' => [
function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options);
},
new SearchCriteriaProvider(),
],
'query_modifier' => $this->addJoins(...),
]);
}
private function getFilterQuery(QueryBuilder $builder): void
{
/* In the filter query we only select the IDs. The fetching of the full entities is done in the detail query.
* We only need to join the entities here, so we can filter by them.
* The filter conditions are added to this QB in the buildCriteria method.
*
* The amountSum field and the joins are dynamically added by the addJoins method, if the fields are used in the query.
* This improves the performance, as we do not need to join all tables, if we do not need them.
*/
$builder
->select('assembly.id')
->from(Assembly::class, 'assembly')
//The other group by fields, are dynamically added by the addJoins method
->addGroupBy('assembly');
}
private function getDetailQuery(QueryBuilder $builder, array $filter_results): void
{
$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.
*
* We do not require the subqueries like amountSum here, as it is not used to render the table (and only for sorting)
*/
$builder
->select('assembly')
->addSelect('master_picture_attachment')
->addSelect('attachments')
->from(Assembly::class, 'assembly')
->leftJoin('assembly.master_picture_attachment', 'master_picture_attachment')
->leftJoin('assembly.attachments', 'attachments')
->where('assembly.id IN (:ids)')
->setParameter('ids', $ids)
->addGroupBy('assembly')
->addGroupBy('master_picture_attachment')
->addGroupBy('attachments');
//Get the results in the same order as the IDs were passed
FieldHelper::addOrderByFieldParam($builder, 'assembly.id', 'ids');
}
/**
* This function is called right before the filter query is executed.
* We use it to dynamically add joins to the query, if the fields are used in the query.
* @param QueryBuilder $builder
* @return QueryBuilder
*/
private function addJoins(QueryBuilder $builder): QueryBuilder
{
//Check if the query contains certain conditions, for which we need to add additional joins
//The join fields get prefixed with an underscore, so we can check if they are used in the query easy without confusing them for a assembly subfield
$dql = $builder->getDQL();
if (str_contains($dql, '_master_picture_attachment')) {
$builder->leftJoin('assembly.master_picture_attachment', '_master_picture_attachment');
$builder->addGroupBy('_master_picture_attachment');
}
if (str_contains($dql, '_attachments')) {
$builder->leftJoin('assembly.attachments', '_attachments');
}
return $builder;
}
private function buildCriteria(QueryBuilder $builder, array $options): void
{
//Apply the search criterias first
if ($options['search'] instanceof AssemblySearchFilter) {
$search = $options['search'];
$search->apply($builder);
}
//We do the most stuff here in the filter class
if ($options['filter'] instanceof AssemblyFilter) {
$filter = $options['filter'];
$filter->apply($builder);
}
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Filters;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\DataTables\Filters\Constraints\IntConstraint;
use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Attachments\AttachmentType;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\QueryBuilder;
class AssemblyFilter implements FilterInterface
{
use CompoundFilterTrait;
public readonly IntConstraint $dbId;
public readonly TextConstraint $ipn;
public readonly TextConstraint $name;
public readonly TextConstraint $description;
public readonly TextConstraint $comment;
public readonly DateTimeConstraint $lastModified;
public readonly DateTimeConstraint $addedDate;
public readonly IntConstraint $attachmentsCount;
public readonly EntityConstraint $attachmentType;
public readonly TextConstraint $attachmentName;
public function __construct(NodesListBuilder $nodesListBuilder)
{
$this->name = new TextConstraint('assembly.name');
$this->description = new TextConstraint('assembly.description');
$this->comment = new TextConstraint('assembly.comment');
$this->dbId = new IntConstraint('assembly.id');
$this->ipn = new TextConstraint('assembly.ipn');
$this->addedDate = new DateTimeConstraint('assembly.addedDate');
$this->lastModified = new DateTimeConstraint('assembly.lastModified');
$this->attachmentsCount = new IntConstraint('COUNT(_attachments)');
$this->attachmentType = new EntityConstraint($nodesListBuilder, AttachmentType::class, '_attachments.attachment_type');
$this->attachmentName = new TextConstraint('_attachments.name');
}
public function apply(QueryBuilder $queryBuilder): void
{
$this->applyAllChildFilters($queryBuilder);
}
}

View file

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Filters;
use Doctrine\ORM\QueryBuilder;
class AssemblySearchFilter implements FilterInterface
{
/** @var boolean Whether to use regex for searching */
protected bool $regex = false;
/** @var bool Use name field for searching */
protected bool $name = true;
/** @var bool Use description for searching */
protected bool $description = true;
/** @var bool Use comment field for searching */
protected bool $comment = true;
/** @var bool Use ordernr for searching */
protected bool $ordernr = true;
/** @var bool Use Internal part number for searching */
protected bool $ipn = true;
public function __construct(
/** @var string The string to query for */
protected string $keyword
)
{
}
protected function getFieldsToSearch(): array
{
$fields_to_search = [];
if($this->name) {
$fields_to_search[] = 'assembly.name';
}
if($this->description) {
$fields_to_search[] = 'assembly.description';
}
if ($this->comment) {
$fields_to_search[] = 'assembly.comment';
}
if ($this->ipn) {
$fields_to_search[] = 'assembly.ipn';
}
return $fields_to_search;
}
public function apply(QueryBuilder $queryBuilder): void
{
$fields_to_search = $this->getFieldsToSearch();
//If we have nothing to search for, do nothing
if ($fields_to_search === [] || $this->keyword === '') {
return;
}
//Convert the fields to search to a list of expressions
$expressions = array_map(function (string $field): string {
if ($this->regex) {
return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
}
return sprintf("ILIKE(%s, :search_query) = TRUE", $field);
}, $fields_to_search);
//Add Or concatenation of the expressions to our query
$queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions)
);
//For regex, we pass the query as is, for like we add % to the start and end as wildcards
if ($this->regex) {
$queryBuilder->setParameter('search_query', $this->keyword);
} else {
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
}
}
public function getKeyword(): string
{
return $this->keyword;
}
public function setKeyword(string $keyword): AssemblySearchFilter
{
$this->keyword = $keyword;
return $this;
}
public function isRegex(): bool
{
return $this->regex;
}
public function setRegex(bool $regex): AssemblySearchFilter
{
$this->regex = $regex;
return $this;
}
public function isName(): bool
{
return $this->name;
}
public function setName(bool $name): AssemblySearchFilter
{
$this->name = $name;
return $this;
}
public function isDescription(): bool
{
return $this->description;
}
public function setDescription(bool $description): AssemblySearchFilter
{
$this->description = $description;
return $this;
}
public function isIPN(): bool
{
return $this->ipn;
}
public function setIPN(bool $ipn): AssemblySearchFilter
{
$this->ipn = $ipn;
return $this;
}
public function isComment(): bool
{
return $this->comment;
}
public function setComment(bool $comment): AssemblySearchFilter
{
$this->comment = $comment;
return $this;
}
}

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Helpers;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AssemblyPreviewGenerator;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\EntityURLGenerator;
/**
* A helper service which contains common code to render columns for assembly related tables
*/
class AssemblyDataTableHelper
{
public function __construct(
private readonly EntityURLGenerator $entityURLGenerator,
private readonly AssemblyPreviewGenerator $previewGenerator,
private readonly AttachmentURLGenerator $attachmentURLGenerator
) {
}
public function renderName(Assembly $context): string
{
$icon = '';
return sprintf(
'<a href="%s">%s%s</a>',
$this->entityURLGenerator->infoURL($context),
$icon,
htmlspecialchars($context->getName())
);
}
public function renderPicture(Assembly $context): string
{
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context);
if (!$preview_attachment instanceof Attachment) {
return '';
}
$title = htmlspecialchars($preview_attachment->getName());
if ($preview_attachment->getFilename()) {
$title .= ' ('.htmlspecialchars($preview_attachment->getFilename()).')';
}
return sprintf(
'<img alt="%s" src="%s" data-thumbnail="%s" class="%s" data-title="%s" data-controller="elements--hoverpic">',
'Assembly image',
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment),
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'),
'hoverpic assembly-table-image',
$title
);
}
}

View file

@ -39,6 +39,7 @@ use App\DataTables\Filters\PartSearchFilter;
use App\DataTables\Helpers\ColumnSortHelper;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Doctrine\Helpers\FieldHelper;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
@ -253,6 +254,34 @@ final class PartsDataTable implements DataTableTypeInterface
]);
}
//Add a assembly column to list where the part is used, when the user has the permission to see the assemblies
if ($this->security->isGranted('read', Assembly::class)) {
$this->csh->add('assemblies', TextColumn::class, [
'label' => $this->translator->trans('assembly.labelp'),
'render' => function ($value, Part $context): string {
//Only show the first 5 assembly names
$assemblies = $context->getAssemblies();
$tmp = "";
$max = 5;
for ($i = 0; $i < min($max, count($assemblies)); $i++) {
$url = $this->urlGenerator->infoURL($assemblies[$i]);
$tmp .= sprintf('<a href="%s">%s</a>', $url, htmlspecialchars($assemblies[$i]->getName()));
if ($i < count($assemblies) - 1) {
$tmp .= ", ";
}
}
if (count($assemblies) > $max) {
$tmp .= ", + ".(count($assemblies) - $max);
}
return $tmp;
}
]);
}
$this->csh
->add('edit', IconLinkColumn::class, [
'label' => $this->translator->trans('part.table.edit'),

View file

@ -0,0 +1,373 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\Entity\AssemblySystem;
use App\Repository\AssemblyRepository;
use App\Validator\Constraints\AssemblySystem\AssemblyCycle;
use App\Validator\Constraints\AssemblySystem\AssemblyInvalidBomEntry;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Validator\Constraints\UniqueObjectCollection;
use App\Validator\Constraints\AssemblySystem\UniqueReferencedAssembly;
use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\AssemblyParameter;
use App\Entity\Parts\Part;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* This class represents a assembly in the database.
*
* @extends AbstractStructuralDBElement<AssemblyAttachment, AssemblyParameter>
*/
#[ORM\Entity(repositoryClass: AssemblyRepository::class)]
#[ORM\Table(name: 'assemblies')]
#[UniqueEntity(fields: ['ipn'], message: 'assembly.ipn.must_be_unique')]
#[ORM\Index(columns: ['ipn'], name: 'assembly_idx_ipn')]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@assemblies.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['assembly:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['assembly:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/assemblies/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a assembly.'),
security: 'is_granted("@assemblies.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Assembly::class)
],
normalizationContext: ['groups' => ['assembly:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "ipn"])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Assembly extends AbstractStructuralDBElement
{
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
#[ORM\OrderBy(['name' => Criteria::ASC])]
protected Collection $children;
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['assembly:read', 'assembly:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
#[Groups(['assembly:read', 'assembly:write'])]
protected string $comment = '';
/**
* @var Collection<int, AssemblyBOMEntry>
*/
#[Assert\Valid]
#[AssemblyCycle]
#[AssemblyInvalidBomEntry]
#[UniqueReferencedAssembly]
#[Groups(['extended', 'full', 'import'])]
#[ORM\OneToMany(targetEntity: AssemblyBOMEntry::class, mappedBy: 'assembly', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[UniqueObjectCollection(message: 'assembly.bom_entry.part_already_in_bom', fields: ['part'])]
#[UniqueObjectCollection(message: 'assembly.bom_entry.name_already_in_bom', fields: ['name'])]
protected Collection $bom_entries;
#[ORM\Column(type: Types::INTEGER)]
protected int $order_quantity = 0;
/**
* @var string|null The current status of the assembly
*/
#[Assert\Choice(['draft', 'planning', 'in_production', 'finished', 'archived'])]
#[Groups(['extended', 'full', 'assembly:read', 'assembly:write', 'import'])]
#[ORM\Column(type: Types::STRING, length: 64, nullable: true)]
protected ?string $status = null;
/**
* @var string|null The internal ipn number of the assembly
*/
#[Assert\Length(max: 100)]
#[Groups(['extended', 'full', 'assembly:read', 'assembly:write', 'import'])]
#[ORM\Column(type: Types::STRING, length: 100, unique: true, nullable: true)]
#[Length(max: 100)]
protected ?string $ipn = null;
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $order_only_missing_parts = false;
#[Groups(['simple', 'extended', 'full', 'assembly:read', 'assembly:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $description = '';
/**
* @var Collection<int, AssemblyAttachment>
*/
#[ORM\OneToMany(mappedBy: 'element', targetEntity: AssemblyAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => Criteria::ASC])]
#[Groups(['assembly:read', 'assembly:write'])]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: AssemblyAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['assembly:read', 'assembly:write'])]
protected ?Attachment $master_picture_attachment = null;
/** @var Collection<int, AssemblyParameter>
*/
#[ORM\OneToMany(mappedBy: 'element', targetEntity: AssemblyParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
#[Groups(['assembly:read', 'assembly:write'])]
protected Collection $parameters;
#[Groups(['assembly:read'])]
protected ?\DateTimeImmutable $addedDate = null;
#[Groups(['assembly:read'])]
protected ?\DateTimeImmutable $lastModified = null;
/********************************************************************************
*
* Getters
*
*********************************************************************************/
public function __construct()
{
$this->attachments = new ArrayCollection();
$this->parameters = new ArrayCollection();
parent::__construct();
$this->bom_entries = new ArrayCollection();
$this->children = new ArrayCollection();
}
public function __clone()
{
//When cloning this assembly, we have to clone each bom entry too.
if ($this->id) {
$bom_entries = $this->bom_entries;
$this->bom_entries = new ArrayCollection();
//Set master attachment is needed
foreach ($bom_entries as $bom_entry) {
$clone = clone $bom_entry;
$this->addBomEntry($clone);
}
}
//Parent has to be last call, as it resets the ID
parent::__clone();
}
/**
* Get the order quantity of this assembly.
*
* @return int the order quantity
*/
public function getOrderQuantity(): int
{
return $this->order_quantity;
}
/**
* Get the "order_only_missing_parts" attribute.
*
* @return bool the "order_only_missing_parts" attribute
*/
public function getOrderOnlyMissingParts(): bool
{
return $this->order_only_missing_parts;
}
/********************************************************************************
*
* Setters
*
*********************************************************************************/
/**
* Set the order quantity.
*
* @param int $new_order_quantity the new order quantity
*
* @return $this
*/
public function setOrderQuantity(int $new_order_quantity): self
{
if ($new_order_quantity < 0) {
throw new InvalidArgumentException('The new order quantity must not be negative!');
}
$this->order_quantity = $new_order_quantity;
return $this;
}
/**
* Set the "order_only_missing_parts" attribute.
*
* @param bool $new_order_only_missing_parts the new "order_only_missing_parts" attribute
*/
public function setOrderOnlyMissingParts(bool $new_order_only_missing_parts): self
{
$this->order_only_missing_parts = $new_order_only_missing_parts;
return $this;
}
public function getBomEntries(): Collection
{
return $this->bom_entries;
}
/**
* @return $this
*/
public function addBomEntry(AssemblyBOMEntry $entry): self
{
$entry->setAssembly($this);
$this->bom_entries->add($entry);
return $this;
}
/**
* @return $this
*/
public function removeBomEntry(AssemblyBOMEntry $entry): self
{
$this->bom_entries->removeElement($entry);
return $this;
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(string $description): Assembly
{
$this->description = $description;
return $this;
}
/**
* @return string
*/
public function getStatus(): ?string
{
return $this->status;
}
/**
* @param string $status
*/
public function setStatus(?string $status): void
{
$this->status = $status;
}
/**
* Returns the internal part number of the assembly.
* @return string
*/
public function getIpn(): ?string
{
return $this->ipn;
}
/**
* Sets the internal part number of the assembly.
* @param string $ipn The new IPN of the assembly
*/
public function setIpn(?string $ipn): Assembly
{
$this->ipn = $ipn;
return $this;
}
#[Assert\Callback]
public function validate(ExecutionContextInterface $context, $payload): void
{
}
/**
* Get all assemblies and sub-assemblies recursive that are referenced in the assembly bom entries.
*
* @param Assembly $assembly Assembly, which is to be processed recursively.
* @param array $processedAssemblies (optional) a list of the already edited assemblies to avoid circulatory references.
* @return Assembly[] A flat list of all recursively found assemblies.
*/
public function getAllReferencedAssembliesRecursive(Assembly $assembly, array &$processedAssemblies = []): array
{
$assemblies = [];
// Avoid circular references
if (in_array($assembly, $processedAssemblies, true)) {
return $assemblies;
}
// Add the current assembly to the processed
$processedAssemblies[] = $assembly;
// Iterate by the bom entries of the current assembly
foreach ($assembly->getBomEntries() as $bomEntry) {
if ($bomEntry->getReferencedAssembly() !== null) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
$assemblies[] = $referencedAssembly;
// Continue recursively to process sub-assemblies
$assemblies = array_merge($assemblies, $this->getAllReferencedAssembliesRecursive($referencedAssembly, $processedAssemblies));
}
}
return $assemblies;
}
}

View file

@ -0,0 +1,340 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\Entity\AssemblySystem;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Contracts\TimeStampableInterface;
use App\Repository\DBElementRepository;
use App\Validator\Constraints\AssemblySystem\AssemblyCycle;
use App\Validator\Constraints\AssemblySystem\AssemblyInvalidBomEntry;
use App\Validator\UniqueValidatableInterface;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Currency;
use App\Validator\Constraints\BigDecimal\BigDecimalPositive;
use App\Validator\Constraints\Selectable;
use Brick\Math\BigDecimal;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* The AssemblyBOMEntry class represents an entry in a assembly's BOM.
*/
#[ORM\HasLifecycleCallbacks]
#[ORM\Entity(repositoryClass: DBElementRepository::class)]
#[ORM\Table('assembly_bom_entries')]
#[ApiResource(
operations: [
new Get(uriTemplate: '/assembly_bom_entries/{id}.{_format}', security: 'is_granted("read", object)',),
new GetCollection(uriTemplate: '/assembly_bom_entries.{_format}', security: 'is_granted("@assemblies.read")',),
new Post(uriTemplate: '/assembly_bom_entries.{_format}', securityPostDenormalize: 'is_granted("create", object)',),
new Patch(uriTemplate: '/assembly_bom_entries/{id}.{_format}', security: 'is_granted("edit", object)',),
new Delete(uriTemplate: '/assembly_bom_entries/{id}.{_format}', security: 'is_granted("delete", object)',),
],
normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['bom_entry:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/assemblies/{id}/bom.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the BOM entries of the given assembly.'),
security: 'is_granted("@assemblies.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'bom_entries', fromClass: Assembly::class)
],
normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", 'mountnames', 'designator', "comment"])]
#[ApiFilter(RangeFilter::class, properties: ['quantity'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified', 'quantity'])]
class AssemblyBOMEntry extends AbstractDBElement implements UniqueValidatableInterface, TimeStampableInterface
{
use TimestampTrait;
#[Assert\Positive]
#[ORM\Column(name: 'quantity', type: Types::FLOAT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
protected float $quantity = 1.0;
/**
* @var string A comma separated list of the names, where this parts should be placed
*/
#[ORM\Column(name: 'mountnames', type: Types::TEXT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
protected string $mountnames = '';
/**
* @var string Reference mark on the circuit diagram/PCB
*/
#[ORM\Column(name: 'designator', type: Types::TEXT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
protected string $designator = '';
/**
* @var string|null An optional name describing this BOM entry (useful for non-part entries)
*/
#[Assert\Expression('this.getPart() !== null or this.getReferencedAssembly() !== null or this.getName() !== null', message: 'validator.assembly.bom_entry.name_or_part_needed')]
#[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
protected ?string $name = null;
/**
* @var string An optional comment for this BOM entry
*/
#[ORM\Column(type: Types::TEXT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full'])]
protected string $comment = '';
/**
* @var Assembly|null
*/
#[ORM\ManyToOne(targetEntity: Assembly::class, inversedBy: 'bom_entries')]
#[ORM\JoinColumn(name: 'id_assembly', nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?Assembly $assembly = null;
/**
* @var Part|null The part associated with this
*/
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'assembly_bom_entries')]
#[ORM\JoinColumn(name: 'id_part')]
#[Groups(['bom_entry:read', 'bom_entry:write', 'full'])]
protected ?Part $part = null;
/**
* @var Assembly|null The associated assembly
*/
#[Assert\Expression(
'(this.getPart() === null or this.getReferencedAssembly() === null) and (this.getName() === null or (this.getName() != null and this.getName() != ""))',
message: 'validator.assembly.bom_entry.only_part_or_assembly_allowed'
)]
#[AssemblyCycle]
#[AssemblyInvalidBomEntry]
#[ORM\ManyToOne(targetEntity: Assembly::class)]
#[ORM\JoinColumn(name: 'id_referenced_assembly', nullable: true, onDelete: 'SET NULL')]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?Assembly $referencedAssembly = null;
/**
* @var BigDecimal|null The price of this non-part BOM entry
*/
#[Assert\AtLeastOneOf([new BigDecimalPositive(), new Assert\IsNull()])]
#[ORM\Column(type: 'big_decimal', precision: 11, scale: 5, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full'])]
protected ?BigDecimal $price = null;
/**
* @var ?Currency The currency for the price of this non-part BOM entry
*/
#[ORM\ManyToOne(targetEntity: Currency::class)]
#[ORM\JoinColumn]
#[Selectable]
protected ?Currency $price_currency = null;
public function __construct()
{
}
public function getQuantity(): float
{
return $this->quantity;
}
public function setQuantity(float $quantity): AssemblyBOMEntry
{
$this->quantity = $quantity;
return $this;
}
public function getMountnames(): string
{
return $this->mountnames;
}
public function setMountnames(string $mountnames): AssemblyBOMEntry
{
$this->mountnames = $mountnames;
return $this;
}
public function getDesignator(): string
{
return $this->designator;
}
public function setDesignator(string $designator): AssemblyBOMEntry
{
$this->designator = $designator;
return $this;
}
/**
* @return string
*/
public function getName(): ?string
{
return trim($this->name ?? '') === '' ? null : $this->name;
}
/**
* @param string $name
*/
public function setName(?string $name): AssemblyBOMEntry
{
$this->name = trim($name ?? '') === '' ? null : $name;
return $this;
}
public function getComment(): string
{
return $this->comment;
}
public function setComment(string $comment): AssemblyBOMEntry
{
$this->comment = $comment;
return $this;
}
public function getAssembly(): ?Assembly
{
return $this->assembly;
}
public function setAssembly(?Assembly $assembly): AssemblyBOMEntry
{
$this->assembly = $assembly;
return $this;
}
public function getPart(): ?Part
{
return $this->part;
}
public function setPart(?Part $part): AssemblyBOMEntry
{
$this->part = $part;
return $this;
}
public function getReferencedAssembly(): ?Assembly
{
return $this->referencedAssembly;
}
public function setReferencedAssembly(?Assembly $referencedAssembly): AssemblyBOMEntry
{
$this->referencedAssembly = $referencedAssembly;
return $this;
}
/**
* Returns the price of this BOM entry, if existing.
* Prices are only valid on non-Part BOM entries.
*/
public function getPrice(): ?BigDecimal
{
return $this->price;
}
/**
* Sets the price of this BOM entry.
* Prices are only valid on non-Part BOM entries.
*/
public function setPrice(?BigDecimal $price): void
{
$this->price = $price;
}
public function getPriceCurrency(): ?Currency
{
return $this->price_currency;
}
public function setPriceCurrency(?Currency $price_currency): void
{
$this->price_currency = $price_currency;
}
/**
* Checks whether this BOM entry is a part associated BOM entry or not.
* @return bool True if this BOM entry is a part associated BOM entry, false otherwise.
*/
public function isPartBomEntry(): bool
{
return $this->part instanceof Part;
}
/**
* Checks whether this BOM entry is a assembly associated BOM entry or not.
* @return bool True if this BOM entry is a assembly associated BOM entry, false otherwise.
*/
public function isAssemblyBomEntry(): bool
{
return $this->referencedAssembly !== null;
}
#[Assert\Callback]
public function validate(ExecutionContextInterface $context, $payload): void
{
//Round quantity to whole numbers, if the part is not a decimal part
if ($this->part instanceof Part && (!$this->part->getPartUnit() || $this->part->getPartUnit()->isInteger())) {
$this->quantity = round($this->quantity);
}
//Non-Part BOM entries are rounded
if (!$this->part instanceof Part) {
$this->quantity = round($this->quantity);
}
}
public function getComparableFields(): array
{
return [
'name' => $this->getName(),
'part' => $this->getPart()?->getID(),
'referencedAssembly' => $this->getReferencedAssembly()?->getID(),
];
}
}

View file

@ -0,0 +1,48 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\AssemblySystem\Assembly;
use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Context;
/**
* A attachment attached to a device element.
* @extends Attachment<Assembly>
*/
#[UniqueEntity(['name', 'attachment_type', 'element'])]
#[UniqueEntity(['name', 'attachment_type', 'element'])]
#[ORM\Entity]
class AssemblyAttachment extends Attachment
{
final public const ALLOWED_ELEMENT_CLASS = Assembly::class;
/**
* @var Assembly|null the element this attachment is associated with
*/
#[ORM\ManyToOne(targetEntity: Assembly::class, inversedBy: 'attachments')]
#[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
#[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}

View file

@ -97,7 +97,7 @@ use function in_array;
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
abstract class Attachment extends AbstractNamedDBElement
{
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class, 'Assembly' => AssemblyAttachment::class,
'AttachmentType' => AttachmentTypeAttachment::class,
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
@ -107,7 +107,7 @@ abstract class Attachment extends AbstractNamedDBElement
/*
* The discriminator map used for API platform. The key should be the same as the api platform short type (the @type JSONLD field).
*/
private const API_DISCRIMINATOR_MAP = ["Part" => PartAttachment::class, "PartCustomState" => PartCustomStateAttachment::class, "Project" => ProjectAttachment::class,
private const API_DISCRIMINATOR_MAP = ["Part" => PartAttachment::class, "PartCustomState" => PartCustomStateAttachment::class, "Project" => ProjectAttachment::class, "Assembly" => AssemblyAttachment::class,
"AttachmentType" => AttachmentTypeAttachment::class,
"Category" => CategoryAttachment::class, "Footprint" => FootprintAttachment::class, "Manufacturer" => ManufacturerAttachment::class,
"Currency" => CurrencyAttachment::class, "Group" => GroupAttachment::class, "MeasurementUnit" => MeasurementUnitAttachment::class,

View file

@ -22,6 +22,9 @@ declare(strict_types=1);
namespace App\Entity\Base;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
@ -84,12 +87,15 @@ use Symfony\Component\Serializer\Annotation\Groups;
'part_attachment' => PartAttachment::class,
'part_custom_state_attachment' => PartCustomStateAttachment::class,
'project_attachment' => ProjectAttachment::class,
'assembly_attachment' => AssemblyAttachment::class,
'storelocation_attachment' => StorageLocationAttachment::class,
'supplier_attachment' => SupplierAttachment::class,
'user_attachment' => UserAttachment::class,
'category' => Category::class,
'project' => Project::class,
'project_bom_entry' => ProjectBOMEntry::class,
'assembly' => Assembly::class,
'assembly_bom_entry' => AssemblyBOMEntry::class,
'footprint' => Footprint::class,
'group' => Group::class,
'manufacturer' => Manufacturer::class,

View file

@ -41,6 +41,8 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\AttachmentTypeAttachment;
@ -61,6 +63,7 @@ use App\Entity\Contracts\LogWithEventUndoInterface;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Parameters\PartCustomStateParameter;
use App\Entity\Parts\PartCustomState;
use App\Entity\Parameters\AssemblyParameter;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parameters\AttachmentTypeParameter;
@ -150,6 +153,7 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
{
if (is_a($abstract_class, AbstractParameter::class, true)) {
return match ($this->getTargetClass()) {
Assembly::class => AssemblyParameter::class,
AttachmentType::class => AttachmentTypeParameter::class,
Category::class => CategoryParameter::class,
Currency::class => CurrencyParameter::class,
@ -172,6 +176,7 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
Category::class => CategoryAttachment::class,
Currency::class => CurrencyAttachment::class,
Project::class => ProjectAttachment::class,
Assembly::class => AssemblyAttachment::class,
Footprint::class => FootprintAttachment::class,
Group::class => GroupAttachment::class,
Manufacturer::class => ManufacturerAttachment::class,

View file

@ -22,6 +22,8 @@ declare(strict_types=1);
*/
namespace App\Entity\LogSystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
@ -74,6 +76,9 @@ enum LogTargetType: int
case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22;
case PART_CUSTOM_STATE = 23;
case ASSEMBLY = 23;
case ASSEMBLY_BOM_ENTRY = 24;
/**
* Returns the class name of the target type or null if the target type is NONE.
* @return string|null
@ -88,6 +93,8 @@ enum LogTargetType: int
self::CATEGORY => Category::class,
self::PROJECT => Project::class,
self::BOM_ENTRY => ProjectBOMEntry::class,
self::ASSEMBLY => Assembly::class,
self::ASSEMBLY_BOM_ENTRY => AssemblyBOMEntry::class,
self::FOOTPRINT => Footprint::class,
self::GROUP => Group::class,
self::MANUFACTURER => Manufacturer::class,

View file

@ -74,7 +74,7 @@ use function sprintf;
3 => FootprintParameter::class, 4 => GroupParameter::class, 5 => ManufacturerParameter::class,
6 => MeasurementUnitParameter::class, 7 => PartParameter::class, 8 => StorageLocationParameter::class,
9 => SupplierParameter::class, 10 => AttachmentTypeParameter::class,
12 => PartCustomStateParameter::class])]
11 => AssemblyParameter::class, 12 => PartCustomStateParameter::class])]
#[ORM\Table('parameters')]
#[ORM\Index(columns: ['name'], name: 'parameter_name_idx')]
#[ORM\Index(columns: ['param_group'], name: 'parameter_group_idx')]
@ -104,7 +104,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
*/
private const API_DISCRIMINATOR_MAP = ["Part" => PartParameter::class,
"AttachmentType" => AttachmentTypeParameter::class, "Category" => CategoryParameter::class, "Currency" => CurrencyParameter::class,
"Project" => ProjectParameter::class, "Footprint" => FootprintParameter::class, "Group" => GroupParameter::class,
"Project" => ProjectParameter::class, "Assembly" => AssemblyParameter::class, "Footprint" => FootprintParameter::class, "Group" => GroupParameter::class,
"Manufacturer" => ManufacturerParameter::class, "MeasurementUnit" => MeasurementUnitParameter::class,
"StorageLocation" => StorageLocationParameter::class, "Supplier" => SupplierParameter::class, "PartCustomState" => PartCustomStateParameter::class];

View file

@ -0,0 +1,65 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Entity\Parameters;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Base\AbstractDBElement;
use App\Repository\ParameterRepository;
use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Context;
#[UniqueEntity(fields: ['name', 'group', 'element'])]
#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class AssemblyParameter extends AbstractParameter
{
final public const ALLOWED_ELEMENT_CLASS = Assembly::class;
/**
* @var Assembly the element this para is associated with
*/
#[ORM\ManyToOne(targetEntity: Assembly::class, inversedBy: 'parameters')]
#[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
#[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AbstractDBElement $element = null;
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use App\Entity\Parts\PartTraits\AssemblyTrait;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
@ -123,6 +124,7 @@ class Part extends AttachmentContainingDBElement
use OrderTrait;
use ParametersTrait;
use ProjectTrait;
use AssemblyTrait;
use AssociationTrait;
use EDATrait;
@ -184,6 +186,7 @@ class Part extends AttachmentContainingDBElement
$this->orderdetails = new ArrayCollection();
$this->parameters = new ArrayCollection();
$this->project_bom_entries = new ArrayCollection();
$this->assembly_bom_entries = new ArrayCollection();
$this->associated_parts_as_owner = new ArrayCollection();
$this->associated_parts_as_other = new ArrayCollection();

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Entity\Parts\PartTraits;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
trait AssemblyTrait
{
/**
* @var Collection<AssemblyBOMEntry> $assembly_bom_entries
*/
#[ORM\OneToMany(mappedBy: 'part', targetEntity: AssemblyBOMEntry::class, cascade: ['remove'], orphanRemoval: true)]
protected Collection $assembly_bom_entries;
/**
* Returns all AssemblyBOMEntry that use this part.
*
* @phpstan-return Collection<int, AssemblyBOMEntry>
*/
public function getAssemblyBomEntries(): Collection
{
return $this->assembly_bom_entries;
}
/**
* Get all assemblies which uses this part.
*
* @return Assembly[] all assemblies which uses this part as a one-dimensional array of Assembly objects
*/
public function getAssemblies(): array
{
$assemblies = [];
foreach($this->assembly_bom_entries as $entry) {
$assemblies[] = $entry->getAssembly();
}
return $assemblies;
}
}

View file

@ -36,6 +36,7 @@ use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Contracts\TimeStampableInterface;
use App\Repository\DBElementRepository;
use App\Validator\UniqueValidatableInterface;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
@ -54,7 +55,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* The ProjectBOMEntry class represents an entry in a project's BOM.
*/
#[ORM\HasLifecycleCallbacks]
#[ORM\Entity]
#[ORM\Entity(repositoryClass: DBElementRepository::class)]
#[ORM\Table('project_bom_entries')]
#[ApiResource(
operations: [

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Form\AdminPages;
use App\Entity\Base\AbstractNamedDBElement;
use App\Form\AssemblySystem\AssemblyBOMEntryCollectionType;
use App\Form\Type\RichTextEditorType;
use App\Services\LogSystem\EventCommentNeededHelper;
use App\Settings\MiscSettings\AssemblySettings;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class AssemblyAdminForm extends BaseEntityAdminForm
{
public function __construct(
protected Security $security,
protected EventCommentNeededHelper $eventCommentNeededHelper,
protected ?AssemblySettings $assemblySettings = null,
) {
parent::__construct($security, $eventCommentNeededHelper, $assemblySettings);
}
protected function additionalFormElements(FormBuilderInterface $builder, array $options, AbstractNamedDBElement $entity): void
{
$builder->add('description', RichTextEditorType::class, [
'required' => false,
'label' => 'part.edit.description',
'mode' => 'markdown-single_line',
'empty_data' => '',
'attr' => [
'placeholder' => 'part.edit.description.placeholder',
'rows' => 2,
],
]);
$builder->add('bom_entries', AssemblyBOMEntryCollectionType::class);
$builder->add('status', ChoiceType::class, [
'attr' => [
'class' => 'form-select',
],
'label' => 'assembly.edit.status',
'required' => false,
'empty_data' => '',
'choices' => [
'assembly.status.draft' => 'draft',
'assembly.status.planning' => 'planning',
'assembly.status.in_production' => 'in_production',
'assembly.status.finished' => 'finished',
'assembly.status.archived' => 'archived',
],
]);
$builder->add('ipn', TextType::class, [
'required' => false,
'empty_data' => null,
'label' => 'assembly.edit.ipn',
]);
}
}

View file

@ -22,10 +22,12 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\PriceInformations\Currency;
use App\Entity\ProjectSystem\Project;
use App\Entity\UserSystem\Group;
use App\Services\LogSystem\EventCommentType;
use App\Settings\MiscSettings\AssemblySettings;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
@ -47,8 +49,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class BaseEntityAdminForm extends AbstractType
{
public function __construct(protected Security $security, protected EventCommentNeededHelper $eventCommentNeededHelper)
{
public function __construct(
protected Security $security,
protected EventCommentNeededHelper $eventCommentNeededHelper,
protected ?AssemblySettings $assemblySettings = null,
) {
}
public function configureOptions(OptionsResolver $resolver): void
@ -69,6 +74,7 @@ class BaseEntityAdminForm extends AbstractType
->add('name', TextType::class, [
'empty_data' => '',
'label' => 'name.label',
'data' => $is_new && $entity instanceof Assembly && $this->assemblySettings !== null && $this->assemblySettings->useIpnPlaceholderInName ? '%%ipn%%' : $entity->getName(),
'attr' => [
'placeholder' => 'part.name.placeholder',
],
@ -114,7 +120,7 @@ class BaseEntityAdminForm extends AbstractType
);
}
if ($entity instanceof AbstractStructuralDBElement && !($entity instanceof Group || $entity instanceof Project || $entity instanceof Currency)) {
if ($entity instanceof AbstractStructuralDBElement && !($entity instanceof Group || $entity instanceof Project || $entity instanceof Assembly || $entity instanceof Currency)) {
$builder->add('alternative_names', TextType::class, [
'required' => false,
'label' => 'entity.edit.alternative_names.label',

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\Form\AssemblySystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Form\Type\StructuralEntityType;
use App\Validator\Constraints\UniqueObjectCollection;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotNull;
class AssemblyAddPartsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('assembly', StructuralEntityType::class, [
'class' => Assembly::class,
'required' => true,
'disabled' => $options['assembly'] instanceof Assembly, //If a assembly is given, disable the field
'data' => $options['assembly'],
'constraints' => [
new NotNull()
]
]);
$builder->add('bom_entries', AssemblyBOMEntryCollectionType::class, [
'entry_options' => [
'constraints' => [
new UniqueEntity(fields: ['part'], message: 'assembly.bom_entry.part_already_in_bom',
entityClass: AssemblyBOMEntry::class),
new UniqueEntity(fields: ['referencedAssembly'], message: 'assembly.bom_entry.assembly_already_in_bom',
entityClass: AssemblyBOMEntry::class),
new UniqueEntity(fields: ['name'], message: 'assembly.bom_entry.name_already_in_bom',
entityClass: AssemblyBOMEntry::class, ignoreNull: true),
]
],
'constraints' => [
new UniqueObjectCollection(message: 'assembly.bom_entry.part_already_in_bom', fields: ['part']),
new UniqueObjectCollection(message: 'assembly.bom_entry.assembly_already_in_bom', fields: ['referencedAssembly']),
new UniqueObjectCollection(message: 'assembly.bom_entry.name_already_in_bom', fields: ['name']),
]
]);
$builder->add('submit', SubmitType::class, ['label' => 'save']);
//After submit set the assembly for all bom entries, so that it can be validated properly
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) {
$form = $event->getForm();
/** @var Assembly $assembly */
$assembly = $form->get('assembly')->getData();
$bom_entries = $form->get('bom_entries')->getData();
foreach ($bom_entries as $bom_entry) {
$bom_entry->setAssembly($assembly);
}
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'assembly' => null,
]);
$resolver->setAllowedTypes('assembly', ['null', Assembly::class]);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Form\AssemblySystem;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AssemblyBOMEntryCollectionType extends AbstractType
{
public function getParent(): string
{
return CollectionType::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'entry_type' => AssemblyBOMEntryType::class,
'entry_options' => [
'label' => false,
],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'reindex_enable' => true,
'label' => false,
]);
}
}

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Form\AssemblySystem;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Form\Type\AssemblySelectType;
use App\Form\Type\BigDecimalNumberType;
use App\Form\Type\CurrencyEntityType;
use App\Form\Type\PartSelectType;
use App\Form\Type\RichTextEditorType;
use App\Form\Type\SIUnitType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AssemblyBOMEntryType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) {
$form = $event->getForm();
/** @var AssemblyBOMEntry $data */
$data = $event->getData();
$form->add('quantity', SIUnitType::class, [
'label' => 'assembly.bom.quantity',
'measurement_unit' => $data && $data->getPart() ? $data->getPart()->getPartUnit() : null,
]);
});
$builder
->add('part', PartSelectType::class, [
'required' => false,
])
->add('referencedAssembly', AssemblySelectType::class, [
'label' => 'assembly.bom.referencedAssembly',
'required' => false,
])
->add('name', TextType::class, [
'label' => 'assembly.bom.name',
'help' => 'assembly.bom.name.help',
'required' => false,
])
->add('designator', TextType::class, [
'label' => 'assembly.bom.designator',
'help' => 'assembly.bom.designator.help',
'empty_data' => '',
'required' => false,
])
->add('mountnames', TextType::class, [
'required' => false,
'label' => 'assembly.bom.mountnames',
'empty_data' => '',
'attr' => [
'class' => 'tagsinput',
'data-controller' => 'elements--tagsinput',
],
])
->add('comment', RichTextEditorType::class, [
'required' => false,
'label' => 'assembly.bom.comment',
'empty_data' => '',
'mode' => 'markdown-single_line',
'attr' => [
'rows' => 2,
],
])
->add('price', BigDecimalNumberType::class, [
'label' => false,
'required' => false,
'scale' => 5,
'html5' => true,
'attr' => [
'min' => 0,
'step' => 'any',
],
])
->add('priceCurrency', CurrencyEntityType::class, [
'required' => false,
'label' => false,
'short' => true,
]
);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AssemblyBOMEntry::class,
]);
}
}

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Form\Filters;
use App\DataTables\Filters\AssemblyFilter;
use App\Entity\Attachments\AttachmentType;
use App\Form\Filters\Constraints\DateTimeConstraintType;
use App\Form\Filters\Constraints\NumberConstraintType;
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
use App\Form\Filters\Constraints\TextConstraintType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AssemblyFilterType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => AssemblyFilter::class,
'csrf_protection' => false,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/*
* Common tab
*/
$builder->add('name', TextConstraintType::class, [
'label' => 'assembly.filter.name',
]);
$builder->add('description', TextConstraintType::class, [
'label' => 'assembly.filter.description',
]);
$builder->add('comment', TextConstraintType::class, [
'label' => 'assembly.filter.comment'
]);
/*
* Advanced tab
*/
$builder->add('dbId', NumberConstraintType::class, [
'label' => 'assembly.filter.dbId',
'min' => 1,
'step' => 1,
]);
$builder->add('ipn', TextConstraintType::class, [
'label' => 'assembly.filter.ipn',
]);
$builder->add('lastModified', DateTimeConstraintType::class, [
'label' => 'lastModified'
]);
$builder->add('addedDate', DateTimeConstraintType::class, [
'label' => 'createdAt'
]);
/**
* Attachments count
*/
$builder->add('attachmentsCount', NumberConstraintType::class, [
'label' => 'assembly.filter.attachments_count',
'step' => 1,
'min' => 0,
]);
$builder->add('attachmentType', StructuralEntityConstraintType::class, [
'label' => 'attachment.attachment_type',
'entity_class' => AttachmentType::class
]);
$builder->add('attachmentName', TextConstraintType::class, [
'label' => 'assembly.filter.attachmentName',
]);
$builder->add('submit', SubmitType::class, [
'label' => 'filter.submit',
]);
$builder->add('discard', ResetType::class, [
'label' => 'filter.discard',
]);
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Form\Filters;
use App\DataTables\Filters\AttachmentFilter;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Entity\Attachments\CategoryAttachment;
@ -80,6 +81,7 @@ class AttachmentFilterType extends AbstractType
'category.label' => CategoryAttachment::class,
'currency.label' => CurrencyAttachment::class,
'project.label' => ProjectAttachment::class,
'assembly.label' => AssemblyAttachment::class,
'footprint.label' => FootprintAttachment::class,
'group.label' => GroupAttachment::class,
'label_profile.label' => LabelAttachment::class,

View file

@ -114,6 +114,8 @@ class LogFilterType extends AbstractType
LogTargetType::CATEGORY => 'category.label',
LogTargetType::PROJECT => 'project.label',
LogTargetType::BOM_ENTRY => 'project_bom_entry.label',
LogTargetType::ASSEMBLY => 'assembly.label',
LogTargetType::ASSEMBLY_BOM_ENTRY => 'assembly_bom_entry.label',
LogTargetType::FOOTPRINT => 'footprint.label',
LogTargetType::GROUP => 'group.label',
LogTargetType::MANUFACTURER => 'manufacturer.label',

View file

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Form\Type;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AssemblyPreviewGenerator;
use App\Services\Attachments\AttachmentURLGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\ChoiceList\ChoiceList;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class AssemblySelectType extends AbstractType implements DataMapperInterface
{
public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em, private readonly AssemblyPreviewGenerator $previewGenerator, private readonly AttachmentURLGenerator $attachmentURLGenerator)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
//At initialization, we have to fill the form element with our selected data, so the user can see it
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) {
$form = $event->getForm();
$config = $form->getConfig()->getOptions();
$data = $event->getData() ?? [];
$config['compound'] = false;
$config['choices'] = is_iterable($data) ? $data : [$data];
$config['error_bubbling'] = true;
$form->add('autocomplete', EntityType::class, $config);
});
//After form submit, we have to add the selected element as choice, otherwise the form will not accept this element
$builder->addEventListener(FormEvents::PRE_SUBMIT, function(FormEvent $event) {
$data = $event->getData();
$form = $event->getForm();
$options = $form->get('autocomplete')->getConfig()->getOptions();
if (!isset($data['autocomplete']) || '' === $data['autocomplete'] || empty($data['autocomplete'])) {
$options['choices'] = [];
} else {
//Extract the ID from the submitted data
$id = $data['autocomplete'];
//Find the element in the database
$element = $this->em->find($options['class'], $id);
//Add the element as choice
$options['choices'] = [$element];
$options['error_bubbling'] = true;
$form->add('autocomplete', EntityType::class, $options);
}
});
$builder->setDataMapper($this);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'class' => Assembly::class,
'choice_label' => 'name',
'compound' => true,
'error_bubbling' => false,
]);
$resolver->setDefaults([
'attr' => [
'data-controller' => 'elements--assembly-select',
'data-autocomplete' => $this->urlGenerator->generate('typeahead_assemblies', ['query' => '__QUERY__']),
'autocomplete' => 'off',
],
]);
$resolver->setDefaults([
//Prefill the selected choice with the needed data, so the user can see it without an additional Ajax request
'choice_attr' => ChoiceList::attr($this, function (?Assembly $assembly) {
if($assembly instanceof Assembly) {
//Determine the picture to show:
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($assembly);
if ($preview_attachment instanceof Attachment) {
$preview_url = $this->attachmentURLGenerator->getThumbnailURL($preview_attachment,
'thumbnail_sm');
} else {
$preview_url = '';
}
}
return $assembly instanceof Assembly ? [
'data-description' => $assembly->getDescription() ? mb_strimwidth($assembly->getDescription(), 0, 127, '...') : '',
'data-category' => '',
'data-footprint' => '',
'data-image' => $preview_url,
] : [];
})
]);
}
public function mapDataToForms($data, \Traversable $forms): void
{
$form = current(iterator_to_array($forms, false));
$form->setData($data);
}
public function mapFormsToData(\Traversable $forms, &$data): void
{
$form = current(iterator_to_array($forms, false));
$data = $form->getData();
}
}

View file

@ -50,7 +50,7 @@ class PartSelectType extends AbstractType implements DataMapperInterface
$options = $form->get('autocomplete')->getConfig()->getOptions();
if (!isset($data['autocomplete']) || '' === $data['autocomplete']) {
if (!isset($data['autocomplete']) || '' === $data['autocomplete'] || empty($data['autocomplete'])) {
$options['choices'] = [];
} else {
//Extract the ID from the submitted data

View file

@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\Helpers\Assemblies;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Part;
use Dompdf\Dompdf;
use Dompdf\Options;
use Twig\Environment;
class AssemblyPartAggregator
{
public function __construct(private readonly Environment $twig)
{
}
/**
* Aggregate the required parts and their total quantities for an assembly.
*
* @param Assembly $assembly The assembly to process.
* @param float $multiplier The quantity multiplier from the parent assembly.
* @return array Array of parts with their aggregated quantities, keyed by Part ID.
*/
public function getAggregatedParts(Assembly $assembly, float $multiplier): array
{
$aggregatedParts = [];
// Start processing the assembly recursively
$this->processAssembly($assembly, $multiplier, $aggregatedParts);
// Return the final aggregated list of parts
return $aggregatedParts;
}
/**
* Recursive helper to process an assembly and all its BOM entries.
*
* @param Assembly $assembly The current assembly to process.
* @param float $multiplier The quantity multiplier from the parent assembly.
* @param array &$aggregatedParts The array to accumulate parts and their quantities.
*/
private function processAssembly(Assembly $assembly, float $multiplier, array &$aggregatedParts): void
{
/** @var AssemblyBOMEntry $bomEntry */
foreach ($assembly->getBomEntries() as $bomEntry) {
// If the BOM entry refers to a part, add its quantity
if ($bomEntry->getPart() instanceof Part) {
$part = $bomEntry->getPart();
if (!isset($aggregatedParts[$part->getId()])) {
$aggregatedParts[$part->getId()] = [
'part' => $part,
'assembly' => $assembly,
'name' => $bomEntry->getName(),
'designator' => $bomEntry->getDesignator(),
'quantity' => $bomEntry->getQuantity(),
'multiplier' => $multiplier,
];
}
} elseif ($bomEntry->getReferencedAssembly() instanceof Assembly) {
// If the BOM entry refers to another assembly, process it recursively
$this->processAssembly($bomEntry->getReferencedAssembly(), $bomEntry->getQuantity(), $aggregatedParts);
} else {
$aggregatedParts[] = [
'part' => null,
'assembly' => $assembly,
'name' => $bomEntry->getName(),
'designator' => $bomEntry->getDesignator(),
'quantity' => $bomEntry->getQuantity(),
'multiplier' => $multiplier,
];
}
}
}
/**
* Exports a hierarchical Bill of Materials (BOM) for assemblies and parts in a readable format,
* including the multiplier for each part and assembly.
*
* @param Assembly $assembly The root assembly to export.
* @param string $indentationSymbol The symbol used for indentation (e.g., ' ').
* @param int $initialDepth The starting depth for formatting (default: 0).
* @return string Human-readable hierarchical BOM list.
*/
public function exportReadableHierarchy(Assembly $assembly, string $indentationSymbol = ' ', int $initialDepth = 0): string
{
// Start building the hierarchy
$output = '';
$this->processAssemblyHierarchy($assembly, $initialDepth, 1, $indentationSymbol, $output);
return $output;
}
public function exportReadableHierarchyForPdf(array $assemblyHierarchies): string
{
$html = $this->twig->render('assemblies/export_bom_pdf.html.twig', [
'assemblies' => $assemblyHierarchies,
]);
$options = new Options();
$options->set('isHtml5ParserEnabled', true);
$options->set('isPhpEnabled', true);
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
$canvas = $dompdf->getCanvas();
$font = $dompdf->getFontMetrics()->getFont('Arial', 'normal');
return $dompdf->output();
}
/**
* Recursive method to process assemblies and their parts.
*
* @param Assembly $assembly The current assembly to process.
* @param int $depth The current depth in the hierarchy.
* @param float $parentMultiplier The multiplier inherited from the parent (default is 1 for root).
* @param string $indentationSymbol The symbol used for indentation.
* @param string &$output The cumulative output string.
*/
private function processAssemblyHierarchy(Assembly $assembly, int $depth, float $parentMultiplier, string $indentationSymbol, string &$output): void
{
// Add the current assembly to the output
if ($depth === 0) {
$output .= sprintf(
"%sAssembly: %s [IPN: %s]\n\n",
str_repeat($indentationSymbol, $depth),
$assembly->getName(),
$assembly->getIpn(),
);
} else {
$output .= sprintf(
"%sAssembly: %s [IPN: %s, Multiplier: %.2f]\n\n",
str_repeat($indentationSymbol, $depth),
$assembly->getName(),
$assembly->getIpn(),
$parentMultiplier
);
}
// Gruppiere BOM-Einträge in Kategorien
$parts = [];
$referencedAssemblies = [];
$others = [];
foreach ($assembly->getBomEntries() as $bomEntry) {
if ($bomEntry->getPart() instanceof Part) {
$parts[] = $bomEntry;
} elseif ($bomEntry->getReferencedAssembly() instanceof Assembly) {
$referencedAssemblies[] = $bomEntry;
} else {
$others[] = $bomEntry;
}
}
if (!empty($parts)) {
// Process each BOM entry for the current assembly
foreach ($parts as $bomEntry) {
$effectiveQuantity = $bomEntry->getQuantity() * $parentMultiplier;
$output .= sprintf(
"%sPart: %s [IPN: %s, MPNR: %s, Quantity: %.2f%s, EffectiveQuantity: %.2f]\n",
str_repeat($indentationSymbol, $depth + 1),
$bomEntry->getPart()?->getName(),
$bomEntry->getPart()?->getIpn() ?? '-',
$bomEntry->getPart()?->getManufacturerProductNumber() ?? '-',
$bomEntry->getQuantity(),
$parentMultiplier > 1 ? sprintf(", Multiplier: %.2f", $parentMultiplier) : '',
$effectiveQuantity,
);
}
$output .= "\n";
}
foreach ($referencedAssemblies as $bomEntry) {
// Add referenced assembly details
$referencedQuantity = $bomEntry->getQuantity() * $parentMultiplier;
$output .= sprintf(
"%sReferenced Assembly: %s [IPN: %s, Quantity: %.2f%s, EffectiveQuantity: %.2f]\n",
str_repeat($indentationSymbol, $depth + 1),
$bomEntry->getReferencedAssembly()->getName(),
$bomEntry->getReferencedAssembly()->getIpn() ?? '-',
$bomEntry->getQuantity(),
$parentMultiplier > 1 ? sprintf(", Multiplier: %.2f", $parentMultiplier) : '',
$referencedQuantity,
);
// Recurse into the referenced assembly
$this->processAssemblyHierarchy(
$bomEntry->getReferencedAssembly(),
$depth + 2, // Increase depth for nested assemblies
$referencedQuantity, // Pass the calculated multiplier
$indentationSymbol,
$output
);
}
foreach ($others as $bomEntry) {
$output .= sprintf(
"%sOther: %s [Quantity: %.2f, Multiplier: %.2f]\n",
str_repeat($indentationSymbol, $depth + 1),
$bomEntry->getName(),
$bomEntry->getQuantity(),
$parentMultiplier,
);
}
}
public function processAssemblyHierarchyForPdf(Assembly $assembly, int $depth, float $quantity, float $parentMultiplier): array
{
$result = [
'name' => $assembly->getName(),
'ipn' => $assembly->getIpn(),
'quantity' => $quantity,
'multiplier' => $depth === 0 ? null : $parentMultiplier,
'parts' => [],
'referencedAssemblies' => [],
'others' => [],
];
foreach ($assembly->getBomEntries() as $bomEntry) {
if ($bomEntry->getPart() instanceof Part) {
$result['parts'][] = [
'name' => $bomEntry->getPart()->getName(),
'ipn' => $bomEntry->getPart()->getIpn(),
'quantity' => $bomEntry->getQuantity(),
'effectiveQuantity' => $bomEntry->getQuantity() * $parentMultiplier,
];
} elseif ($bomEntry->getReferencedAssembly() instanceof Assembly) {
$result['referencedAssemblies'][] = $this->processAssemblyHierarchyForPdf(
$bomEntry->getReferencedAssembly(),
$depth + 1,
$bomEntry->getQuantity(),
$parentMultiplier * $bomEntry->getQuantity()
);
} else {
$result['others'][] = [
'name' => $bomEntry->getName(),
'quantity' => $bomEntry->getQuantity(),
'multiplier' => $parentMultiplier,
];
}
}
return $result;
}
}

View file

@ -0,0 +1,69 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Repository;
use App\Entity\AssemblySystem\Assembly;
/**
* @template TEntityClass of Assembly
* @extends StructuralDBElementRepository<TEntityClass>
*/
class AssemblyRepository extends StructuralDBElementRepository
{
/**
* @return Assembly[]
*/
public function autocompleteSearch(string $query, int $max_limits = 50): array
{
$qb = $this->createQueryBuilder('assembly');
$qb->select('assembly')
->where('ILIKE(assembly.name, :query) = TRUE')
->orWhere('ILIKE(assembly.description, :query) = TRUE');
$qb->setParameter('query', '%'.$query.'%');
$qb->setMaxResults($max_limits);
$qb->orderBy('NATSORT(assembly.name)', 'ASC');
return $qb->getQuery()->getResult();
}
}

View file

@ -154,4 +154,14 @@ class DBElementRepository extends EntityRepository
$property->setAccessible(true);
$property->setValue($element, $new_value);
}
protected function save(AbstractDBElement $entity, bool $flush = true): void
{
$manager = $this->getEntityManager();
$manager->persist($entity);
if ($flush) {
$manager->flush();
}
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Attachments\PartCustomStateAttachment;
use App\Entity\Attachments\AssemblyAttachment;
use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Attachments\AttachmentContainingDBElement;
@ -90,6 +91,8 @@ final class AttachmentVoter extends Voter
$param = 'currencies';
} elseif (is_a($subject, ProjectAttachment::class, true)) {
$param = 'projects';
} elseif (is_a($subject, AssemblyAttachment::class, true)) {
$param = 'assemblies';
} elseif (is_a($subject, FootprintAttachment::class, true)) {
$param = 'footprints';
} elseif (is_a($subject, GroupAttachment::class, true)) {

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Parts\PartCustomState;
use App\Entity\ProjectSystem\Project;
@ -48,6 +49,7 @@ final class StructureVoter extends Voter
AttachmentType::class => 'attachment_types',
Category::class => 'categories',
Project::class => 'projects',
Assembly::class => 'assemblies',
Footprint::class => 'footprints',
Manufacturer::class => 'manufacturers',
StorageLocation::class => 'storelocations',

View file

@ -0,0 +1,93 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\Services\Attachments;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
class AssemblyPreviewGenerator
{
public function __construct(protected AttachmentManager $attachmentHelper)
{
}
/**
* Returns a list of attachments that can be used for previewing the assembly ordered by priority.
*
* @param Assembly $assembly the assembly for which the attachments should be determined
*
* @return (Attachment|null)[]
*
* @psalm-return list<Attachment|null>
*/
public function getPreviewAttachments(Assembly $assembly): array
{
$list = [];
//Master attachment has top priority
$attachment = $assembly->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
$list[] = $attachment;
}
//Then comes the other images of the assembly
foreach ($assembly->getAttachments() as $attachment) {
//Dont show the master attachment twice
if ($this->isAttachmentValidPicture($attachment) && $attachment !== $assembly->getMasterPictureAttachment()) {
$list[] = $attachment;
}
}
return $list;
}
/**
* Determines what attachment should be used for previewing a assembly (especially in assembly table).
* The returned attachment is guaranteed to be existing and be a picture.
*
* @param Assembly $assembly The assembly for which the attachment should be determined
*/
public function getTablePreviewAttachment(Assembly $assembly): ?Attachment
{
$attachment = $assembly->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
return $attachment;
}
return null;
}
/**
* Checks if a attachment is exising and a valid picture.
*
* @param Attachment|null $attachment the attachment that should be checked
*
* @return bool true if the attachment is valid
*/
protected function isAttachmentValidPicture(?Attachment $attachment): bool
{
return $attachment instanceof Attachment
&& $attachment->isPicture()
&& $this->attachmentHelper->isFileExisting($attachment);
}
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\Attachments;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\AttachmentType;
@ -86,6 +87,7 @@ class AttachmentSubmitHandler
CategoryAttachment::class => 'category',
CurrencyAttachment::class => 'currency',
ProjectAttachment::class => 'project',
AssemblyAttachment::class => 'assembly',
FootprintAttachment::class => 'footprint',
GroupAttachment::class => 'group',
ManufacturerAttachment::class => 'manufacturer',

View file

@ -22,6 +22,8 @@ declare(strict_types=1);
namespace App\Services;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Base\AbstractDBElement;
@ -189,6 +191,8 @@ final readonly class ElementTypeNameGenerator
$on = $entity->getOrderdetail()->getPart();
} elseif ($entity instanceof ProjectBOMEntry && $entity->getProject() instanceof Project) {
$on = $entity->getProject();
} elseif ($entity instanceof AssemblyBOMEntry && $entity->getAssembly() instanceof Assembly) {
$on = $entity->getAssembly();
}
if (isset($on) && $on instanceof NamedElementInterface) {

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\PartAttachment;
@ -99,6 +100,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_edit',
Category::class => 'category_edit',
Project::class => 'project_edit',
Assembly::class => 'assembly_edit',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
StorageLocation::class => 'store_location_edit',
@ -206,6 +208,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_edit',
Category::class => 'category_edit',
Project::class => 'project_info',
Assembly::class => 'assembly_info',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
StorageLocation::class => 'store_location_edit',
@ -237,6 +240,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_edit',
Category::class => 'category_edit',
Project::class => 'project_edit',
Assembly::class => 'assembly_edit',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
StorageLocation::class => 'store_location_edit',
@ -269,6 +273,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_new',
Category::class => 'category_new',
Project::class => 'project_new',
Assembly::class => 'assembly_new',
Supplier::class => 'supplier_new',
Manufacturer::class => 'manufacturer_new',
StorageLocation::class => 'store_location_new',
@ -301,6 +306,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_clone',
Category::class => 'category_clone',
Project::class => 'device_clone',
Assembly::class => 'assembly_clone',
Supplier::class => 'supplier_clone',
Manufacturer::class => 'manufacturer_clone',
StorageLocation::class => 'store_location_clone',
@ -329,6 +335,7 @@ class EntityURLGenerator
{
$map = [
Project::class => 'project_info',
Assembly::class => 'assembly_info',
Category::class => 'part_list_category',
Footprint::class => 'part_list_footprint',
@ -347,6 +354,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_delete',
Category::class => 'category_delete',
Project::class => 'project_delete',
Assembly::class => 'assembly_delete',
Supplier::class => 'supplier_delete',
Manufacturer::class => 'manufacturer_delete',
StorageLocation::class => 'store_location_delete',

View file

@ -22,21 +22,37 @@ declare(strict_types=1);
*/
namespace App\Services\ImportExportSystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Category;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Repository\DBElementRepository;
use App\Repository\PartRepository;
use App\Repository\Parts\CategoryRepository;
use App\Repository\Parts\ManufacturerRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use League\Csv\Reader;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
use UnexpectedValueException;
use Symfony\Component\Validator\ConstraintViolation;
/**
* @see \App\Tests\Services\ImportExportSystem\BOMImporterTest
*/
class BOMImporter
{
private const IMPORT_TYPE_JSON = 'json';
private const IMPORT_TYPE_CSV = 'csv';
private const IMPORT_TYPE_KICAD_PCB = 'kicad_pcbnew';
private const IMPORT_TYPE_KICAD_SCHEMATIC = 'kicad_schematic';
private const MAP_KICAD_PCB_FIELDS = [
0 => 'Id',
@ -47,17 +63,35 @@ class BOMImporter
5 => 'Supplier and ref',
];
private readonly PartRepository $partRepository;
private readonly ManufacturerRepository $manufacturerRepository;
private readonly CategoryRepository $categoryRepository;
private readonly DBElementRepository $projectBomEntryRepository;
private readonly DBElementRepository $assemblyBomEntryRepository;
private string $jsonRoot = '';
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly BOMValidationService $validationService
private readonly BOMValidationService $validationService,
private readonly TranslatorInterface $translator
) {
$this->partRepository = $this->entityManager->getRepository(Part::class);
$this->manufacturerRepository = $this->entityManager->getRepository(Manufacturer::class);
$this->categoryRepository = $this->entityManager->getRepository(Category::class);
$this->projectBomEntryRepository = $this->entityManager->getRepository(ProjectBOMEntry::class);
$this->assemblyBomEntryRepository = $this->entityManager->getRepository(AssemblyBOMEntry::class);
}
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
{
$resolver->setRequired('type');
$resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']);
$resolver->setAllowedValues('type', [self::IMPORT_TYPE_KICAD_PCB, self::IMPORT_TYPE_KICAD_SCHEMATIC, self::IMPORT_TYPE_JSON, self::IMPORT_TYPE_CSV]);
// For flexible schematic import with field mapping
$resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']);
@ -73,27 +107,118 @@ class BOMImporter
/**
* Converts the given file into an array of BOM entries using the given options and save them into the given project.
* The changes are not saved into the database yet.
* @return ProjectBOMEntry[]
*/
public function importFileIntoProject(File $file, Project $project, array $options): array
public function importFileIntoProject(UploadedFile $file, Project $project, array $options): ImporterResult
{
$bom_entries = $this->fileToBOMEntries($file, $options);
$importerResult = $this->fileToImporterResult($project, $file, $options);
//Assign the bom_entries to the project
foreach ($bom_entries as $bom_entry) {
$project->addBomEntry($bom_entry);
if ($importerResult->getViolations()->count() === 0) {
//Assign the bom_entries to the project
foreach ($importerResult->getBomEntries() as $bomEntry) {
$project->addBomEntry($bomEntry);
}
}
return $bom_entries;
return $importerResult;
}
/**
* Converts the given file into an array of BOM entries using the given options.
* @return ProjectBOMEntry[]
* Imports a file into an Assembly object and processes its contents.
*
* This method converts the provided file into an ImporterResult object that contains BOM entries and potential
* validation violations. If no violations are found, the BOM entries extracted from the file are added to the
* provided Assembly object.
*
* @param UploadedFile $file The file to be imported and processed.
* @param Assembly $assembly The target Assembly object to which the BOM entries are added.
* @param array $options Options or configurations related to the import process.
*
* @return ImporterResult An object containing the result of the import process, including BOM entries and any violations.
*/
public function fileToBOMEntries(File $file, array $options): array
public function importFileIntoAssembly(UploadedFile $file, Assembly $assembly, array $options): ImporterResult
{
return $this->stringToBOMEntries($file->getContent(), $options);
$importerResult = $this->fileToImporterResult($assembly, $file, $options);
if ($importerResult->getViolations()->count() === 0) {
//Assign the bom_entries to the assembly
foreach ($importerResult->getBomEntries() as $bomEntry) {
$assembly->addBomEntry($bomEntry);
}
}
return $importerResult;
}
/**
* Converts the content of a file into an array of BOM (Bill of Materials) entries.
*
* This method processes the content of the provided file and delegates the conversion
* to a helper method that generates BOM entries based on the provided import object and options.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entries (either a Project or Assembly).
* @param File $file The file whose content will be converted into BOM entries.
* @param array $options Additional options or configurations to be applied during the conversion process.
*
* @return array An array of BOM entries created from the file content.
*/
public function fileToBOMEntries(Project|Assembly $importObject, File $file, array $options): array
{
return $this->stringToBOMEntries($importObject, $file->getContent(), $options);
}
/**
* Handles the conversion of an uploaded file into an ImporterResult for a given project or assembly.
*
* This method processes the uploaded file by validating its file extension based on the provided import type
* options and then proceeds to convert the file content into an ImporterResult. If the file extension is
* invalid or unsupported, the result will contain a corresponding violation.
*
* @param Project|Assembly $importObject The context of the import operation (either a Project or Assembly).
* @param UploadedFile $file The uploaded file to be processed.
* @param array $options An array of options, expected to include an 'type' key to determine valid file types.
*
* @return ImporterResult An object containing the results of the import process, including any detected violations.
*/
public function fileToImporterResult(Project|Assembly $importObject, UploadedFile $file, array $options): ImporterResult
{
$result = new ImporterResult();
//Available file endings depending on the import type
$validExtensions = match ($options['type']) {
self::IMPORT_TYPE_KICAD_PCB => ['kicad_pcb'],
self::IMPORT_TYPE_JSON => ['json'],
self::IMPORT_TYPE_CSV => ['csv'],
default => [],
};
//Get the file extension of the uploaded file
$fileExtension = pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION);
//Check whether the file extension is valid
if ($validExtensions === []) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.invalid_import_type',
'import.type'
));
return $result;
} else if (!in_array(strtolower($fileExtension), $validExtensions, true)) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.invalid_file_extension',
'file.extension',
$fileExtension,
[
'%extension%' => $fileExtension,
'%importType%' => $this->translator->trans($importObject instanceof Project ? 'project.bom_import.type.'.$options['type'] : 'assembly.bom_import.type.'.$options['type']),
'%allowedExtensions%' => implode(', ', $validExtensions),
]
));
return $result;
}
return $this->stringToImporterResult($importObject, $file->getContent(), $options);
}
/**
@ -115,31 +240,76 @@ class BOMImporter
/**
* Import string data into an array of BOM entries, which are not yet assigned to a project.
* @param string $data The data to import
* @param array $options An array of options
* @return ProjectBOMEntry[] An array of imported entries
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param string $data The data to import
* @param array $options An array of options
*
* @return ProjectBOMEntry[]|AssemblyBOMEntry[] An array of imported entries
*/
public function stringToBOMEntries(string $data, array $options): array
public function stringToBOMEntries(Project|Assembly $importObject, string $data, array $options): array
{
$resolver = new OptionsResolver();
$resolver = $this->configureOptions($resolver);
$options = $resolver->resolve($options);
return match ($options['type']) {
'kicad_pcbnew' => $this->parseKiCADPCB($data),
'kicad_schematic' => $this->parseKiCADSchematic($data, $options),
default => throw new InvalidArgumentException('Invalid import type!'),
self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $importObject)->getBomEntries(),
self::IMPORT_TYPE_KICAD_SCHEMATIC => $this->parseKiCADSchematic($data, $options),
default => throw new InvalidArgumentException($this->translator->trans('validator.bom_importer.invalid_import_type', [], 'validators')),
};
}
private function parseKiCADPCB(string $data): array
/**
* Import string data into an array of BOM entries, which are not yet assigned to a project.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param string $data The data to import
* @param array $options An array of options
*
* @return ImporterResult An result of imported entries or a violation list
*/
public function stringToImporterResult(Project|Assembly $importObject, string $data, array $options): ImporterResult
{
$resolver = new OptionsResolver();
$resolver = $this->configureOptions($resolver);
$options = $resolver->resolve($options);
$defaultImporterResult = new ImporterResult();
$defaultImporterResult->addViolation($this->buildJsonViolation(
'validator.bom_importer.invalid_import_type',
'import.type'
));
return match ($options['type']) {
self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $importObject),
self::IMPORT_TYPE_JSON => $this->parseJson($importObject, $data),
self::IMPORT_TYPE_CSV => $this->parseCsv($importObject, $data),
default => $defaultImporterResult,
};
}
/**
* Parses a KiCAD PCB file and imports its BOM (Bill of Materials) entries into the given Project or Assembly context.
*
* This method processes a semicolon-delimited CSV data string, normalizes column names,
* validates the required fields, and creates BOM entries for each record in the data.
* The BOM entries are added to the provided Project or Assembly, depending on the context.
*
* @param string $data The semicolon- or comma-delimited CSV data to be parsed.
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @return ImporterResult The result of the import process, containing the created BOM entries.
*
* @throws UnexpectedValueException If required fields are missing in the provided data.
*/
private function parseKiCADPCB(string $data, Project|Assembly $importObject): ImporterResult
{
$result = new ImporterResult();
$csv = Reader::fromString($data);
$csv->setDelimiter(';');
$csv->setHeaderOffset(0);
$bom_entries = [];
foreach ($csv->getRecords() as $offset => $entry) {
//Translate the german field names to english
$entry = $this->normalizeColumnNames($entry);
@ -158,16 +328,21 @@ class BOMImporter
throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!');
}
$bom_entry = new ProjectBOMEntry();
$bom_entry->setName($entry['Designation'] . ' (' . $entry['Package'] . ')');
$bom_entry->setMountnames($entry['Designator'] ?? '');
$bom_entry = $importObject instanceof Project ? new ProjectBOMEntry() : new AssemblyBOMEntry();
if ($bom_entry instanceof ProjectBOMEntry) {
$bom_entry->setName($entry['Designation'] . ' (' . $entry['Package'] . ')');
} else {
$bom_entry->setName($entry['Designation']);
}
$bom_entry->setMountnames($entry['Designator']);
$bom_entry->setComment($entry['Supplier and ref'] ?? '');
$bom_entry->setQuantity((float) ($entry['Quantity'] ?? 1));
$bom_entries[] = $bom_entry;
$result->addBomEntry($bom_entry);
}
return $bom_entries;
return $result;
}
/**
@ -227,6 +402,545 @@ class BOMImporter
return $this->validationService->validateBOMEntries($mapped_entries, $options);
}
/**
* Parses the given JSON data into an ImporterResult while validating and transforming entries according to the
* specified options and object type. If violations are encountered during parsing, they are added to the result.
*
* The structure of each entry in the JSON data is validated to ensure that required fields (e.g., quantity, and name)
* are present, and optional composite fields, like `part` and its sub-properties, meet specific criteria. Various
* conditions are checked, including whether the provided values are the correct types, and if relationships (like
* matching parts or manufacturers) are resolved successfully.
*
* Violations are added for:
* - Missing or invalid `quantity` values.
* - Non-string `name` values.
* - Invalid structure or missing sub-properties in `part`.
* - Incorrect or unresolved references to parts and their information, such as `id`, `name`, `manufacturer_product_number`
* (mpnr), `internal_part_number` (ipn), or `description`.
* - Inconsistent or absent manufacturer information.
*
* If a match for a part or manufacturer cannot be resolved, a violation is added alongside an indication of the
* imported value and any partially matched information. Warnings for no exact matches are also added for parts
* using specific identifying properties like name, manufacturer product number, or internal part numbers.
*
* Additional validations include:
* - Checking for empty or invalid descriptions.
* - Ensuring manufacturers, if specified, have valid `name` or `id` values.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param string $data JSON encoded string containing BOM entries data.
*
* @return ImporterResult The result containing parsed data and any violations encountered during the parsing process.
*/
private function parseJson(Project|Assembly $importObject, string $data): ImporterResult
{
$result = new ImporterResult();
$this->jsonRoot = 'JSON Import for '.($importObject instanceof Project ? 'Project' : 'Assembly');
$data = json_decode($data, true);
foreach ($data as $key => $entry) {
if (!isset($entry['quantity'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.quantity.required',
"entry[$key].quantity"
));
}
if (isset($entry['quantity']) && (!is_float($entry['quantity']) || $entry['quantity'] <= 0)) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.quantity.float',
"entry[$key].quantity",
$entry['quantity']
));
}
if (isset($entry['name']) && !is_string($entry['name'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.string.notEmpty',
"entry[$key].name",
$entry['name']
));
}
if (isset($entry['part'])) {
$this->processPart($importObject, $entry, $result, $key, self::IMPORT_TYPE_JSON);
} else {
$bomEntry = $this->getOrCreateBomEntry($importObject, $entry['name'] ?? null);
$bomEntry->setQuantity((float) $entry['quantity']);
$result->addBomEntry($bomEntry);
}
}
return $result;
}
/**
* Parses a CSV string and processes its rows into hierarchical data structures,
* performing validations and converting data based on the provided headers.
* Handles potential violations and manages the creation of BOM entries based on the given type.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param string $csvData The raw CSV data to parse, with rows separated by newlines.
*
* @return ImporterResult Returns an ImporterResult instance containing BOM entries and any validation violations encountered.
*/
function parseCsv(Project|Assembly $importObject, string $csvData): ImporterResult
{
$result = new ImporterResult();
$rows = explode("\r\n", trim($csvData));
$headers = str_getcsv(array_shift($rows));
if (count($headers) === 1 && isset($headers[0])) {
//If only one column was recognized, try fallback with semicolon as a separator
$headers = str_getcsv($headers[0], ';');
}
foreach ($rows as $key => $row) {
$entry = [];
$values = str_getcsv($row);
if (count($values) === 1 || count($values) !== count($headers)) {
//If only one column was recognized, try fallback with semicolon as a separator
$values = str_getcsv($row, ';');
}
foreach ($headers as $index => $column) {
//Change the column names in small letters
$column = strtolower($column);
//Convert column name into hierarchy
$path = explode('_', $column);
/** @var array<string, mixed> $temp */
$temp = &$entry;
/** @var lowercase-string $step */
foreach ($path as $step) {
if (!isset($temp[$step])) {
$temp[$step] = [];
}
$temp = &$temp[$step];
}
//If there is no value, skip
if (isset($values[$index]) && $values[$index] !== '') {
//Check whether the value is numerical
if (is_numeric($values[$index]) && !in_array($column, ['name','description','manufacturer','designator'], true)) {
//Convert to integer or float
$temp = (str_contains($values[$index], '.'))
? floatval($values[$index])
: intval($values[$index]);
} else {
//Leave other data types untouched
$temp = $values[$index];
}
}
}
$entry = $this->removeEmptyProperties($entry);
if (!isset($entry['quantity'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.csv.quantity.required',
"row[$key].quantity"
));
}
if (isset($entry['quantity']) && (!is_numeric($entry['quantity']) || $entry['quantity'] <= 0)) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.csv.quantity.float',
"row[$key].quantity",
$entry['quantity']
));
}
if (isset($entry['name']) && !is_string($entry['name'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.string.notEmpty',
"row[$key].name",
$entry['name']
));
}
if (isset($entry['id']) && is_numeric($entry['id'])) {
//Use id column as a fallback for the expected part_id column
$entry['part']['id'] = (int) $entry['id'];
}
if (isset($entry['part'])) {
$this->processPart($importObject, $entry, $result, $key, self::IMPORT_TYPE_CSV);
} else {
$bomEntry = $this->getOrCreateBomEntry($importObject, $entry['name'] ?? null);
if (isset($entry['designator'])) {
$bomEntry->setMountnames(trim($entry['designator']) === '' ? '' : trim($entry['designator']));
}
$bomEntry->setQuantity((float) $entry['quantity']);
$result->addBomEntry($bomEntry);
}
}
return $result;
}
/**
* Processes an individual part entry in the import data.
*
* This method validates the structure and content of the provided part entry and uses the findings
* to identify corresponding objects in the database. The result is recorded, and violations are
* logged if issues or discrepancies exist in the validation or database matching process.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param array $entry The array representation of the part entry.
* @param ImporterResult $result The result object used for recording validation violations.
* @param int $key The index of the entry in the data array.
* @param string $importType The type of import being performed.
*
* @return void
*/
private function processPart(Project|Assembly $importObject, array $entry, ImporterResult $result, int $key, string $importType): void
{
$prefix = $importType === self::IMPORT_TYPE_JSON ? 'entry' : 'row';
if (!is_array($entry['part'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.array',
$prefix."[$key].part",
$entry['part']
));
}
$partIdValid = isset($entry['part']['id']) && is_int($entry['part']['id']) && $entry['part']['id'] > 0;
$partMpnrValid = isset($entry['part']['mpnr']) && is_string($entry['part']['mpnr']) && trim($entry['part']['mpnr']) !== '';
$partIpnValid = isset($entry['part']['ipn']) && is_string($entry['part']['ipn']) && trim($entry['part']['ipn']) !== '';
$partNameValid = isset($entry['part']['name']) && is_string($entry['part']['name']) && trim($entry['part']['name']) !== '';
if (!$partIdValid && !$partNameValid && !$partMpnrValid && !$partIpnValid) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.subproperties',
$prefix."[$key].part",
$entry['part'],
['%propertyString%' => '"id", "name", "mpnr", or "ipn"']
));
}
$part = $partIdValid ? $this->partRepository->findOneBy(['id' => $entry['part']['id']]) : null;
$part = $part ?? ($partMpnrValid ? $this->partRepository->findOneBy(['manufacturer_product_number' => trim($entry['part']['mpnr'])]) : null);
$part = $part ?? ($partIpnValid ? $this->partRepository->findOneBy(['ipn' => trim($entry['part']['ipn'])]) : null);
$part = $part ?? ($partNameValid ? $this->partRepository->findOneBy(['name' => trim($entry['part']['name'])]) : null);
if ($part === null) {
$value = sprintf('part.id: %s, part.mpnr: %s, part.ipn: %s, part.name: %s',
isset($entry['part']['id']) ? '<strong>' . $entry['part']['id'] . '</strong>' : '-',
isset($entry['part']['mpnr']) ? '<strong>' . $entry['part']['mpnr'] . '</strong>' : '-',
isset($entry['part']['ipn']) ? '<strong>' . $entry['part']['ipn'] . '</strong>' : '-',
isset($entry['part']['name']) ? '<strong>' . $entry['part']['name'] . '</strong>' : '-',
);
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.notFoundFor',
$prefix."[$key].part",
$entry['part'],
['%value%' => $value]
));
}
if ($partNameValid && $part !== null && isset($entry['part']['name']) && $part->getName() !== trim($entry['part']['name'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.noExactMatch',
$prefix."[$key].part.name",
$entry['part']['name'],
[
'%importValue%' => '<strong>' . $entry['part']['name'] . '</strong>',
'%foundId%' => $part->getID(),
'%foundValue%' => '<strong>' . $part->getName() . '</strong>'
]
));
}
if ($partMpnrValid && $part !== null && isset($entry['part']['mpnr']) && $part->getManufacturerProductNumber() !== trim($entry['part']['mpnr'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.noExactMatch',
$prefix."[$key].part.mpnr",
$entry['part']['mpnr'],
[
'%importValue%' => '<strong>' . $entry['part']['mpnr'] . '</strong>',
'%foundId%' => $part->getID(),
'%foundValue%' => '<strong>' . $part->getManufacturerProductNumber() . '</strong>'
]
));
}
if ($partIpnValid && $part !== null && isset($entry['part']['ipn']) && $part->getIpn() !== trim($entry['part']['ipn'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.noExactMatch',
$prefix."[$key].part.ipn",
$entry['part']['ipn'],
[
'%importValue%' => '<strong>' . $entry['part']['ipn'] . '</strong>',
'%foundId%' => $part->getID(),
'%foundValue%' => '<strong>' . $part->getIpn() . '</strong>'
]
));
}
if (isset($entry['part']['description'])) {
if (!is_string($entry['part']['description']) || trim($entry['part']['description']) === '') {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.string.notEmpty',
'entry[$key].part.description',
$entry['part']['description']
));
}
}
$partDescription = $entry['part']['description'] ?? '';
$manufacturerIdValid = false;
$manufacturerNameValid = false;
if (array_key_exists('manufacturer', $entry['part'])) {
if (!is_array($entry['part']['manufacturer'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.array',
'entry[$key].part.manufacturer',
$entry['part']['manufacturer'])
);
}
$manufacturerIdValid = isset($entry['part']['manufacturer']['id']) && is_int($entry['part']['manufacturer']['id']) && $entry['part']['manufacturer']['id'] > 0;
$manufacturerNameValid = isset($entry['part']['manufacturer']['name']) && is_string($entry['part']['manufacturer']['name']) && trim($entry['part']['manufacturer']['name']) !== '';
if (!$manufacturerIdValid && !$manufacturerNameValid) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.manufacturerOrCategoryWithSubProperties',
$prefix."[$key].part.manufacturer",
$entry['part']['manufacturer'],
));
}
}
$manufacturer = $manufacturerIdValid ? $this->manufacturerRepository->findOneBy(['id' => $entry['part']['manufacturer']['id']]) : null;
$manufacturer = $manufacturer ?? ($manufacturerNameValid ? $this->manufacturerRepository->findOneBy(['name' => trim($entry['part']['manufacturer']['name'])]) : null);
if (($manufacturerIdValid || $manufacturerNameValid) && $manufacturer === null) {
$value = sprintf(
'manufacturer.id: %s, manufacturer.name: %s',
isset($entry['part']['manufacturer']['id']) && $entry['part']['manufacturer']['id'] != null ? '<strong>' . $entry['part']['manufacturer']['id'] . '</strong>' : '-',
isset($entry['part']['manufacturer']['name']) && $entry['part']['manufacturer']['name'] != null ? '<strong>' . $entry['part']['manufacturer']['name'] . '</strong>' : '-'
);
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.notFoundFor',
$prefix."[$key].part.manufacturer",
$entry['part']['manufacturer'],
['%value%' => $value]
));
}
if ($manufacturerNameValid && $manufacturer !== null && isset($entry['part']['manufacturer']['name']) && $manufacturer->getName() !== trim($entry['part']['manufacturer']['name'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.noExactMatch',
$prefix."[$key].part.manufacturer.name",
$entry['part']['manufacturer']['name'],
[
'%importValue%' => '<strong>' . $entry['part']['manufacturer']['name'] . '</strong>',
'%foundId%' => $manufacturer->getID(),
'%foundValue%' => '<strong>' . $manufacturer->getName() . '</strong>'
]
));
}
$categoryIdValid = false;
$categoryNameValid = false;
if (array_key_exists('category', $entry['part'])) {
if (!is_array($entry['part']['category'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.array',
'entry[$key].part.category',
$entry['part']['category'])
);
}
$categoryIdValid = isset($entry['part']['category']['id']) && is_int($entry['part']['category']['id']) && $entry['part']['category']['id'] > 0;
$categoryNameValid = isset($entry['part']['category']['name']) && is_string($entry['part']['category']['name']) && trim($entry['part']['category']['name']) !== '';
if (!$categoryIdValid && !$categoryNameValid) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.manufacturerOrCategoryWithSubProperties',
$prefix."[$key].part.category",
$entry['part']['category']
));
}
}
$category = $categoryIdValid ? $this->categoryRepository->findOneBy(['id' => $entry['part']['category']['id']]) : null;
$category = $category ?? ($categoryNameValid ? $this->categoryRepository->findOneBy(['name' => trim($entry['part']['category']['name'])]) : null);
if (($categoryIdValid || $categoryNameValid)) {
$value = sprintf(
'category.id: %s, category.name: %s',
isset($entry['part']['category']['id']) && $entry['part']['category']['id'] != null ? '<strong>' . $entry['part']['category']['id'] . '</strong>' : '-',
isset($entry['part']['category']['name']) && $entry['part']['category']['name'] != null ? '<strong>' . $entry['part']['category']['name'] . '</strong>' : '-'
);
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.notFoundFor',
$prefix."[$key].part.category",
$entry['part']['category'],
['%value%' => $value]
));
}
if ($categoryNameValid && $category !== null && isset($entry['part']['category']['name']) && $category->getName() !== trim($entry['part']['category']['name'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.noExactMatch',
$prefix."[$key].part.category.name",
$entry['part']['category']['name'],
[
'%importValue%' => '<strong>' . $entry['part']['category']['name'] . '</strong>',
'%foundId%' => $category->getID(),
'%foundValue%' => '<strong>' . $category->getName() . '</strong>'
]
));
}
if ($result->getViolations()->count() > 0) {
return;
}
if ($partDescription !== '') {
//When updating the associated parts to a assembly, take over the description of the part.
$part->setDescription($partDescription);
}
/** @var Manufacturer|null $manufacturer */
if ($manufacturer !== null && $manufacturer->getID() !== $part->getManufacturer()->getID()) {
//When updating the associated parts, take over to a assembly of the manufacturer of the part.
$part->setManufacturer($manufacturer);
}
/** @var Category|null $category */
if ($category !== null && $category->getID() !== $part->getCategory()->getID()) {
//When updating the associated parts to a assembly, take over the category of the part.
$part->setCategory($category);
}
if ($importObject instanceof Assembly) {
$bomEntry = $this->assemblyBomEntryRepository->findOneBy(['assembly' => $importObject, 'part' => $part]);
if ($bomEntry === null) {
if (isset($entry['name']) && $entry['name'] !== '') {
$bomEntry = $this->assemblyBomEntryRepository->findOneBy(['assembly' => $importObject, 'name' => $entry['name']]);
}
if ($bomEntry === null) {
$bomEntry = new AssemblyBOMEntry();
}
}
} else {
$bomEntry = $this->projectBomEntryRepository->findOneBy(['project' => $importObject, 'part' => $part]);
if ($bomEntry === null) {
if (isset($entry['name']) && $entry['name'] !== '') {
$bomEntry = $this->projectBomEntryRepository->findOneBy(['project' => $importObject, 'name' => $entry['name']]);
}
if ($bomEntry === null) {
$bomEntry = new ProjectBOMEntry();
}
}
}
$bomEntry->setQuantity((float) $entry['quantity']);
if (isset($entry['name'])) {
$givenName = trim($entry['name']) === '' ? null : trim ($entry['name']);
if ($givenName !== null && $part !== null && $part->getName() !== $givenName) {
//Apply different names for parts list entry
$bomEntry->setName(trim($entry['name']) === '' ? null : trim ($entry['name']));
}
} else {
$bomEntry->setName(null);
}
if (isset($entry['designator'])) {
if ($bomEntry instanceof ProjectBOMEntry) {
$bomEntry->setMountnames(trim($entry['designator']) === '' ? '' : trim($entry['designator']));
} elseif ($bomEntry instanceof AssemblyBOMEntry) {
$bomEntry->setDesignator(trim($entry['designator']) === '' ? '' : trim($entry['designator']));
}
}
$bomEntry->setPart($part);
$result->addBomEntry($bomEntry);
}
private function removeEmptyProperties(array $data): array
{
foreach ($data as $key => &$value) {
//Recursive check when the value is an array
if (is_array($value)) {
$value = $this->removeEmptyProperties($value);
//Remove the array when it is empty after cleaning
if (empty($value)) {
unset($data[$key]);
}
} elseif ($value === null || $value === '') {
//Remove values that are explicitly zero or empty
unset($data[$key]);
}
}
return $data;
}
/**
* Retrieves an existing BOM (Bill of Materials) entry by name or creates a new one if not found.
*
* Depending on whether the provided import object is a Project or Assembly, this method attempts to locate
* a corresponding BOM entry in the appropriate repository. If no entry is located, a new BOM entry object
* is instantiated according to the type of the import object.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param string|null $name The name of the BOM entry to search for or assign to a new entry.
*
* @return ProjectBOMEntry|AssemblyBOMEntry An existing or newly created BOM entry.
*/
private function getOrCreateBomEntry(Project|Assembly $importObject, ?string $name): ProjectBOMEntry|AssemblyBOMEntry
{
$bomEntry = null;
//Check whether there is a name
if (!empty($name)) {
if ($importObject instanceof Project) {
$bomEntry = $this->projectBomEntryRepository->findOneBy(['name' => $name]);
} else {
$bomEntry = $this->assemblyBomEntryRepository->findOneBy(['name' => $name]);
}
}
//If no bom entry was found, a new object create
if ($bomEntry === null) {
if ($importObject instanceof Project) {
$bomEntry = new ProjectBOMEntry();
} else {
$bomEntry = new AssemblyBOMEntry();
}
}
$bomEntry->setName($name);
return $bomEntry;
}
/**
* This function uses the order of the fields in the CSV files to make them locale independent.
* @param array $entry
@ -243,13 +957,28 @@ class BOMImporter
}
//@phpstan-ignore-next-line We want to keep this check just to be safe when something changes
$new_index = self::MAP_KICAD_PCB_FIELDS[$index] ?? throw new \UnexpectedValueException('Invalid field index!');
$new_index = self::MAP_KICAD_PCB_FIELDS[$index] ?? throw new UnexpectedValueException('Invalid field index!');
$out[$new_index] = $field;
}
return $out;
}
/**
* Builds a JSON-based constraint violation.
*
* This method creates a `ConstraintViolation` object that represents a validation error.
* The violation includes a message, property path, invalid value, and other contextual information.
* Translations for the violation message can be applied through the translator service.
*
* @param string $message The translation key for the validation message.
* @param string $propertyPath The property path where the violation occurred.
* @param mixed|null $invalidValue The value that caused the violation (optional).
* @param array $parameters Additional parameters for message placeholders (default is an empty array).
*
* @return ConstraintViolation The created constraint violation object.
*/
/**
* Parse KiCad schematic BOM with flexible field mapping
*/
@ -727,4 +1456,30 @@ class BOMImporter
return array_values($headers);
}
/**
* Builds a JSON-based constraint violation.
*
* This method creates a `ConstraintViolation` object that represents a validation error.
* The violation includes a message, property path, invalid value, and other contextual information.
* Translations for the violation message can be applied through the translator service.
*
* @param string $message The translation key for the validation message.
* @param string $propertyPath The property path where the violation occurred.
* @param mixed|null $invalidValue The value that caused the violation (optional).
* @param array $parameters Additional parameters for message placeholders (default is an empty array).
*
* @return ConstraintViolation The created constraint violation object.
*/
private function buildJsonViolation(string $message, string $propertyPath, mixed $invalidValue = null, array $parameters = []): ConstraintViolation
{
return new ConstraintViolation(
message: $this->translator->trans($message, $parameters, 'validators'),
messageTemplate: $message,
parameters: $parameters,
root: $this->jsonRoot,
propertyPath: $propertyPath,
invalidValue: $invalidValue
);
}
}

View file

@ -22,8 +22,22 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
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\Entity\ProjectSystem\ProjectBOMEntry;
use App\Helpers\Assemblies\AssemblyPartAggregator;
use App\Helpers\FilenameSanatizer;
use App\Serializer\APIPlatform\SkippableItemNormalizer;
use Symfony\Component\OptionsResolver\OptionsResolver;
@ -48,8 +62,10 @@ use PhpOffice\PhpSpreadsheet\Writer\Xls;
*/
class EntityExporter
{
public function __construct(protected SerializerInterface $serializer)
{
public function __construct(
protected SerializerInterface $serializer,
protected AssemblyPartAggregator $partAggregator, private readonly AssemblyPartAggregator $assemblyPartAggregator,
) {
}
protected function configureOptions(OptionsResolver $resolver): void
@ -65,6 +81,10 @@ class EntityExporter
$resolver->setDefault('include_children', false);
$resolver->setAllowedTypes('include_children', 'bool');
$resolver->setDefault('readableSelect', null);
$resolver->setAllowedValues('readableSelect', [null, 'readable', 'readable_bom']);
}
/**
@ -222,15 +242,67 @@ class EntityExporter
$entities = [$entities];
}
//Do the serialization with the given options
$serialized_data = $this->exportEntities($entities, $options);
if ($request->get('readableSelect', false) === 'readable') {
// 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';
} elseif ($request->get('readableSelect', false) === 'readable_bom') {
$hierarchies = [];
foreach ($entities as $entity) {
if (!$entity instanceof Assembly) {
throw new InvalidArgumentException('Only assemblies can be exported in readable BOM format');
}
$hierarchies[] = $this->assemblyPartAggregator->processAssemblyHierarchyForPdf($entity, 0, 1, 1);
}
$pdfContent = $this->assemblyPartAggregator->exportReadableHierarchyForPdf($hierarchies);
$response = new Response($pdfContent);
$options['format'] = 'pdf';
$options['level'] = 'readable_bom';
} 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
@ -241,6 +313,7 @@ class EntityExporter
'json' => 'application/json',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls' => 'application/vnd.ms-excel',
'pdf' => 'application/pdf',
default => 'text/plain',
};
$response->headers->set('Content-Type', $content_type);
@ -277,4 +350,311 @@ 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',
//BOM relevant attributes
'Quantity', 'PartId', 'PartName', 'Ipn', 'Manufacturer', 'Mpn', 'Name', 'Mountnames',
'Description',
],
'processEntity' => fn($entity, $depth) => [
'Id' => $entity->getId(),
'ParentId' => $entity->getParent()?->getId() ?? '',
'Type' => 'project',
'ProjectNameHierarchical' => str_repeat('--', $depth) . ' ' . $entity->getName(),
'ProjectName' => $entity->getName(),
'ProjectFullName' => $this->getFullName($entity),
//BOM relevant attributes
'Quantity' => '-',
'PartId' => '-',
'PartName' => '-',
'Ipn' => '-',
'Manufacturer' => '-',
'Mpn' => '-',
'Name' => '-',
'Mountnames' => '-',
'Description' => '-',
],
'processBomEntries' => fn($entity, $depth) => array_map(fn(ProjectBOMEntry $bomEntry) => [
'Id' => $entity->getId(),
'ParentId' => '',
'Type' => 'project_bom_entry',
'ProjectNameHierarchical' => str_repeat('--', $depth) . '> ' . $entity->getName(),
'ProjectName' => $entity->getName(),
'ProjectFullName' => $this->getFullName($entity),
//BOM relevant attributes
'Quantity' => $bomEntry->getQuantity(),
'PartId' => $bomEntry->getPart()?->getId() ?? '',
'PartName' => $bomEntry->getPart()?->getName() ?? '',
'Ipn' => $bomEntry->getPart()?->getIpn() ?? '',
'Manufacturer' => $bomEntry->getPart()?->getManufacturer()?->getName() ?? '',
'Mpn' => $bomEntry->getPart()?->getManufacturerProductNumber() ?? '',
'Name' => $bomEntry->getPart()?->getName() ?? '',
'Mountnames' => $bomEntry->getMountnames(),
'Description' => $bomEntry->getPart()?->getDescription() ?? '',
], $entity->getBomEntries()->toArray()),
],
Assembly::class => [
'header' => [
'Id', 'ParentId', 'Type', 'AssemblyIpn', 'AssemblyNameHierarchical', 'AssemblyName',
'AssemblyFullName',
//BOM relevant attributes
'Quantity', 'PartId', 'PartName', 'Ipn', 'Manufacturer', 'Mpn', 'Name', 'Designator',
'Description', 'ReferencedAssemblyId', 'ReferencedAssemblyIpn',
'ReferencedAssemblyFullName',
],
'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),
//BOM relevant attributes
'Quantity' => '-',
'PartId' => '-',
'PartName' => '-',
'Ipn' => '-',
'Manufacturer' => '-',
'Mpn' => '-',
'Name' => '-',
'Designator' => '-',
'Description' => '-',
'ReferencedAssemblyId' => '-',
'ReferencedAssemblyIpn' => '-',
'ReferencedAssemblyFullName' => '-',
],
'processBomEntries' => fn($entity, $depth) => $this->processBomEntriesWithAggregatedParts($entity, $depth),
],
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;
}
/**
* Process BOM entries and include aggregated parts as "complete_part_list".
*
* @param Assembly $assembly The assembly being processed.
* @param int $depth The current depth in the hierarchy.
* @return array Processed BOM entries and aggregated parts rows.
*/
private function processBomEntriesWithAggregatedParts(Assembly $assembly, int $depth): array
{
$rows = [];
/** @var AssemblyBOMEntry $bomEntry */
foreach ($assembly->getBomEntries() as $bomEntry) {
// Add the BOM entry itself
$rows[] = [
'Id' => $assembly->getId(),
'ParentId' => '',
'Type' => 'assembly_bom_entry',
'AssemblyIpn' => $assembly->getIpn(),
'AssemblyNameHierarchical' => str_repeat('--', $depth) . '> ' . $assembly->getName(),
'AssemblyName' => $assembly->getName(),
'AssemblyFullName' => $this->getFullName($assembly),
//BOM relevant attributes
'Quantity' => $bomEntry->getQuantity(),
'PartId' => $bomEntry->getPart()?->getId() ?? '-',
'PartName' => $bomEntry->getPart()?->getName() ?? '-',
'Ipn' => $bomEntry->getPart()?->getIpn() ?? '-',
'Manufacturer' => $bomEntry->getPart()?->getManufacturer()?->getName() ?? '-',
'Mpn' => $bomEntry->getPart()?->getManufacturerProductNumber() ?? '-',
'Name' => $bomEntry->getName() ?? '-',
'Designator' => $bomEntry->getDesignator(),
'Description' => $bomEntry->getPart()?->getDescription() ?? '-',
'ReferencedAssemblyId' => $bomEntry->getReferencedAssembly()?->getId() ?? '-',
'ReferencedAssemblyIpn' => $bomEntry->getReferencedAssembly()?->getIpn() ?? '-',
'ReferencedAssemblyFullName' => $this->getFullName($bomEntry->getReferencedAssembly() ?? null),
];
// If a referenced assembly exists, add aggregated parts
if ($bomEntry->getReferencedAssembly() instanceof Assembly) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
// Get aggregated parts for the referenced assembly
$aggregatedParts = $this->assemblyPartAggregator->getAggregatedParts($referencedAssembly, $bomEntry->getQuantity());;
foreach ($aggregatedParts as $partData) {
$partAssembly = $partData['assembly'] ?? null;
$rows[] = [
'Id' => $assembly->getId(),
'ParentId' => '',
'Type' => 'subassembly_part_list',
'AssemblyIpn' => $partAssembly ? $partAssembly->getIpn() : '',
'AssemblyNameHierarchical' => '',
'AssemblyName' => $partAssembly ? $partAssembly->getName() : '',
'AssemblyFullName' => $this->getFullName($partAssembly),
//BOM relevant attributes
'Quantity' => $partData['quantity'],
'PartId' => $partData['part']?->getId(),
'PartName' => $partData['part']?->getName(),
'Ipn' => $partData['part']?->getIpn(),
'Manufacturer' => $partData['part']?->getManufacturer()?->getName(),
'Mpn' => $partData['part']?->getManufacturerProductNumber(),
'Name' => $partData['name'] ?? '',
'Designator' => $partData['designator'],
'Description' => $partData['part']?->getDescription(),
'ReferencedAssemblyId' => '-',
'ReferencedAssemblyIpn' => '-',
'ReferencedAssemblyFullName' => '-',
];
}
}
}
return $rows;
}
/**
* 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

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
class ImporterResult
{
private array $bomEntries = [];
private ConstraintViolationList $violations;
public function __construct(array $bomEntries = [])
{
$this->bomEntries = $bomEntries;
$this->violations = new ConstraintViolationList();
}
/**
* Fügt einen neuen BOM-Eintrag hinzu.
*/
public function addBomEntry(object $bomEntry): void
{
$this->bomEntries[] = $bomEntry;
}
/**
* Gibt alle BOM-Einträge zurück.
*/
public function getBomEntries(): array
{
return $this->bomEntries;
}
/**
* Gibt die Liste der Violation zurück.
*/
public function getViolations(): ConstraintViolationList
{
return $this->violations;
}
/**
* Fügt eine neue `ConstraintViolation` zur Liste hinzu.
*/
public function addViolation(ConstraintViolation $violation): void
{
$this->violations->add($violation);
}
/**
* Prüft, ob die Liste der Violationen leer ist.
*/
public function hasViolations(): bool
{
return count($this->violations) > 0;
}
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\Trees;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AttachmentType;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
@ -183,6 +184,12 @@ class ToolsTreeBuilder
$this->urlGenerator->generate('project_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-archive');
}
if ($this->security->isGranted('read', new Assembly())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.assemblies'),
$this->urlGenerator->generate('assembly_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-list');
}
if ($this->security->isGranted('read', new Supplier())) {
$nodes[] = (new TreeViewNode(
$this->elementTypeNameGenerator->typeLabelPlural(Supplier::class),

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\Trees;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
@ -155,6 +156,10 @@ class TreeViewGenerator
$href_type = 'list_parts';
}
if ($mode === 'assemblies') {
$href_type = 'list_parts';
}
$generic = $this->getGenericTree($class, $parent);
$treeIterator = new TreeViewNodeIterator($generic);
$recursiveIterator = new RecursiveIteratorIterator($treeIterator, RecursiveIteratorIterator::SELF_FIRST);
@ -184,6 +189,15 @@ class TreeViewGenerator
$root_node->setExpanded($this->rootNodeExpandedByDefault);
$root_node->setIcon($this->entityClassToRootNodeIcon($class));
$generic = [$root_node];
} elseif ($mode === 'assemblies' && $this->rootNodeEnabled) {
//We show the root node as a link to the list of all assemblies
$show_all_parts_url = $this->router->generate('assemblies_list');
$root_node = new TreeViewNode($this->entityClassToRootNodeString($class), $show_all_parts_url, $generic);
$root_node->setExpanded($this->rootNodeExpandedByDefault);
$root_node->setIcon($this->entityClassToRootNodeIcon($class));
$generic = [$root_node];
}
@ -226,6 +240,7 @@ class TreeViewGenerator
Manufacturer::class => $icon.'fa-industry',
Supplier::class => $icon.'fa-truck',
Project::class => $icon.'fa-archive',
Assembly::class => $icon.'fa-list',
default => null,
};
}

View file

@ -105,6 +105,7 @@ class PermissionPresetsHelper
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'part_custom_states', PermissionData::ALLOW);
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'suppliers', PermissionData::ALLOW);
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'projects', PermissionData::ALLOW);
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'assemblies', PermissionData::ALLOW);
//Allow to change system settings
$this->permissionResolver->setPermission($perm_holder, 'config', 'change_system_settings', PermissionData::ALLOW);
@ -135,6 +136,7 @@ class PermissionPresetsHelper
$this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'part_custom_states', PermissionData::ALLOW, ['import']);
$this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'suppliers', PermissionData::ALLOW, ['import']);
$this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'projects', PermissionData::ALLOW, ['import']);
$this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'assemblies', PermissionData::ALLOW, ['import']);
//Attachments permissions
$this->permissionResolver->setPermission($permHolder, 'attachments', 'show_private', PermissionData::ALLOW);
@ -182,6 +184,9 @@ class PermissionPresetsHelper
//Set projects permissions
$this->permissionResolver->setPermission($perm_holder, 'projects', 'read', PermissionData::ALLOW);
//Set assemblies permissions
$this->permissionResolver->setPermission($perm_holder, 'assemblies', 'read', PermissionData::ALLOW);
return $perm_holder;
}

View file

@ -0,0 +1,54 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Settings\BehaviorSettings;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
enum AssemblyBomTableColumns : string implements TranslatableInterface
{
case NAME = "name";
case ID = "id";
case QUANTITY = "quantity";
case IPN = "ipn";
case DESCRIPTION = "description";
case CATEGORY = "category";
case MANUFACTURER = "manufacturer";
case DESIGNATOR = "designator";
case MOUNTNAMES = "mountnames";
case STORAGE_LOCATION = "storage_location";
case AMOUNT = "amount";
case ADDED_DATE = "addedDate";
case LAST_MODIFIED = "lastModified";
case EDIT = "edit";
public function trans(TranslatorInterface $translator, ?string $locale = null): string
{
$key = match($this) {
default => 'assembly.bom.table.' . $this->value,
};
return $translator->trans($key, locale: $locale);
}
}

View file

@ -0,0 +1,49 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Settings\BehaviorSettings;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
enum AssemblyTableColumns : string implements TranslatableInterface
{
case NAME = "name";
case ID = "id";
case IPN = "ipn";
case DESCRIPTION = "description";
case REFERENCED_ASSEMBLIES = "referencedAssemblies";
case ADDED_DATE = "addedDate";
case LAST_MODIFIED = "lastModified";
case EDIT = "edit";
public function trans(TranslatorInterface $translator, ?string $locale = null): string
{
$key = match($this) {
default => 'assembly.table.' . $this->value,
};
return $translator->trans($key, locale: $locale);
}
}

View file

@ -70,6 +70,37 @@ class TableSettings
PartTableColumns::CATEGORY, PartTableColumns::FOOTPRINT, PartTableColumns::MANUFACTURER,
PartTableColumns::LOCATION, PartTableColumns::AMOUNT, PartTableColumns::CUSTOM_PART_STATE];
/** @var AssemblyTableColumns[] */
#[SettingsParameter(ArrayType::class,
label: new TM("settings.behavior.table.assemblies_default_columns"),
description: new TM("settings.behavior.table.assemblies_default_columns.help"),
options: ['type' => EnumType::class, 'options' => ['class' => AssemblyTableColumns::class]],
formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class,
formOptions: ['class' => AssemblyTableColumns::class, 'multiple' => true, 'ordered' => true],
envVar: "TABLE_ASSEMBLIES_DEFAULT_COLUMNS", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, 'mapAssembliesDefaultColumnsEnv']
)]
#[Assert\NotBlank()]
#[Assert\Unique()]
#[Assert\All([new Assert\Type(AssemblyTableColumns::class)])]
public array $assembliesDefaultColumns = [AssemblyTableColumns::ID, AssemblyTableColumns::IPN, AssemblyTableColumns::NAME,
AssemblyTableColumns::DESCRIPTION, AssemblyTableColumns::REFERENCED_ASSEMBLIES, AssemblyTableColumns::EDIT];
/** @var AssemblyBomTableColumns[] */
#[SettingsParameter(ArrayType::class,
label: new TM("settings.behavior.table.assemblies_bom_default_columns"),
description: new TM("settings.behavior.table.assemblies_bom_default_columns.help"),
options: ['type' => EnumType::class, 'options' => ['class' => AssemblyBomTableColumns::class]],
formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class,
formOptions: ['class' => AssemblyBomTableColumns::class, 'multiple' => true, 'ordered' => true],
envVar: "TABLE_ASSEMBLIES_BOM_DEFAULT_COLUMNS", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, 'mapAssemblyBomsDefaultColumnsEnv']
)]
#[Assert\NotBlank()]
#[Assert\Unique()]
#[Assert\All([new Assert\Type(AssemblyBomTableColumns::class)])]
public array $assembliesBomDefaultColumns = [AssemblyBomTableColumns::QUANTITY, AssemblyBomTableColumns::ID,
AssemblyBomTableColumns::IPN, AssemblyBomTableColumns::NAME, AssemblyBomTableColumns::DESCRIPTION];
#[SettingsParameter(label: new TM("settings.behavior.table.preview_image_min_width"),
formOptions: ['attr' => ['min' => 1, 'max' => 100]],
envVar: "int:TABLE_IMAGE_PREVIEW_MIN_SIZE", envVarMode: EnvVarMode::OVERWRITE
@ -101,4 +132,36 @@ class TableSettings
return $ret;
}
public static function mapAssembliesDefaultColumnsEnv(string $columns): array
{
$exploded = explode(',', $columns);
$ret = [];
foreach ($exploded as $column) {
$enum = AssemblyTableColumns::tryFrom($column);
if (!$enum) {
throw new \InvalidArgumentException("Invalid column '$column' in TABLE_ASSEMBLIES_DEFAULT_COLUMNS");
}
$ret[] = $enum;
}
return $ret;
}
public static function mapAssemblyBomsDefaultColumnsEnv(string $columns): array
{
$exploded = explode(',', $columns);
$ret = [];
foreach ($exploded as $column) {
$enum = AssemblyBomTableColumns::tryFrom($column);
if (!$enum) {
throw new \InvalidArgumentException("Invalid column '$column' in TABLE_ASSEMBLIES_BOM_DEFAULT_COLUMNS");
}
$ret[] = $enum;
}
return $ret;
}
}

View file

@ -0,0 +1,45 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Settings\MiscSettings;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings(label: new TM("settings.misc.assembly"))]
#[SettingsIcon("fa-list")]
class AssemblySettings
{
use SettingsTrait;
#[SettingsParameter(
label: new TM("settings.misc.assembly.useIpnPlaceholderInName"),
envVar: "bool:CREATE_ASSEMBLY_USE_IPN_PLACEHOLDER_IN_NAME", envVarMode: EnvVarMode::OVERWRITE,
)]
public bool $useIpnPlaceholderInName = true;
}

View file

@ -38,4 +38,7 @@ class MiscSettings
#[EmbeddedSettings]
public ?IpnSuggestSettings $ipnSuggestSettings = null;
#[EmbeddedSettings]
public ?AssemblySettings $assembly = null;
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\Twig;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\PartCustomState;
@ -111,6 +112,7 @@ final class EntityExtension extends AbstractExtension
Manufacturer::class => 'manufacturer',
Category::class => 'category',
Project::class => 'device',
Assembly::class => 'assembly',
Attachment::class => 'attachment',
Supplier::class => 'supplier',
User::class => 'user',

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\Validator\Constraints\AssemblySystem;
use Symfony\Component\Validator\Constraint;
/**
* This constraint checks that there is no cycle in bom configuration of the assembly
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class AssemblyCycle extends Constraint
{
public string $message = 'assembly.bom_entry.assembly_cycle';
public function validatedBy(): string
{
return AssemblyCycleValidator::class;
}
}

View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\Validator\Constraints\AssemblySystem;
use App\Entity\AssemblySystem\Assembly;
use Symfony\Component\Form\Form;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilder;
use ReflectionClass;
/**
* Validator class to check for cycles in assemblies based on BOM entries.
*
* This validator ensures that the structure of assemblies does not contain circular dependencies
* by validating each entry in the Bill of Materials (BOM) of the given assembly. Additionally,
* it can handle form-submitted BOM entries to include these in the validation process.
*/
class AssemblyCycleValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof AssemblyCycle) {
throw new UnexpectedTypeException($constraint, AssemblyCycle::class);
}
if (!$value instanceof Assembly) {
return;
}
$availableViolations = $this->context->getViolations();
if (count($availableViolations) > 0) {
//already violations given, currently no more needed to check
return;
}
$bomEntries = [];
if ($this->context->getRoot() instanceof Form && $this->context->getRoot()->has('bom_entries')) {
$bomEntries = $this->context->getRoot()->get('bom_entries')->getData();
$bomEntries = is_array($bomEntries) ? $bomEntries : iterator_to_array($bomEntries);
} elseif ($this->context->getRoot() instanceof Assembly) {
$bomEntries = $value->getBomEntries()->toArray();
}
$relevantEntries = [];
foreach ($bomEntries as $bomEntry) {
if ($bomEntry->getReferencedAssembly() !== null) {
$relevantEntries[$bomEntry->getId()] = $bomEntry;
}
}
$visitedAssemblies = [];
foreach ($relevantEntries as $bomEntry) {
if ($this->hasCycle($bomEntry->getReferencedAssembly(), $value, $visitedAssemblies)) {
$this->addViolation($value, $constraint);
}
}
}
/**
* Determines if there is a cyclic dependency in the assembly hierarchy.
*
* This method checks if a cycle exists in the hierarchy of referenced assemblies starting
* from a given assembly. It traverses through the Bill of Materials (BOM) entries of each
* assembly recursively and keeps track of visited assemblies to detect cycles.
*
* @param Assembly|null $currentAssembly The current assembly being checked for cycles.
* @param Assembly $originalAssembly The original assembly from where the cycle detection started.
* @param Assembly[] $visitedAssemblies A list of assemblies that have been visited during the current traversal.
*
* @return bool True if a cycle is detected, false otherwise.
*/
private function hasCycle(?Assembly $currentAssembly, Assembly $originalAssembly, array $visitedAssemblies = []): bool
{
//No referenced assembly → no cycle
if ($currentAssembly === null) {
return false;
}
//If the assembly has already been visited, there is a cycle
if (in_array($currentAssembly->getId(), array_map(fn($a) => $a->getId(), $visitedAssemblies), true)) {
return true;
}
//Add the current assembly to the visited
$visitedAssemblies[] = $currentAssembly;
//Go through the bom entries of the current assembly
foreach ($currentAssembly->getBomEntries() as $bomEntry) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
if ($referencedAssembly !== null && $this->hasCycle($referencedAssembly, $originalAssembly, $visitedAssemblies)) {
return true;
}
}
//Remove the current assembly from the list of visit (recursion completed)
array_pop($visitedAssemblies);
return false;
}
/**
* Adds a violation to the current context if it hasnt already been added.
*
* This method checks whether a violation with the same property path as the current violation
* already exists in the context. If such a violation is found, the current violation is not added again.
* The process involves reflection to access private or protected properties of violation objects.
*
* @param mixed $value The value that triggered the violation.
* @param AssemblyCycle $constraint The constraint containing the validation details.
*
*/
private function addViolation(mixed $value, AssemblyCycle $constraint): void
{
/** @var ConstraintViolationBuilder $buildViolation */
$buildViolation = $this->context->buildViolation($constraint->message)
->setParameter('%name%', $value->getName());
$alreadyAdded = false;
try {
$reflectionClass = new ReflectionClass($buildViolation);
$property = $reflectionClass->getProperty('propertyPath');
$propertyPath = $property->getValue($buildViolation);
$availableViolations = $this->context->getViolations();
foreach ($availableViolations as $tmpViolation) {
$tmpReflectionClass = new ReflectionClass($tmpViolation);
$tmpProperty = $tmpReflectionClass->getProperty('propertyPath');
$tmpPropertyPath = $tmpProperty->getValue($tmpViolation);
if ($tmpPropertyPath === $propertyPath) {
$alreadyAdded = true;
}
}
} catch (\ReflectionException) {
}
if (!$alreadyAdded) {
$buildViolation->addViolation();
}
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints\AssemblySystem;
use Symfony\Component\Validator\Constraint;
/**
* This constraint ensures that no BOM entries in the assembly reference its own children.
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class AssemblyInvalidBomEntry extends Constraint
{
public string $message = 'assembly.bom_entry.invalid_child_entry';
public function validatedBy(): string
{
return AssemblyInvalidBomEntryValidator::class;
}
}

View file

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints\AssemblySystem;
use App\Entity\AssemblySystem\Assembly;
use Symfony\Component\Form\Form;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilder;
use ReflectionClass;
/**
* Validator to check that no child assemblies are referenced in BOM entries.
*/
class AssemblyInvalidBomEntryValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof AssemblyInvalidBomEntry) {
throw new UnexpectedTypeException($constraint, AssemblyInvalidBomEntry::class);
}
if (!$value instanceof Assembly) {
return;
}
$availableViolations = $this->context->getViolations();
if (count($availableViolations) > 0) {
//already violations given, currently no more needed to check
return;
}
$bomEntries = [];
if ($this->context->getRoot() instanceof Form && $this->context->getRoot()->has('bom_entries')) {
$bomEntries = $this->context->getRoot()->get('bom_entries')->getData();
$bomEntries = is_array($bomEntries) ? $bomEntries : iterator_to_array($bomEntries);
} elseif ($this->context->getRoot() instanceof Assembly) {
$bomEntries = $value->getBomEntries()->toArray();
}
$relevantEntries = [];
foreach ($bomEntries as $bomEntry) {
if ($bomEntry->getReferencedAssembly() !== null) {
$relevantEntries[$bomEntry->getId()] = $bomEntry;
}
}
foreach ($relevantEntries as $bomEntry) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
if ($bomEntry->getAssembly()->getParent()?->getId() === $referencedAssembly->getParent()?->getId()) {
//Save on the same assembly level
continue;
} elseif ($this->isInvalidBomEntry($referencedAssembly, $bomEntry->getAssembly())) {
$this->addViolation($value, $constraint);
}
}
}
/**
* Determines whether a Bill of Materials (BOM) entry is invalid based on the relationship
* between the current assembly and the parent assembly.
*
* @param Assembly|null $currentAssembly The current assembly being analyzed. Null indicates no assembly is referenced.
* @param Assembly $parentAssembly The parent assembly to check against the current assembly.
*
* @return bool Returns
*/
private function isInvalidBomEntry(?Assembly $currentAssembly, Assembly $parentAssembly): bool
{
//No assembly referenced -> no problems
if ($currentAssembly === null) {
return false;
}
//Check: is the current assembly a descendant of the parent assembly?
if ($currentAssembly->isChildOf($parentAssembly)) {
return true;
}
//Recursive check: Analyze the current assembly list
foreach ($currentAssembly->getBomEntries() as $bomEntry) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
if ($this->isInvalidBomEntry($referencedAssembly, $parentAssembly)) {
return true;
}
}
return false;
}
/**
* Adds a violation to the current context if it hasnt already been added.
*
* This method checks whether a violation with the same property path as the current violation
* already exists in the context. If such a violation is found, the current violation is not added again.
* The process involves reflection to access private or protected properties of violation objects.
*
* @param mixed $value The value that triggered the violation.
* @param AssemblyInvalidBomEntry $constraint The constraint containing the validation details.
*
*/
private function addViolation($value, AssemblyInvalidBomEntry $constraint): void
{
/** @var ConstraintViolationBuilder $buildViolation */
$buildViolation = $this->context->buildViolation($constraint->message)
->setParameter('%name%', $value->getName());
$alreadyAdded = false;
try {
$reflectionClass = new ReflectionClass($buildViolation);
$property = $reflectionClass->getProperty('propertyPath');
$propertyPath = $property->getValue($buildViolation);
$availableViolations = $this->context->getViolations();
foreach ($availableViolations as $tmpViolation) {
$tmpReflectionClass = new ReflectionClass($tmpViolation);
$tmpProperty = $tmpReflectionClass->getProperty('propertyPath');
$tmpPropertyPath = $tmpProperty->getValue($tmpViolation);
if ($tmpPropertyPath === $propertyPath) {
$alreadyAdded = true;
}
}
} catch (\ReflectionException) {
}
if (!$alreadyAdded) {
$buildViolation->addViolation();
}
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\Validator\Constraints\AssemblySystem;
use Symfony\Component\Validator\Constraint;
/**
* This constraint checks that the given UniqueReferencedAssembly is valid.
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UniqueReferencedAssembly extends Constraint
{
public string $message = 'assembly.bom_entry.assembly_already_in_bom';
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\Validator\Constraints\AssemblySystem;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class UniqueReferencedAssemblyValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
$assemblies = [];
foreach ($value as $entry) {
$referencedAssemblyId = $entry->getReferencedAssembly()?->getId();
if ($referencedAssemblyId === null) {
continue;
}
if (isset($assemblies[$referencedAssemblyId])) {
/** @var UniqueReferencedAssembly $constraint */
$this->context->buildViolation($constraint->message)
->atPath('referencedAssembly')
->addViolation();
return;
}
$assemblies[$referencedAssemblyId] = true;
}
}
}