Merge branch 'project_system'

This commit is contained in:
Jan Böhmer 2023-01-22 23:47:23 +01:00
commit bb4c05624d
19 changed files with 1389 additions and 2 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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;
}
}
}

View file

@ -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+"})

View file

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

View file

@ -0,0 +1,171 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Form\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());
}
}

View file

@ -0,0 +1,63 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Form\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']);
};
}
]);
}
}

View file

@ -0,0 +1,302 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Helpers\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<int, float>
*/
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;
}
}

View file

@ -0,0 +1,162 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Services\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);
}
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,36 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Validator\Constraints\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;
}
}

View file

@ -0,0 +1,82 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Validator\Constraints\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();
}
}
}
}
}

View file

@ -0,0 +1,85 @@
{% import "helper.twig" as helper %}
{{ form_start(form) }}
<table class="table table-sm table-responsive table-hover">
<thead>
<tr>
<th>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" data-multicheck-name="lots_check" {{ stimulus_controller('pages/checkbox_multicheck') }}>
</div>
</th>
<th>{% trans %}part.table.name{% endtrans %}</th>
<th>{% trans %}project.bom.mountnames{% endtrans %}</th>
<th class="text-end">{% trans %}project.build.required_qty{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for bom_entry in build_request.bomEntries %}
{# 1st row basic infos about the BOM entry #}
<tr class="{% if bom_entry.part is null or buildHelper.bOMEntryBuildable(bom_entry, number_of_builds) %}table-primary{% else %}table-danger{% endif %}">
<td style="width: 20px;">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" data-multicheck-name="lots_check" required>
{# <label class="form-check-label" for="checkbox_{{ loop.index }}"> #}
</div>
</td>
<td >
{% if bom_entry.part %}
<b><a target="_blank" href="{{ entity_url(bom_entry.part) }}">{{ bom_entry.part.name }}</a></b> {% if bom_entry.name %}({{ bom_entry.name }}){% endif %}
{% else %}
<b>{{ bom_entry.name }}</b>
{% endif %}
</td>
<td>
{% for tag in bom_entry.mountnames|split(',') %}
<span class="badge bg-secondary badge-secondary" >{{ tag | trim }}</span>
{% endfor %}
</td>
<td class="text-end">
<b>{{ build_request.neededAmountForBOMEntry(bom_entry) | format_amount(bom_entry.part.partUnit ?? null) }}</b> {% trans %}project.builds.needed{% endtrans %}
(= {{ number_of_builds }} x {{ bom_entry.quantity | format_amount(bom_entry.part.partUnit ?? null) }})
</td>
</tr>
<tr>
<td colspan="4">
{% set lots = build_request.partLotsForBOMEntry(bom_entry) %}
{% if lots is not null %}
{% for lot in lots %}
{# @var lot \App\Entity\Parts\PartLot #}
<div class="mb-2 row">
<label class="col-form-label col-sm-4" for="category_admin_form_parent">
{% if lot.storageLocation %}
<small>{{ helper.structural_entity_link(lot.storageLocation) }}</small>
{% endif %}
{% if lot.name is not empty %}
(<small>{{ lot.name }}</small>)
{% endif %}
</label>
<div class="col-sm-6">
{{ form_errors(form["lot_"~lot.id]) }}
{{ form_widget(form["lot_"~lot.id]) }}
</div>
<div class="col-sm-2 mt-1 text-end">
/ <b>{{ lot.amount | format_amount(lot.part.partUnit) }}</b> {% trans %}project.builds.stocked{% endtrans %}
</div>
</div>
{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ 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) }}

View file

@ -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 %}
<i class="fa-solid fa-bolt fa-fw"></i>
{% trans %}project.info.builds.label{% endtrans %}: <b>{{ number_of_builds }}x</b> <i>{{ project.name }}</i>
{% 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" %}
<div class="alert alert-warning" role="alert">
<i class="fa-solid fa-triangle-exclamation fa-fw"></i> {% trans with {"%project_status%": ('project.status.'~project.status)|trans } %}project.builds.check_project_status{% endtrans %}
</div>
{% endif %}
<div class="alert {% if can_build %}alert-success{% else %}alert-danger{% endif %}" role="alert">
{% if not can_build %}
<h5><i class="fa-solid fa-circle-exclamation fa-fw"></i> {% trans %}project.builds.build_not_possible{% endtrans %}</h5>
<b>{% trans with {"%number_of_builds%": number_of_builds} %}project.builds.following_bom_entries_miss_instock_n{% endtrans %}</b>
<ul>
{% for bom_entry in buildHelper.nonBuildableProjectBomEntries(project, number_of_builds) %}
<li>{{ project_macros.project_bom_entry_with_missing_instock(bom_entry, number_of_builds) }}</li>
{% endfor %}
</ul>
{% else %}
<h5><i class="fa-solid fa-circle-check fa-fw"></i> {% trans %}project.builds.build_possible{% endtrans %}</h5>
<span>{% trans with {"%max_builds%": number_of_builds} %}project.builds.number_of_builds_possible{% endtrans %}</span>
{% endif %}
</div>
<p class="text-muted">{% trans %}project.build.help{% endtrans %}</p>
{% include 'Projects/build/_form.html.twig' %}
{% endblock %}

View file

@ -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" %}
<div class="alert mt-2 alert-warning" role="alert">
<i class="fa-solid fa-triangle-exclamation fa-fw"></i> {% trans with {"%project_status%": ('project.status.'~project.status)|trans } %}project.builds.check_project_status{% endtrans %}
</div>
{% endif %}
<div class="alert mt-2 {% if can_build %}alert-success{% else %}alert-danger{% endif %}" role="alert">
{% if not can_build %}
<h5><i class="fa-solid fa-circle-exclamation fa-fw"></i> {% trans %}project.builds.build_not_possible{% endtrans %}</h5>
<b>{% trans %}project.builds.following_bom_entries_miss_instock{% endtrans %}</b>
<ul>
{% for bom_entry in buildHelper.nonBuildableProjectBomEntries(project) %}
<li>{{ project_macros.project_bom_entry_with_missing_instock(bom_entry) }}</li>
{% endfor %}
</ul>
{% else %}
<h5><i class="fa-solid fa-circle-check fa-fw"></i> {% trans %}project.builds.build_possible{% endtrans %}</h5>
<span>{% trans with {"%max_builds%": buildHelper.maximumBuildableCount(project)} %}project.builds.number_of_builds_possible{% endtrans %}</span>
{% endif %}
</div>
<form method="get" action="{{ path('project_build', {"id": project.iD }) }}">
<div class="row mt-2">
<div class="col-4">
<div class="input-group mb-3">
<input type="number" min="1" class="form-control" placeholder="{% trans %}project.builds.number_of_builds{% endtrans %}" name="n" required>
<input type="hidden" name="_redirect" value="{{ app.request.requestUri }}">
<button class="btn btn-outline-secondary" type="submit" id="button-addon2">{% trans %}project.build.btn_build{% endtrans %}</button>
</div>
</div>
</div>
</form>
{% if project.buildPart %}
<p><b>{% trans %}project.builds.no_stocked_builds{% endtrans %}:</b> <a href="{{ entity_url(project.buildPart) }}">{{ project.buildPart.amountSum }}</a></p>
{% endif %}

View file

@ -47,6 +47,13 @@
<span class="badge bg-secondary">{{ project.bomEntries | length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="builds-tab" data-bs-toggle="tab" data-bs-target="#builds-tab-pane"
type="button" role="tab" aria-controls="builds-tab-pane" aria-selected="false">
<i class="fa-solid fa-bolt fa-fw"></i>
{% trans %}project.info.builds.label{% endtrans %}
</button>
</li>
{% if project.attachments is not empty %}
<li class="nav-item">
<a class="nav-link" id="attachments-tab" data-bs-toggle="tab"
@ -81,6 +88,9 @@
<div class="tab-pane fade" id="bom-tab-pane" role="tabpanel" aria-labelledby="bom-tab" tabindex="0">
{% include "Projects/info/_bom.html.twig" %}
</div>
<div class="tab-pane fade" id="builds-tab-pane" role="tabpanel" aria-labelledby="builds-tab" tabindex="0">
{% include "Projects/info/_builds.html.twig" %}
</div>
<div class="tab-pane fade" id="attachments-tab-pane" role="tabpanel" aria-labelledby="attachments-tab" tabindex="0">
{% include "Parts/info/_attachments_info.html.twig" with {"part": project} %}
</div>

View file

@ -0,0 +1,8 @@
{% macro project_bom_entry_with_missing_instock(project_bom_entry, number_of_builds = 1) %}
{# @var \App\Entity\ProjectSystem\ProjectBOMEntry project_bom_entry #}
<b><a href="{{ entity_url(project_bom_entry.part) }}">{{ project_bom_entry.part.name }}</a></b>
{% if project_bom_entry.name %}&nbsp;({{ project_bom_entry.name }}){% endif %}:
<b>{{ project_bom_entry.part.amountSum | format_amount(project_bom_entry.part.partUnit) }}</b> {% trans %}project.builds.stocked{% endtrans %}
/
<b>{{ (project_bom_entry.quantity * number_of_builds) | format_amount(project_bom_entry.part.partUnit) }}</b> {% trans %}project.builds.needed{% endtrans %}
{% endmacro %}

View file

@ -0,0 +1,116 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\ProjectSystem;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\ProjectSystem\ProjectBuildHelper;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ProjectBuildHelperTest extends WebTestCase
{
/** @var ProjectBuildHelper */
protected $service;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$this->service = self::getContainer()->get(ProjectBuildHelper::class);
}
public function testGetMaximumBuildableCountForBOMEntryNonPartBomEntry()
{
$bom_entry = new ProjectBOMEntry();
$bom_entry->setPart(null);
$bom_entry->setQuantity(10);
$bom_entry->setName('Test');
$this->expectException(\InvalidArgumentException::class);
$this->service->getMaximumBuildableCountForBOMEntry($bom_entry);
}
public function testGetMaximumBuildableCountForBOMEntry()
{
$project_bom_entry = new ProjectBOMEntry();
$project_bom_entry->setQuantity(10);
$part = new Part();
$lot1 = new PartLot();
$lot1->setAmount(120);
$lot2 = new PartLot();
$lot2->setAmount(5);
$part->addPartLot($lot1);
$part->addPartLot($lot2);
$project_bom_entry->setPart($part);
//We have 125 parts in stock, so we can build 12 times the project (125 / 10 = 12.5)
$this->assertEquals(12, $this->service->getMaximumBuildableCountForBOMEntry($project_bom_entry));
$lot1->setAmount(0);
//We have 5 parts in stock, so we can build 0 times the project (5 / 10 = 0.5)
$this->assertEquals(0, $this->service->getMaximumBuildableCountForBOMEntry($project_bom_entry));
}
public function testGetMaximumBuildableCount()
{
$project = new Project();
$project_bom_entry1 = new ProjectBOMEntry();
$project_bom_entry1->setQuantity(10);
$part = new Part();
$lot1 = new PartLot();
$lot1->setAmount(120);
$lot2 = new PartLot();
$lot2->setAmount(5);
$part->addPartLot($lot1);
$part->addPartLot($lot2);
$project_bom_entry1->setPart($part);
$project->addBomEntry($project_bom_entry1);
$project_bom_entry2 = new ProjectBOMEntry();
$project_bom_entry2->setQuantity(5);
$part2 = new Part();
$lot3 = new PartLot();
$lot3->setAmount(10);
$part2->addPartLot($lot3);
$project_bom_entry2->setPart($part2);
$project->addBomEntry($project_bom_entry2);
$project->addBomEntry((new ProjectBOMEntry())->setName('Non part entry')->setQuantity(1));
//Restricted by the few parts in stock of part2
$this->assertEquals(2, $this->service->getMaximumBuildableCount($project));
$lot3->setAmount(1000);
//Now the build count is restricted by the few parts in stock of part1
$this->assertEquals(12, $this->service->getMaximumBuildableCount($project));
$lot3->setAmount(0);
//Now the build count must be 0, as we have no parts in stock
$this->assertEquals(0, $this->service->getMaximumBuildableCount($project));
}
}

View file

@ -0,0 +1,53 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\Services\ProjectSystem;
use App\Entity\ProjectSystem\Project;
use App\Services\Parts\PricedetailHelper;
use App\Services\ProjectSystem\ProjectBuildPartHelper;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ProjectBuildPartHelperTest extends WebTestCase
{
/** @var ProjectBuildPartHelper */
protected $service;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$this->service = self::getContainer()->get(ProjectBuildPartHelper::class);
}
public function testGetPartInitialization(): void
{
$project = new Project();
$project->setName('Project 1');
$project->setDescription('Description 1');
$part = $this->service->getPartInitialization($project);
$this->assertSame('Project 1', $part->getName());
$this->assertSame('Description 1', $part->getDescription());
$this->assertSame($project, $part->getBuiltProject());
$this->assertSame($part, $project->getBuildPart());
}
}

View file

@ -10291,5 +10291,113 @@ Element 3</target>
<target>Empty label</target>
</segment>
</unit>
<unit id="YddcwVg" name="project.info.builds.label">
<segment>
<source>project.info.builds.label</source>
<target>Build</target>
</segment>
</unit>
<unit id="6uUxHyg" name="project.builds.build_not_possible">
<segment>
<source>project.builds.build_not_possible</source>
<target>Build not possible: Parts not stocked</target>
</segment>
</unit>
<unit id="HY05vl8" name="project.builds.following_bom_entries_miss_instock">
<segment>
<source>project.builds.following_bom_entries_miss_instock</source>
<target>The following parts have not enough stock to build this project at least once:</target>
</segment>
</unit>
<unit id="VHUY79k" name="project.builds.stocked">
<segment>
<source>project.builds.stocked</source>
<target>stocked</target>
</segment>
</unit>
<unit id="mwL3d70" name="project.builds.needed">
<segment>
<source>project.builds.needed</source>
<target>needed</target>
</segment>
</unit>
<unit id="jZKw98F" name="project.builds.build_possible">
<segment>
<source>project.builds.build_possible</source>
<target>Build possible</target>
</segment>
</unit>
<unit id="rRMnoEh" name="project.builds.number_of_builds_possible">
<segment>
<source>project.builds.number_of_builds_possible</source>
<target><![CDATA[You have enough stocked to build <b>%max_builds%</b> builds of this project.]]></target>
</segment>
</unit>
<unit id="fZhqvmk" name="project.builds.check_project_status">
<segment>
<source>project.builds.check_project_status</source>
<target><![CDATA[The current project status is <b>"%project_status%"</b>. You should check if you really want to build the project with this status!]]></target>
</segment>
</unit>
<unit id="57BNIbl" name="project.builds.following_bom_entries_miss_instock_n">
<segment>
<source>project.builds.following_bom_entries_miss_instock_n</source>
<target>You do not have enough parts stocked to build this project %number_of_builds% times. The following parts have missing instock:</target>
</segment>
</unit>
<unit id="dB.JmYm" name="project.build.flash.invalid_input">
<segment>
<source>project.build.flash.invalid_input</source>
<target>Can not build project. Check input!</target>
</segment>
</unit>
<unit id="_sFMgby" name="project.build.required_qty">
<segment>
<source>project.build.required_qty</source>
<target>Required quantity</target>
</segment>
</unit>
<unit id="5gpbL_k" name="project.build.btn_build">
<segment>
<source>project.build.btn_build</source>
<target>Build</target>
</segment>
</unit>
<unit id="AqGFkiA" name="project.build.help">
<segment>
<source>project.build.help</source>
<target>Choose from which part lots the stock to build this project should be taken (and in which amount). Check the checkbox for each BOM Entry, when you are finished withdrawing the parts, or use the top checkbox to check all boxes at once.</target>
</segment>
</unit>
<unit id="rvdUHEn" name="project.build.buildsPartLot.new_lot">
<segment>
<source>project.build.buildsPartLot.new_lot</source>
<target>Create new lot</target>
</segment>
</unit>
<unit id="Kn_jkyo" name="project.build.add_builds_to_builds_part">
<segment>
<source>project.build.add_builds_to_builds_part</source>
<target>Add builds to project builds part</target>
</segment>
</unit>
<unit id="92b1_QD" name="project.build.builds_part_lot">
<segment>
<source>project.build.builds_part_lot</source>
<target>Target lot</target>
</segment>
</unit>
<unit id="pyv0k6b" name="project.builds.number_of_builds">
<segment>
<source>project.builds.number_of_builds</source>
<target>Build amount</target>
</segment>
</unit>
<unit id="RjYY9MA" name="project.builds.no_stocked_builds">
<segment>
<source>project.builds.no_stocked_builds</source>
<target>Number of stocked builds</target>
</segment>
</unit>
</file>
</xliff>

View file

@ -281,5 +281,17 @@
<target>Prices are not allowed on BOM entries associated with a part. Define the price on the part instead.</target>
</segment>
</unit>
<unit id="ID056SR" name="validator.project_build.lot_bigger_than_needed">
<segment>
<source>validator.project_build.lot_bigger_than_needed</source>
<target>You have selected more quantity to withdraw than needed! Remove unnecessary quantity.</target>
</segment>
</unit>
<unit id="6hV5UqD" name="validator.project_build.lot_smaller_than_needed">
<segment>
<source>validator.project_build.lot_smaller_than_needed</source>
<target>You have selected less quantity to withdraw than needed for the build! Add additional quantity.</target>
</segment>
</unit>
</file>
</xliff>