Compare commits

..

89 Commits

Author SHA1 Message Date
Jan Böhmer
ab92620f56 Merge remote-tracking branch 'origin/l10n_master' 2025-09-23 23:39:29 +02:00
Jan Böhmer
0a4b873b77 New translations messages.en.xlf (German) 2025-09-23 23:38:41 +02:00
Jan Böhmer
23bafa4471 Bumped version to 2.2.0 2025-09-23 23:38:32 +02:00
Jan Böhmer
436d3df83f New Crowdin updates (#1050)
* New translations messages.en.xlf (English)

* New translations messages.en.xlf (English)

* New translations messages.en.xlf (German)
2025-09-23 23:34:51 +02:00
Jan Böhmer
37393dd6c9 Revert "Removed more unused translations"
This reverts commit 8c15af3105.
2025-09-23 23:12:31 +02:00
Jan Böhmer
8c15af3105 Removed more unused translations 2025-09-23 23:11:48 +02:00
Jan Böhmer
0ac1d19415 Removed unused translations related to bulk imports 2025-09-23 23:06:30 +02:00
Jan Böhmer
63a33d1057 Fixed deprecations 2025-09-23 20:55:22 +02:00
Jan Böhmer
a9d0caad5f Fixed some deprecations 2025-09-23 00:03:04 +02:00
Jan Böhmer
6ed4ad4c8c Use native lazy objects for doctrine when on PHP8.4 2025-09-22 23:52:31 +02:00
Jan Böhmer
71946afd75 Updated dependencies 2025-09-22 22:45:58 +02:00
Jan Böhmer
919bf49ec1 Fix the wrong currency code mouser returns for chinese yuan
This fixes issue #1045
2025-09-22 00:20:52 +02:00
Jan Böhmer
001f2e97ea Do not let phpunit fail on deprecations 2025-09-22 00:12:29 +02:00
Jan Böhmer
d2d5490aab Merge remote-tracking branch 'origin/master' 2025-09-22 00:06:25 +02:00
Jan Böhmer
c788fa99e3 Use an older version of maennchen/zipstream-php which is capable of running on 32-bit systems 2025-09-22 00:06:21 +02:00
Jan Böhmer
34d284b1c4 Do not test against real LCSC provider... 2025-09-22 00:05:49 +02:00
dependabot[bot]
67c736f979 Bump actions/setup-node from 4 to 5 (#1034)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-21 23:52:02 +02:00
Jan Böhmer
6b1e7b3544 Run phpunit tests with PHP8.5 2025-09-21 23:51:36 +02:00
Jan Böhmer
da30a6657e Use the new LCSC endpoint for batch searches 2025-09-21 23:51:11 +02:00
Jan Böhmer
2e0b5edd95 Merge remote-tracking branch 'origin/master' 2025-09-21 23:43:16 +02:00
Jan Böhmer
1d6f0b403a Moved scoll to see more actions hint into optgroup title 2025-09-21 23:43:10 +02:00
Jan Böhmer
df65f39d5e Fixed phpunit tests 2025-09-21 23:33:56 +02:00
Jan Böhmer
1bfea3c48a Fixed phpstan issues 2025-09-21 23:27:56 +02:00
Jan Böhmer
07db1554c7 Updated dependencies 2025-09-21 23:15:28 +02:00
Jan Böhmer
ed1e51f694 Merge branch 'feature/batch-info-provider-import' 2025-09-21 23:14:09 +02:00
Jan Böhmer
5b71d68179 Added tests for new DTO objects 2025-09-21 23:07:45 +02:00
Jan Böhmer
b94e28a961 Added a bit of documentation about the new features 2025-09-21 22:52:23 +02:00
Jan Böhmer
1d52b7c464 Fixed tests 2025-09-21 21:35:13 +02:00
Jan Böhmer
0d49632b92 Refactored constraints, to reuse existing mechanisms 2025-09-21 20:45:18 +02:00
Jan Böhmer
702e5c8732 Use underscore in route paths instead of hyphens to match the other path styles 2025-09-21 19:58:15 +02:00
Jan Böhmer
d2b605edc0 Imrpoved bulk info provider manage page 2025-09-21 19:54:40 +02:00
Jan Böhmer
4c28871283 Fixed problem of failing researchAllParts
This maybe should be revisited in the future, but for now this fix should work
2025-09-21 19:47:49 +02:00
Jan Böhmer
1d38c50abc Fixed step2 template 2025-09-21 19:30:49 +02:00
Jan Böhmer
710569daaf Fixed phpunit tests 2025-09-21 19:03:29 +02:00
Jan Böhmer
92cd645945 Renamed dto to make their relation to batch searches more clear 2025-09-21 17:49:00 +02:00
Jan Böhmer
16126c4000 Encapsulate the fieldmapping data in the importjob further 2025-09-21 17:41:56 +02:00
Jan Böhmer
eda6deff47 Made classes readonly where possible 2025-09-21 14:25:57 +02:00
Jan Böhmer
27a18bdc1e Doing refactoring to remove remains of arrays 2025-09-21 14:24:34 +02:00
Jan Böhmer
98b62cc81e Do not autowire bulkImport parameters globally 2025-09-20 14:33:16 +02:00
barisgit
2c195d9767 Refactor bulk info provider: replace complex arrays with DTOs
- Add BulkSearchResponseDTO, FieldMappingDTO for type safety
- Use composition instead of inheritance in BulkSearchResultDTO
- Remove unnecessary BulkSearchRequestDTO
- Fix N+1 queries and API error handling
- Fix Add Mapping button functionality
2025-09-19 16:28:40 +02:00
Jan Böhmer
bb49c67108 Removed Microsoft X-XSS-Protection header, as it is not recommended on modern browsers anymore and is considered deprecated 2025-09-19 09:18:49 +02:00
Jan Böhmer
f0dc80aac9 New Crowdin updates (#1036)
* New translations messages.en.xlf (German)

* New translations validators.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations security.en.xlf (German)

* New translations validators.en.xlf (Dutch)

* New translations messages.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations security.en.xlf (German)

* New translations messages.en.xlf (German)
2025-09-19 09:12:47 +02:00
Jan Böhmer
8998b006e0 Added some type hints for arrays 2025-09-14 23:17:43 +02:00
Jan Böhmer
b4b758c356 Fixed tests 2025-09-14 23:14:00 +02:00
Jan Böhmer
a399b629d1 Use a proper range constraint on the form
Otherwise it is possible to inject invalid data
2025-09-14 23:04:44 +02:00
Jan Böhmer
41a7238ab7 Pass parts object directly to BulkSearchRequestDTO and added some syntax hints 2025-09-14 22:56:12 +02:00
Jan Böhmer
0e99faee0a Moved BulkImportJobStatus enum to own file to make it discoverable by autoloading 2025-09-14 22:23:07 +02:00
Jan Böhmer
13e75808f8 Use validateJobAccess where applicable and ensure permissions for all controller endpoints 2025-09-14 16:24:56 +02:00
Jan Böhmer
1a0fab0615 Use a deterministic method to generate parameter names for filters, to allow for proper caching of queries 2025-09-09 23:05:03 +02:00
Jan Böhmer
fcdeb0479a Bumped version 2.1.2 2025-09-09 21:31:15 +02:00
Jan Böhmer
79ac318d0f Merge remote-tracking branch 'origin/l10n_master' 2025-09-09 21:30:22 +02:00
Jan Böhmer
6765c110c6 New translations messages.en.xlf (English) 2025-09-09 21:23:52 +02:00
Jan Böhmer
f6f83cc111 New translations messages.en.xlf (Czech) 2025-09-09 21:23:41 +02:00
d-buchmann
c6d5fb3f57 Update translations
When the part count notice is always displayed, the exclamation mark would probably be perceived as rather annoying.
(Of course this would have to be reflected in crowdin)
2025-09-09 21:20:42 +02:00
Jan Böhmer
4b8ef4b0fa Allow the defaultSearchProviders option to be empty
This fixes issue #1032
2025-09-09 21:19:12 +02:00
barisgit
46d8c86e0c Improved makefile 2025-09-09 20:57:58 +02:00
barisgit
c7102bcd8c Update bulk info provider test to work with new services approach 2025-09-09 20:54:27 +02:00
barisgit
d6ac16ede0 Refactor bulk import functionality to make controller smaller (use services) add DTOs and use stimulus controllers on frontend 2025-09-09 20:30:27 +02:00
Jan Böhmer
23cad8261b New translations messages.en.xlf (Czech) 2025-09-09 14:12:30 +02:00
barisgit
65d840c444 Fix invalid flag --memory-limit 2025-09-09 10:31:33 +02:00
Jan Böhmer
c52126ccf8 Update PHP version badge in README to 8.2 2025-09-08 12:53:03 +02:00
Jan Böhmer
52444e05e4 Optimized LCSC batch search calls and extracted it into interface for potential general use in the future 2025-08-31 23:41:16 +02:00
Jan Böhmer
4fcd55748f Use new settings object in LCSCProvider 2025-08-31 23:27:53 +02:00
Jan Böhmer
d57107ed3e Do not use ob_* functions in XSLX exporter, as this affects global state and can lead to sideffects 2025-08-31 23:05:07 +02:00
Jan Böhmer
0c7aa5e92a Fixed phpunit tests 2025-08-31 22:56:10 +02:00
Jan Böhmer
17f123ba8a Fixed logentryRepositoryTest
It seems that this was always wrong, but this was never noticed, because normally the log timestamps are all the same
2025-08-31 22:51:47 +02:00
Jan Böhmer
1156bb52af Added phpoffice dependency 2025-08-31 22:50:56 +02:00
barisgit
71be75b3e7 Improve test coverage 2025-08-31 22:18:25 +02:00
barisgit
5a4f151ca3 Add BulkInfoProviderImportJobPart to element type name generator 2025-08-31 22:18:25 +02:00
barisgit
9729a43f2b Add bulk_info_provider_import_job_part.label 2025-08-31 22:18:24 +02:00
barisgit
4da403569c Increase time limit on batch search and add option to priorities which fields to choose 2025-08-31 22:18:24 +02:00
barisgit
74be016b68 Add abbility to search faster on LCSC without details 2025-08-31 22:18:24 +02:00
barisgit
3896d3d9ab Fix a single failing test 2025-08-31 22:18:24 +02:00
barisgit
ed396765c8 Let symfony manage translations 2025-08-31 22:18:24 +02:00
barisgit
cc9d50a8fe Add makefile to help with development setup, change part_ids in bulk import jobs to junction table and implement filtering based on bulk import jobs status and its associated parts' statuses. 2025-08-31 22:17:05 +02:00
barisgit
9b4d5e9c27 Improve test coverage 2025-08-31 22:16:28 +02:00
barisgit
ccb837e4b4 Fix migration error and dto error 2025-08-31 22:16:28 +02:00
barisgit
2bc39e7791 Add tests and fix static errors 2025-08-31 22:16:27 +02:00
barisgit
fa7f3a1da1 Fix tests 2025-08-31 22:16:27 +02:00
barisgit
c91d37d2a4 More sophisticated two-step bulk import from info providers 2025-08-31 22:16:27 +02:00
barisgit
5ab7ac4d4b Move pageSize and table columns filter buttons apart a bit 2025-08-31 22:16:27 +02:00
barisgit
4c8940f9c3 Simple batch processing 2025-08-31 22:16:27 +02:00
barisgit
aa29f10d51 Remove problematic tests 2025-08-31 22:15:58 +02:00
barisgit
78885ec3c5 Add more tests and fix failing ones 2025-08-31 22:15:58 +02:00
barisgit
1fb137e89f Add export functionality to batch select and fix errors 2025-08-31 22:15:58 +02:00
barisgit
facfb37383 Implement excel based import/export 2025-08-31 22:15:58 +02:00
barisgit
c5751b2aa6 Fix timestamp test 2025-08-31 22:13:54 +02:00
barisgit
aa4299041b Update example import csv to schow real capatibilities 2025-08-31 22:13:54 +02:00
barisgit
c27f2246a3 Update part merger to consider rows with same supplier and spn duplicates 2025-08-31 22:13:54 +02:00
117 changed files with 21672 additions and 10536 deletions

View File

@@ -60,7 +60,7 @@ jobs:
${{ runner.os }}-yarn-
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '20'

View File

@@ -21,7 +21,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php-versions: ['8.2', '8.3', '8.4' ]
php-versions: ['8.2', '8.3', '8.4', '8.5' ]
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@v4
uses: actions/setup-node@v5
with:
node-version: '20'

3
.gitignore vendored
View File

@@ -48,3 +48,6 @@ yarn-error.log
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
.claude/
CLAUDE.md

91
Makefile Normal file
View File

@@ -0,0 +1,91 @@
# 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!"

View File

@@ -3,7 +3,7 @@
![Static analysis](https://github.com/Part-DB/Part-DB-symfony/workflows/Static%20analysis/badge.svg)
[![codecov](https://codecov.io/gh/Part-DB/Part-DB-server/branch/master/graph/badge.svg)](https://codecov.io/gh/Part-DB/Part-DB-server)
![GitHub License](https://img.shields.io/github/license/Part-DB/Part-DB-symfony)
![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.1-green)
![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.2-green)
![Docker Pulls](https://img.shields.io/docker/pulls/jbtronics/part-db1)
![Docker Build Status](https://github.com/Part-DB/Part-DB-symfony/workflows/Docker%20Image%20Build/badge.svg)

View File

@@ -1 +1 @@
2.1.1
2.2.0

View File

@@ -0,0 +1,359 @@
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 = `
<i class="fas fa-check-circle"></i>
Job completed! All parts have been processed.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`
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 = `
<div class="alert ${alertClass} alert-dismissible fade show position-fixed"
style="top: 20px; right: 20px; z-index: 9999; max-width: 400px;"
id="${alertId}">
<i class="fas ${iconClass} me-2"></i>
${message}
<button type="button" class="btn-close" onclick="this.parentElement.remove()" aria-label="Close"></button>
</div>
`
// 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)
}
}

View File

@@ -0,0 +1,92 @@
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)
}
}
}
}

View File

@@ -0,0 +1,136 @@
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 = `
<td>${fieldWidget ? fieldWidget.outerHTML : ''}</td>
<td>${providerWidget ? providerWidget.outerHTML : ''}</td>
<td>${priorityWidget ? priorityWidget.outerHTML : ''}</td>
<td>
<button type="button" class="btn btn-danger btn-sm" data-action="click->field-mapping#removeMapping">
<i class="fas fa-trash"></i>
</button>
</td>
`
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
}
})
}
}
}

