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/composer.lock b/composer.lock
index d9795640..f25634d4 100644
--- a/composer.lock
+++ b/composer.lock
@@ -318,16 +318,16 @@
},
{
"name": "amphp/hpack",
- "version": "v3.2.1",
+ "version": "v3.2.2",
"source": {
"type": "git",
"url": "https://github.com/amphp/hpack.git",
- "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239"
+ "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239",
- "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239",
+ "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4",
+ "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4",
"shasum": ""
},
"require": {
@@ -336,7 +336,7 @@
"require-dev": {
"amphp/php-cs-fixer-config": "^2",
"http2jp/hpack-test-case": "^1",
- "nikic/php-fuzzer": "^0.0.10",
+ "nikic/php-fuzzer": "^0.0.11",
"phpunit/phpunit": "^7 | ^8 | ^9"
},
"type": "library",
@@ -380,7 +380,7 @@
],
"support": {
"issues": "https://github.com/amphp/hpack/issues",
- "source": "https://github.com/amphp/hpack/tree/v3.2.1"
+ "source": "https://github.com/amphp/hpack/tree/v3.2.2"
},
"funding": [
{
@@ -388,7 +388,7 @@
"type": "github"
}
],
- "time": "2024-03-21T19:00:16+00:00"
+ "time": "2026-05-03T19:28:59+00:00"
},
{
"name": "amphp/http",
@@ -17374,16 +17374,16 @@
},
{
"name": "symplify/easy-coding-standard",
- "version": "13.0.4",
+ "version": "13.1.2",
"source": {
"type": "git",
- "url": "https://github.com/easy-coding-standard/easy-coding-standard.git",
- "reference": "5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8"
+ "url": "https://github.com/easy-coding-standard/ecs.git",
+ "reference": "6d22473d1f36945884d8cb291777166020a47770"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8",
- "reference": "5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8",
+ "url": "https://api.github.com/repos/easy-coding-standard/ecs/zipball/6d22473d1f36945884d8cb291777166020a47770",
+ "reference": "6d22473d1f36945884d8cb291777166020a47770",
"shasum": ""
},
"require": {
@@ -17418,8 +17418,8 @@
"static analysis"
],
"support": {
- "issues": "https://github.com/easy-coding-standard/easy-coding-standard/issues",
- "source": "https://github.com/easy-coding-standard/easy-coding-standard/tree/13.0.4"
+ "issues": "https://github.com/easy-coding-standard/ecs/issues",
+ "source": "https://github.com/easy-coding-standard/ecs/tree/13.1.2"
},
"funding": [
{
@@ -17431,7 +17431,7 @@
"type": "github"
}
],
- "time": "2026-01-05T09:10:04+00:00"
+ "time": "2026-05-03T22:05:09+00:00"
},
{
"name": "tecnickcom/tc-lib-barcode",
@@ -18479,16 +18479,16 @@
},
{
"name": "web-auth/cose-lib",
- "version": "4.5.1",
+ "version": "4.5.2",
"source": {
"type": "git",
"url": "https://github.com/web-auth/cose-lib.git",
- "reference": "3185af4df10dc537b65c140c315b88d15ae15b80"
+ "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3185af4df10dc537b65c140c315b88d15ae15b80",
- "reference": "3185af4df10dc537b65c140c315b88d15ae15b80",
+ "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d",
+ "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d",
"shasum": ""
},
"require": {
@@ -18534,7 +18534,7 @@
],
"support": {
"issues": "https://github.com/web-auth/cose-lib/issues",
- "source": "https://github.com/web-auth/cose-lib/tree/4.5.1"
+ "source": "https://github.com/web-auth/cose-lib/tree/4.5.2"
},
"funding": [
{
@@ -18546,11 +18546,11 @@
"type": "patreon"
}
],
- "time": "2026-04-01T12:47:39+00:00"
+ "time": "2026-05-03T09:49:50+00:00"
},
{
"name": "web-auth/webauthn-lib",
- "version": "5.3.1",
+ "version": "5.3.2",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-lib.git",
@@ -18620,7 +18620,7 @@
"webauthn"
],
"support": {
- "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.1"
+ "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.2"
},
"funding": [
{
@@ -18636,16 +18636,16 @@
},
{
"name": "web-auth/webauthn-symfony-bundle",
- "version": "5.3.1",
+ "version": "5.3.2",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-symfony-bundle.git",
- "reference": "40f5ae033d4bea090aaa395b906ebfa7d7fe6055"
+ "reference": "1d20af98b50810e8776c52b671201b6bb73ea981"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/40f5ae033d4bea090aaa395b906ebfa7d7fe6055",
- "reference": "40f5ae033d4bea090aaa395b906ebfa7d7fe6055",
+ "url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/1d20af98b50810e8776c52b671201b6bb73ea981",
+ "reference": "1d20af98b50810e8776c52b671201b6bb73ea981",
"shasum": ""
},
"require": {
@@ -18703,7 +18703,7 @@
"webauthn"
],
"support": {
- "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.3.1"
+ "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.3.2"
},
"funding": [
{
@@ -18715,7 +18715,7 @@
"type": "patreon"
}
],
- "time": "2026-05-01T12:14:37+00:00"
+ "time": "2026-05-04T08:08:16+00:00"
},
{
"name": "webmozart/assert",
diff --git a/public/kicad/footprints.txt b/public/kicad/footprints.txt
index 551d7d9c..c893ad4b 100644
--- a/public/kicad/footprints.txt
+++ b/public/kicad/footprints.txt
@@ -1,4 +1,4 @@
-# Generated on Mon Apr 13 05:19:27 UTC 2026
+# Generated on Mon May 4 05:40:05 UTC 2026
# This file contains all footprints available in the offical KiCAD library
Audio_Module:Reverb_BTDR-1H
Audio_Module:Reverb_BTDR-1V
@@ -8366,6 +8366,7 @@ Converter_DCDC:Converter_DCDC_TRACO_TMR-1SM_SMD
Converter_DCDC:Converter_DCDC_TRACO_TMR10-24xxWIR_48xxWIR_72xxWIR_THT
Converter_DCDC:Converter_DCDC_TRACO_TMR2-xxxxWI_THT
Converter_DCDC:Converter_DCDC_TRACO_TMR4-xxxxWI_THT
+Converter_DCDC:Converter_DCDC_TRACO_TMR8-xxxxWI_THT
Converter_DCDC:Converter_DCDC_TRACO_TMU3-05xx_12xx_THT
Converter_DCDC:Converter_DCDC_TRACO_TMU3-24xx_THT
Converter_DCDC:Converter_DCDC_TRACO_TMV-051xD_121xD_Dual_THT
@@ -11978,6 +11979,8 @@ Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm_ThermalVias
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm
Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm_ThermalVias
+Package_DFN_QFN:VQFN-52-1EP_6x6mm_P0.4mm_EP4.7x4.7mm
+Package_DFN_QFN:VQFN-52-1EP_6x6mm_P0.4mm_EP4.7x4.7mm_ThermalVias
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm_ThermalVias
Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.5x5.06mm
@@ -12028,6 +12031,8 @@ Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm_ThermalVias
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm
Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm_ThermalVias
+Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm
+Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm_ThermalVias
Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm
Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm_ThermalVias
Package_DFN_QFN:WQFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm
diff --git a/public/kicad/symbols.txt b/public/kicad/symbols.txt
index 34e246a5..f41aa152 100644
--- a/public/kicad/symbols.txt
+++ b/public/kicad/symbols.txt
@@ -1,4 +1,4 @@
-# Generated on Mon Apr 13 05:20:06 UTC 2026
+# Generated on Mon May 4 05:40:43 UTC 2026
# This file contains all symbols available in the offical KiCAD library
4xxx:14528
4xxx:14529
@@ -8845,6 +8845,7 @@ Interface_USB:CH343G
Interface_USB:CH343P
Interface_USB:CH344Q
Interface_USB:CH9102F
+Interface_USB:CP2102C-Axx-xQFN24
Interface_USB:CP2102N-Axx-xQFN20
Interface_USB:CP2102N-Axx-xQFN24
Interface_USB:CP2102N-Axx-xQFN28
diff --git a/src/Command/Migrations/DBPlatformConvertCommand.php b/src/Command/Migrations/DBPlatformConvertCommand.php
index 86052bf7..d1215da4 100644
--- a/src/Command/Migrations/DBPlatformConvertCommand.php
+++ b/src/Command/Migrations/DBPlatformConvertCommand.php
@@ -229,24 +229,37 @@ class DBPlatformConvertCommand extends Command
if ($platform instanceof PostgreSQLPlatform) {
$connection->executeStatement(
- //From: https://wiki.postgresql.org/wiki/Fixing_Sequences
+ //See https://github.com/Part-DB/Part-DB-server/issues/1362
<<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/Controller/PartController.php b/src/Controller/PartController.php
index ab424f50..735a48f8 100644
--- a/src/Controller/PartController.php
+++ b/src/Controller/PartController.php
@@ -36,6 +36,7 @@ use App\Entity\PriceInformations\Orderdetail;
use App\Entity\ProjectSystem\Project;
use App\Exceptions\AttachmentDownloadException;
use App\Form\Part\PartBaseType;
+use App\Form\Part\PartLotType;
use App\Services\Attachments\AttachmentSubmitHandler;
use App\Services\Attachments\PartPreviewGenerator;
use App\Services\EntityMergers\Mergers\PartMerger;
@@ -128,6 +129,17 @@ final class PartController extends AbstractController
$table = null;
}
+ // Build the add-lot form for the INFO page modal (only when not in time-travel mode)
+ $addLotForm = null;
+ if ($timeTravel_timestamp === null && $this->isGranted('edit', $part)) {
+ $newLot = new PartLot();
+ $newLot->setPart($part);
+ $addLotForm = $this->createForm(PartLotType::class, $newLot, [
+ 'measurement_unit' => $part->getPartUnit(),
+ 'action' => $this->generateUrl('part_lot_add', ['id' => $part->getID()]),
+ ]);
+ }
+
return $this->render(
'parts/info/show_part_info.html.twig',
[
@@ -140,10 +152,39 @@ final class PartController extends AbstractController
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
'withdraw_add_helper' => $withdrawAddHelper,
'highlightLotId' => $request->query->getInt('highlightLot', 0),
+ 'add_lot_form' => $addLotForm,
]
);
}
+ #[Route(path: '/{id}/add_lot', name: 'part_lot_add', methods: ['POST'])]
+ public function addLot(Part $part, Request $request, EntityManagerInterface $em): Response
+ {
+ $this->denyAccessUnlessGranted('edit', $part);
+
+ $newLot = new PartLot();
+ $newLot->setPart($part);
+
+ $form = $this->createForm(PartLotType::class, $newLot, [
+ 'measurement_unit' => $part->getPartUnit(),
+ ]);
+
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $em->persist($newLot);
+ $em->flush();
+ $this->addFlash('success', 'part.edited_flash');
+ return $this->redirectToRoute('part_info', [
+ 'id' => $part->getID(),
+ 'highlightLot' => $newLot->getID(),
+ ]);
+ }
+
+ $this->addFlash('error', 'part.created_flash.invalid');
+ return $this->redirectToRoute('part_info', ['id' => $part->getID()]);
+ }
+
#[Route(path: '/{id}/edit', name: 'part_edit')]
public function edit(Part $part, Request $request): Response
{
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/templates/parts/info/_add_lot_modal.html.twig b/templates/parts/info/_add_lot_modal.html.twig
new file mode 100644
index 00000000..b31f368f
--- /dev/null
+++ b/templates/parts/info/_add_lot_modal.html.twig
@@ -0,0 +1,46 @@
+{% if add_lot_form is not null %}
+{% form_theme add_lot_form 'form/extended_bootstrap_layout.html.twig' %}
+
+
+
+
+ {{ form_start(add_lot_form) }}
+
+
+ {{ form_row(add_lot_form.description) }}
+ {{ form_row(add_lot_form.storage_location) }}
+ {{ form_row(add_lot_form.amount) }}
+ {{ form_row(add_lot_form.instock_unknown) }}
+ {{ form_row(add_lot_form.needs_refill) }}
+ {{ form_row(add_lot_form.expiration_date) }}
+
+
+
+
+ {{ form_end(add_lot_form) }}
+
+
+
+{% endif %}
diff --git a/templates/parts/info/_part_lots.html.twig b/templates/parts/info/_part_lots.html.twig
index 70e5dc4e..7e53aec1 100644
--- a/templates/parts/info/_part_lots.html.twig
+++ b/templates/parts/info/_part_lots.html.twig
@@ -3,6 +3,7 @@
{% include "parts/info/_withdraw_modal.html.twig" %}
{% include "parts/info/_stocktake_modal.html.twig" %}
+{% include "parts/info/_add_lot_modal.html.twig" %}
+
+{% if add_lot_form is not null %}
+
+
+ {% trans %}part_lot.create{% endtrans %}
+
+{% endif %}
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
diff --git a/yarn.lock b/yarn.lock
index 3ea1a62b..4d43533a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2221,11 +2221,11 @@ bail@^2.0.0:
integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==
barcode-detector@^3.0.0, barcode-detector@^3.0.5:
- version "3.1.2"
- resolved "https://registry.yarnpkg.com/barcode-detector/-/barcode-detector-3.1.2.tgz#8032a211ebb6cb5cc25724c5c56322c77ed02503"
- integrity sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ==
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/barcode-detector/-/barcode-detector-3.1.3.tgz#ea3224c8cf106b91e4f05a25ff0d798cb2b380a9"
+ integrity sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw==
dependencies:
- zxing-wasm "3.0.2"
+ zxing-wasm "3.0.3"
base64-js@0.0.8:
version "0.0.8"
@@ -2238,9 +2238,9 @@ base64-js@^1.1.2, base64-js@^1.3.0:
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.12:
- version "2.10.25"
- resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz#183b45c0d3bdd12addb352426555fb3627eb022a"
- integrity sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==
+ version "2.10.27"
+ resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3"
+ integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==
big.js@^5.2.2:
version "5.2.2"
@@ -2650,10 +2650,10 @@ cssesc@^3.0.0:
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
-cssnano-preset-default@^7.0.16:
- version "7.0.16"
- resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-7.0.16.tgz#b0c9576c87488dfa00388e3a0c8b27ee946aa352"
- integrity sha512-W0hiFi/ca/u2OTptL11OdApaz1vh9jyfd2ku9dMjou6KdpdgbMTagaXHKNl5kaEyRSCu9GIIaPRp5YLdqRAZMw==
+cssnano-preset-default@^7.0.17:
+ version "7.0.17"
+ resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-7.0.17.tgz#6c239741cb8fd77556d0c55575de95c38f3a2537"
+ integrity sha512-11qO63A+czwguQFJCaTdICvbaxn0pJzz/XghLlv+OT7WyToDxAMR0Xb3/26/l0y0hQJywwNbj/SLSQlGBHE1OA==
dependencies:
browserslist "^4.28.2"
css-declaration-sorter "^7.2.0"
@@ -2670,7 +2670,7 @@ cssnano-preset-default@^7.0.16:
postcss-minify-font-values "^7.0.3"
postcss-minify-gradients "^7.0.5"
postcss-minify-params "^7.0.9"
- postcss-minify-selectors "^7.1.1"
+ postcss-minify-selectors "^7.1.2"
postcss-normalize-charset "^7.0.3"
postcss-normalize-display-values "^7.0.3"
postcss-normalize-positions "^7.0.4"
@@ -2692,11 +2692,11 @@ cssnano-utils@^5.0.3:
integrity sha512-ynIREMICLxkxm7e9bCR9sh75s4Q5drICi0ua1yxo5jH2XPBqSKkl4dOh4EbFqtUmnTMhRffHgYL0EKKkMjtJTg==
cssnano@^7.0.4:
- version "7.1.8"
- resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-7.1.8.tgz#a6508bd13f3b206676c6594fa19ef45a89acc9dc"
- integrity sha512-OGXtXqXmwEoIGfXM2QoD35vweUAtx+J8ZvLSZHOEV0Jv9Hs9ScTdGGjRzZXun5J4PEZhEoytKig2O2NR8NXxKw==
+ version "7.1.9"
+ resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-7.1.9.tgz#1e8b5db528ae7cb175da0197adfbc559170f845e"
+ integrity sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==
dependencies:
- cssnano-preset-default "^7.0.16"
+ cssnano-preset-default "^7.0.17"
lilconfig "^3.1.3"
csso@^5.0.5:
@@ -3039,9 +3039,9 @@ fast-deep-equal@^3.1.3:
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-uri@^3.0.1:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa"
- integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.1.tgz#dd085fec2494a2a33bac6e61277374669e1dd774"
+ integrity sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==
fastest-levenshtein@1.0.16, fastest-levenshtein@^1.0.12, fastest-levenshtein@^1.0.16:
version "1.0.16"
@@ -4314,10 +4314,10 @@ postcss-minify-params@^7.0.9:
cssnano-utils "^5.0.3"
postcss-value-parser "^4.2.0"
-postcss-minify-selectors@^7.1.1:
- version "7.1.1"
- resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-7.1.1.tgz#2de3a0f9fc07d745d4164adc59865b3a0c9b1b87"
- integrity sha512-MZWXwSTfcpmNVJIs7tddar/275a4/zT5nG9/gEndHPRZGTAQNpiSkk8s/dq+yZVX2jKfvVn1d5X8Z5SJHWnDoQ==
+postcss-minify-selectors@^7.1.2:
+ version "7.1.2"
+ resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-7.1.2.tgz#9cef2eb836fb95c49c11ea6d0921743c9d2f7eb3"
+ integrity sha512-aQtrEWKwqafNlExcKHQvPGsXR2+vlUqqJtf5XsCQcgsSb5PL4wlujWBYDJuWsP4UnQX1YHDHU8qRlD+1PzTQ+Q==
dependencies:
browserslist "^4.28.1"
caniuse-api "^3.0.0"
@@ -4466,9 +4466,9 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.2.14, postcss@^8.4.40:
- version "8.5.13"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.13.tgz#6cfaf647f2e7ef69850208eccd849e0d3f65d420"
- integrity sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==
+ version "8.5.14"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
+ integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
dependencies:
nanoid "^3.3.11"
picocolors "^1.1.1"
@@ -5017,7 +5017,7 @@ tslib@^2.8.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
-type-fest@^5.5.0:
+type-fest@^5.6.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.6.0.tgz#502f7a003b7309e96a7e17052cc2ab2c7e5c7a31"
integrity sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==
@@ -5345,10 +5345,10 @@ zwitch@^2.0.0, zwitch@^2.0.4:
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==
-zxing-wasm@3.0.2:
- version "3.0.2"
- resolved "https://registry.yarnpkg.com/zxing-wasm/-/zxing-wasm-3.0.2.tgz#3c39821f4a5d20b02bc5bacaefe7e6725d9520dd"
- integrity sha512-2YMAriaYHX9wrBY2k7H0epSo+dyCaCZg/vOtt+nEDXM9ul480gkXz/9SkwpOeHcD2H5qqDG8lWDSBwpTcZpa6w==
+zxing-wasm@3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/zxing-wasm/-/zxing-wasm-3.0.3.tgz#f87a45f7f90420e0f8c1a587147384d1cfeb4759"
+ integrity sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ==
dependencies:
"@types/emscripten" "^1.41.5"
- type-fest "^5.5.0"
+ type-fest "^5.6.0"