diff --git a/assets/controllers/pages/checkbox_multicheck_controller.js b/assets/controllers/pages/checkbox_multicheck_controller.js new file mode 100644 index 00000000..ca77c597 --- /dev/null +++ b/assets/controllers/pages/checkbox_multicheck_controller.js @@ -0,0 +1,39 @@ +/* + * 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 . + */ + +import {Controller} from "@hotwired/stimulus"; + +/* + * Define this controller on a checkbox, which should be used as a master to select/deselect all other checkboxes + * with the same data-multicheck-name attribute. + */ +export default class extends Controller +{ + connect() { + this.element.addEventListener("change", this.toggleAll.bind(this)); + } + + toggleAll() { + //Retrieve all checkboxes, which have the same data-multicheck-name attribute as the current checkbox + const checkboxes = document.querySelectorAll(`input[type="checkbox"][data-multicheck-name="${this.element.dataset.multicheckName}"]`); + for (let checkbox of checkboxes) { + checkbox.checked = this.element.checked; + } + } +} \ No newline at end of file diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index f4e385cb..2d567c70 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -25,7 +25,10 @@ use App\Entity\Parts\Part; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Form\ProjectSystem\ProjectBOMEntryCollectionType; +use App\Form\ProjectSystem\ProjectBuildType; use App\Form\Type\StructuralEntityType; +use App\Helpers\Projects\ProjectBuildRequest; +use App\Services\ProjectSystem\ProjectBuildHelper; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use Omines\DataTablesBundle\DataTableFactory; @@ -52,7 +55,7 @@ class ProjectController extends AbstractController /** * @Route("/{id}/info", name="project_info", requirements={"id"="\d+"}) */ - public function info(Project $project, Request $request) + public function info(Project $project, Request $request, ProjectBuildHelper $buildHelper) { $this->denyAccessUnlessGranted('read', $project); @@ -64,11 +67,58 @@ class ProjectController extends AbstractController } return $this->render('Projects/info/info.html.twig', [ + 'buildHelper' => $buildHelper, 'datatable' => $table, 'project' => $project, ]); } + /** + * @Route("/{id}/build", name="project_build", requirements={"id"="\d+"}) + */ + public function build(Project $project, Request $request, ProjectBuildHelper $buildHelper, EntityManagerInterface $entityManager): Response + { + $this->denyAccessUnlessGranted('read', $project); + + //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; + } + + $projectBuildRequest = new ProjectBuildRequest($project, $number_of_builds); + $form = $this->createForm(ProjectBuildType::class, $projectBuildRequest); + + $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($projectBuildRequest); + $entityManager->flush(); + $this->addFlash('success', 'project.build.flash.success'); + + return $this->redirect( + $request->get('_redirect', + $this->generateUrl('project_info', ['id' => $project->getID()] + ))); + } else { + $this->addFlash('error', 'project.build.flash.invalid_input'); + } + } + + return $this->renderForm('Projects/build/build.html.twig', [ + 'buildHelper' => $buildHelper, + 'project' => $project, + 'build_request' => $projectBuildRequest, + 'number_of_builds' => $number_of_builds, + 'form' => $form, + ]); + } + /** * @Route("/add_parts", name="project_add_parts_no_id") * @Route("/{id}/add_parts", name="project_add_parts", requirements={"id"="\d+"}) diff --git a/src/Entity/ProjectSystem/ProjectBOMEntry.php b/src/Entity/ProjectSystem/ProjectBOMEntry.php index 3c874a7b..9d8be815 100644 --- a/src/Entity/ProjectSystem/ProjectBOMEntry.php +++ b/src/Entity/ProjectSystem/ProjectBOMEntry.php @@ -259,7 +259,14 @@ class ProjectBOMEntry extends AbstractDBElement $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 !== null; + } /** * @Assert\Callback diff --git a/src/Form/ProjectSystem/ProjectBuildType.php b/src/Form/ProjectSystem/ProjectBuildType.php new file mode 100644 index 00000000..3758bb21 --- /dev/null +++ b/src/Form/ProjectSystem/ProjectBuildType.php @@ -0,0 +1,171 @@ +. + */ + +namespace App\Form\ProjectSystem; + +use App\Entity\Parts\PartLot; +use App\Form\Type\PartLotSelectType; +use App\Form\Type\SIUnitType; +use App\Helpers\Projects\ProjectBuildRequest; +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; +use Symfony\Component\Security\Core\Security; + +class ProjectBuildType extends AbstractType implements DataMapperInterface +{ + private Security $security; + + public function __construct(Security $security) + { + $this->security = $security; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'compound' => true, + 'data_class' => ProjectBuildRequest::class + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->setDataMapper($this); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'project.build.btn_build', + 'disabled' => !$this->security->isGranted('@parts_stock.withdraw'), + ]); + + $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 ProjectBuildRequest $build_request */ + $build_request = $event->getData(); + + $form->add('addBuildsToBuildsPart', CheckboxType::class, [ + 'label' => 'project.build.add_builds_to_builds_part', + 'required' => false, + 'disabled' => $build_request->getProject()->getBuildPart() === null, + ]); + + if ($build_request->getProject()->getBuildPart()) { + $form->add('buildsPartLot', PartLotSelectType::class, [ + 'label' => 'project.build.builds_part_lot', + 'required' => false, + 'part' => $build_request->getProject()->getBuildPart(), + 'placeholder' => 'project.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) + { + if (!$data instanceof ProjectBuildRequest) { + throw new \RuntimeException('Data must be an instance of ' . ProjectBuildRequest::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['addBuildsToBuildsPart']->setData($data->getAddBuildsToBuildsPart()); + if (isset($forms['buildsPartLot'])) { + $forms['buildsPartLot']->setData($data->getBuildsPartLot()); + } + + } + + public function mapFormsToData(\Traversable $forms, &$data) + { + if (!$data instanceof ProjectBuildRequest) { + throw new \RuntimeException('Data must be an instance of ' . ProjectBuildRequest::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, $form->getData()); + } + } + + $data->setComment($forms['comment']->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 (!empty($data->getComment())) { + $description .= ' (' . $data->getComment() . ')'; + } + $lot->setDescription($description); + + $data->getProject()->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()); + } +} \ No newline at end of file diff --git a/src/Form/Type/PartLotSelectType.php b/src/Form/Type/PartLotSelectType.php new file mode 100644 index 00000000..aa3c6fc6 --- /dev/null +++ b/src/Form/Type/PartLotSelectType.php @@ -0,0 +1,63 @@ +. + */ + +namespace App\Form\Type; + +use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; +use Doctrine\ORM\EntityRepository; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ChoiceList\ChoiceList; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class PartLotSelectType extends AbstractType +{ + public function getParent() + { + return EntityType::class; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setRequired('part'); + $resolver->setAllowedTypes('part', Part::class); + + $resolver->setDefaults([ + 'class' => PartLot::class, + 'choice_label' => ChoiceList::label($this, function (PartLot $part_lot) { + return ($part_lot->getStorageLocation() ? $part_lot->getStorageLocation()->getFullPath() : '') + . ' (' . $part_lot->getName() . '): ' . $part_lot->getAmount(); + }), + 'attr' => [ + 'data-controller' => 'elements--selectpicker', + 'data-live-search' => true, + ], + 'query_builder' => function (Options $options) { + return function (EntityRepository $er) use ($options) { + return $er->createQueryBuilder('l') + ->where('l.part = :part') + ->setParameter('part', $options['part']); + }; + } + ]); + } +} \ No newline at end of file diff --git a/src/Helpers/Projects/ProjectBuildRequest.php b/src/Helpers/Projects/ProjectBuildRequest.php new file mode 100644 index 00000000..0e3b864f --- /dev/null +++ b/src/Helpers/Projects/ProjectBuildRequest.php @@ -0,0 +1,302 @@ +. + */ + +namespace App\Helpers\Projects; + +use App\Entity\Parts\PartLot; +use App\Entity\ProjectSystem\Project; +use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Validator\Constraints\ProjectSystem\ValidProjectBuildRequest; + +/** + * @ValidProjectBuildRequest() + */ +final class ProjectBuildRequest +{ + private Project $project; + private int $number_of_builds; + + /** + * @var array + */ + private array $withdraw_amounts = []; + + private string $comment = ''; + + private ?PartLot $builds_lot = null; + + private bool $add_build_to_builds_part = false; + + /** + * @param Project $project The project that should be build + * @param int $number_of_builds The number of builds that should be created + */ + public function __construct(Project $project, int $number_of_builds) + { + $this->project = $project; + $this->number_of_builds = $number_of_builds; + + $this->initializeArray(); + + //By default, use the first available lot of builds part if there is one. + if($project->getBuildPart() !== null) { + $this->add_build_to_builds_part = true; + foreach( $project->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 projectBOMEntry belongs to the project, otherwise throw an exception. + * @param ProjectBOMEntry $entry + * @return void + */ + private function ensureBOMEntryValid(ProjectBOMEntry $entry): void + { + if ($entry->getProject() !== $this->project) { + throw new \InvalidArgumentException('The given BOM entry does not belong to the project!'); + } + } + + /** + * Returns the partlot where the builds should be added to, or null if it should not be added to any lot. + * @return PartLot|null + */ + public function getBuildsPartLot(): ?PartLot + { + return $this->builds_lot; + } + + /** + * Return if the builds should be added to the builds part of this project as new stock + * @return bool + */ + public function getAddBuildsToBuildsPart(): bool + { + return $this->add_build_to_builds_part; + } + + /** + * Set if the builds should be added to the builds part of this project as new stock + * @param bool $new_value + * @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 project build part, or an exception is thrown! + * @param PartLot|null $new_part_lot + * @return $this + */ + public function setBuildsPartLot(?PartLot $new_part_lot): self + { + //Ensure that this new_part_lot belongs to the project + if (($new_part_lot !== null && $new_part_lot->getPart() !== $this->project->getBuildPart()) || $this->project->getBuildPart() === null) { + throw new \InvalidArgumentException('The given part lot does not belong to the projects build part!'); + } + + if ($new_part_lot !== null) { + $this->setAddBuildsToBuildsPart(true); + } + + $this->builds_lot = $new_part_lot; + + return $this; + } + + /** + * Returns the comment where the user can write additional information about the build. + * @return string + */ + public function getComment(): string + { + return $this->comment; + } + + /** + * Sets the comment where the user can write additional information about the build. + * @param string $comment + */ + 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 withdraw amount should be get + * @return float + */ + public function getLotWithdrawAmount($lot): float + { + 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!'); + } + + 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 withdraw amount should be get + * @param float $amount + * @return $this + */ + public function setLotWithdrawAmount($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. + * @param ProjectBOMEntry $entry + * @return float + */ + public function getWithdrawAmountSum(ProjectBOMEntry $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. + * @param ProjectBOMEntry $entry + * @return PartLot[]|null Returns null if the entry is a non-part BOM entry + */ + public function getPartLotsForBOMEntry(ProjectBOMEntry $projectBOMEntry): ?array + { + $this->ensureBOMEntryValid($projectBOMEntry); + + if ($projectBOMEntry->getPart() === null) { + return null; + } + + //Filter out all lots which have unknown instock + return $projectBOMEntry->getPart()->getPartLots()->filter(fn (PartLot $lot) => !$lot->isInstockUnknown())->toArray(); + } + + /** + * Returns the needed amount of parts for the given BOM entry. + * @param ProjectBOMEntry $entry + * @return float + */ + public function getNeededAmountForBOMEntry(ProjectBOMEntry $entry): float + { + $this->ensureBOMEntryValid($entry); + + return $entry->getQuantity() * $this->number_of_builds; + } + + /** + * Returns the list of all bom entries that have to be build. + * @return ProjectBOMEntry[] + */ + public function getBomEntries(): array + { + return $this->project->getBomEntries()->toArray(); + } + + /** + * Returns the all part bom entries that have to be build. + * @return ProjectBOMEntry[] + */ + public function getPartBomEntries(): array + { + return $this->project->getBomEntries()->filter(function (ProjectBOMEntry $entry) { + return $entry->isPartBomEntry(); + })->toArray(); + } + + /** + * Returns which project should be build + * @return Project + */ + public function getProject(): Project + { + return $this->project; + } + + /** + * Returns the number of builds that should be created. + * @return int + */ + public function getNumberOfBuilds(): int + { + return $this->number_of_builds; + } +} \ No newline at end of file diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php new file mode 100644 index 00000000..8eee0772 --- /dev/null +++ b/src/Services/ProjectSystem/ProjectBuildHelper.php @@ -0,0 +1,162 @@ +. + */ + +namespace App\Services\ProjectSystem; + +use App\Entity\ProjectSystem\Project; +use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Helpers\Projects\ProjectBuildRequest; +use App\Services\Parts\PartLotWithdrawAddHelper; + +class ProjectBuildHelper +{ + private PartLotWithdrawAddHelper $withdraw_add_helper; + + public function __construct(PartLotWithdrawAddHelper $withdraw_add_helper) + { + $this->withdraw_add_helper = $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. + * @param ProjectBOMEntry $projectBOMEntry + * @return int + */ + public function getMaximumBuildableCountForBOMEntry(ProjectBOMEntry $projectBOMEntry): int + { + $part = $projectBOMEntry->getPart(); + + if ($part === null) { + throw new \InvalidArgumentException('This function cannot determine the maximum buildable count for a BOM entry without a part!'); + } + + if ($projectBOMEntry->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 / $projectBOMEntry->getQuantity()); + } + + /** + * Returns the maximum buildable amount of the given project, based on the stock of the used parts in the BOM. + * @param Project $project + * @return int + */ + public function getMaximumBuildableCount(Project $project): int + { + $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()) { + 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)); + } + + return $maximum_buildable_count; + } + + /** + * Checks if the given project can be build with the current stock. + * This means that the maximum buildable count is greater or equal than the requested $number_of_projects + * @param Project $project + * @parm int $number_of_builds + * @return bool + */ + public function isProjectBuildable(Project $project, int $number_of_builds = 1): bool + { + return $this->getMaximumBuildableCount($project) >= $number_of_builds; + } + + /** + * Check if the given BOM entry can be build with the current stock. + * This means that the maximum buildable count is greater or equal than the requested $number_of_projects + * @param ProjectBOMEntry $bom_entry + * @param int $number_of_builds + * @return bool + */ + public function isBOMEntryBuildable(ProjectBOMEntry $bom_entry, int $number_of_builds = 1): bool + { + return $this->getMaximumBuildableCountForBOMEntry($bom_entry) >= $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[] + */ + public function getNonBuildableProjectBomEntries(Project $project, 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 ($project->getBomEntries() as $bomEntry) { + $part = $bomEntry->getPart(); + + //Skip BOM entries without a part (as we can not determine that) + if ($part === null) { + 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 ProjectBuildRequest and create the build parts entries, if needed. + * The ProjectBuildRequest has to be validated before!! + * You have to flush changes to DB afterwards + * @param ProjectBuildRequest $buildRequest + * @return void + */ + public function doBuild(ProjectBuildRequest $buildRequest): void + { + $message = $buildRequest->getComment(); + $message .= ' (Project build: '.$buildRequest->getProject()->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); + } + } +} \ No newline at end of file diff --git a/src/Services/ProjectSystem/ProjectBuildPartHelper.php b/src/Services/ProjectSystem/ProjectBuildPartHelper.php index 5ec1537b..136e2ff7 100644 --- a/src/Services/ProjectSystem/ProjectBuildPartHelper.php +++ b/src/Services/ProjectSystem/ProjectBuildPartHelper.php @@ -29,6 +29,9 @@ class ProjectBuildPartHelper //Add a tag to the part that indicates that it is a build part $part->setTags('project-build'); + //Associate the part with the project + $project->setBuildPart($part); + return $part; } } \ No newline at end of file diff --git a/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequest.php b/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequest.php new file mode 100644 index 00000000..b0c99947 --- /dev/null +++ b/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequest.php @@ -0,0 +1,36 @@ +. + */ + +namespace App\Validator\Constraints\ProjectSystem; + +use Symfony\Component\Validator\Constraint; + +/** + * This constraint checks that the given ProjectBuildRequest is valid. + * + * @Annotation + */ +class ValidProjectBuildRequest extends Constraint +{ + public function getTargets(): string + { + return self::CLASS_CONSTRAINT; + } +} \ No newline at end of file diff --git a/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequestValidator.php b/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequestValidator.php new file mode 100644 index 00000000..03a9b81f --- /dev/null +++ b/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequestValidator.php @@ -0,0 +1,82 @@ +. + */ + +namespace App\Validator\Constraints\ProjectSystem; + +use App\Entity\Parts\PartLot; +use App\Helpers\Projects\ProjectBuildRequest; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface; + +class ValidProjectBuildRequestValidator 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) + { + if (!$constraint instanceof ValidProjectBuildRequest) { + throw new UnexpectedTypeException($constraint, ValidProjectBuildRequest::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!$value instanceof ProjectBuildRequest) { + throw new UnexpectedTypeException($value, ProjectBuildRequest::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.project_build.lot_must_not_smaller_0') + ->addViolation(); + } + + if ($withdraw_amount > $lot->getAmount()) { + $this->buildViolationForLot($lot, 'validator.project_build.lot_must_not_bigger_than_stock') + ->addViolation(); + } + + if ($withdraw_sum > $needed_amount) { + $this->buildViolationForLot($lot, 'validator.project_build.lot_bigger_than_needed') + ->addViolation(); + } + + if ($withdraw_sum < $needed_amount) { + $this->buildViolationForLot($lot, 'validator.project_build.lot_smaller_than_needed') + ->addViolation(); + } + } + } + } +} \ No newline at end of file diff --git a/templates/Projects/build/_form.html.twig b/templates/Projects/build/_form.html.twig new file mode 100644 index 00000000..4a02dd4d --- /dev/null +++ b/templates/Projects/build/_form.html.twig @@ -0,0 +1,85 @@ +{% import "helper.twig" as helper %} + +{{ form_start(form) }} + + + + + + + + + + + + {% for bom_entry in build_request.bomEntries %} + {# 1st row basic infos about the BOM entry #} + + + + + + + + + + {% endfor %} + +
+
+ +
+
{% trans %}part.table.name{% endtrans %}{% trans %}project.bom.mountnames{% endtrans %}{% trans %}project.build.required_qty{% endtrans %}
+
+ + {#
+
+ {% if bom_entry.part %} + {{ bom_entry.part.name }} {% if bom_entry.name %}({{ bom_entry.name }}){% endif %} + {% else %} + {{ bom_entry.name }} + {% endif %} + + {% for tag in bom_entry.mountnames|split(',') %} + {{ tag | trim }} + {% endfor %} + + {{ build_request.neededAmountForBOMEntry(bom_entry) | format_amount(bom_entry.part.partUnit ?? null) }} {% trans %}project.builds.needed{% endtrans %} + (= {{ number_of_builds }} x {{ bom_entry.quantity | format_amount(bom_entry.part.partUnit ?? null) }}) +
+ {% set lots = build_request.partLotsForBOMEntry(bom_entry) %} + {% if lots is not null %} + {% for lot in lots %} + {# @var lot \App\Entity\Parts\PartLot #} +
+ +
+ {{ form_errors(form["lot_"~lot.id]) }} + {{ form_widget(form["lot_"~lot.id]) }} +
+
+ / {{ lot.amount | format_amount(lot.part.partUnit) }} {% trans %}project.builds.stocked{% endtrans %} +
+
+ {% endfor %} + {% endif %} +
+ +{{ form_row(form.comment) }} + +{{ form_row(form.addBuildsToBuildsPart) }} +{% if form.buildsPartLot is defined %} + {{ form_row(form.buildsPartLot) }} +{% endif %} + +{{ form_row(form.submit) }} + +{{ form_end(form) }} \ No newline at end of file diff --git a/templates/Projects/build/build.html.twig b/templates/Projects/build/build.html.twig new file mode 100644 index 00000000..e1c6844a --- /dev/null +++ b/templates/Projects/build/build.html.twig @@ -0,0 +1,40 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}project.info.builds.label{% endtrans %}: {{ number_of_builds }}x {{ project.name }}{% endblock %} + +{% block card_title %} + + {% trans %}project.info.builds.label{% endtrans %}: {{ number_of_builds }}x {{ project.name }} +{% endblock %} + +{% block card_content %} + {% set can_build = buildHelper.projectBuildable(project, number_of_builds) %} + {% import "components/projects.macro.html.twig" as project_macros %} + + {% if project.status is not empty and project.status != "in_production" %} + + {% endif %} + + + +

{% trans %}project.build.help{% endtrans %}

+ + {% include 'Projects/build/_form.html.twig' %} + + +{% endblock %} \ No newline at end of file diff --git a/templates/Projects/info/_builds.html.twig b/templates/Projects/info/_builds.html.twig new file mode 100644 index 00000000..5418a614 --- /dev/null +++ b/templates/Projects/info/_builds.html.twig @@ -0,0 +1,40 @@ +{% set can_build = buildHelper.projectBuildable(project) %} + +{% import "components/projects.macro.html.twig" as project_macros %} + +{% if project.status is not empty and project.status != "in_production" %} + +{% endif %} + + + +
+
+
+
+ + + +
+
+
+
+ +{% if project.buildPart %} +

{% trans %}project.builds.no_stocked_builds{% endtrans %}: {{ project.buildPart.amountSum }}

+{% endif %} \ No newline at end of file diff --git a/templates/Projects/info/info.html.twig b/templates/Projects/info/info.html.twig index ce920e9e..9a57e55c 100644 --- a/templates/Projects/info/info.html.twig +++ b/templates/Projects/info/info.html.twig @@ -47,6 +47,13 @@ {{ project.bomEntries | length }} + {% if project.attachments is not empty %}