View File

@@ -94,6 +94,11 @@ 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
{

View File

@@ -25,7 +25,6 @@
"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",
@@ -37,6 +36,7 @@
"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,6 +45,8 @@
"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",
@@ -157,7 +159,7 @@
"post-update-cmd": [
"@auto-scripts"
],
"phpstan": "vendor/bin/phpstan analyse src --level 5 --memory-limit 1G"
"phpstan": "php -d memory_limit=1G vendor/bin/phpstan analyse src --level 5"
},
"conflict": {
"symfony/symfony": "*"

981
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 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 <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
/**
* This class extends the default doctrine ORM configuration to enable native lazy objects on PHP 8.4+.
* We have to do this in a PHP file, because the yaml file does not support conditionals on PHP version.
*/
return static function(\Symfony\Config\DoctrineConfig $doctrine) {
//On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies.
if (PHP_VERSION_ID >= 80400) {
$doctrine->orm()->enableNativeLazyObjects(true);
}
};

View File

@@ -20,12 +20,6 @@ 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).

View File

@@ -104,3 +104,9 @@ 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

View File

@@ -1,4 +1,7 @@
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;<b>HTML</b>;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active
Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter;
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
1 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 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
2 BC547 MLCC; 0603; 0.22uF NPN transistor Multilayer ceramic capacitor Transistors -> NPN Electrical Components->Passive Components->Capacitors_SMD very important notes High quality MLCC TO -> TO-92 0603 NPN,Transistor Capacitor,SMD,MLCC,0603 5 500 Room 1 -> Shelf 1 -> Box 2 Room 1->Shelf 1->Box 2 10 0.1 CL10B224KO8NNNC CL10B224KO8NNNC Manufacturer active Samsung You need to fill this line, to use spn and price LCSC BC547C C160828 2,3 0.0023 0 0 1 pcs C 0.22uF 1 0 0 0 Device:C Capacitor_SMD:C_0603_1608Metric
3 BC557 MLCC; 0402; 10pF PNP transistor Small MLCC for high frequency <b>HTML</b> Electrical Components->Passive Components->Capacitors_SMD TO -> TO-92 0402 PNP,Transistor Capacitor,SMD,MLCC,0402 10 500 Room 2-> Box 3 Room 1->Shelf 1->Box 3 0.05 Internal1234 FCC0402N100J500AT FCC0402N100J500AT active Fenghua LCSC C5137557 0.0015 0 1 0 1 pcs active C 10pF 1 0 0 0 Device:C Capacitor_SMD:C_0402_1005Metric
4 Copper Wire Diode; 1N4148W Fast switching diode Wire 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 Meter pcs D 1N4148W 1 0 0 0 Device:D Diode_SMD:D_SOD-123
5 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
6 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
7 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

View File

@@ -142,6 +142,9 @@ 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:

View File

@@ -68,6 +68,13 @@ 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.

View File

