From 074151c72ee65e51436300c9e3b4c3806c2ecd5f Mon Sep 17 00:00:00 2001 From: killecaptron <203002577+killecaptron@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:20:31 +0200 Subject: [PATCH 1/3] Allow refreshing a part directly from the provider it was created with Updating a part from an info provider so far always went through the provider search: pick a provider, search for the part again, choose the right result - even though the part already knows exactly which provider and which provider ID it was created from. Add a button which uses that reference directly and jumps straight to the merge view of the same part at the same provider. It is only shown if the part actually was created by a provider and that provider is still active, and its tooltip states when the part was last updated from the provider. The link bypasses the info provider cache, as the whole point of pressing it is to get the current data. Co-Authored-By: Claude Opus 5 --- templates/parts/info/_tools.html.twig | 15 +++++++++++++++ translations/messages.en.xlf | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/templates/parts/info/_tools.html.twig b/templates/parts/info/_tools.html.twig index 455d51b7d..40219803a 100644 --- a/templates/parts/info/_tools.html.twig +++ b/templates/parts/info/_tools.html.twig @@ -33,6 +33,21 @@ {# Update part from info provider button #} {% if is_granted('edit', part) and is_granted('@info_providers.create_parts') %}
+ {# If the part was created by a provider, it can be refreshed directly, without searching for it again #} + {% set part_provider = part.providerReference.providerCreated ? info_provider(part.providerReference.providerKey) : null %} + {% if part_provider and part_provider.active %} + + + {% trans with {'%provider%': part_provider.providerInfo.name} %}part.refresh_from_info_provider.btn{% endtrans %} + + {% endif %} {% trans %}part.update_part_from_info_provider.btn{% endtrans %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index f7a9d4e9f..8a4292aa7 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -15110,5 +15110,17 @@ Buerklin-API Authentication server: Edit BOM entry #%id% of project + + + part.refresh_from_info_provider.btn + Refresh from %provider% + + + + + part.refresh_from_info_provider.last_updated + Last updated from the info provider: %datetime% + + From fff11fee7129658a4b30d9a6db900a2155062b4f Mon Sep 17 00:00:00 2001 From: killecaptron <203002577+killecaptron@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:27:03 +0200 Subject: [PATCH 2/3] Allow refreshing many parts at once from their info providers The refresh button updates a single part from the provider it was created with, but keeping a whole shelf of parts up to date meant pressing it once per part. Add a "Refresh from info providers" action to the parts table, which does the same thing for the whole selection. It reuses the existing bulk import job for that: only the search step is replaced, as nothing has to be searched for - every part already knows its provider and provider ID, so its single result is exactly that provider entry, and the user lands directly in the well-known review step, where parts can be applied one by one or all at once. Building the results contacts no provider at all: the data is fetched when a part is actually applied, exactly as it is for an imported part. Parts which can not be refreshed (not created by a provider, or their provider is gone or disabled) are kept in the job with an error, so they stay visible instead of silently dropping out of the selection. Co-Authored-By: Claude Opus 5 --- .../BulkInfoProviderImportController.php | 72 ++++++++++ .../BulkRefreshResultsBuilder.php | 117 +++++++++++++++++ .../Parts/PartsTableActionHandler.php | 10 ++ .../components/datatables.macro.html.twig | 1 + .../bulk_import/step2.html.twig | 3 +- .../BulkRefreshResultsBuilderTest.php | 123 ++++++++++++++++++ translations/messages.en.xlf | 18 +++ 7 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 src/Services/InfoProviderSystem/BulkRefreshResultsBuilder.php create mode 100644 tests/Services/InfoProviderSystem/BulkRefreshResultsBuilderTest.php 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 @@ + diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index e68202e04..43d9fdd61 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -188,7 +188,8 @@ {% for error in part_result.errors %} {% endfor %} {% endif %} diff --git a/tests/Services/InfoProviderSystem/BulkRefreshResultsBuilderTest.php b/tests/Services/InfoProviderSystem/BulkRefreshResultsBuilderTest.php new file mode 100644 index 000000000..626ea4df9 --- /dev/null +++ b/tests/Services/InfoProviderSystem/BulkRefreshResultsBuilderTest.php @@ -0,0 +1,123 @@ +. + */ +namespace App\Tests\Services\InfoProviderSystem; + +use App\Entity\Parts\InfoProviderReference; +use App\Entity\Parts\Part; +use App\Services\InfoProviderSystem\BulkRefreshResultsBuilder; +use App\Services\InfoProviderSystem\DTOs\ProviderInfoDTO; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use PHPUnit\Framework\TestCase; + +final class BulkRefreshResultsBuilderTest extends TestCase +{ + private function builder(bool $provider_active = true): BulkRefreshResultsBuilder + { + $provider = $this->createMock(InfoProviderInterface::class); + $provider->method('getProviderInfo')->willReturn(new ProviderInfoDTO(key: 'test', name: 'Test provider')); + $provider->method('isActive')->willReturn($provider_active); + + return new BulkRefreshResultsBuilder(new ProviderRegistry([$provider])); + } + + private function partWithReference(?InfoProviderReference $reference): Part + { + $part = new Part(); + $part->setName('Test part'); + $part->setDescription('A part'); + + if ($reference !== null) { + $part->setProviderReference($reference); + } + + return $part; + } + + public function testEmptyPartListIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->builder()->build([]); + } + + public function testPartIsRefreshedFromItsOwnProviderReference(): void + { + $part = $this->partWithReference( + InfoProviderReference::providerReference('test', '1234', 'https://example.com/part/1234') + ); + + $results = $this->builder()->build([$part])->partResults; + + $this->assertCount(1, $results); + $this->assertSame($part, $results[0]->part); + $this->assertFalse($results[0]->hasErrors()); + $this->assertCount(1, $results[0]->searchResults); + + //The single result must point at exactly the provider entry the part was created from + $search_result = $results[0]->searchResults[0]->searchResult; + $this->assertSame('test', $search_result->provider_key); + $this->assertSame('1234', $search_result->provider_id); + $this->assertSame('https://example.com/part/1234', $search_result->provider_url); + } + + public function testPartWithoutProviderReferenceGetsAnError(): void + { + $part = $this->partWithReference(null); + + $results = $this->builder()->build([$part])->partResults; + + $this->assertFalse($results[0]->hasResults()); + $this->assertSame(['info_providers.bulk_refresh.error.no_provider_reference'], $results[0]->errors); + } + + public function testPartOfAnUnknownProviderGetsAnError(): void + { + $part = $this->partWithReference(InfoProviderReference::providerReference('does_not_exist', '1234')); + + $results = $this->builder()->build([$part])->partResults; + + $this->assertFalse($results[0]->hasResults()); + $this->assertSame(['info_providers.bulk_refresh.error.provider_unavailable'], $results[0]->errors); + } + + public function testPartOfADisabledProviderGetsAnError(): void + { + $part = $this->partWithReference(InfoProviderReference::providerReference('test', '1234')); + + $results = $this->builder(provider_active: false)->build([$part])->partResults; + + $this->assertFalse($results[0]->hasResults()); + $this->assertSame(['info_providers.bulk_refresh.error.provider_unavailable'], $results[0]->errors); + } + + public function testPartsAreKeptInTheGivenOrder(): void + { + $refreshable = $this->partWithReference(InfoProviderReference::providerReference('test', '1234')); + $not_refreshable = $this->partWithReference(null); + + $results = $this->builder()->build([$not_refreshable, $refreshable])->partResults; + + $this->assertSame($not_refreshable, $results[0]->part); + $this->assertSame($refreshable, $results[1]->part); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 8a4292aa7..2636ac64a 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -15122,5 +15122,23 @@ Buerklin-API Authentication server: Last updated from the info provider: %datetime% + + + part_list.action.bulk_info_provider_refresh + Refresh from info providers + + + + + info_providers.bulk_refresh.error.no_provider_reference + This part was not created from an info provider, so it can not be refreshed automatically. + + + + + info_providers.bulk_refresh.error.provider_unavailable + The info provider this part was created with is not available anymore. + + From a0d226d809730ef9f0de216b96b0790bebb0a5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 20 Sep 2026 20:12:22 +0200 Subject: [PATCH 3/3] Updated message IDs --- translations/messages.en.xlf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index dfc571ed7..094250cfa 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -15266,19 +15266,19 @@ Buerklin-API Authentication server: Last updated from the info provider: %datetime% - + part_list.action.bulk_info_provider_refresh Refresh from info providers - + info_providers.bulk_refresh.error.no_provider_reference This part was not created from an info provider, so it can not be refreshed automatically. - + info_providers.bulk_refresh.error.provider_unavailable The info provider this part was created with is not available anymore.