Assemblies einführen

This commit is contained in:
Marcel Diegelmann 2025-03-19 08:13:45 +01:00
parent c79fc47c1e
commit 55828d830d
45 changed files with 2754 additions and 127 deletions

View file

@ -207,7 +207,7 @@ 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_project')]
#[Route(path: '/new_build_part_assembly/{assembly_id}', name: 'part_new_build_part_assembly')]
public function new(
Request $request,

View file

@ -206,7 +206,7 @@ class AssemblyBomEntriesDataTable implements DataTableTypeInterface
//Apply the user configured order and visibility and add the columns to the table
$this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns,"TABLE_ASSEMBLIES_BOM_DEFAULT_COLUMNS");
$dataTable->addOrderBy('name');
$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
$dataTable->createAdapter(ORMAdapter::class, [
'entity' => Attachment::class,

View file

@ -37,6 +37,7 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Contracts\TimeStampableInterface;
use App\Entity\ProjectSystem\Project;
use App\Entity\AssemblySystem\Assembly;
use App\Repository\DBElementRepository;
use App\Validator\Constraints\AssemblySystem\AssemblyCycle;
use App\Validator\Constraints\AssemblySystem\AssemblyInvalidBomEntry;

View file

@ -22,9 +22,6 @@ 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;
@ -38,6 +35,7 @@ use App\Entity\Attachments\MeasurementUnitAttachment;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Attachments\PartCustomStateAttachment;
use App\Entity\Attachments\ProjectAttachment;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
@ -47,6 +45,8 @@ use App\Entity\PriceInformations\Pricedetail;
use App\Entity\Parts\PartCustomState;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Footprint;
use App\Entity\UserSystem\Group;
use App\Entity\Parts\Manufacturer;
@ -87,12 +87,15 @@ use Symfony\Component\Serializer\Annotation\Groups;
'part_attachment' => PartAttachment::class,
'part_custom_state_attachment' => PartCustomStateAttachment::class,
'project_attachment' => ProjectAttachment::class,
'assembly_attachment' => AssemblyAttachment::class,
'storelocation_attachment' => StorageLocationAttachment::class,
'supplier_attachment' => SupplierAttachment::class,
'user_attachment' => UserAttachment::class,
'category' => Category::class,
'project' => Project::class,
'project_bom_entry' => ProjectBOMEntry::class,
'assembly' => Assembly::class,
'assembly_bom_entry' => AssemblyBOMEntry::class,
'footprint' => Footprint::class,
'group' => Group::class,
'manufacturer' => Manufacturer::class,

View file

@ -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\Repository\DBElementRepository;
use App\Validator\UniqueValidatableInterface;
@ -104,7 +105,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;
@ -132,6 +136,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
*/
@ -224,6 +240,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.
@ -261,6 +287,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
{
@ -322,6 +357,7 @@ class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInte
return [
'name' => $this->getName(),
'part' => $this->getPart()?->getID(),
'assembly' => $this->getAssembly()?->getID(),
];
}
}

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

@ -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
*/

View file

@ -74,11 +74,11 @@ class AssemblyBuildHelper
continue;
}
//The maximum buildable count for the whole project is the minimum of all BOM entries
//The maximum buildable count for the whole assembly 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->getReferencedAssembly() !== null) {
$maximum_buildable_count = min($maximum_buildable_count, $this->projectBuildHelper->getMaximumBuildableCount($bom_entry->getReferencedAssembly()));
$maximum_buildable_count = min($maximum_buildable_count, $this->getMaximumBuildableCount($bom_entry->getReferencedAssembly()));
}
}
@ -105,7 +105,7 @@ class AssemblyBuildHelper
}
/**
* Returns the project BOM entries for which parts are missing in the stock for the given 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[]

View file

@ -76,8 +76,6 @@ class BOMImporter
private DBElementRepository $assemblyBOMEntryRepository;
private TranslatorInterface $translator;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator,
@ -89,7 +87,6 @@ class BOMImporter
$this->categoryRepository = $entityManager->getRepository(Category::class);
$this->projectBOMEntryRepository = $entityManager->getRepository(ProjectBOMEntry::class);
$this->assemblyBOMEntryRepository = $entityManager->getRepository(AssemblyBOMEntry::class);
$this->translator = $translator;
}
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
@ -257,7 +254,8 @@ class BOMImporter
$options = $resolver->resolve($options);
return match ($options['type']) {
self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $objectType)->getBomEntries(),
self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $importObject)->getBomEntries(),
self::IMPORT_TYPE_KICAD_SCHEMATIC => $this->parseKiCADPCB($data, $importObject)->getBomEntries(),
default => throw new InvalidArgumentException($this->translator->trans('validator.bom_importer.invalid_import_type', [], 'validators')),
};
}
@ -285,8 +283,8 @@ class BOMImporter
return match ($options['type']) {
self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $importObject),
self::IMPORT_TYPE_JSON => $this->parseJson($data, $importObject),
self::IMPORT_TYPE_CSV => $this->parseCsv($data, $importObject),
self::IMPORT_TYPE_JSON => $this->parseJson($importObject, $data),
self::IMPORT_TYPE_CSV => $this->parseCsv($importObject, $data),
default => $defaultImporterResult,
};
}

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

@ -147,7 +147,7 @@ class ToolsTreeBuilder
$this->translator->trans('info_providers.search.title'),
$this->urlGenerator->generate('info_providers_search')
))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down');
$nodes[] = (new TreeViewNode(
$this->translator->trans('info_providers.bulk_import.manage_jobs'),
$this->urlGenerator->generate('bulk_info_provider_manage')