@@ -1,112 +1,91 @@
# PartDB Makefile for Test Environment Management
.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
.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:
@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"
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)
# Install PHP dependencies with unlimited memory
deps-install:
# 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: deps-install test-clean test-db-create test-db-migrate test-fixtures
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:
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:
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:
test-db-migrate: ## Run database migrations for test environment
@echo "🔄 Running database migrations..."
php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env test
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test
# Clear test cache
test-cache-clear:
test-cache-clear: ## Clear test cache
@echo "🗑️ Clearing test cache..."
rm -rf var/cache/test
@echo "✅ Test cache cleared"
# Load test fixtures
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:
test-run: ## Run PHPUnit tests
@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: deps-install dev-clean dev-db-create dev-db-migrate dev-warmup
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:
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:
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:
dev-db-migrate: ## Run database migrations for development environment
@echo "🔄 Running database migrations..."
php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env dev
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
dev-cache-clear:
dev-cache-clear: ## Clear development cache
@echo "🗑️ Clearing development cache..."
php -d memory_limit=1G bin/console cache:clear --env dev -n
rm -rf var/cache/dev
@echo "✅ Development cache cleared"
dev-warmup:
dev-warmup: ## Warm up development cache
@echo "🔥 Warming up development cache..."
php -d memory_limit=1G bin/console cache:warmup --env dev -n
COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n
dev-reset: dev-cache-clear dev-db-migrate
dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate)
@echo "✅ Development environment reset complete!"

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250802205143 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add bulk info provider import jobs and job parts tables';
}
public function mySQLUp(Schema $schema): void
{
$this->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');
}
}

View File

@@ -4,7 +4,7 @@
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnDeprecation="false"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"

View File

@@ -0,0 +1,588 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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()
]
);
}
}
}

View File

@@ -64,14 +64,17 @@ use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
#[Route(path: '/part')]
class PartController extends AbstractController
final class PartController extends AbstractController
{
public function __construct(protected PricedetailHelper $pricedetailHelper,
protected PartPreviewGenerator $partPreviewGenerator,
public function __construct(
private readonly PricedetailHelper $pricedetailHelper,
private readonly PartPreviewGenerator $partPreviewGenerator,
private readonly TranslatorInterface $translator,
private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
protected EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings)
{
private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
private readonly EntityManagerInterface $em,
private readonly EventCommentHelper $commentHelper,
private readonly PartInfoSettings $partInfoSettings,
) {
}
/**
@@ -80,9 +83,16 @@ 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;
@@ -132,7 +142,43 @@ class PartController extends AbstractController
{
$this->denyAccessUnlessGranted('edit', $part);
return $this->renderPartForm('edit', $request, $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]);
}
#[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])]
@@ -140,7 +186,7 @@ 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));
@@ -159,11 +205,15 @@ 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
@@ -258,9 +308,14 @@ 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');
@@ -274,10 +329,22 @@ 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
'tname_before' => $old_name,
'bulk_job' => $bulkJob
]);
}
@@ -312,7 +379,7 @@ 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()
);
}
}
@@ -353,6 +420,12 @@ 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()]);
}
@@ -371,13 +444,17 @@ 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
]);
'merge_other' => $merge_infos['other_part'] ?? null,
'bulk_job' => $merge_infos['bulk_job'] ?? null,
'jobId' => $request->query->get('jobId')
]
);
}
@@ -387,17 +464,17 @@ 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!");
}
@@ -411,12 +488,12 @@ 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!");
}
@@ -460,7 +537,7 @@ 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

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
@@ -32,6 +33,7 @@ use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Attachments\AttachmentType;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Filter\AbstractFilter;
class AttachmentFilter implements FilterInterface
{
@@ -51,6 +53,9 @@ class AttachmentFilter implements FilterInterface
public function __construct(NodesListBuilder $nodesListBuilder)
{
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
AbstractConstraint::resetParameterCounter();
$this->dbId = new IntConstraint('attachment.id');
$this->name = new TextConstraint('attachment.name');
$this->targetType = new InstanceOfConstraint('attachment');

View File

@@ -28,10 +28,7 @@ abstract class AbstractConstraint implements FilterInterface
{
use FilterTrait;
/**
* @var string
*/
protected string $identifier;
protected ?string $identifier;
/**

View File

@@ -28,6 +28,7 @@ trait FilterTrait
{
protected bool $useHaving = false;
protected static int $parameterCounter = 0;
public function useHaving($value = true): static
{
@@ -50,8 +51,18 @@ trait FilterTrait
{
//Replace all special characters with underscores
$property = preg_replace('/\W/', '_', $property);
//Add a random number to the end of the property name for uniqueness
return $property . '_' . uniqid("", false);
return $property . '_' . (self::$parameterCounter++) . '_';
}
/**
* Resets the parameter counter, so the next call to generateParameterIdentifier will start from 0 again.
* This should be done before initializing a new set of filters to a fresh query builder, to ensure that the parameter
* identifiers are deterministic so that they are cacheable.
* @return void
*/
public static function resetParameterCounter(): void
{
self::$parameterCounter = 0;
}
/**

View File

@@ -0,0 +1,59 @@
<?php
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 <https://www.gnu.org/licenses/>.
*/
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() . ')');
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
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 <https://www.gnu.org/licenses/>.
*/
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);
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
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 <https://www.gnu.org/licenses/>.
*/
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);
}
}
}

View File

@@ -88,7 +88,7 @@ class TagsConstraint extends AbstractConstraint
//Escape any %, _ or \ in the tag
$tag = addcslashes($tag, '%_\\');
$tag_identifier_prefix = uniqid($this->identifier . '_', false);
$tag_identifier_prefix = $this->generateParameterIdentifier('tag');
$expr = $queryBuilder->expr();

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
@@ -44,6 +45,9 @@ class LogFilter implements FilterInterface
public function __construct()
{
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
AbstractConstraint::resetParameterCounter();
$this->timestamp = new DateTimeConstraint('log.timestamp');
$this->dbId = new IntConstraint('log.id');
$this->level = new ChoiceConstraint('log.level');

View File

@@ -22,12 +22,16 @@ declare(strict_types=1);
*/
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
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;
@@ -101,8 +105,19 @@ 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.
AbstractConstraint::resetParameterCounter();
$this->name = new TextConstraint('part.name');
$this->description = new TextConstraint('part.description');
$this->comment = new TextConstraint('part.comment');
@@ -126,7 +141,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())
@@ -162,6 +177,11 @@ 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

View File

@@ -21,6 +21,7 @@ declare(strict_types=1);
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use Doctrine\ORM\QueryBuilder;
class PartSearchFilter implements FilterInterface

View File

@@ -142,23 +142,25 @@ 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 '';
@@ -167,7 +169,7 @@ final class PartsDataTable implements DataTableTypeInterface
$tmp = htmlspecialchars($partUnit->getName());
if ($partUnit->getUnit()) {
$tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')';
$tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')';
}
return $tmp;
}
@@ -230,7 +232,7 @@ final class PartsDataTable implements DataTableTypeInterface
}
if (count($projects) > $max) {
$tmp .= ", + ".(count($projects) - $max);
$tmp .= ", + " . (count($projects) - $max);
}
return $tmp;
@@ -366,7 +368,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())
@@ -423,6 +425,13 @@ 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;
}

View File

@@ -81,7 +81,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
/**
* @var string The website of the company
*/
#[Assert\Url]
#[Assert\Url(requireTld: false)]
#[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
#[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 255)]

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 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 <https://www.gnu.org/licenses/>.
*/
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';
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 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 <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\InfoProviderSystem;
enum BulkImportPartStatus: string
{
case PENDING = 'pending';
case COMPLETED = 'completed';
case SKIPPED = 'skipped';
case FAILED = 'failed';
}

View File

@@ -0,0 +1,449 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<int, BulkInfoProviderImportJobPart> */
#[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;
}
}

View File

@@ -0,0 +1,182 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 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 <https://www.gnu.org/licenses/>.
*/
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 <https://www.gnu.org/licenses/>.
*/
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;
}
}

View File

@@ -24,6 +24,8 @@ 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;
@@ -67,6 +69,8 @@ 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.
@@ -96,6 +100,8 @@ 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,
};
}

View File

@@ -22,8 +22,6 @@ 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;
@@ -40,10 +38,12 @@ 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,6 +59,7 @@ 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;
@@ -83,8 +84,18 @@ 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")'),
@@ -92,7 +103,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)]
@@ -100,7 +111,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'])]
@@ -160,6 +171,12 @@ class Part extends AttachmentContainingDBElement
#[Groups(['part:read'])]
protected ?\DateTimeImmutable $lastModified = null;
/**
* @var Collection<int, BulkInfoProviderImportJobPart>
*/
#[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)]
protected Collection $bulkImportJobParts;
public function __construct()
{
@@ -172,6 +189,7 @@ 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();
@@ -230,4 +248,38 @@ class Part extends AttachmentContainingDBElement
}
}
}
/**
* Get all bulk import job parts for this part
* @return Collection<int, BulkInfoProviderImportJobPart>
*/
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;
}
}

