From ce2b7d11a948a1bd65ad637e5565c76f477228ca Mon Sep 17 00:00:00 2001
From: Sebastian Almberg <83243306+Sebbeben@users.noreply.github.com>
Date: Mon, 4 May 2026 21:56:18 +0200
Subject: [PATCH] Add Quick Apply and batch update to bulk info provider import
(#1316)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Add Quick Apply and Apply All buttons to bulk info provider import
Adds the ability to apply provider search results to parts directly
from the bulk import step 2 page without navigating to individual
part edit forms. Includes per-result Quick Apply buttons and an
Apply All button for batch operations.
* Add navigation buttons and completion banner to bulk import step2
Adds Back to Jobs / Back to Parts buttons at the top of the page
and a success banner when the job is completed, so users aren't
stuck on the page after applying all parts.
* Highlight top search result and remove skip reason prompt
- Highlight the recommended/top priority result row with table-success class
- Add "Top" badge to the recommended Quick Apply button
- Use outline style for non-top Quick Apply buttons to differentiate
- Remove the annoying "reason for skipping" prompt popup
* Fix 500 error when field mapping has null field or no search results
- Skip field mappings with null/empty field values in convertFieldMappingsToDto
- Return empty DTO instead of throwing when no search results found
- Remove unnecessary try/catch workaround in researchPart
* Fix PHPStan error: remove redundant null check on BulkSearchResponseDTO
* Improve bulk import UI: split active/history jobs, fix text visibility, add match highlighting
- Split manage page into Active Jobs and History sections
- Fix source keyword text color (remove text-muted for better visibility)
- Add exact match indicators: green check badge when name or MPN matches
- Add translation keys for new UI elements
* Fix spinning icon, text visibility, auto-priority, and SPN match highlighting
- Replace spinning icon with static icon on Active Jobs header
- Match highlighting now checks source keyword against name, MPN, AND provider ID (SPN)
- Show green "Match" badge in source field column when any field matches 100%
- Auto-increment priority when adding new field mapping rows
- Fix text-muted visibility issues on table-success background
* Fix broken images and improve match highlighting consistency
- Hide broken external provider images with onerror fallback
- Make source keyword text green when any match is detected
- All matched fields (name, MPN, SPN, or any source keyword) show green text
* Fix TypeError in LCSCProvider when keyword is numeric string
PHP auto-casts numeric string array keys to int. When a search keyword
is a pure number (e.g., a part number like "12345"), the foreach loop
passes an int to processSearchResponse() which expects string. Cast
keyword to string explicitly.
* Clean up stale pending jobs and add job ID to display
- Auto-delete pending jobs with 0 results (from failed searches/500 errors)
- Show job ID (#N) in manage page and step2 to distinguish identical jobs
- Move timestamp to subtitle line on manage page for cleaner layout
* Fix tests to match updated bulk search behavior (no more RuntimeException)
The bulk search service now returns empty response DTOs instead of
throwing RuntimeException when no results are found. Updated tests
to use assertFalse(hasAnyResults()) instead of catching exceptions.
* Add comprehensive test coverage for bulk import controller
Covers Quick Apply, Apply All, delete, stop, mark completed/skipped/pending,
manage page active/history split, stale job cleanup, research endpoints,
and various error paths. Increases patch coverage significantly.
* Fix duplicate test method names in bulk import tests
* Fix last duplicate test method name (testQuickApplyWithNoSearchResults)
* Fixed translation key in translation messages
* Moved table rendering logic into macro
* fixed visual glitch with button success outline
* Use native httpfoundation method to convert json to an array
* Show a more user friendly error message, when
* Allow to automatically create new manufacturers within quick apply
---------
Co-authored-by: Jan Böhmer
---
assets/controllers/bulk_import_controller.js | 100 +-
.../controllers/field_mapping_controller.js | 19 +
.../BulkInfoProviderImportController.php | 227 +++-
.../BulkInfoProviderService.php | 6 -
.../Providers/LCSCProvider.php | 1 +
.../bulk_import/manage.html.twig | 207 ++--
.../bulk_import/step2.html.twig | 90 +-
.../BulkInfoProviderImportControllerTest.php | 990 +++++++++++++++++-
translations/messages.en.xlf | 90 ++
9 files changed, 1585 insertions(+), 145 deletions(-)
diff --git a/assets/controllers/bulk_import_controller.js b/assets/controllers/bulk_import_controller.js
index 49e4d60f..a04ff13e 100644
--- a/assets/controllers/bulk_import_controller.js
+++ b/assets/controllers/bulk_import_controller.js
@@ -3,14 +3,16 @@ import { generateCsrfHeaders } from "./csrf_protection_controller"
export default class extends Controller {
static targets = ["progressBar", "progressText"]
- static values = {
+ static values = {
jobId: Number,
partId: Number,
researchUrl: String,
researchAllUrl: String,
markCompletedUrl: String,
markSkippedUrl: String,
- markPendingUrl: String
+ markPendingUrl: String,
+ quickApplyUrl: String,
+ quickApplyAllUrl: String
}
connect() {
@@ -119,13 +121,11 @@ export default class extends Controller {
async markSkipped(event) {
const partId = event.currentTarget.dataset.partId
- const reason = prompt('Reason for skipping (optional):') || ''
-
+
try {
const url = this.markSkippedUrlValue.replace('__PART_ID__', partId)
const data = await this.fetchWithErrorHandling(url, {
- method: 'POST',
- body: JSON.stringify({ reason })
+ method: 'POST'
})
if (data.success) {
@@ -321,6 +321,94 @@ export default class extends Controller {
}
}
+ async quickApply(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ const partId = event.currentTarget.dataset.partId
+ const providerKey = event.currentTarget.dataset.providerKey
+ const providerId = event.currentTarget.dataset.providerId
+ const button = event.currentTarget
+ const originalHtml = button.innerHTML
+
+ button.disabled = true
+ button.innerHTML = ' Applying...'
+
+ try {
+ const url = this.quickApplyUrlValue.replace('__PART_ID__', partId)
+ const data = await this.fetchWithErrorHandling(url, {
+ method: 'POST',
+ body: JSON.stringify({ providerKey, providerId })
+ }, 60000)
+
+ if (data.success) {
+ this.updateProgressDisplay(data)
+ this.showSuccessMessage(data.message || 'Part updated successfully')
+ sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
+ window.location.reload()
+ } else {
+ this.showErrorMessage(data.error || 'Quick apply failed')
+ button.innerHTML = originalHtml
+ button.disabled = false
+ }
+ } catch (error) {
+ console.error('Error in quick apply:', error)
+ this.showErrorMessage(error.message || 'Quick apply failed')
+ button.innerHTML = originalHtml
+ button.disabled = false
+ }
+ }
+
+ async quickApplyAll(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ if (!confirm('This will apply the top search result to all pending parts without individual review. Continue?')) {
+ return
+ }
+
+ const button = event.currentTarget
+ const spinner = document.getElementById('quick-apply-all-spinner')
+ const originalHtml = button.innerHTML
+
+ button.disabled = true
+ if (spinner) {
+ spinner.style.display = 'inline-block'
+ }
+
+ try {
+ const data = await this.fetchWithErrorHandling(this.quickApplyAllUrlValue, {
+ method: 'POST'
+ }, 300000)
+
+ if (data.success) {
+ this.updateProgressDisplay(data)
+
+ let message = data.message || 'Bulk apply completed'
+ if (data.errors && data.errors.length > 0) {
+ message += '\nErrors:\n' + data.errors.join('\n')
+ }
+
+ this.showSuccessMessage(message)
+ sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString())
+ window.location.reload()
+ } else {
+ this.showErrorMessage(data.error || 'Bulk apply failed')
+ button.innerHTML = originalHtml
+ button.disabled = false
+ }
+ } catch (error) {
+ console.error('Error in quick apply all:', error)
+ this.showErrorMessage(error.message || 'Bulk apply failed')
+ button.innerHTML = originalHtml
+ button.disabled = false
+ } finally {
+ if (spinner) {
+ spinner.style.display = 'none'
+ }
+ }
+ }
+
showSuccessMessage(message) {
this.showToast('success', message)
}
diff --git a/assets/controllers/field_mapping_controller.js b/assets/controllers/field_mapping_controller.js
index 9c9c8ac6..50c19a0d 100644
--- a/assets/controllers/field_mapping_controller.js
+++ b/assets/controllers/field_mapping_controller.js
@@ -70,6 +70,13 @@ export default class extends Controller {
newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this))
}
+ // Auto-increment priority based on existing mappings
+ const nextPriority = this.getNextPriority()
+ const priorityInput = newRow.querySelector('input[name*="[priority]"]')
+ if (priorityInput) {
+ priorityInput.value = nextPriority
+ }
+
this.updateFieldOptions()
this.updateAddButtonState()
}
@@ -119,6 +126,18 @@ export default class extends Controller {
}
}
+ getNextPriority() {
+ const priorityInputs = this.tbodyTarget.querySelectorAll('input[name*="[priority]"]')
+ let maxPriority = 0
+ priorityInputs.forEach(input => {
+ const val = parseInt(input.value, 10)
+ if (!isNaN(val) && val > maxPriority) {
+ maxPriority = val
+ }
+ })
+ return Math.min(maxPriority + 1, 10)
+ }
+
handleFormSubmit(event) {
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = true
diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php
index 2d3dd7f6..a8622a28 100644
--- a/src/Controller/BulkInfoProviderImportController.php
+++ b/src/Controller/BulkInfoProviderImportController.php
@@ -29,11 +29,14 @@ use App\Entity\Parts\Part;
use App\Entity\Parts\Supplier;
use App\Entity\UserSystem\User;
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
+use App\Services\EntityMergers\Mergers\PartMerger;
use App\Services\InfoProviderSystem\BulkInfoProviderService;
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
+use App\Services\InfoProviderSystem\PartInfoRetriever;
use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\ORMInvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -66,6 +69,10 @@ class BulkInfoProviderImportController extends AbstractController
{
$dtos = [];
foreach ($fieldMappings as $mapping) {
+ // Skip entries where field is null/empty (e.g. user added a row but didn't select a field)
+ if (empty($mapping['field'])) {
+ continue;
+ }
$dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1);
}
return $dtos;
@@ -276,8 +283,8 @@ class BulkInfoProviderImportController extends AbstractController
$updatedJobs = true;
}
- // Mark jobs with no results for deletion (failed searches)
- if ($job->getResultCount() === 0 && $job->isInProgress()) {
+ // Mark jobs with no results for deletion (failed searches or stale pending)
+ if ($job->getResultCount() === 0 && ($job->isInProgress() || $job->isPending())) {
$jobsToDelete[] = $job;
}
}
@@ -297,9 +304,23 @@ class BulkInfoProviderImportController extends AbstractController
}
}
+ // Refetch after cleanup and split into active vs finished
+ $allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
+ ->findBy([], ['createdAt' => 'DESC']);
+
+ $activeJobs = [];
+ $finishedJobs = [];
+ foreach ($allJobs as $job) {
+ if ($job->isCompleted() || $job->isFailed() || $job->isStopped()) {
+ $finishedJobs[] = $job;
+ } else {
+ $activeJobs[] = $job;
+ }
+ }
+
return $this->render('info_providers/bulk_import/manage.html.twig', [
- 'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
- ->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup
+ 'active_jobs' => $activeJobs,
+ 'finished_jobs' => $finishedJobs,
]);
}
@@ -470,22 +491,13 @@ class BulkInfoProviderImportController extends AbstractController
$fieldMappingDtos = $job->getFieldMappings();
$prefetchDetails = $job->isPrefetchDetails();
- try {
- $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
- } catch (\Exception $searchException) {
- // Handle "no search results found" as a normal case, not an error
- if (str_contains($searchException->getMessage(), 'No search results found')) {
- $searchResultsDto = null;
- } else {
- throw $searchException;
- }
- }
+ $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
// Update the job's search results for this specific part efficiently
$this->updatePartSearchResults($job, $searchResultsDto[0] ?? null);
// Prefetch details if requested
- if ($prefetchDetails && $searchResultsDto !== null) {
+ if ($prefetchDetails) {
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
}
@@ -515,6 +527,191 @@ class BulkInfoProviderImportController extends AbstractController
}
}
+ #[Route('/job/{jobId}/part/{partId}/quick-apply', name: 'bulk_info_provider_quick_apply', methods: ['POST'])]
+ public function quickApply(
+ int $jobId,
+ int $partId,
+ Request $request,
+ PartInfoRetriever $infoRetriever,
+ PartMerger $partMerger
+ ): JsonResponse {
+ $job = $this->validateJobAccess($jobId);
+ if (!$job) {
+ return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
+ }
+
+ /** @var Part $part */
+ $part = $this->entityManager->getRepository(Part::class)->find($partId);
+ if (!$part) {
+ return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]);
+ }
+
+ $this->denyAccessUnlessGranted('edit', $part);
+
+ // Get provider key/id from request body, or fall back to top search result
+ $body = $request->toArray();
+ $providerKey = $body['providerKey'] ?? null;
+ $providerId = $body['providerId'] ?? null;
+
+ if (!$providerKey || !$providerId) {
+ $searchResults = $job->getSearchResults($this->entityManager);
+ foreach ($searchResults->partResults as $partResult) {
+ if ($partResult->part->getId() === $partId) {
+ $sorted = $partResult->getResultsSortedByPriority();
+ if (!empty($sorted)) {
+ $providerKey = $sorted[0]->searchResult->provider_key;
+ $providerId = $sorted[0]->searchResult->provider_id;
+ }
+ break;
+ }
+ }
+ }
+
+ if (!$providerKey || !$providerId) {
+ return $this->createErrorResponse('No search result available for this part', 400, ['part_id' => $partId]);
+ }
+
+ try {
+ $dto = $infoRetriever->getDetails($providerKey, $providerId);
+ $providerPart = $infoRetriever->dtoToPart($dto);
+ $partMerger->merge($part, $providerPart);
+
+ //Persist part manufacturer and supplier if they are new, to avoid issues with detached entities during merge
+ //Do not footprints here, as it might pollute the database with unwanted formatting footprints from the provider,
+ $this->entityManager->persist($part->getManufacturer());
+ foreach ($part->getOrderdetails() as $orderdetail) {
+ $this->entityManager->persist($orderdetail->getSupplier());
+ }
+
+ try {
+ $this->entityManager->flush();
+ } catch (ORMInvalidArgumentException $exception) {
+ if (str_contains($exception->getMessage(), 'not configured to cascade persist operations')) {
+ throw new \RuntimeException('Failed to persist merged part, as it would create new datastructures! Review the provider data by yourself.');
+ }
+
+ throw $exception; // Re-throw if it's a different ORM error
+ }
+
+ $job->markPartAsCompleted($partId);
+ if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
+ $job->markAsCompleted();
+ }
+
+ $this->entityManager->flush();
+
+
+ return $this->json([
+ 'success' => true,
+ 'message' => sprintf('Applied provider data to "%s"', $part->getName()),
+ 'part_id' => $partId,
+ 'provider_key' => $providerKey,
+ 'provider_id' => $providerId,
+ 'progress' => $job->getProgressPercentage(),
+ 'completed_count' => $job->getCompletedPartsCount(),
+ 'total_count' => $job->getPartCount(),
+ 'job_completed' => $job->isCompleted(),
+ ]);
+ } catch (\Exception $e) {
+ $this->logger->error($e);
+
+ return $this->createErrorResponse(
+ 'Quick apply failed: ' . $e->getMessage(),
+ 500,
+ ['job_id' => $jobId, 'part_id' => $partId, 'exception' => $e->getMessage()]
+ );
+ }
+ }
+
+ #[Route('/job/{jobId}/quick-apply-all', name: 'bulk_info_provider_quick_apply_all', methods: ['POST'])]
+ public function quickApplyAll(
+ int $jobId,
+ PartInfoRetriever $infoRetriever,
+ PartMerger $partMerger
+ ): JsonResponse {
+ set_time_limit(600);
+
+ $job = $this->validateJobAccess($jobId);
+ if (!$job) {
+ return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
+ }
+
+ $searchResults = $job->getSearchResults($this->entityManager);
+ $applied = 0;
+ $failed = 0;
+ $noResults = 0;
+ $errors = [];
+
+ foreach ($job->getJobParts() as $jobPart) {
+ if ($jobPart->isCompleted() || $jobPart->isSkipped()) {
+ continue;
+ }
+
+ $part = $jobPart->getPart();
+
+ if (!$this->isGranted('edit', $part)) {
+ $errors[] = sprintf('No edit permission for "%s"', $part->getName());
+ $failed++;
+ continue;
+ }
+
+ // Find top search result for this part
+ $providerKey = null;
+ $providerId = null;
+ foreach ($searchResults->partResults as $partResult) {
+ if ($partResult->part->getId() === $part->getId()) {
+ $sorted = $partResult->getResultsSortedByPriority();
+ if (!empty($sorted)) {
+ $providerKey = $sorted[0]->searchResult->provider_key;
+ $providerId = $sorted[0]->searchResult->provider_id;
+ }
+ break;
+ }
+ }
+
+ if (!$providerKey || !$providerId) {
+ $noResults++;
+ continue;
+ }
+
+ try {
+ $dto = $infoRetriever->getDetails($providerKey, $providerId);
+ $providerPart = $infoRetriever->dtoToPart($dto);
+ $partMerger->merge($part, $providerPart);
+ $this->entityManager->flush();
+
+ $job->markPartAsCompleted($part->getId());
+ $applied++;
+ } catch (\Exception $e) {
+ $this->logger->error('Quick apply failed for part', [
+ 'part_id' => $part->getId(),
+ 'part_name' => $part->getName(),
+ 'error' => $e->getMessage(),
+ ]);
+ $errors[] = sprintf('Failed for "%s": %s', $part->getName(), $e->getMessage());
+ $failed++;
+ }
+ }
+
+ if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
+ $job->markAsCompleted();
+ }
+ $this->entityManager->flush();
+
+ return $this->json([
+ 'success' => true,
+ 'applied' => $applied,
+ 'failed' => $failed,
+ 'no_results' => $noResults,
+ 'errors' => $errors,
+ 'message' => sprintf('Applied to %d parts, %d failed, %d had no results', $applied, $failed, $noResults),
+ 'progress' => $job->getProgressPercentage(),
+ 'completed_count' => $job->getCompletedPartsCount(),
+ 'total_count' => $job->getPartCount(),
+ 'job_completed' => $job->isCompleted(),
+ ]);
+ }
+
#[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])]
public function researchAllParts(int $jobId): JsonResponse
{
diff --git a/src/Services/InfoProviderSystem/BulkInfoProviderService.php b/src/Services/InfoProviderSystem/BulkInfoProviderService.php
index 586fb873..79420134 100644
--- a/src/Services/InfoProviderSystem/BulkInfoProviderService.php
+++ b/src/Services/InfoProviderSystem/BulkInfoProviderService.php
@@ -46,7 +46,6 @@ final class BulkInfoProviderService
}
$partResults = [];
- $hasAnyResults = false;
// Group providers by batch capability
$batchProviders = [];
@@ -88,7 +87,6 @@ final class BulkInfoProviderService
);
if (!empty($allResults)) {
- $hasAnyResults = true;
$searchResults = $this->formatSearchResults($allResults);
}
@@ -99,10 +97,6 @@ final class BulkInfoProviderService
);
}
- if (!$hasAnyResults) {
- throw new \RuntimeException('No search results found for any of the selected parts');
- }
-
$response = new BulkSearchResponseDTO($partResults);
// Prefetch details if requested
diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
index 8bdd776e..5f251b43 100755
--- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
+++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
@@ -397,6 +397,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider
// Now collect all results (like .then() in JavaScript)
foreach ($responses as $keyword => $response) {
try {
+ $keyword = (string) $keyword;
$arr = $response->toArray(); // This waits for the response
$results[$keyword] = $this->processSearchResponse($arr, $keyword);
} catch (\Exception $e) {
diff --git a/templates/info_providers/bulk_import/manage.html.twig b/templates/info_providers/bulk_import/manage.html.twig
index 9bbed906..b31dd650 100644
--- a/templates/info_providers/bulk_import/manage.html.twig
+++ b/templates/info_providers/bulk_import/manage.html.twig
@@ -22,103 +22,130 @@
- {% if jobs is not empty %}
-
-
-
-
- {% trans %}info_providers.bulk_import.job_name{% endtrans %}
- {% trans %}info_providers.bulk_import.parts_count{% endtrans %}
- {% trans %}info_providers.bulk_import.results_count{% endtrans %}
- {% trans %}info_providers.bulk_import.progress{% endtrans %}
- {% trans %}info_providers.bulk_import.status{% endtrans %}
- {% trans %}info_providers.bulk_import.created_by{% endtrans %}
- {% trans %}info_providers.bulk_import.created_at{% endtrans %}
- {% trans %}info_providers.bulk_import.completed_at{% endtrans %}
- {% trans %}info_providers.bulk_import.action.label{% endtrans %}
-
-
-
- {% for job in jobs %}
-
-
- {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
- {% if job.isInProgress %}
- Active
- {% endif %}
-
- {{ job.partCount }}
- {{ job.resultCount }}
-
-
-
-
{{ job.progressPercentage }}%
-
-
- {% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %}
-
-
-
- {% if job.isPending %}
- {% trans %}info_providers.bulk_import.status.pending{% endtrans %}
- {% elseif job.isInProgress %}
- {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}
- {% elseif job.isCompleted %}
- {% trans %}info_providers.bulk_import.status.completed{% endtrans %}
- {% elseif job.isStopped %}
- {% trans %}info_providers.bulk_import.status.stopped{% endtrans %}
- {% elseif job.isFailed %}
- {% trans %}info_providers.bulk_import.status.failed{% endtrans %}
- {% endif %}
-
- {{ job.createdBy.fullName(true) }}
- {{ job.createdAt|format_datetime('short') }}
-
- {% if job.completedAt %}
- {{ job.completedAt|format_datetime('short') }}
- {% else %}
- -
- {% endif %}
-
-
-
- {% if job.isInProgress or job.isCompleted or job.isStopped %}
-
- {% trans %}info_providers.bulk_import.view_results{% endtrans %}
-
- {% endif %}
- {% if job.canBeStopped %}
-
- {% trans %}info_providers.bulk_import.action.stop{% endtrans %}
-
- {% endif %}
- {% if job.isCompleted or job.isFailed or job.isStopped %}
-
- {% trans %}info_providers.bulk_import.action.delete{% endtrans %}
-
- {% endif %}
-
-
-
- {% endfor %}
-
-
-
- {% else %}
+ {% if active_jobs is empty and finished_jobs is empty %}
{% trans %}info_providers.bulk_import.no_jobs_found{% endtrans %}
{% trans %}info_providers.bulk_import.create_first_job{% endtrans %}
+ {% else %}
+ {# Active Jobs #}
+ {% if active_jobs is not empty %}
+
+ {% trans %}info_providers.bulk_import.active_jobs{% endtrans %}
+ {{ active_jobs|length }}
+
+ {{ _self.job_table(active_jobs, false) }}
+ {% endif %}
+
+ {# Finished Jobs (History) #}
+ {% if finished_jobs is not empty %}
+
+ {% trans %}info_providers.bulk_import.finished_jobs{% endtrans %}
+ {{ finished_jobs|length }}
+
+ {{ _self.job_table(finished_jobs, true) }}
+ {% endif %}
{% endif %}
{% endblock %}
+
+{% macro job_table(jobs, showCompletedAt) %}
+
+
+
+
+ {% trans %}info_providers.bulk_import.job_name{% endtrans %}
+ {% trans %}info_providers.bulk_import.parts_count{% endtrans %}
+ {% trans %}info_providers.bulk_import.results_count{% endtrans %}
+ {% trans %}info_providers.bulk_import.progress{% endtrans %}
+ {% trans %}info_providers.bulk_import.status{% endtrans %}
+ {% trans %}info_providers.bulk_import.created_by{% endtrans %}
+ {% trans %}info_providers.bulk_import.created_at{% endtrans %}
+ {% if showCompletedAt %}
+ {% trans %}info_providers.bulk_import.completed_at{% endtrans %}
+ {% endif %}
+ {% trans %}info_providers.bulk_import.action.label{% endtrans %}
+
+
+
+ {% for job in jobs %}
+ {{ _self.job_row(job, showCompletedAt) }}
+ {% endfor %}
+
+
+
+{% endmacro %}
+
+{% macro job_row(job, showCompletedAt) %}
+ {% set showCompletedAt = showCompletedAt|default(false) %}
+
+
+ #{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}
+ {{ job.formattedTimestamp }}
+
+ {{ job.partCount }}
+ {{ job.resultCount }}
+
+
+
+
{{ job.progressPercentage }}%
+
+
+ {% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %}
+
+
+
+ {% if job.isPending %}
+ {% trans %}info_providers.bulk_import.status.pending{% endtrans %}
+ {% elseif job.isInProgress %}
+ {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}
+ {% elseif job.isCompleted %}
+ {% trans %}info_providers.bulk_import.status.completed{% endtrans %}
+ {% elseif job.isStopped %}
+ {% trans %}info_providers.bulk_import.status.stopped{% endtrans %}
+ {% elseif job.isFailed %}
+ {% trans %}info_providers.bulk_import.status.failed{% endtrans %}
+ {% endif %}
+
+ {{ job.createdBy.fullName(true) }}
+ {{ job.createdAt|format_datetime('short') }}
+ {% if showCompletedAt %}
+
+ {% if job.completedAt %}
+ {{ job.completedAt|format_datetime('short') }}
+ {% else %}
+ -
+ {% endif %}
+
+ {% endif %}
+
+
+ {% if job.isInProgress or job.isCompleted or job.isStopped %}
+
+ {% trans %}info_providers.bulk_import.view_results{% endtrans %}
+
+ {% endif %}
+ {% if job.canBeStopped %}
+
+ {% trans %}info_providers.bulk_import.action.stop{% endtrans %}
+
+ {% endif %}
+ {% if job.isCompleted or job.isFailed or job.isStopped %}
+
+ {% trans %}info_providers.bulk_import.action.delete{% endtrans %}
+
+ {% endif %}
+
+
+
+{% endmacro %}
diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig
index 559ca20a..e68202e0 100644
--- a/templates/info_providers/bulk_import/step2.html.twig
+++ b/templates/info_providers/bulk_import/step2.html.twig
@@ -9,22 +9,42 @@
{% block card_title %}
{% trans %}info_providers.bulk_import.step2.title{% endtrans %}
- {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
+ #{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}
{% endblock %}
{% block card_content %}
+
+
+
+ {% if job.isCompleted %}
+
+
+ {% trans %}info_providers.bulk_import.job_completed{% endtrans %}
+ {% trans %}info_providers.bulk_import.job_completed.description{% endtrans %}
+
+ {% endif %}
+
-
{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
+ #{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}
{{ job.partCount }} {% trans %}info_providers.bulk_import.parts{% endtrans %} •
{{ job.resultCount }} {% trans %}info_providers.bulk_import.results{% endtrans %} •
@@ -95,6 +115,13 @@
{% trans %}info_providers.bulk_import.research.all_pending{% endtrans %}
+
+
+ {% trans %}info_providers.bulk_import.quick_apply_all{% endtrans %}
+
@@ -181,39 +208,74 @@
- {% for result in part_result.searchResults %}
+ {% set sortedResults = part_result.resultsSortedByPriority %}
+ {% for result in sortedResults %}
{# @var result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO #}
{% set dto = result.searchResult %}
{% set localPart = result.localPart %}
-
+ {% set isTopResult = loop.first %}
+
-
+ {% if dto.preview_image_url %}
+
+ {% endif %}
+ {# Check for matches against source keyword (what was searched) #}
+ {% set sourceKw = result.sourceKeyword|default('')|lower %}
+ {% set nameMatch = sourceKw is not empty and dto.name is not null and dto.name|lower == sourceKw %}
+ {% set mpnMatch = sourceKw is not empty and dto.mpn is not null and dto.mpn|lower == sourceKw %}
+ {% set spnMatch = sourceKw is not empty and dto.provider_id is not null and dto.provider_id|lower == sourceKw %}
+ {% set anyMatch = nameMatch or mpnMatch or spnMatch %}
{% if dto.provider_url is not null %}
- {{ dto.name }}
+ {{ dto.name }}
{% else %}
- {{ dto.name }}
+ {{ dto.name }}
+ {% endif %}
+ {% if nameMatch %}
+
{% endif %}
{% if dto.mpn is not null %}
- {{ dto.mpn }}
+ {{ dto.mpn }}
+ {% if mpnMatch %}
+ MPN
+ {% endif %}
{% endif %}
{{ dto.description }}
{{ dto.manufacturer ?? '' }}
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
- {{ dto.provider_id }}
+ {{ dto.provider_id }}
+ {% if spnMatch %}
+ SPN
+ {% endif %}
- {{ result.sourceField ?? 'unknown' }}
+ {% if anyMatch %}
+ {% trans %}info_providers.bulk_import.match{% endtrans %}
+ {% else %}
+ {{ result.sourceField ?? 'unknown' }}
+ {% endif %}
{% if result.sourceKeyword %}
- {{ result.sourceKeyword }}
- {% endif %}
+ {{ result.sourceKeyword }}
+ {% endif %}
+ {% if not isCompleted %}
+
+ {% trans %}info_providers.bulk_import.quick_apply{% endtrans %}
+ {% if isTopResult %}{% trans %}info_providers.bulk_import.recommended{% endtrans %} {% endif %}
+
+ {% endif %}
{% set updateHref = path('info_providers_update_part',
{'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %}
diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php
index ec3629fe..d768f55c 100644
--- a/tests/Controller/BulkInfoProviderImportControllerTest.php
+++ b/tests/Controller/BulkInfoProviderImportControllerTest.php
@@ -589,6 +589,296 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase
return $parts;
}
+ public function testQuickApplyWithNonExistentJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/part/1/quick-apply');
+
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertArrayHasKey('error', $response);
+ }
+
+ public function testQuickApplyWithNonExistentPart(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $user = $userRepository->findOneBy(['name' => 'admin']);
+
+ if (!$user) {
+ $this->markTestSkipped('Admin user not found in fixtures');
+ }
+
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+ $job->setStatus(BulkImportJobStatus::IN_PROGRESS);
+ $job->setSearchResults(new BulkSearchResponseDTO([]));
+
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/999999/quick-apply');
+
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+
+ // Clean up
+ $entityManager->remove($job);
+ $entityManager->flush();
+ }
+
+ public function testQuickApplyWithNoSearchResults(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $user = $userRepository->findOneBy(['name' => 'admin']);
+
+ if (!$user) {
+ $this->markTestSkipped('Admin user not found in fixtures');
+ }
+
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+ $job->setStatus(BulkImportJobStatus::IN_PROGRESS);
+ // Empty search results - no provider results for any parts
+ $job->setSearchResults(new BulkSearchResponseDTO([
+ new BulkSearchPartResultsDTO(part: $parts[0], searchResults: [], errors: [])
+ ]));
+
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ // Quick apply without providing providerKey/providerId and no search results available
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/quick-apply', [], [], [
+ 'CONTENT_TYPE' => 'application/json',
+ ], json_encode([]));
+
+ $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertFalse($response['success']);
+
+ // Clean up
+ $entityManager->remove($job);
+ $entityManager->flush();
+ }
+
+ public function testQuickApplyAccessControl(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $admin = $userRepository->findOneBy(['name' => 'admin']);
+ $readonly = $userRepository->findOneBy(['name' => 'noread']);
+
+ if (!$admin || !$readonly) {
+ $this->markTestSkipped('Required test users not found in fixtures');
+ }
+
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ // Create job owned by readonly user
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($readonly);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+ $job->setStatus(BulkImportJobStatus::IN_PROGRESS);
+ $job->setSearchResults(new BulkSearchResponseDTO([]));
+
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ // Admin tries to quick apply on readonly user's job - should fail
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/quick-apply');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+
+ // Clean up
+ $jobId = $job->getId();
+ $entityManager->clear();
+ $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
+ if ($persistedJob) {
+ $entityManager->remove($persistedJob);
+ $entityManager->flush();
+ }
+ }
+
+ public function testQuickApplyAllWithNonExistentJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/quick-apply-all');
+
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertArrayHasKey('error', $response);
+ }
+
+ public function testQuickApplyAllWithNoResults(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $user = $userRepository->findOneBy(['name' => 'admin']);
+
+ if (!$user) {
+ $this->markTestSkipped('Admin user not found in fixtures');
+ }
+
+ $parts = $this->getTestParts($entityManager, [1, 2]);
+
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+ $job->setStatus(BulkImportJobStatus::IN_PROGRESS);
+ // Empty search results for all parts
+ $job->setSearchResults(new BulkSearchResponseDTO([
+ new BulkSearchPartResultsDTO(part: $parts[0], searchResults: [], errors: []),
+ new BulkSearchPartResultsDTO(part: $parts[1], searchResults: [], errors: []),
+ ]));
+
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/quick-apply-all');
+
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertTrue($response['success']);
+ $this->assertEquals(0, $response['applied']);
+ $this->assertEquals(2, $response['no_results']);
+
+ // Clean up
+ $entityManager->remove($job);
+ $entityManager->flush();
+ }
+
+ public function testQuickApplyAllAccessControl(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $userRepository = $entityManager->getRepository(User::class);
+ $readonly = $userRepository->findOneBy(['name' => 'noread']);
+
+ if (!$readonly) {
+ $this->markTestSkipped('Required test users not found in fixtures');
+ }
+
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($readonly);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+ $job->setStatus(BulkImportJobStatus::IN_PROGRESS);
+ $job->setSearchResults(new BulkSearchResponseDTO([]));
+
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ // Admin tries quick apply all on readonly user's job
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/quick-apply-all');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+
+ // Clean up
+ $jobId = $job->getId();
+ $entityManager->clear();
+ $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
+ if ($persistedJob) {
+ $entityManager->remove($persistedJob);
+ $entityManager->flush();
+ }
+ }
+
+ public function testStep2TemplateRenderingWithQuickApplyButtons(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = static::getContainer()->get('doctrine')->getManager();
+ $partRepository = $entityManager->getRepository(Part::class);
+ $part = $partRepository->find(1);
+
+ if (!$part) {
+ $this->markTestSkipped('Test part with ID 1 not found in fixtures');
+ }
+
+ $userRepository = $entityManager->getRepository(User::class);
+ $user = $userRepository->findOneBy(['name' => 'admin']);
+
+ if (!$user) {
+ $this->markTestSkipped('Admin user not found in fixtures');
+ }
+
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+ $job->addPart($part);
+ $job->setStatus(BulkImportJobStatus::IN_PROGRESS);
+
+ $searchResults = new BulkSearchResponseDTO(partResults: [
+ new BulkSearchPartResultsDTO(part: $part,
+ searchResults: [new BulkSearchPartResultDTO(
+ searchResult: new SearchResultDTO(provider_key: 'test_provider', provider_id: 'TEST123', name: 'Test Component', description: 'Test description', manufacturer: 'Test Mfg', mpn: 'TEST-MPN', provider_url: 'https://example.com/test', preview_image_url: null),
+ sourceField: 'mpn',
+ sourceKeyword: 'TEST-MPN',
+ )]
+ )
+ ]);
+
+ $job->setSearchResults($searchResults);
+
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ $client->request('GET', '/tools/bulk_info_provider_import/step2/' . $job->getId());
+
+ if ($client->getResponse()->isRedirect()) {
+ $client->followRedirect();
+ }
+
+ self::assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ $content = (string) $client->getResponse()->getContent();
+ // Verify quick apply buttons are rendered (Stimulus renders camelCase as kebab-case data attributes)
+ $this->assertStringContainsString('quick-apply-url-value', $content);
+ $this->assertStringContainsString('quick-apply-all-url-value', $content);
+
+ // Clean up
+ $jobId = $job->getId();
+ $entityManager->clear();
+ $jobToRemove = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
+ if ($jobToRemove) {
+ $entityManager->remove($jobToRemove);
+ $entityManager->flush();
+ }
+ }
+
public function testStep1Form(): void
{
$client = static::createClient();
@@ -735,13 +1025,9 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase
new BulkSearchFieldMappingDTO('test_supplier_spn', ['test'], 2)
];
- // The service should be able to process the request and throw an exception when no results are found
- try {
- $bulkService->performBulkSearch([$part], $fieldMappings, false);
- $this->fail('Expected RuntimeException to be thrown when no search results are found');
- } catch (\RuntimeException $e) {
- $this->assertStringContainsString('No search results found', $e->getMessage());
- }
+ // The service should return an empty response DTO when no results are found
+ $response = $bulkService->performBulkSearch([$part], $fieldMappings, false);
+ $this->assertFalse($response->hasAnyResults());
}
public function testBulkInfoProviderServiceBatchProcessing(): void
@@ -765,13 +1051,9 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase
new BulkSearchFieldMappingDTO('empty', ['test'], 1)
];
- // The service should be able to process the request and throw an exception when no results are found
- try {
- $response = $bulkService->performBulkSearch([$part], $fieldMappings, false);
- $this->fail('Expected RuntimeException to be thrown when no search results are found');
- } catch (\RuntimeException $e) {
- $this->assertStringContainsString('No search results found', $e->getMessage());
- }
+ // The service should return an empty response DTO when no results are found
+ $response = $bulkService->performBulkSearch([$part], $fieldMappings, false);
+ $this->assertFalse($response->hasAnyResults());
}
public function testBulkInfoProviderServicePrefetchDetails(): void
@@ -887,4 +1169,684 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase
$entityManager->remove($job);
$entityManager->flush();
}
+
+ /**
+ * Helper to create a job with search results for testing.
+ */
+ private function createJobWithSearchResults(object $entityManager, object $user, array $parts, string $status = 'in_progress'): BulkInfoProviderImportJob
+ {
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+
+ $statusEnum = match ($status) {
+ 'pending' => BulkImportJobStatus::PENDING,
+ 'completed' => BulkImportJobStatus::COMPLETED,
+ 'stopped' => BulkImportJobStatus::STOPPED,
+ default => BulkImportJobStatus::IN_PROGRESS,
+ };
+ $job->setStatus($statusEnum);
+
+ // Create search results with a result per part
+ $partResults = [];
+ foreach ($parts as $part) {
+ $partResults[] = new BulkSearchPartResultsDTO(
+ part: $part,
+ searchResults: [
+ new BulkSearchPartResultDTO(
+ searchResult: new SearchResultDTO(
+ provider_key: 'test_provider',
+ provider_id: 'TEST_' . $part->getId(),
+ name: $part->getName() ?? 'Test Part',
+ description: 'Test description',
+ manufacturer: 'Test Mfg',
+ mpn: 'MPN-' . $part->getId(),
+ provider_url: 'https://example.com/' . $part->getId(),
+ preview_image_url: null,
+ ),
+ sourceField: 'mpn',
+ sourceKeyword: $part->getName() ?? 'test',
+ localPart: null,
+ ),
+ ]
+ );
+ }
+
+ $job->setSearchResults(new BulkSearchResponseDTO($partResults));
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ return $job;
+ }
+
+ private function cleanupJob(object $entityManager, int $jobId): void
+ {
+ $entityManager->clear();
+ $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
+ if ($persistedJob) {
+ $entityManager->remove($persistedJob);
+ $entityManager->flush();
+ }
+ }
+
+ public function testDeleteCompletedJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'completed');
+ $jobId = $job->getId();
+
+ $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/delete');
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertTrue($response['success']);
+
+ // Verify job was deleted
+ $entityManager->clear();
+ $this->assertNull($entityManager->find(BulkInfoProviderImportJob::class, $jobId));
+ }
+
+ public function testDeleteActiveJobFails(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'in_progress');
+ $jobId = $job->getId();
+
+ $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/delete');
+ $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testDeleteNonExistentJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/999999/delete');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ }
+
+ public function testStopInProgressJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'in_progress');
+ $jobId = $job->getId();
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/stop');
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertTrue($response['success']);
+
+ // Verify job is stopped
+ $entityManager->clear();
+ $stoppedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
+ $this->assertTrue($stoppedJob->isStopped());
+
+ $entityManager->remove($stoppedJob);
+ $entityManager->flush();
+ }
+
+ public function testStopNonExistentJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/stop');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ }
+
+ public function testMarkPartCompletedAutoCompletesJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts);
+ $jobId = $job->getId();
+ $partId = $parts[0]->getId();
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-completed');
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertTrue($response['success']);
+ $this->assertEquals(1, $response['completed_count']);
+ $this->assertTrue($response['job_completed']);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testMarkPartSkippedWithReason(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts);
+ $jobId = $job->getId();
+ $partId = $parts[0]->getId();
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-skipped', [
+ 'reason' => 'Not needed'
+ ]);
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertTrue($response['success']);
+ $this->assertEquals(1, $response['skipped_count']);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testMarkPartPendingAfterCompleted(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts);
+ $jobId = $job->getId();
+ $partId = $parts[0]->getId();
+
+ // First mark as completed
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-completed');
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ // Then mark as pending again
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-pending');
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertTrue($response['success']);
+ $this->assertEquals(0, $response['completed_count']);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testMarkPartCompletedNonExistentJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/part/1/mark-completed');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ }
+
+ public function testQuickApplyWithValidJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts);
+ $jobId = $job->getId();
+ $partId = $parts[0]->getId();
+
+ // Quick apply will fail because test_provider doesn't exist, but it exercises the code path
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/quick-apply', [], [], [
+ 'CONTENT_TYPE' => 'application/json',
+ ], json_encode(['providerKey' => 'test_provider', 'providerId' => 'TEST_1']));
+
+ // Will get 500 because test_provider doesn't exist, which exercises the catch block
+ $this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertFalse($response['success']);
+ $this->assertStringContainsString('Quick apply failed', $response['error']);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testQuickApplyFallsBackToTopResult(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts);
+ $jobId = $job->getId();
+ $partId = $parts[0]->getId();
+
+ // No providerKey/providerId in body - should fall back to top search result
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/quick-apply', [], [], [
+ 'CONTENT_TYPE' => 'application/json',
+ ], '{}');
+
+ // Will get 500 because test_provider doesn't exist, but exercises the fallback code path
+ $this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertStringContainsString('Quick apply failed', $response['error']);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testQuickApplyEmptyResultsReturns400(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ // Create job with empty search results
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+ $job->setStatus(BulkImportJobStatus::IN_PROGRESS);
+ $job->setSearchResults(new BulkSearchResponseDTO([
+ new BulkSearchPartResultsDTO(part: $parts[0], searchResults: [])
+ ]));
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ $jobId = $job->getId();
+ $partId = $parts[0]->getId();
+
+ // No provider specified and no search results - should return 400
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/quick-apply', [], [], [
+ 'CONTENT_TYPE' => 'application/json',
+ ], '{}');
+ $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertStringContainsString('No search result available', $response['error']);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testQuickApplyNonExistentPart(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts);
+ $jobId = $job->getId();
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/999999/quick-apply');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testQuickApplyAllWithValidJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts);
+ $jobId = $job->getId();
+
+ // Quick apply all - will fail for test_provider but exercises the code path
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/quick-apply-all');
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertTrue($response['success']);
+ // Should have 1 failed (because test_provider doesn't exist)
+ $this->assertEquals(1, $response['failed']);
+ $this->assertNotEmpty($response['errors']);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testQuickApplyAllWithNoSearchResults(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ // Create job with empty results
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+ $job->setStatus(BulkImportJobStatus::IN_PROGRESS);
+ $job->setSearchResults(new BulkSearchResponseDTO([
+ new BulkSearchPartResultsDTO(part: $parts[0], searchResults: [])
+ ]));
+ $entityManager->persist($job);
+ $entityManager->flush();
+
+ $jobId = $job->getId();
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/quick-apply-all');
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertTrue($response['success']);
+ $this->assertEquals(0, $response['applied']);
+ $this->assertEquals(1, $response['no_results']);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testQuickApplyAllNonExistentJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/quick-apply-all');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ }
+
+ public function testQuickApplyAllSkipsCompletedParts(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts);
+ $jobId = $job->getId();
+
+ // Mark the part as completed first
+ $job->markPartAsCompleted($parts[0]->getId());
+ $entityManager->flush();
+
+ // Quick apply all should skip already-completed parts
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/quick-apply-all');
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertEquals(0, $response['applied']);
+ $this->assertEquals(0, $response['failed']);
+ $this->assertEquals(0, $response['no_results']);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testDeleteStoppedJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'stopped');
+ $jobId = $job->getId();
+
+ $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/delete');
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertTrue($response['success']);
+
+ $entityManager->clear();
+ $this->assertNull($entityManager->find(BulkInfoProviderImportJob::class, $jobId));
+ }
+
+ public function testManagePageSplitsActiveAndHistory(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ // Create one active and one completed job
+ $activeJob = $this->createJobWithSearchResults($entityManager, $user, $parts, 'in_progress');
+ $completedJob = $this->createJobWithSearchResults($entityManager, $user, $parts, 'completed');
+
+ $client->request('GET', '/en/tools/bulk_info_provider_import/manage');
+ if ($client->getResponse()->isRedirect()) {
+ $client->followRedirect();
+ }
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ $content = (string) $client->getResponse()->getContent();
+ $this->assertStringContainsString('Active Jobs', $content);
+ $this->assertStringContainsString('History', $content);
+
+ $this->cleanupJob($entityManager, $activeJob->getId());
+ $this->cleanupJob($entityManager, $completedJob->getId());
+ }
+
+ public function testManagePageCleansUpPendingJobsWithNoResults(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ // Create a pending job with no results (should be cleaned up)
+ $job = new BulkInfoProviderImportJob();
+ $job->setCreatedBy($user);
+ foreach ($parts as $part) {
+ $job->addPart($part);
+ }
+ $job->setStatus(BulkImportJobStatus::PENDING);
+ $job->setSearchResults(new BulkSearchResponseDTO([]));
+ $entityManager->persist($job);
+ $entityManager->flush();
+ $jobId = $job->getId();
+
+ // Visit manage page - should trigger cleanup
+ $client->request('GET', '/en/tools/bulk_info_provider_import/manage');
+ if ($client->getResponse()->isRedirect()) {
+ $client->followRedirect();
+ }
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+
+ // Verify the stale job was cleaned up
+ $entityManager->clear();
+ $this->assertNull($entityManager->find(BulkInfoProviderImportJob::class, $jobId));
+ }
+
+ public function testStep2RedirectsForNonExistentJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $client->request('GET', '/en/tools/bulk_info_provider_import/step2/999999');
+
+ // Should redirect with error flash
+ $this->assertResponseRedirects();
+ }
+
+ public function testStep2WithOtherUsersJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $otherUser = $entityManager->getRepository(User::class)->findOneBy(['name' => 'noread']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$otherUser || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $otherUser, $parts);
+ $jobId = $job->getId();
+
+ $client->request('GET', '/en/tools/bulk_info_provider_import/step2/' . $jobId);
+
+ // Should redirect with access denied
+ $this->assertResponseRedirects();
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testResearchPartNonExistentJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/part/1/research');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ }
+
+ public function testResearchPartNonExistentPart(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts);
+ $jobId = $job->getId();
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/999999/research');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
+
+ public function testResearchAllNonExistentJob(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/research-all');
+ $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ }
+
+ public function testResearchAllWithAllPartsCompleted(): void
+ {
+ $client = static::createClient();
+ $this->loginAsUser($client, 'admin');
+
+ $entityManager = $client->getContainer()->get('doctrine')->getManager();
+ $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
+ $parts = $this->getTestParts($entityManager, [1]);
+
+ if (!$user || empty($parts)) {
+ $this->markTestSkipped('Required fixtures not found');
+ }
+
+ $job = $this->createJobWithSearchResults($entityManager, $user, $parts);
+ $jobId = $job->getId();
+
+ // Mark all parts as completed
+ foreach ($parts as $part) {
+ $job->markPartAsCompleted($part->getId());
+ }
+ $entityManager->flush();
+
+ $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/research-all');
+ $this->assertResponseStatusCodeSame(Response::HTTP_OK);
+ $response = json_decode($client->getResponse()->getContent(), true);
+ $this->assertTrue($response['success']);
+ $this->assertEquals(0, $response['researched_count']);
+
+ $this->cleanupJob($entityManager, $jobId);
+ }
}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index d5f5c183..0044edcc 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -11211,6 +11211,96 @@ Please note, that you can not impersonate a disabled user. If you try you will g
Update Part
+
+
+ info_providers.bulk_import.back_to_jobs
+ Back to Jobs
+
+
+
+
+ info_providers.bulk_import.back_to_parts
+ Back to Parts
+
+
+
+
+ info_providers.bulk_import.job_completed
+ Job completed!
+
+
+
+
+ info_providers.bulk_import.job_completed.description
+ All parts have been processed. You can review the results below or navigate back to the parts list.
+
+
+
+
+ info_providers.bulk_import.recommended
+ Top
+
+
+
+
+ info_providers.bulk_import.exact_match
+ Exact name match
+
+
+
+
+ info_providers.bulk_import.mpn_match
+ MPN matches
+
+
+
+
+ info_providers.bulk_import.active_jobs
+ Active Jobs
+
+
+
+
+ info_providers.bulk_import.finished_jobs
+ History
+
+
+
+
+ info_providers.bulk_import.spn_match
+ SPN matches
+
+
+
+
+ info_providers.bulk_import.match
+ Match
+
+
+
+
+ info_providers.bulk_import.quick_apply
+ Quick Apply
+
+
+
+
+ info_providers.bulk_import.quick_apply.tooltip
+ Apply this provider result to the part without opening the edit form
+
+
+
+
+ info_providers.bulk_import.quick_apply_all
+ Apply All (Top Results)
+
+
+
+
+ info_providers.bulk_import.quick_apply_all.tooltip
+ Apply the top-ranked search result to all pending parts without individual review
+
+
info_providers.bulk_import.prefetch_details