diff --git a/.github/workflows/assets_artifact_build.yml b/.github/workflows/assets_artifact_build.yml index 447f95bf..c950375b 100644 --- a/.github/workflows/assets_artifact_build.yml +++ b/.github/workflows/assets_artifact_build.yml @@ -60,7 +60,7 @@ jobs: ${{ runner.os }}-yarn- - name: Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v4 with: node-version: '20' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c7c0965b..66e2f40c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['8.2', '8.3', '8.4', '8.5' ] + php-versions: ['8.2', '8.3', '8.4' ] db-type: [ 'mysql', 'sqlite', 'postgres' ] env: @@ -104,7 +104,7 @@ jobs: run: composer install --prefer-dist --no-progress - name: Setup node - uses: actions/setup-node@v5 + uses: actions/setup-node@v4 with: node-version: '20' diff --git a/.gitignore b/.gitignore index dd5c43db..76655919 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,3 @@ yarn-error.log ###> phpstan/phpstan ### phpstan.neon ###< phpstan/phpstan ### - -.claude/ -CLAUDE.md \ No newline at end of file diff --git a/Makefile b/Makefile deleted file mode 100644 index bc4d0bf3..00000000 --- a/Makefile +++ /dev/null @@ -1,91 +0,0 @@ -# PartDB Makefile for Test Environment Management - -.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \ -test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \ -section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset - -# Default target -help: ## Show this help - @awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) - -# Dependencies -deps-install: ## Install PHP dependencies with unlimited memory - @echo "πŸ“¦ Installing PHP dependencies..." - COMPOSER_MEMORY_LIMIT=-1 composer install - yarn install - @echo "βœ… Dependencies installed" - -# Complete test environment setup -test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures) - @echo "βœ… Test environment setup complete!" - -# Clean test environment -test-clean: ## Clean test cache and database files - @echo "🧹 Cleaning test environment..." - rm -rf var/cache/test - rm -f var/app_test.db - @echo "βœ… Test environment cleaned" - -# Create test database -test-db-create: ## Create test database (if not exists) - @echo "πŸ—„οΈ Creating test database..." - -php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..." - -# Run database migrations for test environment -test-db-migrate: ## Run database migrations for test environment - @echo "πŸ”„ Running database migrations..." - COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test - -# Clear test cache -test-cache-clear: ## Clear test cache - @echo "πŸ—‘οΈ Clearing test cache..." - rm -rf var/cache/test - @echo "βœ… Test cache cleared" - -# Load test fixtures -test-fixtures: ## Load test fixtures - @echo "πŸ“¦ Loading test fixtures..." - php bin/console partdb:fixtures:load -n --env test - -# Run PHPUnit tests -test-run: ## Run PHPUnit tests - @echo "πŸ§ͺ Running tests..." - php bin/phpunit - -# Quick test reset (clean + migrate + fixtures, skip DB creation) -test-reset: test-cache-clear test-db-migrate test-fixtures - @echo "βœ… Test environment reset complete!" - -test-typecheck: ## Run static analysis (PHPStan) - @echo "πŸ§ͺ Running type checks..." - COMPOSER_MEMORY_LIMIT=-1 composer phpstan - -# Development helpers -dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup) - @echo "βœ… Development environment setup complete!" - -dev-clean: ## Clean development cache and database files - @echo "🧹 Cleaning development environment..." - rm -rf var/cache/dev - rm -f var/app_dev.db - @echo "βœ… Development environment cleaned" - -dev-db-create: ## Create development database (if not exists) - @echo "πŸ—„οΈ Creating development database..." - -php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..." - -dev-db-migrate: ## Run database migrations for development environment - @echo "πŸ”„ Running database migrations..." - COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev - -dev-cache-clear: ## Clear development cache - @echo "πŸ—‘οΈ Clearing development cache..." - rm -rf var/cache/dev - @echo "βœ… Development cache cleared" - -dev-warmup: ## Warm up development cache - @echo "πŸ”₯ Warming up development cache..." - COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n - -dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate) - @echo "βœ… Development environment reset complete!" \ No newline at end of file diff --git a/assets/controllers/bulk_import_controller.js b/assets/controllers/bulk_import_controller.js deleted file mode 100644 index 49e4d60f..00000000 --- a/assets/controllers/bulk_import_controller.js +++ /dev/null @@ -1,359 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import { generateCsrfHeaders } from "./csrf_protection_controller" - -export default class extends Controller { - static targets = ["progressBar", "progressText"] - static values = { - jobId: Number, - partId: Number, - researchUrl: String, - researchAllUrl: String, - markCompletedUrl: String, - markSkippedUrl: String, - markPendingUrl: String - } - - connect() { - // Auto-refresh progress if job is in progress - if (this.hasProgressBarTarget) { - this.startProgressUpdates() - } - - // Restore scroll position after page reload (if any) - this.restoreScrollPosition() - } - - getHeaders() { - const headers = { - 'Content-Type': 'application/json', - 'X-Requested-With': 'XMLHttpRequest' - } - - // Add CSRF headers if available - const form = document.querySelector('form') - if (form) { - const csrfHeaders = generateCsrfHeaders(form) - Object.assign(headers, csrfHeaders) - } - - return headers - } - - async fetchWithErrorHandling(url, options = {}, timeout = 30000) { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeout) - - try { - const response = await fetch(url, { - ...options, - headers: { ...this.getHeaders(), ...options.headers }, - signal: controller.signal - }) - - clearTimeout(timeoutId) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Server error (${response.status}): ${errorText}`) - } - - return await response.json() - } catch (error) { - clearTimeout(timeoutId) - - if (error.name === 'AbortError') { - throw new Error('Request timed out. Please try again.') - } else if (error.message.includes('Failed to fetch')) { - throw new Error('Network error. Please check your connection and try again.') - } else { - throw error - } - } - } - - disconnect() { - if (this.progressInterval) { - clearInterval(this.progressInterval) - } - } - - startProgressUpdates() { - // Progress updates are handled via page reload for better reliability - // No need for periodic updates since state changes trigger page refresh - } - - restoreScrollPosition() { - const savedPosition = sessionStorage.getItem('bulkImportScrollPosition') - if (savedPosition) { - // Restore scroll position after a small delay to ensure page is fully loaded - setTimeout(() => { - window.scrollTo(0, parseInt(savedPosition)) - // Clear the saved position so it doesn't interfere with normal navigation - sessionStorage.removeItem('bulkImportScrollPosition') - }, 100) - } - } - - async markCompleted(event) { - const partId = event.currentTarget.dataset.partId - - try { - const url = this.markCompletedUrlValue.replace('__PART_ID__', partId) - const data = await this.fetchWithErrorHandling(url, { method: 'POST' }) - - if (data.success) { - this.updateProgressDisplay(data) - this.markRowAsCompleted(partId) - - if (data.job_completed) { - this.showJobCompletedMessage() - } - } else { - this.showErrorMessage(data.error || 'Failed to mark part as completed') - } - } catch (error) { - console.error('Error marking part as completed:', error) - this.showErrorMessage(error.message || 'Failed to mark part as completed') - } - } - - 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 }) - }) - - if (data.success) { - this.updateProgressDisplay(data) - this.markRowAsSkipped(partId) - } else { - this.showErrorMessage(data.error || 'Failed to mark part as skipped') - } - } catch (error) { - console.error('Error marking part as skipped:', error) - this.showErrorMessage(error.message || 'Failed to mark part as skipped') - } - } - - async markPending(event) { - const partId = event.currentTarget.dataset.partId - - try { - const url = this.markPendingUrlValue.replace('__PART_ID__', partId) - const data = await this.fetchWithErrorHandling(url, { method: 'POST' }) - - if (data.success) { - this.updateProgressDisplay(data) - this.markRowAsPending(partId) - } else { - this.showErrorMessage(data.error || 'Failed to mark part as pending') - } - } catch (error) { - console.error('Error marking part as pending:', error) - this.showErrorMessage(error.message || 'Failed to mark part as pending') - } - } - - updateProgressDisplay(data) { - if (this.hasProgressBarTarget) { - this.progressBarTarget.style.width = `${data.progress}%` - this.progressBarTarget.setAttribute('aria-valuenow', data.progress) - } - - if (this.hasProgressTextTarget) { - this.progressTextTarget.textContent = `${data.completed_count} / ${data.total_count} completed` - } - } - - markRowAsCompleted(partId) { - // Save scroll position and refresh page to show updated state - sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) - window.location.reload() - } - - markRowAsSkipped(partId) { - // Save scroll position and refresh page to show updated state - sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) - window.location.reload() - } - - markRowAsPending(partId) { - // Save scroll position and refresh page to show updated state - sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) - window.location.reload() - } - - showJobCompletedMessage() { - const alert = document.createElement('div') - alert.className = 'alert alert-success alert-dismissible fade show' - alert.innerHTML = ` - - Job completed! All parts have been processed. - - ` - - const container = document.querySelector('.card-body') - container.insertBefore(alert, container.firstChild) - } - - async researchPart(event) { - event.preventDefault() - event.stopPropagation() - - const partId = event.currentTarget.dataset.partId - const spinner = event.currentTarget.querySelector(`[data-research-spinner="${partId}"]`) - const button = event.currentTarget - - // Show loading state - if (spinner) { - spinner.style.display = 'inline-block' - } - button.disabled = true - - try { - const url = this.researchUrlValue.replace('__PART_ID__', partId) - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout - - const response = await fetch(url, { - method: 'POST', - headers: this.getHeaders(), - signal: controller.signal - }) - - clearTimeout(timeoutId) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Server error (${response.status}): ${errorText}`) - } - - const data = await response.json() - - if (data.success) { - this.showSuccessMessage(`Research completed for part. Found ${data.results_count} results.`) - // Save scroll position and reload to show updated results - sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) - window.location.reload() - } else { - this.showErrorMessage(data.error || 'Research failed') - } - } catch (error) { - console.error('Error researching part:', error) - - if (error.name === 'AbortError') { - this.showErrorMessage('Research timed out. Please try again.') - } else if (error.message.includes('Failed to fetch')) { - this.showErrorMessage('Network error. Please check your connection and try again.') - } else { - this.showErrorMessage(error.message || 'Research failed due to an unexpected error') - } - } finally { - // Hide loading state - if (spinner) { - spinner.style.display = 'none' - } - button.disabled = false - } - } - - async researchAllParts(event) { - event.preventDefault() - event.stopPropagation() - - const spinner = document.getElementById('research-all-spinner') - const button = event.currentTarget - - // Show loading state - if (spinner) { - spinner.style.display = 'inline-block' - } - button.disabled = true - - try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 120000) // 2 minute timeout for bulk operations - - const response = await fetch(this.researchAllUrlValue, { - method: 'POST', - headers: this.getHeaders(), - signal: controller.signal - }) - - clearTimeout(timeoutId) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Server error (${response.status}): ${errorText}`) - } - - const data = await response.json() - - if (data.success) { - this.showSuccessMessage(`Research completed for ${data.researched_count} parts.`) - // Save scroll position and reload to show updated results - sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) - window.location.reload() - } else { - this.showErrorMessage(data.error || 'Bulk research failed') - } - } catch (error) { - console.error('Error researching all parts:', error) - - if (error.name === 'AbortError') { - this.showErrorMessage('Bulk research timed out. This may happen with large batches. Please try again or process smaller batches.') - } else if (error.message.includes('Failed to fetch')) { - this.showErrorMessage('Network error. Please check your connection and try again.') - } else { - this.showErrorMessage(error.message || 'Bulk research failed due to an unexpected error') - } - } finally { - // Hide loading state - if (spinner) { - spinner.style.display = 'none' - } - button.disabled = false - } - } - - showSuccessMessage(message) { - this.showToast('success', message) - } - - showErrorMessage(message) { - this.showToast('error', message) - } - - showToast(type, message) { - // Create a simple alert that doesn't disrupt layout - const alertId = 'alert-' + Date.now() - const iconClass = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle' - const alertClass = type === 'success' ? 'alert-success' : 'alert-danger' - - const alertHTML = ` -
- - ${message} - -
- ` - - // Add alert to body - document.body.insertAdjacentHTML('beforeend', alertHTML) - - // Auto-remove after 5 seconds - setTimeout(() => { - const alertElement = document.getElementById(alertId) - if (alertElement) { - alertElement.remove() - } - }, 5000) - } -} \ No newline at end of file diff --git a/assets/controllers/bulk_job_manage_controller.js b/assets/controllers/bulk_job_manage_controller.js deleted file mode 100644 index c26e37c6..00000000 --- a/assets/controllers/bulk_job_manage_controller.js +++ /dev/null @@ -1,92 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import { generateCsrfHeaders } from "./csrf_protection_controller" - -export default class extends Controller { - static values = { - deleteUrl: String, - stopUrl: String, - deleteConfirmMessage: String, - stopConfirmMessage: String - } - - connect() { - // Controller initialized - } - getHeaders() { - const headers = { - 'X-Requested-With': 'XMLHttpRequest' - } - - // Add CSRF headers if available - const form = document.querySelector('form') - if (form) { - const csrfHeaders = generateCsrfHeaders(form) - Object.assign(headers, csrfHeaders) - } - - return headers - } - async deleteJob(event) { - const jobId = event.currentTarget.dataset.jobId - const confirmMessage = this.deleteConfirmMessageValue || 'Are you sure you want to delete this job?' - - if (confirm(confirmMessage)) { - try { - const deleteUrl = this.deleteUrlValue.replace('__JOB_ID__', jobId) - - const response = await fetch(deleteUrl, { - method: 'DELETE', - headers: this.getHeaders() - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`HTTP ${response.status}: ${errorText}`) - } - - const data = await response.json() - - if (data.success) { - location.reload() - } else { - alert('Error deleting job: ' + (data.error || 'Unknown error')) - } - } catch (error) { - console.error('Error deleting job:', error) - alert('Error deleting job: ' + error.message) - } - } - } - - async stopJob(event) { - const jobId = event.currentTarget.dataset.jobId - const confirmMessage = this.stopConfirmMessageValue || 'Are you sure you want to stop this job?' - - if (confirm(confirmMessage)) { - try { - const stopUrl = this.stopUrlValue.replace('__JOB_ID__', jobId) - - const response = await fetch(stopUrl, { - method: 'POST', - headers: this.getHeaders() - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`HTTP ${response.status}: ${errorText}`) - } - - const data = await response.json() - - if (data.success) { - location.reload() - } else { - alert('Error stopping job: ' + (data.error || 'Unknown error')) - } - } catch (error) { - console.error('Error stopping job:', error) - alert('Error stopping job: ' + error.message) - } - } - } -} \ No newline at end of file diff --git a/assets/controllers/field_mapping_controller.js b/assets/controllers/field_mapping_controller.js deleted file mode 100644 index 9c9c8ac6..00000000 --- a/assets/controllers/field_mapping_controller.js +++ /dev/null @@ -1,136 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = ["tbody", "addButton", "submitButton"] - static values = { - mappingIndex: Number, - maxMappings: Number, - prototype: String, - maxMappingsReachedMessage: String - } - - connect() { - this.updateAddButtonState() - this.updateFieldOptions() - this.attachEventListeners() - } - - attachEventListeners() { - // Add event listeners to existing field selects - const fieldSelects = this.tbodyTarget.querySelectorAll('select[name*="[field]"]') - fieldSelects.forEach(select => { - select.addEventListener('change', this.updateFieldOptions.bind(this)) - }) - - // Note: Add button click is handled by Stimulus action in template (data-action="click->field-mapping#addMapping") - // No manual event listener needed - - // Form submit handler - const form = this.element.querySelector('form') - if (form && this.hasSubmitButtonTarget) { - form.addEventListener('submit', this.handleFormSubmit.bind(this)) - } - } - - addMapping() { - const currentMappings = this.tbodyTarget.querySelectorAll('.mapping-row').length - - if (currentMappings >= this.maxMappingsValue) { - alert(this.maxMappingsReachedMessageValue) - return - } - - const newRowHtml = this.prototypeValue.replace(/__name__/g, this.mappingIndexValue) - const tempDiv = document.createElement('div') - tempDiv.innerHTML = newRowHtml - - const fieldWidget = tempDiv.querySelector('select[name*="[field]"]') || tempDiv.children[0] - const providerWidget = tempDiv.querySelector('select[name*="[providers]"]') || tempDiv.children[1] - const priorityWidget = tempDiv.querySelector('input[name*="[priority]"]') || tempDiv.children[2] - - const newRow = document.createElement('tr') - newRow.className = 'mapping-row' - newRow.innerHTML = ` - ${fieldWidget ? fieldWidget.outerHTML : ''} - ${providerWidget ? providerWidget.outerHTML : ''} - ${priorityWidget ? priorityWidget.outerHTML : ''} - - - - ` - - this.tbodyTarget.appendChild(newRow) - this.mappingIndexValue++ - - const newFieldSelect = newRow.querySelector('select[name*="[field]"]') - if (newFieldSelect) { - newFieldSelect.value = '' - newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this)) - } - - this.updateFieldOptions() - this.updateAddButtonState() - } - - removeMapping(event) { - const row = event.target.closest('tr') - row.remove() - this.updateFieldOptions() - this.updateAddButtonState() - } - - updateFieldOptions() { - const fieldSelects = this.tbodyTarget.querySelectorAll('select[name*="[field]"]') - - const selectedFields = Array.from(fieldSelects) - .map(select => select.value) - .filter(value => value && value !== '') - - fieldSelects.forEach(select => { - Array.from(select.options).forEach(option => { - const isCurrentValue = option.value === select.value - const isEmptyOption = !option.value || option.value === '' - const isAlreadySelected = selectedFields.includes(option.value) - - if (!isEmptyOption && isAlreadySelected && !isCurrentValue) { - option.disabled = true - option.style.display = 'none' - } else { - option.disabled = false - option.style.display = '' - } - }) - }) - } - - updateAddButtonState() { - const currentMappings = this.tbodyTarget.querySelectorAll('.mapping-row').length - - if (this.hasAddButtonTarget) { - if (currentMappings >= this.maxMappingsValue) { - this.addButtonTarget.disabled = true - this.addButtonTarget.title = this.maxMappingsReachedMessageValue - } else { - this.addButtonTarget.disabled = false - this.addButtonTarget.title = '' - } - } - } - - handleFormSubmit(event) { - if (this.hasSubmitButtonTarget) { - this.submitButtonTarget.disabled = true - - // Disable the entire form to prevent changes during processing - const form = event.target - const formElements = form.querySelectorAll('input, select, textarea, button') - formElements.forEach(element => { - if (element !== this.submitButtonTarget) { - element.disabled = true - } - }) - } - } -} \ No newline at end of file diff --git a/assets/css/app/tables.css b/assets/css/app/tables.css index b2d8882c..8d4b200c 100644 --- a/assets/css/app/tables.css +++ b/assets/css/app/tables.css @@ -94,11 +94,6 @@ th.select-checkbox { display: inline-flex; } -/** Add spacing between column visibility button and length menu */ -.buttons-colvis { - margin-right: 0.2em !important; -} - /** Fix datatables select-checkbox position */ table.dataTable tr.selected td.select-checkbox:after { diff --git a/composer.json b/composer.json index 9f335f94..80b413f8 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "doctrine/doctrine-migrations-bundle": "^3.0", "doctrine/orm": "^3.2.0", "dompdf/dompdf": "^v3.0.0", + "part-db/swap-bundle": "^6.0.0", "gregwar/captcha-bundle": "^2.1.0", "hshn/base64-encoded-file": "^5.0", "jbtronics/2fa-webauthn": "^3.0.0", @@ -36,7 +37,6 @@ "league/csv": "^9.8.0", "league/html-to-markdown": "^5.0.1", "liip/imagine-bundle": "^2.2", - "maennchen/zipstream-php": "2.1", "nbgrp/onelogin-saml-bundle": "^v2.0.2", "nelexa/zip": "^4.0", "nelmio/cors-bundle": "^2.3", @@ -45,8 +45,6 @@ "omines/datatables-bundle": "^0.10.0", "paragonie/sodium_compat": "^1.21", "part-db/label-fonts": "^1.0", - "part-db/swap-bundle": "^6.0.0", - "phpoffice/phpspreadsheet": "^5.0.0", "rhukster/dom-sanitizer": "^1.0", "runtime/frankenphp-symfony": "^0.2.0", "s9e/text-formatter": "^2.1", @@ -159,7 +157,7 @@ "post-update-cmd": [ "@auto-scripts" ], - "phpstan": "php -d memory_limit=1G vendor/bin/phpstan analyse src --level 5" + "phpstan": "vendor/bin/phpstan analyse src --level 5 --memory-limit 1G" }, "conflict": { "symfony/symfony": "*" diff --git a/composer.lock b/composer.lock index 22f1f60f..1f67b80f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "10fd1b276a868a4f195721ac5fcd82de", + "content-hash": "fe6dfc229f551945cfa6be8ca26a437e", "packages": [ { "name": "amphp/amp", @@ -968,16 +968,16 @@ }, { "name": "api-platform/doctrine-common", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-common.git", - "reference": "8acbed7c2768f7c15a5b030018132e454f895e55" + "reference": "e0ef3f5d1c4a9d023da519ea120a1d7732e0b1a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/8acbed7c2768f7c15a5b030018132e454f895e55", - "reference": "8acbed7c2768f7c15a5b030018132e454f895e55", + "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/e0ef3f5d1c4a9d023da519ea120a1d7732e0b1a7", + "reference": "e0ef3f5d1c4a9d023da519ea120a1d7732e0b1a7", "shasum": "" }, "require": { @@ -995,8 +995,7 @@ "doctrine/mongodb-odm": "^2.10", "doctrine/orm": "^2.17 || ^3.0", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", - "symfony/type-info": "^7.3" + "phpunit/phpunit": "11.5.x-dev" }, "suggest": { "api-platform/graphql": "For GraphQl mercure subscriptions.", @@ -1018,8 +1017,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1052,31 +1050,31 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-common/tree/v4.2.0" + "source": "https://github.com/api-platform/doctrine-common/tree/v4.1.23" }, - "time": "2025-08-27T12:34:14+00:00" + "time": "2025-08-18T13:30:43+00:00" }, { "name": "api-platform/doctrine-orm", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-orm.git", - "reference": "23b0de35bb7d2903854c6ee3ac300b7f5056c12b" + "reference": "61a199da6f6014dba2da43ea1a66b2c9dda27263" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/23b0de35bb7d2903854c6ee3ac300b7f5056c12b", - "reference": "23b0de35bb7d2903854c6ee3ac300b7f5056c12b", + "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/61a199da6f6014dba2da43ea1a66b2c9dda27263", + "reference": "61a199da6f6014dba2da43ea1a66b2c9dda27263", "shasum": "" }, "require": { - "api-platform/doctrine-common": "^4.2.0-alpha.3@alpha", + "api-platform/doctrine-common": "^4.1.11", "api-platform/metadata": "^4.1.11", "api-platform/state": "^4.1.11", "doctrine/orm": "^2.17 || ^3.0", "php": ">=8.2", - "symfony/type-info": "^7.3" + "symfony/property-info": "^6.4 || ^7.1" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11", @@ -1087,7 +1085,6 @@ "symfony/cache": "^6.4 || ^7.0", "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/uid": "^6.4 || ^7.0", "symfony/validator": "^6.4 || ^7.0", @@ -1105,8 +1102,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1139,22 +1135,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-orm/tree/v4.2.0" + "source": "https://github.com/api-platform/doctrine-orm/tree/v4.1.23" }, - "time": "2025-09-16T12:49:22+00:00" + "time": "2025-06-06T14:56:47+00:00" }, { "name": "api-platform/documentation", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/documentation.git", - "reference": "c5a54336d8c51271aa5d54e57147cdee7162ab3a" + "reference": "1a0ac988d659008ef8667d05bc9978863026bab8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/documentation/zipball/c5a54336d8c51271aa5d54e57147cdee7162ab3a", - "reference": "c5a54336d8c51271aa5d54e57147cdee7162ab3a", + "url": "https://api.github.com/repos/api-platform/documentation/zipball/1a0ac988d659008ef8667d05bc9978863026bab8", + "reference": "1a0ac988d659008ef8667d05bc9978863026bab8", "shasum": "" }, "require": { @@ -1176,8 +1172,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1202,22 +1197,22 @@ ], "description": "API Platform documentation controller.", "support": { - "source": "https://github.com/api-platform/documentation/tree/v4.2.0" + "source": "https://github.com/api-platform/documentation/tree/v4.2.0-alpha.1" }, - "time": "2025-08-19T08:04:29+00:00" + "time": "2025-06-06T14:56:47+00:00" }, { "name": "api-platform/http-cache", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/http-cache.git", - "reference": "aef434b026b861ea451d814c86838b5470b8bfb4" + "reference": "f65f092c90311a87ebb6dda87db3ca08b57c10d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/http-cache/zipball/aef434b026b861ea451d814c86838b5470b8bfb4", - "reference": "aef434b026b861ea451d814c86838b5470b8bfb4", + "url": "https://api.github.com/repos/api-platform/http-cache/zipball/f65f092c90311a87ebb6dda87db3ca08b57c10d6", + "reference": "f65f092c90311a87ebb6dda87db3ca08b57c10d6", "shasum": "" }, "require": { @@ -1231,8 +1226,7 @@ "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "11.5.x-dev", "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/http-client": "^6.4 || ^7.0", - "symfony/type-info": "^7.3" + "symfony/http-client": "^6.4 || ^7.0" }, "type": "library", "extra": { @@ -1246,8 +1240,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1282,33 +1275,32 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/http-cache/tree/v4.2.0" + "source": "https://github.com/api-platform/http-cache/tree/v4.1.23" }, - "time": "2025-09-16T12:51:08+00:00" + "time": "2025-06-06T14:56:47+00:00" }, { "name": "api-platform/hydra", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/hydra.git", - "reference": "5061103e7a5f019097993e6370232c46e24b9b42" + "reference": "8c75b814af143c95ffc1857565169ff5b6f1b421" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/hydra/zipball/5061103e7a5f019097993e6370232c46e24b9b42", - "reference": "5061103e7a5f019097993e6370232c46e24b9b42", + "url": "https://api.github.com/repos/api-platform/hydra/zipball/8c75b814af143c95ffc1857565169ff5b6f1b421", + "reference": "8c75b814af143c95ffc1857565169ff5b6f1b421", "shasum": "" }, "require": { - "api-platform/documentation": "^4.1", - "api-platform/json-schema": "^4.2@beta", - "api-platform/jsonld": "^4.1", - "api-platform/metadata": "^4.2@beta", - "api-platform/serializer": "^4.1", - "api-platform/state": "^4.1.8", + "api-platform/documentation": "^4.1.11", + "api-platform/json-schema": "^4.1.11", + "api-platform/jsonld": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/serializer": "^4.1.11", + "api-platform/state": "^4.1.11", "php": ">=8.2", - "symfony/type-info": "^7.3", "symfony/web-link": "^6.4 || ^7.1" }, "require-dev": { @@ -1331,8 +1323,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1369,40 +1360,38 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/hydra/tree/v4.2.0" + "source": "https://github.com/api-platform/hydra/tree/v4.1.23" }, - "time": "2025-09-16T12:49:22+00:00" + "time": "2025-07-15T14:10:59+00:00" }, { "name": "api-platform/json-api", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/json-api.git", - "reference": "e8da698d55fb1702b25c63d7c821d1760159912e" + "reference": "7ea9bbe5f801f58b3f78730f6e6cd4b168b450d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/json-api/zipball/e8da698d55fb1702b25c63d7c821d1760159912e", - "reference": "e8da698d55fb1702b25c63d7c821d1760159912e", + "url": "https://api.github.com/repos/api-platform/json-api/zipball/7ea9bbe5f801f58b3f78730f6e6cd4b168b450d4", + "reference": "7ea9bbe5f801f58b3f78730f6e6cd4b168b450d4", "shasum": "" }, "require": { "api-platform/documentation": "^4.1.11", - "api-platform/json-schema": "^4.2@beta", - "api-platform/metadata": "^4.2@beta", + "api-platform/json-schema": "^4.1.11", + "api-platform/metadata": "^4.1.11", "api-platform/serializer": "^4.1.11", "api-platform/state": "^4.1.11", "php": ">=8.2", "symfony/error-handler": "^6.4 || ^7.0", - "symfony/http-foundation": "^6.4 || ^7.0", - "symfony/type-info": "^7.3" + "symfony/http-foundation": "^6.4 || ^7.0" }, "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", - "symfony/type-info": "^7.3" + "phpunit/phpunit": "11.5.x-dev" }, "type": "library", "extra": { @@ -1416,8 +1405,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1451,31 +1439,30 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/json-api/tree/v4.2.0" + "source": "https://github.com/api-platform/json-api/tree/v4.1.23" }, - "time": "2025-09-16T12:49:22+00:00" + "time": "2025-08-06T07:56:58+00:00" }, { "name": "api-platform/json-schema", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/json-schema.git", - "reference": "e25a8d95b3958abdbe07055833bd69f5f3ed4aeb" + "reference": "1d1c6eaa4841f3989e2bec4cdf8167fb0ca42a8f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/json-schema/zipball/e25a8d95b3958abdbe07055833bd69f5f3ed4aeb", - "reference": "e25a8d95b3958abdbe07055833bd69f5f3ed4aeb", + "url": "https://api.github.com/repos/api-platform/json-schema/zipball/1d1c6eaa4841f3989e2bec4cdf8167fb0ca42a8f", + "reference": "1d1c6eaa4841f3989e2bec4cdf8167fb0ca42a8f", "shasum": "" }, "require": { - "api-platform/metadata": "^4.2@beta", + "api-platform/metadata": "^4.1.11", "php": ">=8.2", "symfony/console": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", - "symfony/type-info": "^7.3", "symfony/uid": "^6.4 || ^7.0" }, "require-dev": { @@ -1494,8 +1481,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1532,22 +1518,22 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/json-schema/tree/v4.2.0" + "source": "https://github.com/api-platform/json-schema/tree/v4.1.23" }, - "time": "2025-09-16T12:49:22+00:00" + "time": "2025-06-29T12:24:14+00:00" }, { "name": "api-platform/jsonld", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/jsonld.git", - "reference": "0f4c79c1f57680cbbcaaf7219e4e0aa2865a2c51" + "reference": "e122bf1f04f895e80e6469e0f09d1f06f7508ca6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/jsonld/zipball/0f4c79c1f57680cbbcaaf7219e4e0aa2865a2c51", - "reference": "0f4c79c1f57680cbbcaaf7219e4e0aa2865a2c51", + "url": "https://api.github.com/repos/api-platform/jsonld/zipball/e122bf1f04f895e80e6469e0f09d1f06f7508ca6", + "reference": "e122bf1f04f895e80e6469e0f09d1f06f7508ca6", "shasum": "" }, "require": { @@ -1557,8 +1543,7 @@ "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "11.5.x-dev", - "symfony/type-info": "^7.3" + "phpunit/phpunit": "11.5.x-dev" }, "type": "library", "extra": { @@ -1572,8 +1557,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1612,22 +1596,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/jsonld/tree/v4.2.0" + "source": "https://github.com/api-platform/jsonld/tree/v4.1.23" }, - "time": "2025-09-09T12:23:22+00:00" + "time": "2025-07-25T10:05:30+00:00" }, { "name": "api-platform/metadata", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/metadata.git", - "reference": "71db1e169d3c0b28d1d3eab5b572720285ad1a6c" + "reference": "58b25f9a82c12727afab09b5a311828aacff8e88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/metadata/zipball/71db1e169d3c0b28d1d3eab5b572720285ad1a6c", - "reference": "71db1e169d3c0b28d1d3eab5b572720285ad1a6c", + "url": "https://api.github.com/repos/api-platform/metadata/zipball/58b25f9a82c12727afab09b5a311828aacff8e88", + "reference": "58b25f9a82c12727afab09b5a311828aacff8e88", "shasum": "" }, "require": { @@ -1669,8 +1653,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1710,42 +1693,40 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/metadata/tree/v4.2.0" + "source": "https://github.com/api-platform/metadata/tree/v4.1.23" }, - "time": "2025-09-15T12:27:38+00:00" + "time": "2025-09-05T09:06:52+00:00" }, { "name": "api-platform/openapi", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/openapi.git", - "reference": "8e400e24ef695f17dbeeaeb60442707ba9c1dbd1" + "reference": "793b53e51a5c24076d4024b6aa77de29e74015cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/openapi/zipball/8e400e24ef695f17dbeeaeb60442707ba9c1dbd1", - "reference": "8e400e24ef695f17dbeeaeb60442707ba9c1dbd1", + "url": "https://api.github.com/repos/api-platform/openapi/zipball/793b53e51a5c24076d4024b6aa77de29e74015cd", + "reference": "793b53e51a5c24076d4024b6aa77de29e74015cd", "shasum": "" }, "require": { - "api-platform/json-schema": "^4.2@beta", - "api-platform/metadata": "^4.2@beta", - "api-platform/state": "^4.2@beta", + "api-platform/json-schema": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", "php": ">=8.2", "symfony/console": "^6.4 || ^7.0", "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", - "symfony/serializer": "^6.4 || ^7.0", - "symfony/type-info": "^7.3" + "symfony/serializer": "^6.4 || ^7.0" }, "require-dev": { "api-platform/doctrine-common": "^4.1", "api-platform/doctrine-odm": "^4.1", "api-platform/doctrine-orm": "^4.1", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", - "symfony/type-info": "^7.3" + "phpunit/phpunit": "11.5.x-dev" }, "type": "library", "extra": { @@ -1759,8 +1740,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1800,26 +1780,26 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/openapi/tree/v4.2.0" + "source": "https://github.com/api-platform/openapi/tree/v4.1.23" }, - "time": "2025-09-16T12:49:22+00:00" + "time": "2025-07-29T08:53:27+00:00" }, { "name": "api-platform/serializer", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/serializer.git", - "reference": "8c94416556df14fd20203975d000c0213a308e2d" + "reference": "70dbdeac9584870be444d78c1a796b6edb9e46a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/serializer/zipball/8c94416556df14fd20203975d000c0213a308e2d", - "reference": "8c94416556df14fd20203975d000c0213a308e2d", + "url": "https://api.github.com/repos/api-platform/serializer/zipball/70dbdeac9584870be444d78c1a796b6edb9e46a5", + "reference": "70dbdeac9584870be444d78c1a796b6edb9e46a5", "shasum": "" }, "require": { - "api-platform/metadata": "^4.1.16", + "api-platform/metadata": "^4.1.11", "api-platform/state": "^4.1.11", "php": ">=8.2", "symfony/property-access": "^6.4 || ^7.0", @@ -1837,7 +1817,6 @@ "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "11.5.x-dev", "symfony/mercure-bundle": "*", - "symfony/type-info": "^7.3", "symfony/var-dumper": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0" }, @@ -1857,8 +1836,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1893,22 +1871,22 @@ "serializer" ], "support": { - "source": "https://github.com/api-platform/serializer/tree/v4.2.0" + "source": "https://github.com/api-platform/serializer/tree/v4.1.23" }, - "time": "2025-09-15T13:20:40+00:00" + "time": "2025-08-29T15:13:26+00:00" }, { "name": "api-platform/state", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/state.git", - "reference": "439b0c542e4c2e921f909f2f2e01d077ed0248f4" + "reference": "056b07285cdc904984fb44c2614f7df8f4620a95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/state/zipball/439b0c542e4c2e921f909f2f2e01d077ed0248f4", - "reference": "439b0c542e4c2e921f909f2f2e01d077ed0248f4", + "url": "https://api.github.com/repos/api-platform/state/zipball/056b07285cdc904984fb44c2614f7df8f4620a95", + "reference": "056b07285cdc904984fb44c2614f7df8f4620a95", "shasum": "" }, "require": { @@ -1920,11 +1898,9 @@ "symfony/translation-contracts": "^3.0" }, "require-dev": { - "api-platform/serializer": "^4.1", "api-platform/validator": "^4.1", "phpunit/phpunit": "11.5.x-dev", "symfony/http-foundation": "^6.4 || ^7.0", - "symfony/type-info": "^7.3", "symfony/web-link": "^6.4 || ^7.1", "willdurand/negotiation": "^3.1" }, @@ -1947,8 +1923,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -1988,22 +1963,22 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/state/tree/v4.2.0" + "source": "https://github.com/api-platform/state/tree/v4.1.23" }, - "time": "2025-09-17T08:54:53+00:00" + "time": "2025-07-16T14:01:52+00:00" }, { "name": "api-platform/symfony", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/symfony.git", - "reference": "c3fa7d2176bd9c36e2961d88c4a69a5a7e948b83" + "reference": "e35839489b4e76ffc5fc2b0cbadbbaece75b9ad1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/symfony/zipball/c3fa7d2176bd9c36e2961d88c4a69a5a7e948b83", - "reference": "c3fa7d2176bd9c36e2961d88c4a69a5a7e948b83", + "url": "https://api.github.com/repos/api-platform/symfony/zipball/e35839489b4e76ffc5fc2b0cbadbbaece75b9ad1", + "reference": "e35839489b4e76ffc5fc2b0cbadbbaece75b9ad1", "shasum": "" }, "require": { @@ -2012,13 +1987,12 @@ "api-platform/hydra": "^4.1.11", "api-platform/json-schema": "^4.1.11", "api-platform/jsonld": "^4.1.11", - "api-platform/metadata": "^4.2@beta", + "api-platform/metadata": "^4.1.11", "api-platform/openapi": "^4.1.11", "api-platform/serializer": "^4.1.11", - "api-platform/state": "^4.2@beta", + "api-platform/state": "^4.1.11", "api-platform/validator": "^4.1.11", "php": ">=8.2", - "symfony/finder": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/security-core": "^6.4 || ^7.0", @@ -2035,11 +2009,8 @@ "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "11.5.x-dev", "symfony/expression-language": "^6.4 || ^7.0", - "symfony/intl": "^6.4 || ^7.0", "symfony/mercure-bundle": "*", - "symfony/object-mapper": "^7.0", "symfony/routing": "^6.4 || ^7.0", - "symfony/type-info": "^7.3", "symfony/validator": "^6.4 || ^7.0", "webonyx/graphql-php": "^15.0" }, @@ -2048,7 +2019,6 @@ "api-platform/doctrine-orm": "To support Doctrine ORM.", "api-platform/elasticsearch": "To support Elasticsearch.", "api-platform/graphql": "To support GraphQL.", - "api-platform/hal": "to support the HAL format", "api-platform/ramsey-uuid": "To support Ramsey's UUID identifiers.", "ocramius/package-versions": "To display the API Platform's version in the debug bar.", "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", @@ -2076,8 +2046,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -2118,22 +2087,22 @@ "symfony" ], "support": { - "source": "https://github.com/api-platform/symfony/tree/v4.2.0" + "source": "https://github.com/api-platform/symfony/tree/v4.1.23" }, - "time": "2025-09-16T12:49:22+00:00" + "time": "2025-09-05T07:30:37+00:00" }, { "name": "api-platform/validator", - "version": "v4.2.0", + "version": "v4.1.23", "source": { "type": "git", "url": "https://github.com/api-platform/validator.git", - "reference": "562f97b0acdacef462ff9ececd62158ae4709530" + "reference": "9f0bde95dccf1d86e6a6165543d601a4a46eaa9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/validator/zipball/562f97b0acdacef462ff9ececd62158ae4709530", - "reference": "562f97b0acdacef462ff9ececd62158ae4709530", + "url": "https://api.github.com/repos/api-platform/validator/zipball/9f0bde95dccf1d86e6a6165543d601a4a46eaa9a", + "reference": "9f0bde95dccf1d86e6a6165543d601a4a46eaa9a", "shasum": "" }, "require": { @@ -2141,7 +2110,7 @@ "php": ">=8.2", "symfony/http-kernel": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1", - "symfony/type-info": "^7.3", + "symfony/type-info": "^7.2", "symfony/validator": "^6.4 || ^7.1", "symfony/web-link": "^6.4 || ^7.1" }, @@ -2161,8 +2130,7 @@ "branch-alias": { "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", - "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.2.x-dev" } }, "autoload": { @@ -2194,9 +2162,9 @@ "validator" ], "support": { - "source": "https://github.com/api-platform/validator/tree/v4.2.0" + "source": "https://github.com/api-platform/validator/tree/v4.1.23" }, - "time": "2025-09-05T08:12:26+00:00" + "time": "2025-07-16T14:01:52+00:00" }, { "name": "beberlei/assert", @@ -2532,85 +2500,6 @@ ], "time": "2022-01-17T14:14:24+00:00" }, - { - "name": "composer/pcre", - "version": "3.3.2", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" - }, - "type": "library", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-11-12T16:29:46+00:00" - }, { "name": "daverandom/libdns", "version": "v2.1.0", @@ -3146,16 +3035,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.16.2", + "version": "2.16.1", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "1c10de0fe995f01eca6b073d1c2549ef0b603a7f" + "reference": "152d5083f0cd205a278131dc4351a8c94d007fe1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/1c10de0fe995f01eca6b073d1c2549ef0b603a7f", - "reference": "1c10de0fe995f01eca6b073d1c2549ef0b603a7f", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/152d5083f0cd205a278131dc4351a8c94d007fe1", + "reference": "152d5083f0cd205a278131dc4351a8c94d007fe1", "shasum": "" }, "require": { @@ -3189,11 +3078,12 @@ "phpstan/phpstan": "2.1.1", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^10.5.53", + "phpunit/phpunit": "^9.6.22", "psr/log": "^1.1.4 || ^2.0 || ^3.0", "symfony/doctrine-messenger": "^6.4 || ^7.0", "symfony/expression-language": "^6.4 || ^7.0", "symfony/messenger": "^6.4 || ^7.0", + "symfony/phpunit-bridge": "^7.2", "symfony/property-info": "^6.4 || ^7.0", "symfony/security-bundle": "^6.4 || ^7.0", "symfony/stopwatch": "^6.4 || ^7.0", @@ -3248,7 +3138,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.16.2" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.16.1" }, "funding": [ { @@ -3264,7 +3154,7 @@ "type": "tidelift" } ], - "time": "2025-09-10T19:14:48+00:00" + "time": "2025-09-05T15:24:53+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", @@ -4022,16 +3912,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.1.1", + "version": "v3.1.0", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "794ec856134a73d2a69a474c5d4faa47e1e645b1" + "reference": "a51bd7a063a65499446919286fb18b518177155a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/794ec856134a73d2a69a474c5d4faa47e1e645b1", - "reference": "794ec856134a73d2a69a474c5d4faa47e1e645b1", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/a51bd7a063a65499446919286fb18b518177155a", + "reference": "a51bd7a063a65499446919286fb18b518177155a", "shasum": "" }, "require": { @@ -4080,9 +3970,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.1" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.0" }, - "time": "2025-09-20T17:30:31+00:00" + "time": "2025-01-15T14:09:04+00:00" }, { "name": "dompdf/php-font-lib", @@ -5381,16 +5271,16 @@ }, { "name": "knpuniversity/oauth2-client-bundle", - "version": "v2.19.0", + "version": "v2.18.4", "source": { "type": "git", "url": "https://github.com/knpuniversity/oauth2-client-bundle.git", - "reference": "cd1cb6945a46df81be6e94944872546ca4bf335c" + "reference": "2f48e1ff7969ef0252482d0f6af874eca639ea2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/cd1cb6945a46df81be6e94944872546ca4bf335c", - "reference": "cd1cb6945a46df81be6e94944872546ca4bf335c", + "url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/2f48e1ff7969ef0252482d0f6af874eca639ea2d", + "reference": "2f48e1ff7969ef0252482d0f6af874eca639ea2d", "shasum": "" }, "require": { @@ -5434,9 +5324,9 @@ ], "support": { "issues": "https://github.com/knpuniversity/oauth2-client-bundle/issues", - "source": "https://github.com/knpuniversity/oauth2-client-bundle/tree/v2.19.0" + "source": "https://github.com/knpuniversity/oauth2-client-bundle/tree/v2.18.4" }, - "time": "2025-09-17T15:00:36+00:00" + "time": "2025-08-18T15:33:00+00:00" }, { "name": "lcobucci/clock", @@ -5766,16 +5656,16 @@ }, { "name": "league/csv", - "version": "9.25.0", + "version": "9.24.1", "source": { "type": "git", "url": "https://github.com/thephpleague/csv.git", - "reference": "f856f532866369fb1debe4e7c5a1db185f40ef86" + "reference": "e0221a3f16aa2a823047d59fab5809d552e29bc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/csv/zipball/f856f532866369fb1debe4e7c5a1db185f40ef86", - "reference": "f856f532866369fb1debe4e7c5a1db185f40ef86", + "url": "https://api.github.com/repos/thephpleague/csv/zipball/e0221a3f16aa2a823047d59fab5809d552e29bc8", + "reference": "e0221a3f16aa2a823047d59fab5809d552e29bc8", "shasum": "" }, "require": { @@ -5791,7 +5681,7 @@ "phpstan/phpstan-deprecation-rules": "^1.2.1", "phpstan/phpstan-phpunit": "^1.4.2", "phpstan/phpstan-strict-rules": "^1.6.2", - "phpunit/phpunit": "^10.5.16 || ^11.5.22 || ^12.3.6", + "phpunit/phpunit": "^10.5.16 || ^11.5.22", "symfony/var-dumper": "^6.4.8 || ^7.3.0" }, "suggest": { @@ -5853,7 +5743,7 @@ "type": "github" } ], - "time": "2025-09-11T08:29:08+00:00" + "time": "2025-06-25T14:53:51+00:00" }, { "name": "league/html-to-markdown", @@ -6425,188 +6315,6 @@ }, "time": "2023-07-31T13:36:50+00:00" }, - { - "name": "maennchen/zipstream-php", - "version": "2.1.0", - "source": { - "type": "git", - "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "c4c5803cc1f93df3d2448478ef79394a5981cc58" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/c4c5803cc1f93df3d2448478ef79394a5981cc58", - "reference": "c4c5803cc1f93df3d2448478ef79394a5981cc58", - "shasum": "" - }, - "require": { - "myclabs/php-enum": "^1.5", - "php": ">= 7.1", - "psr/http-message": "^1.0", - "symfony/polyfill-mbstring": "^1.0" - }, - "require-dev": { - "ext-zip": "*", - "guzzlehttp/guzzle": ">= 6.3", - "mikey179/vfsstream": "^1.6", - "phpunit/phpunit": ">= 7.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "ZipStream\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paul Duncan", - "email": "pabs@pablotron.org" - }, - { - "name": "Jonatan MΓ€nnchen", - "email": "jonatan@maennchen.ch" - }, - { - "name": "Jesse Donat", - "email": "donatj@gmail.com" - }, - { - "name": "AndrΓ‘s KolesΓ‘r", - "email": "kolesar@kolesar.hu" - } - ], - "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", - "keywords": [ - "stream", - "zip" - ], - "support": { - "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/2.1.0" - }, - "funding": [ - { - "url": "https://github.com/maennchen", - "type": "github" - }, - { - "url": "https://opencollective.com/zipstream", - "type": "open_collective" - } - ], - "time": "2020-05-30T13:11:16+00:00" - }, - { - "name": "markbaker/complex", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/MarkBaker/PHPComplex.git", - "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", - "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "dev-master", - "phpcompatibility/php-compatibility": "^9.3", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", - "squizlabs/php_codesniffer": "^3.7" - }, - "type": "library", - "autoload": { - "psr-4": { - "Complex\\": "classes/src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mark Baker", - "email": "mark@lange.demon.co.uk" - } - ], - "description": "PHP Class for working with complex numbers", - "homepage": "https://github.com/MarkBaker/PHPComplex", - "keywords": [ - "complex", - "mathematics" - ], - "support": { - "issues": "https://github.com/MarkBaker/PHPComplex/issues", - "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" - }, - "time": "2022-12-06T16:21:08+00:00" - }, - { - "name": "markbaker/matrix", - "version": "3.0.1", - "source": { - "type": "git", - "url": "https://github.com/MarkBaker/PHPMatrix.git", - "reference": "728434227fe21be27ff6d86621a1b13107a2562c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", - "reference": "728434227fe21be27ff6d86621a1b13107a2562c", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "dev-master", - "phpcompatibility/php-compatibility": "^9.3", - "phpdocumentor/phpdocumentor": "2.*", - "phploc/phploc": "^4.0", - "phpmd/phpmd": "2.*", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", - "sebastian/phpcpd": "^4.0", - "squizlabs/php_codesniffer": "^3.7" - }, - "type": "library", - "autoload": { - "psr-4": { - "Matrix\\": "classes/src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mark Baker", - "email": "mark@demon-angel.eu" - } - ], - "description": "PHP Class for working with matrices", - "homepage": "https://github.com/MarkBaker/PHPMatrix", - "keywords": [ - "mathematics", - "matrix", - "vector" - ], - "support": { - "issues": "https://github.com/MarkBaker/PHPMatrix/issues", - "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" - }, - "time": "2022-12-02T22:17:43+00:00" - }, { "name": "masterminds/html5", "version": "2.10.0", @@ -6777,81 +6485,18 @@ ], "time": "2025-03-24T10:02:05+00:00" }, - { - "name": "myclabs/php-enum", - "version": "1.8.5", - "source": { - "type": "git", - "url": "https://github.com/myclabs/php-enum.git", - "reference": "e7be26966b7398204a234f8673fdad5ac6277802" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802", - "reference": "e7be26966b7398204a234f8673fdad5ac6277802", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": "^7.3 || ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5", - "squizlabs/php_codesniffer": "1.*", - "vimeo/psalm": "^4.6.2 || ^5.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "MyCLabs\\Enum\\": "src/" - }, - "classmap": [ - "stubs/Stringable.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP Enum contributors", - "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" - } - ], - "description": "PHP Enum implementation", - "homepage": "https://github.com/myclabs/php-enum", - "keywords": [ - "enum" - ], - "support": { - "issues": "https://github.com/myclabs/php-enum/issues", - "source": "https://github.com/myclabs/php-enum/tree/1.8.5" - }, - "funding": [ - { - "url": "https://github.com/mnapoli", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", - "type": "tidelift" - } - ], - "time": "2025-01-14T11:49:03+00:00" - }, { "name": "nbgrp/onelogin-saml-bundle", - "version": "v2.0.3", + "version": "v2.0.2", "source": { "type": "git", "url": "https://github.com/nbgrp/onelogin-saml-bundle.git", - "reference": "cbf58a8742ee8179dce0547e6f2f826cd19b525f" + "reference": "d2feeb7de6ab5b98e69deeea31ad0ceb20a1c4dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nbgrp/onelogin-saml-bundle/zipball/cbf58a8742ee8179dce0547e6f2f826cd19b525f", - "reference": "cbf58a8742ee8179dce0547e6f2f826cd19b525f", + "url": "https://api.github.com/repos/nbgrp/onelogin-saml-bundle/zipball/d2feeb7de6ab5b98e69deeea31ad0ceb20a1c4dc", + "reference": "d2feeb7de6ab5b98e69deeea31ad0ceb20a1c4dc", "shasum": "" }, "require": { @@ -6899,9 +6544,9 @@ ], "support": { "issues": "https://github.com/nbgrp/onelogin-saml-bundle/issues", - "source": "https://github.com/nbgrp/onelogin-saml-bundle/tree/v2.0.3" + "source": "https://github.com/nbgrp/onelogin-saml-bundle/tree/v2.0.2" }, - "time": "2025-09-19T14:08:21+00:00" + "time": "2024-09-01T22:16:27+00:00" }, { "name": "nelexa/zip", @@ -7040,16 +6685,16 @@ }, { "name": "nelmio/security-bundle", - "version": "v3.6.0", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/nelmio/NelmioSecurityBundle.git", - "reference": "f3a7bf628a0873788172a0d05d20c0224080f5eb" + "reference": "b1c5e323d71152bc1a61a4f8fbf7d88c6fa3e2e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/NelmioSecurityBundle/zipball/f3a7bf628a0873788172a0d05d20c0224080f5eb", - "reference": "f3a7bf628a0873788172a0d05d20c0224080f5eb", + "url": "https://api.github.com/repos/nelmio/NelmioSecurityBundle/zipball/b1c5e323d71152bc1a61a4f8fbf7d88c6fa3e2e7", + "reference": "b1c5e323d71152bc1a61a4f8fbf7d88c6fa3e2e7", "shasum": "" }, "require": { @@ -7108,9 +6753,9 @@ ], "support": { "issues": "https://github.com/nelmio/NelmioSecurityBundle/issues", - "source": "https://github.com/nelmio/NelmioSecurityBundle/tree/v3.6.0" + "source": "https://github.com/nelmio/NelmioSecurityBundle/tree/v3.5.1" }, - "time": "2025-09-19T08:24:46+00:00" + "time": "2025-03-13T09:17:16+00:00" }, { "name": "nette/schema", @@ -7705,16 +7350,16 @@ }, { "name": "paragonie/sodium_compat", - "version": "v1.21.2", + "version": "v1.21.1", "source": { "type": "git", "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "d3043fd10faacb72e9eeb2df4c21a13214b45c33" + "reference": "bb312875dcdd20680419564fe42ba1d9564b9e37" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/d3043fd10faacb72e9eeb2df4c21a13214b45c33", - "reference": "d3043fd10faacb72e9eeb2df4c21a13214b45c33", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/bb312875dcdd20680419564fe42ba1d9564b9e37", + "reference": "bb312875dcdd20680419564fe42ba1d9564b9e37", "shasum": "" }, "require": { @@ -7785,9 +7430,9 @@ ], "support": { "issues": "https://github.com/paragonie/sodium_compat/issues", - "source": "https://github.com/paragonie/sodium_compat/tree/v1.21.2" + "source": "https://github.com/paragonie/sodium_compat/tree/v1.21.1" }, - "time": "2025-09-19T16:14:19+00:00" + "time": "2024-04-22T22:05:04+00:00" }, { "name": "part-db/exchanger", @@ -8465,112 +8110,6 @@ }, "time": "2024-11-09T15:12:26+00:00" }, - { - "name": "phpoffice/phpspreadsheet", - "version": "5.1.0", - "source": { - "type": "git", - "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "fd26e45a814e94ae2aad0df757d9d1739c4bf2e0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/fd26e45a814e94ae2aad0df757d9d1739c4bf2e0", - "reference": "fd26e45a814e94ae2aad0df757d9d1739c4bf2e0", - "shasum": "" - }, - "require": { - "composer/pcre": "^1||^2||^3", - "ext-ctype": "*", - "ext-dom": "*", - "ext-fileinfo": "*", - "ext-gd": "*", - "ext-iconv": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-simplexml": "*", - "ext-xml": "*", - "ext-xmlreader": "*", - "ext-xmlwriter": "*", - "ext-zip": "*", - "ext-zlib": "*", - "maennchen/zipstream-php": "^2.1 || ^3.0", - "markbaker/complex": "^3.0", - "markbaker/matrix": "^3.0", - "php": "^8.1", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.0", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" - }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "dev-main", - "dompdf/dompdf": "^2.0 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.2", - "mitoteam/jpgraph": "^10.3", - "mpdf/mpdf": "^8.1.1", - "phpcompatibility/php-compatibility": "^9.3", - "phpstan/phpstan": "^1.1 || ^2.0", - "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", - "phpstan/phpstan-phpunit": "^1.0 || ^2.0", - "phpunit/phpunit": "^10.5", - "squizlabs/php_codesniffer": "^3.7", - "tecnickcom/tcpdf": "^6.5" - }, - "suggest": { - "dompdf/dompdf": "Option for rendering PDF with PDF Writer", - "ext-intl": "PHP Internationalization Functions", - "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", - "mpdf/mpdf": "Option for rendering PDF with PDF Writer", - "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" - }, - "type": "library", - "autoload": { - "psr-4": { - "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Maarten Balliauw", - "homepage": "https://blog.maartenballiauw.be" - }, - { - "name": "Mark Baker", - "homepage": "https://markbakeruk.net" - }, - { - "name": "Franck Lefevre", - "homepage": "https://rootslabs.net" - }, - { - "name": "Erik Tilt" - }, - { - "name": "Adrien Crivelli" - } - ], - "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", - "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", - "keywords": [ - "OpenXML", - "excel", - "gnumeric", - "ods", - "php", - "spreadsheet", - "xls", - "xlsx" - ], - "support": { - "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.1.0" - }, - "time": "2025-09-04T05:34:49+00:00" - }, { "name": "phpstan/phpdoc-parser", "version": "2.3.0", @@ -8927,16 +8466,16 @@ }, { "name": "psr/http-message", - "version": "1.1", + "version": "2.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", - "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { @@ -8945,7 +8484,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -8960,7 +8499,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], "description": "Common interface for HTTP messages", @@ -8974,9 +8513,9 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/1.1" + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2023-04-04T09:50:52+00:00" + "time": "2023-04-04T09:54:51+00:00" }, { "name": "psr/link", @@ -16434,16 +15973,16 @@ }, { "name": "symplify/easy-coding-standard", - "version": "12.6.0", + "version": "12.5.24", "source": { "type": "git", "url": "https://github.com/easy-coding-standard/easy-coding-standard.git", - "reference": "781e6124dc7e14768ae999a8f5309566bbe62004" + "reference": "4b90f2b6efed9508000968eac2397ac7aff34354" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/781e6124dc7e14768ae999a8f5309566bbe62004", - "reference": "781e6124dc7e14768ae999a8f5309566bbe62004", + "url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/4b90f2b6efed9508000968eac2397ac7aff34354", + "reference": "4b90f2b6efed9508000968eac2397ac7aff34354", "shasum": "" }, "require": { @@ -16479,7 +16018,7 @@ ], "support": { "issues": "https://github.com/easy-coding-standard/easy-coding-standard/issues", - "source": "https://github.com/easy-coding-standard/easy-coding-standard/tree/12.6.0" + "source": "https://github.com/easy-coding-standard/easy-coding-standard/tree/12.5.24" }, "funding": [ { @@ -16491,7 +16030,7 @@ "type": "github" } ], - "time": "2025-09-10T14:21:58+00:00" + "time": "2025-08-21T06:57:14+00:00" }, { "name": "tecnickcom/tc-lib-barcode", @@ -18286,16 +17825,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.28", + "version": "2.1.22", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "578fa296a166605d97b94091f724f1257185d278" + "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/578fa296a166605d97b94091f724f1257185d278", - "reference": "578fa296a166605d97b94091f724f1257185d278", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", + "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", "shasum": "" }, "require": { @@ -18340,20 +17879,20 @@ "type": "github" } ], - "time": "2025-09-19T08:58:49+00:00" + "time": "2025-08-04T19:17:37+00:00" }, { "name": "phpstan/phpstan-doctrine", - "version": "2.0.6", + "version": "2.0.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "934f5734812341358fc41c44006b30fa00c785f0" + "reference": "eeff19808f8ae3a6f7c4e43e388a2848eb2b0865" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/934f5734812341358fc41c44006b30fa00c785f0", - "reference": "934f5734812341358fc41c44006b30fa00c785f0", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/eeff19808f8ae3a6f7c4e43e388a2848eb2b0865", + "reference": "eeff19808f8ae3a6f7c4e43e388a2848eb2b0865", "shasum": "" }, "require": { @@ -18410,9 +17949,9 @@ "description": "Doctrine extensions for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.6" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.5" }, - "time": "2025-09-10T07:06:30+00:00" + "time": "2025-09-07T11:52:30+00:00" }, { "name": "phpstan/phpstan-strict-rules", @@ -18870,16 +18409,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.39", + "version": "11.5.36", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ad5597f79d8489d2870073ac0bc0dd0ad1fa9931" + "reference": "264a87c7ef68b1ab9af7172357740dc266df5957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ad5597f79d8489d2870073ac0bc0dd0ad1fa9931", - "reference": "ad5597f79d8489d2870073ac0bc0dd0ad1fa9931", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/264a87c7ef68b1ab9af7172357740dc266df5957", + "reference": "264a87c7ef68b1ab9af7172357740dc266df5957", "shasum": "" }, "require": { @@ -18951,7 +18490,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.39" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.36" }, "funding": [ { @@ -18975,20 +18514,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T06:20:41+00:00" + "time": "2025-09-03T06:24:17+00:00" }, { "name": "rector/rector", - "version": "2.1.7", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "c34cc07c4698f007a20dc5c99ff820089ae413ce" + "reference": "729aabc0ec66e700ef164e26454a1357f222a2f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/c34cc07c4698f007a20dc5c99ff820089ae413ce", - "reference": "c34cc07c4698f007a20dc5c99ff820089ae413ce", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/729aabc0ec66e700ef164e26454a1357f222a2f3", + "reference": "729aabc0ec66e700ef164e26454a1357f222a2f3", "shasum": "" }, "require": { @@ -19027,7 +18566,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.1.7" + "source": "https://github.com/rectorphp/rector/tree/2.1.6" }, "funding": [ { @@ -19035,7 +18574,7 @@ "type": "github" } ], - "time": "2025-09-10T11:13:58+00:00" + "time": "2025-09-05T15:43:08+00:00" }, { "name": "roave/security-advisories", @@ -19043,12 +18582,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "f48b3e601515b060334744b4b495f0d6b3cc2e6b" + "reference": "dc5c4ede5c331ae21fb68947ff89672df9b7cc7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/f48b3e601515b060334744b4b495f0d6b3cc2e6b", - "reference": "f48b3e601515b060334744b4b495f0d6b3cc2e6b", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/dc5c4ede5c331ae21fb68947ff89672df9b7cc7d", + "reference": "dc5c4ede5c331ae21fb68947ff89672df9b7cc7d", "shasum": "" }, "conflict": { @@ -19186,7 +18725,6 @@ "dapphp/securimage": "<3.6.6", "darylldoyle/safe-svg": "<1.9.10", "datadog/dd-trace": ">=0.30,<0.30.2", - "datahihi1/tiny-env": "<1.0.3|>=1.0.9,<1.0.11", "datatables/datatables": "<1.10.10", "david-garcia/phpwhois": "<=4.3.1", "dbrisinajumi/d2files": "<1", @@ -19437,7 +18975,6 @@ "laravel/socialite": ">=1,<2.0.10", "latte/latte": "<2.10.8", "lavalite/cms": "<=9|==10.1", - "lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2", "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", "league/commonmark": "<2.7", "league/flysystem": "<1.1.4|>=2,<2.1.1", @@ -19459,14 +18996,13 @@ "luyadev/yii-helpers": "<1.2.1", "macropay-solutions/laravel-crud-wizard-free": "<3.4.17", "maestroerror/php-heic-to-jpg": "<1.0.5", - "magento/community-edition": "<=2.4.5.0-patch14|==2.4.6|>=2.4.6.0-patch1,<=2.4.6.0-patch12|>=2.4.7.0-beta1,<=2.4.7.0-patch7|>=2.4.8.0-beta1,<=2.4.8.0-patch2|>=2.4.9.0-alpha1,<=2.4.9.0-alpha2|==2.4.9", + "magento/community-edition": "<2.4.5.0-patch14|==2.4.6|>=2.4.6.0-patch1,<2.4.6.0-patch12|>=2.4.7.0-beta1,<2.4.7.0-patch7|>=2.4.8.0-beta1,<2.4.8.0-patch1", "magento/core": "<=1.9.4.5", "magento/magento1ce": "<1.9.4.3-dev", "magento/magento1ee": ">=1,<1.14.4.3-dev", "magento/product-community-edition": "<2.4.4.0-patch9|>=2.4.5,<2.4.5.0-patch8|>=2.4.6,<2.4.6.0-patch6|>=2.4.7,<2.4.7.0-patch1", "magento/project-community-edition": "<=2.0.2", "magneto/core": "<1.9.4.4-dev", - "mahocommerce/maho": "<25.9", "maikuolan/phpmussel": ">=1,<1.6", "mainwp/mainwp": "<=4.4.3.3", "manogi/nova-tiptap": "<=3.2.6", @@ -19679,10 +19215,10 @@ "setasign/fpdi": "<2.6.4", "sfroemken/url_redirect": "<=1.2.1", "sheng/yiicms": "<1.2.1", - "shopware/core": "<6.5.8.18-dev|>=6.6,<6.6.10.3-dev|>=6.7,<6.7.2.1-dev", + "shopware/core": "<6.5.8.18-dev|>=6.6,<6.6.10.3-dev|>=6.7.0.0-RC1-dev,<6.7.0.0-RC2-dev", "shopware/platform": "<=6.6.10.4|>=6.7.0.0-RC1-dev,<6.7.0.0-RC2-dev", "shopware/production": "<=6.3.5.2", - "shopware/shopware": "<=5.7.17|>=6.7,<6.7.2.1-dev", + "shopware/shopware": "<=5.7.17", "shopware/storefront": "<=6.4.8.1|>=6.5.8,<6.5.8.7-dev", "shopxo/shopxo": "<=6.4", "showdoc/showdoc": "<2.10.4", @@ -19724,7 +19260,7 @@ "slim/slim": "<2.6", "slub/slub-events": "<3.0.3", "smarty/smarty": "<4.5.3|>=5,<5.1.1", - "snipe/snipe-it": "<8.1.18", + "snipe/snipe-it": "<8.1", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", "solspace/craft-freeform": ">=5,<5.10.16", @@ -19830,14 +19366,14 @@ "tribalsystems/zenario": "<=9.7.61188", "truckersmp/phpwhois": "<=4.3.1", "ttskch/pagination-service-provider": "<1", - "twbs/bootstrap": "<3.4.1|>=4,<=4.6.2", + "twbs/bootstrap": "<=3.4.1|>=4,<=4.6.2", "twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19", "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", - "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", + "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<=9.5.24|>=10,<10.4.46|>=11,<11.5.40|>=12,<=12.4.30|>=13,<=13.4.11", "typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", - "typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", - "typo3/cms-core": "<=8.7.56|>=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", - "typo3/cms-dashboard": ">=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", + "typo3/cms-beuser": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", + "typo3/cms-core": "<=8.7.56|>=9,<=9.5.50|>=10,<=10.4.49|>=11,<=11.5.43|>=12,<=12.4.30|>=13,<=13.4.11", + "typo3/cms-dashboard": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1", "typo3/cms-extensionmanager": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-felogin": ">=4.2,<4.2.3", @@ -19847,13 +19383,10 @@ "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8|==13.4.2", "typo3/cms-lowlevel": ">=11,<=11.5.41", - "typo3/cms-recordlist": ">=11,<11.5.48", - "typo3/cms-recycler": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/cms-rte-ckeditor": ">=9.5,<9.5.42|>=10,<10.4.39|>=11,<11.5.30", "typo3/cms-scheduler": ">=11,<=11.5.41", "typo3/cms-setup": ">=9,<=9.5.50|>=10,<=10.4.49|>=11,<=11.5.43|>=12,<=12.4.30|>=13,<=13.4.11", "typo3/cms-webhooks": ">=12,<=12.4.30|>=13,<=13.4.11", - "typo3/cms-workspaces": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", "typo3/html-sanitizer": ">=1,<=1.5.2|>=2,<=2.1.3", "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.3.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3", @@ -19914,7 +19447,7 @@ "xataface/xataface": "<3", "xpressengine/xpressengine": "<3.0.15", "yab/quarx": "<2.4.5", - "yeswiki/yeswiki": "<=4.5.4", + "yeswiki/yeswiki": "<4.5.4", "yetiforce/yetiforce-crm": "<6.5", "yidashi/yii2cmf": "<=2", "yii2mod/yii2-cms": "<1.9.2", @@ -20006,7 +19539,7 @@ "type": "tidelift" } ], - "time": "2025-09-19T18:07:33+00:00" + "time": "2025-09-04T20:05:35+00:00" }, { "name": "sebastian/cli-parser", @@ -21516,9 +21049,9 @@ "ext-json": "*", "ext-mbstring": "*" }, - "platform-dev": {}, + "platform-dev": [], "platform-overrides": { "php": "8.2.0" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index 6b2b7337..c283cd8e 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -20,6 +20,12 @@ nelmio_security: - 'digikey.com' - 'nexar.com' + # forces Microsoft's XSS-Protection with + # its block mode + xss_protection: + enabled: true + mode_block: true + # Send a full URL in the `Referer` header when performing a same-origin request, # only send the origin of the document to secure destination (HTTPS->HTTPS), # and send no header to a less secure destination (HTTPS->HTTP). diff --git a/config/parameters.yaml b/config/parameters.yaml index 5b40899d..154fbd8a 100644 --- a/config/parameters.yaml +++ b/config/parameters.yaml @@ -104,9 +104,3 @@ parameters: env(SAML_ROLE_MAPPING): '{}' env(DATABASE_EMULATE_NATURAL_SORT): 0 - - ###################################################################################################################### - # Bulk Info Provider Import Configuration - ###################################################################################################################### - partdb.bulk_import.batch_size: 20 # Number of parts to process in each batch during bulk operations - partdb.bulk_import.max_parts_per_operation: 1000 # Maximum number of parts allowed per bulk import operation diff --git a/docs/assets/usage/import_export/part_import_example.csv b/docs/assets/usage/import_export/part_import_example.csv index 14d4500f..08701426 100644 --- a/docs/assets/usage/import_export/part_import_example.csv +++ b/docs/assets/usage/import_export/part_import_example.csv @@ -1,7 +1,4 @@ -name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;eda_info.reference_prefix;eda_info.value;eda_info.visibility;eda_info.exclude_from_bom;eda_info.exclude_from_board;eda_info.exclude_from_sim;eda_info.kicad_symbol;eda_info.kicad_footprint -"MLCC; 0603; 0.22uF";Multilayer ceramic capacitor;Electrical Components->Passive Components->Capacitors_SMD;High quality MLCC;0603;Capacitor,SMD,MLCC,0603;500;Room 1->Shelf 1->Box 2;0.1;CL10B224KO8NNNC;CL10B224KO8NNNC;active;Samsung;LCSC;C160828;0.0023;0;0;1;pcs;C;0.22uF;1;0;0;0;Device:C;Capacitor_SMD:C_0603_1608Metric -"MLCC; 0402; 10pF";Small MLCC for high frequency;Electrical Components->Passive Components->Capacitors_SMD;;0402;Capacitor,SMD,MLCC,0402;500;Room 1->Shelf 1->Box 3;0.05;FCC0402N100J500AT;FCC0402N100J500AT;active;Fenghua;LCSC;C5137557;0.0015;0;0;1;pcs;C;10pF;1;0;0;0;Device:C;Capacitor_SMD:C_0402_1005Metric -"Diode; 1N4148W";Fast switching diode;Electrical Components->Semiconductors->Diodes;Fast recovery time;Diode_SMD:D_SOD-123;Diode,SMD,Schottky;100;Room 2->Box 1;0.2;1N4148W;1N4148W;active;Vishay;LCSC;C917030;0.008;0;0;1;pcs;D;1N4148W;1;0;0;0;Device:D;Diode_SMD:D_SOD-123 -BC547;NPN transistor;Transistors->NPN;very important notes;TO->TO-92;NPN,Transistor;5;Room 1->Shelf 1->Box 2;10;BC547;BC547;active;Generic;LCSC;BC547C;2.3;0;0;1;pcs;Q;BC547;1;0;0;0;Device:Q_NPN_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder -BC557;PNP transistor;Transistors->PNP;PNP complement to BC547;TO->TO-92;PNP,Transistor;10;Room 2->Box 3;10;BC557;BC557;active;Generic;LCSC;BC557C;2.1;0;0;1;pcs;Q;BC557;1;0;0;0;Device:Q_PNP_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder -Copper Wire;Bare copper wire;Wire->Copper;For prototyping;Wire;Wire,Copper;50;Room 3->Spool Rack;0.5;CW-22AWG;CW-22AWG;active;Generic;Local Supplier;LS-CW-22;0.15;0;0;1;Meter;W;22AWG;1;0;0;0;Device:Wire;Connector_PinHeader_2.54mm:PinHeader_1x01_P2.54mm_Vertical +name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;manufacturing_status +BC547;NPN transistor;Transistors -> NPN;very important notes;TO -> TO-92;NPN,Transistor;5;Room 1 -> Shelf 1 -> Box 2;10;;;Manufacturer;;You need to fill this line, to use spn and price;BC547C;2,3;0;;;; +BC557;PNP transistor;HTML;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active +Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter; \ No newline at end of file diff --git a/docs/usage/import_export.md b/docs/usage/import_export.md index 136624e2..0534221f 100644 --- a/docs/usage/import_export.md +++ b/docs/usage/import_export.md @@ -142,9 +142,6 @@ You can select between the following export formats: efficiently. * **YAML** (Yet Another Markup Language): Very similar to JSON * **XML** (Extensible Markup Language): Good support with nested data structures. Similar use cases as JSON and YAML. -* **Excel**: Similar to CSV, but in a native Excel format. Can be opened in Excel and LibreOffice Calc. Does not support nested - data structures or sub-data (like parameters, attachments, etc.), very well (many columns are generated, as every - possible sub-data is exported as a separate column). Also, you can select between the following export levels: diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md index bc6fe76e..953db409 100644 --- a/docs/usage/information_provider_system.md +++ b/docs/usage/information_provider_system.md @@ -68,13 +68,6 @@ If you already have attachment types for images and datasheets and want the info can add the alternative names "Datasheet" and "Image" to the alternative names field of the attachment types. -## Bulk import - -If you want to update the information of multiple parts, you can use the bulk import system: Go to a part table and select -the parts you want to update. In the bulk actions dropdown select "Bulk info provider import" and click "Apply". -You will be redirected to a page, where you can select how part fields should be mapped to info provider fields, and the -results will be shown. - ## Data providers The system tries to be as flexible as possible, so many different information sources can be used. diff --git a/makefile b/makefile index bc4d0bf3..9041ba0f 100644 --- a/makefile +++ b/makefile @@ -1,91 +1,112 @@ # PartDB Makefile for Test Environment Management -.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \ -test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \ -section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset +.PHONY: help test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset deps-install # Default target -help: ## Show this help - @awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) +help: + @echo "PartDB Test Environment Management" + @echo "==================================" + @echo "" + @echo "Available targets:" + @echo " deps-install - Install PHP dependencies with unlimited memory" + @echo "" + @echo "Development Environment:" + @echo " dev-setup - Complete development environment setup (clean, create DB, migrate, warmup)" + @echo " dev-clean - Clean development cache and database files" + @echo " dev-db-create - Create development database (if not exists)" + @echo " dev-db-migrate - Run database migrations for development environment" + @echo " dev-cache-clear - Clear development cache" + @echo " dev-warmup - Warm up development cache" + @echo " dev-reset - Quick development reset (clean + migrate)" + @echo "" + @echo "Test Environment:" + @echo " test-setup - Complete test environment setup (clean, create DB, migrate, load fixtures)" + @echo " test-clean - Clean test cache and database files" + @echo " test-db-create - Create test database (if not exists)" + @echo " test-db-migrate - Run database migrations for test environment" + @echo " test-cache-clear- Clear test cache" + @echo " test-fixtures - Load test fixtures" + @echo " test-run - Run PHPUnit tests" + @echo "" + @echo " help - Show this help message" -# Dependencies -deps-install: ## Install PHP dependencies with unlimited memory +# Install PHP dependencies with unlimited memory +deps-install: @echo "πŸ“¦ Installing PHP dependencies..." COMPOSER_MEMORY_LIMIT=-1 composer install - yarn install @echo "βœ… Dependencies installed" # Complete test environment setup -test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures) +test-setup: deps-install test-clean test-db-create test-db-migrate test-fixtures @echo "βœ… Test environment setup complete!" # Clean test environment -test-clean: ## Clean test cache and database files +test-clean: @echo "🧹 Cleaning test environment..." rm -rf var/cache/test rm -f var/app_test.db @echo "βœ… Test environment cleaned" # Create test database -test-db-create: ## Create test database (if not exists) +test-db-create: @echo "πŸ—„οΈ Creating test database..." -php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..." # Run database migrations for test environment -test-db-migrate: ## Run database migrations for test environment +test-db-migrate: @echo "πŸ”„ Running database migrations..." - COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test + php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env test # Clear test cache -test-cache-clear: ## Clear test cache +test-cache-clear: @echo "πŸ—‘οΈ Clearing test cache..." rm -rf var/cache/test @echo "βœ… Test cache cleared" # Load test fixtures -test-fixtures: ## Load test fixtures +test-fixtures: @echo "πŸ“¦ Loading test fixtures..." php bin/console partdb:fixtures:load -n --env test # Run PHPUnit tests -test-run: ## Run PHPUnit tests +test-run: @echo "πŸ§ͺ Running tests..." php bin/phpunit +test-typecheck: + @echo "πŸ§ͺ Running type checks..." + COMPOSER_MEMORY_LIMIT=-1 composer phpstan + # Quick test reset (clean + migrate + fixtures, skip DB creation) test-reset: test-cache-clear test-db-migrate test-fixtures @echo "βœ… Test environment reset complete!" -test-typecheck: ## Run static analysis (PHPStan) - @echo "πŸ§ͺ Running type checks..." - COMPOSER_MEMORY_LIMIT=-1 composer phpstan - # Development helpers -dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup) +dev-setup: deps-install dev-clean dev-db-create dev-db-migrate dev-warmup @echo "βœ… Development environment setup complete!" -dev-clean: ## Clean development cache and database files +dev-clean: @echo "🧹 Cleaning development environment..." rm -rf var/cache/dev rm -f var/app_dev.db @echo "βœ… Development environment cleaned" -dev-db-create: ## Create development database (if not exists) +dev-db-create: @echo "πŸ—„οΈ Creating development database..." -php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..." -dev-db-migrate: ## Run database migrations for development environment +dev-db-migrate: @echo "πŸ”„ Running database migrations..." - COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev + php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env dev -dev-cache-clear: ## Clear development cache +dev-cache-clear: @echo "πŸ—‘οΈ Clearing development cache..." - rm -rf var/cache/dev + php -d memory_limit=1G bin/console cache:clear --env dev -n @echo "βœ… Development cache cleared" -dev-warmup: ## Warm up development cache +dev-warmup: @echo "πŸ”₯ Warming up development cache..." - COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n + php -d memory_limit=1G bin/console cache:warmup --env dev -n -dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate) +dev-reset: dev-cache-clear dev-db-migrate @echo "βœ… Development environment reset complete!" \ No newline at end of file diff --git a/migrations/Version20250802205143.php b/migrations/Version20250802205143.php deleted file mode 100644 index 5eb09a77..00000000 --- a/migrations/Version20250802205143.php +++ /dev/null @@ -1,70 +0,0 @@ -addSql('CREATE TABLE bulk_info_provider_import_jobs (id INT AUTO_INCREMENT NOT NULL, name LONGTEXT NOT NULL, field_mappings LONGTEXT NOT NULL, search_results LONGTEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details TINYINT(1) NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES `users` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); - $this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)'); - - $this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INT AUTO_INCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason LONGTEXT DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id), CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES `parts` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); - $this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)'); - $this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)'); - $this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)'); - } - - public function mySQLDown(Schema $schema): void - { - $this->addSql('DROP TABLE bulk_info_provider_import_job_parts'); - $this->addSql('DROP TABLE bulk_info_provider_import_jobs'); - } - - public function sqLiteUp(Schema $schema): void - { - $this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name CLOB NOT NULL, field_mappings CLOB NOT NULL, search_results CLOB NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INTEGER NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); - $this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)'); - - $this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason CLOB DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INTEGER NOT NULL, part_id INTEGER NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); - $this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)'); - $this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)'); - $this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)'); - } - - public function sqLiteDown(Schema $schema): void - { - $this->addSql('DROP TABLE bulk_info_provider_import_job_parts'); - $this->addSql('DROP TABLE bulk_info_provider_import_jobs'); - } - - public function postgreSQLUp(Schema $schema): void - { - $this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id SERIAL PRIMARY KEY NOT NULL, name TEXT NOT NULL, field_mappings TEXT NOT NULL, search_results TEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); - $this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)'); - - $this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id SERIAL PRIMARY KEY NOT NULL, status VARCHAR(20) NOT NULL, reason TEXT DEFAULT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES parts (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); - $this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)'); - $this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)'); - $this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)'); - } - - public function postgreSQLDown(Schema $schema): void - { - $this->addSql('DROP TABLE bulk_info_provider_import_job_parts'); - $this->addSql('DROP TABLE bulk_info_provider_import_jobs'); - } -} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3feb4940..4a37b420 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -4,7 +4,7 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" colors="true" - failOnDeprecation="false" + failOnDeprecation="true" failOnNotice="true" failOnWarning="true" bootstrap="tests/bootstrap.php" diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php deleted file mode 100644 index 2d3dd7f6..00000000 --- a/src/Controller/BulkInfoProviderImportController.php +++ /dev/null @@ -1,588 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Controller; - -use App\Entity\InfoProviderSystem\BulkImportJobStatus; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; -use App\Entity\Parts\Part; -use App\Entity\Parts\Supplier; -use App\Entity\UserSystem\User; -use App\Form\InfoProviderSystem\GlobalFieldMappingType; -use App\Services\InfoProviderSystem\BulkInfoProviderService; -use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; -use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; -use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; -use Doctrine\ORM\EntityManagerInterface; -use Psr\Log\LoggerInterface; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Attribute\Route; - -#[Route('/tools/bulk_info_provider_import')] -class BulkInfoProviderImportController extends AbstractController -{ - public function __construct( - private readonly BulkInfoProviderService $bulkService, - private readonly EntityManagerInterface $entityManager, - private readonly LoggerInterface $logger, - #[Autowire(param: 'partdb.bulk_import.batch_size')] - private readonly int $bulkImportBatchSize, - #[Autowire(param: 'partdb.bulk_import.max_parts_per_operation')] - private readonly int $bulkImportMaxParts - ) { - } - - /** - * Convert field mappings from array format to FieldMappingDTO[]. - * - * @param array $fieldMappings Array of field mapping arrays - * @return BulkSearchFieldMappingDTO[] Array of FieldMappingDTO objects - */ - private function convertFieldMappingsToDto(array $fieldMappings): array - { - $dtos = []; - foreach ($fieldMappings as $mapping) { - $dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1); - } - return $dtos; - } - - private function createErrorResponse(string $message, int $statusCode = 400, array $context = []): JsonResponse - { - $this->logger->warning('Bulk import operation failed', array_merge([ - 'error' => $message, - 'user' => $this->getUser()?->getUserIdentifier(), - ], $context)); - - return $this->json([ - 'success' => false, - 'error' => $message - ], $statusCode); - } - - private function validateJobAccess(int $jobId): ?BulkInfoProviderImportJob - { - $this->denyAccessUnlessGranted('@info_providers.create_parts'); - - $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId); - - if (!$job) { - return null; - } - - if ($job->getCreatedBy() !== $this->getUser()) { - return null; - } - - return $job; - } - - private function updatePartSearchResults(BulkInfoProviderImportJob $job, ?BulkSearchPartResultsDTO $newResults): void - { - if ($newResults === null) { - return; - } - - // Only deserialize and update if we have new results - $allResults = $job->getSearchResults($this->entityManager); - - // Find and update the results for this specific part - $allResults = $allResults->replaceResultsForPart($newResults); - - // Save updated results back to job - $job->setSearchResults($allResults); - } - - #[Route('/step1', name: 'bulk_info_provider_step1')] - public function step1(Request $request): Response - { - $this->denyAccessUnlessGranted('@info_providers.create_parts'); - - set_time_limit(600); - - $ids = $request->query->get('ids'); - if (!$ids) { - $this->addFlash('error', 'No parts selected for bulk import'); - return $this->redirectToRoute('homepage'); - } - - $partIds = explode(',', $ids); - $partRepository = $this->entityManager->getRepository(Part::class); - $parts = $partRepository->getElementsFromIDArray($partIds); - - if (empty($parts)) { - $this->addFlash('error', 'No valid parts found for bulk import'); - return $this->redirectToRoute('homepage'); - } - - // Validate against configured maximum - if (count($parts) > $this->bulkImportMaxParts) { - $this->addFlash('error', sprintf( - 'Too many parts selected (%d). Maximum allowed is %d parts per operation.', - count($parts), - $this->bulkImportMaxParts - )); - return $this->redirectToRoute('homepage'); - } - - if (count($parts) > ($this->bulkImportMaxParts / 2)) { - $this->addFlash('warning', 'Processing ' . count($parts) . ' parts may take several minutes and could timeout. Consider processing smaller batches.'); - } - - // Generate field choices - $fieldChoices = [ - 'info_providers.bulk_search.field.mpn' => 'mpn', - 'info_providers.bulk_search.field.name' => 'name', - ]; - - // Add dynamic supplier fields - $suppliers = $this->entityManager->getRepository(Supplier::class)->findAll(); - foreach ($suppliers as $supplier) { - $supplierKey = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName())); - $fieldChoices["Supplier: " . $supplier->getName() . " (SPN)"] = $supplierKey . '_spn'; - } - - // Initialize form with useful default mappings - $initialData = [ - 'field_mappings' => [ - ['field' => 'mpn', 'providers' => [], 'priority' => 1] - ], - 'prefetch_details' => false - ]; - - $form = $this->createForm(GlobalFieldMappingType::class, $initialData, [ - 'field_choices' => $fieldChoices - ]); - $form->handleRequest($request); - - $searchResults = null; - - if ($form->isSubmitted() && $form->isValid()) { - $formData = $form->getData(); - $fieldMappingDtos = $this->convertFieldMappingsToDto($formData['field_mappings']); - $prefetchDetails = $formData['prefetch_details'] ?? false; - - $user = $this->getUser(); - if (!$user instanceof User) { - throw new \RuntimeException('User must be authenticated and of type User'); - } - - // Validate part count against configuration limit - if (count($parts) > $this->bulkImportMaxParts) { - $this->addFlash('error', "Too many parts selected. Maximum allowed: {$this->bulkImportMaxParts}"); - $partIds = array_map(fn($part) => $part->getId(), $parts); - return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]); - } - - // Create and save the job - $job = new BulkInfoProviderImportJob(); - $job->setFieldMappings($fieldMappingDtos); - $job->setPrefetchDetails($prefetchDetails); - $job->setCreatedBy($user); - - foreach ($parts as $part) { - $jobPart = new BulkInfoProviderImportJobPart($job, $part); - $job->addJobPart($jobPart); - } - - $this->entityManager->persist($job); - $this->entityManager->flush(); - - try { - $searchResultsDto = $this->bulkService->performBulkSearch($parts, $fieldMappingDtos, $prefetchDetails); - - // Save search results to job - $job->setSearchResults($searchResultsDto); - $job->markAsInProgress(); - $this->entityManager->flush(); - - // Prefetch details if requested - if ($prefetchDetails) { - $this->bulkService->prefetchDetailsForResults($searchResultsDto); - } - - return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]); - - } catch (\Exception $e) { - $this->logger->error('Critical error during bulk import search', [ - 'job_id' => $job->getId(), - 'error' => $e->getMessage(), - 'exception' => $e - ]); - - $this->entityManager->remove($job); - $this->entityManager->flush(); - - $this->addFlash('error', 'Search failed due to an error: ' . $e->getMessage()); - $partIds = array_map(fn($part) => $part->getId(), $parts); - return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]); - } - } - - // Get existing in-progress jobs for current user - $existingJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class) - ->findBy(['createdBy' => $this->getUser(), 'status' => BulkImportJobStatus::IN_PROGRESS], ['createdAt' => 'DESC'], 10); - - return $this->render('info_providers/bulk_import/step1.html.twig', [ - 'form' => $form, - 'parts' => $parts, - 'search_results' => $searchResults, - 'existing_jobs' => $existingJobs, - 'fieldChoices' => $fieldChoices - ]); - } - - #[Route('/manage', name: 'bulk_info_provider_manage')] - public function manageBulkJobs(): Response - { - $this->denyAccessUnlessGranted('@info_providers.create_parts'); - - // Get all jobs for current user - $allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class) - ->findBy([], ['createdAt' => 'DESC']); - - // Check and auto-complete jobs that should be completed - // Also clean up jobs with no results (failed searches) - $updatedJobs = false; - $jobsToDelete = []; - - foreach ($allJobs as $job) { - if ($job->isAllPartsCompleted() && !$job->isCompleted()) { - $job->markAsCompleted(); - $updatedJobs = true; - } - - // Mark jobs with no results for deletion (failed searches) - if ($job->getResultCount() === 0 && $job->isInProgress()) { - $jobsToDelete[] = $job; - } - } - - // Delete failed jobs - foreach ($jobsToDelete as $job) { - $this->entityManager->remove($job); - $updatedJobs = true; - } - - // Flush changes if any jobs were updated - if ($updatedJobs) { - $this->entityManager->flush(); - - if (!empty($jobsToDelete)) { - $this->addFlash('info', 'Cleaned up ' . count($jobsToDelete) . ' failed job(s) with no results.'); - } - } - - return $this->render('info_providers/bulk_import/manage.html.twig', [ - 'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class) - ->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup - ]); - } - - #[Route('/job/{jobId}/delete', name: 'bulk_info_provider_delete', methods: ['DELETE'])] - public function deleteJob(int $jobId): Response - { - $job = $this->validateJobAccess($jobId); - if (!$job) { - return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); - } - - // Only allow deletion of completed, failed, or stopped jobs - if (!$job->isCompleted() && !$job->isFailed() && !$job->isStopped()) { - return $this->json(['error' => 'Cannot delete active job'], 400); - } - - $this->entityManager->remove($job); - $this->entityManager->flush(); - - return $this->json(['success' => true]); - } - - #[Route('/job/{jobId}/stop', name: 'bulk_info_provider_stop', methods: ['POST'])] - public function stopJob(int $jobId): Response - { - $job = $this->validateJobAccess($jobId); - if (!$job) { - return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); - } - - // Only allow stopping of pending or in-progress jobs - if (!$job->canBeStopped()) { - return $this->json(['error' => 'Cannot stop job in current status'], 400); - } - - $job->markAsStopped(); - $this->entityManager->flush(); - - return $this->json(['success' => true]); - } - - - #[Route('/step2/{jobId}', name: 'bulk_info_provider_step2')] - public function step2(int $jobId): Response - { - $this->denyAccessUnlessGranted('@info_providers.create_parts'); - - $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId); - - if (!$job) { - $this->addFlash('error', 'Bulk import job not found'); - return $this->redirectToRoute('bulk_info_provider_step1'); - } - - // Check if user owns this job - if ($job->getCreatedBy() !== $this->getUser()) { - $this->addFlash('error', 'Access denied to this bulk import job'); - return $this->redirectToRoute('bulk_info_provider_step1'); - } - - // Get the parts and deserialize search results - $parts = $job->getJobParts()->map(fn($jobPart) => $jobPart->getPart())->toArray(); - $searchResults = $job->getSearchResults($this->entityManager); - - return $this->render('info_providers/bulk_import/step2.html.twig', [ - 'job' => $job, - 'parts' => $parts, - 'search_results' => $searchResults, - ]); - } - - - #[Route('/job/{jobId}/part/{partId}/mark-completed', name: 'bulk_info_provider_mark_completed', methods: ['POST'])] - public function markPartCompleted(int $jobId, int $partId): Response - { - $job = $this->validateJobAccess($jobId); - if (!$job) { - return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); - } - - $job->markPartAsCompleted($partId); - - // Auto-complete job if all parts are done - if ($job->isAllPartsCompleted() && !$job->isCompleted()) { - $job->markAsCompleted(); - } - - $this->entityManager->flush(); - - return $this->json([ - 'success' => true, - 'progress' => $job->getProgressPercentage(), - 'completed_count' => $job->getCompletedPartsCount(), - 'total_count' => $job->getPartCount(), - 'job_completed' => $job->isCompleted() - ]); - } - - #[Route('/job/{jobId}/part/{partId}/mark-skipped', name: 'bulk_info_provider_mark_skipped', methods: ['POST'])] - public function markPartSkipped(int $jobId, int $partId, Request $request): Response - { - $job = $this->validateJobAccess($jobId); - if (!$job) { - return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); - } - - $reason = $request->request->get('reason', ''); - $job->markPartAsSkipped($partId, $reason); - - // Auto-complete job if all parts are done - if ($job->isAllPartsCompleted() && !$job->isCompleted()) { - $job->markAsCompleted(); - } - - $this->entityManager->flush(); - - return $this->json([ - 'success' => true, - 'progress' => $job->getProgressPercentage(), - 'completed_count' => $job->getCompletedPartsCount(), - 'skipped_count' => $job->getSkippedPartsCount(), - 'total_count' => $job->getPartCount(), - 'job_completed' => $job->isCompleted() - ]); - } - - #[Route('/job/{jobId}/part/{partId}/mark-pending', name: 'bulk_info_provider_mark_pending', methods: ['POST'])] - public function markPartPending(int $jobId, int $partId): Response - { - $job = $this->validateJobAccess($jobId); - if (!$job) { - return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); - } - - $job->markPartAsPending($partId); - $this->entityManager->flush(); - - return $this->json([ - 'success' => true, - 'progress' => $job->getProgressPercentage(), - 'completed_count' => $job->getCompletedPartsCount(), - 'skipped_count' => $job->getSkippedPartsCount(), - 'total_count' => $job->getPartCount(), - 'job_completed' => $job->isCompleted() - ]); - } - - #[Route('/job/{jobId}/part/{partId}/research', name: 'bulk_info_provider_research_part', methods: ['POST'])] - public function researchPart(int $jobId, int $partId): JsonResponse - { - $job = $this->validateJobAccess($jobId); - if (!$job) { - return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); - } - - $part = $this->entityManager->getRepository(Part::class)->find($partId); - if (!$part) { - return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]); - } - - // Only refresh if the entity might be stale (optional optimization) - if ($this->entityManager->getUnitOfWork()->isScheduledForUpdate($part)) { - $this->entityManager->refresh($part); - } - - try { - // Use the job's field mappings to perform the search - $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; - } - } - - // 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) { - $this->bulkService->prefetchDetailsForResults($searchResultsDto); - } - - $this->entityManager->flush(); - - // Return the new results for this part - $newResults = $searchResultsDto[0] ?? null; - - return $this->json([ - 'success' => true, - 'part_id' => $partId, - 'results_count' => $newResults ? $newResults->getResultCount() : 0, - 'errors_count' => $newResults ? $newResults->getErrorCount() : 0, - 'message' => 'Part research completed successfully' - ]); - - } catch (\Exception $e) { - return $this->createErrorResponse( - 'Research failed: ' . $e->getMessage(), - 500, - [ - 'job_id' => $jobId, - 'part_id' => $partId, - 'exception' => $e->getMessage() - ] - ); - } - } - - #[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])] - public function researchAllParts(int $jobId): JsonResponse - { - $job = $this->validateJobAccess($jobId); - if (!$job) { - return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); - } - - // Get all parts that are not completed or skipped - $parts = []; - foreach ($job->getJobParts() as $jobPart) { - if (!$jobPart->isCompleted() && !$jobPart->isSkipped()) { - $parts[] = $jobPart->getPart(); - } - } - - if (empty($parts)) { - return $this->json([ - 'success' => true, - 'message' => 'No parts to research', - 'researched_count' => 0 - ]); - } - - try { - $fieldMappingDtos = $job->getFieldMappings(); - $prefetchDetails = $job->isPrefetchDetails(); - - // Process in batches to reduce memory usage for large operations - $allResults = new BulkSearchResponseDTO(partResults: []); - $batches = array_chunk($parts, $this->bulkImportBatchSize); - - foreach ($batches as $batch) { - $batchResultsDto = $this->bulkService->performBulkSearch($batch, $fieldMappingDtos, $prefetchDetails); - $allResults = BulkSearchResponseDTO::merge($allResults, $batchResultsDto); - - // Properly manage entity manager memory without losing state - $jobId = $job->getId(); - //$this->entityManager->clear(); //TODO: This seems to cause problems with the user relation, when trying to flush later - $job = $this->entityManager->find(BulkInfoProviderImportJob::class, $jobId); - } - - // Update the job's search results - $job->setSearchResults($allResults); - - // Prefetch details if requested - if ($prefetchDetails) { - $this->bulkService->prefetchDetailsForResults($allResults); - } - - $this->entityManager->flush(); - - return $this->json([ - 'success' => true, - 'researched_count' => count($parts), - 'message' => sprintf('Successfully researched %d parts', count($parts)) - ]); - - } catch (\Exception $e) { - return $this->createErrorResponse( - 'Bulk research failed: ' . $e->getMessage(), - 500, - [ - 'job_id' => $jobId, - 'part_count' => count($parts), - 'exception' => $e->getMessage() - ] - ); - } - } -} diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index aeb2664e..6708ed4c 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -64,17 +64,14 @@ use Symfony\Contracts\Translation\TranslatorInterface; use function Symfony\Component\Translation\t; #[Route(path: '/part')] -final class PartController extends AbstractController +class PartController extends AbstractController { - public function __construct( - private readonly PricedetailHelper $pricedetailHelper, - private readonly PartPreviewGenerator $partPreviewGenerator, + public function __construct(protected PricedetailHelper $pricedetailHelper, + protected PartPreviewGenerator $partPreviewGenerator, private readonly TranslatorInterface $translator, - private readonly AttachmentSubmitHandler $attachmentSubmitHandler, - private readonly EntityManagerInterface $em, - private readonly EventCommentHelper $commentHelper, - private readonly PartInfoSettings $partInfoSettings, - ) { + private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em, + protected EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings) + { } /** @@ -83,16 +80,9 @@ final class PartController extends AbstractController */ #[Route(path: '/{id}/info/{timestamp}', name: 'part_info')] #[Route(path: '/{id}', requirements: ['id' => '\d+'])] - public function show( - Part $part, - Request $request, - TimeTravel $timeTravel, - HistoryHelper $historyHelper, - DataTableFactory $dataTable, - ParameterExtractor $parameterExtractor, - PartLotWithdrawAddHelper $withdrawAddHelper, - ?string $timestamp = null - ): Response { + public function show(Part $part, Request $request, TimeTravel $timeTravel, HistoryHelper $historyHelper, + DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, PartLotWithdrawAddHelper $withdrawAddHelper, ?string $timestamp = null): Response + { $this->denyAccessUnlessGranted('read', $part); $timeTravel_timestamp = null; @@ -142,43 +132,7 @@ final class PartController extends AbstractController { $this->denyAccessUnlessGranted('edit', $part); - // Check if this is part of a bulk import job - $jobId = $request->query->get('jobId'); - $bulkJob = null; - if ($jobId) { - $bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId); - // Verify user owns this job - if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) { - $bulkJob = null; - } - } - - return $this->renderPartForm('edit', $request, $part, [], [ - 'bulk_job' => $bulkJob - ]); - } - - #[Route(path: '/{id}/bulk-import-complete/{jobId}', name: 'part_bulk_import_complete', methods: ['POST'])] - public function markBulkImportComplete(Part $part, int $jobId, Request $request): Response - { - $this->denyAccessUnlessGranted('edit', $part); - - if (!$this->isCsrfTokenValid('bulk_complete_' . $part->getId(), $request->request->get('_token'))) { - throw $this->createAccessDeniedException('Invalid CSRF token'); - } - - $bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId); - if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) { - throw $this->createNotFoundException('Bulk import job not found'); - } - - $bulkJob->markPartAsCompleted($part->getId()); - $this->em->persist($bulkJob); - $this->em->flush(); - - $this->addFlash('success', 'Part marked as completed in bulk import'); - - return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $jobId]); + return $this->renderPartForm('edit', $request, $part); } #[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])] @@ -186,7 +140,7 @@ final class PartController extends AbstractController { $this->denyAccessUnlessGranted('delete', $part); - if ($this->isCsrfTokenValid('delete' . $part->getID(), $request->request->get('_token'))) { + if ($this->isCsrfTokenValid('delete'.$part->getID(), $request->request->get('_token'))) { $this->commentHelper->setMessage($request->request->get('log_comment', null)); @@ -205,15 +159,11 @@ final class PartController extends AbstractController #[Route(path: '/new', name: 'part_new')] #[Route(path: '/{id}/clone', name: 'part_clone')] #[Route(path: '/new_build_part/{project_id}', name: 'part_new_build_part')] - public function new( - Request $request, - EntityManagerInterface $em, - TranslatorInterface $translator, - AttachmentSubmitHandler $attachmentSubmitHandler, - ProjectBuildPartHelper $projectBuildPartHelper, + public function new(Request $request, EntityManagerInterface $em, TranslatorInterface $translator, + AttachmentSubmitHandler $attachmentSubmitHandler, ProjectBuildPartHelper $projectBuildPartHelper, #[MapEntity(mapping: ['id' => 'id'])] ?Part $part = null, - #[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null - ): Response { + #[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null): Response + { if ($part instanceof Part) { //Clone part @@ -308,14 +258,9 @@ final class PartController extends AbstractController } #[Route(path: '/{id}/from_info_provider/{providerKey}/{providerId}/update', name: 'info_providers_update_part', requirements: ['providerId' => '.+'])] - public function updateFromInfoProvider( - Part $part, - Request $request, - string $providerKey, - string $providerId, - PartInfoRetriever $infoRetriever, - PartMerger $partMerger - ): Response { + public function updateFromInfoProvider(Part $part, Request $request, string $providerKey, string $providerId, + PartInfoRetriever $infoRetriever, PartMerger $partMerger): Response + { $this->denyAccessUnlessGranted('edit', $part); $this->denyAccessUnlessGranted('@info_providers.create_parts'); @@ -329,22 +274,10 @@ final class PartController extends AbstractController $this->addFlash('notice', t('part.merge.flash.please_review')); - // Check if this is part of a bulk import job - $jobId = $request->query->get('jobId'); - $bulkJob = null; - if ($jobId) { - $bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId); - // Verify user owns this job - if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) { - $bulkJob = null; - } - } - return $this->renderPartForm('update_from_ip', $request, $part, [ 'info_provider_dto' => $dto, ], [ - 'tname_before' => $old_name, - 'bulk_job' => $bulkJob + 'tname_before' => $old_name ]); } @@ -379,7 +312,7 @@ final class PartController extends AbstractController } catch (AttachmentDownloadException $attachmentDownloadException) { $this->addFlash( 'error', - $this->translator->trans('attachment.download_failed') . ' ' . $attachmentDownloadException->getMessage() + $this->translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage() ); } } @@ -420,12 +353,6 @@ final class PartController extends AbstractController return $this->redirectToRoute('part_new'); } - // Check if we're in bulk import mode and preserve jobId - $jobId = $request->query->get('jobId'); - if ($jobId && isset($merge_infos['bulk_job'])) { - return $this->redirectToRoute('part_edit', ['id' => $new_part->getID(), 'jobId' => $jobId]); - } - return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]); } @@ -444,17 +371,13 @@ final class PartController extends AbstractController $template = 'parts/edit/update_from_ip.html.twig'; } - return $this->render( - $template, + return $this->render($template, [ 'part' => $new_part, 'form' => $form, 'merge_old_name' => $merge_infos['tname_before'] ?? null, - 'merge_other' => $merge_infos['other_part'] ?? null, - 'bulk_job' => $merge_infos['bulk_job'] ?? null, - 'jobId' => $request->query->get('jobId') - ] - ); + 'merge_other' => $merge_infos['other_part'] ?? null + ]); } @@ -464,17 +387,17 @@ final class PartController extends AbstractController if ($this->isCsrfTokenValid('part_withraw' . $part->getID(), $request->request->get('_csfr'))) { //Retrieve partlot from the request $partLot = $em->find(PartLot::class, $request->request->get('lot_id')); - if (!$partLot instanceof PartLot) { + if(!$partLot instanceof PartLot) { throw new \RuntimeException('Part lot not found!'); } //Ensure that the partlot belongs to the part - if ($partLot->getPart() !== $part) { + if($partLot->getPart() !== $part) { throw new \RuntimeException("The origin partlot does not belong to the part!"); } //Try to determine the target lot (used for move actions), if the parameter is existing $targetId = $request->request->get('target_id', null); - $targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null; + $targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null; if ($targetLot && $targetLot->getPart() !== $part) { throw new \RuntimeException("The target partlot does not belong to the part!"); } @@ -488,12 +411,12 @@ final class PartController extends AbstractController $timestamp = null; $timestamp_str = $request->request->getString('timestamp', ''); //Try to parse the timestamp - if ($timestamp_str !== '') { + if($timestamp_str !== '') { $timestamp = new DateTime($timestamp_str); } //Ensure that the timestamp is not in the future - if ($timestamp !== null && $timestamp > new DateTime("+20min")) { + if($timestamp !== null && $timestamp > new DateTime("+20min")) { throw new \LogicException("The timestamp must not be in the future!"); } @@ -537,7 +460,7 @@ final class PartController extends AbstractController err: //If a redirect was passed, then redirect there - if ($request->request->get('_redirect')) { + if($request->request->get('_redirect')) { return $this->redirect($request->request->get('_redirect')); } //Otherwise just redirect to the part page diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php deleted file mode 100644 index 9d21dd58..00000000 --- a/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php +++ /dev/null @@ -1,59 +0,0 @@ -. - */ - -namespace App\DataTables\Filters\Constraints\Part; - -use App\DataTables\Filters\Constraints\BooleanConstraint; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; -use Doctrine\ORM\QueryBuilder; - -class BulkImportJobExistsConstraint extends BooleanConstraint -{ - - public function __construct() - { - parent::__construct('bulk_import_job_exists'); - } - - public function apply(QueryBuilder $queryBuilder): void - { - // Do not apply a filter if value is null (filter is set to ignore) - if (!$this->isEnabled()) { - return; - } - - // Use EXISTS subquery to avoid join conflicts - $existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder(); - $existsSubquery->select('1') - ->from(BulkInfoProviderImportJobPart::class, 'bip_exists') - ->where('bip_exists.part = part.id'); - - if ($this->value === true) { - // Filter for parts that ARE in bulk import jobs - $queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')'); - } else { - // Filter for parts that are NOT in bulk import jobs - $queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')'); - } - } -} diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php deleted file mode 100644 index d9451577..00000000 --- a/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php +++ /dev/null @@ -1,64 +0,0 @@ -. - */ - -namespace App\DataTables\Filters\Constraints\Part; - -use App\DataTables\Filters\Constraints\AbstractConstraint; -use App\DataTables\Filters\Constraints\ChoiceConstraint; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; -use Doctrine\ORM\QueryBuilder; - -class BulkImportJobStatusConstraint extends ChoiceConstraint -{ - - public function __construct() - { - parent::__construct('bulk_import_job_status'); - } - - public function apply(QueryBuilder $queryBuilder): void - { - // Do not apply a filter if values are empty or operator is null - if (!$this->isEnabled()) { - return; - } - - // Use EXISTS subquery to check if part has a job with the specified status(es) - $existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder(); - $existsSubquery->select('1') - ->from(BulkInfoProviderImportJobPart::class, 'bip_status') - ->join('bip_status.job', 'job_status') - ->where('bip_status.part = part.id'); - - // Add status conditions based on operator - if ($this->operator === 'ANY') { - $existsSubquery->andWhere('job_status.status IN (:job_status_values)'); - $queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')'); - $queryBuilder->setParameter('job_status_values', $this->value); - } elseif ($this->operator === 'NONE') { - $existsSubquery->andWhere('job_status.status IN (:job_status_values)'); - $queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')'); - $queryBuilder->setParameter('job_status_values', $this->value); - } - } -} diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php deleted file mode 100644 index 7656a290..00000000 --- a/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php +++ /dev/null @@ -1,61 +0,0 @@ -. - */ - -namespace App\DataTables\Filters\Constraints\Part; - -use App\DataTables\Filters\Constraints\ChoiceConstraint; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; -use Doctrine\ORM\QueryBuilder; - -class BulkImportPartStatusConstraint extends ChoiceConstraint -{ - public function __construct() - { - parent::__construct('bulk_import_part_status'); - } - - public function apply(QueryBuilder $queryBuilder): void - { - // Do not apply a filter if values are empty or operator is null - if (!$this->isEnabled()) { - return; - } - - // Use EXISTS subquery to check if part has the specified status(es) - $existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder(); - $existsSubquery->select('1') - ->from(BulkInfoProviderImportJobPart::class, 'bip_part_status') - ->where('bip_part_status.part = part.id'); - - // Add status conditions based on operator - if ($this->operator === 'ANY') { - $existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)'); - $queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')'); - $queryBuilder->setParameter('part_status_values', $this->value); - } elseif ($this->operator === 'NONE') { - $existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)'); - $queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')'); - $queryBuilder->setParameter('part_status_values', $this->value); - } - } -} diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index e44cf69d..8dcbd6b3 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -29,9 +29,6 @@ use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\EntityConstraint; use App\DataTables\Filters\Constraints\IntConstraint; use App\DataTables\Filters\Constraints\NumberConstraint; -use App\DataTables\Filters\Constraints\Part\BulkImportJobExistsConstraint; -use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint; -use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint; use App\DataTables\Filters\Constraints\Part\LessThanDesiredConstraint; use App\DataTables\Filters\Constraints\Part\ParameterConstraint; use App\DataTables\Filters\Constraints\Part\TagsConstraint; @@ -105,14 +102,6 @@ class PartFilter implements FilterInterface public readonly TextConstraint $bomName; public readonly TextConstraint $bomComment; - /************************************************* - * Bulk Import Job tab - *************************************************/ - - public readonly BulkImportJobExistsConstraint $inBulkImportJob; - public readonly BulkImportJobStatusConstraint $bulkImportJobStatus; - public readonly BulkImportPartStatusConstraint $bulkImportPartStatus; - public function __construct(NodesListBuilder $nodesListBuilder) { //Must be done for every new set of attachment filters, to ensure deterministic parameter names. @@ -141,7 +130,7 @@ class PartFilter implements FilterInterface */ $this->amountSum = (new IntConstraint('( SELECT COALESCE(SUM(__partLot.amount), 0.0) - FROM ' . PartLot::class . ' __partLot + FROM '.PartLot::class.' __partLot WHERE __partLot.part = part.id AND __partLot.instock_unknown = false AND (__partLot.expiration_date IS NULL OR __partLot.expiration_date > CURRENT_DATE()) @@ -177,11 +166,6 @@ class PartFilter implements FilterInterface $this->bomName = new TextConstraint('_projectBomEntries.name'); $this->bomComment = new TextConstraint('_projectBomEntries.comment'); - // Bulk Import Job filters - $this->inBulkImportJob = new BulkImportJobExistsConstraint(); - $this->bulkImportJobStatus = new BulkImportJobStatusConstraint(); - $this->bulkImportPartStatus = new BulkImportPartStatusConstraint(); - } public function apply(QueryBuilder $queryBuilder): void diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index a97762b1..f0decf27 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -142,25 +142,23 @@ final class PartsDataTable implements DataTableTypeInterface 'label' => $this->translator->trans('part.table.storeLocations'), //We need to use a aggregate function to get the first store location, as we have a one-to-many relation 'orderField' => 'NATSORT(MIN(_storelocations.name))', - 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context), + 'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context), ], alias: 'storage_location') ->add('amount', TextColumn::class, [ 'label' => $this->translator->trans('part.table.amount'), - 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context), + 'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderAmount($context), 'orderField' => 'amountSum' ]) ->add('minamount', TextColumn::class, [ 'label' => $this->translator->trans('part.table.minamount'), - 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format( - $value, - $context->getPartUnit() - )), + 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value, + $context->getPartUnit())), ]) ->add('partUnit', TextColumn::class, [ 'label' => $this->translator->trans('part.table.partUnit'), 'orderField' => 'NATSORT(_partUnit.name)', - 'render' => function ($value, Part $context): string { + 'render' => function($value, Part $context): string { $partUnit = $context->getPartUnit(); if ($partUnit === null) { return ''; @@ -169,7 +167,7 @@ final class PartsDataTable implements DataTableTypeInterface $tmp = htmlspecialchars($partUnit->getName()); if ($partUnit->getUnit()) { - $tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')'; + $tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')'; } return $tmp; } @@ -232,7 +230,7 @@ final class PartsDataTable implements DataTableTypeInterface } if (count($projects) > $max) { - $tmp .= ", + " . (count($projects) - $max); + $tmp .= ", + ".(count($projects) - $max); } return $tmp; @@ -368,7 +366,7 @@ final class PartsDataTable implements DataTableTypeInterface $builder->addSelect( '( SELECT COALESCE(SUM(partLot.amount), 0.0) - FROM ' . PartLot::class . ' partLot + FROM '.PartLot::class.' partLot WHERE partLot.part = part.id AND partLot.instock_unknown = false AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE()) @@ -425,13 +423,6 @@ final class PartsDataTable implements DataTableTypeInterface //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_projectBomEntries'); } - if (str_contains($dql, '_jobPart')) { - $builder->leftJoin('part.bulkImportJobParts', '_jobPart'); - $builder->leftJoin('_jobPart.job', '_bulkImportJob'); - //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 - //$builder->addGroupBy('_jobPart'); - //$builder->addGroupBy('_bulkImportJob'); - } return $builder; } diff --git a/src/Entity/InfoProviderSystem/BulkImportJobStatus.php b/src/Entity/InfoProviderSystem/BulkImportJobStatus.php deleted file mode 100644 index 7a88802f..00000000 --- a/src/Entity/InfoProviderSystem/BulkImportJobStatus.php +++ /dev/null @@ -1,35 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Entity\InfoProviderSystem; - -use Symfony\Contracts\Translation\TranslatableInterface; -use Symfony\Contracts\Translation\TranslatorInterface; - -enum BulkImportJobStatus: string -{ - case PENDING = 'pending'; - case IN_PROGRESS = 'in_progress'; - case COMPLETED = 'completed'; - case STOPPED = 'stopped'; - case FAILED = 'failed'; -} diff --git a/src/Entity/InfoProviderSystem/BulkImportPartStatus.php b/src/Entity/InfoProviderSystem/BulkImportPartStatus.php deleted file mode 100644 index 0eedc553..00000000 --- a/src/Entity/InfoProviderSystem/BulkImportPartStatus.php +++ /dev/null @@ -1,32 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Entity\InfoProviderSystem; - - -enum BulkImportPartStatus: string -{ - case PENDING = 'pending'; - case COMPLETED = 'completed'; - case SKIPPED = 'skipped'; - case FAILED = 'failed'; -} diff --git a/src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php b/src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php deleted file mode 100644 index bc842a26..00000000 --- a/src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php +++ /dev/null @@ -1,449 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Entity\InfoProviderSystem; - -use App\Entity\Base\AbstractDBElement; -use App\Entity\Parts\Part; -use App\Entity\UserSystem\User; -use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; -use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; -use Doctrine\DBAL\Types\Types; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping as ORM; - -#[ORM\Entity] -#[ORM\Table(name: 'bulk_info_provider_import_jobs')] -class BulkInfoProviderImportJob extends AbstractDBElement -{ - #[ORM\Column(type: Types::TEXT)] - private string $name = ''; - - #[ORM\Column(type: Types::JSON)] - private array $fieldMappings = []; - - /** - * @var BulkSearchFieldMappingDTO[] The deserialized field mappings DTOs, cached for performance - */ - private ?array $fieldMappingsDTO = null; - - #[ORM\Column(type: Types::JSON)] - private array $searchResults = []; - - /** - * @var BulkSearchResponseDTO|null The deserialized search results DTO, cached for performance - */ - private ?BulkSearchResponseDTO $searchResultsDTO = null; - - #[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportJobStatus::class)] - private BulkImportJobStatus $status = BulkImportJobStatus::PENDING; - - #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] - private \DateTimeImmutable $createdAt; - - #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] - private ?\DateTimeImmutable $completedAt = null; - - #[ORM\Column(type: Types::BOOLEAN)] - private bool $prefetchDetails = false; - - #[ORM\ManyToOne(targetEntity: User::class)] - #[ORM\JoinColumn(nullable: false)] - private ?User $createdBy = null; - - /** @var Collection */ - #[ORM\OneToMany(targetEntity: BulkInfoProviderImportJobPart::class, mappedBy: 'job', cascade: ['persist', 'remove'], orphanRemoval: true)] - private Collection $jobParts; - - public function __construct() - { - $this->createdAt = new \DateTimeImmutable(); - $this->jobParts = new ArrayCollection(); - } - - public function getName(): string - { - return $this->name; - } - - public function getDisplayNameKey(): string - { - return 'info_providers.bulk_import.job_name_template'; - } - - public function getDisplayNameParams(): array - { - return ['%count%' => $this->getPartCount()]; - } - - public function getFormattedTimestamp(): string - { - return $this->createdAt->format('Y-m-d H:i:s'); - } - - public function setName(string $name): self - { - $this->name = $name; - return $this; - } - - public function getJobParts(): Collection - { - return $this->jobParts; - } - - public function addJobPart(BulkInfoProviderImportJobPart $jobPart): self - { - if (!$this->jobParts->contains($jobPart)) { - $this->jobParts->add($jobPart); - $jobPart->setJob($this); - } - return $this; - } - - public function removeJobPart(BulkInfoProviderImportJobPart $jobPart): self - { - if ($this->jobParts->removeElement($jobPart)) { - if ($jobPart->getJob() === $this) { - $jobPart->setJob(null); - } - } - return $this; - } - - public function getPartIds(): array - { - return $this->jobParts->map(fn($jobPart) => $jobPart->getPart()->getId())->toArray(); - } - - public function setPartIds(array $partIds): self - { - // This method is kept for backward compatibility but should be replaced with addJobPart - // Clear existing job parts - $this->jobParts->clear(); - - // Add new job parts (this would need the actual Part entities, not just IDs) - // This is a simplified implementation - in practice, you'd want to pass Part entities - return $this; - } - - public function addPart(Part $part): self - { - $jobPart = new BulkInfoProviderImportJobPart($this, $part); - $this->addJobPart($jobPart); - return $this; - } - - /** - * @return BulkSearchFieldMappingDTO[] The deserialized field mappings - */ - public function getFieldMappings(): array - { - if ($this->fieldMappingsDTO === null) { - // Lazy load the DTOs from the raw JSON data - $this->fieldMappingsDTO = array_map( - static fn($data) => BulkSearchFieldMappingDTO::fromSerializableArray($data), - $this->fieldMappings - ); - } - - return $this->fieldMappingsDTO; - } - - /** - * @param BulkSearchFieldMappingDTO[] $fieldMappings - * @return $this - */ - public function setFieldMappings(array $fieldMappings): self - { - //Ensure that we are dealing with the objects here - if (count($fieldMappings) > 0 && !$fieldMappings[0] instanceof BulkSearchFieldMappingDTO) { - throw new \InvalidArgumentException('Expected an array of FieldMappingDTO objects'); - } - - $this->fieldMappingsDTO = $fieldMappings; - - $this->fieldMappings = array_map( - static fn(BulkSearchFieldMappingDTO $dto) => $dto->toSerializableArray(), - $fieldMappings - ); - return $this; - } - - public function getSearchResultsRaw(): array - { - return $this->searchResults; - } - - public function setSearchResultsRaw(array $searchResults): self - { - $this->searchResults = $searchResults; - return $this; - } - - public function setSearchResults(BulkSearchResponseDTO $searchResponse): self - { - $this->searchResultsDTO = $searchResponse; - $this->searchResults = $searchResponse->toSerializableRepresentation(); - return $this; - } - - public function getSearchResults(EntityManagerInterface $entityManager): BulkSearchResponseDTO - { - if ($this->searchResultsDTO === null) { - // Lazy load the DTO from the raw JSON data - $this->searchResultsDTO = BulkSearchResponseDTO::fromSerializableRepresentation($this->searchResults, $entityManager); - } - return $this->searchResultsDTO; - } - - public function hasSearchResults(): bool - { - return !empty($this->searchResults); - } - - public function getStatus(): BulkImportJobStatus - { - return $this->status; - } - - public function setStatus(BulkImportJobStatus $status): self - { - $this->status = $status; - return $this; - } - - public function getCreatedAt(): \DateTimeImmutable - { - return $this->createdAt; - } - - public function getCompletedAt(): ?\DateTimeImmutable - { - return $this->completedAt; - } - - public function setCompletedAt(?\DateTimeImmutable $completedAt): self - { - $this->completedAt = $completedAt; - return $this; - } - - public function isPrefetchDetails(): bool - { - return $this->prefetchDetails; - } - - public function setPrefetchDetails(bool $prefetchDetails): self - { - $this->prefetchDetails = $prefetchDetails; - return $this; - } - - public function getCreatedBy(): User - { - return $this->createdBy; - } - - public function setCreatedBy(User $createdBy): self - { - $this->createdBy = $createdBy; - return $this; - } - - public function getProgress(): array - { - $progress = []; - foreach ($this->jobParts as $jobPart) { - $progressData = [ - 'status' => $jobPart->getStatus()->value - ]; - - // Only include completed_at if it's not null - if ($jobPart->getCompletedAt() !== null) { - $progressData['completed_at'] = $jobPart->getCompletedAt()->format('c'); - } - - // Only include reason if it's not null - if ($jobPart->getReason() !== null) { - $progressData['reason'] = $jobPart->getReason(); - } - - $progress[$jobPart->getPart()->getId()] = $progressData; - } - return $progress; - } - - public function markAsCompleted(): self - { - $this->status = BulkImportJobStatus::COMPLETED; - $this->completedAt = new \DateTimeImmutable(); - return $this; - } - - public function markAsFailed(): self - { - $this->status = BulkImportJobStatus::FAILED; - $this->completedAt = new \DateTimeImmutable(); - return $this; - } - - public function markAsStopped(): self - { - $this->status = BulkImportJobStatus::STOPPED; - $this->completedAt = new \DateTimeImmutable(); - return $this; - } - - public function markAsInProgress(): self - { - $this->status = BulkImportJobStatus::IN_PROGRESS; - return $this; - } - - public function isPending(): bool - { - return $this->status === BulkImportJobStatus::PENDING; - } - - public function isInProgress(): bool - { - return $this->status === BulkImportJobStatus::IN_PROGRESS; - } - - public function isCompleted(): bool - { - return $this->status === BulkImportJobStatus::COMPLETED; - } - - public function isFailed(): bool - { - return $this->status === BulkImportJobStatus::FAILED; - } - - public function isStopped(): bool - { - return $this->status === BulkImportJobStatus::STOPPED; - } - - public function canBeStopped(): bool - { - return $this->status === BulkImportJobStatus::PENDING || $this->status === BulkImportJobStatus::IN_PROGRESS; - } - - public function getPartCount(): int - { - return $this->jobParts->count(); - } - - public function getResultCount(): int - { - $count = 0; - foreach ($this->searchResults as $partResult) { - $count += count($partResult['search_results'] ?? []); - } - return $count; - } - - public function markPartAsCompleted(int $partId): self - { - $jobPart = $this->findJobPartByPartId($partId); - if ($jobPart) { - $jobPart->markAsCompleted(); - } - return $this; - } - - public function markPartAsSkipped(int $partId, string $reason = ''): self - { - $jobPart = $this->findJobPartByPartId($partId); - if ($jobPart) { - $jobPart->markAsSkipped($reason); - } - return $this; - } - - public function markPartAsPending(int $partId): self - { - $jobPart = $this->findJobPartByPartId($partId); - if ($jobPart) { - $jobPart->markAsPending(); - } - return $this; - } - - public function isPartCompleted(int $partId): bool - { - $jobPart = $this->findJobPartByPartId($partId); - return $jobPart ? $jobPart->isCompleted() : false; - } - - public function isPartSkipped(int $partId): bool - { - $jobPart = $this->findJobPartByPartId($partId); - return $jobPart ? $jobPart->isSkipped() : false; - } - - public function getCompletedPartsCount(): int - { - return $this->jobParts->filter(fn($jobPart) => $jobPart->isCompleted())->count(); - } - - public function getSkippedPartsCount(): int - { - return $this->jobParts->filter(fn($jobPart) => $jobPart->isSkipped())->count(); - } - - private function findJobPartByPartId(int $partId): ?BulkInfoProviderImportJobPart - { - foreach ($this->jobParts as $jobPart) { - if ($jobPart->getPart()->getId() === $partId) { - return $jobPart; - } - } - return null; - } - - public function getProgressPercentage(): float - { - $total = $this->getPartCount(); - if ($total === 0) { - return 100.0; - } - - $completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount(); - return round(($completed / $total) * 100, 1); - } - - public function isAllPartsCompleted(): bool - { - $total = $this->getPartCount(); - if ($total === 0) { - return true; - } - - $completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount(); - return $completed >= $total; - } -} diff --git a/src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php b/src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php deleted file mode 100644 index 90519561..00000000 --- a/src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php +++ /dev/null @@ -1,182 +0,0 @@ -. - */ - -declare(strict_types=1); - -/* - * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). - * - * Copyright (C) 2019 - 2023 Jan BΓΆhmer (https://github.com/jbtronics) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -namespace App\Entity\InfoProviderSystem; - -use App\Entity\Base\AbstractDBElement; -use App\Entity\Parts\Part; -use Doctrine\DBAL\Types\Types; -use Doctrine\ORM\Mapping as ORM; - -#[ORM\Entity] -#[ORM\Table(name: 'bulk_info_provider_import_job_parts')] -#[ORM\UniqueConstraint(name: 'unique_job_part', columns: ['job_id', 'part_id'])] -class BulkInfoProviderImportJobPart extends AbstractDBElement -{ - #[ORM\ManyToOne(targetEntity: BulkInfoProviderImportJob::class, inversedBy: 'jobParts')] - #[ORM\JoinColumn(nullable: false)] - private BulkInfoProviderImportJob $job; - - #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'bulkImportJobParts')] - #[ORM\JoinColumn(nullable: false)] - private Part $part; - - #[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportPartStatus::class)] - private BulkImportPartStatus $status = BulkImportPartStatus::PENDING; - - #[ORM\Column(type: Types::TEXT, nullable: true)] - private ?string $reason = null; - - #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] - private ?\DateTimeImmutable $completedAt = null; - - public function __construct(BulkInfoProviderImportJob $job, Part $part) - { - $this->job = $job; - $this->part = $part; - } - - public function getJob(): BulkInfoProviderImportJob - { - return $this->job; - } - - public function setJob(?BulkInfoProviderImportJob $job): self - { - $this->job = $job; - return $this; - } - - public function getPart(): Part - { - return $this->part; - } - - public function setPart(?Part $part): self - { - $this->part = $part; - return $this; - } - - public function getStatus(): BulkImportPartStatus - { - return $this->status; - } - - public function setStatus(BulkImportPartStatus $status): self - { - $this->status = $status; - return $this; - } - - public function getReason(): ?string - { - return $this->reason; - } - - public function setReason(?string $reason): self - { - $this->reason = $reason; - return $this; - } - - public function getCompletedAt(): ?\DateTimeImmutable - { - return $this->completedAt; - } - - public function setCompletedAt(?\DateTimeImmutable $completedAt): self - { - $this->completedAt = $completedAt; - return $this; - } - - public function markAsCompleted(): self - { - $this->status = BulkImportPartStatus::COMPLETED; - $this->completedAt = new \DateTimeImmutable(); - return $this; - } - - public function markAsSkipped(string $reason = ''): self - { - $this->status = BulkImportPartStatus::SKIPPED; - $this->reason = $reason; - $this->completedAt = new \DateTimeImmutable(); - return $this; - } - - public function markAsFailed(string $reason = ''): self - { - $this->status = BulkImportPartStatus::FAILED; - $this->reason = $reason; - $this->completedAt = new \DateTimeImmutable(); - return $this; - } - - public function markAsPending(): self - { - $this->status = BulkImportPartStatus::PENDING; - $this->reason = null; - $this->completedAt = null; - return $this; - } - - public function isPending(): bool - { - return $this->status === BulkImportPartStatus::PENDING; - } - - public function isCompleted(): bool - { - return $this->status === BulkImportPartStatus::COMPLETED; - } - - public function isSkipped(): bool - { - return $this->status === BulkImportPartStatus::SKIPPED; - } - - public function isFailed(): bool - { - return $this->status === BulkImportPartStatus::FAILED; - } -} diff --git a/src/Entity/LogSystem/LogTargetType.php b/src/Entity/LogSystem/LogTargetType.php index 61a2b081..1c6e4f8c 100644 --- a/src/Entity/LogSystem/LogTargetType.php +++ b/src/Entity/LogSystem/LogTargetType.php @@ -24,8 +24,6 @@ namespace App\Entity\LogSystem; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\Category; @@ -69,8 +67,6 @@ enum LogTargetType: int case LABEL_PROFILE = 19; case PART_ASSOCIATION = 20; - case BULK_INFO_PROVIDER_IMPORT_JOB = 21; - case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22; /** * Returns the class name of the target type or null if the target type is NONE. @@ -100,8 +96,6 @@ enum LogTargetType: int self::PARAMETER => AbstractParameter::class, self::LABEL_PROFILE => LabelProfile::class, self::PART_ASSOCIATION => PartAssociation::class, - self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class, - self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => BulkInfoProviderImportJobPart::class, }; } diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 2f274a8a..14a7903f 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -22,6 +22,8 @@ declare(strict_types=1); namespace App\Entity\Parts; +use App\ApiPlatform\Filter\TagFilter; +use Doctrine\Common\Collections\Criteria; use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; @@ -38,12 +40,10 @@ use ApiPlatform\Serializer\Filter\PropertyFilter; use App\ApiPlatform\Filter\EntityFilter; use App\ApiPlatform\Filter\LikeFilter; use App\ApiPlatform\Filter\PartStoragelocationFilter; -use App\ApiPlatform\Filter\TagFilter; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentContainingDBElement; use App\Entity\Attachments\PartAttachment; use App\Entity\EDA\EDAPartInfo; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; use App\Entity\Parameters\ParametersTrait; use App\Entity\Parameters\PartParameter; use App\Entity\Parts\PartTraits\AdvancedPropertyTrait; @@ -59,7 +59,6 @@ use App\Repository\PartRepository; use App\Validator\Constraints\UniqueObjectCollection; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; -use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Serializer\Annotation\Groups; @@ -84,18 +83,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')] #[ApiResource( operations: [ - new Get(normalizationContext: [ - 'groups' => [ - 'part:read', - 'provider_reference:read', - 'api:basic:read', - 'part_lot:read', - 'orderdetail:read', - 'pricedetail:read', - 'parameter:read', - 'attachment:read', - 'eda_info:read' - ], + new Get(normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read', + 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'], 'openapi_definition_name' => 'Read', ], security: 'is_granted("read", object)'), new GetCollection(security: 'is_granted("@parts.read")'), @@ -103,7 +92,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; new Patch(security: 'is_granted("edit", object)'), new Delete(security: 'is_granted("delete", object)'), ], - normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'], + normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], )] #[ApiFilter(PropertyFilter::class)] @@ -111,7 +100,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])] #[ApiFilter(TagFilter::class, properties: ["tags"])] -#[ApiFilter(BooleanFilter::class, properties: ["favorite", "needs_review"])] +#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])] #[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])] #[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] #[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])] @@ -171,12 +160,6 @@ class Part extends AttachmentContainingDBElement #[Groups(['part:read'])] protected ?\DateTimeImmutable $lastModified = null; - /** - * @var Collection - */ - #[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)] - protected Collection $bulkImportJobParts; - public function __construct() { @@ -189,7 +172,6 @@ class Part extends AttachmentContainingDBElement $this->associated_parts_as_owner = new ArrayCollection(); $this->associated_parts_as_other = new ArrayCollection(); - $this->bulkImportJobParts = new ArrayCollection(); //By default, the part has no provider $this->providerReference = InfoProviderReference::noProvider(); @@ -248,38 +230,4 @@ class Part extends AttachmentContainingDBElement } } } - - /** - * Get all bulk import job parts for this part - * @return Collection - */ - public function getBulkImportJobParts(): Collection - { - return $this->bulkImportJobParts; - } - - /** - * Add a bulk import job part to this part - */ - public function addBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self - { - if (!$this->bulkImportJobParts->contains($jobPart)) { - $this->bulkImportJobParts->add($jobPart); - $jobPart->setPart($this); - } - return $this; - } - - /** - * Remove a bulk import job part from this part - */ - public function removeBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self - { - if ($this->bulkImportJobParts->removeElement($jobPart)) { - if ($jobPart->getPart() === $this) { - $jobPart->setPart(null); - } - } - return $this; - } } diff --git a/src/EventListener/LogSystem/EventLoggerListener.php b/src/EventListener/LogSystem/EventLoggerListener.php index f5029c28..96c6ef51 100644 --- a/src/EventListener/LogSystem/EventLoggerListener.php +++ b/src/EventListener/LogSystem/EventLoggerListener.php @@ -170,7 +170,6 @@ class EventLoggerListener public function hasFieldRestrictions(AbstractDBElement $element): bool { foreach (array_keys(static::FIELD_BLACKLIST) as $class) { - /** @var string $class */ if ($element instanceof $class) { return true; } @@ -185,7 +184,6 @@ class EventLoggerListener public function shouldFieldBeSaved(AbstractDBElement $element, string $field_name): bool { foreach (static::FIELD_BLACKLIST as $class => $blacklist) { - /** @var string $class */ if ($element instanceof $class && in_array($field_name, $blacklist, true)) { return false; } @@ -217,7 +215,6 @@ class EventLoggerListener $mappings = $metadata->getAssociationMappings(); //Check if class is whitelisted for CollectionElementDeleted entry foreach (static::TRIGGER_ASSOCIATION_LOG_WHITELIST as $class => $whitelist) { - /** @var string $class */ if ($entity instanceof $class) { //Check names foreach ($mappings as $field => $mapping) { diff --git a/src/Form/AdminPages/ImportType.php b/src/Form/AdminPages/ImportType.php index 0bd3cea1..3e87812c 100644 --- a/src/Form/AdminPages/ImportType.php +++ b/src/Form/AdminPages/ImportType.php @@ -59,8 +59,6 @@ class ImportType extends AbstractType 'XML' => 'xml', 'CSV' => 'csv', 'YAML' => 'yaml', - 'XLSX' => 'xlsx', - 'XLS' => 'xls', ], 'label' => 'export.format', 'disabled' => $disabled, diff --git a/src/Form/Filters/LogFilterType.php b/src/Form/Filters/LogFilterType.php index c973ad0f..42b367b7 100644 --- a/src/Form/Filters/LogFilterType.php +++ b/src/Form/Filters/LogFilterType.php @@ -100,7 +100,7 @@ class LogFilterType extends AbstractType ]); $builder->add('user', UserEntityConstraintType::class, [ - 'label' => 'log.user', + 'label' => 'log.user', ]); $builder->add('targetType', EnumConstraintType::class, [ @@ -128,13 +128,11 @@ class LogFilterType extends AbstractType LogTargetType::PARAMETER => 'parameter.label', LogTargetType::LABEL_PROFILE => 'label_profile.label', LogTargetType::PART_ASSOCIATION => 'part_association.label', - LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label', - LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label', }, ]); $builder->add('targetId', NumberConstraintType::class, [ - 'label' => 'log.target_id', + 'label' => 'log.target_id', 'min' => 1, 'step' => 1, ]); diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index 871f9b07..dfe449d1 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -22,12 +22,9 @@ declare(strict_types=1); */ namespace App\Form\Filters; -use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint; use App\DataTables\Filters\Constraints\Part\ParameterConstraint; use App\DataTables\Filters\PartFilter; use App\Entity\Attachments\AttachmentType; -use App\Entity\InfoProviderSystem\BulkImportJobStatus; -use App\Entity\InfoProviderSystem\BulkImportPartStatus; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -36,12 +33,8 @@ use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Entity\ProjectSystem\Project; use App\Form\Filters\Constraints\BooleanConstraintType; -use App\Form\Filters\Constraints\BulkImportJobExistsConstraintType; -use App\Form\Filters\Constraints\BulkImportJobStatusConstraintType; -use App\Form\Filters\Constraints\BulkImportPartStatusConstraintType; use App\Form\Filters\Constraints\ChoiceConstraintType; use App\Form\Filters\Constraints\DateTimeConstraintType; -use App\Form\Filters\Constraints\EnumConstraintType; use App\Form\Filters\Constraints\NumberConstraintType; use App\Form\Filters\Constraints\ParameterConstraintType; use App\Form\Filters\Constraints\StructuralEntityConstraintType; @@ -57,8 +50,6 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use function Symfony\Component\Translation\t; - class PartFilterType extends AbstractType { public function __construct(private readonly Security $security) @@ -307,31 +298,6 @@ class PartFilterType extends AbstractType } - /************************************************************************** - * Bulk Import Job tab - **************************************************************************/ - if ($this->security->isGranted('@info_providers.create_parts')) { - $builder - ->add('inBulkImportJob', BooleanConstraintType::class, [ - 'label' => 'part.filter.in_bulk_import_job', - ]) - ->add('bulkImportJobStatus', EnumConstraintType::class, [ - 'enum_class' => BulkImportJobStatus::class, - 'label' => 'part.filter.bulk_import_job_status', - 'choice_label' => function (BulkImportJobStatus $value) { - return t('bulk_import.status.' . $value->value); - }, - ]) - ->add('bulkImportPartStatus', EnumConstraintType::class, [ - 'enum_class' => BulkImportPartStatus::class, - 'label' => 'part.filter.bulk_import_part_status', - 'choice_label' => function (BulkImportPartStatus $value) { - return t('bulk_import.part_status.' . $value->value); - }, - ]) - ; - } - $builder->add('submit', SubmitType::class, [ 'label' => 'filter.submit', diff --git a/src/Form/InfoProviderSystem/BulkProviderSearchType.php b/src/Form/InfoProviderSystem/BulkProviderSearchType.php deleted file mode 100644 index 24a3cfb4..00000000 --- a/src/Form/InfoProviderSystem/BulkProviderSearchType.php +++ /dev/null @@ -1,62 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Form\InfoProviderSystem; - -use App\Entity\Parts\Part; -use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\CollectionType; -use Symfony\Component\Form\Extension\Core\Type\HiddenType; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\OptionsResolver\OptionsResolver; - -class BulkProviderSearchType extends AbstractType -{ - public function buildForm(FormBuilderInterface $builder, array $options): void - { - $parts = $options['parts']; - - $builder->add('part_configurations', CollectionType::class, [ - 'entry_type' => PartProviderConfigurationType::class, - 'entry_options' => [ - 'label' => false, - ], - 'allow_add' => false, - 'allow_delete' => false, - 'label' => false, - ]); - - $builder->add('submit', SubmitType::class, [ - 'label' => 'info_providers.bulk_search.submit' - ]); - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'parts' => [], - ]); - $resolver->setRequired('parts'); - } -} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/FieldToProviderMappingType.php b/src/Form/InfoProviderSystem/FieldToProviderMappingType.php deleted file mode 100644 index 13e9581e..00000000 --- a/src/Form/InfoProviderSystem/FieldToProviderMappingType.php +++ /dev/null @@ -1,75 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Form\InfoProviderSystem; - -use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\IntegerType; -use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\OptionsResolver\OptionsResolver; - -class FieldToProviderMappingType extends AbstractType -{ - public function buildForm(FormBuilderInterface $builder, array $options): void - { - $fieldChoices = $options['field_choices'] ?? []; - - $builder->add('field', ChoiceType::class, [ - 'label' => 'info_providers.bulk_search.search_field', - 'choices' => $fieldChoices, - 'expanded' => false, - 'multiple' => false, - 'required' => false, - 'placeholder' => 'info_providers.bulk_search.field.select', - ]); - - $builder->add('providers', ProviderSelectType::class, [ - 'label' => 'info_providers.bulk_search.providers', - 'help' => 'info_providers.bulk_search.providers.help', - 'required' => false, - ]); - - $builder->add('priority', IntegerType::class, [ - 'label' => 'info_providers.bulk_search.priority', - 'help' => 'info_providers.bulk_search.priority.help', - 'required' => false, - 'data' => 1, // Default priority - 'attr' => [ - 'min' => 1, - 'max' => 10, - 'class' => 'form-control-sm', - 'style' => 'width: 80px;' - ], - 'constraints' => [ - new \Symfony\Component\Validator\Constraints\Range(['min' => 1, 'max' => 10]), - ], - ]); - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'field_choices' => [], - ]); - } -} diff --git a/src/Form/InfoProviderSystem/GlobalFieldMappingType.php b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php deleted file mode 100644 index ea70284f..00000000 --- a/src/Form/InfoProviderSystem/GlobalFieldMappingType.php +++ /dev/null @@ -1,67 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Form\InfoProviderSystem; - -use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\CheckboxType; -use Symfony\Component\Form\Extension\Core\Type\CollectionType; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\OptionsResolver\OptionsResolver; - -class GlobalFieldMappingType extends AbstractType -{ - public function buildForm(FormBuilderInterface $builder, array $options): void - { - $fieldChoices = $options['field_choices'] ?? []; - - $builder->add('field_mappings', CollectionType::class, [ - 'entry_type' => FieldToProviderMappingType::class, - 'entry_options' => [ - 'label' => false, - 'field_choices' => $fieldChoices, - ], - 'allow_add' => true, - 'allow_delete' => true, - 'prototype' => true, - 'label' => false, - ]); - - $builder->add('prefetch_details', CheckboxType::class, [ - 'label' => 'info_providers.bulk_import.prefetch_details', - 'required' => false, - 'help' => 'info_providers.bulk_import.prefetch_details_help', - ]); - - $builder->add('submit', SubmitType::class, [ - 'label' => 'info_providers.bulk_import.search.submit' - ]); - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'field_choices' => [], - ]); - } -} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/PartProviderConfigurationType.php b/src/Form/InfoProviderSystem/PartProviderConfigurationType.php deleted file mode 100644 index cecf62a3..00000000 --- a/src/Form/InfoProviderSystem/PartProviderConfigurationType.php +++ /dev/null @@ -1,55 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Form\InfoProviderSystem; - -use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\ChoiceType; -use Symfony\Component\Form\Extension\Core\Type\HiddenType; -use Symfony\Component\Form\FormBuilderInterface; - -class PartProviderConfigurationType extends AbstractType -{ - public function buildForm(FormBuilderInterface $builder, array $options): void - { - $builder->add('part_id', HiddenType::class); - - $builder->add('search_field', ChoiceType::class, [ - 'label' => 'info_providers.bulk_search.search_field', - 'choices' => [ - 'info_providers.bulk_search.field.mpn' => 'mpn', - 'info_providers.bulk_search.field.name' => 'name', - 'info_providers.bulk_search.field.digikey_spn' => 'digikey_spn', - 'info_providers.bulk_search.field.mouser_spn' => 'mouser_spn', - 'info_providers.bulk_search.field.lcsc_spn' => 'lcsc_spn', - 'info_providers.bulk_search.field.farnell_spn' => 'farnell_spn', - ], - 'expanded' => false, - 'multiple' => false, - ]); - - $builder->add('providers', ProviderSelectType::class, [ - 'label' => 'info_providers.bulk_search.providers', - 'help' => 'info_providers.bulk_search.providers.help', - ]); - } -} \ No newline at end of file diff --git a/src/Services/Attachments/AttachmentSubmitHandler.php b/src/Services/Attachments/AttachmentSubmitHandler.php index 9fbc3fe3..a30163ae 100644 --- a/src/Services/Attachments/AttachmentSubmitHandler.php +++ b/src/Services/Attachments/AttachmentSubmitHandler.php @@ -57,9 +57,6 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; */ class AttachmentSubmitHandler { - /** - * @var array The mapping used to determine which folder will be used for an attachment type - */ protected array $folder_mapping; private ?int $max_upload_size_bytes = null; @@ -163,7 +160,6 @@ class AttachmentSubmitHandler } else { //If not, check for instance of: foreach ($this->folder_mapping as $class => $folder) { - /** @var string $class */ if ($attachment instanceof $class) { $prefix = $folder; break; diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php index 326707b7..14247145 100644 --- a/src/Services/ElementTypeNameGenerator.php +++ b/src/Services/ElementTypeNameGenerator.php @@ -22,13 +22,13 @@ declare(strict_types=1); namespace App\Services; -use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentContainingDBElement; +use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractDBElement; use App\Entity\Contracts\NamedElementInterface; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; +use App\Entity\Parts\PartAssociation; +use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\Category; @@ -36,14 +36,12 @@ use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; -use App\Entity\Parts\PartAssociation; use App\Entity\Parts\PartLot; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Currency; use App\Entity\PriceInformations\Orderdetail; use App\Entity\PriceInformations\Pricedetail; -use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Entity\UserSystem\Group; use App\Entity\UserSystem\User; @@ -81,8 +79,6 @@ class ElementTypeNameGenerator AbstractParameter::class => $this->translator->trans('parameter.label'), LabelProfile::class => $this->translator->trans('label_profile.label'), PartAssociation::class => $this->translator->trans('part_association.label'), - BulkInfoProviderImportJob::class => $this->translator->trans('bulk_info_provider_import_job.label'), - BulkInfoProviderImportJobPart::class => $this->translator->trans('bulk_info_provider_import_job_part.label'), ]; } @@ -134,10 +130,10 @@ class ElementTypeNameGenerator { $type = $this->getLocalizedTypeLabel($entity); if ($use_html) { - return '' . $type . ': ' . htmlspecialchars($entity->getName()); + return ''.$type.': '.htmlspecialchars($entity->getName()); } - return $type . ': ' . $entity->getName(); + return $type.': '.$entity->getName(); } diff --git a/src/Services/EntityMergers/Mergers/PartMerger.php b/src/Services/EntityMergers/Mergers/PartMerger.php index 01b53e25..4ce779e8 100644 --- a/src/Services/EntityMergers/Mergers/PartMerger.php +++ b/src/Services/EntityMergers/Mergers/PartMerger.php @@ -100,8 +100,7 @@ class PartMerger implements EntityMergerInterface return $target; } - private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool - { + private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool { //We compare the translation keys, as it contains info about the type and other type info return $t->getOther() === $o->getOther() && $t->getTypeTranslationKey() === $o->getTypeTranslationKey(); @@ -142,39 +141,40 @@ class PartMerger implements EntityMergerInterface $owner->addAssociatedPartsAsOwner($clone); } - // Merge orderdetails, considering same supplier+part number as duplicates $this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) { - // If supplier and part number match, merge the orderdetails - if ($t->getSupplier() === $o->getSupplier() && $t->getSupplierPartNr() === $o->getSupplierPartNr()) { - // Update URL if target doesn't have one - if (empty($t->getSupplierProductUrl(false)) && !empty($o->getSupplierProductUrl(false))) { - $t->setSupplierProductUrl($o->getSupplierProductUrl(false)); - } - // Merge price details: add new ones, update empty ones, keep existing non-empty ones - foreach ($o->getPricedetails() as $otherPrice) { - $found = false; - foreach ($t->getPricedetails() as $targetPrice) { - if ($targetPrice->getMinDiscountQuantity() === $otherPrice->getMinDiscountQuantity() - && $targetPrice->getCurrency() === $otherPrice->getCurrency()) { - // Only update price if the existing one is zero/empty (most logical) - if ($targetPrice->getPrice()->isZero()) { - $targetPrice->setPrice($otherPrice->getPrice()); - $targetPrice->setPriceRelatedQuantity($otherPrice->getPriceRelatedQuantity()); - } - $found = true; - break; - } - } - // Add completely new price tiers - if (!$found) { - $clonedPrice = clone $otherPrice; - $clonedPrice->setOrderdetail($t); - $t->addPricedetail($clonedPrice); - } - } - return true; // Consider them equal so the other one gets skipped + //First check that the orderdetails infos are equal + $tmp = $t->getSupplier() === $o->getSupplier() + && $t->getSupplierPartNr() === $o->getSupplierPartNr() + && $t->getSupplierProductUrl(false) === $o->getSupplierProductUrl(false); + + if (!$tmp) { + return false; } - return false; // Different supplier/part number, add as new + + //Check if the pricedetails are equal + $t_pricedetails = $t->getPricedetails(); + $o_pricedetails = $o->getPricedetails(); + //Ensure that both pricedetails have the same length + if (count($t_pricedetails) !== count($o_pricedetails)) { + return false; + } + + //Check if all pricedetails are equal + for ($n=0, $nMax = count($t_pricedetails); $n< $nMax; $n++) { + $t_price = $t_pricedetails->get($n); + $o_price = $o_pricedetails->get($n); + + if (!$t_price->getPrice()->isEqualTo($o_price->getPrice()) + || $t_price->getCurrency() !== $o_price->getCurrency() + || $t_price->getPriceRelatedQuantity() !== $o_price->getPriceRelatedQuantity() + || $t_price->getMinDiscountQuantity() !== $o_price->getMinDiscountQuantity() + ) { + return false; + } + } + + //If all pricedetails are equal, the orderdetails are equal + return true; }); //The pricedetails are not correctly assigned to the new orderdetails, so fix that foreach ($target->getOrderdetails() as $orderdetail) { diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php index 70feb8e6..271642da 100644 --- a/src/Services/ImportExportSystem/EntityExporter.php +++ b/src/Services/ImportExportSystem/EntityExporter.php @@ -38,9 +38,6 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\Serializer\SerializerInterface; use function Symfony\Component\String\u; -use PhpOffice\PhpSpreadsheet\Spreadsheet; -use PhpOffice\PhpSpreadsheet\Writer\Xlsx; -use PhpOffice\PhpSpreadsheet\Writer\Xls; /** * Use this class to export an entity to multiple file formats. @@ -55,7 +52,7 @@ class EntityExporter protected function configureOptions(OptionsResolver $resolver): void { $resolver->setDefault('format', 'csv'); - $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']); + $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']); $resolver->setDefault('csv_delimiter', ';'); $resolver->setAllowedTypes('csv_delimiter', 'string'); @@ -91,35 +88,28 @@ class EntityExporter $options = $resolver->resolve($options); - //Handle Excel formats by converting from CSV - if (in_array($options['format'], ['xlsx', 'xls'], true)) { - return $this->exportToExcel($entities, $options); - } - //If include children is set, then we need to add the include_children group $groups = [$options['level']]; if ($options['include_children']) { $groups[] = 'include_children'; } - return $this->serializer->serialize( - $entities, - $options['format'], + return $this->serializer->serialize($entities, $options['format'], [ 'groups' => $groups, 'as_collection' => true, 'csv_delimiter' => $options['csv_delimiter'], 'xml_root_node_name' => 'PartDBExport', 'partdb_export' => true, - //Skip the item normalizer, so that we dont get IRIs in the output + //Skip the item normalizer, so that we dont get IRIs in the output SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true, - //Handle circular references + //Handle circular references AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...), ] ); } - private function handleCircularReference(object $object): string + private function handleCircularReference(object $object, string $format, array $context): string { if ($object instanceof AbstractStructuralDBElement) { return $object->getFullPath("->"); @@ -129,75 +119,7 @@ class EntityExporter return $object->__toString(); } - throw new CircularReferenceException('Circular reference detected for object of type ' . get_class($object)); - } - - /** - * Exports entities to Excel format (xlsx or xls). - * - * @param AbstractNamedDBElement[] $entities The entities to export - * @param array $options The export options - * - * @return string The Excel file content as binary string - */ - protected function exportToExcel(array $entities, array $options): string - { - //First get CSV data using existing serializer - $groups = [$options['level']]; - if ($options['include_children']) { - $groups[] = 'include_children'; - } - - $csvData = $this->serializer->serialize( - $entities, - 'csv', - [ - 'groups' => $groups, - 'as_collection' => true, - 'csv_delimiter' => $options['csv_delimiter'], - 'partdb_export' => true, - SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true, - AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...), - ] - ); - - //Convert CSV to Excel - $spreadsheet = new Spreadsheet(); - $worksheet = $spreadsheet->getActiveSheet(); - - $rows = explode("\n", $csvData); - $rowIndex = 1; - - foreach ($rows as $row) { - if (trim($row) === '') { - continue; - } - - $columns = str_getcsv($row, $options['csv_delimiter'], '"', '\\'); - $colIndex = 1; - - foreach ($columns as $column) { - $cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex; - $worksheet->setCellValue($cellCoordinate, $column); - $colIndex++; - } - $rowIndex++; - } - - //Save to memory stream - $writer = $options['format'] === 'xlsx' ? new Xlsx($spreadsheet) : new Xls($spreadsheet); - - $memFile = fopen("php://temp", 'r+b'); - $writer->save($memFile); - rewind($memFile); - $content = stream_get_contents($memFile); - fclose($memFile); - - if ($content === false) { - throw new \RuntimeException('Failed to read Excel content from memory stream.'); - } - - return $content; + throw new CircularReferenceException('Circular reference detected for object of type '.get_class($object)); } /** @@ -234,15 +156,19 @@ class EntityExporter //Determine the content type for the response + //Plain text should work for all types + $content_type = 'text/plain'; + //Try to use better content types based on the format $format = $options['format']; - $content_type = match ($format) { - 'xml' => 'application/xml', - 'json' => 'application/json', - 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'xls' => 'application/vnd.ms-excel', - default => 'text/plain', - }; + switch ($format) { + case 'xml': + $content_type = 'application/xml'; + break; + case 'json': + $content_type = 'application/json'; + break; + } $response->headers->set('Content-Type', $content_type); //If view option is not specified, then download the file. @@ -260,7 +186,7 @@ class EntityExporter $level = $options['level']; - $filename = "export_{$entity_name}_{$level}.{$format}"; + $filename = 'export_'.$entity_name.'_'.$level.'.'.$format; //Sanitize the filename $filename = FilenameSanatizer::sanitizeFilename($filename); diff --git a/src/Services/ImportExportSystem/EntityImporter.php b/src/Services/ImportExportSystem/EntityImporter.php index 459866ba..11915cfb 100644 --- a/src/Services/ImportExportSystem/EntityImporter.php +++ b/src/Services/ImportExportSystem/EntityImporter.php @@ -38,9 +38,6 @@ use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; -use PhpOffice\PhpSpreadsheet\IOFactory; -use PhpOffice\PhpSpreadsheet\Spreadsheet; -use Psr\Log\LoggerInterface; /** * @see \App\Tests\Services\ImportExportSystem\EntityImporterTest @@ -53,7 +50,7 @@ class EntityImporter */ private const ENCODINGS = ["ASCII", "UTF-8", "ISO-8859-1", "ISO-8859-15", "Windows-1252", "UTF-16", "UTF-32"]; - public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator, protected LoggerInterface $logger) + public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator) { } @@ -105,7 +102,7 @@ class EntityImporter foreach ($names as $name) { //Count indentation level (whitespace characters at the beginning of the line) - $identSize = strlen($name) - strlen(ltrim($name)); + $identSize = strlen($name)-strlen(ltrim($name)); //If the line is intended more than the last line, we have a new parent element if ($identSize > end($indentations)) { @@ -198,20 +195,16 @@ class EntityImporter } //The [] behind class_name denotes that we expect an array. - $entities = $this->serializer->deserialize( - $data, - $options['class'] . '[]', - $options['format'], + $entities = $this->serializer->deserialize($data, $options['class'].'[]', $options['format'], [ 'groups' => $groups, 'csv_delimiter' => $options['csv_delimiter'], 'create_unknown_datastructures' => $options['create_unknown_datastructures'], 'path_delimiter' => $options['path_delimiter'], 'partdb_import' => true, - //Disable API Platform normalizer, as we don't want to use it here + //Disable API Platform normalizer, as we don't want to use it here SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true, - ] - ); + ]); //Ensure we have an array of entity elements. if (!is_array($entities)) { @@ -286,7 +279,7 @@ class EntityImporter 'path_delimiter' => '->', //The delimiter used to separate the path elements in the name of a structural element ]); - $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']); + $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']); $resolver->setAllowedTypes('csv_delimiter', 'string'); $resolver->setAllowedTypes('preserve_children', 'bool'); $resolver->setAllowedTypes('class', 'string'); @@ -342,33 +335,6 @@ class EntityImporter */ public function importFile(File $file, array $options = [], array &$errors = []): array { - $resolver = new OptionsResolver(); - $this->configureOptions($resolver); - $options = $resolver->resolve($options); - - if (in_array($options['format'], ['xlsx', 'xls'], true)) { - $this->logger->info('Converting Excel file to CSV', [ - 'filename' => $file->getFilename(), - 'format' => $options['format'], - 'delimiter' => $options['csv_delimiter'] - ]); - - $csvData = $this->convertExcelToCsv($file, $options['csv_delimiter']); - $options['format'] = 'csv'; - - $this->logger->debug('Excel to CSV conversion completed', [ - 'csv_length' => strlen($csvData), - 'csv_lines' => substr_count($csvData, "\n") + 1 - ]); - - // Log the converted CSV for debugging (first 1000 characters) - $this->logger->debug('Converted CSV preview', [ - 'csv_preview' => substr($csvData, 0, 1000) . (strlen($csvData) > 1000 ? '...' : '') - ]); - - return $this->importString($csvData, $options, $errors); - } - return $this->importString($file->getContent(), $options, $errors); } @@ -388,103 +354,10 @@ class EntityImporter 'xml' => 'xml', 'csv', 'tsv' => 'csv', 'yaml', 'yml' => 'yaml', - 'xlsx' => 'xlsx', - 'xls' => 'xls', default => null, }; } - /** - * Converts Excel file to CSV format using PhpSpreadsheet. - * - * @param File $file The Excel file to convert - * @param string $delimiter The CSV delimiter to use - * - * @return string The CSV data as string - */ - protected function convertExcelToCsv(File $file, string $delimiter = ';'): string - { - try { - $this->logger->debug('Loading Excel file', ['path' => $file->getPathname()]); - $spreadsheet = IOFactory::load($file->getPathname()); - $worksheet = $spreadsheet->getActiveSheet(); - - $csvData = []; - $highestRow = $worksheet->getHighestRow(); - $highestColumn = $worksheet->getHighestColumn(); - - $this->logger->debug('Excel file dimensions', [ - 'rows' => $highestRow, - 'columns_detected' => $highestColumn, - 'worksheet_title' => $worksheet->getTitle() - ]); - - $highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn); - - for ($row = 1; $row <= $highestRow; $row++) { - $rowData = []; - - // Read all columns using numeric index - for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) { - $col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex); - try { - $cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue(); - $rowData[] = $cellValue ?? ''; - - } catch (\Exception $e) { - $this->logger->warning('Error reading cell value', [ - 'cell' => "{$col}{$row}", - 'error' => $e->getMessage() - ]); - $rowData[] = ''; - } - } - - $csvRow = implode($delimiter, array_map(function ($value) use ($delimiter) { - $value = (string) $value; - if (strpos($value, $delimiter) !== false || strpos($value, '"') !== false || strpos($value, "\n") !== false) { - return '"' . str_replace('"', '""', $value) . '"'; - } - return $value; - }, $rowData)); - - $csvData[] = $csvRow; - - // Log first few rows for debugging - if ($row <= 3) { - $this->logger->debug("Row {$row} converted", [ - 'original_data' => $rowData, - 'csv_row' => $csvRow, - 'first_cell_raw' => $worksheet->getCell("A{$row}")->getValue(), - 'first_cell_calculated' => $worksheet->getCell("A{$row}")->getCalculatedValue() - ]); - } - } - - $result = implode("\n", $csvData); - - $this->logger->info('Excel to CSV conversion successful', [ - 'total_rows' => count($csvData), - 'total_characters' => strlen($result) - ]); - - $this->logger->debug('Full CSV data', [ - 'csv_data' => $result - ]); - - return $result; - - } catch (\Exception $e) { - $this->logger->error('Failed to convert Excel to CSV', [ - 'file' => $file->getFilename(), - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() - ]); - throw $e; - } - } - - /** * This functions corrects the parent setting based on the children value of the parent. * diff --git a/src/Services/InfoProviderSystem/BulkInfoProviderService.php b/src/Services/InfoProviderSystem/BulkInfoProviderService.php deleted file mode 100644 index 586fb873..00000000 --- a/src/Services/InfoProviderSystem/BulkInfoProviderService.php +++ /dev/null @@ -1,380 +0,0 @@ - Cache for normalized supplier names */ - private array $supplierCache = []; - - public function __construct( - private readonly PartInfoRetriever $infoRetriever, - private readonly ExistingPartFinder $existingPartFinder, - private readonly ProviderRegistry $providerRegistry, - private readonly EntityManagerInterface $entityManager, - private readonly LoggerInterface $logger - ) {} - - /** - * Perform bulk search across multiple parts and providers. - * - * @param Part[] $parts Array of parts to search for - * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mappings defining search strategy - * @param bool $prefetchDetails Whether to prefetch detailed information for results - * @return BulkSearchResponseDTO Structured response containing all search results - * @throws \InvalidArgumentException If no valid parts provided - * @throws \RuntimeException If no search results found for any parts - */ - public function performBulkSearch(array $parts, array $fieldMappings, bool $prefetchDetails = false): BulkSearchResponseDTO - { - if (empty($parts)) { - throw new \InvalidArgumentException('No valid parts found for bulk import'); - } - - $partResults = []; - $hasAnyResults = false; - - // Group providers by batch capability - $batchProviders = []; - $regularProviders = []; - - foreach ($fieldMappings as $mapping) { - foreach ($mapping->providers as $providerKey) { - if (!is_string($providerKey)) { - $this->logger->error('Invalid provider key type', [ - 'providerKey' => $providerKey, - 'type' => gettype($providerKey) - ]); - continue; - } - - $provider = $this->providerRegistry->getProviderByKey($providerKey); - if ($provider instanceof BatchInfoProviderInterface) { - $batchProviders[$providerKey] = $provider; - } else { - $regularProviders[$providerKey] = $provider; - } - } - } - - // Process batch providers first (more efficient) - $batchResults = $this->processBatchProviders($parts, $fieldMappings, $batchProviders); - - // Process regular providers - $regularResults = $this->processRegularProviders($parts, $fieldMappings, $regularProviders, $batchResults); - - // Combine and format results for each part - foreach ($parts as $part) { - $searchResults = []; - - // Get results from batch and regular processing - $allResults = array_merge( - $batchResults[$part->getId()] ?? [], - $regularResults[$part->getId()] ?? [] - ); - - if (!empty($allResults)) { - $hasAnyResults = true; - $searchResults = $this->formatSearchResults($allResults); - } - - $partResults[] = new BulkSearchPartResultsDTO( - part: $part, - searchResults: $searchResults, - errors: [] - ); - } - - if (!$hasAnyResults) { - throw new \RuntimeException('No search results found for any of the selected parts'); - } - - $response = new BulkSearchResponseDTO($partResults); - - // Prefetch details if requested - if ($prefetchDetails) { - $this->prefetchDetailsForResults($response); - } - - return $response; - } - - /** - * Process parts using batch-capable info providers. - * - * @param Part[] $parts Array of parts to search for - * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations - * @param array $batchProviders Batch providers indexed by key - * @return array Results indexed by part ID - */ - private function processBatchProviders(array $parts, array $fieldMappings, array $batchProviders): array - { - $batchResults = []; - - foreach ($batchProviders as $providerKey => $provider) { - $keywords = $this->collectKeywordsForProvider($parts, $fieldMappings, $providerKey); - - if (empty($keywords)) { - continue; - } - - try { - $providerResults = $provider->searchByKeywordsBatch($keywords); - - // Map results back to parts - foreach ($parts as $part) { - foreach ($fieldMappings as $mapping) { - if (!in_array($providerKey, $mapping->providers, true)) { - continue; - } - - $keyword = $this->getKeywordFromField($part, $mapping->field); - if ($keyword && isset($providerResults[$keyword])) { - foreach ($providerResults[$keyword] as $dto) { - $batchResults[$part->getId()][] = new BulkSearchPartResultDTO( - searchResult: $dto, - sourceField: $mapping->field, - sourceKeyword: $keyword, - localPart: $this->existingPartFinder->findFirstExisting($dto), - priority: $mapping->priority - ); - } - } - } - } - } catch (\Exception $e) { - $this->logger->error('Batch search failed for provider ' . $providerKey, [ - 'error' => $e->getMessage(), - 'provider' => $providerKey - ]); - } - } - - return $batchResults; - } - - /** - * Process parts using regular (non-batch) info providers. - * - * @param Part[] $parts Array of parts to search for - * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations - * @param array $regularProviders Regular providers indexed by key - * @param array $excludeResults Results to exclude (from batch processing) - * @return array Results indexed by part ID - */ - private function processRegularProviders(array $parts, array $fieldMappings, array $regularProviders, array $excludeResults): array - { - $regularResults = []; - - foreach ($parts as $part) { - $regularResults[$part->getId()] = []; - - // Skip if we already have batch results for this part - if (!empty($excludeResults[$part->getId()] ?? [])) { - continue; - } - - foreach ($fieldMappings as $mapping) { - $providers = array_intersect($mapping->providers, array_keys($regularProviders)); - - if (empty($providers)) { - continue; - } - - $keyword = $this->getKeywordFromField($part, $mapping->field); - if (!$keyword) { - continue; - } - - try { - $dtos = $this->infoRetriever->searchByKeyword($keyword, $providers); - - foreach ($dtos as $dto) { - $regularResults[$part->getId()][] = new BulkSearchPartResultDTO( - searchResult: $dto, - sourceField: $mapping->field, - sourceKeyword: $keyword, - localPart: $this->existingPartFinder->findFirstExisting($dto), - priority: $mapping->priority - ); - } - } catch (ClientException $e) { - $this->logger->error('Regular search failed', [ - 'part_id' => $part->getId(), - 'field' => $mapping->field, - 'error' => $e->getMessage() - ]); - } - } - } - - return $regularResults; - } - - /** - * Collect unique keywords for a specific provider from all parts and field mappings. - * - * @param Part[] $parts Array of parts to collect keywords from - * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations - * @param string $providerKey The provider key to collect keywords for - * @return string[] Array of unique keywords - */ - private function collectKeywordsForProvider(array $parts, array $fieldMappings, string $providerKey): array - { - $keywords = []; - - foreach ($parts as $part) { - foreach ($fieldMappings as $mapping) { - if (!in_array($providerKey, $mapping->providers, true)) { - continue; - } - - $keyword = $this->getKeywordFromField($part, $mapping->field); - if ($keyword && !in_array($keyword, $keywords, true)) { - $keywords[] = $keyword; - } - } - } - - return $keywords; - } - - private function getKeywordFromField(Part $part, string $field): ?string - { - return match ($field) { - 'mpn' => $part->getManufacturerProductNumber(), - 'name' => $part->getName(), - default => $this->getSupplierPartNumber($part, $field) - }; - } - - private function getSupplierPartNumber(Part $part, string $field): ?string - { - if (!str_ends_with($field, '_spn')) { - return null; - } - - $supplierKey = substr($field, 0, -4); - $supplier = $this->getSupplierByNormalizedName($supplierKey); - - if (!$supplier) { - return null; - } - - $orderDetail = $part->getOrderdetails()->filter( - fn($od) => $od->getSupplier()?->getId() === $supplier->getId() - )->first(); - - return $orderDetail !== false ? $orderDetail->getSupplierpartnr() : null; - } - - /** - * Get supplier by normalized name with caching to prevent N+1 queries. - * - * @param string $normalizedKey The normalized supplier key to search for - * @return Supplier|null The matching supplier or null if not found - */ - private function getSupplierByNormalizedName(string $normalizedKey): ?Supplier - { - // Check cache first - if (isset($this->supplierCache[$normalizedKey])) { - return $this->supplierCache[$normalizedKey]; - } - - // Use efficient database query with PHP normalization - // Since DQL doesn't support REPLACE, we'll load all suppliers once and cache the normalization - if (empty($this->supplierCache)) { - $this->loadSuppliersIntoCache(); - } - - $supplier = $this->supplierCache[$normalizedKey] ?? null; - - // Cache the result (including null results to prevent repeated queries) - $this->supplierCache[$normalizedKey] = $supplier; - - return $supplier; - } - - /** - * Load all suppliers into cache with normalized names to avoid N+1 queries. - */ - private function loadSuppliersIntoCache(): void - { - /** @var Supplier[] $suppliers */ - $suppliers = $this->entityManager->getRepository(Supplier::class)->findAll(); - - foreach ($suppliers as $supplier) { - $normalizedName = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName())); - $this->supplierCache[$normalizedName] = $supplier; - } - } - - /** - * Format and deduplicate search results. - * - * @param BulkSearchPartResultDTO[] $bulkResults Array of bulk search results - * @return BulkSearchPartResultDTO[] Array of formatted search results with metadata - */ - private function formatSearchResults(array $bulkResults): array - { - // Sort by priority and remove duplicates - usort($bulkResults, fn($a, $b) => $a->priority <=> $b->priority); - - $uniqueResults = []; - $seenKeys = []; - - foreach ($bulkResults as $result) { - $key = "{$result->searchResult->provider_key}|{$result->searchResult->provider_id}"; - if (!in_array($key, $seenKeys, true)) { - $seenKeys[] = $key; - $uniqueResults[] = $result; - } - } - - return $uniqueResults; - } - - /** - * Prefetch detailed information for search results. - * - * @param BulkSearchResponseDTO $searchResults Search results (supports both new DTO and legacy array format) - */ - public function prefetchDetailsForResults(BulkSearchResponseDTO $searchResults): void - { - $prefetchCount = 0; - - // Handle both new DTO format and legacy array format for backwards compatibility - foreach ($searchResults->partResults as $partResult) { - foreach ($partResult->searchResults as $result) { - $dto = $result->searchResult; - - try { - $this->infoRetriever->getDetails($dto->provider_key, $dto->provider_id); - $prefetchCount++; - } catch (\Exception $e) { - $this->logger->warning('Failed to prefetch details for provider part', [ - 'provider_key' => $dto->provider_key, - 'provider_id' => $dto->provider_id, - 'error' => $e->getMessage() - ]); - } - } - } - - $this->logger->info("Prefetched details for {$prefetchCount} search results"); - } -} diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php deleted file mode 100644 index 50b7f4cf..00000000 --- a/src/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTO.php +++ /dev/null @@ -1,91 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Services\InfoProviderSystem\DTOs; - -/** - * Represents a mapping between a part field and the info providers that should search in that field. - */ -readonly class BulkSearchFieldMappingDTO -{ - /** - * @param string $field The field to search in (e.g., 'mpn', 'name', or supplier-specific fields like 'digikey_spn') - * @param string[] $providers Array of provider keys to search with (e.g., ['digikey', 'farnell']) - * @param int $priority Priority for this field mapping (1-10, lower numbers = higher priority) - */ - public function __construct( - public string $field, - public array $providers, - public int $priority = 1 - ) { - if ($priority < 1 || $priority > 10) { - throw new \InvalidArgumentException('Priority must be between 1 and 10'); - } - } - - /** - * Create a FieldMappingDTO from legacy array format. - * @param array{field: string, providers: string[], priority?: int} $data - */ - public static function fromSerializableArray(array $data): self - { - return new self( - field: $data['field'], - providers: $data['providers'] ?? [], - priority: $data['priority'] ?? 1 - ); - } - - /** - * Convert this DTO to the legacy array format for backwards compatibility. - * @return array{field: string, providers: string[], priority: int} - */ - public function toSerializableArray(): array - { - return [ - 'field' => $this->field, - 'providers' => $this->providers, - 'priority' => $this->priority, - ]; - } - - /** - * Check if this field mapping is for a supplier part number field. - */ - public function isSupplierPartNumberField(): bool - { - return str_ends_with($this->field, '_spn'); - } - - /** - * Get the supplier key from a supplier part number field. - * Returns null if this is not a supplier part number field. - */ - public function getSupplierKey(): ?string - { - if (!$this->isSupplierPartNumberField()) { - return null; - } - - return substr($this->field, 0, -4); - } -} diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultDTO.php deleted file mode 100644 index d46624d4..00000000 --- a/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultDTO.php +++ /dev/null @@ -1,44 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Services\InfoProviderSystem\DTOs; - -use App\Entity\Parts\Part; - -/** - * Represents a single search result from bulk search with additional context information, like how the part was found. - */ -readonly class BulkSearchPartResultDTO -{ - public function __construct( - /** The base search result DTO containing provider data */ - public SearchResultDTO $searchResult, - /** The field that was used to find this result */ - public ?string $sourceField = null, - /** The actual keyword that was searched for */ - public ?string $sourceKeyword = null, - /** Local part that matches this search result, if any */ - public ?Part $localPart = null, - /** Priority for this search result */ - public int $priority = 1 - ) {} -} diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTO.php deleted file mode 100644 index 8614f4ec..00000000 --- a/src/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTO.php +++ /dev/null @@ -1,83 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Services\InfoProviderSystem\DTOs; - -use App\Entity\Parts\Part; - -/** - * Represents the search results for a single part from bulk info provider search. - * It contains multiple search results, that match the part. - */ -readonly class BulkSearchPartResultsDTO -{ - /** - * @param Part $part The part that was searched for - * @param BulkSearchPartResultDTO[] $searchResults Array of search results found for this part - * @param string[] $errors Array of error messages encountered during search - */ - public function __construct( - public Part $part, - public array $searchResults = [], - public array $errors = [] - ) {} - - /** - * Check if this part has any search results. - */ - public function hasResults(): bool - { - return !empty($this->searchResults); - } - - /** - * Check if this part has any errors. - */ - public function hasErrors(): bool - { - return !empty($this->errors); - } - - /** - * Get the number of search results for this part. - */ - public function getResultCount(): int - { - return count($this->searchResults); - } - - public function getErrorCount(): int - { - return count($this->errors); - } - - /** - * Get search results sorted by priority (ascending). - * @return BulkSearchPartResultDTO[] - */ - public function getResultsSortedByPriority(): array - { - $results = $this->searchResults; - usort($results, static fn(BulkSearchPartResultDTO $a, BulkSearchPartResultDTO $b) => $a->priority <=> $b->priority); - return $results; - } -} diff --git a/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php b/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php deleted file mode 100644 index 58e9e240..00000000 --- a/src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php +++ /dev/null @@ -1,231 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Services\InfoProviderSystem\DTOs; - -use App\Entity\Parts\Part; -use Doctrine\ORM\EntityManagerInterface; -use Traversable; - -/** - * Represents the complete response from a bulk info provider search operation. - * It contains a list of PartSearchResultDTOs, one for each part searched. - */ -readonly class BulkSearchResponseDTO implements \ArrayAccess, \IteratorAggregate -{ - /** - * @param BulkSearchPartResultsDTO[] $partResults Array of search results for each part - */ - public function __construct( - public array $partResults - ) {} - - /** - * Replaces the search results for a specific part, and returns a new instance. - * The part to replaced, is identified by the part property of the new_results parameter. - * The original instance remains unchanged. - * @param BulkSearchPartResultsDTO $new_results - * @return BulkSearchResponseDTO - */ - public function replaceResultsForPart(BulkSearchPartResultsDTO $new_results): self - { - $array = $this->partResults; - $replaced = false; - foreach ($array as $index => $partResult) { - if ($partResult->part === $new_results->part) { - $array[$index] = $new_results; - $replaced = true; - break; - } - } - - if (!$replaced) { - throw new \InvalidArgumentException("Part not found in existing results."); - } - - return new self($array); - } - - /** - * Check if any parts have search results. - */ - public function hasAnyResults(): bool - { - foreach ($this->partResults as $partResult) { - if ($partResult->hasResults()) { - return true; - } - } - return false; - } - - /** - * Get the total number of search results across all parts. - */ - public function getTotalResultCount(): int - { - $count = 0; - foreach ($this->partResults as $partResult) { - $count += $partResult->getResultCount(); - } - return $count; - } - - /** - * Get all parts that have search results. - * @return BulkSearchPartResultsDTO[] - */ - public function getPartsWithResults(): array - { - return array_filter($this->partResults, fn($result) => $result->hasResults()); - } - - /** - * Get all parts that have errors. - * @return BulkSearchPartResultsDTO[] - */ - public function getPartsWithErrors(): array - { - return array_filter($this->partResults, fn($result) => $result->hasErrors()); - } - - /** - * Get the number of parts processed. - */ - public function getPartCount(): int - { - return count($this->partResults); - } - - /** - * Get the number of parts with successful results. - */ - public function getSuccessfulPartCount(): int - { - return count($this->getPartsWithResults()); - } - - /** - * Merge multiple BulkSearchResponseDTO instances into one. - * @param BulkSearchResponseDTO ...$responses - * @return BulkSearchResponseDTO - */ - public static function merge(BulkSearchResponseDTO ...$responses): BulkSearchResponseDTO - { - $mergedResults = []; - foreach ($responses as $response) { - foreach ($response->partResults as $partResult) { - $mergedResults[] = $partResult; - } - } - return new BulkSearchResponseDTO($mergedResults); - } - - /** - * Convert this DTO to a serializable representation suitable for storage in the database - * @return array - */ - public function toSerializableRepresentation(): array - { - $serialized = []; - - foreach ($this->partResults as $partResult) { - $partData = [ - 'part_id' => $partResult->part->getId(), - 'search_results' => [], - 'errors' => $partResult->errors ?? [] - ]; - - foreach ($partResult->searchResults as $result) { - $partData['search_results'][] = [ - 'dto' => $result->searchResult->toNormalizedSearchResultArray(), - 'source_field' => $result->sourceField ?? null, - 'source_keyword' => $result->sourceKeyword ?? null, - 'localPart' => $result->localPart?->getId(), - 'priority' => $result->priority - ]; - } - - $serialized[] = $partData; - } - - return $serialized; - } - - /** - * Creates a BulkSearchResponseDTO from a serializable representation. - * @param array $data - * @param EntityManagerInterface $entityManager - * @return BulkSearchResponseDTO - * @throws \Doctrine\ORM\Exception\ORMException - */ - public static function fromSerializableRepresentation(array $data, EntityManagerInterface $entityManager): BulkSearchResponseDTO - { - $partResults = []; - foreach ($data as $partData) { - $partResults[] = new BulkSearchPartResultsDTO( - part: $entityManager->getReference(Part::class, $partData['part_id']), - searchResults: array_map(fn($result) => new BulkSearchPartResultDTO( - searchResult: SearchResultDTO::fromNormalizedSearchResultArray($result['dto']), - sourceField: $result['source_field'] ?? null, - sourceKeyword: $result['source_keyword'] ?? null, - localPart: isset($result['localPart']) ? $entityManager->getReference(Part::class, $result['localPart']) : null, - priority: $result['priority'] ?? null - ), $partData['search_results'] ?? []), - errors: $partData['errors'] ?? [] - ); - } - - return new BulkSearchResponseDTO($partResults); - } - - public function offsetExists(mixed $offset): bool - { - if (!is_int($offset)) { - throw new \InvalidArgumentException("Offset must be an integer."); - } - return isset($this->partResults[$offset]); - } - - public function offsetGet(mixed $offset): ?BulkSearchPartResultsDTO - { - if (!is_int($offset)) { - throw new \InvalidArgumentException("Offset must be an integer."); - } - return $this->partResults[$offset] ?? null; - } - - public function offsetSet(mixed $offset, mixed $value): void - { - throw new \LogicException("BulkSearchResponseDTO is immutable."); - } - - public function offsetUnset(mixed $offset): void - { - throw new \LogicException('BulkSearchResponseDTO is immutable.'); - } - - public function getIterator(): Traversable - { - return new \ArrayIterator($this->partResults); - } -} diff --git a/src/Services/InfoProviderSystem/DTOs/FileDTO.php b/src/Services/InfoProviderSystem/DTOs/FileDTO.php index 84eed0c9..0d1db76a 100644 --- a/src/Services/InfoProviderSystem/DTOs/FileDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/FileDTO.php @@ -28,12 +28,12 @@ namespace App\Services\InfoProviderSystem\DTOs; * This could be a datasheet, a 3D model, a picture or similar. * @see \App\Tests\Services\InfoProviderSystem\DTOs\FileDTOTest */ -readonly class FileDTO +class FileDTO { /** * @var string The URL where to get this file */ - public string $url; + public readonly string $url; /** * @param string $url The URL where to get this file @@ -41,7 +41,7 @@ readonly class FileDTO */ public function __construct( string $url, - public ?string $name = null, + public readonly ?string $name = null, ) { //Find all occurrences of non URL safe characters and replace them with their URL encoded version. //We only want to replace characters which can not have a valid meaning in a URL (what would break the URL). @@ -50,4 +50,4 @@ readonly class FileDTO } -} +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php index f5868039..0b54d1a9 100644 --- a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php @@ -28,17 +28,17 @@ namespace App\Services\InfoProviderSystem\DTOs; * This could be a voltage, a current, a temperature or similar. * @see \App\Tests\Services\InfoProviderSystem\DTOs\ParameterDTOTest */ -readonly class ParameterDTO +class ParameterDTO { public function __construct( - public string $name, - public ?string $value_text = null, - public ?float $value_typ = null, - public ?float $value_min = null, - public ?float $value_max = null, - public ?string $unit = null, - public ?string $symbol = null, - public ?string $group = null, + public readonly string $name, + public readonly ?string $value_text = null, + public readonly ?float $value_typ = null, + public readonly ?float $value_min = null, + public readonly ?float $value_max = null, + public readonly ?string $unit = null, + public readonly ?string $symbol = null, + public readonly ?string $group = null, ) { } diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 41d50510..9f365f1e 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -70,4 +70,4 @@ class PartDetailDTO extends SearchResultDTO footprint: $footprint, ); } -} +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php index 2acf3e57..f1eb28f7 100644 --- a/src/Services/InfoProviderSystem/DTOs/PriceDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php @@ -28,21 +28,21 @@ use Brick\Math\BigDecimal; /** * This DTO represents a price for a single unit in a certain discount range */ -readonly class PriceDTO +class PriceDTO { - private BigDecimal $price_as_big_decimal; + private readonly BigDecimal $price_as_big_decimal; public function __construct( /** @var float The minimum amount that needs to get ordered for this price to be valid */ - public float $minimum_discount_amount, + public readonly float $minimum_discount_amount, /** @var string The price as string (with .) */ - public string $price, + public readonly string $price, /** @var string The currency of the used ISO code of this price detail */ - public ?string $currency_iso_code, + public readonly ?string $currency_iso_code, /** @var bool If the price includes tax */ - public ?bool $includes_tax = true, + public readonly ?bool $includes_tax = true, /** @var float the price related quantity */ - public ?float $price_related_quantity = 1.0, + public readonly ?float $price_related_quantity = 1.0, ) { $this->price_as_big_decimal = BigDecimal::of($this->price); diff --git a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php index 9ac142ff..bcd8be43 100644 --- a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php @@ -27,15 +27,15 @@ namespace App\Services\InfoProviderSystem\DTOs; * This DTO represents a purchase information for a part (supplier name, order number and prices). * @see \App\Tests\Services\InfoProviderSystem\DTOs\PurchaseInfoDTOTest */ -readonly class PurchaseInfoDTO +class PurchaseInfoDTO { public function __construct( - public string $distributor_name, - public string $order_number, + public readonly string $distributor_name, + public readonly string $order_number, /** @var PriceDTO[] */ - public array $prices, + public readonly array $prices, /** @var string|null An url to the product page of the vendor */ - public ?string $product_url = null, + public readonly ?string $product_url = null, ) { //Ensure that the prices are PriceDTO instances @@ -45,4 +45,4 @@ readonly class PurchaseInfoDTO } } } -} +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index a70b2486..28943702 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -59,8 +59,8 @@ class SearchResultDTO public readonly ?string $provider_url = null, /** @var string|null A footprint representation of the providers page */ public readonly ?string $footprint = null, - ) - { + ) { + if ($preview_image_url !== null) { //Utilize the escaping mechanism of FileDTO to ensure that the preview image URL is correctly encoded //See issue #521: https://github.com/Part-DB/Part-DB-server/issues/521 @@ -71,47 +71,4 @@ class SearchResultDTO $this->preview_image_url = null; } } - - /** - * This method creates a normalized array representation of the DTO. - * @return array - */ - public function toNormalizedSearchResultArray(): array - { - return [ - 'provider_key' => $this->provider_key, - 'provider_id' => $this->provider_id, - 'name' => $this->name, - 'description' => $this->description, - 'category' => $this->category, - 'manufacturer' => $this->manufacturer, - 'mpn' => $this->mpn, - 'preview_image_url' => $this->preview_image_url, - 'manufacturing_status' => $this->manufacturing_status?->value, - 'provider_url' => $this->provider_url, - 'footprint' => $this->footprint, - ]; - } - - /** - * Creates a SearchResultDTO from a normalized array representation. - * @param array $data - * @return self - */ - public static function fromNormalizedSearchResultArray(array $data): self - { - return new self( - provider_key: $data['provider_key'], - provider_id: $data['provider_id'], - name: $data['name'], - description: $data['description'], - category: $data['category'] ?? null, - manufacturer: $data['manufacturer'] ?? null, - mpn: $data['mpn'] ?? null, - preview_image_url: $data['preview_image_url'] ?? null, - manufacturing_status: isset($data['manufacturing_status']) ? ManufacturingStatus::tryFrom($data['manufacturing_status']) : null, - provider_url: $data['provider_url'] ?? null, - footprint: $data['footprint'] ?? null, - ); - } -} +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php deleted file mode 100644 index 549f117a..00000000 --- a/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php +++ /dev/null @@ -1,40 +0,0 @@ -. - */ - -declare(strict_types=1); - - -namespace App\Services\InfoProviderSystem\Providers; - -use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; - -/** - * This interface marks a provider as a info provider which can provide information directly in batch operations - */ -interface BatchInfoProviderInterface extends InfoProviderInterface -{ - /** - * Search for multiple keywords in a single batch operation and return the results, ordered by the keywords. - * This allows for a more efficient search compared to running multiple single searches. - * @param string[] $keywords - * @return array An associative array where the key is the keyword and the value is the search results for that keyword - */ - public function searchByKeywordsBatch(array $keywords): array; -} diff --git a/src/Services/InfoProviderSystem/Providers/EmptyProvider.php b/src/Services/InfoProviderSystem/Providers/EmptyProvider.php deleted file mode 100644 index e0de9772..00000000 --- a/src/Services/InfoProviderSystem/Providers/EmptyProvider.php +++ /dev/null @@ -1,76 +0,0 @@ -. - */ - -declare(strict_types=1); - - -namespace App\Services\InfoProviderSystem\Providers; - -use App\Services\InfoProviderSystem\DTOs\FileDTO; -use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; -use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; -use Symfony\Component\DependencyInjection\Attribute\When; - -/** - * This is a provider, which is used during tests. It always returns no results. - */ -#[When(env: 'test')] -class EmptyProvider implements InfoProviderInterface -{ - public function getProviderInfo(): array - { - return [ - 'name' => 'Empty Provider', - 'description' => 'This is a test provider', - //'url' => 'https://example.com', - 'disabled_help' => 'This provider is disabled for testing purposes' - ]; - } - - public function getProviderKey(): string - { - return 'empty'; - } - - public function isActive(): bool - { - return true; - } - - public function searchByKeyword(string $keyword): array - { - return [ - - ]; - } - - public function getCapabilities(): array - { - return [ - ProviderCapabilities::BASIC, - ProviderCapabilities::FOOTPRINT, - ]; - } - - public function getDetails(string $id): PartDetailDTO - { - throw new \RuntimeException('No part details available'); - } -} diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php index ede34eb8..2d83fc7c 100755 --- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php +++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php @@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\LCSCSettings; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Contracts\HttpClient\HttpClientInterface; -class LCSCProvider implements BatchInfoProviderInterface +class LCSCProvider implements InfoProviderInterface { private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm'; @@ -69,10 +69,9 @@ class LCSCProvider implements BatchInfoProviderInterface /** * @param string $id - * @param bool $lightweight If true, skip expensive operations like datasheet resolution * @return PartDetailDTO */ - private function queryDetail(string $id, bool $lightweight = false): PartDetailDTO + private function queryDetail(string $id): PartDetailDTO { $response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [ 'headers' => [ @@ -90,7 +89,7 @@ class LCSCProvider implements BatchInfoProviderInterface throw new \RuntimeException('Could not find product code: ' . $id); } - return $this->getPartDetail($product, $lightweight); + return $this->getPartDetail($product); } /** @@ -100,42 +99,30 @@ class LCSCProvider implements BatchInfoProviderInterface private function getRealDatasheetUrl(?string $url): string { if ($url !== null && trim($url) !== '' && preg_match("/^https:\/\/(datasheet\.lcsc\.com|www\.lcsc\.com\/datasheet)\/.*(C\d+)\.pdf$/", $url, $matches) > 0) { - if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) { - $url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1]; - } - $response = $this->lcscClient->request('GET', $url, [ - 'headers' => [ - 'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html' - ], - ]); - if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) { - //HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused - //See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934 - $jsonObj = json_decode('{"' . $matches[1] . '": ' . $matches[2] . '}'); - $url = $jsonObj->previewPdfUrl; - } + if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) { + $url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1]; + } + $response = $this->lcscClient->request('GET', $url, [ + 'headers' => [ + 'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html' + ], + ]); + if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) { + //HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused + //See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934 + $jsonObj = json_decode('{"' . $matches[1] . '": ' . $matches[2] . '}'); + $url = $jsonObj->previewPdfUrl; + } } return $url; } /** * @param string $term - * @param bool $lightweight If true, skip expensive operations like datasheet resolution * @return PartDetailDTO[] */ - private function queryByTerm(string $term, bool $lightweight = false): array + private function queryByTerm(string $term): array { - // Optimize: If term looks like an LCSC part number (starts with C followed by digits), - // use direct detail query instead of slower search - if (preg_match('/^C\d+$/i', trim($term))) { - try { - return [$this->queryDetail(trim($term), $lightweight)]; - } catch (\Exception $e) { - // If direct lookup fails, fall back to search - // This handles cases where the C-code might not exist - } - } - $response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [ 'headers' => [ 'Cookie' => new Cookie('currencyCode', $this->settings->currency) @@ -158,11 +145,11 @@ class LCSCProvider implements BatchInfoProviderInterface // detailed product listing. It does so utilizing a product tip field. // If product tip exists and there are no products in the product list try a detail query if (count($products) === 0 && $tipProductCode !== null) { - $result[] = $this->queryDetail($tipProductCode, $lightweight); + $result[] = $this->queryDetail($tipProductCode); } foreach ($products as $product) { - $result[] = $this->getPartDetail($product, $lightweight); + $result[] = $this->getPartDetail($product); } return $result; @@ -191,7 +178,7 @@ class LCSCProvider implements BatchInfoProviderInterface * @param array $product * @return PartDetailDTO */ - private function getPartDetail(array $product, bool $lightweight = false): PartDetailDTO + private function getPartDetail(array $product): PartDetailDTO { // Get product images in advance $product_images = $this->getProductImages($product['productImages'] ?? null); @@ -227,10 +214,10 @@ class LCSCProvider implements BatchInfoProviderInterface manufacturing_status: null, provider_url: $this->getProductShortURL($product['productCode']), footprint: $this->sanitizeField($footprint), - datasheets: $lightweight ? [] : $this->getProductDatasheets($product['pdfUrl'] ?? null), - images: $product_images, // Always include images - users need to see them - parameters: $lightweight ? [] : $this->attributesToParameters($product['paramVOList'] ?? []), - vendor_infos: $lightweight ? [] : $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []), + datasheets: $this->getProductDatasheets($product['pdfUrl'] ?? null), + images: $product_images, + parameters: $this->attributesToParameters($product['paramVOList'] ?? []), + vendor_infos: $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []), mass: $product['weight'] ?? null, ); } @@ -299,7 +286,7 @@ class LCSCProvider implements BatchInfoProviderInterface */ private function getProductShortURL(string $product_code): string { - return 'https://www.lcsc.com/product-detail/' . $product_code . '.html'; + return 'https://www.lcsc.com/product-detail/' . $product_code .'.html'; } /** @@ -340,7 +327,7 @@ class LCSCProvider implements BatchInfoProviderInterface //Skip this attribute if it's empty if (in_array(trim((string) $attribute['paramValueEn']), ['', '-'], true)) { - continue; + continue; } $result[] = ParameterDTO::parseValueIncludingUnit(name: $attribute['paramNameEn'], value: $attribute['paramValueEn'], group: null); @@ -351,86 +338,12 @@ class LCSCProvider implements BatchInfoProviderInterface public function searchByKeyword(string $keyword): array { - return $this->queryByTerm($keyword, true); // Use lightweight mode for search - } - - /** - * Batch search multiple keywords asynchronously (like JavaScript Promise.all) - * @param array $keywords Array of keywords to search - * @return array Results indexed by keyword - */ - public function searchByKeywordsBatch(array $keywords): array - { - if (empty($keywords)) { - return []; - } - - $responses = []; - $results = []; - - // Start all requests immediately (like JavaScript promises without await) - foreach ($keywords as $keyword) { - if (preg_match('/^C\d+$/i', trim($keyword))) { - // Direct detail API call for C-codes - $responses[$keyword] = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [ - 'headers' => [ - 'Cookie' => new Cookie('currencyCode', $this->settings->currency) - ], - 'query' => [ - 'productCode' => trim($keyword), - ], - ]); - } else { - // Search API call for other terms - $responses[$keyword] = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [ - 'headers' => [ - 'Cookie' => new Cookie('currencyCode', $this->settings->currency) - ], - 'json' => [ - 'keyword' => $keyword, - ], - ]); - } - } - - // Now collect all results (like .then() in JavaScript) - foreach ($responses as $keyword => $response) { - try { - $arr = $response->toArray(); // This waits for the response - $results[$keyword] = $this->processSearchResponse($arr, $keyword); - } catch (\Exception $e) { - $results[$keyword] = []; // Empty results on error - } - } - - return $results; - } - - private function processSearchResponse(array $arr, string $keyword): array - { - $result = []; - - // Check if this looks like a detail response (direct C-code lookup) - if (isset($arr['result']['productCode'])) { - $product = $arr['result']; - $result[] = $this->getPartDetail($product, true); // lightweight mode - } else { - // This is a search response - $products = $arr['result']['productSearchResultVO']['productList'] ?? []; - $tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null; - - // If no products but has tip, we'd need another API call - skip for batch mode - foreach ($products as $product) { - $result[] = $this->getPartDetail($product, true); // lightweight mode - } - } - - return $result; + return $this->queryByTerm($keyword); } public function getDetails(string $id): PartDetailDTO { - $tmp = $this->queryByTerm($id, false); + $tmp = $this->queryByTerm($id); if (count($tmp) === 0) { throw new \RuntimeException('No part found with ID ' . $id); } diff --git a/src/Services/InfoProviderSystem/Providers/MouserProvider.php b/src/Services/InfoProviderSystem/Providers/MouserProvider.php index 3171c994..6639e5c1 100644 --- a/src/Services/InfoProviderSystem/Providers/MouserProvider.php +++ b/src/Services/InfoProviderSystem/Providers/MouserProvider.php @@ -132,15 +132,6 @@ class MouserProvider implements InfoProviderInterface ], ]); - // Check for API errors before processing response - if ($response->getStatusCode() !== 200) { - throw new \RuntimeException(sprintf( - 'Mouser API returned HTTP %d: %s', - $response->getStatusCode(), - $response->getContent(false) - )); - } - return $this->responseToDTOArray($response); } @@ -178,16 +169,6 @@ class MouserProvider implements InfoProviderInterface ] ], ]); - - // Check for API errors before processing response - if ($response->getStatusCode() !== 200) { - throw new \RuntimeException(sprintf( - 'Mouser API returned HTTP %d: %s', - $response->getStatusCode(), - $response->getContent(false) - )); - } - $tmp = $this->responseToDTOArray($response); //Ensure that we have exactly one result @@ -305,17 +286,6 @@ class MouserProvider implements InfoProviderInterface return (float)$val; } - private function mapCurrencyCode(string $currency): string - { - //Mouser uses "RMB" for Chinese Yuan, but the correct ISO code is "CNY" - if ($currency === "RMB") { - return "CNY"; - } - - //For all other currencies, we assume that the ISO code is correct - return $currency; - } - /** * Converts the pricing (StandardPricing field) from the Mouser API to an array of PurchaseInfoDTOs * @param array $price_breaks @@ -332,7 +302,7 @@ class MouserProvider implements InfoProviderInterface $prices[] = new PriceDTO( minimum_discount_amount: $price_break['Quantity'], price: (string)$number, - currency_iso_code: $this->mapCurrencyCode($price_break['Currency']) + currency_iso_code: $price_break['Currency'] ); } diff --git a/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php b/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php index 3df7d227..7ceb30dd 100644 --- a/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php +++ b/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php @@ -95,11 +95,6 @@ final class BarcodeContentGenerator return $prefix.$id; } - /** - * @param array $map - * @param object $target - * @return string - */ private function classToString(array $map, object $target): string { $class = $target::class; diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 945cff7b..616df229 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -30,11 +30,13 @@ use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; +use App\Repository\PartRepository; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Contracts\Translation\TranslatableInterface; use function Symfony\Component\Translation\t; @@ -98,7 +100,7 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart //When action starts with "export_" we have to redirect to the export controller $matches = []; - if (preg_match('/^export_(json|yaml|xml|csv|xlsx)$/', $action, $matches)) { + if (preg_match('/^export_(json|yaml|xml|csv)$/', $action, $matches)) { $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); $level = match ($target_id) { 2 => 'extended', @@ -117,16 +119,6 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart ); } - if ($action === 'bulk_info_provider_import') { - $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); - return new RedirectResponse( - $this->urlGenerator->generate('bulk_info_provider_step1', [ - 'ids' => $ids, - '_redirect' => $redirect_url - ]) - ); - } - //Iterate over the parts and apply the action to it: foreach ($selected_parts as $part) { diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 036797f6..f7a9d1c4 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -138,11 +138,6 @@ class ToolsTreeBuilder $this->translator->trans('info_providers.search.title'), $this->urlGenerator->generate('info_providers_search') ))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down'); - - $nodes[] = (new TreeViewNode( - $this->translator->trans('info_providers.bulk_import.manage_jobs'), - $this->urlGenerator->generate('bulk_info_provider_manage') - ))->setIcon('fa-treeview fa-fw fa-solid fa-tasks'); } return $nodes; diff --git a/src/Twig/Sandbox/InheritanceSecurityPolicy.php b/src/Twig/Sandbox/InheritanceSecurityPolicy.php index 06ab3a1f..93e874e9 100644 --- a/src/Twig/Sandbox/InheritanceSecurityPolicy.php +++ b/src/Twig/Sandbox/InheritanceSecurityPolicy.php @@ -34,14 +34,9 @@ use function is_array; */ final class InheritanceSecurityPolicy implements SecurityPolicyInterface { - /** - * @var array - */ private array $allowedMethods; - public function __construct(private array $allowedTags = [], private array $allowedFilters = [], array $allowedMethods = [], - /** @var array */ - private array $allowedProperties = [], private array $allowedFunctions = []) + public function __construct(private array $allowedTags = [], private array $allowedFilters = [], array $allowedMethods = [], private array $allowedProperties = [], private array $allowedFunctions = []) { $this->setAllowedMethods($allowedMethods); } diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index d7873498..009f815e 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -30,6 +30,8 @@
+ {# #} +
@@ -39,7 +41,7 @@ diff --git a/templates/info_providers/bulk_import/manage.html.twig b/templates/info_providers/bulk_import/manage.html.twig deleted file mode 100644 index 9bbed906..00000000 --- a/templates/info_providers/bulk_import/manage.html.twig +++ /dev/null @@ -1,124 +0,0 @@ -{% extends "main_card.html.twig" %} - -{% block title %} - {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %} -{% endblock %} - -{% block card_title %} - {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %} -{% endblock %} - -{% block card_content %} - -
- -
-

- {% trans %}info_providers.bulk_import.manage_jobs_description{% endtrans %} -

-
- - {% 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 %} - - {% endif %} - -
- -{% endblock %} diff --git a/templates/info_providers/bulk_import/step1.html.twig b/templates/info_providers/bulk_import/step1.html.twig deleted file mode 100644 index bb9bb351..00000000 --- a/templates/info_providers/bulk_import/step1.html.twig +++ /dev/null @@ -1,304 +0,0 @@ -{% extends "main_card.html.twig" %} - -{% import "info_providers/providers.macro.html.twig" as providers_macro %} -{% import "helper.twig" as helper %} - -{% block title %} - {% trans %}info_providers.bulk_import.step1.title{% endtrans %} -{% endblock %} - -{% block card_title %} - {% trans %}info_providers.bulk_import.step1.title{% endtrans %} - {{ parts|length }} {% trans %}info_providers.bulk_import.parts_selected{% endtrans %} -{% endblock %} - -{% block card_content %} - -
- - - {% if existing_jobs is not empty %} -
-
-
{% trans %}info_providers.bulk_import.existing_jobs{% endtrans %}
-
-
-
- - - - - - - - - - - - - - {% for job in existing_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_at{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}{{ job.partCount }}{{ job.resultCount }} -
-
-
-
-
- {{ job.progressPercentage }}% -
- {{ job.completedPartsCount }}/{{ job.partCount }} -
- {% 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.isFailed %} - {% trans %}info_providers.bulk_import.status.failed{% endtrans %} - {% endif %} - {{ job.createdAt|date('Y-m-d H:i') }} - {% if job.isInProgress or job.isCompleted %} - - {% trans %}info_providers.bulk_import.view_results{% endtrans %} - - {% endif %} -
-
-
-
- {% endif %} - - - - - - - - -
-
-
{% trans %}info_providers.bulk_import.selected_parts{% endtrans %}
-
- -
- - {{ form_start(form) }} - -
-
-
{% trans %}info_providers.bulk_import.field_mappings{% endtrans %}
- {% trans %}info_providers.bulk_import.field_mappings_help{% endtrans %} -
-
- - - - - - - - - - - {% for mapping in form.field_mappings %} - - - - - - - {% endfor %} - -
{% trans %}info_providers.bulk_search.search_field{% endtrans %}{% trans %}info_providers.bulk_search.providers{% endtrans %}{% trans %}info_providers.bulk_search.priority{% endtrans %}{% trans %}info_providers.bulk_import.actions.label{% endtrans %}
{{ form_widget(mapping.field) }}{{ form_errors(mapping.field) }}{{ form_widget(mapping.providers) }}{{ form_errors(mapping.providers) }}{{ form_widget(mapping.priority) }}{{ form_errors(mapping.priority) }} - -
- -
-
- -
- - -
- {{ form_widget(form.prefetch_details, {'attr': {'class': 'form-check-input'}}) }} - {{ form_label(form.prefetch_details, null, {'label_attr': {'class': 'form-check-label'}}) }} - {{ form_help(form.prefetch_details) }} -
- - {{ form_widget(form.submit, {'attr': {'class': 'btn btn-primary', 'data-field-mapping-target': 'submitButton'}}) }} -
- - {{ form_end(form) }} - - {% if search_results is not null %} -
-

{% trans %}info_providers.bulk_import.search_results.title{% endtrans %}

- - {% for part_result in search_results %} - {% set part = part_result.part %} -
-
-
- {{ part.name }} - {% if part_result.errors is not empty %} - {{ part_result.errors|length }} {% trans %}info_providers.bulk_import.errors{% endtrans %} - {% endif %} - {{ part_result.search_results|length }} {% trans %}info_providers.bulk_import.results_found{% endtrans %} -
-
-
- {% if part_result.errors is not empty %} - {% for error in part_result.errors %} - - {% endfor %} - {% endif %} - - {% if part_result.search_results|length > 0 %} -
- - - - - - - - - - - - - - {% for result in part_result.search_results %} - {% set dto = result.dto %} - {% set localPart = result.localPart %} - - - - - - - - - - {% endfor %} - -
{% trans %}name.label{% endtrans %}{% trans %}description.label{% endtrans %}{% trans %}manufacturer.label{% endtrans %}{% trans %}info_providers.table.provider.label{% endtrans %}{% trans %}info_providers.bulk_import.source_field{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
- - - {% if dto.provider_url is not null %} - {{ dto.name }} - {% else %} - {{ dto.name }} - {% endif %} - {% if dto.mpn is not null %} -
{{ dto.mpn }} - {% endif %} -
{{ dto.description }}{{ dto.manufacturer ?? '' }} - {{ info_provider_label(dto.provider_key)|default(dto.provider_key) }} -
{{ dto.provider_id }} -
- {{ result.source_field ?? 'unknown' }} - {% if result.source_keyword %} -
{{ result.source_keyword }} - {% endif %} -
-
- {% set updateHref = path('info_providers_update_part', - {'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %} - - {% trans %}info_providers.bulk_import.update_part{% endtrans %} - - - {% if localPart is not null %} - - {% trans %}info_providers.bulk_import.view_existing{% endtrans %} - - {% endif %} -
-
-
- {% else %} - - {% endif %} -
-
- {% endfor %} - {% endif %} - -
- -{% endblock %} - diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig deleted file mode 100644 index 559ca20a..00000000 --- a/templates/info_providers/bulk_import/step2.html.twig +++ /dev/null @@ -1,240 +0,0 @@ -{% extends "main_card.html.twig" %} - -{% import "info_providers/providers.macro.html.twig" as providers_macro %} -{% import "helper.twig" as helper %} - -{% block title %} - {% trans %}info_providers.bulk_import.step2.title{% endtrans %} -{% endblock %} - -{% block card_title %} - {% trans %}info_providers.bulk_import.step2.title{% endtrans %} - {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} -{% endblock %} - -{% block card_content %} - -
-
-
-
{{ 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 %} β€’ - {% trans %}info_providers.bulk_import.created_at{% endtrans %}: {{ job.createdAt|date('Y-m-d H:i') }} - -
-
- {% 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.isFailed %} - {% trans %}info_providers.bulk_import.status.failed{% endtrans %} - {% endif %} -
-
- - -
-
-
-
Progress
- {{ job.completedPartsCount }} / {{ job.partCount }} completed -
-
-
-
-
-
- - {{ job.completedPartsCount }} {% trans %}info_providers.bulk_import.completed{% endtrans %} β€’ - {{ job.skippedPartsCount }} {% trans %}info_providers.bulk_import.skipped{% endtrans %} - - {{ job.progressPercentage }}% -
-
-
- - - - - -
-
-
-
-
{% trans %}info_providers.bulk_import.research.title{% endtrans %}
- {% trans %}info_providers.bulk_import.research.description{% endtrans %} -
-
- -
-
-
-
- - {% for part_result in search_results %} - {# @var part_result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO #} - - {% set part = part_result.part %} - {% set isCompleted = job.isPartCompleted(part.id) %} - {% set isSkipped = job.isPartSkipped(part.id) %} -
-
-
-
- - {{ part.name }} - - {% if isCompleted %} - - {% trans %}info_providers.bulk_import.completed{% endtrans %} - - {% elseif isSkipped %} - - {% trans %}info_providers.bulk_import.skipped{% endtrans %} - - {% endif %} - {% if part_result.errors is not empty %} - {% trans with {'%count%': part_result.errors|length} %}info_providers.bulk_import.errors{% endtrans %} - {% endif %} - {% trans with {'%count%': part_result.searchResults|length} %}info_providers.bulk_import.results_found{% endtrans %} -
-
-
- - {% if not isCompleted and not isSkipped %} - - - {% elseif isCompleted %} - - {% elseif isSkipped %} - - {% endif %} -
-
-
- {% if part_result.errors is not empty %} - {% for error in part_result.errors %} - - {% endfor %} - {% endif %} - - {% if part_result.searchResults|length > 0 %} -
- - - - - - - - - - - - - - {% for result in part_result.searchResults %} - {# @var result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO #} - {% set dto = result.searchResult %} - {% set localPart = result.localPart %} - - - - - - - - - - {% endfor %} - -
{% trans %}name.label{% endtrans %}{% trans %}description.label{% endtrans %}{% trans %}manufacturer.label{% endtrans %}{% trans %}info_providers.table.provider.label{% endtrans %}{% trans %}info_providers.bulk_import.source_field{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
- - - {% if dto.provider_url is not null %} - {{ dto.name }} - {% else %} - {{ dto.name }} - {% endif %} - {% if dto.mpn is not null %} -
{{ dto.mpn }} - {% endif %} -
{{ dto.description }}{{ dto.manufacturer ?? '' }} - {{ info_provider_label(dto.provider_key)|default(dto.provider_key) }} -
{{ dto.provider_id }} -
- {{ result.sourceField ?? 'unknown' }} - {% if result.sourceKeyword %} -
{{ result.sourceKeyword }} - {% endif %} -
-
- {% set updateHref = path('info_providers_update_part', - {'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %} - - {% trans %}info_providers.bulk_import.update_part{% endtrans %} - -
-
-
- {% else %} - - {% endif %} -
-
- {% endfor %} - -
-{% endblock %} - diff --git a/templates/parts/edit/edit_part_info.html.twig b/templates/parts/edit/edit_part_info.html.twig index 28a88132..20cddbd7 100644 --- a/templates/parts/edit/edit_part_info.html.twig +++ b/templates/parts/edit/edit_part_info.html.twig @@ -4,32 +4,6 @@ {% trans with {'%name%': part.name|escape } %}part.edit.title{% endtrans %} {% endblock %} -{% block before_card %} - {% if bulk_job and jobId %} -
-
-
- - - {% trans %}info_providers.bulk_import.back{% endtrans %} - -
- - -
-
- - {% trans %}info_providers.bulk_import.editing_part{% endtrans %} -
-
-
-
- {% endif %} -{% endblock %} - {% block card_title %} {% trans with {'%name%': part.name|escape } %}part.edit.card_title{% endtrans %} diff --git a/templates/parts/edit/update_from_ip.html.twig b/templates/parts/edit/update_from_ip.html.twig index 1ab2ca59..fb1dfad3 100644 --- a/templates/parts/edit/update_from_ip.html.twig +++ b/templates/parts/edit/update_from_ip.html.twig @@ -5,19 +5,6 @@ {% block card_border %}border-info{% endblock %} {% block card_type %}bg-info text-bg-info{% endblock %} -{% block before_card %} - {% if bulk_job and jobId %} -
-
-
- - {% trans %}info_providers.bulk_import.editing_part{% endtrans %} -
-
-
- {% endif %} -{% endblock %} - {% block title %} {% trans %}info_providers.update_part.title{% endtrans %}: {{ merge_old_name }} {% endblock %} diff --git a/templates/parts/lists/_filter.html.twig b/templates/parts/lists/_filter.html.twig index ba9168d1..c29e8ecd 100644 --- a/templates/parts/lists/_filter.html.twig +++ b/templates/parts/lists/_filter.html.twig @@ -31,11 +31,6 @@ {% endif %} - {% if filterForm.inBulkImportJob is defined %} - - {% endif %} {{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }} @@ -131,13 +126,6 @@ {{ form_row(filterForm.bomComment) }}
{% endif %} - {% if filterForm.inBulkImportJob is defined %} -
- {{ form_row(filterForm.inBulkImportJob) }} - {{ form_row(filterForm.bulkImportJobStatus) }} - {{ form_row(filterForm.bulkImportPartStatus) }} -
- {% endif %}
diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php deleted file mode 100644 index e71a5fa2..00000000 --- a/tests/Controller/BulkInfoProviderImportControllerTest.php +++ /dev/null @@ -1,889 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Tests\Controller; - -use App\Entity\InfoProviderSystem\BulkImportJobStatus; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; -use App\Entity\Parts\Part; -use App\Entity\UserSystem\User; -use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO; -use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; -use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; -use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; -use Symfony\Component\HttpFoundation\Response; - -/** - * @group slow - * @group DB - */ -class BulkInfoProviderImportControllerTest extends WebTestCase -{ - public function testStep1WithoutIds(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $client->request('GET', '/tools/bulk_info_provider_import/step1'); - - self::assertResponseRedirects(); - } - - public function testStep1WithInvalidIds(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $client->request('GET', '/tools/bulk_info_provider_import/step1?ids=999999,888888'); - - self::assertResponseRedirects(); - } - - public function testManagePage(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $client->request('GET', '/tools/bulk_info_provider_import/manage'); - - // Follow any redirects (like locale redirects) - if ($client->getResponse()->isRedirect()) { - $client->followRedirect(); - } - - self::assertResponseStatusCodeSame(Response::HTTP_OK); - } - - public function testAccessControlForStep1(): void - { - $client = static::createClient(); - - $client->request('GET', '/tools/bulk_info_provider_import/step1?ids=1'); - self::assertResponseRedirects(); - - $this->loginAsUser($client, 'noread'); - $client->request('GET', '/tools/bulk_info_provider_import/step1?ids=1'); - - // Follow redirects if any, then check for 403 or final response - if ($client->getResponse()->isRedirect()) { - $client->followRedirect(); - } - - // The user might get redirected to an error page instead of direct 403 - $this->assertTrue( - $client->getResponse()->getStatusCode() === Response::HTTP_FORBIDDEN || - $client->getResponse()->getStatusCode() === Response::HTTP_OK - ); - } - - public function testAccessControlForManage(): void - { - $client = static::createClient(); - - $client->request('GET', '/tools/bulk_info_provider_import/manage'); - self::assertResponseRedirects(); - - $this->loginAsUser($client, 'noread'); - $client->request('GET', '/tools/bulk_info_provider_import/manage'); - - // Follow redirects if any, then check for 403 or final response - if ($client->getResponse()->isRedirect()) { - $client->followRedirect(); - } - - // The user might get redirected to an error page instead of direct 403 - $this->assertTrue( - $client->getResponse()->getStatusCode() === Response::HTTP_FORBIDDEN || - $client->getResponse()->getStatusCode() === Response::HTTP_OK - ); - } - - public function testStep2TemplateRendering(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = static::getContainer()->get('doctrine')->getManager(); - - // Use an existing part from test fixtures (ID 1 should exist) - $partRepository = $entityManager->getRepository(Part::class); - $part = $partRepository->find(1); - - if (!$part) { - $this->markTestSkipped('Test part with ID 1 not found in fixtures'); - } - - // Get the admin user for the createdBy field - $userRepository = $entityManager->getRepository(User::class); - $user = $userRepository->findOneBy(['name' => 'admin']); - - if (!$user) { - $this->markTestSkipped('Admin user not found in fixtures'); - } - - // Create a test job with search results that include source_field and source_keyword - $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 component description', manufacturer: 'Test Manufacturer', mpn: 'TEST-MPN-123', provider_url: 'https://example.com/test', preview_image_url: null,), - sourceField: 'test_field', - sourceKeyword: 'test_keyword', - localPart: null, - )] - ) - ]); - - $job->setSearchResults($searchResults); - - $entityManager->persist($job); - $entityManager->flush(); - - // Test that step2 renders correctly with the search results - $client->request('GET', '/tools/bulk_info_provider_import/step2/' . $job->getId()); - - // Follow any redirects (like locale redirects) - if ($client->getResponse()->isRedirect()) { - $client->followRedirect(); - } - - self::assertResponseStatusCodeSame(Response::HTTP_OK); - - // Verify the template rendered the source_field and source_keyword correctly - $content = $client->getResponse()->getContent(); - $this->assertStringContainsString('test_field', $content); - $this->assertStringContainsString('test_keyword', $content); - - // Clean up - find by ID to avoid detached entity issues - $jobId = $job->getId(); - $entityManager->clear(); // Clear all entities - $jobToRemove = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); - if ($jobToRemove) { - $entityManager->remove($jobToRemove); - $entityManager->flush(); - } - } - - public function testStep1WithValidIds(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - $client->request('GET', '/tools/bulk_info_provider_import/step1?ids=' . $part->getId()); - - if ($client->getResponse()->isRedirect()) { - $client->followRedirect(); - } - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - } - - - public function testDeleteJobWithValidJob(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = self::getContainer()->get('doctrine')->getManager(); - $userRepository = $entityManager->getRepository(User::class); - $user = $userRepository->findOneBy(['name' => 'admin']); - - if (!$user) { - $this->markTestSkipped('Admin user not found in fixtures'); - } - - // Get a test part - $partRepository = $entityManager->getRepository(Part::class); - $part = $partRepository->find(1); - - if (!$part) { - $this->markTestSkipped('Test part with ID 1 not found in fixtures'); - } - - // Create a completed job - $job = new BulkInfoProviderImportJob(); - $job->setCreatedBy($user); - $job->addPart($part); - $job->setStatus(BulkImportJobStatus::COMPLETED); - $job->setSearchResults(new BulkSearchResponseDTO([])); - - $entityManager->persist($job); - $entityManager->flush(); - - $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/delete'); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $response = json_decode($client->getResponse()->getContent(), true); - $this->assertTrue($response['success']); - } - - public function testDeleteJobWithNonExistentJob(): 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); - $response = json_decode($client->getResponse()->getContent(), true); - $this->assertArrayHasKey('error', $response); - } - - public function testDeleteJobWithActiveJob(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = self::getContainer()->get('doctrine')->getManager(); - $userRepository = $entityManager->getRepository(User::class); - $user = $userRepository->findOneBy(['name' => 'admin']); - - if (!$user) { - $this->markTestSkipped('Admin user not found in fixtures'); - } - - // Get test parts - $parts = $this->getTestParts($entityManager, [1]); - - // Create an active job - $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('DELETE', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/delete'); - - $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); - $response = json_decode($client->getResponse()->getContent(), true); - $this->assertArrayHasKey('error', $response); - - // Clean up - $entityManager->remove($job); - $entityManager->flush(); - } - - public function testStopJobWithValidJob(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = self::getContainer()->get('doctrine')->getManager(); - $userRepository = $entityManager->getRepository(User::class); - $user = $userRepository->findOneBy(['name' => 'admin']); - - if (!$user) { - $this->markTestSkipped('Admin user not found in fixtures'); - } - - // Get test parts - $parts = $this->getTestParts($entityManager, [1]); - - // Create an active job - $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() . '/stop'); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $response = json_decode($client->getResponse()->getContent(), true); - $this->assertTrue($response['success']); - - // Clean up - $entityManager->remove($job); - $entityManager->flush(); - } - - public function testStopJobWithNonExistentJob(): 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); - $response = json_decode($client->getResponse()->getContent(), true); - $this->assertArrayHasKey('error', $response); - } - - public function testMarkPartCompleted(): 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'); - } - - // Get test parts - $parts = $this->getTestParts($entityManager, [1, 2]); - - $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/1/mark-completed'); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $response = json_decode($client->getResponse()->getContent(), true); - $this->assertTrue($response['success']); - $this->assertArrayHasKey('progress', $response); - $this->assertArrayHasKey('completed_count', $response); - - // Clean up - $entityManager->remove($job); - $entityManager->flush(); - } - - public function testMarkPartSkipped(): 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'); - } - - // Get test parts - $parts = $this->getTestParts($entityManager, [1, 2]); - - $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/1/mark-skipped', [ - 'reason' => 'Test skip reason' - ]); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $response = json_decode($client->getResponse()->getContent(), true); - $this->assertTrue($response['success']); - $this->assertArrayHasKey('skipped_count', $response); - - // Clean up - $entityManager->remove($job); - $entityManager->flush(); - } - - public function testMarkPartPending(): 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'); - } - - // Get test parts - $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/1/mark-pending'); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $response = json_decode($client->getResponse()->getContent(), true); - $this->assertTrue($response['success']); - - // Clean up - $entityManager->remove($job); - $entityManager->flush(); - } - - public function testStep2WithNonExistentJob(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $client->request('GET', '/tools/bulk_info_provider_import/step2/999999'); - - $this->assertResponseRedirects(); - } - - public function testStep2WithUnauthorizedAccess(): 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'); - } - - // Get test parts - $parts = $this->getTestParts($entityManager, [1]); - - // Create job as admin - $job = new BulkInfoProviderImportJob(); - $job->setCreatedBy($admin); - foreach ($parts as $part) { - $job->addPart($part); - } - $job->setStatus(BulkImportJobStatus::IN_PROGRESS); - $job->setSearchResults(new BulkSearchResponseDTO([])); - - $entityManager->persist($job); - $entityManager->flush(); - - // Try to access as readonly user - $this->loginAsUser($client, 'noread'); - $client->request('GET', '/tools/bulk_info_provider_import/step2/' . $job->getId()); - - $this->assertResponseRedirects(); - - // Clean up - $entityManager->remove($job); - $entityManager->flush(); - } - - public function testJobAccessControlForDelete(): 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'); - } - - // Get test parts - $parts = $this->getTestParts($entityManager, [1]); - - // Create job as readonly user - $job = new BulkInfoProviderImportJob(); - $job->setCreatedBy($readonly); - foreach ($parts as $part) { - $job->addPart($part); - } - $job->setStatus(BulkImportJobStatus::COMPLETED); - $job->setSearchResults(new BulkSearchResponseDTO([])); - - $entityManager->persist($job); - $entityManager->flush(); - - // Try to delete as admin (should fail due to ownership) - $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/delete'); - - $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); - - // Clean up - $entityManager->remove($job); - $entityManager->flush(); - } - - private function loginAsUser($client, string $username): void - { - $entityManager = $client->getContainer()->get('doctrine')->getManager(); - $userRepository = $entityManager->getRepository(User::class); - $user = $userRepository->findOneBy(['name' => $username]); - - if (!$user) { - $this->markTestSkipped("User {$username} not found"); - } - - $client->loginUser($user); - } - - private function getTestParts($entityManager, array $ids): array - { - $partRepository = $entityManager->getRepository(Part::class); - $parts = []; - - foreach ($ids as $id) { - $part = $partRepository->find($id); - if (!$part) { - $this->markTestSkipped("Test part with ID {$id} not found in fixtures"); - } - $parts[] = $part; - } - - return $parts; - } - - public function testStep1Form(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - $client->request('GET', '/tools/bulk_info_provider_import/step1?ids=' . $part->getId()); - - if ($client->getResponse()->isRedirect()) { - $client->followRedirect(); - } - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $this->assertStringContainsString('Bulk Info Provider Import', $client->getResponse()->getContent()); - } - - public function testStep1FormSubmissionWithErrors(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - $client->request('GET', '/tools/bulk_info_provider_import/step1?ids=' . $part->getId()); - - if ($client->getResponse()->isRedirect()) { - $client->followRedirect(); - } - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $this->assertStringContainsString('Bulk Info Provider Import', $client->getResponse()->getContent()); - } - - public function testBulkInfoProviderServiceKeywordExtraction(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - // Test that the service can extract keywords from parts - $bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class); - - // Create field mappings to verify the service works - $fieldMappings = [ - new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('name', ['test'], 1), - new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('mpn', ['test'], 2) - ]; - - // The service may return an empty result or throw when no results are found - try { - $result = $bulkService->performBulkSearch([$part], $fieldMappings, false); - $this->assertInstanceOf(\App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO::class, $result); - } catch (\RuntimeException $e) { - $this->assertStringContainsString('No search results found', $e->getMessage()); - } - } - - public function testManagePageWithJobCleanup(): 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'); - } - - $partRepository = $entityManager->getRepository(Part::class); - $part = $partRepository->find(1); - - if (!$part) { - $this->markTestSkipped('Test part with ID 1 not found in fixtures'); - } - - $job = new BulkInfoProviderImportJob(); - $job->setCreatedBy($user); - $job->addPart($part); - $job->setStatus(BulkImportJobStatus::IN_PROGRESS); - $job->setSearchResults(new BulkSearchResponseDTO([])); - - $entityManager->persist($job); - $entityManager->flush(); - - $client->request('GET', '/tools/bulk_info_provider_import/manage'); - - if ($client->getResponse()->isRedirect()) { - $client->followRedirect(); - } - - self::assertResponseStatusCodeSame(Response::HTTP_OK); - - // Find job from database to avoid detached entity errors - $jobId = $job->getId(); - $entityManager->clear(); - $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); - if ($persistedJob) { - $entityManager->remove($persistedJob); - $entityManager->flush(); - } - } - - public function testBulkInfoProviderServiceSupplierPartNumberExtraction(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - // Test that the service can handle supplier part number fields - $bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class); - - // Create field mappings with supplier SPN field mapping - $fieldMappings = [ - new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('invalid_field', ['test'], 1), - new \App\Services\InfoProviderSystem\DTOs\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()); - } - } - - public function testBulkInfoProviderServiceBatchProcessing(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - // Test that the service can handle batch processing - $bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class); - - // Create field mappings with multiple keywords - $fieldMappings = [ - new \App\Services\InfoProviderSystem\DTOs\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()); - } - } - - public function testBulkInfoProviderServicePrefetchDetails(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - // Test that the service can handle prefetch details - $bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class); - - // Create empty search results to test prefetch method - $searchResults = new BulkSearchResponseDTO([ - new BulkSearchPartResultsDTO(part: $part, searchResults: [], errors: []) - ]); - - // The prefetch method should not throw any errors - $bulkService->prefetchDetailsForResults($searchResults); - - // If we get here, the method executed successfully - $this->assertTrue(true); - } - - public function testJobAccessControlForStopAndMarkOperations(): 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]); - - $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(); - - $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/stop'); - $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); - - $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/mark-completed'); - $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); - - $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/mark-skipped', [ - 'reason' => 'Test reason' - ]); - $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); - - $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/mark-pending'); - $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); - - // Find job from database to avoid detached entity errors - $jobId = $job->getId(); - $entityManager->clear(); - $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); - if ($persistedJob) { - $entityManager->remove($persistedJob); - $entityManager->flush(); - } - } - - public function testOperationsOnCompletedJob(): 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::COMPLETED); - $job->setSearchResults(new BulkSearchResponseDTO([])); - - $entityManager->persist($job); - $entityManager->flush(); - - $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/stop'); - $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); - $response = json_decode($client->getResponse()->getContent(), true); - $this->assertArrayHasKey('error', $response); - - $entityManager->remove($job); - $entityManager->flush(); - } -} diff --git a/tests/Controller/PartControllerTest.php b/tests/Controller/PartControllerTest.php deleted file mode 100644 index c47c62f8..00000000 --- a/tests/Controller/PartControllerTest.php +++ /dev/null @@ -1,334 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Tests\Controller; - -use App\Entity\InfoProviderSystem\BulkImportJobStatus; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; -use App\Entity\Parts\Category; -use App\Entity\Parts\Footprint; -use App\Entity\Parts\Manufacturer; -use App\Entity\Parts\Part; -use App\Entity\Parts\StorageLocation; -use App\Entity\Parts\Supplier; -use App\Entity\UserSystem\User; -use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; -use Symfony\Component\HttpFoundation\Response; - -/** - * @group slow - * @group DB - */ -class PartControllerTest extends WebTestCase -{ - public function testShowPart(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - $client->request('GET', '/en/part/' . $part->getId()); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - } - - public function testShowPartWithTimestamp(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - $timestamp = time(); - $client->request('GET', "/en/part/{$part->getId()}/info/{$timestamp}"); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - } - - public function testEditPart(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - $client->request('GET', '/en/part/' . $part->getId() . '/edit'); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $this->assertSelectorExists('form[name="part_base"]'); - } - - public function testEditPartWithBulkJob(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->getContainer()->get('doctrine')->getManager(); - $partRepository = $entityManager->getRepository(Part::class); - $part = $partRepository->find(1); - - $userRepository = $entityManager->getRepository(User::class); - $user = $userRepository->findOneBy(['name' => 'admin']); - - if (!$part || !$user) { - $this->markTestSkipped('Required test data not found in fixtures'); - } - - // Create a bulk job - $job = new BulkInfoProviderImportJob(); - $job->setCreatedBy($user); - $job->setPartIds([$part->getId()]); - $job->setStatus(BulkImportJobStatus::IN_PROGRESS); - $job->setSearchResults(new BulkSearchResponseDTO([])); - - $entityManager->persist($job); - $entityManager->flush(); - - $client->request('GET', '/en/part/' . $part->getId() . '/edit?jobId=' . $job->getId()); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - - // Clean up - $entityManager->remove($job); - $entityManager->flush(); - } - - - - public function testNewPart(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $client->request('GET', '/en/part/new'); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $this->assertSelectorExists('form[name="part_base"]'); - } - - public function testNewPartWithCategory(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->getContainer()->get('doctrine')->getManager(); - $categoryRepository = $entityManager->getRepository(Category::class); - $category = $categoryRepository->find(1); - - if (!$category) { - $this->markTestSkipped('Test category with ID 1 not found in fixtures'); - } - - $client->request('GET', '/en/part/new?category=' . $category->getId()); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - } - - public function testNewPartWithFootprint(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->getContainer()->get('doctrine')->getManager(); - $footprintRepository = $entityManager->getRepository(Footprint::class); - $footprint = $footprintRepository->find(1); - - if (!$footprint) { - $this->markTestSkipped('Test footprint with ID 1 not found in fixtures'); - } - - $client->request('GET', '/en/part/new?footprint=' . $footprint->getId()); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - } - - public function testNewPartWithManufacturer(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->getContainer()->get('doctrine')->getManager(); - $manufacturerRepository = $entityManager->getRepository(Manufacturer::class); - $manufacturer = $manufacturerRepository->find(1); - - if (!$manufacturer) { - $this->markTestSkipped('Test manufacturer with ID 1 not found in fixtures'); - } - - $client->request('GET', '/en/part/new?manufacturer=' . $manufacturer->getId()); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - } - - public function testNewPartWithStorageLocation(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->getContainer()->get('doctrine')->getManager(); - $storageLocationRepository = $entityManager->getRepository(StorageLocation::class); - $storageLocation = $storageLocationRepository->find(1); - - if (!$storageLocation) { - $this->markTestSkipped('Test storage location with ID 1 not found in fixtures'); - } - - $client->request('GET', '/en/part/new?storelocation=' . $storageLocation->getId()); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - } - - public function testNewPartWithSupplier(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->getContainer()->get('doctrine')->getManager(); - $supplierRepository = $entityManager->getRepository(Supplier::class); - $supplier = $supplierRepository->find(1); - - if (!$supplier) { - $this->markTestSkipped('Test supplier with ID 1 not found in fixtures'); - } - - $client->request('GET', '/en/part/new?supplier=' . $supplier->getId()); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - } - - public function testClonePart(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->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'); - } - - $client->request('GET', '/en/part/' . $part->getId() . '/clone'); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $this->assertSelectorExists('form[name="part_base"]'); - } - - public function testMergeParts(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'admin'); - - $entityManager = $client->getContainer()->get('doctrine')->getManager(); - $categoryRepository = $entityManager->getRepository(Category::class); - $category = $categoryRepository->find(1); - - if (!$category) { - $this->markTestSkipped('Test category with ID 1 not found in fixtures'); - } - - // Create two test parts - $targetPart = new Part(); - $targetPart->setName('Target Part'); - $targetPart->setCategory($category); - - $otherPart = new Part(); - $otherPart->setName('Other Part'); - $otherPart->setCategory($category); - - $entityManager->persist($targetPart); - $entityManager->persist($otherPart); - $entityManager->flush(); - - $client->request('GET', "/en/part/{$targetPart->getId()}/merge/{$otherPart->getId()}"); - - $this->assertResponseStatusCodeSame(Response::HTTP_OK); - $this->assertSelectorExists('form[name="part_base"]'); - - // Clean up - $entityManager->remove($targetPart); - $entityManager->remove($otherPart); - $entityManager->flush(); - } - - - - - - public function testAccessControlForUnauthorizedUser(): void - { - $client = static::createClient(); - $this->loginAsUser($client, 'noread'); - - $entityManager = $client->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'); - } - - $client->request('GET', '/en/part/' . $part->getId()); - - // Should either be forbidden or redirected to error page - $this->assertTrue( - $client->getResponse()->getStatusCode() === Response::HTTP_FORBIDDEN || - $client->getResponse()->isRedirect() - ); - } - - private function loginAsUser($client, string $username): void - { - $entityManager = $client->getContainer()->get('doctrine')->getManager(); - $userRepository = $entityManager->getRepository(User::class); - $user = $userRepository->findOneBy(['name' => $username]); - - if (!$user) { - $this->markTestSkipped("User {$username} not found"); - } - - $client->loginUser($user); - } - -} diff --git a/tests/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraintTest.php b/tests/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraintTest.php deleted file mode 100644 index 816a8035..00000000 --- a/tests/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraintTest.php +++ /dev/null @@ -1,250 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Tests\DataTables\Filters\Constraints\Part; - -use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\QueryBuilder; -use PHPUnit\Framework\TestCase; - -class BulkImportJobStatusConstraintTest extends TestCase -{ - private BulkImportJobStatusConstraint $constraint; - private QueryBuilder $queryBuilder; - private EntityManagerInterface $entityManager; - - protected function setUp(): void - { - $this->constraint = new BulkImportJobStatusConstraint(); - $this->entityManager = $this->createMock(EntityManagerInterface::class); - $this->queryBuilder = $this->createMock(QueryBuilder::class); - - $this->queryBuilder->method('getEntityManager') - ->willReturn($this->entityManager); - } - - public function testConstructor(): void - { - $this->assertEquals([], $this->constraint->getValue()); - $this->assertEmpty($this->constraint->getOperator()); - $this->assertFalse($this->constraint->isEnabled()); - } - - public function testGetAndSetValues(): void - { - $values = ['pending', 'in_progress']; - $this->constraint->setValue($values); - - $this->assertEquals($values, $this->constraint->getValue()); - } - - public function testGetAndSetOperator(): void - { - $operator = 'ANY'; - $this->constraint->setOperator($operator); - - $this->assertEquals($operator, $this->constraint->getOperator()); - } - - public function testIsEnabledWithEmptyValues(): void - { - $this->constraint->setOperator('ANY'); - - $this->assertFalse($this->constraint->isEnabled()); - } - - public function testIsEnabledWithNullOperator(): void - { - $this->constraint->setValue(['pending']); - - $this->assertFalse($this->constraint->isEnabled()); - } - - public function testIsEnabledWithValuesAndOperator(): void - { - $this->constraint->setValue(['pending']); - $this->constraint->setOperator('ANY'); - - $this->assertTrue($this->constraint->isEnabled()); - } - - public function testApplyWithEmptyValues(): void - { - $this->constraint->setOperator('ANY'); - - $this->queryBuilder->expects($this->never()) - ->method('andWhere'); - - $this->constraint->apply($this->queryBuilder); - } - - public function testApplyWithNullOperator(): void - { - $this->constraint->setValue(['pending']); - - $this->queryBuilder->expects($this->never()) - ->method('andWhere'); - - $this->constraint->apply($this->queryBuilder); - } - - public function testApplyWithAnyOperator(): void - { - $this->constraint->setValue(['pending', 'in_progress']); - $this->constraint->setOperator('ANY'); - - $subQueryBuilder = $this->createMock(QueryBuilder::class); - $subQueryBuilder->method('select')->willReturnSelf(); - $subQueryBuilder->method('from')->willReturnSelf(); - $subQueryBuilder->method('join')->willReturnSelf(); - $subQueryBuilder->method('where')->willReturnSelf(); - $subQueryBuilder->method('andWhere')->willReturnSelf(); - $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL'); - - $this->entityManager->method('createQueryBuilder') - ->willReturn($subQueryBuilder); - - $this->queryBuilder->expects($this->once()) - ->method('andWhere') - ->with('EXISTS (EXISTS_SUBQUERY_DQL)'); - - $this->queryBuilder->expects($this->once()) - ->method('setParameter') - ->with('job_status_values', ['pending', 'in_progress']); - - $this->constraint->apply($this->queryBuilder); - } - - public function testApplyWithNoneOperator(): void - { - $this->constraint->setValue(['completed']); - $this->constraint->setOperator('NONE'); - - $subQueryBuilder = $this->createMock(QueryBuilder::class); - $subQueryBuilder->method('select')->willReturnSelf(); - $subQueryBuilder->method('from')->willReturnSelf(); - $subQueryBuilder->method('join')->willReturnSelf(); - $subQueryBuilder->method('where')->willReturnSelf(); - $subQueryBuilder->method('andWhere')->willReturnSelf(); - $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL'); - - $this->entityManager->method('createQueryBuilder') - ->willReturn($subQueryBuilder); - - $this->queryBuilder->expects($this->once()) - ->method('andWhere') - ->with('NOT EXISTS (EXISTS_SUBQUERY_DQL)'); - - $this->queryBuilder->expects($this->once()) - ->method('setParameter') - ->with('job_status_values', ['completed']); - - $this->constraint->apply($this->queryBuilder); - } - - public function testApplyWithUnsupportedOperator(): void - { - $this->constraint->setValue(['pending']); - $this->constraint->setOperator('UNKNOWN'); - - $subQueryBuilder = $this->createMock(QueryBuilder::class); - $subQueryBuilder->method('select')->willReturnSelf(); - $subQueryBuilder->method('from')->willReturnSelf(); - $subQueryBuilder->method('join')->willReturnSelf(); - $subQueryBuilder->method('where')->willReturnSelf(); - $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL'); - - $this->entityManager->method('createQueryBuilder') - ->willReturn($subQueryBuilder); - - // Should not call andWhere for unsupported operator - $this->queryBuilder->expects($this->never()) - ->method('andWhere'); - - $this->constraint->apply($this->queryBuilder); - } - - public function testSubqueryStructure(): void - { - $this->constraint->setValue(['pending']); - $this->constraint->setOperator('ANY'); - - $subQueryBuilder = $this->createMock(QueryBuilder::class); - - $subQueryBuilder->expects($this->once()) - ->method('select') - ->with('1') - ->willReturnSelf(); - - $subQueryBuilder->expects($this->once()) - ->method('from') - ->with(BulkInfoProviderImportJobPart::class, 'bip_status') - ->willReturnSelf(); - - $subQueryBuilder->expects($this->once()) - ->method('join') - ->with('bip_status.job', 'job_status') - ->willReturnSelf(); - - $subQueryBuilder->expects($this->once()) - ->method('where') - ->with('bip_status.part = part.id') - ->willReturnSelf(); - - $subQueryBuilder->expects($this->once()) - ->method('andWhere') - ->with('job_status.status IN (:job_status_values)') - ->willReturnSelf(); - - $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL'); - - $this->entityManager->method('createQueryBuilder') - ->willReturn($subQueryBuilder); - - $this->queryBuilder->method('andWhere'); - $this->queryBuilder->method('setParameter'); - - $this->constraint->apply($this->queryBuilder); - } - - public function testValuesAndOperatorMutation(): void - { - // Test that values and operator can be changed after creation - $this->constraint->setValue(['pending']); - $this->constraint->setOperator('ANY'); - $this->assertTrue($this->constraint->isEnabled()); - - $this->constraint->setValue([]); - $this->assertFalse($this->constraint->isEnabled()); - - $this->constraint->setValue(['completed']); - $this->assertTrue($this->constraint->isEnabled()); - - $this->constraint->setOperator(''); - $this->assertFalse($this->constraint->isEnabled()); - - $this->constraint->setOperator('NONE'); - $this->assertTrue($this->constraint->isEnabled()); - } -} diff --git a/tests/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraintTest.php b/tests/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraintTest.php deleted file mode 100644 index bc110eda..00000000 --- a/tests/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraintTest.php +++ /dev/null @@ -1,299 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Tests\DataTables\Filters\Constraints\Part; - -use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\QueryBuilder; -use PHPUnit\Framework\TestCase; - -class BulkImportPartStatusConstraintTest extends TestCase -{ - private BulkImportPartStatusConstraint $constraint; - private QueryBuilder $queryBuilder; - private EntityManagerInterface $entityManager; - - protected function setUp(): void - { - $this->constraint = new BulkImportPartStatusConstraint(); - $this->entityManager = $this->createMock(EntityManagerInterface::class); - $this->queryBuilder = $this->createMock(QueryBuilder::class); - - $this->queryBuilder->method('getEntityManager') - ->willReturn($this->entityManager); - } - - public function testConstructor(): void - { - $this->assertEquals([], $this->constraint->getValue()); - $this->assertEmpty($this->constraint->getOperator()); - $this->assertFalse($this->constraint->isEnabled()); - } - - public function testGetAndSetValues(): void - { - $values = ['pending', 'completed', 'skipped']; - $this->constraint->setValue($values); - - $this->assertEquals($values, $this->constraint->getValue()); - } - - public function testGetAndSetOperator(): void - { - $operator = 'ANY'; - $this->constraint->setOperator($operator); - - $this->assertEquals($operator, $this->constraint->getOperator()); - } - - public function testIsEnabledWithEmptyValues(): void - { - $this->constraint->setOperator('ANY'); - - $this->assertFalse($this->constraint->isEnabled()); - } - - public function testIsEnabledWithNullOperator(): void - { - $this->constraint->setValue(['pending']); - - $this->assertFalse($this->constraint->isEnabled()); - } - - public function testIsEnabledWithValuesAndOperator(): void - { - $this->constraint->setValue(['pending']); - $this->constraint->setOperator('ANY'); - - $this->assertTrue($this->constraint->isEnabled()); - } - - public function testApplyWithEmptyValues(): void - { - $this->constraint->setOperator('ANY'); - - $this->queryBuilder->expects($this->never()) - ->method('andWhere'); - - $this->constraint->apply($this->queryBuilder); - } - - public function testApplyWithNullOperator(): void - { - $this->constraint->setValue(['pending']); - - $this->queryBuilder->expects($this->never()) - ->method('andWhere'); - - $this->constraint->apply($this->queryBuilder); - } - - public function testApplyWithAnyOperator(): void - { - $this->constraint->setValue(['pending', 'completed']); - $this->constraint->setOperator('ANY'); - - $subQueryBuilder = $this->createMock(QueryBuilder::class); - $subQueryBuilder->method('select')->willReturnSelf(); - $subQueryBuilder->method('from')->willReturnSelf(); - $subQueryBuilder->method('where')->willReturnSelf(); - $subQueryBuilder->method('andWhere')->willReturnSelf(); - $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL'); - - $this->entityManager->method('createQueryBuilder') - ->willReturn($subQueryBuilder); - - $this->queryBuilder->expects($this->once()) - ->method('andWhere') - ->with('EXISTS (EXISTS_SUBQUERY_DQL)'); - - $this->queryBuilder->expects($this->once()) - ->method('setParameter') - ->with('part_status_values', ['pending', 'completed']); - - $this->constraint->apply($this->queryBuilder); - } - - public function testApplyWithNoneOperator(): void - { - $this->constraint->setValue(['failed']); - $this->constraint->setOperator('NONE'); - - $subQueryBuilder = $this->createMock(QueryBuilder::class); - $subQueryBuilder->method('select')->willReturnSelf(); - $subQueryBuilder->method('from')->willReturnSelf(); - $subQueryBuilder->method('where')->willReturnSelf(); - $subQueryBuilder->method('andWhere')->willReturnSelf(); - $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL'); - - $this->entityManager->method('createQueryBuilder') - ->willReturn($subQueryBuilder); - - $this->queryBuilder->expects($this->once()) - ->method('andWhere') - ->with('NOT EXISTS (EXISTS_SUBQUERY_DQL)'); - - $this->queryBuilder->expects($this->once()) - ->method('setParameter') - ->with('part_status_values', ['failed']); - - $this->constraint->apply($this->queryBuilder); - } - - public function testApplyWithUnsupportedOperator(): void - { - $this->constraint->setValue(['pending']); - $this->constraint->setOperator('UNKNOWN'); - - $subQueryBuilder = $this->createMock(QueryBuilder::class); - $subQueryBuilder->method('select')->willReturnSelf(); - $subQueryBuilder->method('from')->willReturnSelf(); - $subQueryBuilder->method('where')->willReturnSelf(); - $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL'); - - $this->entityManager->method('createQueryBuilder') - ->willReturn($subQueryBuilder); - - // Should not call andWhere for unsupported operator - $this->queryBuilder->expects($this->never()) - ->method('andWhere'); - - $this->constraint->apply($this->queryBuilder); - } - - public function testSubqueryStructure(): void - { - $this->constraint->setValue(['completed', 'skipped']); - $this->constraint->setOperator('ANY'); - - $subQueryBuilder = $this->createMock(QueryBuilder::class); - - $subQueryBuilder->expects($this->once()) - ->method('select') - ->with('1') - ->willReturnSelf(); - - $subQueryBuilder->expects($this->once()) - ->method('from') - ->with(BulkInfoProviderImportJobPart::class, 'bip_part_status') - ->willReturnSelf(); - - $subQueryBuilder->expects($this->once()) - ->method('where') - ->with('bip_part_status.part = part.id') - ->willReturnSelf(); - - $subQueryBuilder->expects($this->once()) - ->method('andWhere') - ->with('bip_part_status.status IN (:part_status_values)') - ->willReturnSelf(); - - $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL'); - - $this->entityManager->method('createQueryBuilder') - ->willReturn($subQueryBuilder); - - $this->queryBuilder->method('andWhere'); - $this->queryBuilder->method('setParameter'); - - $this->constraint->apply($this->queryBuilder); - } - - public function testValuesAndOperatorMutation(): void - { - // Test that values and operator can be changed after creation - $this->constraint->setValue(['pending']); - $this->constraint->setOperator('ANY'); - $this->assertTrue($this->constraint->isEnabled()); - - $this->constraint->setValue([]); - $this->assertFalse($this->constraint->isEnabled()); - - $this->constraint->setValue(['completed', 'skipped']); - $this->assertTrue($this->constraint->isEnabled()); - - $this->constraint->setOperator(""); - $this->assertFalse($this->constraint->isEnabled()); - - $this->constraint->setOperator('NONE'); - $this->assertTrue($this->constraint->isEnabled()); - } - - public function testDifferentFromJobStatusConstraint(): void - { - // This constraint should work differently from BulkImportJobStatusConstraint - // It queries the part status directly, not the job status - $this->constraint->setValue(['pending']); - $this->constraint->setOperator('ANY'); - - $subQueryBuilder = $this->createMock(QueryBuilder::class); - $subQueryBuilder->method('select')->willReturnSelf(); - $subQueryBuilder->method('from')->willReturnSelf(); - $subQueryBuilder->method('where')->willReturnSelf(); - $subQueryBuilder->method('andWhere')->willReturnSelf(); - $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL'); - - $this->entityManager->method('createQueryBuilder') - ->willReturn($subQueryBuilder); - - // Should use different alias than job status constraint - $subQueryBuilder->expects($this->once()) - ->method('from') - ->with(BulkInfoProviderImportJobPart::class, 'bip_part_status'); - - // Should not join with job table like job status constraint does - $subQueryBuilder->expects($this->never()) - ->method('join'); - - $this->queryBuilder->method('andWhere'); - $this->queryBuilder->method('setParameter'); - - $this->constraint->apply($this->queryBuilder); - } - - public function testMultipleStatusValues(): void - { - $statusValues = ['pending', 'completed', 'skipped', 'failed']; - $this->constraint->setValue($statusValues); - $this->constraint->setOperator('ANY'); - - $subQueryBuilder = $this->createMock(QueryBuilder::class); - $subQueryBuilder->method('select')->willReturnSelf(); - $subQueryBuilder->method('from')->willReturnSelf(); - $subQueryBuilder->method('where')->willReturnSelf(); - $subQueryBuilder->method('andWhere')->willReturnSelf(); - $subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL'); - - $this->entityManager->method('createQueryBuilder') - ->willReturn($subQueryBuilder); - - $this->queryBuilder->expects($this->once()) - ->method('setParameter') - ->with('part_status_values', $statusValues); - - $this->constraint->apply($this->queryBuilder); - - $this->assertEquals($statusValues, $this->constraint->getValue()); - } -} diff --git a/tests/Entity/BulkImportJobStatusTest.php b/tests/Entity/BulkImportJobStatusTest.php deleted file mode 100644 index e8b4a977..00000000 --- a/tests/Entity/BulkImportJobStatusTest.php +++ /dev/null @@ -1,71 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Tests\Entity; - -use App\Entity\InfoProviderSystem\BulkImportJobStatus; -use PHPUnit\Framework\TestCase; - -class BulkImportJobStatusTest extends TestCase -{ - public function testEnumValues(): void - { - $this->assertEquals('pending', BulkImportJobStatus::PENDING->value); - $this->assertEquals('in_progress', BulkImportJobStatus::IN_PROGRESS->value); - $this->assertEquals('completed', BulkImportJobStatus::COMPLETED->value); - $this->assertEquals('stopped', BulkImportJobStatus::STOPPED->value); - $this->assertEquals('failed', BulkImportJobStatus::FAILED->value); - } - - public function testEnumCases(): void - { - $cases = BulkImportJobStatus::cases(); - - $this->assertCount(5, $cases); - $this->assertContains(BulkImportJobStatus::PENDING, $cases); - $this->assertContains(BulkImportJobStatus::IN_PROGRESS, $cases); - $this->assertContains(BulkImportJobStatus::COMPLETED, $cases); - $this->assertContains(BulkImportJobStatus::STOPPED, $cases); - $this->assertContains(BulkImportJobStatus::FAILED, $cases); - } - - public function testFromString(): void - { - $this->assertEquals(BulkImportJobStatus::PENDING, BulkImportJobStatus::from('pending')); - $this->assertEquals(BulkImportJobStatus::IN_PROGRESS, BulkImportJobStatus::from('in_progress')); - $this->assertEquals(BulkImportJobStatus::COMPLETED, BulkImportJobStatus::from('completed')); - $this->assertEquals(BulkImportJobStatus::STOPPED, BulkImportJobStatus::from('stopped')); - $this->assertEquals(BulkImportJobStatus::FAILED, BulkImportJobStatus::from('failed')); - } - - public function testTryFromInvalidValue(): void - { - $this->assertNull(BulkImportJobStatus::tryFrom('invalid')); - $this->assertNull(BulkImportJobStatus::tryFrom('')); - } - - public function testFromInvalidValueThrowsException(): void - { - $this->expectException(\ValueError::class); - BulkImportJobStatus::from('invalid'); - } -} diff --git a/tests/Entity/BulkInfoProviderImportJobPartTest.php b/tests/Entity/BulkInfoProviderImportJobPartTest.php deleted file mode 100644 index dd9600dd..00000000 --- a/tests/Entity/BulkInfoProviderImportJobPartTest.php +++ /dev/null @@ -1,301 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Tests\Entity; - -use App\Entity\InfoProviderSystem\BulkImportPartStatus; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; -use App\Entity\Parts\Part; -use PHPUnit\Framework\TestCase; - -class BulkInfoProviderImportJobPartTest extends TestCase -{ - private BulkInfoProviderImportJob $job; - private Part $part; - private BulkInfoProviderImportJobPart $jobPart; - - protected function setUp(): void - { - $this->job = $this->createMock(BulkInfoProviderImportJob::class); - $this->part = $this->createMock(Part::class); - - $this->jobPart = new BulkInfoProviderImportJobPart($this->job, $this->part); - } - - public function testConstructor(): void - { - $this->assertSame($this->job, $this->jobPart->getJob()); - $this->assertSame($this->part, $this->jobPart->getPart()); - $this->assertEquals(BulkImportPartStatus::PENDING, $this->jobPart->getStatus()); - $this->assertNull($this->jobPart->getReason()); - $this->assertNull($this->jobPart->getCompletedAt()); - } - - public function testGetAndSetJob(): void - { - $newJob = $this->createMock(BulkInfoProviderImportJob::class); - - $result = $this->jobPart->setJob($newJob); - - $this->assertSame($this->jobPart, $result); - $this->assertSame($newJob, $this->jobPart->getJob()); - } - - public function testGetAndSetPart(): void - { - $newPart = $this->createMock(Part::class); - - $result = $this->jobPart->setPart($newPart); - - $this->assertSame($this->jobPart, $result); - $this->assertSame($newPart, $this->jobPart->getPart()); - } - - public function testGetAndSetStatus(): void - { - $result = $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED); - - $this->assertSame($this->jobPart, $result); - $this->assertEquals(BulkImportPartStatus::COMPLETED, $this->jobPart->getStatus()); - } - - public function testGetAndSetReason(): void - { - $reason = 'Test reason'; - - $result = $this->jobPart->setReason($reason); - - $this->assertSame($this->jobPart, $result); - $this->assertEquals($reason, $this->jobPart->getReason()); - } - - public function testGetAndSetCompletedAt(): void - { - $completedAt = new \DateTimeImmutable(); - - $result = $this->jobPart->setCompletedAt($completedAt); - - $this->assertSame($this->jobPart, $result); - $this->assertSame($completedAt, $this->jobPart->getCompletedAt()); - } - - public function testMarkAsCompleted(): void - { - $beforeTime = new \DateTimeImmutable(); - - $result = $this->jobPart->markAsCompleted(); - - $afterTime = new \DateTimeImmutable(); - - $this->assertSame($this->jobPart, $result); - $this->assertEquals(BulkImportPartStatus::COMPLETED, $this->jobPart->getStatus()); - $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt()); - $this->assertGreaterThanOrEqual($beforeTime, $this->jobPart->getCompletedAt()); - $this->assertLessThanOrEqual($afterTime, $this->jobPart->getCompletedAt()); - } - - public function testMarkAsSkipped(): void - { - $reason = 'Skipped for testing'; - $beforeTime = new \DateTimeImmutable(); - - $result = $this->jobPart->markAsSkipped($reason); - - $afterTime = new \DateTimeImmutable(); - - $this->assertSame($this->jobPart, $result); - $this->assertEquals(BulkImportPartStatus::SKIPPED, $this->jobPart->getStatus()); - $this->assertEquals($reason, $this->jobPart->getReason()); - $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt()); - $this->assertGreaterThanOrEqual($beforeTime, $this->jobPart->getCompletedAt()); - $this->assertLessThanOrEqual($afterTime, $this->jobPart->getCompletedAt()); - } - - public function testMarkAsSkippedWithoutReason(): void - { - $result = $this->jobPart->markAsSkipped(); - - $this->assertSame($this->jobPart, $result); - $this->assertEquals(BulkImportPartStatus::SKIPPED, $this->jobPart->getStatus()); - $this->assertEquals('', $this->jobPart->getReason()); - $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt()); - } - - public function testMarkAsFailed(): void - { - $reason = 'Failed for testing'; - $beforeTime = new \DateTimeImmutable(); - - $result = $this->jobPart->markAsFailed($reason); - - $afterTime = new \DateTimeImmutable(); - - $this->assertSame($this->jobPart, $result); - $this->assertEquals(BulkImportPartStatus::FAILED, $this->jobPart->getStatus()); - $this->assertEquals($reason, $this->jobPart->getReason()); - $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt()); - $this->assertGreaterThanOrEqual($beforeTime, $this->jobPart->getCompletedAt()); - $this->assertLessThanOrEqual($afterTime, $this->jobPart->getCompletedAt()); - } - - public function testMarkAsFailedWithoutReason(): void - { - $result = $this->jobPart->markAsFailed(); - - $this->assertSame($this->jobPart, $result); - $this->assertEquals(BulkImportPartStatus::FAILED, $this->jobPart->getStatus()); - $this->assertEquals('', $this->jobPart->getReason()); - $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt()); - } - - public function testMarkAsPending(): void - { - // First mark as completed to have something to reset - $this->jobPart->markAsCompleted(); - - $result = $this->jobPart->markAsPending(); - - $this->assertSame($this->jobPart, $result); - $this->assertEquals(BulkImportPartStatus::PENDING, $this->jobPart->getStatus()); - $this->assertNull($this->jobPart->getReason()); - $this->assertNull($this->jobPart->getCompletedAt()); - } - - public function testIsPending(): void - { - $this->assertTrue($this->jobPart->isPending()); - - $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED); - $this->assertFalse($this->jobPart->isPending()); - - $this->jobPart->setStatus(BulkImportPartStatus::SKIPPED); - $this->assertFalse($this->jobPart->isPending()); - - $this->jobPart->setStatus(BulkImportPartStatus::FAILED); - $this->assertFalse($this->jobPart->isPending()); - } - - public function testIsCompleted(): void - { - $this->assertFalse($this->jobPart->isCompleted()); - - $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED); - $this->assertTrue($this->jobPart->isCompleted()); - - $this->jobPart->setStatus(BulkImportPartStatus::SKIPPED); - $this->assertFalse($this->jobPart->isCompleted()); - - $this->jobPart->setStatus(BulkImportPartStatus::FAILED); - $this->assertFalse($this->jobPart->isCompleted()); - } - - public function testIsSkipped(): void - { - $this->assertFalse($this->jobPart->isSkipped()); - - $this->jobPart->setStatus(BulkImportPartStatus::SKIPPED); - $this->assertTrue($this->jobPart->isSkipped()); - - $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED); - $this->assertFalse($this->jobPart->isSkipped()); - - $this->jobPart->setStatus(BulkImportPartStatus::FAILED); - $this->assertFalse($this->jobPart->isSkipped()); - } - - public function testIsFailed(): void - { - $this->assertFalse($this->jobPart->isFailed()); - - $this->jobPart->setStatus(BulkImportPartStatus::FAILED); - $this->assertTrue($this->jobPart->isFailed()); - - $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED); - $this->assertFalse($this->jobPart->isFailed()); - - $this->jobPart->setStatus(BulkImportPartStatus::SKIPPED); - $this->assertFalse($this->jobPart->isFailed()); - } - - public function testBulkImportPartStatusEnum(): void - { - $this->assertEquals('pending', BulkImportPartStatus::PENDING->value); - $this->assertEquals('completed', BulkImportPartStatus::COMPLETED->value); - $this->assertEquals('skipped', BulkImportPartStatus::SKIPPED->value); - $this->assertEquals('failed', BulkImportPartStatus::FAILED->value); - } - - public function testStatusTransitions(): void - { - // Test pending -> completed - $this->assertTrue($this->jobPart->isPending()); - $this->jobPart->markAsCompleted(); - $this->assertTrue($this->jobPart->isCompleted()); - - // Test completed -> pending - $this->jobPart->markAsPending(); - $this->assertTrue($this->jobPart->isPending()); - - // Test pending -> skipped - $this->jobPart->markAsSkipped('Test reason'); - $this->assertTrue($this->jobPart->isSkipped()); - - // Test skipped -> pending - $this->jobPart->markAsPending(); - $this->assertTrue($this->jobPart->isPending()); - - // Test pending -> failed - $this->jobPart->markAsFailed('Test error'); - $this->assertTrue($this->jobPart->isFailed()); - - // Test failed -> pending - $this->jobPart->markAsPending(); - $this->assertTrue($this->jobPart->isPending()); - } - - public function testReasonAndCompletedAtConsistency(): void - { - // Initially no reason or completion time - $this->assertNull($this->jobPart->getReason()); - $this->assertNull($this->jobPart->getCompletedAt()); - - // After marking as skipped, should have reason and completion time - $this->jobPart->markAsSkipped('Skipped reason'); - $this->assertEquals('Skipped reason', $this->jobPart->getReason()); - $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt()); - - // After marking as pending, reason and completion time should be cleared - $this->jobPart->markAsPending(); - $this->assertNull($this->jobPart->getReason()); - $this->assertNull($this->jobPart->getCompletedAt()); - - // After marking as failed, should have reason and completion time - $this->jobPart->markAsFailed('Failed reason'); - $this->assertEquals('Failed reason', $this->jobPart->getReason()); - $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt()); - - // After marking as completed, should have completion time (reason may remain from previous state) - $this->jobPart->markAsCompleted(); - $this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt()); - } -} diff --git a/tests/Entity/BulkInfoProviderImportJobTest.php b/tests/Entity/BulkInfoProviderImportJobTest.php deleted file mode 100644 index c9841ac4..00000000 --- a/tests/Entity/BulkInfoProviderImportJobTest.php +++ /dev/null @@ -1,368 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Tests\Entity; - -use App\Entity\InfoProviderSystem\BulkImportJobStatus; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; -use App\Entity\UserSystem\User; -use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; -use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO; -use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; -use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; -use PHPUnit\Framework\TestCase; - -class BulkInfoProviderImportJobTest extends TestCase -{ - private BulkInfoProviderImportJob $job; - private User $user; - - protected function setUp(): void - { - $this->user = new User(); - $this->user->setName('test_user'); - - $this->job = new BulkInfoProviderImportJob(); - $this->job->setCreatedBy($this->user); - } - - private function createMockPart(int $id): \App\Entity\Parts\Part - { - $part = $this->createMock(\App\Entity\Parts\Part::class); - $part->method('getId')->willReturn($id); - $part->method('getName')->willReturn("Test Part {$id}"); - return $part; - } - - public function testConstruct(): void - { - $job = new BulkInfoProviderImportJob(); - - $this->assertInstanceOf(\DateTimeImmutable::class, $job->getCreatedAt()); - $this->assertEquals(BulkImportJobStatus::PENDING, $job->getStatus()); - $this->assertEmpty($job->getPartIds()); - $this->assertEmpty($job->getFieldMappings()); - $this->assertEmpty($job->getSearchResultsRaw()); - $this->assertEmpty($job->getProgress()); - $this->assertNull($job->getCompletedAt()); - $this->assertFalse($job->isPrefetchDetails()); - } - - public function testBasicGettersSetters(): void - { - $this->job->setName('Test Job'); - $this->assertEquals('Test Job', $this->job->getName()); - - // Test with actual parts - this is what actually works - $parts = [$this->createMockPart(1), $this->createMockPart(2), $this->createMockPart(3)]; - foreach ($parts as $part) { - $this->job->addPart($part); - } - $this->assertEquals([1, 2, 3], $this->job->getPartIds()); - - $fieldMappings = [new BulkSearchFieldMappingDTO(field: 'field1', providers: ['provider1', 'provider2'])]; - $this->job->setFieldMappings($fieldMappings); - $this->assertEquals($fieldMappings, $this->job->getFieldMappings()); - - $this->job->setPrefetchDetails(true); - $this->assertTrue($this->job->isPrefetchDetails()); - - $this->assertEquals($this->user, $this->job->getCreatedBy()); - } - - public function testStatusTransitions(): void - { - $this->assertTrue($this->job->isPending()); - $this->assertFalse($this->job->isInProgress()); - $this->assertFalse($this->job->isCompleted()); - $this->assertFalse($this->job->isFailed()); - $this->assertFalse($this->job->isStopped()); - - $this->job->markAsInProgress(); - $this->assertEquals(BulkImportJobStatus::IN_PROGRESS, $this->job->getStatus()); - $this->assertTrue($this->job->isInProgress()); - $this->assertFalse($this->job->isPending()); - - $this->job->markAsCompleted(); - $this->assertEquals(BulkImportJobStatus::COMPLETED, $this->job->getStatus()); - $this->assertTrue($this->job->isCompleted()); - $this->assertNotNull($this->job->getCompletedAt()); - - $job2 = new BulkInfoProviderImportJob(); - $job2->markAsFailed(); - $this->assertEquals(BulkImportJobStatus::FAILED, $job2->getStatus()); - $this->assertTrue($job2->isFailed()); - $this->assertNotNull($job2->getCompletedAt()); - - $job3 = new BulkInfoProviderImportJob(); - $job3->markAsStopped(); - $this->assertEquals(BulkImportJobStatus::STOPPED, $job3->getStatus()); - $this->assertTrue($job3->isStopped()); - $this->assertNotNull($job3->getCompletedAt()); - } - - public function testCanBeStopped(): void - { - $this->assertTrue($this->job->canBeStopped()); - - $this->job->markAsInProgress(); - $this->assertTrue($this->job->canBeStopped()); - - $this->job->markAsCompleted(); - $this->assertFalse($this->job->canBeStopped()); - - $this->job->setStatus(BulkImportJobStatus::FAILED); - $this->assertFalse($this->job->canBeStopped()); - - $this->job->setStatus(BulkImportJobStatus::STOPPED); - $this->assertFalse($this->job->canBeStopped()); - } - - public function testPartCount(): void - { - $this->assertEquals(0, $this->job->getPartCount()); - - // Test with actual parts - setPartIds doesn't actually add parts - $parts = [ - $this->createMockPart(1), - $this->createMockPart(2), - $this->createMockPart(3), - $this->createMockPart(4), - $this->createMockPart(5) - ]; - foreach ($parts as $part) { - $this->job->addPart($part); - } - $this->assertEquals(5, $this->job->getPartCount()); - } - - public function testResultCount(): void - { - $this->assertEquals(0, $this->job->getResultCount()); - - $searchResults = new BulkSearchResponseDTO([ - new \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO( - part: $this->createMockPart(1), - searchResults: [new BulkSearchPartResultDTO(searchResult: new SearchResultDTO(provider_key: 'dummy', provider_id: '1234', name: 'Part 1', description: 'A part'))] - ), - new \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO( - part: $this->createMockPart(2), - searchResults: [new BulkSearchPartResultDTO(searchResult: new SearchResultDTO(provider_key: 'dummy', provider_id: '1234', name: 'Part 2', description: 'A part')), - new BulkSearchPartResultDTO(searchResult: new SearchResultDTO(provider_key: 'dummy', provider_id: '5678', name: 'Part 2 Alt', description: 'Another part'))] - ), - new \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO( - part: $this->createMockPart(3), - searchResults: [] - ) - ]); - - $this->job->setSearchResults($searchResults); - $this->assertEquals(3, $this->job->getResultCount()); - } - - public function testPartProgressTracking(): void - { - // Test with actual parts - setPartIds doesn't actually add parts - $parts = [ - $this->createMockPart(1), - $this->createMockPart(2), - $this->createMockPart(3), - $this->createMockPart(4) - ]; - foreach ($parts as $part) { - $this->job->addPart($part); - } - - $this->assertFalse($this->job->isPartCompleted(1)); - $this->assertFalse($this->job->isPartSkipped(1)); - - $this->job->markPartAsCompleted(1); - $this->assertTrue($this->job->isPartCompleted(1)); - $this->assertFalse($this->job->isPartSkipped(1)); - - $this->job->markPartAsSkipped(2, 'Not found'); - $this->assertFalse($this->job->isPartCompleted(2)); - $this->assertTrue($this->job->isPartSkipped(2)); - - $this->job->markPartAsPending(1); - $this->assertFalse($this->job->isPartCompleted(1)); - $this->assertFalse($this->job->isPartSkipped(1)); - } - - public function testProgressCounts(): void - { - // Test with actual parts - setPartIds doesn't actually add parts - $parts = [ - $this->createMockPart(1), - $this->createMockPart(2), - $this->createMockPart(3), - $this->createMockPart(4), - $this->createMockPart(5) - ]; - foreach ($parts as $part) { - $this->job->addPart($part); - } - - $this->assertEquals(0, $this->job->getCompletedPartsCount()); - $this->assertEquals(0, $this->job->getSkippedPartsCount()); - - $this->job->markPartAsCompleted(1); - $this->job->markPartAsCompleted(2); - $this->job->markPartAsSkipped(3, 'Error'); - - $this->assertEquals(2, $this->job->getCompletedPartsCount()); - $this->assertEquals(1, $this->job->getSkippedPartsCount()); - } - - public function testProgressPercentage(): void - { - $emptyJob = new BulkInfoProviderImportJob(); - $this->assertEquals(100.0, $emptyJob->getProgressPercentage()); - - // Test with actual parts - setPartIds doesn't actually add parts - $parts = [ - $this->createMockPart(1), - $this->createMockPart(2), - $this->createMockPart(3), - $this->createMockPart(4), - $this->createMockPart(5) - ]; - foreach ($parts as $part) { - $this->job->addPart($part); - } - - $this->assertEquals(0.0, $this->job->getProgressPercentage()); - - $this->job->markPartAsCompleted(1); - $this->job->markPartAsCompleted(2); - $this->assertEquals(40.0, $this->job->getProgressPercentage()); - - $this->job->markPartAsSkipped(3, 'Error'); - $this->assertEquals(60.0, $this->job->getProgressPercentage()); - - $this->job->markPartAsCompleted(4); - $this->job->markPartAsCompleted(5); - $this->assertEquals(100.0, $this->job->getProgressPercentage()); - } - - public function testIsAllPartsCompleted(): void - { - $emptyJob = new BulkInfoProviderImportJob(); - $this->assertTrue($emptyJob->isAllPartsCompleted()); - - // Test with actual parts - setPartIds doesn't actually add parts - $parts = [ - $this->createMockPart(1), - $this->createMockPart(2), - $this->createMockPart(3) - ]; - foreach ($parts as $part) { - $this->job->addPart($part); - } - - $this->assertFalse($this->job->isAllPartsCompleted()); - - $this->job->markPartAsCompleted(1); - $this->assertFalse($this->job->isAllPartsCompleted()); - - $this->job->markPartAsCompleted(2); - $this->job->markPartAsSkipped(3, 'Error'); - $this->assertTrue($this->job->isAllPartsCompleted()); - } - - public function testDisplayNameMethods(): void - { - // Test with actual parts - setPartIds doesn't actually add parts - $parts = [ - $this->createMockPart(1), - $this->createMockPart(2), - $this->createMockPart(3) - ]; - foreach ($parts as $part) { - $this->job->addPart($part); - } - - $this->assertEquals('info_providers.bulk_import.job_name_template', $this->job->getDisplayNameKey()); - $this->assertEquals(['%count%' => 3], $this->job->getDisplayNameParams()); - } - - public function testFormattedTimestamp(): void - { - $timestampRegex = '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/'; - $this->assertMatchesRegularExpression($timestampRegex, $this->job->getFormattedTimestamp()); - } - - public function testProgressDataStructure(): void - { - $parts = [ - $this->createMockPart(1), - $this->createMockPart(2), - $this->createMockPart(3) - ]; - foreach ($parts as $part) { - $this->job->addPart($part); - } - - $this->job->markPartAsCompleted(1); - $this->job->markPartAsSkipped(2, 'Test reason'); - - $progress = $this->job->getProgress(); - - // The progress array should have keys for all part IDs, even if not completed/skipped - $this->assertArrayHasKey(1, $progress, 'Progress should contain key for part 1'); - $this->assertArrayHasKey(2, $progress, 'Progress should contain key for part 2'); - $this->assertArrayHasKey(3, $progress, 'Progress should contain key for part 3'); - - // Part 1: completed - $this->assertEquals('completed', $progress[1]['status']); - $this->assertArrayHasKey('completed_at', $progress[1]); - $this->assertArrayNotHasKey('reason', $progress[1]); - - // Part 2: skipped - $this->assertEquals('skipped', $progress[2]['status']); - $this->assertEquals('Test reason', $progress[2]['reason']); - $this->assertArrayHasKey('completed_at', $progress[2]); - - // Part 3: should be present but not completed/skipped - $this->assertEquals('pending', $progress[3]['status']); - $this->assertArrayNotHasKey('completed_at', $progress[3]); - $this->assertArrayNotHasKey('reason', $progress[3]); - } - - public function testCompletedAtTimestamp(): void - { - $this->assertNull($this->job->getCompletedAt()); - - $beforeCompletion = new \DateTimeImmutable(); - $this->job->markAsCompleted(); - $afterCompletion = new \DateTimeImmutable(); - - $completedAt = $this->job->getCompletedAt(); - $this->assertNotNull($completedAt); - $this->assertGreaterThanOrEqual($beforeCompletion, $completedAt); - $this->assertLessThanOrEqual($afterCompletion, $completedAt); - - $customTime = new \DateTimeImmutable('2023-01-01 12:00:00'); - $this->job->setCompletedAt($customTime); - $this->assertEquals($customTime, $this->job->getCompletedAt()); - } -} diff --git a/tests/Form/InfoProviderSystem/GlobalFieldMappingTypeTest.php b/tests/Form/InfoProviderSystem/GlobalFieldMappingTypeTest.php deleted file mode 100644 index 52e0b1d2..00000000 --- a/tests/Form/InfoProviderSystem/GlobalFieldMappingTypeTest.php +++ /dev/null @@ -1,68 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Tests\Form\InfoProviderSystem; - -use App\Form\InfoProviderSystem\GlobalFieldMappingType; -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Component\Form\FormFactoryInterface; - -/** - * @group slow - * @group DB - */ -class GlobalFieldMappingTypeTest extends KernelTestCase -{ - private FormFactoryInterface $formFactory; - - protected function setUp(): void - { - self::bootKernel(); - $this->formFactory = static::getContainer()->get(FormFactoryInterface::class); - } - - public function testFormCreation(): void - { - $form = $this->formFactory->create(GlobalFieldMappingType::class, null, [ - 'field_choices' => [ - 'MPN' => 'mpn', - 'Name' => 'name' - ], - 'csrf_protection' => false - ]); - - $this->assertTrue($form->has('field_mappings')); - $this->assertTrue($form->has('prefetch_details')); - $this->assertTrue($form->has('submit')); - } - - public function testFormOptions(): void - { - $form = $this->formFactory->create(GlobalFieldMappingType::class, null, [ - 'field_choices' => [], - 'csrf_protection' => false - ]); - - $view = $form->createView(); - $this->assertFalse($view['prefetch_details']->vars['required']); - } -} \ No newline at end of file diff --git a/tests/Repository/LogEntryRepositoryTest.php b/tests/Repository/LogEntryRepositoryTest.php index f6cc991d..fc31faf5 100644 --- a/tests/Repository/LogEntryRepositoryTest.php +++ b/tests/Repository/LogEntryRepositoryTest.php @@ -112,8 +112,7 @@ class LogEntryRepositoryTest extends KernelTestCase $this->assertCount(2, $logs); //The first one must be newer than the second one - $this->assertGreaterThanOrEqual($logs[1]->getTimestamp(), $logs[0]->getTimestamp()); - $this->assertGreaterThanOrEqual($logs[1]->getID(), $logs[0]->getID()); + $this->assertGreaterThanOrEqual($logs[0]->getTimestamp(), $logs[1]->getTimestamp()); } public function testGetElementExistedAtTimestamp(): void diff --git a/tests/Services/ElementTypeNameGeneratorTest.php b/tests/Services/ElementTypeNameGeneratorTest.php index f99b0676..934a3bbd 100644 --- a/tests/Services/ElementTypeNameGeneratorTest.php +++ b/tests/Services/ElementTypeNameGeneratorTest.php @@ -25,12 +25,11 @@ namespace App\Tests\Services; use App\Entity\Attachments\PartAttachment; use App\Entity\Base\AbstractDBElement; use App\Entity\Base\AbstractNamedDBElement; -use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob; use App\Entity\Parts\Category; use App\Entity\Parts\Part; use App\Exceptions\EntityNotSupportedException; -use App\Services\ElementTypeNameGenerator; use App\Services\Formatters\AmountFormatter; +use App\Services\ElementTypeNameGenerator; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class ElementTypeNameGeneratorTest extends WebTestCase @@ -51,18 +50,16 @@ class ElementTypeNameGeneratorTest extends WebTestCase //We only test in english $this->assertSame('Part', $this->service->getLocalizedTypeLabel(new Part())); $this->assertSame('Category', $this->service->getLocalizedTypeLabel(new Category())); - $this->assertSame('Bulk info provider import', $this->service->getLocalizedTypeLabel(new BulkInfoProviderImportJob())); //Test inheritance $this->assertSame('Attachment', $this->service->getLocalizedTypeLabel(new PartAttachment())); //Test for class name $this->assertSame('Part', $this->service->getLocalizedTypeLabel(Part::class)); - $this->assertSame('Bulk info provider import', $this->service->getLocalizedTypeLabel(BulkInfoProviderImportJob::class)); //Test exception for unknpwn type $this->expectException(EntityNotSupportedException::class); - $this->service->getLocalizedTypeLabel(new class () extends AbstractDBElement { + $this->service->getLocalizedTypeLabel(new class() extends AbstractDBElement { }); } @@ -77,7 +74,7 @@ class ElementTypeNameGeneratorTest extends WebTestCase //Test exception $this->expectException(EntityNotSupportedException::class); - $this->service->getTypeNameCombination(new class () extends AbstractNamedDBElement { + $this->service->getTypeNameCombination(new class() extends AbstractNamedDBElement { public function getIDString(): string { return 'Stub'; diff --git a/tests/Services/ImportExportSystem/EntityExporterTest.php b/tests/Services/ImportExportSystem/EntityExporterTest.php index e9b924b1..004971ab 100644 --- a/tests/Services/ImportExportSystem/EntityExporterTest.php +++ b/tests/Services/ImportExportSystem/EntityExporterTest.php @@ -26,7 +26,6 @@ use App\Entity\Parts\Category; use App\Services\ImportExportSystem\EntityExporter; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Request; -use PhpOffice\PhpSpreadsheet\IOFactory; class EntityExporterTest extends WebTestCase { @@ -77,40 +76,7 @@ class EntityExporterTest extends WebTestCase $this->assertSame('application/json', $response->headers->get('Content-Type')); $this->assertNotEmpty($response->headers->get('Content-Disposition')); - } - public function testExportToExcel(): void - { - $entities = $this->getEntities(); - $xlsxData = $this->service->exportEntities($entities, ['format' => 'xlsx', 'level' => 'simple']); - $this->assertNotEmpty($xlsxData); - - $tempFile = tempnam(sys_get_temp_dir(), 'test_export') . '.xlsx'; - file_put_contents($tempFile, $xlsxData); - - $spreadsheet = IOFactory::load($tempFile); - $worksheet = $spreadsheet->getActiveSheet(); - - $this->assertSame('name', $worksheet->getCell('A1')->getValue()); - $this->assertSame('full_name', $worksheet->getCell('B1')->getValue()); - - $this->assertSame('Enitity 1', $worksheet->getCell('A2')->getValue()); - $this->assertSame('Enitity 1', $worksheet->getCell('B2')->getValue()); - - unlink($tempFile); - } - - public function testExportExcelFromRequest(): void - { - $entities = $this->getEntities(); - - $request = new Request(); - $request->request->set('format', 'xlsx'); - $request->request->set('level', 'simple'); - $response = $this->service->exportEntityFromRequest($entities, $request); - - $this->assertSame('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type')); - $this->assertStringContainsString('export_Category_simple.xlsx', $response->headers->get('Content-Disposition')); } } diff --git a/tests/Services/ImportExportSystem/EntityImporterTest.php b/tests/Services/ImportExportSystem/EntityImporterTest.php index 83367f80..fd5e8b9e 100644 --- a/tests/Services/ImportExportSystem/EntityImporterTest.php +++ b/tests/Services/ImportExportSystem/EntityImporterTest.php @@ -36,9 +36,6 @@ use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationListInterface; -use Symfony\Component\HttpFoundation\File\File; -use PhpOffice\PhpSpreadsheet\Spreadsheet; -use PhpOffice\PhpSpreadsheet\Writer\Xlsx; #[Group('DB')] class EntityImporterTest extends WebTestCase @@ -210,10 +207,6 @@ EOT; yield ['json', 'json']; yield ['yaml', 'yml']; yield ['yaml', 'YAML']; - yield ['xlsx', 'xlsx']; - yield ['xlsx', 'XLSX']; - yield ['xls', 'xls']; - yield ['xls', 'XLS']; } #[DataProvider('formatDataProvider')] @@ -349,41 +342,4 @@ EOT; $this->assertSame($category, $results[0]->getCategory()); $this->assertSame('test,test2', $results[0]->getTags()); } - - public function testImportExcelFileProjects(): void - { - $spreadsheet = new Spreadsheet(); - $worksheet = $spreadsheet->getActiveSheet(); - - $worksheet->setCellValue('A1', 'name'); - $worksheet->setCellValue('B1', 'comment'); - $worksheet->setCellValue('A2', 'Test Excel 1'); - $worksheet->setCellValue('B2', 'Test Excel 1 notes'); - $worksheet->setCellValue('A3', 'Test Excel 2'); - $worksheet->setCellValue('B3', 'Test Excel 2 notes'); - - $tempFile = tempnam(sys_get_temp_dir(), 'test_excel') . '.xlsx'; - $writer = new Xlsx($spreadsheet); - $writer->save($tempFile); - - $file = new File($tempFile); - - $errors = []; - $results = $this->service->importFile($file, [ - 'class' => Project::class, - 'format' => 'xlsx', - 'csv_delimiter' => ';', - ], $errors); - - $this->assertCount(2, $results); - $this->assertEmpty($errors); - $this->assertContainsOnlyInstancesOf(Project::class, $results); - - $this->assertSame('Test Excel 1', $results[0]->getName()); - $this->assertSame('Test Excel 1 notes', $results[0]->getComment()); - $this->assertSame('Test Excel 2', $results[1]->getName()); - $this->assertSame('Test Excel 2 notes', $results[1]->getComment()); - - unlink($tempFile); - } } diff --git a/tests/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTOTest.php b/tests/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTOTest.php deleted file mode 100644 index a2101938..00000000 --- a/tests/Services/InfoProviderSystem/DTOs/BulkSearchFieldMappingDTOTest.php +++ /dev/null @@ -1,63 +0,0 @@ -. - */ - -namespace App\Tests\Services\InfoProviderSystem\DTOs; - -use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; -use PHPUnit\Framework\TestCase; - -class BulkSearchFieldMappingDTOTest extends TestCase -{ - - public function testIsSupplierPartNumberField(): void - { - $fieldMapping = new BulkSearchFieldMappingDTO(field: 'reichelt_spn', providers: ['provider1'], priority: 1); - $this->assertTrue($fieldMapping->isSupplierPartNumberField()); - - $fieldMapping = new BulkSearchFieldMappingDTO(field: 'partNumber', providers: ['provider1'], priority: 1); - $this->assertFalse($fieldMapping->isSupplierPartNumberField()); - } - - public function testToSerializableArray(): void - { - $fieldMapping = new BulkSearchFieldMappingDTO(field: 'test', providers: ['provider1', 'provider2'], priority: 3); - $array = $fieldMapping->toSerializableArray(); - $this->assertIsArray($array); - $this->assertSame([ - 'field' => 'test', - 'providers' => ['provider1', 'provider2'], - 'priority' => 3, - ], $array); - } - - public function testFromSerializableArray(): void - { - $data = [ - 'field' => 'test', - 'providers' => ['provider1', 'provider2'], - 'priority' => 3, - ]; - $fieldMapping = BulkSearchFieldMappingDTO::fromSerializableArray($data); - $this->assertInstanceOf(BulkSearchFieldMappingDTO::class, $fieldMapping); - $this->assertSame('test', $fieldMapping->field); - $this->assertSame(['provider1', 'provider2'], $fieldMapping->providers); - $this->assertSame(3, $fieldMapping->priority); - } -} diff --git a/tests/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTOTest.php b/tests/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTOTest.php deleted file mode 100644 index 09fa4973..00000000 --- a/tests/Services/InfoProviderSystem/DTOs/BulkSearchPartResultsDTOTest.php +++ /dev/null @@ -1,63 +0,0 @@ -. - */ - -namespace App\Tests\Services\InfoProviderSystem\DTOs; - -use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; -use PHPUnit\Framework\TestCase; - -class BulkSearchPartResultsDTOTest extends TestCase -{ - - public function testHasErrors(): void - { - $test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []); - $this->assertFalse($test->hasErrors()); - $test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], ['error1']); - $this->assertTrue($test->hasErrors()); - } - - public function testGetErrorCount(): void - { - $test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []); - $this->assertCount(0, $test->errors); - $test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], ['error1', 'error2']); - $this->assertCount(2, $test->errors); - } - - public function testHasResults(): void - { - $test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []); - $this->assertFalse($test->hasResults()); - $test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [ $this->createMock(\App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO::class) ], []); - $this->assertTrue($test->hasResults()); - } - - public function testGetResultCount(): void - { - $test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []); - $this->assertCount(0, $test->searchResults); - $test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [ - $this->createMock(\App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO::class), - $this->createMock(\App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO::class) - ], []); - $this->assertCount(2, $test->searchResults); - } -} diff --git a/tests/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php b/tests/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php deleted file mode 100644 index b4dc0dea..00000000 --- a/tests/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTOTest.php +++ /dev/null @@ -1,258 +0,0 @@ -. - */ - -namespace App\Tests\Services\InfoProviderSystem\DTOs; - -use App\Entity\Parts\Part; -use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; -use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO; -use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; -use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; -use Doctrine\ORM\EntityManagerInterface; -use PHPUnit\Framework\TestCase; -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; - -class BulkSearchResponseDTOTest extends KernelTestCase -{ - - private EntityManagerInterface $entityManager; - - private BulkSearchResponseDTO $dummyEmpty; - private BulkSearchResponseDTO $dummy; - - protected function setUp(): void - { - self::bootKernel(); - $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); - - $this->dummyEmpty = new BulkSearchResponseDTO(partResults: []); - $this->dummy = new BulkSearchResponseDTO(partResults: [ - new BulkSearchPartResultsDTO( - part: $this->entityManager->find(Part::class, 1), - searchResults: [ - new BulkSearchPartResultDTO( - searchResult: new SearchResultDTO(provider_key: "dummy", provider_id: "1234", name: "Test Part", description: "A part for testing"), - sourceField: "mpn", sourceKeyword: "1234", priority: 1 - ), - new BulkSearchPartResultDTO( - searchResult: new SearchResultDTO(provider_key: "test", provider_id: "test", name: "Test Part2", description: "A part for testing"), - sourceField: "name", sourceKeyword: "1234", - localPart: $this->entityManager->find(Part::class, 2), priority: 2, - ), - ], - errors: ['Error 1'] - ) - ]); - } - - public function testSerializationBackAndForthEmpty(): void - { - $serialized = $this->dummyEmpty->toSerializableRepresentation(); - //Ensure that it is json_encodable - $json = json_encode($serialized, JSON_THROW_ON_ERROR); - $this->assertJson($json); - $deserialized = BulkSearchResponseDTO::fromSerializableRepresentation(json_decode($json), $this->entityManager); - - $this->assertEquals($this->dummyEmpty, $deserialized); - } - - public function testSerializationBackAndForth(): void - { - $serialized = $this->dummy->toSerializableRepresentation(); - //Ensure that it is json_encodable - $this->assertJson(json_encode($serialized, JSON_THROW_ON_ERROR)); - $deserialized = BulkSearchResponseDTO::fromSerializableRepresentation($serialized, $this->entityManager); - - $this->assertEquals($this->dummy, $deserialized); - } - - public function testToSerializableRepresentation(): void - { - $serialized = $this->dummy->toSerializableRepresentation(); - - $expected = array ( - 0 => - array ( - 'part_id' => 1, - 'search_results' => - array ( - 0 => - array ( - 'dto' => - array ( - 'provider_key' => 'dummy', - 'provider_id' => '1234', - 'name' => 'Test Part', - 'description' => 'A part for testing', - 'category' => NULL, - 'manufacturer' => NULL, - 'mpn' => NULL, - 'preview_image_url' => NULL, - 'manufacturing_status' => NULL, - 'provider_url' => NULL, - 'footprint' => NULL, - ), - 'source_field' => 'mpn', - 'source_keyword' => '1234', - 'localPart' => NULL, - 'priority' => 1, - ), - 1 => - array ( - 'dto' => - array ( - 'provider_key' => 'test', - 'provider_id' => 'test', - 'name' => 'Test Part2', - 'description' => 'A part for testing', - 'category' => NULL, - 'manufacturer' => NULL, - 'mpn' => NULL, - 'preview_image_url' => NULL, - 'manufacturing_status' => NULL, - 'provider_url' => NULL, - 'footprint' => NULL, - ), - 'source_field' => 'name', - 'source_keyword' => '1234', - 'localPart' => 2, - 'priority' => 2, - ), - ), - 'errors' => - array ( - 0 => 'Error 1', - ), - ), - ); - - $this->assertEquals($expected, $serialized); - } - - public function testFromSerializableRepresentation(): void - { - $input = array ( - 0 => - array ( - 'part_id' => 1, - 'search_results' => - array ( - 0 => - array ( - 'dto' => - array ( - 'provider_key' => 'dummy', - 'provider_id' => '1234', - 'name' => 'Test Part', - 'description' => 'A part for testing', - 'category' => NULL, - 'manufacturer' => NULL, - 'mpn' => NULL, - 'preview_image_url' => NULL, - 'manufacturing_status' => NULL, - 'provider_url' => NULL, - 'footprint' => NULL, - ), - 'source_field' => 'mpn', - 'source_keyword' => '1234', - 'localPart' => NULL, - 'priority' => 1, - ), - 1 => - array ( - 'dto' => - array ( - 'provider_key' => 'test', - 'provider_id' => 'test', - 'name' => 'Test Part2', - 'description' => 'A part for testing', - 'category' => NULL, - 'manufacturer' => NULL, - 'mpn' => NULL, - 'preview_image_url' => NULL, - 'manufacturing_status' => NULL, - 'provider_url' => NULL, - 'footprint' => NULL, - ), - 'source_field' => 'name', - 'source_keyword' => '1234', - 'localPart' => 2, - 'priority' => 2, - ), - ), - 'errors' => - array ( - 0 => 'Error 1', - ), - ), - ); - - $deserialized = BulkSearchResponseDTO::fromSerializableRepresentation($input, $this->entityManager); - $this->assertEquals($this->dummy, $deserialized); - } - - public function testMerge(): void - { - $merged = BulkSearchResponseDTO::merge($this->dummy, $this->dummyEmpty); - $this->assertCount(1, $merged->partResults); - - $merged = BulkSearchResponseDTO::merge($this->dummyEmpty, $this->dummyEmpty); - $this->assertCount(0, $merged->partResults); - - $merged = BulkSearchResponseDTO::merge($this->dummy, $this->dummy, $this->dummy); - $this->assertCount(3, $merged->partResults); - } - - public function testReplaceResultsForPart(): void - { - $newPartResults = new BulkSearchPartResultsDTO( - part: $this->entityManager->find(Part::class, 1), - searchResults: [ - new BulkSearchPartResultDTO( - searchResult: new SearchResultDTO(provider_key: "new", provider_id: "new", name: "New Part", description: "A new part"), - sourceField: "mpn", sourceKeyword: "new", priority: 1 - ) - ], - errors: ['New Error'] - ); - - $replaced = $this->dummy->replaceResultsForPart($newPartResults); - $this->assertCount(1, $replaced->partResults); - $this->assertSame($newPartResults, $replaced->partResults[0]); - } - - public function testReplaceResultsForPartNotExisting(): void - { - $newPartResults = new BulkSearchPartResultsDTO( - part: $this->entityManager->find(Part::class, 1), - searchResults: [ - new BulkSearchPartResultDTO( - searchResult: new SearchResultDTO(provider_key: "new", provider_id: "new", name: "New Part", description: "A new part"), - sourceField: "mpn", sourceKeyword: "new", priority: 1 - ) - ], - errors: ['New Error'] - ); - - $this->expectException(\InvalidArgumentException::class); - - $replaced = $this->dummyEmpty->replaceResultsForPart($newPartResults); - } -} diff --git a/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php b/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php deleted file mode 100644 index 57527f57..00000000 --- a/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php +++ /dev/null @@ -1,540 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Tests\Services\InfoProviderSystem\Providers; - -use App\Services\InfoProviderSystem\DTOs\FileDTO; -use App\Services\InfoProviderSystem\DTOs\ParameterDTO; -use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; -use App\Services\InfoProviderSystem\DTOs\PriceDTO; -use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; -use App\Services\InfoProviderSystem\Providers\LCSCProvider; -use App\Services\InfoProviderSystem\Providers\ProviderCapabilities; -use App\Settings\InfoProviderSystem\LCSCSettings; -use App\Tests\SettingsTestHelper; -use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpClient\MockHttpClient; -use Symfony\Component\HttpClient\Response\MockResponse; -use Symfony\Contracts\HttpClient\HttpClientInterface; - -class LCSCProviderTest extends TestCase -{ - private LCSCSettings $settings; - private LCSCProvider $provider; - private MockHttpClient $httpClient; - - protected function setUp(): void - { - $this->httpClient = new MockHttpClient(); - $this->settings = SettingsTestHelper::createSettingsDummy(LCSCSettings::class); - $this->settings->currency = 'USD'; - $this->settings->enabled = true; - $this->provider = new LCSCProvider($this->httpClient, $this->settings); - } - - public function testGetProviderInfo(): void - { - $info = $this->provider->getProviderInfo(); - - $this->assertIsArray($info); - $this->assertArrayHasKey('name', $info); - $this->assertArrayHasKey('description', $info); - $this->assertArrayHasKey('url', $info); - $this->assertArrayHasKey('disabled_help', $info); - $this->assertEquals('LCSC', $info['name']); - $this->assertEquals('https://www.lcsc.com/', $info['url']); - } - - public function testGetProviderKey(): void - { - $this->assertEquals('lcsc', $this->provider->getProviderKey()); - } - - public function testIsActiveWhenEnabled(): void - { - //Ensure that the settings are enabled - $this->settings->enabled = true; - $this->assertTrue($this->provider->isActive()); - } - - public function testIsActiveWhenDisabled(): void - { - //Ensure that the settings are disabled - $this->settings->enabled = false; - $this->assertFalse($this->provider->isActive()); - } - - public function testGetCapabilities(): void - { - $capabilities = $this->provider->getCapabilities(); - - $this->assertIsArray($capabilities); - $this->assertContains(ProviderCapabilities::BASIC, $capabilities); - $this->assertContains(ProviderCapabilities::PICTURE, $capabilities); - $this->assertContains(ProviderCapabilities::DATASHEET, $capabilities); - $this->assertContains(ProviderCapabilities::PRICE, $capabilities); - $this->assertContains(ProviderCapabilities::FOOTPRINT, $capabilities); - } - - public function testSearchByKeywordWithCCode(): void - { - $mockResponse = new MockResponse(json_encode([ - 'result' => [ - 'productCode' => 'C123456', - 'productModel' => 'Test Component', - 'productIntroEn' => 'Test description', - 'brandNameEn' => 'Test Manufacturer', - 'encapStandard' => '0603', - 'productImageUrl' => 'https://example.com/image.jpg', - 'productImages' => ['https://example.com/image1.jpg'], - 'productPriceList' => [ - ['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$'] - ], - 'paramVOList' => [ - ['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΞ©'] - ], - 'pdfUrl' => 'https://example.com/datasheet.pdf', - 'weight' => 0.001 - ] - ])); - - $this->httpClient->setResponseFactory([$mockResponse]); - - $results = $this->provider->searchByKeyword('C123456'); - - $this->assertIsArray($results); - $this->assertCount(1, $results); - $this->assertInstanceOf(PartDetailDTO::class, $results[0]); - $this->assertEquals('C123456', $results[0]->provider_id); - $this->assertEquals('Test Component', $results[0]->name); - } - - public function testSearchByKeywordWithRegularTerm(): void - { - $mockResponse = new MockResponse(json_encode([ - 'result' => [ - 'productSearchResultVO' => [ - 'productList' => [ - [ - 'productCode' => 'C789012', - 'productModel' => 'Regular Component', - 'productIntroEn' => 'Regular description', - 'brandNameEn' => 'Regular Manufacturer', - 'encapStandard' => '0805', - 'productImageUrl' => 'https://example.com/regular.jpg', - 'productImages' => ['https://example.com/regular1.jpg'], - 'productPriceList' => [ - ['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => '€'] - ], - 'paramVOList' => [], - 'pdfUrl' => null, - 'weight' => null - ] - ] - ] - ] - ])); - - $this->httpClient->setResponseFactory([$mockResponse]); - - $results = $this->provider->searchByKeyword('resistor'); - - $this->assertIsArray($results); - $this->assertCount(1, $results); - $this->assertInstanceOf(PartDetailDTO::class, $results[0]); - $this->assertEquals('C789012', $results[0]->provider_id); - $this->assertEquals('Regular Component', $results[0]->name); - } - - public function testSearchByKeywordWithTipProduct(): void - { - $mockResponse = new MockResponse(json_encode([ - 'result' => [ - 'productSearchResultVO' => [ - 'productList' => [] - ], - 'tipProductDetailUrlVO' => [ - 'productCode' => 'C555555' - ] - ] - ])); - - $detailResponse = new MockResponse(json_encode([ - 'result' => [ - 'productCode' => 'C555555', - 'productModel' => 'Tip Component', - 'productIntroEn' => 'Tip description', - 'brandNameEn' => 'Tip Manufacturer', - 'encapStandard' => '1206', - 'productImageUrl' => null, - 'productImages' => [], - 'productPriceList' => [], - 'paramVOList' => [], - 'pdfUrl' => null, - 'weight' => null - ] - ])); - - $this->httpClient->setResponseFactory([$mockResponse, $detailResponse]); - - $results = $this->provider->searchByKeyword('special'); - - $this->assertIsArray($results); - $this->assertCount(1, $results); - $this->assertInstanceOf(PartDetailDTO::class, $results[0]); - $this->assertEquals('C555555', $results[0]->provider_id); - $this->assertEquals('Tip Component', $results[0]->name); - } - - public function testSearchByKeywordsBatch(): void - { - $mockResponse1 = new MockResponse(json_encode([ - 'result' => [ - 'productCode' => 'C123456', - 'productModel' => 'Batch Component 1', - 'productIntroEn' => 'Batch description 1', - 'brandNameEn' => 'Batch Manufacturer', - 'encapStandard' => '0603', - 'productImageUrl' => null, - 'productImages' => [], - 'productPriceList' => [], - 'paramVOList' => [], - 'pdfUrl' => null, - 'weight' => null - ] - ])); - - $mockResponse2 = new MockResponse(json_encode([ - 'result' => [ - 'productSearchResultVO' => [ - 'productList' => [ - [ - 'productCode' => 'C789012', - 'productModel' => 'Batch Component 2', - 'productIntroEn' => 'Batch description 2', - 'brandNameEn' => 'Batch Manufacturer', - 'encapStandard' => '0805', - 'productImageUrl' => null, - 'productImages' => [], - 'productPriceList' => [], - 'paramVOList' => [], - 'pdfUrl' => null, - 'weight' => null - ] - ] - ] - ] - ])); - - $this->httpClient->setResponseFactory([$mockResponse1, $mockResponse2]); - - $results = $this->provider->searchByKeywordsBatch(['C123456', 'resistor']); - - $this->assertIsArray($results); - $this->assertArrayHasKey('C123456', $results); - $this->assertArrayHasKey('resistor', $results); - $this->assertCount(1, $results['C123456']); - $this->assertCount(1, $results['resistor']); - $this->assertEquals('C123456', $results['C123456'][0]->provider_id); - $this->assertEquals('C789012', $results['resistor'][0]->provider_id); - } - - public function testGetDetails(): void - { - $mockResponse = new MockResponse(json_encode([ - 'result' => [ - 'productCode' => 'C123456', - 'productModel' => 'Detailed Component', - 'productIntroEn' => 'Detailed description', - 'brandNameEn' => 'Detailed Manufacturer', - 'encapStandard' => '0603', - 'productImageUrl' => 'https://example.com/detail.jpg', - 'productImages' => ['https://example.com/detail1.jpg'], - 'productPriceList' => [ - ['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$'], - ['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => 'US$'] - ], - 'paramVOList' => [ - ['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΞ©'], - ['paramNameEn' => 'Tolerance', 'paramValueEn' => '1%'] - ], - 'pdfUrl' => 'https://example.com/datasheet.pdf', - 'weight' => 0.001 - ] - ])); - - $this->httpClient->setResponseFactory([$mockResponse]); - - $result = $this->provider->getDetails('C123456'); - - $this->assertInstanceOf(PartDetailDTO::class, $result); - $this->assertEquals('C123456', $result->provider_id); - $this->assertEquals('Detailed Component', $result->name); - $this->assertEquals('Detailed description', $result->description); - $this->assertEquals('Detailed Manufacturer', $result->manufacturer); - $this->assertEquals('0603', $result->footprint); - $this->assertEquals('https://www.lcsc.com/product-detail/C123456.html', $result->provider_url); - $this->assertCount(1, $result->images); - $this->assertCount(2, $result->parameters); - $this->assertCount(1, $result->vendor_infos); - $this->assertEquals('0.001', $result->mass); - } - - public function testGetDetailsWithNoResults(): void - { - $mockResponse = new MockResponse(json_encode([ - 'result' => [ - 'productSearchResultVO' => [ - 'productList' => [] - ] - ] - ])); - - $this->httpClient->setResponseFactory([$mockResponse]); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('No part found with ID INVALID'); - - $this->provider->getDetails('INVALID'); - } - - public function testGetDetailsWithMultipleResults(): void - { - $mockResponse = new MockResponse(json_encode([ - 'result' => [ - 'productSearchResultVO' => [ - 'productList' => [ - [ - 'productCode' => 'C123456', - 'productModel' => 'Component 1', - 'productIntroEn' => 'Description 1', - 'brandNameEn' => 'Manufacturer 1', - 'encapStandard' => '0603', - 'productImageUrl' => null, - 'productImages' => [], - 'productPriceList' => [], - 'paramVOList' => [], - 'pdfUrl' => null, - 'weight' => null - ], - [ - 'productCode' => 'C789012', - 'productModel' => 'Component 2', - 'productIntroEn' => 'Description 2', - 'brandNameEn' => 'Manufacturer 2', - 'encapStandard' => '0805', - 'productImageUrl' => null, - 'productImages' => [], - 'productPriceList' => [], - 'paramVOList' => [], - 'pdfUrl' => null, - 'weight' => null - ] - ] - ] - ] - ])); - - $this->httpClient->setResponseFactory([$mockResponse]); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Multiple parts found with ID ambiguous'); - - $this->provider->getDetails('ambiguous'); - } - - public function testSanitizeFieldPrivateMethod(): void - { - $reflection = new \ReflectionClass($this->provider); - $method = $reflection->getMethod('sanitizeField'); - $method->setAccessible(true); - - $this->assertNull($method->invokeArgs($this->provider, [null])); - $this->assertEquals('Clean text', $method->invokeArgs($this->provider, ['Clean text'])); - $this->assertEquals('Text without tags', $method->invokeArgs($this->provider, ['Text without tags'])); - } - - public function testGetUsedCurrencyPrivateMethod(): void - { - $reflection = new \ReflectionClass($this->provider); - $method = $reflection->getMethod('getUsedCurrency'); - $method->setAccessible(true); - - $this->assertEquals('USD', $method->invokeArgs($this->provider, ['US$'])); - $this->assertEquals('USD', $method->invokeArgs($this->provider, ['$'])); - $this->assertEquals('EUR', $method->invokeArgs($this->provider, ['€'])); - $this->assertEquals('GBP', $method->invokeArgs($this->provider, ['Β£'])); - $this->assertEquals('USD', $method->invokeArgs($this->provider, ['UNKNOWN'])); // fallback to configured currency - } - - public function testGetProductShortURLPrivateMethod(): void - { - $reflection = new \ReflectionClass($this->provider); - $method = $reflection->getMethod('getProductShortURL'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->provider, ['C123456']); - $this->assertEquals('https://www.lcsc.com/product-detail/C123456.html', $result); - } - - public function testGetProductDatasheetsPrivateMethod(): void - { - $reflection = new \ReflectionClass($this->provider); - $method = $reflection->getMethod('getProductDatasheets'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->provider, [null]); - $this->assertIsArray($result); - $this->assertEmpty($result); - - $result = $method->invokeArgs($this->provider, ['https://example.com/datasheet.pdf']); - $this->assertIsArray($result); - $this->assertCount(1, $result); - $this->assertInstanceOf(FileDTO::class, $result[0]); - } - - public function testGetProductImagesPrivateMethod(): void - { - $reflection = new \ReflectionClass($this->provider); - $method = $reflection->getMethod('getProductImages'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->provider, [null]); - $this->assertIsArray($result); - $this->assertEmpty($result); - - $result = $method->invokeArgs($this->provider, [['https://example.com/image1.jpg', 'https://example.com/image2.jpg']]); - $this->assertIsArray($result); - $this->assertCount(2, $result); - $this->assertInstanceOf(FileDTO::class, $result[0]); - $this->assertInstanceOf(FileDTO::class, $result[1]); - } - - public function testAttributesToParametersPrivateMethod(): void - { - $reflection = new \ReflectionClass($this->provider); - $method = $reflection->getMethod('attributesToParameters'); - $method->setAccessible(true); - - $attributes = [ - ['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΞ©'], - ['paramNameEn' => 'Tolerance', 'paramValueEn' => '1%'], - ['paramNameEn' => 'Empty', 'paramValueEn' => ''], - ['paramNameEn' => 'Dash', 'paramValueEn' => '-'] - ]; - - $result = $method->invokeArgs($this->provider, [$attributes]); - $this->assertIsArray($result); - $this->assertCount(2, $result); // Only non-empty values - $this->assertInstanceOf(ParameterDTO::class, $result[0]); - $this->assertInstanceOf(ParameterDTO::class, $result[1]); - } - - public function testPricesToVendorInfoPrivateMethod(): void - { - $reflection = new \ReflectionClass($this->provider); - $method = $reflection->getMethod('pricesToVendorInfo'); - $method->setAccessible(true); - - $prices = [ - ['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$'], - ['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => 'US$'] - ]; - - $result = $method->invokeArgs($this->provider, ['C123456', 'https://example.com', $prices]); - $this->assertIsArray($result); - $this->assertCount(1, $result); - $this->assertInstanceOf(PurchaseInfoDTO::class, $result[0]); - $this->assertEquals('LCSC', $result[0]->distributor_name); - $this->assertEquals('C123456', $result[0]->order_number); - $this->assertCount(2, $result[0]->prices); - } - - public function testCategoryBuilding(): void - { - $mockResponse = new MockResponse(json_encode([ - 'result' => [ - 'productCode' => 'C123456', - 'productModel' => 'Test Component', - 'productIntroEn' => 'Test description', - 'brandNameEn' => 'Test Manufacturer', - 'parentCatalogName' => 'Electronic Components', - 'catalogName' => 'Resistors (SMT)', - 'encapStandard' => '0603', - 'productImageUrl' => null, - 'productImages' => [], - 'productPriceList' => [], - 'paramVOList' => [], - 'pdfUrl' => null, - 'weight' => null - ] - ])); - - $this->httpClient->setResponseFactory([$mockResponse]); - - $result = $this->provider->getDetails('C123456'); - $this->assertEquals('Electronic Components -> Resistors (SMT)', $result->category); - } - - public function testEmptyFootprintHandling(): void - { - $mockResponse = new MockResponse(json_encode([ - 'result' => [ - 'productCode' => 'C123456', - 'productModel' => 'Test Component', - 'productIntroEn' => 'Test description', - 'brandNameEn' => 'Test Manufacturer', - 'encapStandard' => '-', - 'productImageUrl' => null, - 'productImages' => [], - 'productPriceList' => [], - 'paramVOList' => [], - 'pdfUrl' => null, - 'weight' => null - ] - ])); - - $this->httpClient->setResponseFactory([$mockResponse]); - - $result = $this->provider->getDetails('C123456'); - $this->assertNull($result->footprint); - } - - public function testSearchByKeywordsBatchWithEmptyKeywords(): void - { - $result = $this->provider->searchByKeywordsBatch([]); - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - public function testSearchByKeywordsBatchWithException(): void - { - $mockResponse = new MockResponse('', ['http_code' => 500]); - $this->httpClient->setResponseFactory([$mockResponse]); - - $results = $this->provider->searchByKeywordsBatch(['error']); - $this->assertIsArray($results); - $this->assertArrayHasKey('error', $results); - $this->assertEmpty($results['error']); - } -} diff --git a/tests/Services/Parts/PartsTableActionHandlerTest.php b/tests/Services/Parts/PartsTableActionHandlerTest.php deleted file mode 100644 index c5105cd7..00000000 --- a/tests/Services/Parts/PartsTableActionHandlerTest.php +++ /dev/null @@ -1,62 +0,0 @@ -. - */ -namespace App\Tests\Services\Parts; - -use App\Entity\Parts\Part; -use App\Services\Parts\PartsTableActionHandler; -use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; -use Symfony\Component\HttpFoundation\RedirectResponse; - -class PartsTableActionHandlerTest extends WebTestCase -{ - private PartsTableActionHandler $service; - - protected function setUp(): void - { - self::bootKernel(); - $this->service = self::getContainer()->get(PartsTableActionHandler::class); - } - - public function testExportActionsRedirectToExportController(): void - { - // Mock a Part entity with required properties - $part = $this->createMock(Part::class); - $part->method('getId')->willReturn(1); - $part->method('getName')->willReturn('Test Part'); - - $selected_parts = [$part]; - - // Test each export format, focusing on our new xlsx format - $formats = ['json', 'csv', 'xml', 'yaml', 'xlsx']; - - foreach ($formats as $format) { - $action = "export_{$format}"; - $result = $this->service->handleAction($action, $selected_parts, 1, '/test'); - - $this->assertInstanceOf(RedirectResponse::class, $result); - $this->assertStringContainsString('parts/export', $result->getTargetUrl()); - $this->assertStringContainsString("format={$format}", $result->getTargetUrl()); - } - } - -} \ No newline at end of file diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index b109eb6f..be1e6348 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -8925,12 +8925,6 @@ Element 1 -> Element 1.2 Edit part - - - part_list.action.scrollable_hint - Scroll to see all actions - - part_list.action.action.title @@ -9321,84 +9315,6 @@ Element 1 -> Element 1.2 Attachment name - - - filter.bulk_import_job.label - Bulk Import Job - - - - - filter.bulk_import_job.job_status - Job Status - - - - - filter.bulk_import_job.part_status_in_job - Part Status in Job - - - - - filter.bulk_import_job.status.any - Any Status - - - - - filter.bulk_import_job.status.pending - Pending - - - - - filter.bulk_import_job.status.in_progress - In Progress - - - - - filter.bulk_import_job.status.completed - Completed - - - - - filter.bulk_import_job.status.stopped - Stopped - - - - - filter.bulk_import_job.status.failed - Failed - - - - - filter.bulk_import_job.part_status.any - Any Part Status - - - - - filter.bulk_import_job.part_status.pending - Pending - - - - - filter.bulk_import_job.part_status.completed - Completed - - - - - filter.bulk_import_job.part_status.skipped - Skipped - - filter.choice_constraint.operator.ANY @@ -10971,12 +10887,6 @@ Element 1 -> Element 1.2 Export to XML - - - part_list.action.export_xlsx - Export to Excel - - parts.import.title @@ -12284,7 +12194,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g info_providers.search.no_results - No results found + No results found at the selected providers! Check your search term or try to choose additional providers. @@ -12404,7 +12314,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.element14.apiKey.help - https://partner.element14.com/.]]> + You can register for an API key on <a href="https://partner.element14.com/">https://partner.element14.com/</a>. @@ -12416,7 +12326,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.element14.storeId.help - here for a list of valid domains.]]> + The store domain to retrieve the data from. This decides the language and currency of results. See <a href="https://partner.element14.com/docs/Product_Search_API_REST__Description">here</a> for a list of valid domains. @@ -12434,7 +12344,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.tme.token.help - https://developers.tme.eu/en/.]]> + You can get an API token and secret on <a href="https://developers.tme.eu/en/">https://developers.tme.eu/en/</a>. @@ -12482,7 +12392,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.mouser.apiKey.help - https://eu.mouser.com/api-hub/.]]> + You can register for an API key on <a href="https://eu.mouser.com/api-hub/">https://eu.mouser.com/api-hub/</a>. @@ -12560,7 +12470,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.attachments - + Attachments & Files @@ -12584,7 +12494,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.attachments.allowDownloads.help - Attention: This can be a security issue, as it might allow users to access intranet ressources via Part-DB!]]> + With this option users can download external files into Part-DB by providing an URL. <b>Attention: This can be a security issue, as it might allow users to access intranet ressources via Part-DB!</b> @@ -12758,8 +12668,8 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.system.localization.base_currency_description - Please note that the currencies are not converted, when changing this value. So changing the default currency after you already added price information, will result in wrong prices!]]> + The currency that is used to store price information and exchange rates in. This currency is assumed, when no currency is set for a price information. +<b>Please note that the currencies are not converted, when changing this value. So changing the default currency after you already added price information, will result in wrong prices!</b> @@ -12789,7 +12699,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.misc.kicad_eda.category_depth.help - 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad.]]> + This value determines the depth of the category tree, that is visible inside KiCad. 0 means that only the top level categories are visible. Set to a value > 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad. @@ -12807,7 +12717,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.behavior.sidebar.items.help - + The menus which appear at the sidebar by default. Order of items can be changed via drag & drop. @@ -12855,7 +12765,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.behavior.table.parts_default_columns.help - + The columns to show by default in part tables. Order of items can be changed via drag & drop. @@ -12909,7 +12819,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g settings.ips.oemsecrets.sortMode.M - + Completeness & Manufacturer name @@ -13572,803 +13482,5 @@ Please note, that you can not impersonate a disabled user. If you try you will g Preview image min width (px) - - - info_providers.bulk_import.step1.title - Bulk Info Provider Import - Step 1 - - - - - info_providers.bulk_import.parts_selected - parts selected - - - - - info_providers.bulk_import.step1.global_mapping_description - Configure field mappings that will be applied to all selected parts. For example: "MPN β†’ LCSC + Mouser" means search LCSC and Mouser providers using each part's MPN field. - - - - - info_providers.bulk_import.selected_parts - Selected Parts - - - - - info_providers.bulk_import.field_mappings - Field Mappings - - - - - info_providers.bulk_import.field_mappings_help - Define which part fields to search with which info providers. Multiple mappings will be combined. - - - - - info_providers.bulk_import.add_mapping - Add Mapping - - - - - info_providers.bulk_import.search_results.title - Search Results - - - - - info_providers.bulk_import.errors - errors - - - - - info_providers.bulk_import.results_found - %count% results found - - - - - info_providers.bulk_import.source_field - Source Field - - - - - info_providers.bulk_import.create_part - Create Part - - - - - info_providers.bulk_import.view_existing - View Existing - - - - - info_providers.bulk_search.search_field - Search Field - - - - - info_providers.bulk_search.providers - Info Providers - - - - - info_providers.bulk_import.actions.label - Actions - - - - - info_providers.bulk_search.providers.help - Select which info providers to search when parts have this field. - - - - - info_providers.bulk_search.submit - Search All Parts - - - - - info_providers.bulk_search.field.select - Select a field to search by - - - - - info_providers.bulk_search.field.mpn - Manufacturer Part Number (MPN) - - - - - info_providers.bulk_search.field.name - Part Name - - - - - part_list.action.action.info_provider - Info Provider - - - - - part_list.action.bulk_info_provider_import - Bulk Info Provider Import - - - - - info_providers.bulk_import.clear_selections - Clear All Selections - - - - - info_providers.bulk_import.clear_row - Clear this row's selections - - - - - info_providers.bulk_import.step1.spn_recommendation - SPN (Supplier Part Number) is recommended for better results. Add a mapping for each supplier to use their SPNs. - - - - - info_providers.bulk_import.update_part - Update Part - - - - - info_providers.bulk_import.prefetch_details - Prefetch Details - - - - - info_providers.bulk_import.prefetch_details_help - Prefetch details for all results. This will take longer, but will speed up workflow for updating parts. - - - - - info_providers.bulk_import.step2.title - Bulk import from info providers - - - - - info_providers.bulk_import.step2.card_title - Bulk import for %count% parts - %date% - - - - - info_providers.bulk_import.parts - parts - - - - - info_providers.bulk_import.results - results - - - - - info_providers.bulk_import.created_at - Created at - - - - - info_providers.bulk_import.status.in_progress - In Progress - - - - - info_providers.bulk_import.status.completed - Completed - - - - - info_providers.bulk_import.status.failed - Failed - - - - - info_providers.bulk_import.table.name - Name - - - - - info_providers.bulk_import.table.description - Description - - - - - info_providers.bulk_import.table.manufacturer - Manufacturer - - - - - info_providers.bulk_import.table.provider - Provider - - - - - info_providers.bulk_import.table.source_field - Source Field - - - - - info_providers.bulk_import.table.action - Action - - - - - info_providers.bulk_import.action.select - Select - - - - - info_providers.bulk_import.action.deselect - Deselect - - - - - info_providers.bulk_import.action.view_details - View Details - - - - - info_providers.bulk_import.no_results - No results found - - - - - info_providers.bulk_import.processing - Processing... - - - - - info_providers.bulk_import.error - Error occurred during import - - - - - info_providers.bulk_import.success - Import completed successfully - - - - - info_providers.bulk_import.partial_success - Import completed with some errors - - - - - info_providers.bulk_import.retry - Retry - - - - - info_providers.bulk_import.cancel - Cancel - - - - - info_providers.bulk_import.confirm - Confirm Import - - - - - info_providers.bulk_import.back - Back - - - - - info_providers.bulk_import.next - Next - - - - - info_providers.bulk_import.finish - Finish - - - - - info_providers.bulk_import.progress - Progress: - - - - - info_providers.bulk_import.time_remaining - Estimated time remaining: %time% - - - - - info_providers.bulk_import.details_modal.title - Part Details - - - - - info_providers.bulk_import.details_modal.close - Close - - - - - info_providers.bulk_import.details_modal.select_this_part - Select This Part - - - - - info_providers.bulk_import.status.pending - Pending - - - - - info_providers.bulk_import.completed - completed - - - - - info_providers.bulk_import.skipped - skipped - - - - - info_providers.bulk_import.mark_completed - Mark Completed - - - - - info_providers.bulk_import.mark_skipped - Mark Skipped - - - - - info_providers.bulk_import.mark_pending - Mark Pending - - - - - info_providers.bulk_import.skip_reason - Skip reason - - - - - info_providers.bulk_import.editing_part - Editing part as part of bulk import - - - - - info_providers.bulk_import.complete - Complete - - - - - info_providers.bulk_import.existing_jobs - Existing Jobs - - - - - info_providers.bulk_import.job_name - Job Name - - - - - info_providers.bulk_import.parts_count - Parts Count - - - - - info_providers.bulk_import.results_count - Results Count - - - - - info_providers.bulk_import.progress_label - Progress: %current%/%total% - - - - - info_providers.bulk_import.manage_jobs - Manage Bulk Import Jobs - - - - - info_providers.bulk_import.view_results - View Results - - - - - info_providers.bulk_import.status - Status - - - - - info_providers.bulk_import.manage_jobs_description - View and manage all your bulk import jobs. To create a new job, select parts and click "Bulk import from info providers". - - - - - info_providers.bulk_import.no_jobs_found - No bulk import jobs found. - - - - - info_providers.bulk_import.create_first_job - Create your first bulk import job by selecting multiple parts in a part table and select the "Bulk info provider import" option. - - - - - info_providers.bulk_import.confirm_delete_job - Are you sure you want to delete this job? - - - - - info_providers.bulk_import.job_name_template - Bulk import for %count% parts - - - - - info_providers.bulk_import.step2.instructions.title - How to use bulk import - - - - - info_providers.bulk_import.step2.instructions.description - Follow these steps to efficiently update your parts: - - - - - info_providers.bulk_import.step2.instructions.step1 - Click "Update Part" to edit a part with the supplier data - - - - - info_providers.bulk_import.step2.instructions.step2 - Review and modify the part information as needed. Note: You need to click "Save" twice to save the changes. - - - - - info_providers.bulk_import.step2.instructions.step3 - Click "Complete" to mark the part as done and return to this overview - - - - - info_providers.bulk_import.created_by - Created By - - - - - info_providers.bulk_import.completed_at - Completed At - - - - - info_providers.bulk_import.action.label - Action - - - - - info_providers.bulk_import.action.delete - Delete - - - - - info_providers.bulk_import.status.active - Active - - - - - info_providers.bulk_import.progress.title - Progress - - - - - info_providers.bulk_import.progress.completed_text - %completed% / %total% completed - - - - - info_providers.bulk_import.error.deleting_job - Error deleting job - - - - - info_providers.bulk_import.error.unknown - Unknown error - - - - - info_providers.bulk_import.error.deleting_job_with_details - Error deleting job: %error% - - - - - info_providers.bulk_import.status.stopped - Stopped - - - - - info_providers.bulk_import.action.stop - Stop - - - - - info_providers.bulk_import.confirm_stop_job - Are you sure you want to stop this job? - - - - - part.filter.in_bulk_import_job - In Bulk Import Job - - - - - part.filter.in_bulk_import_job.yes - Yes - - - - - part.filter.in_bulk_import_job.no - No - - - - - part.filter.bulk_import_job_status - Bulk Import Job Status - - - - - part.filter.bulk_import_part_status - Bulk Import Part Status - - - - - part.edit.tab.bulk_import - Bulk Import Job - - - - - bulk_import.status.pending - Pending - - - - - bulk_import.status.in_progress - In Progress - - - - - bulk_import.status.completed - Completed - - - - - bulk_import.status.stopped - Stopped - - - - - bulk_import.status.failed - Failed - - - - - bulk_import.part_status.pending - Pending - - - - - bulk_import.part_status.completed - Completed - - - - - bulk_import.part_status.skipped - Skipped - - - - - bulk_import.part_status.failed - Failed - - - - - filter.operator - Operator - - - - - bulk_info_provider_import_job.label - Bulk info provider import - - - - - bulk_info_provider_import_job_part.label - Bulk Import Job Part - - - - - info_providers.bulk_search.priority - Priority - - - - - info_providers.bulk_search.priority.help - Lower numbers = higher priority. Same priority = combine results. Different priorities = try highest first, fallback if no results. - - - - - info_providers.bulk_import.priority_system.title - Priority System - - - - - info_providers.bulk_import.priority_system.description - Lower numbers = higher priority. Same priority = combine results. Different priorities = try highest first, fallback if no results. - - - - - info_providers.bulk_import.priority_system.example - Example: Priority 1: "LCSC SPN β†’ LCSC", Priority 2: "MPN β†’ LCSC + Mouser", Priority 3: "Name β†’ All providers" - - - - - info_providers.bulk_import.search.submit - Search Providers - - - - - info_providers.bulk_import.searching - Searching - - - - - info_providers.bulk_import.research.title - Research Parts - - - - - info_providers.bulk_import.research.description - Re-search for parts using updated information (e.g., new MPNs). Uses the same field mappings as the original search. - - - - - info_providers.bulk_import.research.all_pending - Research All Pending Parts - - - - - info_providers.bulk_import.research.part - Research - - - - - info_providers.bulk_import.research.part_tooltip - Research this part with updated information - - - - - info_providers.bulk_import.max_mappings_reached - Maximum number of mappings reached - -