mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-03-01 04:49:36 +00:00
Compare commits
4 commits
e5231e29f2
...
35598df354
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35598df354 | ||
|
|
3c87fe0932 | ||
|
|
d8fdaa9529 | ||
|
|
2f9601364e |
15 changed files with 310 additions and 28 deletions
27
assets/controllers/pages/part_stocktake_modal_controller.js
Normal file
27
assets/controllers/pages/part_stocktake_modal_controller.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,9 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
||||||
move:
|
move:
|
||||||
label: "perm.parts_stock.move"
|
label: "perm.parts_stock.move"
|
||||||
apiTokenRole: ROLE_API_EDIT
|
apiTokenRole: ROLE_API_EDIT
|
||||||
|
stocktake:
|
||||||
|
label: "perm.parts_stock.stocktake"
|
||||||
|
apiTokenRole: ROLE_API_EDIT
|
||||||
|
|
||||||
|
|
||||||
storelocations: &PART_CONTAINING
|
storelocations: &PART_CONTAINING
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,14 @@ use Exception;
|
||||||
use Omines\DataTablesBundle\DataTableFactory;
|
use Omines\DataTablesBundle\DataTableFactory;
|
||||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\ExpressionLanguage\Expression;
|
||||||
use Symfony\Component\Form\FormInterface;
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
use function Symfony\Component\Translation\t;
|
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'])]
|
#[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])]
|
||||||
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
|
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ enum PartStockChangeType: string
|
||||||
case WITHDRAW = "withdraw";
|
case WITHDRAW = "withdraw";
|
||||||
case MOVE = "move";
|
case MOVE = "move";
|
||||||
|
|
||||||
|
case STOCKTAKE = "stock_take";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the type to a short representation usable in the extra field of the log entry.
|
* Converts the type to a short representation usable in the extra field of the log entry.
|
||||||
* @return string
|
* @return string
|
||||||
|
|
@ -38,6 +40,7 @@ enum PartStockChangeType: string
|
||||||
self::ADD => 'a',
|
self::ADD => 'a',
|
||||||
self::WITHDRAW => 'w',
|
self::WITHDRAW => 'w',
|
||||||
self::MOVE => 'm',
|
self::MOVE => 'm',
|
||||||
|
self::STOCKTAKE => 's',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,6 +55,7 @@ enum PartStockChangeType: string
|
||||||
'a' => self::ADD,
|
'a' => self::ADD,
|
||||||
'w' => self::WITHDRAW,
|
'w' => self::WITHDRAW,
|
||||||
'm' => self::MOVE,
|
'm' => self::MOVE,
|
||||||
|
's' => self::STOCKTAKE,
|
||||||
default => throw new \InvalidArgumentException("Invalid short type: $value"),
|
default => throw new \InvalidArgumentException("Invalid short type: $value"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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
|
* Returns the instock change type of this entry
|
||||||
* @return PartStockChangeType
|
* @return PartStockChangeType
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable
|
||||||
/**
|
/**
|
||||||
* The current schema version of the permission data
|
* 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.
|
* Creates a new Permission Data Instance using the given data.
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ use App\Form\Type\StructuralEntityType;
|
||||||
use App\Form\Type\UserSelectType;
|
use App\Form\Type\UserSelectType;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
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\DateType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
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
|
//Do not remove whitespace chars on the beginning and end of the string
|
||||||
'trim' => false,
|
'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
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
|
|
||||||
|
|
@ -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
|
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||||
{
|
{
|
||||||
$user = $this->helper->resolveUser($token);
|
$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);
|
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -197,4 +197,45 @@ final class PartLotWithdrawAddHelper
|
||||||
$this->entityManager->remove($origin);
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,4 +157,20 @@ class PermissionSchemaUpdater
|
||||||
$permissions->setPermissionValue('system', 'show_updates', $new_value);
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@
|
||||||
{{ form_row(form.comment) }}
|
{{ form_row(form.comment) }}
|
||||||
{{ form_row(form.owner) }}
|
{{ form_row(form.owner) }}
|
||||||
{{ form_row(form.user_barcode) }}
|
{{ form_row(form.user_barcode) }}
|
||||||
|
{{ form_row(form.last_stocktake_at) }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
{% import "label_system/dropdown_macro.html.twig" as dropdown %}
|
{% import "label_system/dropdown_macro.html.twig" as dropdown %}
|
||||||
|
|
||||||
{% include "parts/info/_withdraw_modal.html.twig" %}
|
{% include "parts/info/_withdraw_modal.html.twig" %}
|
||||||
|
{% include "parts/info/_stocktake_modal.html.twig" %}
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-hover">
|
<table class="table table-striped table-hover">
|
||||||
|
|
@ -19,53 +20,56 @@
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for lot in part.partLots %}
|
{% for lot in part.partLots %}
|
||||||
|
{# @var lot App\Entity\Parts\PartLot #}
|
||||||
<tr {% if lot.id == highlightLotId %}class="table-primary row-highlight row-pulse"{% endif %}>
|
<tr {% if lot.id == highlightLotId %}class="table-primary row-highlight row-pulse"{% endif %}>
|
||||||
<td>{{ lot.description }}</td>
|
<td>{{ lot.description }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if lot.storageLocation %}
|
{% if lot.storageLocation %}
|
||||||
{{ helper.structural_entity_link(lot.storageLocation) }}
|
{{ helper.structural_entity_link(lot.storageLocation) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge rounded-pill bg-warning">
|
<span class="badge rounded-pill text-bg-warning">
|
||||||
<i class="fas fa-question-circle fa-fw"></i> {% trans %}part_lots.location_unknown{% endtrans %}
|
<i class="fas fa-question-circle fa-fw"></i> {% trans %}part_lots.location_unknown{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if lot.instockUnknown %}
|
{% if lot.instockUnknown %}
|
||||||
<span class="badge rounded-pill bg-warning">
|
<span class="badge rounded-pill text-bg-warning">
|
||||||
<i class="fas fa-question-circle fa-fw"></i> {% trans %}part_lots.instock_unknown{% endtrans %}
|
<i class="fas fa-question-circle fa-fw"></i> {% trans %}part_lots.instock_unknown{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ lot.amount | format_amount(part.partUnit, {'decimals': 5}) }}
|
{{ lot.amount | format_amount(part.partUnit, {'decimals': 5}) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="d-flex flex-column align-items-start">
|
||||||
<h6>
|
{% if lot.owner %}
|
||||||
{% if lot.owner %}
|
<span class="badge text-bg-light mb-1" title="{% trans %}part_lot.owner{% endtrans %}">
|
||||||
<span class="badge bg-body-tertiary mb-1" title="{% trans %}part_lot.owner{% endtrans %}">
|
|
||||||
{{ helper.user_icon_link(lot.owner) }}
|
{{ helper.user_icon_link(lot.owner) }}
|
||||||
</span><br>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if lot.expirationDate %}
|
{% if lot.expirationDate %}
|
||||||
<span class="badge bg-info mb-1" title="{% trans %}part_lots.expiration_date{% endtrans %}">
|
<span class="badge text-bg-info mb-1" title="{% trans %}part_lots.expiration_date{% endtrans %}">
|
||||||
<i class="fas fa-calendar-alt fa-fw"></i> {{ lot.expirationDate | format_date() }}<br>
|
<i class="fas fa-calendar-alt fa-fw"></i> {{ lot.expirationDate | format_date() }}<br>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if lot.expired %}
|
{% if lot.expired %}
|
||||||
<br>
|
<span class="badge text-bg-warning mb-1">
|
||||||
<span class="badge bg-warning mb-1">
|
|
||||||
<i class="fas fa-exclamation-circle fa-fw"></i>
|
<i class="fas fa-exclamation-circle fa-fw"></i>
|
||||||
{% trans %}part_lots.is_expired{% endtrans %}
|
{% trans %}part_lots.is_expired{% endtrans %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if lot.needsRefill %}
|
{% if lot.needsRefill %}
|
||||||
<br>
|
<span class="badge text-bg-warning mb-1">
|
||||||
<span class="badge bg-warning mb-1">
|
<i class="fas fa-dolly fa-fw"></i>
|
||||||
<i class="fas fa-dolly fa-fw"></i>
|
{% trans %}part_lots.need_refill{% endtrans %}
|
||||||
{% trans %}part_lots.need_refill{% endtrans %}
|
</span>
|
||||||
</span>
|
{% endif %}
|
||||||
{% endif %}
|
{% if lot.lastStocktakeAt %}
|
||||||
</h6>
|
<span class="badge text-bg-secondary" title="{% trans %}part_lot.edit.last_stocktake_at{% endtrans %}">
|
||||||
|
<i class="fas fa-clipboard-check fa-fw"></i>
|
||||||
|
{{ lot.lastStocktakeAt | format_datetime("short") }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
|
|
@ -90,12 +94,15 @@
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-right-left fa-fw"></i>
|
<i class="fa-solid fa-right-left fa-fw"></i>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }}
|
{{ dropdown.profile_dropdown('part_lot', lot.id, false) }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<td> {# Action for order information #}
|
<td> {# Action for order information #}
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
|
@ -111,7 +118,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
63
templates/parts/info/_stocktake_modal.html.twig
Normal file
63
templates/parts/info/_stocktake_modal.html.twig
Normal 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>
|
||||||
|
|
@ -154,4 +154,19 @@ class PartLotWithdrawAddHelperTest extends WebTestCase
|
||||||
$this->assertEqualsWithDelta(5.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON);
|
$this->assertEqualsWithDelta(5.0, $this->partLot2->getAmount(), PHP_FLOAT_EPSILON);
|
||||||
$this->assertEqualsWithDelta(2.0, $this->partLot3->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
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12467,5 +12467,47 @@ Buerklin-API Authentication server:
|
||||||
<target>The default value for newly created purchase infos, if prices include VAT or not.</target>
|
<target>The default value for newly created purchase infos, if prices include VAT or not.</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="heWSnAH" name="part_lot.edit.last_stocktake_at">
|
||||||
|
<segment>
|
||||||
|
<source>part_lot.edit.last_stocktake_at</source>
|
||||||
|
<target>Last stocktake</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id=".LP93kG" name="perm.parts_stock.stocktake">
|
||||||
|
<segment>
|
||||||
|
<source>perm.parts_stock.stocktake</source>
|
||||||
|
<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>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue