diff --git a/assets/controllers/bulk_import_controller.js b/assets/controllers/bulk_import_controller.js
index a04ff13e..49e4d60f 100644
--- a/assets/controllers/bulk_import_controller.js
+++ b/assets/controllers/bulk_import_controller.js
@@ -3,16 +3,14 @@ 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,
- quickApplyUrl: String,
- quickApplyAllUrl: String
+ markPendingUrl: String
}
connect() {
@@ -121,11 +119,13 @@ 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'
+ method: 'POST',
+ body: JSON.stringify({ reason })
})
if (data.success) {
@@ -321,94 +321,6 @@ 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 50c19a0d..9c9c8ac6 100644
--- a/assets/controllers/field_mapping_controller.js
+++ b/assets/controllers/field_mapping_controller.js
@@ -70,13 +70,6 @@ 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()
}
@@ -126,18 +119,6 @@ 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 f25634d4..d9795640 100644
--- a/composer.lock
+++ b/composer.lock
@@ -318,16 +318,16 @@
},
{
"name": "amphp/hpack",
- "version": "v3.2.2",
+ "version": "v3.2.1",
"source": {
"type": "git",
"url": "https://github.com/amphp/hpack.git",
- "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4"
+ "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4",
- "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4",
+ "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239",
+ "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239",
"shasum": ""
},
"require": {
@@ -336,7 +336,7 @@
"require-dev": {
"amphp/php-cs-fixer-config": "^2",
"http2jp/hpack-test-case": "^1",
- "nikic/php-fuzzer": "^0.0.11",
+ "nikic/php-fuzzer": "^0.0.10",
"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.2"
+ "source": "https://github.com/amphp/hpack/tree/v3.2.1"
},
"funding": [
{
@@ -388,7 +388,7 @@
"type": "github"
}
],
- "time": "2026-05-03T19:28:59+00:00"
+ "time": "2024-03-21T19:00:16+00:00"
},
{
"name": "amphp/http",
@@ -17374,16 +17374,16 @@
},
{
"name": "symplify/easy-coding-standard",
- "version": "13.1.2",
+ "version": "13.0.4",
"source": {
"type": "git",
- "url": "https://github.com/easy-coding-standard/ecs.git",
- "reference": "6d22473d1f36945884d8cb291777166020a47770"
+ "url": "https://github.com/easy-coding-standard/easy-coding-standard.git",
+ "reference": "5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/easy-coding-standard/ecs/zipball/6d22473d1f36945884d8cb291777166020a47770",
- "reference": "6d22473d1f36945884d8cb291777166020a47770",
+ "url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8",
+ "reference": "5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8",
"shasum": ""
},
"require": {
@@ -17418,8 +17418,8 @@
"static analysis"
],
"support": {
- "issues": "https://github.com/easy-coding-standard/ecs/issues",
- "source": "https://github.com/easy-coding-standard/ecs/tree/13.1.2"
+ "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"
},
"funding": [
{
@@ -17431,7 +17431,7 @@
"type": "github"
}
],
- "time": "2026-05-03T22:05:09+00:00"
+ "time": "2026-01-05T09:10:04+00:00"
},
{
"name": "tecnickcom/tc-lib-barcode",
@@ -18479,16 +18479,16 @@
},
{
"name": "web-auth/cose-lib",
- "version": "4.5.2",
+ "version": "4.5.1",
"source": {
"type": "git",
"url": "https://github.com/web-auth/cose-lib.git",
- "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d"
+ "reference": "3185af4df10dc537b65c140c315b88d15ae15b80"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d",
- "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d",
+ "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3185af4df10dc537b65c140c315b88d15ae15b80",
+ "reference": "3185af4df10dc537b65c140c315b88d15ae15b80",
"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.2"
+ "source": "https://github.com/web-auth/cose-lib/tree/4.5.1"
},
"funding": [
{
@@ -18546,11 +18546,11 @@
"type": "patreon"
}
],
- "time": "2026-05-03T09:49:50+00:00"
+ "time": "2026-04-01T12:47:39+00:00"
},
{
"name": "web-auth/webauthn-lib",
- "version": "5.3.2",
+ "version": "5.3.1",
"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.2"
+ "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.1"
},
"funding": [
{
@@ -18636,16 +18636,16 @@
},
{
"name": "web-auth/webauthn-symfony-bundle",
- "version": "5.3.2",
+ "version": "5.3.1",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-symfony-bundle.git",
- "reference": "1d20af98b50810e8776c52b671201b6bb73ea981"
+ "reference": "40f5ae033d4bea090aaa395b906ebfa7d7fe6055"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/1d20af98b50810e8776c52b671201b6bb73ea981",
- "reference": "1d20af98b50810e8776c52b671201b6bb73ea981",
+ "url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/40f5ae033d4bea090aaa395b906ebfa7d7fe6055",
+ "reference": "40f5ae033d4bea090aaa395b906ebfa7d7fe6055",
"shasum": ""
},
"require": {
@@ -18703,7 +18703,7 @@
"webauthn"
],
"support": {
- "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.3.2"
+ "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.3.1"
},
"funding": [
{
@@ -18715,7 +18715,7 @@
"type": "patreon"
}
],
- "time": "2026-05-04T08:08:16+00:00"
+ "time": "2026-05-01T12:14:37+00:00"
},
{
"name": "webmozart/assert",
diff --git a/public/kicad/footprints.txt b/public/kicad/footprints.txt
index c893ad4b..551d7d9c 100644
--- a/public/kicad/footprints.txt
+++ b/public/kicad/footprints.txt
@@ -1,4 +1,4 @@
-# Generated on Mon May 4 05:40:05 UTC 2026
+# Generated on Mon Apr 13 05:19:27 UTC 2026
# This file contains all footprints available in the offical KiCAD library
Audio_Module:Reverb_BTDR-1H
Audio_Module:Reverb_BTDR-1V
@@ -8366,7 +8366,6 @@ 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
@@ -11979,8 +11978,6 @@ 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
@@ -12031,8 +12028,6 @@ 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 f41aa152..34e246a5 100644
--- a/public/kicad/symbols.txt
+++ b/public/kicad/symbols.txt
@@ -1,4 +1,4 @@
-# Generated on Mon May 4 05:40:43 UTC 2026
+# Generated on Mon Apr 13 05:20:06 UTC 2026
# This file contains all symbols available in the offical KiCAD library
4xxx:14528
4xxx:14529
@@ -8845,7 +8845,6 @@ 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 d1215da4..86052bf7 100644
--- a/src/Command/Migrations/DBPlatformConvertCommand.php
+++ b/src/Command/Migrations/DBPlatformConvertCommand.php
@@ -229,37 +229,24 @@ class DBPlatformConvertCommand extends Command
if ($platform instanceof PostgreSQLPlatform) {
$connection->executeStatement(
- //See https://github.com/Part-DB/Part-DB-server/issues/1362
+ //From: https://wiki.postgresql.org/wiki/Fixing_Sequences
<<getResultCount() === 0 && ($job->isInProgress() || $job->isPending())) {
+ // Mark jobs with no results for deletion (failed searches)
+ if ($job->getResultCount() === 0 && $job->isInProgress()) {
$jobsToDelete[] = $job;
}
}
@@ -304,23 +297,9 @@ 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', [
- 'active_jobs' => $activeJobs,
- 'finished_jobs' => $finishedJobs,
+ 'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
+ ->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup
]);
}
@@ -491,13 +470,22 @@ class BulkInfoProviderImportController extends AbstractController
$fieldMappingDtos = $job->getFieldMappings();
$prefetchDetails = $job->isPrefetchDetails();
- $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
+ 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;
+ }
+ }
// Update the job's search results for this specific part efficiently
$this->updatePartSearchResults($job, $searchResultsDto[0] ?? null);
// Prefetch details if requested
- if ($prefetchDetails) {
+ if ($prefetchDetails && $searchResultsDto !== null) {
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
}
@@ -527,191 +515,6 @@ 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 735a48f8..ab424f50 100644
--- a/src/Controller/PartController.php
+++ b/src/Controller/PartController.php
@@ -36,7 +36,6 @@ 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;
@@ -129,17 +128,6 @@ 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',
[
@@ -152,39 +140,10 @@ 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 79420134..586fb873 100644
--- a/src/Services/InfoProviderSystem/BulkInfoProviderService.php
+++ b/src/Services/InfoProviderSystem/BulkInfoProviderService.php
@@ -46,6 +46,7 @@ final class BulkInfoProviderService
}
$partResults = [];
+ $hasAnyResults = false;
// Group providers by batch capability
$batchProviders = [];
@@ -87,6 +88,7 @@ final class BulkInfoProviderService
);
if (!empty($allResults)) {
+ $hasAnyResults = true;
$searchResults = $this->formatSearchResults($allResults);
}
@@ -97,6 +99,10 @@ 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 5f251b43..8bdd776e 100755
--- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
+++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
@@ -397,7 +397,6 @@ 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 b31dd650..9bbed906 100644
--- a/templates/info_providers/bulk_import/manage.html.twig
+++ b/templates/info_providers/bulk_import/manage.html.twig
@@ -22,130 +22,103 @@
- {% if active_jobs is empty and finished_jobs is empty %}
+ {% 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 %}
{% 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 e68202e0..559ca20a 100644
--- a/templates/info_providers/bulk_import/step2.html.twig
+++ b/templates/info_providers/bulk_import/step2.html.twig
@@ -9,42 +9,22 @@
{% block card_title %}
{% trans %}info_providers.bulk_import.step2.title{% endtrans %}
- #{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}
+ {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
{% 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.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}
+ {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
{{ job.partCount }} {% trans %}info_providers.bulk_import.parts{% endtrans %} •
{{ job.resultCount }} {% trans %}info_providers.bulk_import.results{% endtrans %} •
@@ -115,13 +95,6 @@
{% trans %}info_providers.bulk_import.research.all_pending{% endtrans %}
-
-
- {% trans %}info_providers.bulk_import.quick_apply_all{% endtrans %}
-
@@ -208,74 +181,39 @@
- {% set sortedResults = part_result.resultsSortedByPriority %}
- {% for result in sortedResults %}
+ {% for result in part_result.searchResults %}
{# @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 }}
- {% endif %}
- {% if nameMatch %}
-
+ {{ dto.name }}
{% endif %}
{% if dto.mpn is not null %}
- {{ dto.mpn }}
- {% if mpnMatch %}
- MPN
- {% endif %}
+ {{ dto.mpn }}
{% endif %}
{{ dto.description }}
{{ dto.manufacturer ?? '' }}
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
- {{ dto.provider_id }}
- {% if spnMatch %}
- SPN
- {% endif %}
+ {{ dto.provider_id }}
- {% if anyMatch %}
- {% trans %}info_providers.bulk_import.match{% endtrans %}
- {% else %}
- {{ result.sourceField ?? 'unknown' }}
- {% endif %}
+ {{ result.sourceField ?? 'unknown' }}
{% 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
deleted file mode 100644
index b31f368f..00000000
--- a/templates/parts/info/_add_lot_modal.html.twig
+++ /dev/null
@@ -1,46 +0,0 @@
-{% 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 7e53aec1..70e5dc4e 100644
--- a/templates/parts/info/_part_lots.html.twig
+++ b/templates/parts/info/_part_lots.html.twig
@@ -3,7 +3,6 @@
{% 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 d768f55c..ec3629fe 100644
--- a/tests/Controller/BulkInfoProviderImportControllerTest.php
+++ b/tests/Controller/BulkInfoProviderImportControllerTest.php
@@ -589,296 +589,6 @@ 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();
@@ -1025,9 +735,13 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase
new BulkSearchFieldMappingDTO('test_supplier_spn', ['test'], 2)
];
- // The service should return an empty response DTO when no results are found
- $response = $bulkService->performBulkSearch([$part], $fieldMappings, false);
- $this->assertFalse($response->hasAnyResults());
+ // 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());
+ }
}
public function testBulkInfoProviderServiceBatchProcessing(): void
@@ -1051,9 +765,13 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase
new BulkSearchFieldMappingDTO('empty', ['test'], 1)
];
- // The service should return an empty response DTO when no results are found
- $response = $bulkService->performBulkSearch([$part], $fieldMappings, false);
- $this->assertFalse($response->hasAnyResults());
+ // 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());
+ }
}
public function testBulkInfoProviderServicePrefetchDetails(): void
@@ -1169,684 +887,4 @@ 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 0044edcc..d5f5c183 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -11211,96 +11211,6 @@ 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 4d43533a..3ea1a62b 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.3"
- resolved "https://registry.yarnpkg.com/barcode-detector/-/barcode-detector-3.1.3.tgz#ea3224c8cf106b91e4f05a25ff0d798cb2b380a9"
- integrity sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw==
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/barcode-detector/-/barcode-detector-3.1.2.tgz#8032a211ebb6cb5cc25724c5c56322c77ed02503"
+ integrity sha512-Q5kjXpVH5I3ItykNzbWmfWnNryFN1ZTWp10k9/PKJuS0RnoKR7jTrHEJODR4fn04bRomq7TJwie/Dr9fj/GoGQ==
dependencies:
- zxing-wasm "3.0.3"
+ zxing-wasm "3.0.2"
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.27"
- resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3"
- integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==
+ 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==
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.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==
+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==
dependencies:
browserslist "^4.28.2"
css-declaration-sorter "^7.2.0"
@@ -2670,7 +2670,7 @@ cssnano-preset-default@^7.0.17:
postcss-minify-font-values "^7.0.3"
postcss-minify-gradients "^7.0.5"
postcss-minify-params "^7.0.9"
- postcss-minify-selectors "^7.1.2"
+ postcss-minify-selectors "^7.1.1"
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.9"
- resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-7.1.9.tgz#1e8b5db528ae7cb175da0197adfbc559170f845e"
- integrity sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==
+ version "7.1.8"
+ resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-7.1.8.tgz#a6508bd13f3b206676c6594fa19ef45a89acc9dc"
+ integrity sha512-OGXtXqXmwEoIGfXM2QoD35vweUAtx+J8ZvLSZHOEV0Jv9Hs9ScTdGGjRzZXun5J4PEZhEoytKig2O2NR8NXxKw==
dependencies:
- cssnano-preset-default "^7.0.17"
+ cssnano-preset-default "^7.0.16"
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.1"
- resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.1.tgz#dd085fec2494a2a33bac6e61277374669e1dd774"
- integrity sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa"
+ integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==
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.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==
+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==
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.14"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
- integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
+ version "8.5.13"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.13.tgz#6cfaf647f2e7ef69850208eccd849e0d3f65d420"
+ integrity sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==
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.6.0:
+type-fest@^5.5.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.3:
- version "3.0.3"
- resolved "https://registry.yarnpkg.com/zxing-wasm/-/zxing-wasm-3.0.3.tgz#f87a45f7f90420e0f8c1a587147384d1cfeb4759"
- integrity sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ==
+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==
dependencies:
"@types/emscripten" "^1.41.5"
- type-fest "^5.6.0"
+ type-fest "^5.5.0"