View File

@@ -49,7 +49,7 @@ trait ManufacturerTrait
/**
* @var string The url to the part on the manufacturer's homepage
*/
#[Assert\Url]
#[Assert\Url(requireTld: false)]
#[Groups(['full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $manufacturer_product_url = '';

View File

@@ -124,7 +124,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
/**
* @var string The URL to the product on the supplier's website
*/
#[Assert\Url]
#[Assert\Url(requireTld: false)]
#[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $supplier_product_url = '';

View File

@@ -170,6 +170,7 @@ 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;
}
@@ -184,6 +185,7 @@ 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;
}
@@ -215,6 +217,7 @@ 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) {

View File

@@ -59,6 +59,8 @@ class ImportType extends AbstractType
'XML' => 'xml',
'CSV' => 'csv',
'YAML' => 'yaml',
'XLSX' => 'xlsx',
'XLS' => 'xls',
],
'label' => 'export.format',
'disabled' => $disabled,

View File

@@ -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,11 +128,13 @@ 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,
]);

View File

@@ -22,9 +22,12 @@ 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;
@@ -33,8 +36,12 @@ 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;
@@ -50,6 +57,8 @@ 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)
@@ -298,6 +307,31 @@ 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',

View File

@@ -38,7 +38,7 @@ class EnforceEventCommentTypesType extends AbstractType
return EnumType::class;
}
public function configureOptions(OptionsResolver $resolver)
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'multiple' => true,
@@ -46,4 +46,4 @@ class EnforceEventCommentTypesType extends AbstractType
'empty_data' => [],
]);
}
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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');
}
}

View File

@@ -0,0 +1,75 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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' => [],
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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' => [],
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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',
]);
}
}

View File

@@ -44,10 +44,10 @@ class LocaleSelectType extends AbstractType
return LocaleType::class;
}
public function configureOptions(OptionsResolver $resolver)
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'preferred_choices' => $this->preferred_languages,
]);
}
}
}

View File

@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Security;
use App\Entity\UserSystem\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
@@ -51,7 +52,7 @@ final class UserChecker implements UserCheckerInterface
*
* @throws AccountStatusException
*/
public function checkPostAuth(UserInterface $user): void
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void
{
if (!$user instanceof User) {
return;

View File

@@ -27,6 +27,7 @@ use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -46,7 +47,7 @@ class BOMEntryVoter extends Voter
return $this->supportsAttribute($attribute) && is_a($subject, ProjectBOMEntry::class, true);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (!is_a($subject, ProjectBOMEntry::class, true)) {
return false;
@@ -87,4 +88,4 @@ class BOMEntryVoter extends Voter
{
return $subjectType === 'string' || is_a($subjectType, ProjectBOMEntry::class, true);
}
}
}

View File

@@ -26,6 +26,7 @@ namespace App\Security\Voter;
use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@@ -41,7 +42,7 @@ final class HasAccessPermissionsVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
return $this->permissionManager->hasAnyPermissionSetToAllowInherited($user);
@@ -56,4 +57,4 @@ final class HasAccessPermissionsVoter extends Voter
{
return $attribute === self::ROLE;
}
}
}

View File

@@ -57,6 +57,9 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
class AttachmentSubmitHandler
{
/**
* @var array<string, string> 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;
@@ -160,6 +163,7 @@ 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;

View File

@@ -22,13 +22,13 @@ declare(strict_types=1);
namespace App\Services;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Parts\PartAssociation;
use App\Entity\ProjectSystem\Project;
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;
@@ -36,12 +36,14 @@ 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;
@@ -79,6 +81,8 @@ 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'),
];
}
@@ -130,10 +134,10 @@ class ElementTypeNameGenerator
{
$type = $this->getLocalizedTypeLabel($entity);
if ($use_html) {
return '<i>'.$type.':</i> '.htmlspecialchars($entity->getName());
return '<i>' . $type . ':</i> ' . htmlspecialchars($entity->getName());
}
return $type.': '.$entity->getName();
return $type . ': ' . $entity->getName();
}

View File

@@ -100,7 +100,8 @@ 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();
@@ -141,40 +142,39 @@ 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) {
//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;
}
//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 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
}
//If all pricedetails are equal, the orderdetails are equal
return true;
return false; // Different supplier/part number, add as new
});
//The pricedetails are not correctly assigned to the new orderdetails, so fix that
foreach ($target->getOrderdetails() as $orderdetail) {

View File

@@ -38,6 +38,9 @@ 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.
@@ -52,7 +55,7 @@ class EntityExporter
protected function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('format', 'csv');
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
$resolver->setDefault('csv_delimiter', ';');
$resolver->setAllowedTypes('csv_delimiter', 'string');
@@ -88,28 +91,35 @@ 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 $format, array $context): string
private function handleCircularReference(object $object): string
{
if ($object instanceof AbstractStructuralDBElement) {
return $object->getFullPath("->");
@@ -119,7 +129,75 @@ class EntityExporter
return $object->__toString();
}
throw new CircularReferenceException('Circular reference detected for object of type '.get_class($object));
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;
}
/**
@@ -156,19 +234,15 @@ 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'];
switch ($format) {
case 'xml':
$content_type = 'application/xml';
break;
case 'json':
$content_type = 'application/json';
break;
}
$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',
};
$response->headers->set('Content-Type', $content_type);
//If view option is not specified, then download the file.
@@ -186,7 +260,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);

View File

@@ -38,6 +38,9 @@ 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
@@ -50,7 +53,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)
public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator, protected LoggerInterface $logger)
{
}
@@ -102,7 +105,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)) {
@@ -195,16 +198,20 @@ 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)) {
@@ -279,7 +286,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']);
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
$resolver->setAllowedTypes('csv_delimiter', 'string');
$resolver->setAllowedTypes('preserve_children', 'bool');
$resolver->setAllowedTypes('class', 'string');
@@ -335,6 +342,33 @@ 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);
}
@@ -354,10 +388,103 @@ 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.
*

View File

@@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace App\Services\InfoProviderSystem;
use App\Entity\Parts\Part;
use App\Entity\Parts\Supplier;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
use App\Services\InfoProviderSystem\Providers\BatchInfoProviderInterface;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\ClientException;
final class BulkInfoProviderService
{
/** @var array<string, Supplier|null> 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<string, BatchInfoProviderInterface> $batchProviders Batch providers indexed by key
* @return array<int, BulkSearchPartResultDTO[]> 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<string, InfoProviderInterface> $regularProviders Regular providers indexed by key
* @param array<int, BulkSearchPartResultDTO[]> $excludeResults Results to exclude (from batch processing)
* @return array<int, BulkSearchPartResultDTO[]> 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");
}
}

View File

@@ -0,0 +1,91 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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);
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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
) {}
}

View File

@@ -0,0 +1,83 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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;
}
}

View File

@@ -0,0 +1,231 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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);
}
}

View File

@@ -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
*/
class FileDTO
readonly class FileDTO
{
/**
* @var string The URL where to get this file
*/
public readonly string $url;
public string $url;
/**
* @param string $url The URL where to get this file
@@ -41,7 +41,7 @@ class FileDTO
*/
public function __construct(
string $url,
public readonly ?string $name = null,
public ?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 @@ class FileDTO
}
}
}

View File

@@ -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
*/
class ParameterDTO
readonly class ParameterDTO
{
public function __construct(
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,
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,
) {
}

View File

@@ -70,4 +70,4 @@ class PartDetailDTO extends SearchResultDTO
footprint: $footprint,
);
}
}
}

View File

@@ -28,21 +28,21 @@ use Brick\Math\BigDecimal;
/**
* This DTO represents a price for a single unit in a certain discount range
*/
class PriceDTO
readonly class PriceDTO
{
private readonly BigDecimal $price_as_big_decimal;
private 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 readonly float $minimum_discount_amount,
public float $minimum_discount_amount,
/** @var string The price as string (with .) */
public readonly string $price,
public string $price,
/** @var string The currency of the used ISO code of this price detail */
public readonly ?string $currency_iso_code,
public ?string $currency_iso_code,
/** @var bool If the price includes tax */
public readonly ?bool $includes_tax = true,
public ?bool $includes_tax = true,
/** @var float the price related quantity */
public readonly ?float $price_related_quantity = 1.0,
public ?float $price_related_quantity = 1.0,
)
{
$this->price_as_big_decimal = BigDecimal::of($this->price);

View File

@@ -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
*/
class PurchaseInfoDTO
readonly class PurchaseInfoDTO
{
public function __construct(
public readonly string $distributor_name,
public readonly string $order_number,
public string $distributor_name,
public string $order_number,
/** @var PriceDTO[] */
public readonly array $prices,
public array $prices,
/** @var string|null An url to the product page of the vendor */
public readonly ?string $product_url = null,
public ?string $product_url = null,
)
{
//Ensure that the prices are PriceDTO instances
@@ -45,4 +45,4 @@ class PurchaseInfoDTO
}
}
}
}
}

