Assemblies einführen

This commit is contained in:
Marcel Diegelmann 2025-03-19 08:13:45 +01:00
parent e1418dfdc1
commit 6fa960df42
107 changed files with 14101 additions and 96 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

@ -0,0 +1,302 @@
<?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\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Part;
use App\Form\AssemblySystem\AssemblyAddPartsType;
use App\Form\AssemblySystem\AssemblyBuildType;
use App\Helpers\Assemblies\AssemblyBuildRequest;
use App\Repository\PartRepository;
use App\Services\ImportExportSystem\BOMImporter;
use App\Services\AssemblySystem\AssemblyBuildHelper;
use Doctrine\Common\Collections\ArrayCollection;
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
{
private PartRepository $partRepository;
public function __construct(
private readonly DataTableFactory $dataTableFactory,
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator,
) {
$this->partRepository = $this->entityManager->getRepository(Part::class);
}
#[Route(path: '/{id}/info', name: 'assembly_info', requirements: ['id' => '\d+'])]
public function info(Assembly $assembly, Request $request, AssemblyBuildHelper $buildHelper): 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', [
'buildHelper' => $buildHelper,
'datatable' => $table,
'assembly' => $assembly,
]);
}
#[Route(path: '/{id}/build', name: 'assembly_build', requirements: ['id' => '\d+'])]
public function build(Assembly $assembly, Request $request, AssemblyBuildHelper $buildHelper, EntityManagerInterface $entityManager): Response
{
$this->denyAccessUnlessGranted('read', $assembly);
//If no number of builds is given (or it is invalid), just assume 1
$number_of_builds = $request->query->getInt('n', 1);
if ($number_of_builds < 1) {
$number_of_builds = 1;
}
$assemblyBuildRequest = new AssemblyBuildRequest($assembly, $number_of_builds);
$form = $this->createForm(AssemblyBuildType::class, $assemblyBuildRequest);
$form->handleRequest($request);
if ($form->isSubmitted()) {
if ($form->isValid()) {
//Ensure that the user can withdraw stock from all parts
$this->denyAccessUnlessGranted('@parts_stock.withdraw');
//We have to do a flush already here, so that the newly created partLot gets an ID and can be logged to DB later.
$entityManager->flush();
$buildHelper->doBuild($assemblyBuildRequest);
$entityManager->flush();
$this->addFlash('success', 'assembly.build.flash.success');
return $this->redirect(
$request->get('_redirect',
$this->generateUrl('assembly_info', ['id' => $assembly->getID()]
)));
}
$this->addFlash('error', 'assembly.build.flash.invalid_input');
}
return $this->render('assemblies/build/build.html.twig', [
'buildHelper' => $buildHelper,
'assembly' => $assembly,
'build_request' => $assemblyBuildRequest,
'number_of_builds' => $number_of_builds,
'form' => $form,
]);
}
#[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.kicad_pcbnew' => 'kicad_pcbnew',
]
]);
$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 BOM entries if requested
if ($form->get('clear_existing_bom')->getData()) {
$assembly->getBomEntries()->clear();
$entityManager->flush();
}
try {
$entries = $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) {
foreach ($entries as $entry) {
if ($entry instanceof AssemblyBOMEntry && $entry->getPart() !== null) {
$part = $entry->getPart();
if ($part->getID() === null) {
$this->partRepository->save($part);
}
}
}
$this->addFlash('success', t('assembly.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
return $this->redirectToRoute('assembly_edit', ['id' => $assembly->getID()]);
}
//When we get here, there were 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,
'errors' => $errors ?? 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

@ -33,6 +33,7 @@ use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\ProjectSystem\Project;
use App\Entity\AssemblySystem\Assembly;
use App\Exceptions\AttachmentDownloadException;
use App\Form\Part\PartBaseType;
use App\Services\Attachments\AttachmentSubmitHandler;
@ -45,6 +46,7 @@ use App\Services\LogSystem\TimeTravel;
use App\Services\Parameters\ParameterExtractor;
use App\Services\Parts\PartLotWithdrawAddHelper;
use App\Services\Parts\PricedetailHelper;
use App\Services\AssemblySystem\AssemblyBuildPartHelper;
use App\Services\ProjectSystem\ProjectBuildPartHelper;
use App\Settings\BehaviorSettings\PartInfoSettings;
use DateTime;
@ -205,14 +207,17 @@ final class PartController extends AbstractController
#[Route(path: '/new', name: 'part_new')]
#[Route(path: '/{id}/clone', name: 'part_clone')]
#[Route(path: '/new_build_part/{project_id}', name: 'part_new_build_part')]
#[Route(path: '/new_build_part_assembly/{assembly_id}', name: 'part_new_build_part_assembly')]
public function new(
Request $request,
EntityManagerInterface $em,
TranslatorInterface $translator,
AttachmentSubmitHandler $attachmentSubmitHandler,
ProjectBuildPartHelper $projectBuildPartHelper,
AssemblyBuildPartHelper $assemblyBuildPartHelper,
#[MapEntity(mapping: ['id' => 'id'])] ?Part $part = null,
#[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null
#[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null,
#[MapEntity(mapping: ['assembly_id' => 'id'])] ?Assembly $assembly = null
): Response {
if ($part instanceof Part) {
@ -226,6 +231,14 @@ final class PartController extends AbstractController
return $this->redirectToRoute('part_edit', ['id' => $project->getBuildPart()->getID()]);
}
$new_part = $projectBuildPartHelper->getPartInitialization($project);
} elseif ($assembly instanceof Assembly) {
//Initialize a new part for a build part from the given assembly
//Ensure that the assembly has not already a build part
if ($project->getBuildPart() instanceof Part) {
$this->addFlash('error', 'part.new_build_part.error.build_part_already_exists');
return $this->redirectToRoute('part_edit', ['id' => $project->getBuildPart()->getID()]);
}
$new_part = $assemblyBuildPartHelper->getPartInitialization($assembly);
} else { //Create an empty part from scratch
$new_part = new Part();
}

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,7 +22,9 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Parameters\AbstractParameter;
use App\Services\Attachments\AssemblyPreviewGenerator;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
@ -53,6 +55,8 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Contracts\Translation\TranslatorInterface;
use InvalidArgumentException;
/**
* In this controller the endpoints for the typeaheads are collected.
@ -60,8 +64,11 @@ use Symfony\Component\Serializer\Serializer;
#[Route(path: '/typeahead')]
class TypeaheadController extends AbstractController
{
public function __construct(protected AttachmentURLGenerator $urlGenerator, protected Packages $assets)
{
public function __construct(
protected AttachmentURLGenerator $urlGenerator,
protected Packages $assets,
protected TranslatorInterface $translator
) {
}
#[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')]
@ -109,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) {
@ -147,6 +157,44 @@ class TypeaheadController extends AbstractController
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' => $this->translator->trans('typeahead.parts.assembly.name', ['%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

@ -0,0 +1,209 @@
<?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\PartDataTableHelper;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
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(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
{
}
public function configure(DataTable $dataTable, array $options): void
{
$dataTable
//->add('select', SelectColumn::class)
->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'),
'visible' => false,
])
->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) {
return htmlspecialchars((string) $context->getName());
}
//Part exists if we reach this point
$tmp = $this->partDataTableHelper->renderName($context->getPart());
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)',
'visible' => false,
'render' => function ($value, AssemblyBOMEntry $context) {
if($context->getPart() instanceof Part) {
return $context->getPart()->getIpn();
}
}
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('part.table.description'),
'data' => function (AssemblyBOMEntry $context) {
if($context->getPart() instanceof Part) {
return $context->getPart()->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('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'),
'visible' => false,
])
->add('lastModified', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.lastModified'),
'visible' => false,
])
;
$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
$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('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,48 @@
<?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\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) {
}
public function renderName(Assembly $context): string
{
$icon = '';
return sprintf(
'<a href="%s">%s%s</a>',
$this->entityURLGenerator->infoURL($context),
$icon,
htmlspecialchars($context->getName())
);
}
}

View file

@ -25,7 +25,9 @@ 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\PartDataTableHelper;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\ProjectBOMEntry;
@ -41,11 +43,15 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class ProjectBomEntriesDataTable implements DataTableTypeInterface
{
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
{
public function __construct(
protected TranslatorInterface $translator,
protected PartDataTableHelper $partDataTableHelper,
protected AssemblyDataTableHelper $assemblyDataTableHelper,
protected EntityURLGenerator $entityURLGenerator,
protected AmountFormatter $amountFormatter
) {
}
public function configure(DataTable $dataTable, array $options): void
{
$dataTable
@ -84,16 +90,26 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.name'),
'orderField' => 'NATSORT(part.name)',
'render' => function ($value, ProjectBOMEntry $context) {
if(!$context->getPart() instanceof Part) {
if(!$context->getPart() instanceof Part && !$context->getAssembly() instanceof Assembly) {
return htmlspecialchars((string) $context->getName());
}
//Part exists if we reach this point
if ($context->getPart() !== null) {
$tmp = $this->partDataTableHelper->renderName($context->getPart());
$tmp = $this->translator->trans('part.table.name.value.for_part', ['%value%' => $tmp]);
$tmp = $this->partDataTableHelper->renderName($context->getPart());
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
} elseif ($context->getAssembly() !== null) {
$tmp = $this->assemblyDataTableHelper->renderName($context->getAssembly());
$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;
},
])

View file

@ -0,0 +1,358 @@
<?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 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 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\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
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')]
#[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"])]
#[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]
#[Groups(['extended', 'full', 'import'])]
#[ORM\OneToMany(mappedBy: 'assembly', targetEntity: AssemblyBOMEntry::class, 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 Part|null The (optional) part that represents the builds of this assembly in the stock
*/
#[ORM\OneToOne(mappedBy: 'built_assembly', targetEntity: Part::class, cascade: ['persist'], orphanRemoval: true)]
#[Groups(['assembly:read', 'assembly:write'])]
protected ?Part $build_part = 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;
}
/**
* Checks if this assembly has an associated part representing the builds of this assembly in the stock.
*/
public function hasBuildPart(): bool
{
return $this->build_part instanceof Part;
}
/**
* Gets the part representing the builds of this assembly in the stock, if it is existing
*/
public function getBuildPart(): ?Part
{
return $this->build_part;
}
/**
* Sets the part representing the builds of this assembly in the stock.
*/
public function setBuildPart(?Part $build_part): void
{
$this->build_part = $build_part;
if ($build_part instanceof Part) {
$build_part->setBuiltAssembly($this);
}
}
#[Assert\Callback]
public function validate(ExecutionContextInterface $context, $payload): void
{
//If this assembly has subassemblies, and these have builds part, they must be included in the BOM
foreach ($this->getChildren() as $child) {
if (!$child->getBuildPart() instanceof Part) {
continue;
}
//We have to search all bom entries for the build part
$found = false;
foreach ($this->getBomEntries() as $bom_entry) {
if ($bom_entry->getPart() === $child->getBuildPart()) {
$found = true;
break;
}
}
//When the build part is not found, we have to add an error
if (!$found) {
$context->buildViolation('assembly.bom_has_to_include_all_subelement_parts')
->atPath('bom_entries')
->setParameter('%assembly_name%', $child->getName())
->setParameter('%part_name%', $child->getBuildPart()->getName())
->addViolation();
}
}
}
}

