Projekt BOM-Konfiguration um Assemblies bereinigen.

Assembly BOM-Konfiguration um Projektauswahl erweitern (APS-3, APS-4)
This commit is contained in:
Marcel Diegelmann 2025-06-17 11:28:42 +02:00
parent f79dc3a102
commit df2ce45e4c
53 changed files with 738 additions and 1541 deletions

View file

@ -22,9 +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 App\Entity\ProjectSystem\Project;
use App\Services\Attachments\ProjectPreviewGenerator;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
@ -151,29 +151,29 @@ class TypeaheadController extends AbstractController
'footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '',
'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'),
'image' => $preview_url,
];
];
}
return new JsonResponse($data);
}
#[Route(path: '/assemblies/search/{query}', name: 'typeahead_assemblies')]
public function assemblies(
EntityManagerInterface $entityManager,
AssemblyPreviewGenerator $assemblyPreviewGenerator,
AttachmentURLGenerator $attachmentURLGenerator,
string $query = ""
#[Route(path: '/projects/search/{query}', name: 'typeahead_projects')]
public function projects(
EntityManagerInterface $entityManager,
ProjectPreviewGenerator $projectPreviewGenerator,
AttachmentURLGenerator $attachmentURLGenerator,
string $query = ""
): JsonResponse {
$this->denyAccessUnlessGranted('@assemblies.read');
$this->denyAccessUnlessGranted('@projects.read');
$result = [];
$assemblyRepository = $entityManager->getRepository(Assembly::class);
$projectRepository = $entityManager->getRepository(Project::class);
$assemblies = $assemblyRepository->autocompleteSearch($query, 100);
$projects = $projectRepository->autocompleteSearch($query, 100);
foreach ($assemblies as $assembly) {
$preview_attachment = $assemblyPreviewGenerator->getTablePreviewAttachment($assembly);
foreach ($projects as $project) {
$preview_attachment = $projectPreviewGenerator->getTablePreviewAttachment($project);
if($preview_attachment instanceof Attachment) {
$preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm');
@ -181,13 +181,13 @@ class TypeaheadController extends AbstractController
$preview_url = '';
}
/** @var Assembly $assembly */
/** @var Project $project */
$result[] = [
'id' => $assembly->getID(),
'name' => $this->translator->trans('typeahead.parts.assembly.name', ['%name%' => $assembly->getName()]),
'id' => $project->getID(),
'name' => $project->getName(),
'category' => '',
'footprint' => '',
'description' => mb_strimwidth($assembly->getDescription(), 0, 127, '...'),
'description' => mb_strimwidth($project->getDescription(), 0, 127, '...'),
'image' => $preview_url,
];
}

View file

@ -25,11 +25,13 @@ namespace App\DataTables;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\ProjectDataTableHelper;
use App\DataTables\Helpers\ColumnSortHelper;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\ProjectSystem\Project;
use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use Doctrine\ORM\QueryBuilder;
@ -43,12 +45,13 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class AssemblyBomEntriesDataTable implements DataTableTypeInterface
{
public function __construct(
protected TranslatorInterface $translator,
protected PartDataTableHelper $partDataTableHelper,
protected EntityURLGenerator $entityURLGenerator,
protected AmountFormatter $amountFormatter,
private string $visible_columns,
private ColumnSortHelper $csh
protected TranslatorInterface $translator,
protected PartDataTableHelper $partDataTableHelper,
protected ProjectDataTableHelper $projectDataTableHelper,
protected EntityURLGenerator $entityURLGenerator,
protected AmountFormatter $amountFormatter,
private string $visible_columns,
private ColumnSortHelper $csh
){
}
@ -86,18 +89,29 @@ class AssemblyBomEntriesDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.name'),
'orderField' => 'NATSORT(part.name)',
'render' => function ($value, AssemblyBOMEntry $context) {
if(!$context->getPart() instanceof Part) {
if(!$context->getPart() instanceof Part && !$context->getProject() instanceof Project) {
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->getProject() !== null) {
$tmp = $this->projectDataTableHelper->renderName($context->getProject());
$tmp = $this->translator->trans('part.table.name.value.for_project', ['%value%' => $tmp]);
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
}
return $tmp;
},
])
->add('ipn', TextColumn::class, [
'label' => $this->translator->trans('part.table.ipn'),

View file

@ -23,18 +23,18 @@ declare(strict_types=1);
namespace App\DataTables\Helpers;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\ProjectSystem\Project;
use App\Services\EntityURLGenerator;
/**
* A helper service which contains common code to render columns for assembly related tables
*/
class AssemblyDataTableHelper
class ProjectDataTableHelper
{
public function __construct(private readonly EntityURLGenerator $entityURLGenerator) {
}
public function renderName(Assembly $context): string
public function renderName(Project $context): string
{
$icon = '';

View file

@ -25,9 +25,7 @@ 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;
@ -46,7 +44,6 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
public function __construct(
protected TranslatorInterface $translator,
protected PartDataTableHelper $partDataTableHelper,
protected AssemblyDataTableHelper $assemblyDataTableHelper,
protected EntityURLGenerator $entityURLGenerator,
protected AmountFormatter $amountFormatter
) {
@ -90,26 +87,16 @@ 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 && !$context->getAssembly() instanceof Assembly) {
if(!$context->getPart() instanceof Part) {
return htmlspecialchars((string) $context->getName());
}
if ($context->getPart() !== null) {
$tmp = $this->partDataTableHelper->renderName($context->getPart());
$tmp = $this->translator->trans('part.table.name.value.for_part', ['%value%' => $tmp]);
//Part exists if we reach this point
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>';
}
$tmp = $this->partDataTableHelper->renderName($context->getPart());
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
return $tmp;
},
])
@ -121,6 +108,8 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
if($context->getPart() instanceof Part) {
return $context->getPart()->getIpn();
}
return '';
}
])
->add('description', MarkdownColumn::class, [

View file

@ -22,7 +22,6 @@ 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;
@ -57,7 +56,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
*
* @extends AbstractStructuralDBElement<AssemblyAttachment, AssemblyParameter>
*/
#[ORM\Entity(repositoryClass: AssemblyRepository::class)]
#[ORM\Entity]
#[ORM\Table(name: 'assemblies')]
#[ApiResource(
operations: [
@ -108,6 +107,7 @@ class Assembly extends AbstractStructuralDBElement
#[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.project_already_in_bom', fields: ['project'])]
#[UniqueObjectCollection(message: 'assembly.bom_entry.name_already_in_bom', fields: ['name'])]
protected Collection $bom_entries;

View file

@ -36,7 +36,7 @@ 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\Entity\ProjectSystem\Project;
use App\Repository\DBElementRepository;
use App\Validator\UniqueValidatableInterface;
use Doctrine\DBAL\Types\Types;
@ -133,6 +133,18 @@ class AssemblyBOMEntry extends AbstractDBElement implements UniqueValidatableInt
#[Groups(['bom_entry:read', 'bom_entry:write', 'full'])]
protected ?Part $part = null;
/**
* @var Project|null The associated project
*/
#[Assert\Expression(
'(this.getPart() === null or this.getProject() === 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: Project::class)]
#[ORM\JoinColumn(name: 'id_project', nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', ])]
protected ?Project $project = null;
/**
* @var BigDecimal|null The price of this non-part BOM entry
*/
@ -225,6 +237,17 @@ class AssemblyBOMEntry extends AbstractDBElement implements UniqueValidatableInt
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): AssemblyBOMEntry
{
$this->project = $project;
return $this;
}
/**
* Returns the price of this BOM entry, if existing.
* Prices are only valid on non-Part BOM entries.
@ -297,6 +320,7 @@ class AssemblyBOMEntry extends AbstractDBElement implements UniqueValidatableInt
return [
'name' => $this->getName(),
'part' => $this->getPart()?->getID(),
'project' => $this->getProject()?->getID(),
];
}
}

View file

@ -108,7 +108,6 @@ 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,7 +35,6 @@ 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;
@ -104,10 +103,7 @@ 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.getAssembly() !== null or (this.getName() !== null and this.getName() != "")',
message: 'validator.project.bom_entry.part_or_assembly_needed'
)]
#[Assert\Expression('this.getPart() !== null or this.getName() !== null', message: 'validator.project.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;
@ -135,18 +131,6 @@ 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
*/
@ -239,16 +223,6 @@ 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.
@ -286,15 +260,6 @@ 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
{
@ -356,7 +321,6 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
return [
'name' => $this->getName(),
'part' => $this->getPart()?->getID(),
'assembly' => $this->getAssembly()?->getID(),
];
}
}

View file

@ -8,6 +8,7 @@ use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Form\Type\BigDecimalNumberType;
use App\Form\Type\CurrencyEntityType;
use App\Form\Type\PartSelectType;
use App\Form\Type\ProjectSelectType;
use App\Form\Type\RichTextEditorType;
use App\Form\Type\SIUnitType;
use Symfony\Component\Form\AbstractType;
@ -34,11 +35,13 @@ class AssemblyBOMEntryType extends AbstractType
});
$builder
->add('part', PartSelectType::class, [
'required' => false,
])
->add('project', ProjectSelectType::class, [
'label' => 'assembly.bom.project',
'required' => false,
])
->add('name', TextType::class, [
'label' => 'assembly.bom.name',
'required' => false,
@ -75,10 +78,8 @@ class AssemblyBOMEntryType extends AbstractType
'required' => false,
'label' => false,
'short' => true,
])
;
]
);
}
public function configureOptions(OptionsResolver $resolver): void

