diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php
index bcb196e67..e13a725e3 100644
--- a/src/Controller/BulkInfoProviderImportController.php
+++ b/src/Controller/BulkInfoProviderImportController.php
@@ -31,6 +31,7 @@
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
use App\Services\EntityMergers\Mergers\PartMerger;
use App\Services\InfoProviderSystem\BulkInfoProviderService;
+use App\Services\InfoProviderSystem\BulkRefreshResultsBuilder;
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
@@ -50,6 +51,7 @@ class BulkInfoProviderImportController extends AbstractController
{
public function __construct(
private readonly BulkInfoProviderService $bulkService,
+ private readonly BulkRefreshResultsBuilder $refreshResultsBuilder,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
#[Autowire(param: 'partdb.bulk_import.batch_size')]
@@ -263,6 +265,76 @@ public function step1(Request $request): Response
]);
}
+ /**
+ * Refreshes a set of parts from the info provider each of them was created with.
+ *
+ * This is the bulk version of the refresh button on a part: no search step is needed, as every part already
+ * knows where it came from, so the job is created with its results filled in and the user lands directly in the
+ * review step, where the parts can be applied one by one or all at once.
+ */
+ #[Route('/refresh', name: 'bulk_info_provider_refresh')]
+ public function refresh(Request $request): Response
+ {
+ $this->denyAccessUnlessGranted('@info_providers.create_parts');
+
+ $ids = $request->query->get('ids');
+ if (!$ids) {
+ $this->addFlash('error', 'No parts selected for bulk refresh');
+ return $this->redirectToRoute('homepage');
+ }
+
+ $parts = $this->entityManager->getRepository(Part::class)->getElementsFromIDArray(explode(',', $ids));
+
+ if ($parts === []) {
+ $this->addFlash('error', 'No valid parts found for bulk refresh');
+ return $this->redirectToRoute('homepage');
+ }
+
+ if (count($parts) > $this->bulkImportMaxParts) {
+ $this->addFlash('error', sprintf(
+ 'Too many parts selected (%d). Maximum allowed is %d parts per operation.',
+ count($parts),
+ $this->bulkImportMaxParts
+ ));
+ return $this->redirectToRoute('homepage');
+ }
+
+ $user = $this->getUser();
+ if (!$user instanceof User) {
+ throw new \RuntimeException('User must be authenticated and of type User');
+ }
+
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+
+ foreach ($parts as $part) {
+ $job->addJobPart(new BulkInfoProviderImportJobPart($job, $part));
+ }
+
+ $this->entityManager->persist($job);
+ $this->entityManager->flush();
+
+ try {
+ $job->setSearchResults($this->refreshResultsBuilder->build($parts));
+ $job->markAsInProgress();
+ $this->entityManager->flush();
+ } catch (\Exception $e) {
+ $this->logger->error('Critical error during bulk refresh', [
+ 'job_id' => $job->getId(),
+ 'error' => $e->getMessage(),
+ 'exception' => $e,
+ ]);
+
+ $this->entityManager->remove($job);
+ $this->entityManager->flush();
+
+ $this->addFlash('error', 'Refresh failed due to an error: ' . $e->getMessage());
+ return $this->redirectToRoute('homepage');
+ }
+
+ return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]);
+ }
+
#[Route('/manage', name: 'bulk_info_provider_manage')]
public function manageBulkJobs(): Response
{
diff --git a/src/Services/InfoProviderSystem/BulkRefreshResultsBuilder.php b/src/Services/InfoProviderSystem/BulkRefreshResultsBuilder.php
new file mode 100644
index 000000000..f1b0f9e84
--- /dev/null
+++ b/src/Services/InfoProviderSystem/BulkRefreshResultsBuilder.php
@@ -0,0 +1,117 @@
+.
+ */
+
+namespace App\Services\InfoProviderSystem;
+
+use App\Entity\Parts\Part;
+use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO;
+use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
+use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
+use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
+
+/**
+ * Builds the results of a bulk refresh, in the same shape a bulk search produces them, so that a refresh can reuse
+ * the whole review step of the bulk import (see BulkInfoProviderImportController).
+ *
+ * Unlike a search, nothing has to be looked for here: a part which was created by an info provider already knows
+ * which provider and which provider ID it came from, so its single "result" is exactly that provider entry.
+ * No provider is contacted while building the results - the data itself is only fetched when the user actually
+ * applies a part in the review step.
+ *
+ * @see \App\Tests\Services\InfoProviderSystem\BulkRefreshResultsBuilderTest
+ */
+final readonly class BulkRefreshResultsBuilder
+{
+ public function __construct(private ProviderRegistry $providerRegistry)
+ {
+ }
+
+ /**
+ * @param Part[] $parts The parts which should be refreshed
+ * @throws \InvalidArgumentException If no parts were given
+ */
+ public function build(array $parts): BulkSearchResponseDTO
+ {
+ if ($parts === []) {
+ throw new \InvalidArgumentException('No valid parts found for bulk refresh');
+ }
+
+ $part_results = [];
+
+ foreach ($parts as $part) {
+ $reference = $part->getProviderReference();
+ $provider_key = $reference->getProviderKey();
+ $provider_id = $reference->getProviderId();
+
+ //Parts which can not be refreshed are kept in the job with an error, so they stay visible to the user
+ //instead of silently disappearing from their selection
+ if (!$reference->isProviderCreated() || $provider_key === null || $provider_id === null) {
+ $part_results[] = new BulkSearchPartResultsDTO(part: $part, errors: [
+ 'info_providers.bulk_refresh.error.no_provider_reference',
+ ]);
+ continue;
+ }
+
+ if (!$this->isProviderUsable($provider_key)) {
+ $part_results[] = new BulkSearchPartResultsDTO(part: $part, errors: [
+ 'info_providers.bulk_refresh.error.provider_unavailable',
+ ]);
+ continue;
+ }
+
+ $part_results[] = new BulkSearchPartResultsDTO(part: $part, searchResults: [
+ new BulkSearchPartResultDTO(
+ searchResult: new SearchResultDTO(
+ provider_key: $provider_key,
+ provider_id: $provider_id,
+ name: $part->getName(),
+ description: $part->getDescription(),
+ manufacturer: $part->getManufacturer()?->getName(),
+ mpn: $part->getManufacturerProductNumber(),
+ provider_url: $reference->getProviderUrl(),
+ ),
+ //The part is not searched for, it is looked up by the provider ID it was created with
+ sourceField: 'provider_reference',
+ sourceKeyword: $provider_id,
+ ),
+ ]);
+ }
+
+ return new BulkSearchResponseDTO($part_results);
+ }
+
+ /**
+ * Checks whether the provider with the given key exists in this installation and is currently usable.
+ */
+ private function isProviderUsable(string $provider_key): bool
+ {
+ try {
+ $provider = $this->providerRegistry->getProviderByKey($provider_key);
+ } catch (\InvalidArgumentException) {
+ //The provider the part was created with is not part of this installation (anymore)
+ return false;
+ }
+
+ return $provider->isActive();
+ }
+}
diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php
index 7837ef281..bdec44258 100644
--- a/src/Services/Parts/PartsTableActionHandler.php
+++ b/src/Services/Parts/PartsTableActionHandler.php
@@ -127,6 +127,16 @@ public function handleAction(string $action, array $selected_parts, ?int $target
);
}
+ if ($action === 'bulk_info_provider_refresh') {
+ $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
+ return new RedirectResponse(
+ $this->urlGenerator->generate('bulk_info_provider_refresh', [
+ 'ids' => $ids,
+ '_redirect' => $redirect_url
+ ])
+ );
+ }
+
if ($action === 'batch_edit_eda') {
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
return new RedirectResponse(
diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig
index 42a30f519..17f980019 100644
--- a/templates/components/datatables.macro.html.twig
+++ b/templates/components/datatables.macro.html.twig
@@ -77,6 +77,7 @@