View File

@@ -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,4 +71,47 @@ 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,
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 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 <https://www.gnu.org/licenses/>.
*/
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<string, SearchResultDTO[]> 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;
}

View File

@@ -0,0 +1,76 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 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 <https://www.gnu.org/licenses/>.
*/
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');
}
}

View File

@@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\LCSCSettings;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class LCSCProvider implements InfoProviderInterface
class LCSCProvider implements BatchInfoProviderInterface
{
private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
@@ -69,9 +69,10 @@ class LCSCProvider implements InfoProviderInterface
/**
* @param string $id
* @param bool $lightweight If true, skip expensive operations like datasheet resolution
* @return PartDetailDTO
*/
private function queryDetail(string $id): PartDetailDTO
private function queryDetail(string $id, bool $lightweight = false): PartDetailDTO
{
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
'headers' => [
@@ -89,7 +90,7 @@ class LCSCProvider implements InfoProviderInterface
throw new \RuntimeException('Could not find product code: ' . $id);
}
return $this->getPartDetail($product);
return $this->getPartDetail($product, $lightweight);
}
/**
@@ -99,30 +100,42 @@ class LCSCProvider implements InfoProviderInterface
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): array
private function queryByTerm(string $term, bool $lightweight = false): 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)
@@ -145,11 +158,11 @@ class LCSCProvider implements InfoProviderInterface
// 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);
$result[] = $this->queryDetail($tipProductCode, $lightweight);
}
foreach ($products as $product) {
$result[] = $this->getPartDetail($product);
$result[] = $this->getPartDetail($product, $lightweight);
}
return $result;
@@ -178,7 +191,7 @@ class LCSCProvider implements InfoProviderInterface
* @param array $product
* @return PartDetailDTO
*/
private function getPartDetail(array $product): PartDetailDTO
private function getPartDetail(array $product, bool $lightweight = false): PartDetailDTO
{
// Get product images in advance
$product_images = $this->getProductImages($product['productImages'] ?? null);
@@ -214,10 +227,10 @@ class LCSCProvider implements InfoProviderInterface
manufacturing_status: null,
provider_url: $this->getProductShortURL($product['productCode']),
footprint: $this->sanitizeField($footprint),
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'] ?? []),
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'] ?? []),
mass: $product['weight'] ?? null,
);
}
@@ -286,7 +299,7 @@ class LCSCProvider implements InfoProviderInterface
*/
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';
}
/**
@@ -327,7 +340,7 @@ class LCSCProvider implements InfoProviderInterface
//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);
@@ -338,12 +351,86 @@ class LCSCProvider implements InfoProviderInterface
public function searchByKeyword(string $keyword): array
{
return $this->queryByTerm($keyword);
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;
}
public function getDetails(string $id): PartDetailDTO
{
$tmp = $this->queryByTerm($id);
$tmp = $this->queryByTerm($id, false);
if (count($tmp) === 0) {
throw new \RuntimeException('No part found with ID ' . $id);
}

View File

@@ -132,6 +132,15 @@ 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);
}
@@ -169,6 +178,16 @@ 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
@@ -286,6 +305,17 @@ 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
@@ -302,7 +332,7 @@ class MouserProvider implements InfoProviderInterface
$prices[] = new PriceDTO(
minimum_discount_amount: $price_break['Quantity'],
price: (string)$number,
currency_iso_code: $price_break['Currency']
currency_iso_code: $this->mapCurrencyCode($price_break['Currency'])
);
}

View File

@@ -95,6 +95,11 @@ final class BarcodeContentGenerator
return $prefix.$id;
}
/**
* @param array<string, string> $map
* @param object $target
* @return string
*/
private function classToString(array $map, object $target): string
{
$class = $target::class;

View File

@@ -30,13 +30,11 @@ 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;
@@ -100,7 +98,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)$/', $action, $matches)) {
if (preg_match('/^export_(json|yaml|xml|csv|xlsx)$/', $action, $matches)) {
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
$level = match ($target_id) {
2 => 'extended',
@@ -119,6 +117,16 @@ 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) {

View File

@@ -138,6 +138,11 @@ 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;

View File

@@ -40,6 +40,6 @@ class InfoProviderGeneralSettings
*/
#[SettingsParameter(type: ArrayType::class, label: new TM("settings.ips.default_providers"),
description: new TM("settings.ips.default_providers.help"), options: ['type' => StringType::class],
formType: ProviderSelectType::class, formOptions: ['input' => 'string'])]
formType: ProviderSelectType::class, formOptions: ['input' => 'string', 'required' => false, 'empty_data' => []])]
public array $defaultSearchProviders = [];
}

View File

