Assemblies einführen

This commit is contained in:
Marcel Diegelmann 2025-03-19 08:13:45 +01:00
parent e1418dfdc1
commit f0748a2123
107 changed files with 14096 additions and 98 deletions

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

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\DataTables\LogDataTable;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AttachmentUpload;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
@ -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;
@ -204,15 +206,18 @@ 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_project/{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
{