From 2f9601364ee58c273e093eda035d878680d39bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 22:23:54 +0100 Subject: [PATCH 1/4] Allow to set stocktake date for part lots --- config/permissions.yaml | 3 ++ src/Form/Part/PartLotType.php | 9 ++++ src/Security/Voter/PartLotVoter.php | 4 +- .../parts/edit/edit_form_styles.html.twig | 1 + templates/parts/info/_part_lots.html.twig | 49 ++++++++++--------- translations/messages.en.xlf | 12 +++++ 6 files changed, 53 insertions(+), 25 deletions(-) diff --git a/config/permissions.yaml b/config/permissions.yaml index 0dabf9d3..39e91b57 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -68,6 +68,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co move: label: "perm.parts_stock.move" apiTokenRole: ROLE_API_EDIT + stocktake: + label: "perm.parts_stock.stocktake" + apiTokenRole: ROLE_API_EDIT storelocations: &PART_CONTAINING diff --git a/src/Form/Part/PartLotType.php b/src/Form/Part/PartLotType.php index 7d545340..ae86fb61 100644 --- a/src/Form/Part/PartLotType.php +++ b/src/Form/Part/PartLotType.php @@ -31,6 +31,7 @@ use App\Form\Type\StructuralEntityType; use App\Form\Type\UserSelectType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -110,6 +111,14 @@ class PartLotType extends AbstractType //Do not remove whitespace chars on the beginning and end of the string 'trim' => false, ]); + + $builder->add('last_stocktake_at', DateTimeType::class, [ + 'label' => 'part_lot.edit.last_stocktake_at', + 'widget' => 'single_text', + 'disabled' => !$this->security->isGranted('@parts_stock.stocktake'), + 'required' => false, + 'empty_data' => null, + ]); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Security/Voter/PartLotVoter.php b/src/Security/Voter/PartLotVoter.php index 87c3d135..5748f4af 100644 --- a/src/Security/Voter/PartLotVoter.php +++ b/src/Security/Voter/PartLotVoter.php @@ -58,13 +58,13 @@ final class PartLotVoter extends Voter { } - protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move']; + protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move', 'stocktake']; protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool { $user = $this->helper->resolveUser($token); - if (in_array($attribute, ['withdraw', 'add', 'move'], true)) + if (in_array($attribute, ['withdraw', 'add', 'move', 'stocktake'], true)) { $base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote); diff --git a/templates/parts/edit/edit_form_styles.html.twig b/templates/parts/edit/edit_form_styles.html.twig index 1856dbff..844c8700 100644 --- a/templates/parts/edit/edit_form_styles.html.twig +++ b/templates/parts/edit/edit_form_styles.html.twig @@ -110,6 +110,7 @@ {{ form_row(form.comment) }} {{ form_row(form.owner) }} {{ form_row(form.user_barcode) }} + {{ form_row(form.last_stocktake_at) }} diff --git a/templates/parts/info/_part_lots.html.twig b/templates/parts/info/_part_lots.html.twig index 1ef25ae4..f4ee4812 100644 --- a/templates/parts/info/_part_lots.html.twig +++ b/templates/parts/info/_part_lots.html.twig @@ -19,53 +19,56 @@ {% for lot in part.partLots %} + {# @var lot App\Entity\Parts\PartLot #} {{ lot.description }} {% if lot.storageLocation %} {{ helper.structural_entity_link(lot.storageLocation) }} {% else %} - + {% trans %}part_lots.location_unknown{% endtrans %} {% endif %} {% if lot.instockUnknown %} - + {% trans %}part_lots.instock_unknown{% endtrans %} {% else %} {{ lot.amount | format_amount(part.partUnit, {'decimals': 5}) }} {% endif %} - -
- {% if lot.owner %} - + + {% if lot.owner %} + {{ helper.user_icon_link(lot.owner) }} -
- {% endif %} - {% if lot.expirationDate %} - + + {% endif %} + {% if lot.expirationDate %} + {{ lot.expirationDate | format_date() }}
- {% endif %} - {% if lot.expired %} -
- + {% endif %} + {% if lot.expired %} + {% trans %}part_lots.is_expired{% endtrans %} - {% endif %} - {% if lot.needsRefill %} -
- - - {% trans %}part_lots.need_refill{% endtrans %} - - {% endif %} -
+ {% endif %} + {% if lot.needsRefill %} + + + {% trans %}part_lots.need_refill{% endtrans %} + + {% endif %} + {% if lot.lastStocktakeAt %} + + + {{ lot.lastStocktakeAt | format_datetime("short") }} + + {% endif %}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 36fc10d1..e73caaf2 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12467,5 +12467,17 @@ Buerklin-API Authentication server: The default value for newly created purchase infos, if prices include VAT or not. + + + part_lot.edit.last_stocktake_at + Last stocktake + + + + + perm.parts_stock.stocktake + Stocktake + + 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 2/4] 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 + + From 3c87fe093204aec51bb384440683f449b9be27b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 23:19:57 +0100 Subject: [PATCH 3/4] Added test for stocktake method on PartLotWithdrawAddHelper --- .../Parts/PartLotWithdrawAddHelperTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Services/Parts/PartLotWithdrawAddHelperTest.php b/tests/Services/Parts/PartLotWithdrawAddHelperTest.php index 697d3983..b033f07e 100644 --- a/tests/Services/Parts/PartLotWithdrawAddHelperTest.php +++ b/tests/Services/Parts/PartLotWithdrawAddHelperTest.php @@ -154,4 +154,19 @@ class PartLotWithdrawAddHelperTest extends WebTestCase $this->assertEqualsWithDelta(5.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON); $this->assertEqualsWithDelta(2.0, $this->partLot3->getAmount(), PHP_FLOAT_EPSILON); } + + public function testStocktake(): void + { + //Stocktake lot 1 to 20 + $this->service->stocktake($this->partLot1, 20, "Test"); + $this->assertEqualsWithDelta(20.0, $this->partLot1->getAmount(), PHP_FLOAT_EPSILON); + $this->assertNotNull($this->partLot1->getLastStocktakeAt()); //Stocktake date should be set + + //Stocktake lot 2 to 5 + $this->partLot2->setInstockUnknown(true); + $this->service->stocktake($this->partLot2, 0, "Test"); + $this->assertEqualsWithDelta(0.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON); + $this->assertFalse($this->partLot2->isInstockUnknown()); //Instock unknown should be cleared + + } } From 35598df354e679ccfc43d2a70384412ce0dff386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 10 Feb 2026 23:24:40 +0100 Subject: [PATCH 4/4] Automatically set the stocktake permission if a user can already add and withdraw from a lot --- src/Entity/UserSystem/PermissionData.php | 2 +- .../UserSystem/PermissionSchemaUpdater.php | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Entity/UserSystem/PermissionData.php b/src/Entity/UserSystem/PermissionData.php index 9ebdc9c9..b7d1ff8f 100644 --- a/src/Entity/UserSystem/PermissionData.php +++ b/src/Entity/UserSystem/PermissionData.php @@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable /** * The current schema version of the permission data */ - public const CURRENT_SCHEMA_VERSION = 3; + public const CURRENT_SCHEMA_VERSION = 4; /** * Creates a new Permission Data Instance using the given data. diff --git a/src/Services/UserSystem/PermissionSchemaUpdater.php b/src/Services/UserSystem/PermissionSchemaUpdater.php index 104800dc..fd85ee7c 100644 --- a/src/Services/UserSystem/PermissionSchemaUpdater.php +++ b/src/Services/UserSystem/PermissionSchemaUpdater.php @@ -157,4 +157,20 @@ class PermissionSchemaUpdater $permissions->setPermissionValue('system', 'show_updates', $new_value); } } + + private function upgradeSchemaToVersion4(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection + { + $permissions = $holder->getPermissions(); + + //If the reports.generate permission is not defined yet, set it to the value of reports.read + if (!$permissions->isPermissionSet('parts_stock', 'stocktake')) { + //Set the new permission to true only if both add and withdraw are allowed + $new_value = TrinaryLogicHelper::and( + $permissions->getPermissionValue('parts_stock', 'withdraw'), + $permissions->getPermissionValue('parts_stock', 'add') + ); + + $permissions->setPermissionValue('parts_stock', 'stocktake', $new_value); + } + } }
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }} {# Action for order information #}