Added way to batch edit the location of parts with a single stock

This commit is contained in:
Jan Böhmer 2025-03-29 16:21:10 +01:00
parent 68124a340b
commit 48c70c3bb4
5 changed files with 120 additions and 36 deletions

View file

@ -29,6 +29,7 @@ use App\DataTables\PartsDataTable;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Exceptions\InvalidRegexException;
@ -43,8 +44,11 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
class PartListsController extends AbstractController
{
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NodesListBuilder $nodesListBuilder, private readonly DataTableFactory $dataTableFactory, private readonly TranslatorInterface $translator)
@ -71,13 +75,32 @@ class PartListsController extends AbstractController
if (null === $action || null === $ids) {
$this->addFlash('error', 'part.table.actions.no_params_given');
} else {
$errors = [];
$parts = $actionHandler->idStringToArray($ids);
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect);
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect, $errors);
//Save changes
$this->entityManager->flush();
$this->addFlash('success', 'part.table.actions.success');
if (count($errors) === 0) {
$this->addFlash('success', 'part.table.actions.success');
} else {
$this->addFlash('error', t('part.table.actions.error', ['%count%' => count($errors)]));
//Create a flash message for each error
foreach ($errors as $error) {
/** @var Part $part */
$part = $error['part'];
$this->addFlash('error',
t('part.table.actions.error_detail', [
'%part_name%' => $part->getName(),
'%part_id%' => $part->getID(),
'%message%' => $error['message']
])
);
}
}
}
//If the action handler returned a response, we use it, otherwise we redirect back to the previous page.

View file