View file

@ -0,0 +1,302 @@
<?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\Entity\AssemblySystem\Assembly;
use App\Repository\DBElementRepository;
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", "comment", 'mountnames'])]
#[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|null An optional name describing this BOM entry (useful for non-part entries)
*/
#[Assert\Expression('this.getPart() !== 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 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;
}
/**
* @return string
*/
public function getName(): ?string
{
return $this->name;
}
/**
* @param string $name
*/
public function setName(?string $name): AssemblyBOMEntry
{
$this->name = $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;
}
/**
* 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;
}
#[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);
}
//Check that the part is not the build representation part of this assembly or one of its parents
if ($this->part && $this->part->getBuiltAssembly() instanceof Assembly) {
//Get the associated assembly
$associated_assembly = $this->part->getBuiltAssembly();
//Check that it is not the same as the current assembly neither one of its parents
$current_assembly = $this->assembly;
while ($current_assembly) {
if ($associated_assembly === $current_assembly) {
$context->buildViolation('assembly.bom_entry.can_not_add_own_builds_part')
->atPath('part')
->addViolation();
}
$current_assembly = $current_assembly->getParent();
}
}
}
public function getComparableFields(): array
{
return [
'name' => $this->getName(),
'part' => $this->getPart()?->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, 'Device' => ProjectAttachment::class,
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::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,8 @@ 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, "Project" => ProjectAttachment::class, "AttachmentType" => AttachmentTypeAttachment::class,
private const API_DISCRIMINATOR_MAP = ["Part" => PartAttachment::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,
"StorageLocation" => StorageLocationAttachment::class, "Supplier" => SupplierAttachment::class, "User" => UserAttachment::class, "LabelProfile" => LabelAttachment::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;
@ -68,7 +71,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
* Every database table which are managed with this class (or a subclass of it)
* must have the table row "id"!! The ID is the unique key to identify the elements.
*/
#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::class, 'project_attachment' => ProjectAttachment::class, 'storelocation_attachment' => StorageLocationAttachment::class, 'supplier_attachment' => SupplierAttachment::class, 'user_attachment' => UserAttachment::class, 'category' => Category::class, 'project' => Project::class, 'project_bom_entry' => ProjectBOMEntry::class, 'footprint' => Footprint::class, 'group' => Group::class, 'manufacturer' => Manufacturer::class, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => Pricedetail::class, 'storelocation' => StorageLocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => AbstractParameter::class, 'supplier' => Supplier::class, 'user' => User::class])]
#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::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, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => Pricedetail::class, 'storelocation' => StorageLocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => AbstractParameter::class, 'supplier' => Supplier::class, 'user' => User::class])]
#[ORM\MappedSuperclass(repositoryClass: DBElementRepository::class)]
abstract class AbstractDBElement implements JsonSerializable
{

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;
@ -58,6 +60,7 @@ use App\Entity\Attachments\UserAttachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\LogWithEventUndoInterface;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Parameters\AssemblyParameter;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parameters\AttachmentTypeParameter;
@ -147,6 +150,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,
@ -168,6 +172,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

@ -42,6 +42,8 @@ use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
@ -71,6 +73,8 @@ enum LogTargetType: int
case PART_ASSOCIATION = 20;
case BULK_INFO_PROVIDER_IMPORT_JOB = 21;
case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22;
case ASSEMBLY = 23;
case ASSEMBLY_BOM_ENTRY = 24;
/**
* Returns the class name of the target type or null if the target type is NONE.
@ -86,6 +90,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

@ -73,7 +73,7 @@ use function sprintf;
#[ORM\DiscriminatorMap([0 => CategoryParameter::class, 1 => CurrencyParameter::class, 2 => ProjectParameter::class,
3 => FootprintParameter::class, 4 => GroupParameter::class, 5 => ManufacturerParameter::class,
6 => MeasurementUnitParameter::class, 7 => PartParameter::class, 8 => StorageLocationParameter::class,
9 => SupplierParameter::class, 10 => AttachmentTypeParameter::class])]
9 => SupplierParameter::class, 10 => AttachmentTypeParameter::class, 11 => AssemblyParameter::class])]
#[ORM\Table('parameters')]
#[ORM\Index(columns: ['name'], name: 'parameter_name_idx')]
#[ORM\Index(columns: ['param_group'], name: 'parameter_group_idx')]
@ -103,7 +103,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];

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

@ -54,6 +54,7 @@ use App\Entity\Parts\PartTraits\InstockTrait;
use App\Entity\Parts\PartTraits\ManufacturerTrait;
use App\Entity\Parts\PartTraits\OrderTrait;
use App\Entity\Parts\PartTraits\ProjectTrait;
use App\Entity\Parts\PartTraits\AssemblyTrait;
use App\EntityListeners\TreeCacheInvalidationListener;
use App\Repository\PartRepository;
use App\Validator\Constraints\UniqueObjectCollection;
@ -125,6 +126,7 @@ class Part extends AttachmentContainingDBElement
use OrderTrait;
use ParametersTrait;
use ProjectTrait;
use AssemblyTrait;
use AssociationTrait;
use EDATrait;
@ -186,6 +188,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,83 @@
<?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;
use Symfony\Component\Serializer\Annotation\Groups;
trait AssemblyTrait
{
/**
* @var Collection<AssemblyBOMEntry> $assembly_bom_entries
*/
#[ORM\OneToMany(mappedBy: 'part', targetEntity: AssemblyBOMEntry::class, cascade: ['remove'], orphanRemoval: true)]
protected Collection $assembly_bom_entries;
/**
* @var Assembly|null If a assembly is set here, then this part is special and represents the builds of an assembly.
*/
#[ORM\OneToOne(inversedBy: 'build_part', targetEntity: Assembly::class)]
#[ORM\JoinColumn]
protected ?Assembly $built_assembly = null;
/**
* Returns all AssemblyBOMEntry that use this part.
*
* @phpstan-return Collection<int, AssemblyBOMEntry>
*/
public function getAssemblyBomEntries(): Collection
{
return $this->assembly_bom_entries;
}
/**
* Checks whether this part represents the builds of a assembly
* @return bool True if it represents the builds, false if not
*/
#[Groups(['part:read'])]
public function isAssemblyBuildPart(): bool
{
return $this->built_assembly !== null;
}
/**
* Returns the assembly that this part represents the builds of, or null if it doesn't
*/
public function getBuiltAssembly(): ?Assembly
{
return $this->built_assembly;
}
/**
* Sets the assembly that this part represents the builds of
* @param Assembly|null $built_assembly The assembly that this part represents the builds of, or null if it is not a build part
*/
public function setBuiltAssembly(?Assembly $built_assembly): self
{
$this->built_assembly = $built_assembly;
return $this;
}
/**
* 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

@ -108,6 +108,7 @@ class Project extends AbstractStructuralDBElement
#[Groups(['extended', 'full', 'import'])]
#[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectBOMEntry::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[UniqueObjectCollection(message: 'project.bom_entry.part_already_in_bom', fields: ['part'])]
#[UniqueObjectCollection(message: 'project.bom_entry.assembly_already_in_bom', fields: ['assembly'])]
#[UniqueObjectCollection(message: 'project.bom_entry.name_already_in_bom', fields: ['name'])]
protected Collection $bom_entries;

View file

@ -35,6 +35,7 @@ use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Contracts\TimeStampableInterface;
use App\Validator\UniqueValidatableInterface;
use Doctrine\DBAL\Types\Types;
@ -103,7 +104,10 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
/**
* @var string|null An optional name describing this BOM entry (useful for non-part entries)
*/
#[Assert\Expression('this.getPart() !== null or this.getName() !== null', message: 'validator.project.bom_entry.name_or_part_needed')]
#[Assert\Expression(
'this.getPart() !== null or this.getAssembly() !== null or (this.getName() !== null and this.getName() != "")',
message: 'validator.project.bom_entry.part_or_assembly_needed'
)]
#[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
protected ?string $name = null;
@ -131,6 +135,18 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
#[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.getAssembly() === null) and (this.getName() === null or (this.getName() != null and this.getName() != ""))',
message: 'validator.project.bom_entry.only_part_or_assembly_allowed'
)]
#[ORM\ManyToOne(targetEntity: Assembly::class, inversedBy: 'assembly_bom_entries')]
#[ORM\JoinColumn(name: 'id_assembly')]
#[Groups(['bom_entry:read', 'bom_entry:write', ])]
protected ?Assembly $assembly = null;
/**
* @var BigDecimal|null The price of this non-part BOM entry
*/
@ -212,8 +228,6 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
return $this;
}
public function getPart(): ?Part
{
return $this->part;
@ -225,6 +239,16 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
return $this;
}
public function getAssembly(): ?Assembly
{
return $this->assembly;
}
public function setAssembly(?Assembly $assembly): void
{
$this->assembly = $assembly;
}
/**
* Returns the price of this BOM entry, if existing.
* Prices are only valid on non-Part BOM entries.
@ -262,6 +286,15 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
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->assembly instanceof Assembly;
}
#[Assert\Callback]
public function validate(ExecutionContextInterface $context, $payload): void
{
@ -323,6 +356,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
return [
'name' => $this->getName(),
'part' => $this->getPart()?->getID(),
'assembly' => $this->getAssembly()?->getID(),
];
}
}

View file

@ -0,0 +1,64 @@
<?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 Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
class AssemblyAdminForm extends BaseEntityAdminForm
{
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',
],
]);
}
}

View file

@ -22,6 +22,7 @@ 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;
@ -114,7 +115,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,88 @@
<?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', 'assembly'], message: 'assembly.bom_entry.part_already_in_bom',
entityClass: AssemblyBOMEntry::class),
new UniqueEntity(fields: ['name', 'assembly'], 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.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,90 @@
<?php
declare(strict_types=1);
namespace App\Form\AssemblySystem;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
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('name', TextType::class, [
'label' => 'assembly.bom.name',
'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,183 @@
<?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\Helpers\Assemblies\AssemblyBuildRequest;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Form\Type\PartLotSelectType;
use App\Form\Type\SIUnitType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AssemblyBuildType extends AbstractType implements DataMapperInterface
{
public function __construct(private readonly Security $security)
{
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => AssemblyBuildRequest::class
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->setDataMapper($this);
$builder->add('submit', SubmitType::class, [
'label' => 'assembly.build.btn_build',
'disabled' => !$this->security->isGranted('@parts_stock.withdraw'),
]);
$builder->add('dontCheckQuantity', CheckboxType::class, [
'label' => 'assembly.build.dont_check_quantity',
'help' => 'assembly.build.dont_check_quantity.help',
'required' => false,
'attr' => [
'data-controller' => 'pages--dont-check-quantity-checkbox'
]
]);
$builder->add('comment', TextType::class, [
'label' => 'part.info.withdraw_modal.comment',
'help' => 'part.info.withdraw_modal.comment.hint',
'empty_data' => '',
'required' => false,
]);
//The form is initially empty, we have to define the fields after we know the data
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) {
$form = $event->getForm();
/** @var AssemblyBuildRequest $build_request */
$build_request = $event->getData();
$form->add('addBuildsToBuildsPart', CheckboxType::class, [
'label' => 'assembly.build.add_builds_to_builds_part',
'required' => false,
'disabled' => !$build_request->getAssembly()->getBuildPart() instanceof Part,
]);
if ($build_request->getAssembly()->getBuildPart() instanceof Part) {
$form->add('buildsPartLot', PartLotSelectType::class, [
'label' => 'assembly.build.builds_part_lot',
'required' => false,
'part' => $build_request->getAssembly()->getBuildPart(),
'placeholder' => 'assembly.build.buildsPartLot.new_lot'
]);
}
foreach ($build_request->getPartBomEntries() as $bomEntry) {
//Every part lot has a field to specify the number of parts to take from this lot
foreach ($build_request->getPartLotsForBOMEntry($bomEntry) as $lot) {
$form->add('lot_' . $lot->getID(), SIUnitType::class, [
'label' => false,
'measurement_unit' => $bomEntry->getPart()->getPartUnit(),
'max' => min($build_request->getNeededAmountForBOMEntry($bomEntry), $lot->getAmount()),
'disabled' => !$this->security->isGranted('withdraw', $lot),
]);
}
}
});
}
public function mapDataToForms($data, \Traversable $forms): void
{
if (!$data instanceof AssemblyBuildRequest) {
throw new \RuntimeException('Data must be an instance of ' . AssemblyBuildRequest::class);
}
/** @var FormInterface[] $forms */
$forms = iterator_to_array($forms);
foreach ($forms as $key => $form) {
//Extract the lot id from the form name
$matches = [];
if (preg_match('/^lot_(\d+)$/', $key, $matches)) {
$lot_id = (int) $matches[1];
$form->setData($data->getLotWithdrawAmount($lot_id));
}
}
$forms['comment']->setData($data->getComment());
$forms['dontCheckQuantity']->setData($data->isDontCheckQuantity());
$forms['addBuildsToBuildsPart']->setData($data->getAddBuildsToBuildsPart());
if (isset($forms['buildsPartLot'])) {
$forms['buildsPartLot']->setData($data->getBuildsPartLot());
}
}
public function mapFormsToData(\Traversable $forms, &$data): void
{
if (!$data instanceof AssemblyBuildRequest) {
throw new \RuntimeException('Data must be an instance of ' . AssemblyBuildRequest::class);
}
/** @var FormInterface[] $forms */
$forms = iterator_to_array($forms);
foreach ($forms as $key => $form) {
//Extract the lot id from the form name
$matches = [];
if (preg_match('/^lot_(\d+)$/', $key, $matches)) {
$lot_id = (int) $matches[1];
$data->setLotWithdrawAmount($lot_id, (float) $form->getData());
}
}
$data->setComment($forms['comment']->getData());
$data->setDontCheckQuantity($forms['dontCheckQuantity']->getData());
if (isset($forms['buildsPartLot'])) {
$lot = $forms['buildsPartLot']->getData();
if (!$lot) { //When the user selected "Create new lot", create a new lot
$lot = new PartLot();
$description = 'Build ' . date('Y-m-d H:i:s');
if ($data->getComment() !== '') {
$description .= ' (' . $data->getComment() . ')';
}
$lot->setDescription($description);
$data->getAssembly()->getBuildPart()->addPartLot($lot);
}
$data->setBuildsPartLot($lot);
}
//This has to be set after the builds part lot, so that it can disable the option
$data->setAddBuildsToBuildsPart($forms['addBuildsToBuildsPart']->getData());
}
}

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

@ -59,6 +59,7 @@ class ProjectAddPartsType extends AbstractType
],
'constraints' => [
new UniqueObjectCollection(message: 'project.bom_entry.part_already_in_bom', fields: ['part']),
new UniqueObjectCollection(message: 'project.bom_entry.assembly_already_in_bom', fields: ['assembly']),
new UniqueObjectCollection(message: 'project.bom_entry.name_already_in_bom', fields: ['name']),
]
]);

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Form\ProjectSystem;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Form\Type\AssemblySelectType;
use App\Form\Type\BigDecimalNumberType;
use App\Form\Type\CurrencyEntityType;
use App\Form\Type\PartSelectType;
@ -22,8 +23,6 @@ class ProjectBOMEntryType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) {
$form = $event->getForm();
/** @var ProjectBOMEntry $data */
@ -36,11 +35,14 @@ class ProjectBOMEntryType extends AbstractType
});
$builder
->add('part', PartSelectType::class, [
'label' => 'project.bom.part',
'required' => false,
])
->add('assembly', AssemblySelectType::class, [
'label' => 'project.bom.assembly',
'required' => false,
])
->add('name', TextType::class, [
'label' => 'project.bom.name',
'required' => false,
@ -77,10 +79,7 @@ class ProjectBOMEntryType extends AbstractType
'required' => false,
'label' => false,
'short' => true,
])
;
]);
}
public function configureOptions(OptionsResolver $resolver): void

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\Form\ProjectSystem;
use App\Helpers\Assemblies\AssemblyBuildRequest;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
@ -38,10 +39,11 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
class ProjectBuildType extends AbstractType implements DataMapperInterface
{
public function __construct(private readonly Security $security)
public function __construct(private readonly Security $security, private readonly TranslatorInterface $translator)
{
}
@ -82,36 +84,54 @@ class ProjectBuildType extends AbstractType implements DataMapperInterface
//The form is initially empty, we have to define the fields after we know the data
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) {
$form = $event->getForm();
/** @var ProjectBuildRequest $build_request */
$build_request = $event->getData();
/** @var ProjectBuildRequest $projectBuildRequest */
$projectBuildRequest = $event->getData();
$form->add('addBuildsToBuildsPart', CheckboxType::class, [
'label' => 'project.build.add_builds_to_builds_part',
'required' => false,
'disabled' => !$build_request->getProject()->getBuildPart() instanceof Part,
'disabled' => !$projectBuildRequest->getProject()->getBuildPart() instanceof Part,
]);
if ($build_request->getProject()->getBuildPart() instanceof Part) {
if ($projectBuildRequest->getProject()->getBuildPart() instanceof Part) {
$form->add('buildsPartLot', PartLotSelectType::class, [
'label' => 'project.build.builds_part_lot',
'required' => false,
'part' => $build_request->getProject()->getBuildPart(),
'part' => $projectBuildRequest->getProject()->getBuildPart(),
'placeholder' => 'project.build.buildsPartLot.new_lot'
]);
}
foreach ($build_request->getPartBomEntries() as $bomEntry) {
foreach ($projectBuildRequest->getPartBomEntries() as $bomEntry) {
//Every part lot has a field to specify the number of parts to take from this lot
foreach ($build_request->getPartLotsForBOMEntry($bomEntry) as $lot) {
foreach ($projectBuildRequest->getPartLotsForBOMEntry($bomEntry) as $lot) {
$form->add('lot_' . $lot->getID(), SIUnitType::class, [
'label' => false,
'measurement_unit' => $bomEntry->getPart()->getPartUnit(),
'max' => min($build_request->getNeededAmountForBOMEntry($bomEntry), $lot->getAmount()),
'max' => min($projectBuildRequest->getNeededAmountForBOMEntry($bomEntry), $lot->getAmount()),
'disabled' => !$this->security->isGranted('withdraw', $lot),
]);
}
}
foreach ($projectBuildRequest->getAssemblyBomEntries() as $bomEntry) {
$assemblyBuildRequest = new AssemblyBuildRequest($bomEntry->getAssembly(), $projectBuildRequest->getNumberOfBuilds());
//Add fields for assembly bom entries
foreach ($assemblyBuildRequest->getPartBomEntries() as $partBomEntry) {
foreach ($assemblyBuildRequest->getPartLotsForBOMEntry($partBomEntry) as $lot) {
$form->add('lot_' . $lot->getID(), SIUnitType::class, [
'label' => $this->translator->trans('project.build.builds_part_lot_label', [
'%name%' => $partBomEntry->getPart()->getName(),
'%quantity%' => $partBomEntry->getQuantity() * $projectBuildRequest->getNumberOfBuilds()
]),
'measurement_unit' => $partBomEntry->getPart()->getPartUnit(),
'max' => min($assemblyBuildRequest->getNeededAmountForBOMEntry($partBomEntry), $lot->getAmount()),
'disabled' => !$this->security->isGranted('withdraw', $lot),
]);
}
}
}
});
}

View file

@ -0,0 +1,125 @@
<?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',
'placeholder' => 'None',
'compound' => true,
'error_bubbling' => false,
]);
error_log($this->urlGenerator->generate('typeahead_assemblies', ['query' => '__QUERY__']));
$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
@ -84,7 +84,6 @@ class PartSelectType extends AbstractType implements DataMapperInterface
'data-autocomplete' => $this->urlGenerator->generate('typeahead_parts', ['query' => '__QUERY__']),
//Disable browser autocomplete
'autocomplete' => 'off',
],
]);
@ -103,7 +102,7 @@ class PartSelectType extends AbstractType implements DataMapperInterface
}
return $part instanceof Part ? [
'data-description' => mb_strimwidth($part->getDescription(), 0, 127, '...'),
'data-description' => $part->getDescription() ? mb_strimwidth($part->getDescription(), 0, 127, '...') : '',
'data-category' => $part->getCategory() instanceof Category ? $part->getCategory()->getName() : '',
'data-footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '',
'data-image' => $preview_url,

View file

@ -0,0 +1,306 @@
<?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\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Validator\Constraints\AssemblySystem\ValidAssemblyBuildRequest;
/**
* @see \App\Tests\Helpers\Assemblies\AssemblyBuildRequestTest
*/
#[ValidAssemblyBuildRequest]
final class AssemblyBuildRequest
{
private readonly int $number_of_builds;
/**
* @var array<int, float>
*/
private array $withdraw_amounts = [];
private string $comment = '';
private ?PartLot $builds_lot = null;
private bool $add_build_to_builds_part = false;
private bool $dont_check_quantity = false;
/**
* @param Assembly $assembly The assembly that should be build
* @param int $number_of_builds The number of builds that should be created
*/
public function __construct(private readonly Assembly $assembly, int $number_of_builds)
{
if ($number_of_builds < 1) {
throw new \InvalidArgumentException('Number of builds must be at least 1!');
}
$this->number_of_builds = $number_of_builds;
$this->initializeArray();
//By default, use the first available lot of builds part if there is one.
if($assembly->getBuildPart() instanceof Part) {
$this->add_build_to_builds_part = true;
foreach( $assembly->getBuildPart()->getPartLots() as $lot) {
if (!$lot->isInstockUnknown()) {
$this->builds_lot = $lot;
break;
}
}
}
}
private function initializeArray(): void
{
//Completely reset the array
$this->withdraw_amounts = [];
//Now create an array for each BOM entry
foreach ($this->getPartBomEntries() as $bom_entry) {
$remaining_amount = $this->getNeededAmountForBOMEntry($bom_entry);
foreach($this->getPartLotsForBOMEntry($bom_entry) as $lot) {
//If the lot has instock use it for the build
$this->withdraw_amounts[$lot->getID()] = min($remaining_amount, $lot->getAmount());
$remaining_amount -= max(0, $this->withdraw_amounts[$lot->getID()]);
}
}
}
/**
* Ensure that the assemblyBOMEntry belongs to the assembly, otherwise throw an exception.
*/
private function ensureBOMEntryValid(AssemblyBOMEntry $entry): void
{
if ($entry->getAssembly() !== $this->assembly) {
throw new \InvalidArgumentException('The given BOM entry does not belong to the assembly!');
}
}
/**
* Returns the partlot where the builds should be added to, or null if it should not be added to any lot.
*/
public function getBuildsPartLot(): ?PartLot
{
return $this->builds_lot;
}
/**
* Return if the builds should be added to the builds part of this assembly as new stock
*/
public function getAddBuildsToBuildsPart(): bool
{
return $this->add_build_to_builds_part;
}
/**
* Set if the builds should be added to the builds part of this assembly as new stock
* @return $this
*/
public function setAddBuildsToBuildsPart(bool $new_value): self
{
$this->add_build_to_builds_part = $new_value;
if ($new_value === false) {
$this->builds_lot = null;
}
return $this;
}
/**
* Set the partlot where the builds should be added to, or null if it should not be added to any lot.
* The part lot must belong to the assembly build part, or an exception is thrown!
* @return $this
*/
public function setBuildsPartLot(?PartLot $new_part_lot): self
{
//Ensure that this new_part_lot belongs to the assembly
if (($new_part_lot instanceof PartLot && $new_part_lot->getPart() !== $this->assembly->getBuildPart()) || !$this->assembly->getBuildPart() instanceof Part) {
throw new \InvalidArgumentException('The given part lot does not belong to the assemblies build part!');
}
if ($new_part_lot instanceof PartLot) {
$this->setAddBuildsToBuildsPart(true);
}
$this->builds_lot = $new_part_lot;
return $this;
}
/**
* Returns the comment where the user can write additional information about the build.
*/
public function getComment(): string
{
return $this->comment;
}
/**
* Sets the comment where the user can write additional information about the build.
*/
public function setComment(string $comment): void
{
$this->comment = $comment;
}
/**
* Returns the amount of parts that should be withdrawn from the given lot for the corresponding BOM entry.
* @param PartLot|int $lot The part lot (or the ID of the part lot) for which the withdrawal amount should be got
*/
public function getLotWithdrawAmount(PartLot|int $lot): float
{
$lot_id = $lot instanceof PartLot ? $lot->getID() : $lot;
if (! array_key_exists($lot_id, $this->withdraw_amounts)) {
throw new \InvalidArgumentException('The given lot is not in the withdraw amounts array!');
}
return $this->withdraw_amounts[$lot_id];
}
/**
* Sets the amount of parts that should be withdrawn from the given lot for the corresponding BOM entry.
* @param PartLot|int $lot The part lot (or the ID of the part lot) for which the withdrawal amount should be got
* @return $this
*/
public function setLotWithdrawAmount(PartLot|int $lot, float $amount): self
{
if ($lot instanceof PartLot) {
$lot_id = $lot->getID();
} elseif (is_int($lot)) {
$lot_id = $lot;
} else {
throw new \InvalidArgumentException('The given lot must be an instance of PartLot or an ID of a PartLot!');
}
$this->withdraw_amounts[$lot_id] = $amount;
return $this;
}
/**
* Returns the sum of all withdraw amounts for the given BOM entry.
*/
public function getWithdrawAmountSum(AssemblyBOMEntry $entry): float
{
$this->ensureBOMEntryValid($entry);
$sum = 0;
foreach ($this->getPartLotsForBOMEntry($entry) as $lot) {
$sum += $this->getLotWithdrawAmount($lot);
}
if ($entry->getPart() && !$entry->getPart()->useFloatAmount()) {
$sum = round($sum);
}
return $sum;
}
/**
* Returns the number of available lots to take stock from for the given BOM entry.
* @return PartLot[]|null Returns null if the entry is a non-part BOM entry
*/
public function getPartLotsForBOMEntry(AssemblyBOMEntry $assemblyBOMEntry): ?array
{
$this->ensureBOMEntryValid($assemblyBOMEntry);
if (!$assemblyBOMEntry->getPart() instanceof Part) {
return null;
}
//Filter out all lots which have unknown instock
return $assemblyBOMEntry->getPart()->getPartLots()->filter(fn (PartLot $lot) => !$lot->isInstockUnknown())->toArray();
}
/**
* Returns the needed amount of parts for the given BOM entry.
*/
public function getNeededAmountForBOMEntry(AssemblyBOMEntry $entry): float
{
$this->ensureBOMEntryValid($entry);
return $entry->getQuantity() * $this->number_of_builds;
}
/**
* Returns the list of all bom entries.
* @return AssemblyBOMEntry[]
*/
public function getBomEntries(): array
{
return $this->assembly->getBomEntries()->toArray();
}
/**
* Returns all part bom entries.
* @return AssemblyBOMEntry[]
*/
public function getPartBomEntries(): array
{
return $this->assembly->getBomEntries()->filter(fn(AssemblyBOMEntry $entry) => $entry->isPartBomEntry())->toArray();
}
/**
* Returns which assembly should be build
*/
public function getAssembly(): Assembly
{
return $this->assembly;
}
/**
* Returns the number of builds that should be created.
*/
public function getNumberOfBuilds(): int
{
return $this->number_of_builds;
}
/**
* If Set to true, the given withdraw amounts are used without any checks for requirements.
* @return bool
*/
public function isDontCheckQuantity(): bool
{
return $this->dont_check_quantity;
}
/**
* Set to true, the given withdraw amounts are used without any checks for requirements.
* @param bool $dont_check_quantity
* @return $this
*/
public function setDontCheckQuantity(bool $dont_check_quantity): AssemblyBuildRequest
{
$this->dont_check_quantity = $dont_check_quantity;
return $this;
}
}

View file

@ -22,10 +22,13 @@ declare(strict_types=1);
*/
namespace App\Helpers\Projects;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Helpers\Assemblies\AssemblyBuildRequest;
use App\Validator\Constraints\ProjectSystem\ValidProjectBuildRequest;
/**
@ -79,7 +82,7 @@ final class ProjectBuildRequest
//Completely reset the array
$this->withdraw_amounts = [];
//Now create an array for each BOM entry
//Now create an array for each part BOM entry
foreach ($this->getPartBomEntries() as $bom_entry) {
$remaining_amount = $this->getNeededAmountForBOMEntry($bom_entry);
foreach($this->getPartLotsForBOMEntry($bom_entry) as $lot) {
@ -88,6 +91,21 @@ final class ProjectBuildRequest
$remaining_amount -= max(0, $this->withdraw_amounts[$lot->getID()]);
}
}
//Now create an array for each assembly BOM entry
foreach ($this->getAssemblyBomEntries() as $assemblyBomEntry) {
$assemblyBuildRequest = new AssemblyBuildRequest($assemblyBomEntry->getAssembly(), $this->number_of_builds);
//Add fields for assembly bom entries
foreach ($assemblyBuildRequest->getPartBomEntries() as $partBomEntry) {
$remaining_amount = $assemblyBuildRequest->getNeededAmountForBOMEntry($partBomEntry) * $assemblyBomEntry->getQuantity();
foreach ($assemblyBuildRequest->getPartLotsForBOMEntry($partBomEntry) as $lot) {
$this->withdraw_amounts[$lot->getID()] = min($remaining_amount, $lot->getAmount());
$remaining_amount -= max(0, $this->withdraw_amounts[$lot->getID()]);
}
}
}
}
/**
@ -230,12 +248,77 @@ final class ProjectBuildRequest
{
$this->ensureBOMEntryValid($projectBOMEntry);
if (!$projectBOMEntry->getPart() instanceof Part) {
if (!$projectBOMEntry->getPart() instanceof Part && !$projectBOMEntry->getAssembly() instanceof Assembly) {
return null;
}
//Filter out all lots which have unknown instock
return $projectBOMEntry->getPart()->getPartLots()->filter(fn (PartLot $lot) => !$lot->isInstockUnknown())->toArray();
if ($projectBOMEntry->getPart() instanceof Part) {
return $projectBOMEntry->getPart()->getPartLots()->filter(fn (PartLot $lot) => !$lot->isInstockUnknown())->toArray();
} elseif ($projectBOMEntry->getAssembly() instanceof Assembly) {
$assemblyBuildRequest = new AssemblyBuildRequest($projectBOMEntry->getAssembly(), $this->number_of_builds);
//Add fields for assembly bom entries
$result = [];
foreach ($assemblyBuildRequest->getPartBomEntries() as $assemblyBOMEntry) {
$tmp = $assemblyBOMEntry->getPart()->getPartLots()->filter(fn (PartLot $lot) => !$lot->isInstockUnknown())->toArray();
$result = array_merge($result, $tmp);
}
return $result;
}
return null;
}
/**
* Returns all available assembly BOM-entries with no part assigned.
* @return AssemblyBOMEntry[]|null Returns null if no entries found
*/
public function getAssemblyBomEntriesWithoutPart(ProjectBOMEntry $projectBOMEntry): ?array
{
$this->ensureBOMEntryValid($projectBOMEntry);
if (!$projectBOMEntry->getAssembly() instanceof Assembly) {
return null;
}
$assemblyBuildRequest = new AssemblyBuildRequest($projectBOMEntry->getAssembly(), $this->number_of_builds);
$result = [];
foreach ($assemblyBuildRequest->getBomEntries() as $assemblyBOMEntry) {
if ($assemblyBOMEntry->getPart() === null) {
$result[] = $assemblyBOMEntry;
}
}
return count($result) > 0 ? $result : null;
}
/**
* Returns all available assembly BOM-entries with no part assigned.
* @return AssemblyBOMEntry[]|null Returns null if no entries found
*/
public function getAssemblyBomEntriesWithPartNoStock(ProjectBOMEntry $projectBOMEntry): ?array
{
$this->ensureBOMEntryValid($projectBOMEntry);
if (!$projectBOMEntry->getAssembly() instanceof Assembly) {
return null;
}
$assemblyBuildRequest = new AssemblyBuildRequest($projectBOMEntry->getAssembly(), $this->number_of_builds);
$result = [];
foreach ($assemblyBuildRequest->getBomEntries() as $assemblyBOMEntry) {
if ($assemblyBOMEntry->getPart() instanceof Part && $assemblyBOMEntry->getPart()->getPartLots()->filter(fn (PartLot $lot) => !$lot->isInstockUnknown())->count() === 0) {
$result[] = $assemblyBOMEntry;
}
}
return count($result) > 0 ? $result : null;
}
/**
@ -266,6 +349,15 @@ final class ProjectBuildRequest
return $this->project->getBomEntries()->filter(fn(ProjectBOMEntry $entry) => $entry->isPartBomEntry())->toArray();
}
/**
* Returns the all assembly bom entries that have to be built.
* @return ProjectBOMEntry[]
*/
public function getAssemblyBomEntries(): array
{
return $this->project->getBomEntries()->filter(fn(ProjectBOMEntry $entry) => $entry->isAssemblyBomEntry())->toArray();
}
/**
* Returns which project should be build
*/
@ -301,6 +393,4 @@ final class ProjectBuildRequest
$this->dont_check_quantity = $dont_check_quantity;
return $this;
}
}
}

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 DBElementRepository<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

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Attachments\AssemblyAttachment;
use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Attachments\AttachmentContainingDBElement;
@ -89,6 +90,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\ProjectSystem\Project;
use App\Entity\Parts\Category;
@ -47,6 +48,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,154 @@
<?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\Services\AssemblySystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Part;
use App\Helpers\Assemblies\AssemblyBuildRequest;
use App\Services\Parts\PartLotWithdrawAddHelper;
/**
* @see \App\Tests\Services\AssemblySystem\AssemblyBuildHelperTest
*/
class AssemblyBuildHelper
{
public function __construct(private readonly PartLotWithdrawAddHelper $withdraw_add_helper)
{
}
/**
* Returns the maximum buildable amount of the given BOM entry based on the stock of the used parts.
* This function only works for BOM entries that are associated with a part.
*/
public function getMaximumBuildableCountForBOMEntry(AssemblyBOMEntry $assemblyBOMEntry): int
{
$part = $assemblyBOMEntry->getPart();
if (!$part instanceof Part) {
throw new \InvalidArgumentException('This function cannot determine the maximum buildable count for a BOM entry without a part!');
}
if ($assemblyBOMEntry->getQuantity() <= 0) {
throw new \RuntimeException('The quantity of the BOM entry must be greater than 0!');
}
$amount_sum = $part->getAmountSum();
return (int) floor($amount_sum / $assemblyBOMEntry->getQuantity());
}
/**
* Returns the maximum buildable amount of the given assembly, based on the stock of the used parts in the BOM.
*/
public function getMaximumBuildableCount(Assembly $assembly): int
{
$maximum_buildable_count = PHP_INT_MAX;
foreach ($assembly->getBomEntries() as $bom_entry) {
//Skip BOM entries without a part (as we can not determine that)
if (!$bom_entry->isPartBomEntry()) {
continue;
}
//The maximum buildable count for the whole assembly is the minimum of all BOM entries
$maximum_buildable_count = min($maximum_buildable_count, $this->getMaximumBuildableCountForBOMEntry($bom_entry));
}
return $maximum_buildable_count;
}
/**
* Checks if the given assembly can be built with the current stock.
* This means that the maximum buildable count is greater or equal than the requested $number_of_assemblies
* @param int $number_of_builds
*/
public function isAssemblyBuildable(Assembly $assembly, int $number_of_builds = 1): bool
{
return $this->getMaximumBuildableCount($assembly) >= $number_of_builds;
}
/**
* Check if the given BOM entry can be built with the current stock.
* This means that the maximum buildable count is greater or equal than the requested $number_of_assemblies
*/
public function isBOMEntryBuildable(AssemblyBOMEntry $bom_entry, int $number_of_builds = 1): bool
{
return $this->getMaximumBuildableCountForBOMEntry($bom_entry) >= $number_of_builds;
}
/**
* Returns the assembly BOM entries for which parts are missing in the stock for the given number of builds
* @param Assembly $assembly The assembly for which the BOM entries should be checked
* @param int $number_of_builds How often should the assembly be build?
* @return AssemblyBOMEntry[]
*/
public function getNonBuildableAssemblyBomEntries(Assembly $assembly, int $number_of_builds = 1): array
{
if ($number_of_builds < 1) {
throw new \InvalidArgumentException('The number of builds must be greater than 0!');
}
$non_buildable_entries = [];
foreach ($assembly->getBomEntries() as $bomEntry) {
$part = $bomEntry->getPart();
//Skip BOM entries without a part (as we can not determine that)
if (!$part instanceof Part) {
continue;
}
$amount_sum = $part->getAmountSum();
if ($amount_sum < $bomEntry->getQuantity() * $number_of_builds) {
$non_buildable_entries[] = $bomEntry;
}
}
return $non_buildable_entries;
}
/**
* Withdraw the parts from the stock using the given AssemblyBuildRequest and create the build parts entries, if needed.
* The AssemblyBuildRequest has to be validated before!!
* You have to flush changes to DB afterward
*/
public function doBuild(AssemblyBuildRequest $buildRequest): void
{
$message = $buildRequest->getComment();
$message .= ' (Assembly build: '.$buildRequest->getAssembly()->getName().')';
foreach ($buildRequest->getPartBomEntries() as $bom_entry) {
foreach ($buildRequest->getPartLotsForBOMEntry($bom_entry) as $part_lot) {
$amount = $buildRequest->getLotWithdrawAmount($part_lot);
if ($amount > 0) {
$this->withdraw_add_helper->withdraw($part_lot, $amount, $message);
}
}
}
if ($buildRequest->getAddBuildsToBuildsPart()) {
$this->withdraw_add_helper->add($buildRequest->getBuildsPartLot(), $buildRequest->getNumberOfBuilds(), $message);
}
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services\AssemblySystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Parts\Part;
/**
* @see \App\Tests\Services\AssemblySystem\AssemblyBuildPartHelperTest
*/
class AssemblyBuildPartHelper
{
/**
* Returns a part that represents the builds of a assembly. This part is not saved to the database, and can be used
* as initial data for the new part form.
*/
public function getPartInitialization(Assembly $assembly): Part
{
$part = new Part();
//Associate the part with the assembly
$part->setBuiltAssembly($assembly);
//Set the name of the part to the name of the assembly
$part->setName($assembly->getName());
//Set the description of the part to the description of the assembly
$part->setDescription($assembly->getDescription());
//Add a tag to the part that indicates that it is a build part
$part->setTags('assembly-build');
//Associate the part with the assembly
$assembly->setBuildPart($part);
return $part;
}
}

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;
@ -84,6 +85,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

@ -45,6 +45,8 @@ use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Exceptions\EntityNotSupportedException;
@ -66,6 +68,8 @@ class ElementTypeNameGenerator
AttachmentType::class => $this->translator->trans('attachment_type.label'),
Project::class => $this->translator->trans('project.label'),
ProjectBOMEntry::class => $this->translator->trans('project_bom_entry.label'),
Assembly::class => $this->translator->trans('assembly.label'),
AssemblyBOMEntry::class => $this->translator->trans('assembly_bom_entry.label'),
Footprint::class => $this->translator->trans('footprint.label'),
Manufacturer::class => $this->translator->trans('manufacturer.label'),
MeasurementUnit::class => $this->translator->trans('measurement_unit.label'),
@ -182,6 +186,8 @@ 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;
@ -98,6 +99,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',
@ -204,6 +206,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',
@ -234,6 +237,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',
@ -265,6 +269,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',
@ -296,6 +301,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',
@ -323,6 +329,7 @@ class EntityURLGenerator
{
$map = [
Project::class => 'project_info',
Assembly::class => 'assembly_info',
Category::class => 'part_list_category',
Footprint::class => 'part_list_footprint',
@ -341,6 +348,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,15 +22,25 @@ 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\OptionsResolver\OptionsResolver;
use RuntimeException;
use UnexpectedValueException;
/**
* @see \App\Tests\Services\ImportExportSystem\BOMImporterTest
@ -47,17 +57,29 @@ class BOMImporter
5 => 'Supplier and ref',
];
private readonly PartRepository $partRepository;
private readonly ManufacturerRepository $manufacturerRepository;
private readonly CategoryRepository $categoryRepository;
private readonly DBElementRepository $assemblyBOMEntryRepository;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly BOMValidationService $validationService
) {
$this->partRepository = $entityManager->getRepository(Part::class);
$this->manufacturerRepository = $entityManager->getRepository(Manufacturer::class);
$this->categoryRepository = $entityManager->getRepository(Category::class);
$this->assemblyBOMEntryRepository = $entityManager->getRepository(AssemblyBOMEntry::class);
}
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
{
$resolver->setRequired('type');
$resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']);
$resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic', 'json']);
// For flexible schematic import with field mapping
$resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']);
@ -88,12 +110,29 @@ class BOMImporter
}
/**
* Converts the given file into an array of BOM entries using the given options.
* @return ProjectBOMEntry[]
* Converts the given file into an array of BOM entries using the given options and save them into the given assembly.
* The changes are not saved into the database yet.
* @return AssemblyBOMEntry[]
*/
public function fileToBOMEntries(File $file, array $options): array
public function importFileIntoAssembly(File $file, Assembly $assembly, array $options): array
{
return $this->stringToBOMEntries($file->getContent(), $options);
$bomEntries = $this->fileToBOMEntries($file, $options, AssemblyBOMEntry::class);
//Assign the bom_entries to the assembly
foreach ($bomEntries as $bom_entry) {
$assembly->addBomEntry($bom_entry);
}
return $bomEntries;
}
/**
* Converts the given file into an array of BOM entries using the given options.
* @return ProjectBOMEntry[]|AssemblyBOMEntry[]
*/
public function fileToBOMEntries(File $file, array $options, string $objectType = ProjectBOMEntry::class): array
{
return $this->stringToBOMEntries($file->getContent(), $options, $objectType);
}
/**
@ -117,22 +156,22 @@ 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
* @return ProjectBOMEntry[]|AssemblyBOMEntry[] An array of imported entries
*/
public function stringToBOMEntries(string $data, array $options): array
public function stringToBOMEntries(string $data, array $options, string $objectType = ProjectBOMEntry::class): 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),
'kicad_pcbnew' => $this->parseKiCADPCB($data, $objectType),
'json' => $this->parseJson($data, $options, $objectType),
default => throw new InvalidArgumentException('Invalid import type!'),
};
}
private function parseKiCADPCB(string $data): array
private function parseKiCADPCB(string $data, string $objectType = ProjectBOMEntry::class): array
{
$csv = Reader::createFromString($data);
$csv->setDelimiter(';');
@ -158,8 +197,13 @@ class BOMImporter
throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!');
}
$bom_entry = new ProjectBOMEntry();
$bom_entry->setName($entry['Designation'] . ' (' . $entry['Package'] . ')');
$bom_entry = $objectType === ProjectBOMEntry::class ? new ProjectBOMEntry() : new AssemblyBOMEntry();
if ($objectType === ProjectBOMEntry::class) {
$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));
@ -227,6 +271,174 @@ class BOMImporter
return $this->validationService->validateBOMEntries($mapped_entries, $options);
}
private function parseJson(string $data, array $options = [], string $objectType = ProjectBOMEntry::class): array
{
$result = [];
$data = json_decode($data, true);
foreach ($data as $entry) {
// Check quantity
if (!isset($entry['quantity'])) {
throw new UnexpectedValueException('quantity missing');
}
if (!is_float($entry['quantity']) || $entry['quantity'] <= 0) {
throw new UnexpectedValueException('quantity expected as float greater than 0.0');
}
// Check name
if (isset($entry['name']) && !is_string($entry['name'])) {
throw new UnexpectedValueException('name of part list entry expected as string');
}
// Check if part is assigned with relevant information
if (isset($entry['part'])) {
if (!is_array($entry['part'])) {
throw new UnexpectedValueException('The property "part" should be an array');
}
$partIdValid = isset($entry['part']['id']) && is_int($entry['part']['id']) && $entry['part']['id'] > 0;
$partNameValid = isset($entry['part']['name']) && is_string($entry['part']['name']) && trim($entry['part']['name']) !== '';
$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']) !== '';
if (!$partIdValid && !$partNameValid && !$partMpnrValid && !$partIpnValid) {
throw new UnexpectedValueException(
'The property "part" must have either assigned: "id" as integer greater than 0, "name", "mpnr", or "ipn" as non-empty string'
);
}
$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) {
$part = new Part();
$part->setName($entry['part']['name']);
}
if ($partNameValid && $part->getName() !== trim($entry['part']['name'])) {
throw new RuntimeException(sprintf('Part name does not match exact the given name. Given for import: %s, found part: %s', $entry['part']['name'], $part->getName()));
}
if ($partIpnValid && $part->getManufacturerProductNumber() !== trim($entry['part']['mpnr'])) {
throw new RuntimeException(sprintf('Part mpnr does not match exact the given mpnr. Given for import: %s, found part: %s', $entry['part']['mpnr'], $part->getManufacturerProductNumber()));
}
if ($partIpnValid && $part->getIpn() !== trim($entry['part']['ipn'])) {
throw new RuntimeException(sprintf('Part ipn does not match exact the given ipn. Given for import: %s, found part: %s', $entry['part']['ipn'], $part->getIpn()));
}
// Part: Description check
if (isset($entry['part']['description']) && !is_null($entry['part']['description'])) {
if (!is_string($entry['part']['description']) || trim($entry['part']['description']) === '') {
throw new UnexpectedValueException('The property path "part.description" must be a non-empty string if not null');
}
}
$partDescription = $entry['part']['description'] ?? '';
// Part: Manufacturer check
$manufacturerIdValid = false;
$manufacturerNameValid = false;
if (array_key_exists('manufacturer', $entry['part'])) {
if (!is_array($entry['part']['manufacturer'])) {
throw new UnexpectedValueException('The property path "part.manufacturer" must be an array');
}
$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']) !== '';
// Stellen sicher, dass mindestens eine Bedingung für manufacturer erfüllt sein muss
if (!$manufacturerIdValid && !$manufacturerNameValid) {
throw new UnexpectedValueException(
'The property "manufacturer" must have either assigned: "id" as integer greater than 0, or "name" as non-empty string'
);
}
}
$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 ($manufacturer === null) {
throw new RuntimeException(
'Manufacturer not found'
);
}
if ($manufacturerNameValid && $manufacturer->getName() !== trim($entry['part']['manufacturer']['name'])) {
throw new RuntimeException(sprintf('Manufacturer name does not match exact the given name. Given for import: %s, found manufacturer: %s', $entry['manufacturer']['name'], $manufacturer->getName()));
}
// Part: Category check
$categoryIdValid = false;
$categoryNameValid = false;
if (array_key_exists('category', $entry['part'])) {
if (!is_array($entry['part']['category'])) {
throw new UnexpectedValueException('part.category must be an array');
}
$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) {
throw new UnexpectedValueException(
'The property "category" must have either assigned: "id" as integer greater than 0, or "name" as non-empty string'
);
}
}
$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 ($category === null) {
throw new RuntimeException(
'Category not found'
);
}
if ($categoryNameValid && $category->getName() !== trim($entry['part']['category']['name'])) {
throw new RuntimeException(sprintf('Category name does not match exact the given name. Given for import: %s, found category: %s', $entry['category']['name'], $category->getName()));
}
$part->setDescription($partDescription);
$part->setManufacturer($manufacturer);
$part->setCategory($category);
if ($partMpnrValid) {
$part->setManufacturerProductNumber($entry['part']['mpnr'] ?? '');
}
if ($partIpnValid) {
$part->setIpn($entry['part']['ipn'] ?? '');
}
if ($objectType === AssemblyBOMEntry::class) {
$bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['part' => $part]);
if ($bomEntry === null) {
$name = isset($entry['name']) && $entry['name'] !== null ? trim($entry['name']) : '';
$bomEntry = $this->assemblyBOMEntryRepository->findOneBy(['name' => $name]);
if ($bomEntry === null) {
$bomEntry = new AssemblyBOMEntry();
}
}
} else {
$bomEntry = new ProjectBOMEntry();
}
$bomEntry->setQuantity($entry['quantity']);
$bomEntry->setName($entry['name'] ?? '');
$bomEntry->setPart($part);
}
$result[] = $bomEntry;
}
return $result;
}
/**
* This function uses the order of the fields in the CSV files to make them locale independent.
* @param array $entry
@ -243,7 +455,7 @@ 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;
}

View file

@ -22,10 +22,13 @@ declare(strict_types=1);
*/
namespace App\Services\ProjectSystem;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Helpers\Assemblies\AssemblyBuildRequest;
use App\Helpers\Projects\ProjectBuildRequest;
use App\Services\AssemblySystem\AssemblyBuildHelper;
use App\Services\Parts\PartLotWithdrawAddHelper;
/**
@ -33,8 +36,10 @@ use App\Services\Parts\PartLotWithdrawAddHelper;
*/
class ProjectBuildHelper
{
public function __construct(private readonly PartLotWithdrawAddHelper $withdraw_add_helper)
{
public function __construct(
private readonly PartLotWithdrawAddHelper $withdrawAddHelper,
private readonly AssemblyBuildHelper $assemblyBuildHelper
) {
}
/**
@ -66,12 +71,16 @@ class ProjectBuildHelper
$maximum_buildable_count = PHP_INT_MAX;
foreach ($project->getBomEntries() as $bom_entry) {
//Skip BOM entries without a part (as we can not determine that)
if (!$bom_entry->isPartBomEntry()) {
if (!$bom_entry->isPartBomEntry() && $bom_entry->getAssembly() === null) {
continue;
}
//The maximum buildable count for the whole project is the minimum of all BOM entries
$maximum_buildable_count = min($maximum_buildable_count, $this->getMaximumBuildableCountForBOMEntry($bom_entry));
if ($bom_entry->getPart() !== null) {
$maximum_buildable_count = min($maximum_buildable_count, $this->getMaximumBuildableCountForBOMEntry($bom_entry));
} elseif ($bom_entry->getAssembly() !== null) {
$maximum_buildable_count = min($maximum_buildable_count, $this->assemblyBuildHelper->getMaximumBuildableCount($bom_entry->getAssembly()));
}
}
return $maximum_buildable_count;
@ -97,10 +106,10 @@ class ProjectBuildHelper
}
/**
* Returns the project BOM entries for which parts are missing in the stock for the given number of builds
* Returns the project or assembly BOM entries for which parts are missing in the stock for the given number of builds
* @param Project $project The project for which the BOM entries should be checked
* @param int $number_of_builds How often should the project be build?
* @return ProjectBOMEntry[]
* @return ProjectBOMEntry[]|AssemblyBOMEntry[]
*/
public function getNonBuildableProjectBomEntries(Project $project, int $number_of_builds = 1): array
{
@ -108,24 +117,29 @@ class ProjectBuildHelper
throw new \InvalidArgumentException('The number of builds must be greater than 0!');
}
$non_buildable_entries = [];
$nonBuildableEntries = [];
foreach ($project->getBomEntries() as $bomEntry) {
$part = $bomEntry->getPart();
//Skip BOM entries without a part (as we can not determine that)
if (!$part instanceof Part) {
if (!$part instanceof Part && $bomEntry->getAssembly() === null) {
continue;
}
$amount_sum = $part->getAmountSum();
if ($bomEntry->getPart() !== null) {
$amount_sum = $part->getAmountSum();
if ($amount_sum < $bomEntry->getQuantity() * $number_of_builds) {
$non_buildable_entries[] = $bomEntry;
if ($amount_sum < $bomEntry->getQuantity() * $number_of_builds) {
$nonBuildableEntries[] = $bomEntry;
}
} elseif ($bomEntry->getAssembly() !== null) {
$nonBuildableAssemblyEntries = $this->assemblyBuildHelper->getNonBuildableAssemblyBomEntries($bomEntry->getAssembly(), $number_of_builds);
$nonBuildableEntries = array_merge($nonBuildableEntries, $nonBuildableAssemblyEntries);
}
}
return $non_buildable_entries;
return $nonBuildableEntries;
}
/**
@ -133,22 +147,37 @@ class ProjectBuildHelper
* The ProjectBuildRequest has to be validated before!!
* You have to flush changes to DB afterward
*/
public function doBuild(ProjectBuildRequest $buildRequest): void
public function doBuild(ProjectBuildRequest $projectBuildRequest): void
{
$message = $buildRequest->getComment();
$message .= ' (Project build: '.$buildRequest->getProject()->getName().')';
$message = $projectBuildRequest->getComment();
$message .= ' (Project build: '.$projectBuildRequest->getProject()->getName().')';
foreach ($buildRequest->getPartBomEntries() as $bom_entry) {
foreach ($buildRequest->getPartLotsForBOMEntry($bom_entry) as $part_lot) {
$amount = $buildRequest->getLotWithdrawAmount($part_lot);
foreach ($projectBuildRequest->getPartBomEntries() as $bomEntry) {
foreach ($projectBuildRequest->getPartLotsForBOMEntry($bomEntry) as $partLot) {
$amount = $projectBuildRequest->getLotWithdrawAmount($partLot);
if ($amount > 0) {
$this->withdraw_add_helper->withdraw($part_lot, $amount, $message);
$this->withdrawAddHelper->withdraw($partLot, $amount, $message);
}
}
}
if ($buildRequest->getAddBuildsToBuildsPart()) {
$this->withdraw_add_helper->add($buildRequest->getBuildsPartLot(), $buildRequest->getNumberOfBuilds(), $message);
foreach ($projectBuildRequest->getAssemblyBomEntries() as $bomEntry) {
$assemblyBuildRequest = new AssemblyBuildRequest($bomEntry->getAssembly(), $projectBuildRequest->getNumberOfBuilds());
//Add fields for assembly bom entries
foreach ($assemblyBuildRequest->getPartBomEntries() as $partBomEntry) {
foreach ($assemblyBuildRequest->getPartLotsForBOMEntry($partBomEntry) as $partLot) {
//Read amount from build configuration of the projectBuildRequest
$amount = $projectBuildRequest->getLotWithdrawAmount($partLot);
if ($amount > 0) {
$this->withdrawAddHelper->withdraw($partLot, $amount, $message);
}
}
}
}
if ($projectBuildRequest->getAddBuildsToBuildsPart()) {
$this->withdrawAddHelper->add($projectBuildRequest->getBuildsPartLot(), $projectBuildRequest->getNumberOfBuilds(), $message);
}
}
}

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;
@ -175,6 +176,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->translator->trans('tree.tools.edit.suppliers'),

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;
@ -154,6 +155,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);
@ -219,6 +224,7 @@ class TreeViewGenerator
Manufacturer::class => $this->translator->trans('manufacturer.labelp'),
Supplier::class => $this->translator->trans('supplier.labelp'),
Project::class => $this->translator->trans('project.labelp'),
Assembly::class => $this->translator->trans('assembly.labelp'),
default => $this->translator->trans('tree.root_node.text'),
};
}
@ -233,6 +239,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

@ -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\ProjectSystem\Project;
@ -108,6 +109,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,37 @@
<?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 ValidAssemblyBuildRequest is valid.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class ValidAssemblyBuildRequest extends Constraint
{
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View file

@ -0,0 +1,84 @@
<?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\Parts\PartLot;
use App\Helpers\Assemblies\AssemblyBuildRequest;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface;
class ValidAssemblyBuildRequestValidator extends ConstraintValidator
{
private function buildViolationForLot(PartLot $partLot, string $message): ConstraintViolationBuilderInterface
{
return $this->context->buildViolation($message)
->atPath('lot_' . $partLot->getID())
->setParameter('{{ lot }}', $partLot->getName());
}
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof ValidAssemblyBuildRequest) {
throw new UnexpectedTypeException($constraint, ValidAssemblyBuildRequest::class);
}
if (null === $value || '' === $value) {
return;
}
if (!$value instanceof AssemblyBuildRequest) {
throw new UnexpectedTypeException($value, AssemblyBuildRequest::class);
}
foreach ($value->getPartBomEntries() as $bom_entry) {
$withdraw_sum = $value->getWithdrawAmountSum($bom_entry);
$needed_amount = $value->getNeededAmountForBOMEntry($bom_entry);
foreach ($value->getPartLotsForBOMEntry($bom_entry) as $lot) {
$withdraw_amount = $value->getLotWithdrawAmount($lot);
if ($withdraw_amount < 0) {
$this->buildViolationForLot($lot, 'validator.assembly_build.lot_must_not_smaller_0')
->addViolation();
}
if ($withdraw_amount > $lot->getAmount()) {
$this->buildViolationForLot($lot, 'validator.assembly_build.lot_must_not_bigger_than_stock')
->addViolation();
}
if ($withdraw_sum > $needed_amount && $value->isDontCheckQuantity() === false) {
$this->buildViolationForLot($lot, 'validator.assembly_build.lot_bigger_than_needed')
->addViolation();
}
if ($withdraw_sum < $needed_amount && $value->isDontCheckQuantity() === false) {
$this->buildViolationForLot($lot, 'validator.assembly_build.lot_smaller_than_needed')
->addViolation();
}
}
}
}
}