View file

@ -78,35 +78,34 @@ class AssemblyBuildType extends AbstractType implements DataMapperInterface
'required' => false,
]);
//The form is initially empty, we have to define the fields after we know the data
//The form is initially empty, 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();
/** @var AssemblyBuildRequest $assemblyBuildRequest */
$assemblyBuildRequest = $event->getData();
$form->add('addBuildsToBuildsPart', CheckboxType::class, [
'label' => 'assembly.build.add_builds_to_builds_part',
'required' => false,
'disabled' => !$build_request->getAssembly()->getBuildPart() instanceof Part,
'disabled' => !$assemblyBuildRequest->getAssembly()->getBuildPart() instanceof Part,
]);
if ($build_request->getAssembly()->getBuildPart() instanceof Part) {
if ($assemblyBuildRequest->getAssembly()->getBuildPart() instanceof Part) {
$form->add('buildsPartLot', PartLotSelectType::class, [
'label' => 'assembly.build.builds_part_lot',
'required' => false,
'part' => $build_request->getAssembly()->getBuildPart(),
'part' => $assemblyBuildRequest->getAssembly()->getBuildPart(),
'placeholder' => 'assembly.build.buildsPartLot.new_lot'
]);
}
foreach ($build_request->getPartBomEntries() as $bomEntry) {
foreach ($assemblyBuildRequest->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 ($assemblyBuildRequest->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($assemblyBuildRequest->getNeededAmountForBOMEntry($bomEntry), $lot->getAmount()),
'disabled' => !$this->security->isGranted('withdraw', $lot),
]);
}

View file

@ -59,7 +59,6 @@ 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,7 +5,6 @@ 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;
@ -39,10 +38,6 @@ class ProjectBOMEntryType extends AbstractType
'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,

View file

@ -22,7 +22,6 @@ 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;
@ -39,11 +38,10 @@ 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, private readonly TranslatorInterface $translator)
public function __construct(private readonly Security $security)
{
}
@ -113,25 +111,6 @@ class ProjectBuildType extends AbstractType implements DataMapperInterface
]);
}
}
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

@ -4,9 +4,9 @@ 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\Entity\ProjectSystem\Project;
use App\Services\Attachments\ProjectPreviewGenerator;
use App\Services\Attachments\AttachmentURLGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
@ -20,9 +20,9 @@ use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class AssemblySelectType extends AbstractType implements DataMapperInterface
class ProjectSelectType 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 __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em, private readonly ProjectPreviewGenerator $previewGenerator, private readonly AttachmentURLGenerator $attachmentURLGenerator)
{
}
@ -69,28 +69,28 @@ class AssemblySelectType extends AbstractType implements DataMapperInterface
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'class' => Assembly::class,
'class' => Project::class,
'choice_label' => 'name',
'compound' => true,
'error_bubbling' => false,
]);
error_log($this->urlGenerator->generate('typeahead_assemblies', ['query' => '__QUERY__']));
error_log($this->urlGenerator->generate('typeahead_projects', ['query' => '__QUERY__']));
$resolver->setDefaults([
'attr' => [
'data-controller' => 'elements--assembly-select',
'data-autocomplete' => $this->urlGenerator->generate('typeahead_assemblies', ['query' => '__QUERY__']),
'data-controller' => 'elements--project-select',
'data-autocomplete' => $this->urlGenerator->generate('typeahead_projects', ['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) {
'choice_attr' => ChoiceList::attr($this, function (?Project $project) {
if($project instanceof Project) {
//Determine the picture to show:
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($assembly);
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($project);
if ($preview_attachment instanceof Attachment) {
$preview_url = $this->attachmentURLGenerator->getThumbnailURL($preview_attachment,
'thumbnail_sm');
@ -99,8 +99,8 @@ class AssemblySelectType extends AbstractType implements DataMapperInterface
}
}
return $assembly instanceof Assembly ? [
'data-description' => $assembly->getDescription() ? mb_strimwidth($assembly->getDescription(), 0, 127, '...') : '',
return $project instanceof Project ? [
'data-description' => $project->getDescription() ? mb_strimwidth($project->getDescription(), 0, 127, '...') : '',
'data-category' => '',
'data-footprint' => '',
'data-image' => $preview_url,

View file

@ -22,13 +22,10 @@ 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;
/**
@ -82,7 +79,7 @@ final class ProjectBuildRequest
//Completely reset the array
$this->withdraw_amounts = [];
//Now create an array for each part BOM entry
//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) {
@ -91,21 +88,6 @@ 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()]);
}
}
}
}
/**
@ -248,77 +230,12 @@ final class ProjectBuildRequest
{
$this->ensureBOMEntryValid($projectBOMEntry);
if (!$projectBOMEntry->getPart() instanceof Part && !$projectBOMEntry->getAssembly() instanceof Assembly) {
if (!$projectBOMEntry->getPart() instanceof Part) {
return null;
}
//Filter out all lots which have unknown instock
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;
return $projectBOMEntry->getPart()->getPartLots()->filter(fn (PartLot $lot) => !$lot->isInstockUnknown())->toArray();
}
/**
@ -349,15 +266,6 @@ 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
*/

View file

@ -1,69 +0,0 @@
<?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

@ -51,4 +51,22 @@ class DeviceRepository extends StructuralDBElementRepository
//Prevent user from deleting devices, to not accidentally remove filled devices from old versions
return 1;
}
/**
* @return Project[]
*/
public function autocompleteSearch(string $query, int $max_limits = 50): array
{
$qb = $this->createQueryBuilder('project');
$qb->select('project')
->where('ILIKE(project.name, :query) = TRUE')
->orWhere('ILIKE(project.description, :query) = TRUE');
$qb->setParameter('query', '%'.$query.'%');
$qb->setMaxResults($max_limits);
$qb->orderBy('NATSORT(project.name)', 'ASC');
return $qb->getQuery()->getResult();
}
}

View file

@ -27,14 +27,17 @@ use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Part;
use App\Helpers\Assemblies\AssemblyBuildRequest;
use App\Services\Parts\PartLotWithdrawAddHelper;
use App\Services\ProjectSystem\ProjectBuildHelper;
/**
* @see \App\Tests\Services\AssemblySystem\AssemblyBuildHelperTest
*/
class AssemblyBuildHelper
{
public function __construct(private readonly PartLotWithdrawAddHelper $withdraw_add_helper)
{
public function __construct(
private readonly PartLotWithdrawAddHelper $withdraw_add_helper,
private readonly ProjectBuildHelper $projectBuildHelper
) {
}
/**
@ -66,12 +69,16 @@ class AssemblyBuildHelper
$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()) {
if (!$bom_entry->isPartBomEntry() && $bom_entry->getProject() === null) {
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));
//The maximum buildable count for the whole project is the minimum of all BOM entries
if ($bom_entry->getPart() !== null) {
$maximum_buildable_count = min($maximum_buildable_count, $this->getMaximumBuildableCountForBOMEntry($bom_entry));
} elseif ($bom_entry->getProject() !== null) {
$maximum_buildable_count = min($maximum_buildable_count, $this->projectBuildHelper->getMaximumBuildableCount($bom_entry->getProject()));
}
}
return $maximum_buildable_count;
@ -97,7 +104,7 @@ class AssemblyBuildHelper
}
/**
* Returns the assembly BOM entries for which parts are missing in the stock for the given number of builds
* Returns the project 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[]
@ -108,24 +115,29 @@ class AssemblyBuildHelper
throw new \InvalidArgumentException('The number of builds must be greater than 0!');
}
$non_buildable_entries = [];
$nonBuildableEntries = [];
foreach ($assembly->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->projectBuildHelper->getNonBuildableProjectBomEntries($bomEntry->getProject(), $number_of_builds);
$nonBuildableEntries = array_merge($nonBuildableEntries, $nonBuildableAssemblyEntries);
}
}
return $non_buildable_entries;
return $nonBuildableEntries;
}
/**

View file

@ -22,38 +22,38 @@ declare(strict_types=1);
namespace App\Services\Attachments;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Entity\ProjectSystem\Project;
class AssemblyPreviewGenerator
class ProjectPreviewGenerator
{
public function __construct(protected AttachmentManager $attachmentHelper)
{
}
/**
* Returns a list of attachments that can be used for previewing the assembly ordered by priority.
* Returns a list of attachments that can be used for previewing the project ordered by priority.
*
* @param Assembly $assembly the assembly for which the attachments should be determined
* @param Project $project the project for which the attachments should be determined
*
* @return (Attachment|null)[]
*
* @psalm-return list<Attachment|null>
*/
public function getPreviewAttachments(Assembly $assembly): array
public function getPreviewAttachments(Project $project): array
{
$list = [];
//Master attachment has top priority
$attachment = $assembly->getMasterPictureAttachment();
$attachment = $project->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
$list[] = $attachment;
}
//Then comes the other images of the assembly
foreach ($assembly->getAttachments() as $attachment) {
//Then comes the other images of the project
foreach ($project->getAttachments() as $attachment) {
//Dont show the master attachment twice
if ($this->isAttachmentValidPicture($attachment) && $attachment !== $assembly->getMasterPictureAttachment()) {
if ($this->isAttachmentValidPicture($attachment) && $attachment !== $project->getMasterPictureAttachment()) {
$list[] = $attachment;
}
}
@ -62,14 +62,14 @@ class AssemblyPreviewGenerator
}
/**
* Determines what attachment should be used for previewing a assembly (especially in assembly table).
* Determines what attachment should be used for previewing a project (especially in project 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
* @param Project $project The project for which the attachment should be determined
*/
public function getTablePreviewAttachment(Assembly $assembly): ?Attachment
public function getTablePreviewAttachment(Project $project): ?Attachment
{
$attachment = $assembly->getMasterPictureAttachment();
$attachment = $project->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
return $attachment;
}

View file

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

View file

@ -10,14 +10,14 @@ class AssemblyTwigExtension extends AbstractExtension
public function getFunctions(): array
{
return [
new TwigFunction('has_assembly', [$this, 'hasAssembly']),
new TwigFunction('has_project', [$this, 'hasProject']),
];
}
public function hasAssembly(array $bomEntries): bool
public function hasProject(array $bomEntries): bool
{
foreach ($bomEntries as $entry) {
if ($entry->getAssembly() !== null) {
if ($entry->getProject() !== null) {
return true;
}
}