@ -29,6 +29,7 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\StorageLocation;
use App\Entity\ProjectSystem\Project;
use App\Form\Type\Helper\StructuralEntityChoiceHelper;
use App\Services\Trees\NodesListBuilder;
@ -78,6 +79,12 @@ class SelectAPIController extends AbstractController
return $this->getResponseForClass(Project::class, false);
}
#[Route(path: '/storage_location', name: 'select_storage_location')]
public function locations(): Response
{
return $this->getResponseForClass(StorageLocation::class, true);
}
#[Route(path: '/export_level', name: 'select_export_level')]
public function exportLevel(): Response
{

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\Services\Parts;
use App\Entity\Parts\StorageLocation;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
@ -35,6 +36,9 @@ use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Contracts\Translation\TranslatableInterface;
use function Symfony\Component\Translation\t;
final class PartsTableActionHandler
{
@ -61,8 +65,9 @@ final class PartsTableActionHandler
/**
* @param Part[] $selected_parts
* @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null
* @phpstan-param-out array<array{'part': Part, 'message': string|TranslatableInterface}> $errors
*/
public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null): ?RedirectResponse
public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null, array &$errors = []): ?RedirectResponse
{
if ($action === 'add_to_project') {
return new RedirectResponse(
@ -161,6 +166,29 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
$this->denyAccessUnlessGranted('@measurement_units.read');
$part->setPartUnit(null === $target_id ? null : $this->entityManager->find(MeasurementUnit::class, $target_id));
break;
case 'change_location':
$this->denyAccessUnlessGranted('@storelocations.read');
//Retrieve the first part lot and set the location for it
$part_lots = $part->getPartLots();
if ($part_lots->count() > 0) {
if ($part_lots->count() > 1) {
$errors[] = [
'part' => $part,
'message' => t('parts.table.action_handler.error.part_lots_multiple'),
];
break;
}
$part_lot = $part_lots->first();
$part_lot->setStorageLocation(null === $target_id ? null : $this->entityManager->find(StorageLocation::class, $target_id));
} else { //Create a new part lot if there are none
$part_lot = new PartLot();
$part_lot->setPart($part);
$part_lot->setInstockUnknown(true); //We do not know how many parts are in stock, so we set it to true
$part_lot->setStorageLocation(null === $target_id ? null : $this->entityManager->find(StorageLocation::class, $target_id));
$this->entityManager->persist($part_lot);
}
break;
default:
throw new InvalidArgumentException('The given action is unknown! ('.$action.')');

View file

@ -54,6 +54,7 @@
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_footprint" data-url="{{ path('select_footprint') }}">{% trans %}part_list.action.action.change_footprint{% endtrans %}</option>
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_manufacturer" data-url="{{ path('select_manufacturer') }}">{% trans %}part_list.action.action.change_manufacturer{% endtrans %}</option>
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_unit" data-url="{{ path('select_measurement_unit') }}">{% trans %}part_list.action.action.change_unit{% endtrans %}</option>
<option {% if not is_granted('@parts.edit') %}disabled{% endif %} value="change_location" data-url="{{ path('select_storage_location') }}">{% trans %}part_list.action.action.change_location{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.group.labels{% endtrans %}">
<option {% if not is_granted('@labels.create_labels') %}disabled{% endif %} value="generate_label_lot" data-url="{{ path('select_label_profiles_lot')}}">{% trans %}part_list.action.projects.generate_label_lot{% endtrans %}</option>

View file

@ -242,7 +242,7 @@
</notes>
<segment state="final">
<source>part.info.timetravel_hint</source>
<target>This is how the part appeared before %timestamp%. &lt;i&gt;Please note that this feature is experimental, so the info may not be correct.&lt;/i&gt;</target>
<target><![CDATA[This is how the part appeared before %timestamp%. <i>Please note that this feature is experimental, so the info may not be correct.</i>]]></target>
</segment>
</unit>
<unit id="3exvSpl" name="standard.label">
@ -731,10 +731,10 @@
</notes>
<segment state="translated">
<source>user.edit.tfa.disable_tfa_message</source>
<target>This will disable &lt;b&gt;all active two-factor authentication methods of the user&lt;/b&gt; and delete the &lt;b&gt;backup codes&lt;/b&gt;!
&lt;br&gt;
The user will have to set up all two-factor authentication methods again and print new backup codes! &lt;br&gt;&lt;br&gt;
&lt;b&gt;Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!&lt;/b&gt;</target>
<target><![CDATA[This will disable <b>all active two-factor authentication methods of the user</b> and delete the <b>backup codes</b>!
<br>
The user will have to set up all two-factor authentication methods again and print new backup codes! <br><br>
<b>Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!</b>]]></target>
</segment>
</unit>
<unit id="APsHYu0" name="user.edit.tfa.disable_tfa.btn">
@ -885,9 +885,9 @@ The user will have to set up all two-factor authentication methods again and pri
</notes>
<segment state="translated">
<source>entity.delete.message</source>
<target>This can not be undone!
&lt;br&gt;
Sub elements will be moved upwards.</target>
<target><![CDATA[This can not be undone!
<br>
Sub elements will be moved upwards.]]></target>
</segment>
</unit>
<unit id="2tKAqHw" name="entity.delete">
@ -1441,7 +1441,7 @@ Sub elements will be moved upwards.</target>
</notes>
<segment state="final">
<source>homepage.github.text</source>
<target>Source, downloads, bug reports, to-do-list etc. can be found on &lt;a href="%href%" class="link-external" target="_blank"&gt;GitHub project page&lt;/a&gt;</target>
<target><![CDATA[Source, downloads, bug reports, to-do-list etc. can be found on <a href="%href%" class="link-external" target="_blank">GitHub project page</a>]]></target>
</segment>
</unit>
<unit id="D5OKsgU" name="homepage.help.caption">
@ -1463,7 +1463,7 @@ Sub elements will be moved upwards.</target>
</notes>
<segment state="translated">
<source>homepage.help.text</source>
<target>Help and tips can be found in Wiki the &lt;a href="%href%" class="link-external" target="_blank"&gt;GitHub page&lt;/a&gt;</target>
<target><![CDATA[Help and tips can be found in Wiki the <a href="%href%" class="link-external" target="_blank">GitHub page</a>]]></target>
</segment>
</unit>
<unit id="dnirx4v" name="homepage.forum.caption">
@ -1705,7 +1705,7 @@ Sub elements will be moved upwards.</target>
</notes>
<segment state="translated">
<source>email.pw_reset.fallback</source>
<target>If this does not work for you, go to &lt;a href="%url%"&gt;%url%&lt;/a&gt; and enter the following info</target>
<target><![CDATA[If this does not work for you, go to <a href="%url%">%url%</a> and enter the following info]]></target>
</segment>
</unit>
<unit id="DduL9Hu" name="email.pw_reset.username">
@ -1735,7 +1735,7 @@ Sub elements will be moved upwards.</target>
</notes>
<segment state="translated">
<source>email.pw_reset.valid_unit %date%</source>
<target>The reset token will be valid until &lt;i&gt;%date%&lt;/i&gt;.</target>
<target><![CDATA[The reset token will be valid until <i>%date%</i>.]]></target>
</segment>
</unit>
<unit id="8sBnjRy" name="orderdetail.delete">
@ -3578,8 +3578,8 @@ Sub elements will be moved upwards.</target>
</notes>
<segment state="translated">
<source>tfa_google.disable.confirm_message</source>
<target>If you disable the Authenticator App, all backup codes will be deleted, so you may need to reprint them.&lt;br&gt;
Also note that without two-factor authentication, your account is no longer as well protected against attackers!</target>
<target><![CDATA[If you disable the Authenticator App, all backup codes will be deleted, so you may need to reprint them.<br>
Also note that without two-factor authentication, your account is no longer as well protected against attackers!]]></target>
</segment>
</unit>
<unit id="yu9MSt5" name="tfa_google.disabled_message">
@ -3599,7 +3599,7 @@ Also note that without two-factor authentication, your account is no longer as w
</notes>
<segment state="translated">
<source>tfa_google.step.download</source>
<target>Download an authenticator app (e.g. &lt;a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2"&gt;Google Authenticator&lt;/a&gt; oder &lt;a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp"&gt;FreeOTP Authenticator&lt;/a&gt;)</target>
<target><![CDATA[Download an authenticator app (e.g. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>)]]></target>
</segment>
</unit>
<unit id="eriwJoR" name="tfa_google.step.scan">
@ -3841,8 +3841,8 @@ Also note that without two-factor authentication, your account is no longer as w
</notes>
<segment state="translated">
<source>tfa_trustedDevices.explanation</source>
<target>When checking the second factor, the current computer can be marked as trustworthy, so no more two-factor checks on this computer are needed.
If you have done this incorrectly or if a computer is no longer trusted, you can reset the status of &lt;i&gt;all &lt;/i&gt;computers here.</target>
<target><![CDATA[When checking the second factor, the current computer can be marked as trustworthy, so no more two-factor checks on this computer are needed.
If you have done this incorrectly or if a computer is no longer trusted, you can reset the status of <i>all </i>computers here.]]></target>
</segment>
</unit>
<unit id="FZINq8z" name="tfa_trustedDevices.invalidate.confirm_title">
@ -5313,7 +5313,7 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
</notes>
<segment state="translated">
<source>label_options.lines_mode.help</source>
<target>If you select Twig here, the content field is interpreted as Twig template. See &lt;a href="https://twig.symfony.com/doc/3.x/templates.html"&gt;Twig documentation&lt;/a&gt; and &lt;a href="https://docs.part-db.de/usage/labels.html#twig-mode"&gt;Wiki&lt;/a&gt; for more information.</target>
<target><![CDATA[If you select Twig here, the content field is interpreted as Twig template. See <a href="https://twig.symfony.com/doc/3.x/templates.html">Twig documentation</a> and <a href="https://docs.part-db.de/usage/labels.html#twig-mode">Wiki</a> for more information.]]></target>
</segment>
</unit>
<unit id="isvxbiX" name="label_options.page_size.label">
@ -9388,25 +9388,25 @@ Element 3</target>
<unit id="r4vDLAt" name="filter.parameter_value_constraint.operator.&lt;">
<segment state="translated">
<source>filter.parameter_value_constraint.operator.&lt;</source>
<target>Typ. Value &lt;</target>
<target><![CDATA[Typ. Value <]]></target>
</segment>
</unit>
<unit id="X9SA3UP" name="filter.parameter_value_constraint.operator.&gt;">
<segment state="translated">
<source>filter.parameter_value_constraint.operator.&gt;</source>
<target>Typ. Value &gt;</target>
<target><![CDATA[Typ. Value >]]></target>
</segment>
</unit>
<unit id="BQGaoQS" name="filter.parameter_value_constraint.operator.&lt;=">
<segment state="translated">
<source>filter.parameter_value_constraint.operator.&lt;=</source>
<target>Typ. Value &lt;=</target>
<target><![CDATA[Typ. Value <=]]></target>
</segment>
</unit>
<unit id="2ha3P6g" name="filter.parameter_value_constraint.operator.&gt;=">
<segment state="translated">
<source>filter.parameter_value_constraint.operator.&gt;=</source>
<target>Typ. Value &gt;=</target>
<target><![CDATA[Typ. Value >=]]></target>
</segment>
</unit>
<unit id="4DaBace" name="filter.parameter_value_constraint.operator.BETWEEN">
@ -9514,7 +9514,7 @@ Element 3</target>
<unit id="4tHhDtU" name="parts_list.search.searching_for">
<segment state="translated">
<source>parts_list.search.searching_for</source>
<target>Searching parts with keyword &lt;b&gt;%keyword%&lt;/b&gt;</target>
<target><![CDATA[Searching parts with keyword <b>%keyword%</b>]]></target>
</segment>
</unit>
<unit id="4vomKLa" name="parts_list.search_options.caption">
@ -10174,13 +10174,13 @@ Element 3</target>
<unit id="NdZ1t7a" name="project.builds.number_of_builds_possible">
<segment state="translated">
<source>project.builds.number_of_builds_possible</source>
<target>You have enough stocked to build &lt;b&gt;%max_builds%&lt;/b&gt; builds of this project.</target>
<target><![CDATA[You have enough stocked to build <b>%max_builds%</b> builds of this project.]]></target>
</segment>
</unit>
<unit id="iuSpPbg" name="project.builds.check_project_status">
<segment state="translated">
<source>project.builds.check_project_status</source>
<target>The current project status is &lt;b&gt;"%project_status%"&lt;/b&gt;. You should check if you really want to build the project with this status!</target>
<target><![CDATA[The current project status is <b>"%project_status%"</b>. You should check if you really want to build the project with this status!]]></target>
</segment>
</unit>
<unit id="Y7vSSxi" name="project.builds.following_bom_entries_miss_instock_n">
@ -10282,7 +10282,7 @@ Element 3</target>
<unit id="GzqIwHH" name="entity.select.add_hint">
<segment state="translated">
<source>entity.select.add_hint</source>
<target>Use -&gt; to create nested structures, e.g. "Node 1-&gt;Node 1.1"</target>
<target><![CDATA[Use -> to create nested structures, e.g. "Node 1->Node 1.1"]]></target>
</segment>
</unit>
<unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB">
@ -10306,13 +10306,13 @@ Element 3</target>
<unit id="XLnXtsR" name="homepage.first_steps.introduction">
<segment state="translated">
<source>homepage.first_steps.introduction</source>
<target>Your database is still empty. You might want to read the &lt;a href="%url%"&gt;documentation&lt;/a&gt; or start to creating the following data structures:</target>
<target><![CDATA[Your database is still empty. You might want to read the <a href="%url%">documentation</a> or start to creating the following data structures:]]></target>
</segment>
</unit>
<unit id="Q79MOIk" name="homepage.first_steps.create_part">
<segment state="translated">
<source>homepage.first_steps.create_part</source>
<target>Or you can directly &lt;a href="%url%"&gt;create a new part&lt;/a&gt;.</target>
<target><![CDATA[Or you can directly <a href="%url%">create a new part</a>.]]></target>
</segment>
</unit>
<unit id="vplYq4f" name="homepage.first_steps.hide_hint">
@ -10324,7 +10324,7 @@ Element 3</target>
<unit id="MJoZl4f" name="homepage.forum.text">
<segment state="translated">
<source>homepage.forum.text</source>
<target>For questions about Part-DB use the &lt;a href="%href%" class="link-external" target="_blank"&gt;discussion forum&lt;/a&gt;</target>
<target><![CDATA[For questions about Part-DB use the <a href="%href%" class="link-external" target="_blank">discussion forum</a>]]></target>
</segment>
</unit>
<unit id="YsukbnK" name="log.element_edited.changed_fields.category">
@ -10978,7 +10978,7 @@ Element 3</target>
<unit id="p_IxB9K" name="parts.import.help_documentation">
<segment state="translated">
<source>parts.import.help_documentation</source>
<target>See the &lt;a href="%link%"&gt;documentation&lt;/a&gt; for more information on the file format.</target>
<target><![CDATA[See the <a href="%link%">documentation</a> for more information on the file format.]]></target>
</segment>
</unit>
<unit id="awbvhVq" name="parts.import.help">
@ -11158,7 +11158,7 @@ Element 3</target>
<unit id="o5u.Nnz" name="part.filter.lessThanDesired">
<segment state="translated">
<source>part.filter.lessThanDesired</source>
<target>In stock less than desired (total amount &lt; min. amount)</target>
<target><![CDATA[In stock less than desired (total amount < min. amount)]]></target>
</segment>
</unit>
<unit id="YN9eLcZ" name="part.filter.lotOwner">
@ -11970,13 +11970,13 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="i68lU5x" name="part.merge.confirm.title">
<segment state="translated">
<source>part.merge.confirm.title</source>
<target>Do you really want to merge &lt;b&gt;%other%&lt;/b&gt; into &lt;b&gt;%target%&lt;/b&gt;?</target>
<target><![CDATA[Do you really want to merge <b>%other%</b> into <b>%target%</b>?]]></target>
</segment>
</unit>
<unit id="k0anzYV" name="part.merge.confirm.message">
<segment state="translated">
<source>part.merge.confirm.message</source>
<target>&lt;b&gt;%other%&lt;/b&gt; will be deleted, and the part will be saved with the shown information.</target>
<target><![CDATA[<b>%other%</b> will be deleted, and the part will be saved with the shown information.]]></target>
</segment>
</unit>
<unit id="mmW5Yl1" name="part.info.merge_modal.title">
@ -12345,5 +12345,30 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>View external version</target>
</segment>
</unit>
<unit id="X9HUFrv" name="part.table.actions.error">
<segment>
<source>part.table.actions.error</source>
<target>%count% errors occured, while performing action: </target>
</segment>
</unit>
<unit id=".ppbsNn" name="part.table.actions.error_detail">
<segment>
<source>part.table.actions.error_detail</source>
<target>%part_name% (ID: %part_id%): %message%
</target>
</segment>
</unit>
<unit id="4wpp6h." name="part_list.action.action.change_location">
<segment>
<source>part_list.action.action.change_location</source>
<target>Change location (only for parts with single stock)</target>
</segment>
</unit>
<unit id="9_9I.m4" name="parts.table.action_handler.error.part_lots_multiple">
<segment>
<source>parts.table.action_handler.error.part_lots_multiple</source>
<target>This part contains more than one stock. Change the location by hand to select, which stock to choose.</target>
</segment>
</unit>
</file>
</xliff>