From d8fdaa9529224207836f338974556f3803f498a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 23:17:10 +0100 Subject: [PATCH] Added a modal to stocktake / set part lots amount from info page --- .../pages/part_stocktake_modal_controller.js | 27 ++++++++ src/Controller/PartController.php | 50 +++++++++++++++ src/Entity/LogSystem/PartStockChangeType.php | 4 ++ .../LogSystem/PartStockChangedLogEntry.php | 5 ++ .../Parts/PartLotWithdrawAddHelper.php | 41 ++++++++++++ templates/parts/info/_part_lots.html.twig | 7 ++- .../parts/info/_stocktake_modal.html.twig | 63 +++++++++++++++++++ translations/messages.en.xlf | 30 +++++++++ 8 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 assets/controllers/pages/part_stocktake_modal_controller.js create mode 100644 templates/parts/info/_stocktake_modal.html.twig diff --git a/assets/controllers/pages/part_stocktake_modal_controller.js b/assets/controllers/pages/part_stocktake_modal_controller.js new file mode 100644 index 00000000..7aef2906 --- /dev/null +++ b/assets/controllers/pages/part_stocktake_modal_controller.js @@ -0,0 +1,27 @@ +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)); + } + + _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 lotID = button.getAttribute('data-lot-id'); + const lotAmount = button.getAttribute('data-lot-amount'); + + //Find the expected amount field and set the value to the lot amount + const expectedAmountInput = this.element.querySelector('#stocktake-modal-expected-amount'); + expectedAmountInput.textContent = lotAmount; + + //Set the action and lotID inputs in the form + this.element.querySelector('input[name="lot_id"]').setAttribute('value', lotID); + } +} diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index ef2bae5f..d9fcd7f1 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -54,12 +54,14 @@ use Exception; use Omines\DataTablesBundle\DataTableFactory; use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; use Symfony\Contracts\Translation\TranslatorInterface; use function Symfony\Component\Translation\t; @@ -463,6 +465,54 @@ final class PartController extends AbstractController ); } + #[Route(path: '/{id}/stocktake', name: 'part_stocktake', methods: ['POST'])] + #[IsCsrfTokenValid(new Expression("'part_stocktake-' ~ args['part'].getid()"), '_token')] + public function stocktakeHandler(Part $part, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper, + Request $request, + ): Response + { + $partLot = $em->find(PartLot::class, $request->request->get('lot_id')); + + //Check that the user is allowed to stocktake the partlot + $this->denyAccessUnlessGranted('stocktake', $partLot); + + if (!$partLot instanceof PartLot) { + throw new \RuntimeException('Part lot not found!'); + } + //Ensure that the partlot belongs to the part + if ($partLot->getPart() !== $part) { + throw new \RuntimeException("The origin partlot does not belong to the part!"); + } + + $actualAmount = (float) $request->request->get('actual_amount'); + $comment = $request->request->get('comment'); + + $timestamp = null; + $timestamp_str = $request->request->getString('timestamp', ''); + //Try to parse the timestamp + if ($timestamp_str !== '') { + $timestamp = new DateTime($timestamp_str); + } + + $withdrawAddHelper->stocktake($partLot, $actualAmount, $comment, $timestamp); + + //Ensure that the timestamp is not in the future + if ($timestamp !== null && $timestamp > new DateTime("+20min")) { + throw new \LogicException("The timestamp must not be in the future!"); + } + + //Save the changes to the DB + $em->flush(); + $this->addFlash('success', 'part.withdraw.success'); + + //If a 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()]); + } + #[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])] public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response { diff --git a/src/Entity/LogSystem/PartStockChangeType.php b/src/Entity/LogSystem/PartStockChangeType.php index f69fe95f..79e4c6da 100644 --- a/src/Entity/LogSystem/PartStockChangeType.php +++ b/src/Entity/LogSystem/PartStockChangeType.php @@ -28,6 +28,8 @@ enum PartStockChangeType: string case WITHDRAW = "withdraw"; case MOVE = "move"; + case STOCKTAKE = "stock_take"; + /** * Converts the type to a short representation usable in the extra field of the log entry. * @return string @@ -38,6 +40,7 @@ enum PartStockChangeType: string self::ADD => 'a', self::WITHDRAW => 'w', self::MOVE => 'm', + self::STOCKTAKE => 's', }; } @@ -52,6 +55,7 @@ enum PartStockChangeType: string 'a' => self::ADD, 'w' => self::WITHDRAW, 'm' => self::MOVE, + 's' => self::STOCKTAKE, default => throw new \InvalidArgumentException("Invalid short type: $value"), }; } diff --git a/src/Entity/LogSystem/PartStockChangedLogEntry.php b/src/Entity/LogSystem/PartStockChangedLogEntry.php index 1bac9e9f..a46f2ecf 100644 --- a/src/Entity/LogSystem/PartStockChangedLogEntry.php +++ b/src/Entity/LogSystem/PartStockChangedLogEntry.php @@ -122,6 +122,11 @@ class PartStockChangedLogEntry extends AbstractLogEntry return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target, action_timestamp: $action_timestamp); } + public static function stocktake(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self + { + return new self(PartStockChangeType::STOCKTAKE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp); + } + /** * Returns the instock change type of this entry * @return PartStockChangeType diff --git a/src/Services/Parts/PartLotWithdrawAddHelper.php b/src/Services/Parts/PartLotWithdrawAddHelper.php index 34ec4c1d..d6a95b34 100644 --- a/src/Services/Parts/PartLotWithdrawAddHelper.php +++ b/src/Services/Parts/PartLotWithdrawAddHelper.php @@ -197,4 +197,45 @@ final class PartLotWithdrawAddHelper $this->entityManager->remove($origin); } } + + /** + * Perform a stocktake for the given part lot, setting the amount to the given actual amount. + * Please note that the changes are not flushed to DB yet, you have to do this yourself + * @param PartLot $lot + * @param float $actualAmount + * @param string|null $comment + * @param \DateTimeInterface|null $action_timestamp + * @return void + */ + public function stocktake(PartLot $lot, float $actualAmount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null): void + { + if ($actualAmount < 0) { + throw new \InvalidArgumentException('Actual amount must be non-negative'); + } + + $part = $lot->getPart(); + + //Check whether we have to round the amount + if (!$part->useFloatAmount()) { + $actualAmount = round($actualAmount); + } + + $oldAmount = $lot->getAmount(); + //Clear any unknown status when doing a stocktake, as we now have a known amount + $lot->setInstockUnknown(false); + $lot->setAmount($actualAmount); + if ($action_timestamp) { + $lot->setLastStocktakeAt(\DateTimeImmutable::createFromInterface($action_timestamp)); + } else { + $lot->setLastStocktakeAt(new \DateTimeImmutable()); //Use now if no timestamp is given + } + + $event = PartStockChangedLogEntry::stocktake($lot, $oldAmount, $lot->getAmount(), $part->getAmountSum() , $comment, $action_timestamp); + $this->eventLogger->log($event); + + //Apply the comment also to global events, so it gets associated with the elementChanged log entry + if (!$this->eventCommentHelper->isMessageSet() && ($comment !== null && $comment !== '')) { + $this->eventCommentHelper->setMessage($comment); + } + } } diff --git a/templates/parts/info/_part_lots.html.twig b/templates/parts/info/_part_lots.html.twig index f4ee4812..cfb7190b 100644 --- a/templates/parts/info/_part_lots.html.twig +++ b/templates/parts/info/_part_lots.html.twig @@ -2,6 +2,7 @@ {% import "label_system/dropdown_macro.html.twig" as dropdown %} {% include "parts/info/_withdraw_modal.html.twig" %} +{% include "parts/info/_stocktake_modal.html.twig" %}
@@ -93,12 +94,15 @@ > + + - - {% endfor %} diff --git a/templates/parts/info/_stocktake_modal.html.twig b/templates/parts/info/_stocktake_modal.html.twig new file mode 100644 index 00000000..5e8c1ae5 --- /dev/null +++ b/templates/parts/info/_stocktake_modal.html.twig @@ -0,0 +1,63 @@ + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index e73caaf2..bbd96ac6 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12479,5 +12479,35 @@ Buerklin-API Authentication server: Stocktake + + + part.info.stocktake_modal.title + Stocktake lot + + + + + part.info.stocktake_modal.expected_amount + Expected amount + + + + + part.info.stocktake_modal.actual_amount + Actual amount + + + + + log.part_stock_changed.stock_take + Stocktake + + + + + log.element_edited.changed_fields.last_stocktake_at + Last stocktake + +
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }} {# Action for order information #}