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 %} -
- - - - - - - - - - - - - - - - {% for job in jobs %} - - - - - - - - - - - - {% endfor %} - -
{% 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 %}
- {{ 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 %} - - {% endif %} - {% if job.isCompleted or job.isFailed or job.isStopped %} - - {% endif %} -
-
-
- {% else %} + {% if active_jobs is empty and finished_jobs is empty %} + {% 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) %} +
+ + + + + + + + + + + {% if showCompletedAt %} + + {% endif %} + + + + + {% for job in jobs %} + {{ _self.job_row(job, showCompletedAt) }} + {% endfor %} + +
{% 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 %}
+
+{% 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 %} + + {% endif %} + {% if job.isCompleted or job.isFailed or job.isStopped %} + + {% 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 %} + +
+ + {% trans %}info_providers.bulk_import.back_to_jobs{% endtrans %} + + + {% trans %}info_providers.bulk_import.back_to_parts{% endtrans %} + +
+ + {% if job.isCompleted %} + + {% 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 %} +
@@ -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 %} + + {% 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' %} + + +{% 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" %}
@@ -126,3 +127,10 @@
+ +{% if add_lot_form is not null %} + +{% 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"