@@ -34,9 +34,14 @@ use function is_array;
*/
final class InheritanceSecurityPolicy implements SecurityPolicyInterface
{
/**
* @var array<string, string[]>
*/
private array $allowedMethods;
public function __construct(private array $allowedTags = [], private array $allowedFilters = [], array $allowedMethods = [], private array $allowedProperties = [], private array $allowedFunctions = [])
public function __construct(private array $allowedTags = [], private array $allowedFilters = [], array $allowedMethods = [],
/** @var array<string, string|string[]> */
private array $allowedProperties = [], private array $allowedFunctions = [])
{
$this->setAllowedMethods($allowedMethods);
}

View File

@@ -30,8 +30,6 @@
<input type="hidden" name="ids" {{ stimulus_target('elements/datatables/parts', 'selectIDs') }} value="">
<div class="d-none mb-2 bg-body-tertiary shadow-sm border border-secondary rounded mx-2 p-2" {{ stimulus_target('elements/datatables/parts', 'selectPanel') }}>
{# <span id="select_count"></span> #}
<div class="input-group">
<button class="btn btn-outline-secondary" type="button" {{ stimulus_action('elements/datatables/parts', 'invertSelection')}}
title="{% trans %}part_list.action.invert_selection{% endtrans %}" ><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
@@ -41,7 +39,7 @@
<select class="form-select" name="action" data-controller="elements--select" {{ stimulus_action('elements/datatables/parts', 'updateTargetPicker', 'change') }}
title="{% trans %}part_list.action.action.title{% endtrans %}" required>
<optgroup label="{% trans %}part_list.action.action.group.favorite{% endtrans %}">
<optgroup label="{% trans %}part_list.action.action.group.favorite{% endtrans %} ({% trans %}part_list.action.scrollable_hint{% endtrans %})">
<option {% if not is_granted('@parts.change_favorite') %}disabled{% endif %} value="favorite">{% trans %}part_list.action.action.favorite{% endtrans %}</option>
<option {% if not is_granted('@parts.change_favorite') %}disabled{% endif %} value="unfavorite">{% trans %}part_list.action.action.unfavorite{% endtrans %}</option>
</optgroup>
@@ -72,6 +70,10 @@
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_csv" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_csv{% endtrans %}</option>
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_yaml" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_yaml{% endtrans %}</option>
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_xml" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_xml{% endtrans %}</option>
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_xlsx" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_xlsx{% endtrans %}</option>
</optgroup>
<optgroup label="{% trans %}part_list.action.action.info_provider{% endtrans %}">
<option {% if not is_granted('@info_providers.create_parts') %}disabled{% endif %} value="bulk_info_provider_import" data-url="{{ path('bulk_info_provider_step1')}}" data-turbo="false">{% trans %}part_list.action.bulk_info_provider_import{% endtrans %}</option>
</optgroup>
</select>

View File

@@ -0,0 +1,124 @@
{% extends "main_card.html.twig" %}
{% block title %}
{% trans %}info_providers.bulk_import.manage_jobs{% endtrans %}
{% endblock %}
{% block card_title %}
<i class="fas fa-tasks"></i> {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %}
{% endblock %}
{% block card_content %}
<div data-controller="bulk-job-manage"
data-bulk-job-manage-delete-url-value="{{ path('bulk_info_provider_delete', {'jobId': '__JOB_ID__'}) }}"
data-bulk-job-manage-stop-url-value="{{ path('bulk_info_provider_stop', {'jobId': '__JOB_ID__'}) }}"
data-bulk-job-manage-delete-confirm-message-value="{% trans %}info_providers.bulk_import.confirm_delete_job{% endtrans %}"
data-bulk-job-manage-stop-confirm-message-value="{% trans %}info_providers.bulk_import.confirm_stop_job{% endtrans %}">
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="text-muted mb-0">
{% trans %}info_providers.bulk_import.manage_jobs_description{% endtrans %}
</p>
</div>
{% if jobs is not empty %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{% trans %}info_providers.bulk_import.job_name{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.parts_count{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.results_count{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.progress{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.status{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.created_by{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.created_at{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.completed_at{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>
<strong>{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</strong>
{% if job.isInProgress %}
<span class="badge bg-info ms-2">Active</span>
{% endif %}
</td>
<td>{{ job.partCount }}</td>
<td>{{ job.resultCount }}</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 80px; height: 12px;">
<div class="progress-bar {% if job.isCompleted %}bg-success{% elseif job.isFailed %}bg-danger{% else %}bg-info{% endif %}"
role="progressbar"
style="width: {{ job.progressPercentage }}%"
aria-valuenow="{{ job.progressPercentage }}"
aria-valuemin="0" aria-valuemax="100">
</div>
</div>
<small class="text-muted">{{ job.progressPercentage }}%</small>
</div>
<small class="text-muted">
{% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %}
</small>
</td>
<td>
{% if job.isPending %}
<span class="badge bg-warning">{% trans %}info_providers.bulk_import.status.pending{% endtrans %}</span>
{% elseif job.isInProgress %}
<span class="badge bg-info">{% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}</span>
{% elseif job.isCompleted %}
<span class="badge bg-success">{% trans %}info_providers.bulk_import.status.completed{% endtrans %}</span>
{% elseif job.isStopped %}
<span class="badge bg-secondary">{% trans %}info_providers.bulk_import.status.stopped{% endtrans %}</span>
{% elseif job.isFailed %}
<span class="badge bg-danger">{% trans %}info_providers.bulk_import.status.failed{% endtrans %}</span>
{% endif %}
</td>
<td>{{ job.createdBy.fullName(true) }}</td>
<td>{{ job.createdAt|format_datetime('short') }}</td>
<td>
{% if job.completedAt %}
{{ job.completedAt|format_datetime('short') }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
{% if job.isInProgress or job.isCompleted or job.isStopped %}
<a href="{{ path('bulk_info_provider_step2', {'jobId': job.id}) }}" class="btn btn-primary">
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_results{% endtrans %}
</a>
{% endif %}
{% if job.canBeStopped %}
<button type="button" class="btn btn-warning" data-action="click->bulk-job-manage#stopJob" data-job-id="{{ job.id }}">
<i class="fas fa-stop"></i> {% trans %}info_providers.bulk_import.action.stop{% endtrans %}
</button>
{% endif %}
{% if job.isCompleted or job.isFailed or job.isStopped %}
<button type="button" class="btn btn-danger" data-action="click->bulk-job-manage#deleteJob" data-job-id="{{ job.id }}">
<i class="fas fa-trash"></i> {% trans %}info_providers.bulk_import.action.delete{% endtrans %}
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle"></i>
{% trans %}info_providers.bulk_import.no_jobs_found{% endtrans %}<br>
{% trans %}info_providers.bulk_import.create_first_job{% endtrans %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,304 @@
{% 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 %}
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.bulk_import.step1.title{% endtrans %}
<span class="badge bg-secondary">{{ parts|length }} {% trans %}info_providers.bulk_import.parts_selected{% endtrans %}</span>
{% endblock %}
{% block card_content %}
<div>
<!-- Show existing jobs -->
{% if existing_jobs is not empty %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">{% trans %}info_providers.bulk_import.existing_jobs{% endtrans %}</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>{% trans %}info_providers.bulk_import.job_name{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.parts_count{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.results_count{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.progress{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.status{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.created_at{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for job in existing_jobs %}
<tr>
<td>{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</td>
<td>{{ job.partCount }}</td>
<td>{{ job.resultCount }}</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 60px; height: 8px;">
<div class="progress-bar {% if job.isCompleted %}bg-success{% else %}bg-info{% endif %}"
role="progressbar"
style="width: {{ job.progressPercentage }}%"
aria-valuenow="{{ job.progressPercentage }}"
aria-valuemin="0" aria-valuemax="100">
</div>
</div>
<small class="text-muted">{{ job.progressPercentage }}%</small>
</div>
<small class="text-muted">{{ job.completedPartsCount }}/{{ job.partCount }}</small>
</td>
<td>
{% if job.isPending %}
<span class="badge bg-warning">{% trans %}info_providers.bulk_import.status.pending{% endtrans %}</span>
{% elseif job.isInProgress %}
<span class="badge bg-info">{% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}</span>
{% elseif job.isCompleted %}
<span class="badge bg-success">{% trans %}info_providers.bulk_import.status.completed{% endtrans %}</span>
{% elseif job.isFailed %}
<span class="badge bg-danger">{% trans %}info_providers.bulk_import.status.failed{% endtrans %}</span>
{% endif %}
</td>
<td>{{ job.createdAt|date('Y-m-d H:i') }}</td>
<td>
{% if job.isInProgress or job.isCompleted %}
<a href="{{ path('bulk_info_provider_step2', {'jobId': job.id}) }}" class="btn btn-primary btn-sm">
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_results{% endtrans %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle"></i>
{% trans %}info_providers.bulk_import.step1.global_mapping_description{% endtrans %}
</div>
<div class="alert alert-success" role="alert">
<i class="fas fa-lightbulb"></i>
<strong>{% trans %}info_providers.bulk_import.priority_system.title{% endtrans %}:</strong> {% trans %}info_providers.bulk_import.priority_system.description{% endtrans %}
<br><small class="text-muted">
{% trans %}info_providers.bulk_import.priority_system.example{% endtrans %}
</small>
</div>
<div class="alert alert-warning" role="alert">
<i class="fas fa-exclamation-triangle"></i>
{% trans %}info_providers.bulk_import.step1.spn_recommendation{% endtrans %}
</div>
<!-- Show selected parts -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">{% trans %}info_providers.bulk_import.selected_parts{% endtrans %}</h5>
</div>
<div class="card-body">
<div class="row">
{% for part in parts %}
{% set hasNoIdentifiers = part.manufacturerProductNumber is empty and part.orderdetails is empty %}
<div class="col-md-6 col-lg-4 mb-2">
<div class="d-flex align-items-center {% if hasNoIdentifiers %}text-danger{% endif %}">
<i class="fas fa-microchip {% if hasNoIdentifiers %}text-danger{% else %}text-primary{% endif %} me-2"></i>
<div>
<a href="{{ path('app_part_show', {'id': part.id}) }}" class="text-decoration-none {% if hasNoIdentifiers %}text-danger{% endif %}">
<strong>{{ part.name }}</strong>
{% if part.manufacturerProductNumber %}
<br><small class="{% if hasNoIdentifiers %}text-danger{% else %}text-muted{% endif %}">MPN: {{ part.manufacturerProductNumber }}</small>
{% endif %}
{% if part.orderdetails is not empty %}
<br><small class="{% if hasNoIdentifiers %}text-danger{% else %}text-muted{% endif %}">
SPNs: {{ part.orderdetails|map(od => od.supplierPartNr)|join(', ') }}
</small>
{% endif %}
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{{ form_start(form) }}
<div class="card"
data-controller="field-mapping"
data-field-mapping-mapping-index-value="{{ form.field_mappings|length }}"
data-field-mapping-max-mappings-value="{{ fieldChoices|length }}"
data-field-mapping-prototype-value="{{ form_widget(form.field_mappings.vars.prototype)|e('html_attr') }}"
data-field-mapping-max-mappings-reached-message-value="{{ 'info_providers.bulk_import.max_mappings_reached'|trans|e('js') }}">
<div class="card-header">
<h5 class="mb-0">{% trans %}info_providers.bulk_import.field_mappings{% endtrans %}</h5>
<small class="text-muted">{% trans %}info_providers.bulk_import.field_mappings_help{% endtrans %}</small>
</div>
<div class="card-body">
<table class="table table-striped">
<thead>
<tr>
<th>{% trans %}info_providers.bulk_search.search_field{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_search.providers{% endtrans %}</th>
<th width="80">{% trans %}info_providers.bulk_search.priority{% endtrans %}</th>
<th width="100">{% trans %}info_providers.bulk_import.actions.label{% endtrans %}</th>
</tr>
</thead>
<tbody id="field-mappings-tbody" data-field-mapping-target="tbody">
{% for mapping in form.field_mappings %}
<tr class="mapping-row">
<td>{{ form_widget(mapping.field) }}{{ form_errors(mapping.field) }}</td>
<td>{{ form_widget(mapping.providers) }}{{ form_errors(mapping.providers) }}</td>
<td>{{ form_widget(mapping.priority) }}{{ form_errors(mapping.priority) }}</td>
<td>
<button type="button" class="btn btn-danger btn-sm" data-action="click->field-mapping#removeMapping">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<button type="button" class="btn btn-success btn-sm" id="addMappingBtn"
data-field-mapping-target="addButton"
data-action="click->field-mapping#addMapping">
<i class="fas fa-plus"></i> {% trans %}info_providers.bulk_import.add_mapping{% endtrans %}
</button>
</div>
</div>
<div class="mb-2 d-flex flex-column align-items-start gap-2">
<div class="mb-2">
<a href="{{ path('info_providers_list') }}">{% trans %}info_providers.search.info_providers_list{% endtrans %}</a>
|
<a href="{{ path('bulk_info_provider_manage') }}">{% trans %}info_providers.bulk_import.manage_jobs{% endtrans %}</a>
</div>
<div class="form-check mb-2">
{{ 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) }}
</div>
{{ form_widget(form.submit, {'attr': {'class': 'btn btn-primary', 'data-field-mapping-target': 'submitButton'}}) }}
</div>
{{ form_end(form) }}
{% if search_results is not null %}
<hr>
<h4>{% trans %}info_providers.bulk_import.search_results.title{% endtrans %}</h4>
{% for part_result in search_results %}
{% set part = part_result.part %}
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0">
{{ part.name }}
{% if part_result.errors is not empty %}
<span class="badge bg-warning">{{ part_result.errors|length }} {% trans %}info_providers.bulk_import.errors{% endtrans %}</span>
{% endif %}
<span class="badge bg-success">{{ part_result.search_results|length }} {% trans %}info_providers.bulk_import.results_found{% endtrans %}</span>
</h5>
</div>
<div class="card-body">
{% if part_result.errors is not empty %}
{% for error in part_result.errors %}
<div class="alert alert-warning" role="alert">
<i class="fas fa-exclamation-triangle"></i>
{{ error }}
</div>
{% endfor %}
{% endif %}
{% if part_result.search_results|length > 0 %}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th></th>
<th>{% trans %}name.label{% endtrans %}</th>
<th>{% trans %}description.label{% endtrans %}</th>
<th>{% trans %}manufacturer.label{% endtrans %}</th>
<th>{% trans %}info_providers.table.provider.label{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.source_field{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for result in part_result.search_results %}
{% set dto = result.dto %}
{% set localPart = result.localPart %}
<tr {% if localPart is not null %}class="table-warning"{% endif %}>
<td>
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
class="hoverpic" style="max-width: 30px;" {{ stimulus_controller('elements/hoverpic') }}>
</td>
<td>
{% if dto.provider_url is not null %}
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">{{ dto.name }}</a>
{% else %}
{{ dto.name }}
{% endif %}
{% if dto.mpn is not null %}
<br><small class="text-muted">{{ dto.mpn }}</small>
{% endif %}
</td>
<td>{{ dto.description }}</td>
<td>{{ dto.manufacturer ?? '' }}</td>
<td>
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
<br><small class="text-muted">{{ dto.provider_id }}</small>
</td>
<td>
<span class="badge bg-info">{{ result.source_field ?? 'unknown' }}</span>
{% if result.source_keyword %}
<br><small class="text-muted">{{ result.source_keyword }}</small>
{% endif %}
</td>
<td>
<div class="btn-group-vertical btn-group-sm" role="group">
{% set updateHref = path('info_providers_update_part',
{'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %}
<a class="btn btn-primary" href="{{ updateHref }}" target="_blank">
<i class="fas fa-edit"></i> {% trans %}info_providers.bulk_import.update_part{% endtrans %}
</a>
{% if localPart is not null %}
<a class="btn btn-info btn-sm" href="{{ path('app_part_show', {'id': localPart.id}) }}" target="_blank">
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_existing{% endtrans %}
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info" role="alert">
{% trans %}info_providers.search.no_results{% endtrans %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,240 @@
{% 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 %}
<i class="fas fa-search"></i> {% trans %}info_providers.bulk_import.step2.title{% endtrans %}
<span class="badge bg-secondary">{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</span>
{% endblock %}
{% block card_content %}
<div {{ stimulus_controller('bulk-import', {
'jobId': job.id,
'researchUrl': path('bulk_info_provider_research_part', {'jobId': job.id, 'partId': '__PART_ID__'}),
'researchAllUrl': path('bulk_info_provider_research_all', {'jobId': job.id}),
'markCompletedUrl': path('bulk_info_provider_mark_completed', {'jobId': job.id, 'partId': '__PART_ID__'}),
'markSkippedUrl': path('bulk_info_provider_mark_skipped', {'jobId': job.id, 'partId': '__PART_ID__'}),
'markPendingUrl': path('bulk_info_provider_mark_pending', {'jobId': job.id, 'partId': '__PART_ID__'})
}) }}>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1">{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</h5>
<small class="text-muted">
{{ 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') }}
</small>
</div>
<div>
{% if job.isPending %}
<span class="badge bg-warning">{% trans %}info_providers.bulk_import.status.pending{% endtrans %}</span>
{% elseif job.isInProgress %}
<span class="badge bg-info">{% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}</span>
{% elseif job.isCompleted %}
<span class="badge bg-success">{% trans %}info_providers.bulk_import.status.completed{% endtrans %}</span>
{% elseif job.isFailed %}
<span class="badge bg-danger">{% trans %}info_providers.bulk_import.status.failed{% endtrans %}</span>
{% endif %}
</div>
</div>
<!-- Progress Bar -->
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Progress</h6>
<span data-bulk-import-target="progressText">{{ job.completedPartsCount }} / {{ job.partCount }} completed</span>
</div>
<div class="progress" style="height: 8px;">
<div data-bulk-import-target="progressBar" class="progress-bar" role="progressbar"
style="width: {{ job.progressPercentage }}%"
aria-valuenow="{{ job.progressPercentage }}" aria-valuemin="0" aria-valuemax="100">
</div>
</div>
<div class="d-flex justify-content-between mt-2">
<small class="text-muted">
<span id="completed-count">{{ job.completedPartsCount }}</span> {% trans %}info_providers.bulk_import.completed{% endtrans %}
<span id="skipped-count">{{ job.skippedPartsCount }}</span> {% trans %}info_providers.bulk_import.skipped{% endtrans %}
</small>
<small class="text-muted"><span id="progress-percentage">{{ job.progressPercentage }}%</span></small>
</div>
</div>
</div>
<!-- Tutorial/Instructions -->
<div class="alert alert-info mb-4" role="alert">
<h6 class="alert-heading">
<i class="fas fa-info-circle"></i> {% trans %}info_providers.bulk_import.step2.instructions.title{% endtrans %}
</h6>
<p class="mb-2">{% trans %}info_providers.bulk_import.step2.instructions.description{% endtrans %}</p>
<ul class="mb-0 ps-3">
<li>{% trans %}info_providers.bulk_import.step2.instructions.step1{% endtrans %}</li>
<li>{% trans %}info_providers.bulk_import.step2.instructions.step2{% endtrans %}</li>
<li>{% trans %}info_providers.bulk_import.step2.instructions.step3{% endtrans %}</li>
</ul>
</div>
<!-- Research Controls -->
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1">{% trans %}info_providers.bulk_import.research.title{% endtrans %}</h6>
<small class="text-muted">{% trans %}info_providers.bulk_import.research.description{% endtrans %}</small>
</div>
<div>
<button type="button" class="btn btn-outline-primary btn-sm me-2"
data-action="click->bulk-import#researchAllParts"
id="research-all-btn">
<span class="spinner-border spinner-border-sm me-1" style="display: none;" id="research-all-spinner"></span>
<i class="fas fa-search"></i> {% trans %}info_providers.bulk_import.research.all_pending{% endtrans %}
</button>
</div>
</div>
</div>
</div>
{% 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) %}
<div class="card mb-3 {% if isCompleted %}border-success{% elseif isSkipped %}border-warning{% endif %}"
data-part-id="{{ part.id }}"
{% if isCompleted %}style="background-color: rgba(25, 135, 84, 0.1);"{% endif %}>
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title mb-0">
<a href="{{ path('app_part_show', {'id': part.id}) }}" class="text-decoration-none">
{{ part.name }}
</a>
{% if isCompleted %}
<span class="badge bg-success">
<i class="fas fa-check"></i> {% trans %}info_providers.bulk_import.completed{% endtrans %}
</span>
{% elseif isSkipped %}
<span class="badge bg-warning">
<i class="fas fa-forward"></i> {% trans %}info_providers.bulk_import.skipped{% endtrans %}
</span>
{% endif %}
{% if part_result.errors is not empty %}
<span class="badge bg-danger">{% trans with {'%count%': part_result.errors|length} %}info_providers.bulk_import.errors{% endtrans %}</span>
{% endif %}
<span class="badge bg-info">{% trans with {'%count%': part_result.searchResults|length} %}info_providers.bulk_import.results_found{% endtrans %}</span>
</h5>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-info btn-sm"
data-action="click->bulk-import#researchPart"
data-part-id="{{ part.id }}"
title="{% trans %}info_providers.bulk_import.research.part_tooltip{% endtrans %}">
<span class="spinner-border spinner-border-sm me-1" style="display: none;" data-research-spinner="{{ part.id }}"></span>
<i class="fas fa-search"></i> {% trans %}info_providers.bulk_import.research.part{% endtrans %}
</button>
{% if not isCompleted and not isSkipped %}
<button type="button" class="btn btn-success btn-sm" data-action="click->bulk-import#markCompleted" data-part-id="{{ part.id }}">
<i class="fas fa-check"></i> {% trans %}info_providers.bulk_import.mark_completed{% endtrans %}
</button>
<button type="button" class="btn btn-warning btn-sm" data-action="click->bulk-import#markSkipped" data-part-id="{{ part.id }}">
<i class="fas fa-forward"></i> {% trans %}info_providers.bulk_import.mark_skipped{% endtrans %}
</button>
{% elseif isCompleted %}
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="click->bulk-import#markPending" data-part-id="{{ part.id }}">
<i class="fas fa-undo"></i> {% trans %}info_providers.bulk_import.mark_pending{% endtrans %}
</button>
{% elseif isSkipped %}
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="click->bulk-import#markPending" data-part-id="{{ part.id }}">
<i class="fas fa-undo"></i> {% trans %}info_providers.bulk_import.mark_pending{% endtrans %}
</button>
{% endif %}
</div>
</div>
<div class="card-body">
{% if part_result.errors is not empty %}
{% for error in part_result.errors %}
<div class="alert alert-warning" role="alert">
<i class="fas fa-exclamation-triangle"></i>
{{ error }}
</div>
{% endfor %}
{% endif %}
{% if part_result.searchResults|length > 0 %}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th></th>
<th>{% trans %}name.label{% endtrans %}</th>
<th>{% trans %}description.label{% endtrans %}</th>
<th>{% trans %}manufacturer.label{% endtrans %}</th>
<th>{% trans %}info_providers.table.provider.label{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.source_field{% endtrans %}</th>
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for result in part_result.searchResults %}
{# @var result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO #}
{% set dto = result.searchResult %}
{% set localPart = result.localPart %}
<tr>
<td>
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
class="hoverpic" style="max-width: 35px;" {{ stimulus_controller('elements/hoverpic') }}>
</td>
<td>
{% if dto.provider_url is not null %}
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">{{ dto.name }}</a>
{% else %}
{{ dto.name }}
{% endif %}
{% if dto.mpn is not null %}
<br><small class="text-muted">{{ dto.mpn }}</small>
{% endif %}
</td>
<td>{{ dto.description }}</td>
<td>{{ dto.manufacturer ?? '' }}</td>
<td>
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
<br><small class="text-muted">{{ dto.provider_id }}</small>
</td>
<td>
<span class="badge bg-info">{{ result.sourceField ?? 'unknown' }}</span>
{% if result.sourceKeyword %}
<br><small class="text-muted">{{ result.sourceKeyword }}</small>
{% endif %}
</td>
<td>
<div class="btn-group-vertical btn-group-sm" role="group">
{% set updateHref = path('info_providers_update_part',
{'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %}
<a class="btn btn-primary{% if isCompleted %} disabled{% endif %}" href="{% if not isCompleted %}{{ updateHref }}{% else %}#{% endif %}"{% if isCompleted %} aria-disabled="true"{% endif %}>
<i class="fas fa-edit"></i> {% trans %}info_providers.bulk_import.update_part{% endtrans %}
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info" role="alert">
{% trans %}info_providers.search.no_results{% endtrans %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -4,6 +4,32 @@
{% trans with {'%name%': part.name|escape } %}part.edit.title{% endtrans %}
{% endblock %}
{% block before_card %}
{% if bulk_job and jobId %}
<div class="alert alert-info mb-3">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<a href="{{ path('bulk_info_provider_step2', {jobId: bulk_job.id}) }}" class="btn btn-outline-primary btn-sm me-2">
<i class="fas fa-arrow-left fa-fw" aria-hidden="true"></i>
{% trans %}info_providers.bulk_import.back{% endtrans %}
</a>
<form method="post" action="{{ path('part_bulk_import_complete', {id: part.id, jobId: bulk_job.id}) }}" style="display: inline;">
<input type="hidden" name="_token" value="{{ csrf_token('bulk_complete_' ~ part.id) }}">
<button type="submit" class="btn btn-primary btn-sm me-3">
<i class="fas fa-check fa-fw" aria-hidden="true"></i>
{% trans %}info_providers.bulk_import.complete{% endtrans %}
</button>
</form>
<div>
<i class="fas fa-cloud-download fa-fw" aria-hidden="true"></i>
{% trans %}info_providers.bulk_import.editing_part{% endtrans %}
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block card_title %}
<i class="fas fa-edit fa-fw" aria-hidden="true"></i>
{% trans with {'%name%': part.name|escape } %}part.edit.card_title{% endtrans %}

View File

@@ -5,6 +5,19 @@
{% block card_border %}border-info{% endblock %}
{% block card_type %}bg-info text-bg-info{% endblock %}
{% block before_card %}
{% if bulk_job and jobId %}
<div class="alert alert-info mb-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-cloud-download fa-fw" aria-hidden="true"></i>
{% trans %}info_providers.bulk_import.editing_part{% endtrans %}
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block title %}
{% trans %}info_providers.update_part.title{% endtrans %}: {{ merge_old_name }}
{% endblock %}

View File

@@ -31,6 +31,11 @@
<button class="nav-link" id="filter-projects-tab" data-bs-toggle="tab" data-bs-target="#filter-projects"><i class="fas fa-archive fa-fw"></i> {% trans %}project.labelp{% endtrans %}</button>
</li>
{% endif %}
{% if filterForm.inBulkImportJob is defined %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="filter-bulk-import-tab" data-bs-toggle="tab" data-bs-target="#filter-bulk-import"><i class="fas fa-download fa-fw"></i> {% trans %}part.edit.tab.bulk_import{% endtrans %}</button>
</li>
{% endif %}
</ul>
{{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }}
@@ -126,6 +131,13 @@
{{ form_row(filterForm.bomComment) }}
</div>
{% endif %}
{% if filterForm.inBulkImportJob is defined %}
<div class="tab-pane pt-3" id="filter-bulk-import" role="tabpanel" aria-labelledby="filter-bulk-import-tab" tabindex="0">
{{ form_row(filterForm.inBulkImportJob) }}
{{ form_row(filterForm.bulkImportJobStatus) }}
{{ form_row(filterForm.bulkImportPartStatus) }}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,889 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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();
}
}

View File

@@ -0,0 +1,334 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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);
}
}

View File

@@ -0,0 +1,250 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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());
}
}

View File

@@ -0,0 +1,299 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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());
}
}

View File

@@ -0,0 +1,71 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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');
}
}

View File

@@ -0,0 +1,301 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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());
}
}

View File

@@ -0,0 +1,368 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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());
}
}

View File

@@ -0,0 +1,68 @@
<?php
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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']);
}
}

View File

@@ -112,7 +112,8 @@ class LogEntryRepositoryTest extends KernelTestCase
$this->assertCount(2, $logs);
//The first one must be newer than the second one
$this->assertGreaterThanOrEqual($logs[0]->getTimestamp(), $logs[1]->getTimestamp());
$this->assertGreaterThanOrEqual($logs[1]->getTimestamp(), $logs[0]->getTimestamp());
$this->assertGreaterThanOrEqual($logs[1]->getID(), $logs[0]->getID());
}
public function testGetElementExistedAtTimestamp(): void

View File

@@ -25,11 +25,12 @@ 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\Formatters\AmountFormatter;
use App\Services\ElementTypeNameGenerator;
use App\Services\Formatters\AmountFormatter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ElementTypeNameGeneratorTest extends WebTestCase
@@ -50,16 +51,18 @@ 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 {
});
}
@@ -74,7 +77,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';

View File

@@ -26,6 +26,7 @@ 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
{
@@ -76,7 +77,40 @@ 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'));
}
}

View File

@@ -36,6 +36,9 @@ 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
@@ -207,6 +210,10 @@ EOT;
yield ['json', 'json'];
yield ['yaml', 'yml'];
yield ['yaml', 'YAML'];
yield ['xlsx', 'xlsx'];
yield ['xlsx', 'XLSX'];
yield ['xls', 'xls'];
yield ['xls', 'XLS'];
}
#[DataProvider('formatDataProvider')]
@@ -342,4 +349,41 @@ 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);
}
}

Some files were not shown because too many files have changed in this diff Show More