diff --git a/assets/controllers/pages/part_withdraw_modal_controller.js b/assets/controllers/pages/part_withdraw_modal_controller.js new file mode 100644 index 00000000..f7e6e7a7 --- /dev/null +++ b/assets/controllers/pages/part_withdraw_modal_controller.js @@ -0,0 +1,41 @@ +import {Controller} from "@hotwired/stimulus"; +import {Modal} from "bootstrap"; + +export default class extends Controller +{ + connect() { + + + this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event)); + + //Register an event to remove the backdrop, when the form is submitted + const form = this.element.querySelector('form'); + form.addEventListener('submit', event => { + //Remove the backdrop + document.querySelector('.modal-backdrop').remove(); + }); + } + + _handleModalOpen(event) { + // Button that triggered the modal + const button = event.relatedTarget; + + const amountInput = this.element.querySelector('input[name="amount"]'); + + // Extract info from button attributes + const action = button.getAttribute('data-action'); + const lotID = button.getAttribute('data-lot-id'); + const lotAmount = button.getAttribute('data-lot-amount'); + + //Set the action and lotID inputs in the form + this.element.querySelector('input[name="action"]').setAttribute('value', action); + this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID); + + //For adding parts there is no limit on the amount to add + if (action == 'add') { + amountInput.removeAttribute('max'); + } else { //Every other action is limited to the amount of parts in the lot + amountInput.setAttribute('max', lotAmount); + } + } +} \ No newline at end of file diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index dc5b1bf8..7c626b8a 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -40,6 +40,7 @@ use App\Services\LogSystem\EventCommentHelper; use App\Services\LogSystem\HistoryHelper; use App\Services\LogSystem\TimeTravel; use App\Services\Parameters\ParameterExtractor; +use App\Services\Parts\PartLotWithdrawAddHelper; use App\Services\Parts\PricedetailHelper; use App\Services\ProjectSystem\ProjectBuildPartHelper; use DateTime; @@ -52,6 +53,7 @@ use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Contracts\Translation\TranslatorInterface; @@ -319,4 +321,60 @@ class PartController extends AbstractController 'form' => $form, ]); } + + /** + * @Route("/{id}/add_withdraw", name="part_add_withdraw", methods={"POST"}) + */ + public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response + { + if ($this->isCsrfTokenValid('part_withraw' . $part->getID(), $request->request->get('_csfr'))) { + //Retrieve partlot from the request + $partLot = $em->find(PartLot::class, $request->request->get('lot_id')); + //Ensure that the partlot belongs to the part + if($partLot->getPart() !== $part) { + throw new \RuntimeException("The origin partlot does not belong to the part!"); + } + //Try to determine the target lot (used for move actions) + $targetLot = $em->find(PartLot::class, $request->request->get('target_id')); + if ($targetLot && $targetLot->getPart() !== $part) { + throw new \RuntimeException("The target partlot does not belong to the part!"); + } + + //Extract the amount and comment from the request + $amount = (float) $request->request->get('amount'); + $comment = $request->request->get('comment'); + $action = $request->request->get('action'); + + + + switch ($action) { + case "withdraw": + case "remove": + $withdrawAddHelper->withdraw($partLot, $amount, $comment); + break; + case "add": + $withdrawAddHelper->add($partLot, $amount, $comment); + break; + case "move": + $withdrawAddHelper->move($partLot, $targetLot, $amount, $comment); + break; + default: + throw new \RuntimeException("Unknown action!"); + } + + //Save the changes to the DB + $em->flush(); + $this->addFlash('success', 'part.withdraw.success'); + + } else { + $this->addFlash('error', 'CSRF Token invalid!'); + } + + //If an redirect was passed, then redirect there + if($request->request->get('_redirect')) { + return $this->redirect($request->request->get('_redirect')); + } + //Otherwise just redirect to the part page + return $this->redirectToRoute('part_info', ['id' => $part->getID()]); + } } diff --git a/src/Form/AdminPages/StorelocationAdminForm.php b/src/Form/AdminPages/StorelocationAdminForm.php index 9caf1169..8a85e4ec 100644 --- a/src/Form/AdminPages/StorelocationAdminForm.php +++ b/src/Form/AdminPages/StorelocationAdminForm.php @@ -38,21 +38,21 @@ class StorelocationAdminForm extends BaseEntityAdminForm 'required' => false, 'label' => 'storelocation.edit.is_full.label', 'help' => 'storelocation.edit.is_full.help', - 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity), + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); $builder->add('limit_to_existing_parts', CheckboxType::class, [ 'required' => false, 'label' => 'storelocation.limit_to_existing.label', 'help' => 'storelocation.limit_to_existing.help', - 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity), + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); $builder->add('only_single_part', CheckboxType::class, [ 'required' => false, 'label' => 'storelocation.only_single_part.label', 'help' => 'storelocation.only_single_part.help', - 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity), + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); $builder->add('storage_type', StructuralEntityType::class, [ @@ -61,7 +61,7 @@ class StorelocationAdminForm extends BaseEntityAdminForm 'help' => 'storelocation.storage_type.help', 'class' => MeasurementUnit::class, 'disable_not_selectable' => true, - 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'move', $entity), + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); } } diff --git a/src/Services/Parts/PartLotWithdrawAddHelper.php b/src/Services/Parts/PartLotWithdrawAddHelper.php new file mode 100644 index 00000000..3fbfb074 --- /dev/null +++ b/src/Services/Parts/PartLotWithdrawAddHelper.php @@ -0,0 +1,157 @@ +isInstockUnknown()) { + return false; + } + + //So far all other restrictions are defined at the storelocation level + if($partLot->getStorageLocation() === null) { + return true; + } + + //We can not add parts if the storage location of the lot is marked as full + if($partLot->getStorageLocation()->isFull()) { + return false; + } + + return true; + } + + public function canWithdraw(PartLot $partLot): bool + { + //We cannot add or withdraw parts from lots with unknown instock value. + if ($partLot->isInstockUnknown()) { + return false; + } + + return true; + } + + /** + * Withdraw the specified amount of parts from the given part lot. + * Please note that the changes are not flushed to DB yet, you have to do this yourself + * @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased) + * @param float $amount The amount of parts that should be taken from the part lot + * @param string|null $comment The optional comment describing the reason for the withdrawal + * @return PartLot The modified part lot + */ + public function withdraw(PartLot $partLot, float $amount, ?string $comment = null): PartLot + { + //Ensure that amount is positive + if ($amount <= 0) { + throw new \InvalidArgumentException('Amount must be positive'); + } + + $part = $partLot->getPart(); + + //Check whether we have to round the amount + if (!$part->useFloatAmount()) { + $amount = round($amount); + } + + //Ensure that we can withdraw from the part lot + if (!$this->canWithdraw($partLot)) { + throw new \RuntimeException("Cannot withdraw from this part lot!"); + } + + //Ensure that there is enough stock to withdraw + if ($amount > $partLot->getAmount()) { + throw new \RuntimeException('Not enough stock to withdraw!'); + } + + //Subtract the amount from the part lot + $partLot->setAmount($partLot->getAmount() - $amount); + + return $partLot; + } + + /** + * Add the specified amount of parts to the given part lot. + * Please note that the changes are not flushed to DB yet, you have to do this yourself + * @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased) + * @param float $amount The amount of parts that should be taken from the part lot + * @param string|null $comment The optional comment describing the reason for the withdrawal + * @return PartLot The modified part lot + */ + public function add(PartLot $partLot, float $amount, ?string $comment = null): PartLot + { + if ($amount <= 0) { + throw new \InvalidArgumentException('Amount must be positive'); + } + + $part = $partLot->getPart(); + + //Check whether we have to round the amount + if (!$part->useFloatAmount()) { + $amount = round($amount); + } + + //Ensure that we can add to the part lot + if (!$this->canAdd($partLot)) { + throw new \RuntimeException("Cannot add to this part lot!"); + } + + //Subtract the amount from the part lot + $partLot->setAmount($partLot->getAmount() + $amount); + + return $partLot; + } + + /** + * Move the specified amount of parts from the given source part lot to the given target part lot. + * Please note that the changes are not flushed to DB yet, you have to do this yourself + * @param PartLot $origin The part lot from which the parts should be taken + * @param PartLot $target The part lot to which the parts should be added + * @param float $amount The amount of parts that should be moved + * @param string|null $comment A comment describing the reason for the move + * @return void + */ + public function move(PartLot $origin, PartLot $target, float $amount, ?string $comment = null): void + { + if ($amount <= 0) { + throw new \InvalidArgumentException('Amount must be positive'); + } + + $part = $origin->getPart(); + + //Ensure that both part lots belong to the same part + if($origin->getPart() !== $target->getPart()) { + throw new \RuntimeException("Cannot move instock between different parts!"); + } + + //Check whether we have to round the amount + if (!$part->useFloatAmount()) { + $amount = round($amount); + } + + //Ensure that we can withdraw from origin and add to target + if (!$this->canWithdraw($origin) || !$this->canAdd($target)) { + throw new \RuntimeException("Cannot move instock between these part lots!"); + } + + //Ensure that there is enough stock to withdraw + if ($amount > $origin->getAmount()) { + throw new \RuntimeException('Not enough stock to withdraw!'); + } + + //Subtract the amount from the part lot + $origin->setAmount($origin->getAmount() - $amount); + //And add it to the target + $target->setAmount($target->getAmount() + $amount); + } +} \ No newline at end of file diff --git a/templates/Parts/info/_part_lots.html.twig b/templates/Parts/info/_part_lots.html.twig index c28dc2ac..3742dac0 100644 --- a/templates/Parts/info/_part_lots.html.twig +++ b/templates/Parts/info/_part_lots.html.twig @@ -1,6 +1,8 @@ {% import "helper.twig" as helper %} {% import "LabelSystem/dropdown_macro.html.twig" as dropdown %} +{% include "Parts/info/_withdraw_modal.html.twig" %} + @@ -8,6 +10,7 @@ {# Tags row #} + {# Button row #} @@ -57,6 +60,26 @@ {% endif %} + diff --git a/templates/Parts/info/_withdraw_modal.html.twig b/templates/Parts/info/_withdraw_modal.html.twig new file mode 100644 index 00000000..0a039d4f --- /dev/null +++ b/templates/Parts/info/_withdraw_modal.html.twig @@ -0,0 +1,56 @@ + \ No newline at end of file diff --git a/tests/Services/Parts/PartLotWithdrawAddHelperTest.php b/tests/Services/Parts/PartLotWithdrawAddHelperTest.php new file mode 100644 index 00000000..f9fc323e --- /dev/null +++ b/tests/Services/Parts/PartLotWithdrawAddHelperTest.php @@ -0,0 +1,149 @@ +service = self::getContainer()->get(PartLotWithdrawAddHelper::class); + + $this->fillTestData(); + } + + private function fillTestData(): void + { + $this->part = new Part(); + + $this->storageLocation = new Storelocation(); + $this->full_storageLocation = new Storelocation(); + $this->full_storageLocation->setIsFull(true); + + $this->partLot1 = new PartLot(); + $this->partLot1->setPart($this->part); + $this->partLot1->setAmount(10); + + $this->partLot2 = new PartLot(); + $this->partLot2->setPart($this->part); + $this->partLot2->setStorageLocation($this->storageLocation); + $this->partLot2->setAmount(2); + + $this->partLot3 = new PartLot(); + $this->partLot3->setPart($this->part); + $this->partLot3->setAmount(0); + + $this->fullLot = new PartLot(); + $this->fullLot->setPart($this->part); + $this->fullLot->setAmount(45); + $this->fullLot->setStorageLocation($this->full_storageLocation); + + $this->lotWithUnknownInstock = new PartLot(); + $this->lotWithUnknownInstock->setPart($this->part); + $this->lotWithUnknownInstock->setAmount(5); + $this->lotWithUnknownInstock->setInstockUnknown(true); + $this->lotWithUnknownInstock->setStorageLocation($this->storageLocation); + } + + public function testCanWithdraw() + { + //Normal lots should be withdrawable + $this->assertTrue($this->service->canWithdraw($this->partLot1)); + $this->assertTrue($this->service->canWithdraw($this->partLot2)); + $this->assertTrue($this->service->canWithdraw($this->partLot3)); + + //Full lots should be withdrawable + $this->assertTrue($this->service->canWithdraw($this->fullLot)); + //Lots with unknown instock should not be withdrawable + $this->assertFalse($this->service->canWithdraw($this->lotWithUnknownInstock)); + } + + public function testCanAdd() + { + //Normal lots should be addable + $this->assertTrue($this->service->canAdd($this->partLot1)); + $this->assertTrue($this->service->canAdd($this->partLot2)); + $this->assertTrue($this->service->canAdd($this->partLot3)); + + //Full lots should not be addable + $this->assertFalse($this->service->canAdd($this->fullLot)); + //Lots with unknown instock should not be addable + $this->assertFalse($this->service->canAdd($this->lotWithUnknownInstock)); + } + + public function testAdd() + { + //Add 5 to lot 1 + $this->service->add($this->partLot1, 5, "Test"); + $this->assertEquals(15, $this->partLot1->getAmount()); + + //Add 3.2 to lot 2 + $this->service->add($this->partLot2, 3.2, "Test"); + $this->assertEquals(5, $this->partLot2->getAmount()); + + //Add 1.5 to lot 3 + $this->service->add($this->partLot3, 1.5, "Test"); + $this->assertEquals(2, $this->partLot3->getAmount()); + + } + + public function testWithdraw() + { + //Withdraw 5 from lot 1 + $this->service->withdraw($this->partLot1, 5, "Test"); + $this->assertEquals(5, $this->partLot1->getAmount()); + + //Withdraw 2.2 from lot 2 + $this->service->withdraw($this->partLot2, 2.2, "Test"); + $this->assertEquals(0, $this->partLot2->getAmount()); + } + + public function testMove() + { + //Move 5 from lot 1 to lot 2 + $this->service->move($this->partLot1, $this->partLot2, 5, "Test"); + $this->assertEquals(5, $this->partLot1->getAmount()); + $this->assertEquals(7, $this->partLot2->getAmount()); + + //Move 2.2 from lot 2 to lot 3 + $this->service->move($this->partLot2, $this->partLot3, 2.2, "Test"); + $this->assertEquals(5, $this->partLot2->getAmount()); + $this->assertEquals(2, $this->partLot3->getAmount()); + } +}
{% trans %}part_lots.storage_location{% endtrans %} {% trans %}part_lots.amount{% endtrans %}
+
+ + + +
+
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }}