Added a modal to stocktake / set part lots amount from info page

This commit is contained in:
Jan Böhmer 2026-02-10 23:17:10 +01:00
parent 2f9601364e
commit d8fdaa9529
8 changed files with 225 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" %}
<div class="table-responsive">
<table class="table table-striped table-hover">
@ -93,12 +94,15 @@
>
<i class="fa-solid fa-right-left fa-fw"></i>
</button>
<button type="button" class="btn-primary btn" data-bs-toggle="modal" data-bs-target="#stocktake-modal" title="{% trans %}part.info.stocktake_modal.title{% endtrans %}"
{% if not is_granted('stocktake', lot) %}disabled{% endif %}
data-lot-id="{{ lot.id }}" data-lot-amount="{{ lot.amount }}"><i class="fas fa-clipboard-check fa-fw"></i></button>
</div>
</td>
<td>
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }}
</td>
<td>
<td> {# Action for order information #}
<div class="btn-group">
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@ -114,7 +118,6 @@
</div>
</div>
</td>
</td>
</tr>
{% endfor %}
</tbody>

View file

@ -0,0 +1,63 @@
<div class="modal fade" id="stocktake-modal" tabindex="-1" aria-labelledby="stocktake-modal-title" aria-hidden="true" {{ stimulus_controller('pages/part_stocktake_modal') }}>
<form method="post" action="{{ path('part_stocktake', {"id": part.id}) }}">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="stocktake-modal-title">{% trans %}part.info.stocktake_modal.title{% endtrans %}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{# non visible form elements #}
<input type="hidden" name="lot_id" value="">
<input type="hidden" name="_token" value="{{ csrf_token('part_stocktake-' ~ part.iD) }}">
<input type="hidden" name="_redirect" value="{{ uri_without_host(app.request) }}">
<div class="row mb-2">
<label class="col-form-label col-sm-3">
{% trans %}part.info.stocktake_modal.expected_amount{% endtrans %}
</label>
<div class="col-sm-9">
<span id="stocktake-modal-expected-amount" class="form-control-plaintext">0</span>
</div>
</div>
<div class="row mb-2">
<label class="col-form-label col-sm-3">
{% trans %}part.info.stocktake_modal.actual_amount{% endtrans %}
</label>
<div class="col-sm-9">
<input type="number" required class="form-control" min="0" step="{{ (part.partUnit and not part.partUnit.integer) ? 'any' : '1' }}" name="actual_amount" value="">
</div>
</div>
<div class="row mb-2">
<label class="col-form-label col-sm-3">
{% trans %}part.info.withdraw_modal.comment{% endtrans %}
</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="comment" value="">
<div class="form-text">{% trans %}part.info.withdraw_modal.comment.hint{% endtrans %}</div>
</div>
</div>
<div class="row mb-2">
<label class="col-form-label col-sm-3">
{% trans %}part.info.withdraw_modal.timestamp{% endtrans %}
</label>
<div class="col-sm-9">
{# The timestamp must be between a year ago and 1 hour in the future #}
<input type="datetime-local" class="form-control" name="timestamp" value=""
max="{{ "+10mins"|date('Y-m-d\\TH:i') }}" min="{{ "-1year"|date('Y-m-d\\TH:i') }}">
<div class="form-text">{% trans %}part.info.withdraw_modal.timestamp.hint{% endtrans %}</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans %}modal.close{% endtrans %}</button>
<button type="submit" class="btn btn-primary">{% trans %}modal.submit{% endtrans %}</button>
</div>
</div>
</div>
</form>
</div>

View file

@ -12479,5 +12479,35 @@ Buerklin-API Authentication server:
<target>Stocktake</target>
</segment>
</unit>
<unit id="Vnhrb5R" name="part.info.stocktake_modal.title">
<segment>
<source>part.info.stocktake_modal.title</source>
<target>Stocktake lot</target>
</segment>
</unit>
<unit id="WqOG7RK" name="part.info.stocktake_modal.expected_amount">
<segment>
<source>part.info.stocktake_modal.expected_amount</source>
<target>Expected amount</target>
</segment>
</unit>
<unit id="E7IbVN6" name="part.info.stocktake_modal.actual_amount">
<segment>
<source>part.info.stocktake_modal.actual_amount</source>
<target>Actual amount</target>
</segment>
</unit>
<unit id="4GwSma7" name="log.part_stock_changed.stock_take">
<segment>
<source>log.part_stock_changed.stock_take</source>
<target>Stocktake</target>
</segment>
</unit>
<unit id="aRQPMW7" name="log.element_edited.changed_fields.last_stocktake_at">
<segment>
<source>log.element_edited.changed_fields.last_stocktake_at</source>
<target>Last stocktake</target>
</segment>
</unit>
</file>
</xliff>