mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-12-06 02:59:29 +00:00
Merge branch 'feature/batch-info-provider-import'
This commit is contained in:
commit
ed1e51f694
80 changed files with 9789 additions and 245 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -48,3 +48,6 @@ yarn-error.log
|
||||||
###> phpstan/phpstan ###
|
###> phpstan/phpstan ###
|
||||||
phpstan.neon
|
phpstan.neon
|
||||||
###< phpstan/phpstan ###
|
###< phpstan/phpstan ###
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
91
Makefile
Normal file
91
Makefile
Normal 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!"
|
||||||
359
assets/controllers/bulk_import_controller.js
Normal file
359
assets/controllers/bulk_import_controller.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
assets/controllers/bulk_job_manage_controller.js
Normal file
92
assets/controllers/bulk_job_manage_controller.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
assets/controllers/field_mapping_controller.js
Normal file
136
assets/controllers/field_mapping_controller.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -94,6 +94,11 @@ th.select-checkbox {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Add spacing between column visibility button and length menu */
|
||||||
|
.buttons-colvis {
|
||||||
|
margin-right: 0.2em !important;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fix datatables select-checkbox position */
|
/** Fix datatables select-checkbox position */
|
||||||
table.dataTable tr.selected td.select-checkbox:after
|
table.dataTable tr.selected td.select-checkbox:after
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
"omines/datatables-bundle": "^0.10.0",
|
"omines/datatables-bundle": "^0.10.0",
|
||||||
"paragonie/sodium_compat": "^1.21",
|
"paragonie/sodium_compat": "^1.21",
|
||||||
"part-db/label-fonts": "^1.0",
|
"part-db/label-fonts": "^1.0",
|
||||||
|
"phpoffice/phpspreadsheet": "^5.0.0",
|
||||||
"rhukster/dom-sanitizer": "^1.0",
|
"rhukster/dom-sanitizer": "^1.0",
|
||||||
"runtime/frankenphp-symfony": "^0.2.0",
|
"runtime/frankenphp-symfony": "^0.2.0",
|
||||||
"s9e/text-formatter": "^2.1",
|
"s9e/text-formatter": "^2.1",
|
||||||
|
|
@ -157,7 +158,7 @@
|
||||||
"post-update-cmd": [
|
"post-update-cmd": [
|
||||||
"@auto-scripts"
|
"@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": {
|
"conflict": {
|
||||||
"symfony/symfony": "*"
|
"symfony/symfony": "*"
|
||||||
|
|
|
||||||
370
composer.lock
generated
370
composer.lock
generated
|
|
@ -2500,6 +2500,85 @@
|
||||||
],
|
],
|
||||||
"time": "2022-01-17T14:14:24+00:00"
|
"time": "2022-01-17T14:14:24+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "composer/pcre",
|
||||||
|
"version": "3.3.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/pcre.git",
|
||||||
|
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||||
|
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan": "<1.11.10"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.12 || ^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||||
|
"phpunit/phpunit": "^8 || ^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Pcre\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||||
|
"keywords": [
|
||||||
|
"PCRE",
|
||||||
|
"preg",
|
||||||
|
"regex",
|
||||||
|
"regular expression"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/composer/pcre/issues",
|
||||||
|
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-11-12T16:29:46+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "daverandom/libdns",
|
"name": "daverandom/libdns",
|
||||||
"version": "v2.1.0",
|
"version": "v2.1.0",
|
||||||
|
|
@ -6315,6 +6394,191 @@
|
||||||
},
|
},
|
||||||
"time": "2023-07-31T13:36:50+00:00"
|
"time": "2023-07-31T13:36:50+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "maennchen/zipstream-php",
|
||||||
|
"version": "3.1.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||||
|
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
|
||||||
|
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"php-64bit": "^8.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.7",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.16",
|
||||||
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
|
"mikey179/vfsstream": "^1.6",
|
||||||
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
|
"phpunit/phpunit": "^11.0",
|
||||||
|
"vimeo/psalm": "^6.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"guzzlehttp/psr7": "^2.4",
|
||||||
|
"psr/http-message": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ZipStream\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paul Duncan",
|
||||||
|
"email": "pabs@pablotron.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonatan Männchen",
|
||||||
|
"email": "jonatan@maennchen.ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jesse Donat",
|
||||||
|
"email": "donatj@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "András Kolesár",
|
||||||
|
"email": "kolesar@kolesar.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||||
|
"keywords": [
|
||||||
|
"stream",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||||
|
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/maennchen",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-01-27T12:07:53+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/complex",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Complex\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@lange.demon.co.uk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with complex numbers",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||||
|
"keywords": [
|
||||||
|
"complex",
|
||||||
|
"mathematics"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||||
|
},
|
||||||
|
"time": "2022-12-06T16:21:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/matrix",
|
||||||
|
"version": "3.0.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpdocumentor/phpdocumentor": "2.*",
|
||||||
|
"phploc/phploc": "^4.0",
|
||||||
|
"phpmd/phpmd": "2.*",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"sebastian/phpcpd": "^4.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Matrix\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@demon-angel.eu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with matrices",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||||
|
"keywords": [
|
||||||
|
"mathematics",
|
||||||
|
"matrix",
|
||||||
|
"vector"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||||
|
},
|
||||||
|
"time": "2022-12-02T22:17:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "masterminds/html5",
|
"name": "masterminds/html5",
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
|
|
@ -8110,6 +8374,112 @@
|
||||||
},
|
},
|
||||||
"time": "2024-11-09T15:12:26+00:00"
|
"time": "2024-11-09T15:12:26+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpoffice/phpspreadsheet",
|
||||||
|
"version": "5.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
|
"reference": "d88efcac2444cde18e17684178de02b25dff2050"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/d88efcac2444cde18e17684178de02b25dff2050",
|
||||||
|
"reference": "d88efcac2444cde18e17684178de02b25dff2050",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/pcre": "^1||^2||^3",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
|
"markbaker/complex": "^3.0",
|
||||||
|
"markbaker/matrix": "^3.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"psr/http-factory": "^1.0",
|
||||||
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
|
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
|
"mitoteam/jpgraph": "^10.3",
|
||||||
|
"mpdf/mpdf": "^8.1.1",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||||
|
"phpunit/phpunit": "^10.5",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
|
"tecnickcom/tcpdf": "^6.5"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"ext-intl": "PHP Internationalization Functions",
|
||||||
|
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||||
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Maarten Balliauw",
|
||||||
|
"homepage": "https://blog.maartenballiauw.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"homepage": "https://markbakeruk.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Franck Lefevre",
|
||||||
|
"homepage": "https://rootslabs.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Erik Tilt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Adrien Crivelli"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||||
|
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||||
|
"keywords": [
|
||||||
|
"OpenXML",
|
||||||
|
"excel",
|
||||||
|
"gnumeric",
|
||||||
|
"ods",
|
||||||
|
"php",
|
||||||
|
"spreadsheet",
|
||||||
|
"xls",
|
||||||
|
"xlsx"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.0.0"
|
||||||
|
},
|
||||||
|
"time": "2025-08-10T06:18:27+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpdoc-parser",
|
"name": "phpstan/phpdoc-parser",
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
|
|
|
||||||
|
|
@ -104,3 +104,9 @@ parameters:
|
||||||
env(SAML_ROLE_MAPPING): '{}'
|
env(SAML_ROLE_MAPPING): '{}'
|
||||||
|
|
||||||
env(DATABASE_EMULATE_NATURAL_SORT): 0
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
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;;;;
|
"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
|
||||||
BC557;PNP transistor;<b>HTML</b>;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active
|
"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
|
||||||
Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter;
|
"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
|
||||||
|
|
|
||||||
|
|
|
@ -142,6 +142,9 @@ You can select between the following export formats:
|
||||||
efficiently.
|
efficiently.
|
||||||
* **YAML** (Yet Another Markup Language): Very similar to JSON
|
* **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.
|
* **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:
|
Also, you can select between the following export levels:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,13 @@ If you already have attachment types for images and datasheets and want the info
|
||||||
can
|
can
|
||||||
add the alternative names "Datasheet" and "Image" to the alternative names field of the attachment types.
|
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
|
## Data providers
|
||||||
|
|
||||||
The system tries to be as flexible as possible, so many different information sources can be used.
|
The system tries to be as flexible as possible, so many different information sources can be used.
|
||||||
|
|
|
||||||
81
makefile
81
makefile
|
|
@ -1,112 +1,91 @@
|
||||||
# PartDB Makefile for Test Environment Management
|
# 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
|
# Default target
|
||||||
help:
|
help: ## Show this help
|
||||||
@echo "PartDB Test Environment Management"
|
@awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
@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"
|
|
||||||
|
|
||||||
# Install PHP dependencies with unlimited memory
|
# Dependencies
|
||||||
deps-install:
|
deps-install: ## Install PHP dependencies with unlimited memory
|
||||||
@echo "📦 Installing PHP dependencies..."
|
@echo "📦 Installing PHP dependencies..."
|
||||||
COMPOSER_MEMORY_LIMIT=-1 composer install
|
COMPOSER_MEMORY_LIMIT=-1 composer install
|
||||||
|
yarn install
|
||||||
@echo "✅ Dependencies installed"
|
@echo "✅ Dependencies installed"
|
||||||
|
|
||||||
# Complete test environment setup
|
# 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!"
|
@echo "✅ Test environment setup complete!"
|
||||||
|
|
||||||
# Clean test environment
|
# Clean test environment
|
||||||
test-clean:
|
test-clean: ## Clean test cache and database files
|
||||||
@echo "🧹 Cleaning test environment..."
|
@echo "🧹 Cleaning test environment..."
|
||||||
rm -rf var/cache/test
|
rm -rf var/cache/test
|
||||||
rm -f var/app_test.db
|
rm -f var/app_test.db
|
||||||
@echo "✅ Test environment cleaned"
|
@echo "✅ Test environment cleaned"
|
||||||
|
|
||||||
# Create test database
|
# Create test database
|
||||||
test-db-create:
|
test-db-create: ## Create test database (if not exists)
|
||||||
@echo "🗄️ Creating test database..."
|
@echo "🗄️ Creating test database..."
|
||||||
-php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
|
-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
|
# Run database migrations for test environment
|
||||||
test-db-migrate:
|
test-db-migrate: ## Run database migrations for test environment
|
||||||
@echo "🔄 Running database migrations..."
|
@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
|
# Clear test cache
|
||||||
test-cache-clear:
|
test-cache-clear: ## Clear test cache
|
||||||
@echo "🗑️ Clearing test cache..."
|
@echo "🗑️ Clearing test cache..."
|
||||||
rm -rf var/cache/test
|
rm -rf var/cache/test
|
||||||
@echo "✅ Test cache cleared"
|
@echo "✅ Test cache cleared"
|
||||||
|
|
||||||
# Load test fixtures
|
# Load test fixtures
|
||||||
test-fixtures:
|
test-fixtures: ## Load test fixtures
|
||||||
@echo "📦 Loading test fixtures..."
|
@echo "📦 Loading test fixtures..."
|
||||||
php bin/console partdb:fixtures:load -n --env test
|
php bin/console partdb:fixtures:load -n --env test
|
||||||
|
|
||||||
# Run PHPUnit tests
|
# Run PHPUnit tests
|
||||||
test-run:
|
test-run: ## Run PHPUnit tests
|
||||||
@echo "🧪 Running tests..."
|
@echo "🧪 Running tests..."
|
||||||
php bin/phpunit
|
php bin/phpunit
|
||||||
|
|
||||||
test-typecheck:
|
|
||||||
@echo "🧪 Running type checks..."
|
|
||||||
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
|
|
||||||
|
|
||||||
# Quick test reset (clean + migrate + fixtures, skip DB creation)
|
# Quick test reset (clean + migrate + fixtures, skip DB creation)
|
||||||
test-reset: test-cache-clear test-db-migrate test-fixtures
|
test-reset: test-cache-clear test-db-migrate test-fixtures
|
||||||
@echo "✅ Test environment reset complete!"
|
@echo "✅ Test environment reset complete!"
|
||||||
|
|
||||||
|
test-typecheck: ## Run static analysis (PHPStan)
|
||||||
|
@echo "🧪 Running type checks..."
|
||||||
|
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
|
||||||
|
|
||||||
# Development helpers
|
# 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!"
|
@echo "✅ Development environment setup complete!"
|
||||||
|
|
||||||
dev-clean:
|
dev-clean: ## Clean development cache and database files
|
||||||
@echo "🧹 Cleaning development environment..."
|
@echo "🧹 Cleaning development environment..."
|
||||||
rm -rf var/cache/dev
|
rm -rf var/cache/dev
|
||||||
rm -f var/app_dev.db
|
rm -f var/app_dev.db
|
||||||
@echo "✅ Development environment cleaned"
|
@echo "✅ Development environment cleaned"
|
||||||
|
|
||||||
dev-db-create:
|
dev-db-create: ## Create development database (if not exists)
|
||||||
@echo "🗄️ Creating development database..."
|
@echo "🗄️ Creating development database..."
|
||||||
-php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
|
-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..."
|
@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..."
|
@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"
|
@echo "✅ Development cache cleared"
|
||||||
|
|
||||||
dev-warmup:
|
dev-warmup: ## Warm up development cache
|
||||||
@echo "🔥 Warming 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!"
|
@echo "✅ Development environment reset complete!"
|
||||||
70
migrations/Version20250802205143.php
Normal file
70
migrations/Version20250802205143.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
588
src/Controller/BulkInfoProviderImportController.php
Normal file
588
src/Controller/BulkInfoProviderImportController.php
Normal 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()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,14 +64,16 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
use function Symfony\Component\Translation\t;
|
use function Symfony\Component\Translation\t;
|
||||||
|
|
||||||
#[Route(path: '/part')]
|
#[Route(path: '/part')]
|
||||||
class PartController extends AbstractController
|
final class PartController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(protected PricedetailHelper $pricedetailHelper,
|
public function __construct(
|
||||||
protected PartPreviewGenerator $partPreviewGenerator,
|
private readonly PricedetailHelper $pricedetailHelper,
|
||||||
|
private readonly PartPreviewGenerator $partPreviewGenerator,
|
||||||
private readonly TranslatorInterface $translator,
|
private readonly TranslatorInterface $translator,
|
||||||
private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
|
private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
|
||||||
protected EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings)
|
private readonly EntityManagerInterface $em,
|
||||||
{
|
private readonly EventCommentHelper $commentHelper
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -80,9 +82,16 @@ class PartController extends AbstractController
|
||||||
*/
|
*/
|
||||||
#[Route(path: '/{id}/info/{timestamp}', name: 'part_info')]
|
#[Route(path: '/{id}/info/{timestamp}', name: 'part_info')]
|
||||||
#[Route(path: '/{id}', requirements: ['id' => '\d+'])]
|
#[Route(path: '/{id}', requirements: ['id' => '\d+'])]
|
||||||
public function show(Part $part, Request $request, TimeTravel $timeTravel, HistoryHelper $historyHelper,
|
public function show(
|
||||||
DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, PartLotWithdrawAddHelper $withdrawAddHelper, ?string $timestamp = null): Response
|
Part $part,
|
||||||
{
|
Request $request,
|
||||||
|
TimeTravel $timeTravel,
|
||||||
|
HistoryHelper $historyHelper,
|
||||||
|
DataTableFactory $dataTable,
|
||||||
|
ParameterExtractor $parameterExtractor,
|
||||||
|
PartLotWithdrawAddHelper $withdrawAddHelper,
|
||||||
|
?string $timestamp = null
|
||||||
|
): Response {
|
||||||
$this->denyAccessUnlessGranted('read', $part);
|
$this->denyAccessUnlessGranted('read', $part);
|
||||||
|
|
||||||
$timeTravel_timestamp = null;
|
$timeTravel_timestamp = null;
|
||||||
|
|
@ -132,7 +141,43 @@ class PartController extends AbstractController
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('edit', $part);
|
$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'])]
|
#[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])]
|
||||||
|
|
@ -159,11 +204,15 @@ class PartController extends AbstractController
|
||||||
#[Route(path: '/new', name: 'part_new')]
|
#[Route(path: '/new', name: 'part_new')]
|
||||||
#[Route(path: '/{id}/clone', name: 'part_clone')]
|
#[Route(path: '/{id}/clone', name: 'part_clone')]
|
||||||
#[Route(path: '/new_build_part/{project_id}', name: 'part_new_build_part')]
|
#[Route(path: '/new_build_part/{project_id}', name: 'part_new_build_part')]
|
||||||
public function new(Request $request, EntityManagerInterface $em, TranslatorInterface $translator,
|
public function new(
|
||||||
AttachmentSubmitHandler $attachmentSubmitHandler, ProjectBuildPartHelper $projectBuildPartHelper,
|
Request $request,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
TranslatorInterface $translator,
|
||||||
|
AttachmentSubmitHandler $attachmentSubmitHandler,
|
||||||
|
ProjectBuildPartHelper $projectBuildPartHelper,
|
||||||
#[MapEntity(mapping: ['id' => 'id'])] ?Part $part = null,
|
#[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) {
|
if ($part instanceof Part) {
|
||||||
//Clone part
|
//Clone part
|
||||||
|
|
@ -258,9 +307,14 @@ class PartController extends AbstractController
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route(path: '/{id}/from_info_provider/{providerKey}/{providerId}/update', name: 'info_providers_update_part', requirements: ['providerId' => '.+'])]
|
#[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,
|
public function updateFromInfoProvider(
|
||||||
PartInfoRetriever $infoRetriever, PartMerger $partMerger): Response
|
Part $part,
|
||||||
{
|
Request $request,
|
||||||
|
string $providerKey,
|
||||||
|
string $providerId,
|
||||||
|
PartInfoRetriever $infoRetriever,
|
||||||
|
PartMerger $partMerger
|
||||||
|
): Response {
|
||||||
$this->denyAccessUnlessGranted('edit', $part);
|
$this->denyAccessUnlessGranted('edit', $part);
|
||||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||||
|
|
||||||
|
|
@ -274,10 +328,22 @@ class PartController extends AbstractController
|
||||||
|
|
||||||
$this->addFlash('notice', t('part.merge.flash.please_review'));
|
$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, [
|
return $this->renderPartForm('update_from_ip', $request, $part, [
|
||||||
'info_provider_dto' => $dto,
|
'info_provider_dto' => $dto,
|
||||||
], [
|
], [
|
||||||
'tname_before' => $old_name
|
'tname_before' => $old_name,
|
||||||
|
'bulk_job' => $bulkJob
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -353,6 +419,12 @@ class PartController extends AbstractController
|
||||||
return $this->redirectToRoute('part_new');
|
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()]);
|
return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,13 +443,17 @@ class PartController extends AbstractController
|
||||||
$template = 'parts/edit/update_from_ip.html.twig';
|
$template = 'parts/edit/update_from_ip.html.twig';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render($template,
|
return $this->render(
|
||||||
|
$template,
|
||||||
[
|
[
|
||||||
'part' => $new_part,
|
'part' => $new_part,
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
'merge_old_name' => $merge_infos['tname_before'] ?? null,
|
'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')
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() . ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,9 @@ use App\DataTables\Filters\Constraints\DateTimeConstraint;
|
||||||
use App\DataTables\Filters\Constraints\EntityConstraint;
|
use App\DataTables\Filters\Constraints\EntityConstraint;
|
||||||
use App\DataTables\Filters\Constraints\IntConstraint;
|
use App\DataTables\Filters\Constraints\IntConstraint;
|
||||||
use App\DataTables\Filters\Constraints\NumberConstraint;
|
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\LessThanDesiredConstraint;
|
||||||
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
|
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
|
||||||
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
|
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
|
||||||
|
|
@ -102,6 +105,14 @@ class PartFilter implements FilterInterface
|
||||||
public readonly TextConstraint $bomName;
|
public readonly TextConstraint $bomName;
|
||||||
public readonly TextConstraint $bomComment;
|
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)
|
public function __construct(NodesListBuilder $nodesListBuilder)
|
||||||
{
|
{
|
||||||
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
|
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
|
||||||
|
|
@ -166,6 +177,11 @@ class PartFilter implements FilterInterface
|
||||||
$this->bomName = new TextConstraint('_projectBomEntries.name');
|
$this->bomName = new TextConstraint('_projectBomEntries.name');
|
||||||
$this->bomComment = new TextConstraint('_projectBomEntries.comment');
|
$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
|
public function apply(QueryBuilder $queryBuilder): void
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,10 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
])
|
])
|
||||||
->add('minamount', TextColumn::class, [
|
->add('minamount', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.minamount'),
|
'label' => $this->translator->trans('part.table.minamount'),
|
||||||
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value,
|
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format(
|
||||||
$context->getPartUnit())),
|
$value,
|
||||||
|
$context->getPartUnit()
|
||||||
|
)),
|
||||||
])
|
])
|
||||||
->add('partUnit', TextColumn::class, [
|
->add('partUnit', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.partUnit'),
|
'label' => $this->translator->trans('part.table.partUnit'),
|
||||||
|
|
@ -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
|
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
|
||||||
//$builder->addGroupBy('_projectBomEntries');
|
//$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;
|
return $builder;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
src/Entity/InfoProviderSystem/BulkImportJobStatus.php
Normal file
35
src/Entity/InfoProviderSystem/BulkImportJobStatus.php
Normal 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';
|
||||||
|
}
|
||||||
32
src/Entity/InfoProviderSystem/BulkImportPartStatus.php
Normal file
32
src/Entity/InfoProviderSystem/BulkImportPartStatus.php
Normal 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';
|
||||||
|
}
|
||||||
449
src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php
Normal file
449
src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php
Normal file
182
src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,8 @@ namespace App\Entity\LogSystem;
|
||||||
|
|
||||||
use App\Entity\Attachments\Attachment;
|
use App\Entity\Attachments\Attachment;
|
||||||
use App\Entity\Attachments\AttachmentType;
|
use App\Entity\Attachments\AttachmentType;
|
||||||
|
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
|
||||||
|
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||||
use App\Entity\LabelSystem\LabelProfile;
|
use App\Entity\LabelSystem\LabelProfile;
|
||||||
use App\Entity\Parameters\AbstractParameter;
|
use App\Entity\Parameters\AbstractParameter;
|
||||||
use App\Entity\Parts\Category;
|
use App\Entity\Parts\Category;
|
||||||
|
|
@ -67,6 +69,8 @@ enum LogTargetType: int
|
||||||
case LABEL_PROFILE = 19;
|
case LABEL_PROFILE = 19;
|
||||||
|
|
||||||
case PART_ASSOCIATION = 20;
|
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.
|
* 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::PARAMETER => AbstractParameter::class,
|
||||||
self::LABEL_PROFILE => LabelProfile::class,
|
self::LABEL_PROFILE => LabelProfile::class,
|
||||||
self::PART_ASSOCIATION => PartAssociation::class,
|
self::PART_ASSOCIATION => PartAssociation::class,
|
||||||
|
self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class,
|
||||||
|
self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => BulkInfoProviderImportJobPart::class,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Entity\Parts;
|
namespace App\Entity\Parts;
|
||||||
|
|
||||||
use App\ApiPlatform\Filter\TagFilter;
|
|
||||||
use Doctrine\Common\Collections\Criteria;
|
|
||||||
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
|
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||||
|
|
@ -40,10 +38,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||||
use App\ApiPlatform\Filter\EntityFilter;
|
use App\ApiPlatform\Filter\EntityFilter;
|
||||||
use App\ApiPlatform\Filter\LikeFilter;
|
use App\ApiPlatform\Filter\LikeFilter;
|
||||||
use App\ApiPlatform\Filter\PartStoragelocationFilter;
|
use App\ApiPlatform\Filter\PartStoragelocationFilter;
|
||||||
|
use App\ApiPlatform\Filter\TagFilter;
|
||||||
use App\Entity\Attachments\Attachment;
|
use App\Entity\Attachments\Attachment;
|
||||||
use App\Entity\Attachments\AttachmentContainingDBElement;
|
use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||||
use App\Entity\Attachments\PartAttachment;
|
use App\Entity\Attachments\PartAttachment;
|
||||||
use App\Entity\EDA\EDAPartInfo;
|
use App\Entity\EDA\EDAPartInfo;
|
||||||
|
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||||
use App\Entity\Parameters\ParametersTrait;
|
use App\Entity\Parameters\ParametersTrait;
|
||||||
use App\Entity\Parameters\PartParameter;
|
use App\Entity\Parameters\PartParameter;
|
||||||
use App\Entity\Parts\PartTraits\AdvancedPropertyTrait;
|
use App\Entity\Parts\PartTraits\AdvancedPropertyTrait;
|
||||||
|
|
@ -59,6 +59,7 @@ use App\Repository\PartRepository;
|
||||||
use App\Validator\Constraints\UniqueObjectCollection;
|
use App\Validator\Constraints\UniqueObjectCollection;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\Common\Collections\Criteria;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||||
use Symfony\Component\Serializer\Annotation\Groups;
|
use Symfony\Component\Serializer\Annotation\Groups;
|
||||||
|
|
@ -83,8 +84,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
|
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new Get(normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read',
|
new Get(normalizationContext: [
|
||||||
'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'],
|
'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',
|
'openapi_definition_name' => 'Read',
|
||||||
], security: 'is_granted("read", object)'),
|
], security: 'is_granted("read", object)'),
|
||||||
new GetCollection(security: 'is_granted("@parts.read")'),
|
new GetCollection(security: 'is_granted("@parts.read")'),
|
||||||
|
|
@ -160,6 +171,12 @@ class Part extends AttachmentContainingDBElement
|
||||||
#[Groups(['part:read'])]
|
#[Groups(['part:read'])]
|
||||||
protected ?\DateTimeImmutable $lastModified = null;
|
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()
|
public function __construct()
|
||||||
{
|
{
|
||||||
|
|
@ -172,6 +189,7 @@ class Part extends AttachmentContainingDBElement
|
||||||
|
|
||||||
$this->associated_parts_as_owner = new ArrayCollection();
|
$this->associated_parts_as_owner = new ArrayCollection();
|
||||||
$this->associated_parts_as_other = new ArrayCollection();
|
$this->associated_parts_as_other = new ArrayCollection();
|
||||||
|
$this->bulkImportJobParts = new ArrayCollection();
|
||||||
|
|
||||||
//By default, the part has no provider
|
//By default, the part has no provider
|
||||||
$this->providerReference = InfoProviderReference::noProvider();
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ class ImportType extends AbstractType
|
||||||
'XML' => 'xml',
|
'XML' => 'xml',
|
||||||
'CSV' => 'csv',
|
'CSV' => 'csv',
|
||||||
'YAML' => 'yaml',
|
'YAML' => 'yaml',
|
||||||
|
'XLSX' => 'xlsx',
|
||||||
|
'XLS' => 'xls',
|
||||||
],
|
],
|
||||||
'label' => 'export.format',
|
'label' => 'export.format',
|
||||||
'disabled' => $disabled,
|
'disabled' => $disabled,
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,8 @@ class LogFilterType extends AbstractType
|
||||||
LogTargetType::PARAMETER => 'parameter.label',
|
LogTargetType::PARAMETER => 'parameter.label',
|
||||||
LogTargetType::LABEL_PROFILE => 'label_profile.label',
|
LogTargetType::LABEL_PROFILE => 'label_profile.label',
|
||||||
LogTargetType::PART_ASSOCIATION => 'part_association.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',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,12 @@ declare(strict_types=1);
|
||||||
*/
|
*/
|
||||||
namespace App\Form\Filters;
|
namespace App\Form\Filters;
|
||||||
|
|
||||||
|
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
|
||||||
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
|
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
|
||||||
use App\DataTables\Filters\PartFilter;
|
use App\DataTables\Filters\PartFilter;
|
||||||
use App\Entity\Attachments\AttachmentType;
|
use App\Entity\Attachments\AttachmentType;
|
||||||
|
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
|
||||||
|
use App\Entity\InfoProviderSystem\BulkImportPartStatus;
|
||||||
use App\Entity\Parts\Category;
|
use App\Entity\Parts\Category;
|
||||||
use App\Entity\Parts\Footprint;
|
use App\Entity\Parts\Footprint;
|
||||||
use App\Entity\Parts\Manufacturer;
|
use App\Entity\Parts\Manufacturer;
|
||||||
|
|
@ -33,8 +36,12 @@ use App\Entity\Parts\StorageLocation;
|
||||||
use App\Entity\Parts\Supplier;
|
use App\Entity\Parts\Supplier;
|
||||||
use App\Entity\ProjectSystem\Project;
|
use App\Entity\ProjectSystem\Project;
|
||||||
use App\Form\Filters\Constraints\BooleanConstraintType;
|
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\ChoiceConstraintType;
|
||||||
use App\Form\Filters\Constraints\DateTimeConstraintType;
|
use App\Form\Filters\Constraints\DateTimeConstraintType;
|
||||||
|
use App\Form\Filters\Constraints\EnumConstraintType;
|
||||||
use App\Form\Filters\Constraints\NumberConstraintType;
|
use App\Form\Filters\Constraints\NumberConstraintType;
|
||||||
use App\Form\Filters\Constraints\ParameterConstraintType;
|
use App\Form\Filters\Constraints\ParameterConstraintType;
|
||||||
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
|
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\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
use function Symfony\Component\Translation\t;
|
||||||
|
|
||||||
class PartFilterType extends AbstractType
|
class PartFilterType extends AbstractType
|
||||||
{
|
{
|
||||||
public function __construct(private readonly Security $security)
|
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, [
|
$builder->add('submit', SubmitType::class, [
|
||||||
'label' => 'filter.submit',
|
'label' => 'filter.submit',
|
||||||
|
|
|
||||||
62
src/Form/InfoProviderSystem/BulkProviderSearchType.php
Normal file
62
src/Form/InfoProviderSystem/BulkProviderSearchType.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/Form/InfoProviderSystem/FieldToProviderMappingType.php
Normal file
75
src/Form/InfoProviderSystem/FieldToProviderMappingType.php
Normal 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' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/Form/InfoProviderSystem/GlobalFieldMappingType.php
Normal file
67
src/Form/InfoProviderSystem/GlobalFieldMappingType.php
Normal 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' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,9 +24,7 @@ declare(strict_types=1);
|
||||||
namespace App\Form\InfoProviderSystem;
|
namespace App\Form\InfoProviderSystem;
|
||||||
|
|
||||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||||
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\ChoiceList\ChoiceList;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
use Symfony\Component\OptionsResolver\Options;
|
use Symfony\Component\OptionsResolver\Options;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,13 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Entity\Attachments\AttachmentContainingDBElement;
|
|
||||||
use App\Entity\Attachments\Attachment;
|
use App\Entity\Attachments\Attachment;
|
||||||
|
use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||||
use App\Entity\Attachments\AttachmentType;
|
use App\Entity\Attachments\AttachmentType;
|
||||||
use App\Entity\Base\AbstractDBElement;
|
use App\Entity\Base\AbstractDBElement;
|
||||||
use App\Entity\Contracts\NamedElementInterface;
|
use App\Entity\Contracts\NamedElementInterface;
|
||||||
use App\Entity\Parts\PartAssociation;
|
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
|
||||||
use App\Entity\ProjectSystem\Project;
|
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||||
use App\Entity\LabelSystem\LabelProfile;
|
use App\Entity\LabelSystem\LabelProfile;
|
||||||
use App\Entity\Parameters\AbstractParameter;
|
use App\Entity\Parameters\AbstractParameter;
|
||||||
use App\Entity\Parts\Category;
|
use App\Entity\Parts\Category;
|
||||||
|
|
@ -36,12 +36,14 @@ use App\Entity\Parts\Footprint;
|
||||||
use App\Entity\Parts\Manufacturer;
|
use App\Entity\Parts\Manufacturer;
|
||||||
use App\Entity\Parts\MeasurementUnit;
|
use App\Entity\Parts\MeasurementUnit;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Entity\Parts\PartAssociation;
|
||||||
use App\Entity\Parts\PartLot;
|
use App\Entity\Parts\PartLot;
|
||||||
use App\Entity\Parts\StorageLocation;
|
use App\Entity\Parts\StorageLocation;
|
||||||
use App\Entity\Parts\Supplier;
|
use App\Entity\Parts\Supplier;
|
||||||
use App\Entity\PriceInformations\Currency;
|
use App\Entity\PriceInformations\Currency;
|
||||||
use App\Entity\PriceInformations\Orderdetail;
|
use App\Entity\PriceInformations\Orderdetail;
|
||||||
use App\Entity\PriceInformations\Pricedetail;
|
use App\Entity\PriceInformations\Pricedetail;
|
||||||
|
use App\Entity\ProjectSystem\Project;
|
||||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
use App\Entity\UserSystem\Group;
|
use App\Entity\UserSystem\Group;
|
||||||
use App\Entity\UserSystem\User;
|
use App\Entity\UserSystem\User;
|
||||||
|
|
@ -79,6 +81,8 @@ class ElementTypeNameGenerator
|
||||||
AbstractParameter::class => $this->translator->trans('parameter.label'),
|
AbstractParameter::class => $this->translator->trans('parameter.label'),
|
||||||
LabelProfile::class => $this->translator->trans('label_profile.label'),
|
LabelProfile::class => $this->translator->trans('label_profile.label'),
|
||||||
PartAssociation::class => $this->translator->trans('part_association.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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,8 @@ class PartMerger implements EntityMergerInterface
|
||||||
return $target;
|
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
|
//We compare the translation keys, as it contains info about the type and other type info
|
||||||
return $t->getOther() === $o->getOther()
|
return $t->getOther() === $o->getOther()
|
||||||
&& $t->getTypeTranslationKey() === $o->getTypeTranslationKey();
|
&& $t->getTypeTranslationKey() === $o->getTypeTranslationKey();
|
||||||
|
|
@ -141,40 +142,39 @@ class PartMerger implements EntityMergerInterface
|
||||||
$owner->addAssociatedPartsAsOwner($clone);
|
$owner->addAssociatedPartsAsOwner($clone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge orderdetails, considering same supplier+part number as duplicates
|
||||||
$this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) {
|
$this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) {
|
||||||
//First check that the orderdetails infos are equal
|
// If supplier and part number match, merge the orderdetails
|
||||||
$tmp = $t->getSupplier() === $o->getSupplier()
|
if ($t->getSupplier() === $o->getSupplier() && $t->getSupplierPartNr() === $o->getSupplierPartNr()) {
|
||||||
&& $t->getSupplierPartNr() === $o->getSupplierPartNr()
|
// Update URL if target doesn't have one
|
||||||
&& $t->getSupplierProductUrl(false) === $o->getSupplierProductUrl(false);
|
if (empty($t->getSupplierProductUrl(false)) && !empty($o->getSupplierProductUrl(false))) {
|
||||||
|
$t->setSupplierProductUrl($o->getSupplierProductUrl(false));
|
||||||
if (!$tmp) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
// Merge price details: add new ones, update empty ones, keep existing non-empty ones
|
||||||
//Check if the pricedetails are equal
|
foreach ($o->getPricedetails() as $otherPrice) {
|
||||||
$t_pricedetails = $t->getPricedetails();
|
$found = false;
|
||||||
$o_pricedetails = $o->getPricedetails();
|
foreach ($t->getPricedetails() as $targetPrice) {
|
||||||
//Ensure that both pricedetails have the same length
|
if ($targetPrice->getMinDiscountQuantity() === $otherPrice->getMinDiscountQuantity()
|
||||||
if (count($t_pricedetails) !== count($o_pricedetails)) {
|
&& $targetPrice->getCurrency() === $otherPrice->getCurrency()) {
|
||||||
return false;
|
// 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;
|
||||||
//Check if all pricedetails are equal
|
break;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Add completely new price tiers
|
||||||
//If all pricedetails are equal, the orderdetails are equal
|
if (!$found) {
|
||||||
return true;
|
$clonedPrice = clone $otherPrice;
|
||||||
|
$clonedPrice->setOrderdetail($t);
|
||||||
|
$t->addPricedetail($clonedPrice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true; // Consider them equal so the other one gets skipped
|
||||||
|
}
|
||||||
|
return false; // Different supplier/part number, add as new
|
||||||
});
|
});
|
||||||
//The pricedetails are not correctly assigned to the new orderdetails, so fix that
|
//The pricedetails are not correctly assigned to the new orderdetails, so fix that
|
||||||
foreach ($target->getOrderdetails() as $orderdetail) {
|
foreach ($target->getOrderdetails() as $orderdetail) {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
use Symfony\Component\Serializer\SerializerInterface;
|
use Symfony\Component\Serializer\SerializerInterface;
|
||||||
use function Symfony\Component\String\u;
|
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.
|
* Use this class to export an entity to multiple file formats.
|
||||||
|
|
@ -52,7 +55,7 @@ class EntityExporter
|
||||||
protected function configureOptions(OptionsResolver $resolver): void
|
protected function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefault('format', 'csv');
|
$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->setDefault('csv_delimiter', ';');
|
||||||
$resolver->setAllowedTypes('csv_delimiter', 'string');
|
$resolver->setAllowedTypes('csv_delimiter', 'string');
|
||||||
|
|
@ -88,13 +91,20 @@ class EntityExporter
|
||||||
|
|
||||||
$options = $resolver->resolve($options);
|
$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
|
//If include children is set, then we need to add the include_children group
|
||||||
$groups = [$options['level']];
|
$groups = [$options['level']];
|
||||||
if ($options['include_children']) {
|
if ($options['include_children']) {
|
||||||
$groups[] = 'include_children';
|
$groups[] = 'include_children';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->serializer->serialize($entities, $options['format'],
|
return $this->serializer->serialize(
|
||||||
|
$entities,
|
||||||
|
$options['format'],
|
||||||
[
|
[
|
||||||
'groups' => $groups,
|
'groups' => $groups,
|
||||||
'as_collection' => true,
|
'as_collection' => true,
|
||||||
|
|
@ -109,7 +119,7 @@ class EntityExporter
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleCircularReference(object $object, string $format, array $context): string
|
private function handleCircularReference(object $object): string
|
||||||
{
|
{
|
||||||
if ($object instanceof AbstractStructuralDBElement) {
|
if ($object instanceof AbstractStructuralDBElement) {
|
||||||
return $object->getFullPath("->");
|
return $object->getFullPath("->");
|
||||||
|
|
@ -122,6 +132,74 @@ class EntityExporter
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports an Entity or an array of entities to multiple file formats.
|
* Exports an Entity or an array of entities to multiple file formats.
|
||||||
*
|
*
|
||||||
|
|
@ -156,19 +234,15 @@ class EntityExporter
|
||||||
|
|
||||||
//Determine the content type for the response
|
//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
|
//Try to use better content types based on the format
|
||||||
$format = $options['format'];
|
$format = $options['format'];
|
||||||
switch ($format) {
|
$content_type = match ($format) {
|
||||||
case 'xml':
|
'xml' => 'application/xml',
|
||||||
$content_type = 'application/xml';
|
'json' => 'application/json',
|
||||||
break;
|
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
case 'json':
|
'xls' => 'application/vnd.ms-excel',
|
||||||
$content_type = 'application/json';
|
default => 'text/plain',
|
||||||
break;
|
};
|
||||||
}
|
|
||||||
$response->headers->set('Content-Type', $content_type);
|
$response->headers->set('Content-Type', $content_type);
|
||||||
|
|
||||||
//If view option is not specified, then download the file.
|
//If view option is not specified, then download the file.
|
||||||
|
|
@ -186,7 +260,7 @@ class EntityExporter
|
||||||
|
|
||||||
$level = $options['level'];
|
$level = $options['level'];
|
||||||
|
|
||||||
$filename = 'export_'.$entity_name.'_'.$level.'.'.$format;
|
$filename = "export_{$entity_name}_{$level}.{$format}";
|
||||||
|
|
||||||
//Sanitize the filename
|
//Sanitize the filename
|
||||||
$filename = FilenameSanatizer::sanitizeFilename($filename);
|
$filename = FilenameSanatizer::sanitizeFilename($filename);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\File\File;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
use Symfony\Component\Serializer\SerializerInterface;
|
use Symfony\Component\Serializer\SerializerInterface;
|
||||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see \App\Tests\Services\ImportExportSystem\EntityImporterTest
|
* @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"];
|
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)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +198,10 @@ class EntityImporter
|
||||||
}
|
}
|
||||||
|
|
||||||
//The [] behind class_name denotes that we expect an array.
|
//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,
|
'groups' => $groups,
|
||||||
'csv_delimiter' => $options['csv_delimiter'],
|
'csv_delimiter' => $options['csv_delimiter'],
|
||||||
|
|
@ -204,7 +210,8 @@ class EntityImporter
|
||||||
'partdb_import' => true,
|
'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,
|
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
|
||||||
]);
|
]
|
||||||
|
);
|
||||||
|
|
||||||
//Ensure we have an array of entity elements.
|
//Ensure we have an array of entity elements.
|
||||||
if (!is_array($entities)) {
|
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
|
'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('csv_delimiter', 'string');
|
||||||
$resolver->setAllowedTypes('preserve_children', 'bool');
|
$resolver->setAllowedTypes('preserve_children', 'bool');
|
||||||
$resolver->setAllowedTypes('class', 'string');
|
$resolver->setAllowedTypes('class', 'string');
|
||||||
|
|
@ -335,6 +342,33 @@ class EntityImporter
|
||||||
*/
|
*/
|
||||||
public function importFile(File $file, array $options = [], array &$errors = []): array
|
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);
|
return $this->importString($file->getContent(), $options, $errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,10 +388,103 @@ class EntityImporter
|
||||||
'xml' => 'xml',
|
'xml' => 'xml',
|
||||||
'csv', 'tsv' => 'csv',
|
'csv', 'tsv' => 'csv',
|
||||||
'yaml', 'yml' => 'yaml',
|
'yaml', 'yml' => 'yaml',
|
||||||
|
'xlsx' => 'xlsx',
|
||||||
|
'xls' => 'xls',
|
||||||
default => null,
|
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.
|
* This functions corrects the parent setting based on the children value of the parent.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
380
src/Services/InfoProviderSystem/BulkInfoProviderService.php
Normal file
380
src/Services/InfoProviderSystem/BulkInfoProviderService.php
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
231
src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php
Normal file
231
src/Services/InfoProviderSystem/DTOs/BulkSearchResponseDTO.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,12 +28,12 @@ namespace App\Services\InfoProviderSystem\DTOs;
|
||||||
* This could be a datasheet, a 3D model, a picture or similar.
|
* This could be a datasheet, a 3D model, a picture or similar.
|
||||||
* @see \App\Tests\Services\InfoProviderSystem\DTOs\FileDTOTest
|
* @see \App\Tests\Services\InfoProviderSystem\DTOs\FileDTOTest
|
||||||
*/
|
*/
|
||||||
class FileDTO
|
readonly class FileDTO
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var string The URL where to get this file
|
* @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
|
* @param string $url The URL where to get this file
|
||||||
|
|
@ -41,7 +41,7 @@ class FileDTO
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $url,
|
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.
|
//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).
|
//We only want to replace characters which can not have a valid meaning in a URL (what would break the URL).
|
||||||
|
|
|
||||||
|
|
@ -28,17 +28,17 @@ namespace App\Services\InfoProviderSystem\DTOs;
|
||||||
* This could be a voltage, a current, a temperature or similar.
|
* This could be a voltage, a current, a temperature or similar.
|
||||||
* @see \App\Tests\Services\InfoProviderSystem\DTOs\ParameterDTOTest
|
* @see \App\Tests\Services\InfoProviderSystem\DTOs\ParameterDTOTest
|
||||||
*/
|
*/
|
||||||
class ParameterDTO
|
readonly class ParameterDTO
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $name,
|
public string $name,
|
||||||
public readonly ?string $value_text = null,
|
public ?string $value_text = null,
|
||||||
public readonly ?float $value_typ = null,
|
public ?float $value_typ = null,
|
||||||
public readonly ?float $value_min = null,
|
public ?float $value_min = null,
|
||||||
public readonly ?float $value_max = null,
|
public ?float $value_max = null,
|
||||||
public readonly ?string $unit = null,
|
public ?string $unit = null,
|
||||||
public readonly ?string $symbol = null,
|
public ?string $symbol = null,
|
||||||
public readonly ?string $group = null,
|
public ?string $group = null,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,21 +28,21 @@ use Brick\Math\BigDecimal;
|
||||||
/**
|
/**
|
||||||
* This DTO represents a price for a single unit in a certain discount range
|
* 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(
|
public function __construct(
|
||||||
/** @var float The minimum amount that needs to get ordered for this price to be valid */
|
/** @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 .) */
|
/** @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 */
|
/** @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 */
|
/** @var bool If the price includes tax */
|
||||||
public readonly ?bool $includes_tax = true,
|
public ?bool $includes_tax = true,
|
||||||
/** @var float the price related quantity */
|
/** @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);
|
$this->price_as_big_decimal = BigDecimal::of($this->price);
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,15 @@ namespace App\Services\InfoProviderSystem\DTOs;
|
||||||
* This DTO represents a purchase information for a part (supplier name, order number and prices).
|
* This DTO represents a purchase information for a part (supplier name, order number and prices).
|
||||||
* @see \App\Tests\Services\InfoProviderSystem\DTOs\PurchaseInfoDTOTest
|
* @see \App\Tests\Services\InfoProviderSystem\DTOs\PurchaseInfoDTOTest
|
||||||
*/
|
*/
|
||||||
class PurchaseInfoDTO
|
readonly class PurchaseInfoDTO
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $distributor_name,
|
public string $distributor_name,
|
||||||
public readonly string $order_number,
|
public string $order_number,
|
||||||
/** @var PriceDTO[] */
|
/** @var PriceDTO[] */
|
||||||
public readonly array $prices,
|
public array $prices,
|
||||||
/** @var string|null An url to the product page of the vendor */
|
/** @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
|
//Ensure that the prices are PriceDTO instances
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@ class SearchResultDTO
|
||||||
public readonly ?string $provider_url = null,
|
public readonly ?string $provider_url = null,
|
||||||
/** @var string|null A footprint representation of the providers page */
|
/** @var string|null A footprint representation of the providers page */
|
||||||
public readonly ?string $footprint = null,
|
public readonly ?string $footprint = null,
|
||||||
) {
|
)
|
||||||
|
{
|
||||||
if ($preview_image_url !== null) {
|
if ($preview_image_url !== null) {
|
||||||
//Utilize the escaping mechanism of FileDTO to ensure that the preview image URL is correctly encoded
|
//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
|
//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->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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\LCSCSettings;
|
||||||
use Symfony\Component\HttpFoundation\Cookie;
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
class LCSCProvider implements InfoProviderInterface
|
class LCSCProvider implements BatchInfoProviderInterface
|
||||||
{
|
{
|
||||||
|
|
||||||
private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
|
private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
|
||||||
|
|
@ -69,9 +69,10 @@ class LCSCProvider implements InfoProviderInterface
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $id
|
* @param string $id
|
||||||
|
* @param bool $lightweight If true, skip expensive operations like datasheet resolution
|
||||||
* @return PartDetailDTO
|
* @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", [
|
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
|
||||||
'headers' => [
|
'headers' => [
|
||||||
|
|
@ -89,7 +90,7 @@ class LCSCProvider implements InfoProviderInterface
|
||||||
throw new \RuntimeException('Could not find product code: ' . $id);
|
throw new \RuntimeException('Could not find product code: ' . $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->getPartDetail($product);
|
return $this->getPartDetail($product, $lightweight);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -119,10 +120,22 @@ class LCSCProvider implements InfoProviderInterface
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $term
|
* @param string $term
|
||||||
|
* @param bool $lightweight If true, skip expensive operations like datasheet resolution
|
||||||
* @return PartDetailDTO[]
|
* @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", [
|
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
|
||||||
'headers' => [
|
'headers' => [
|
||||||
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
'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.
|
// 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 product tip exists and there are no products in the product list try a detail query
|
||||||
if (count($products) === 0 && $tipProductCode !== null) {
|
if (count($products) === 0 && $tipProductCode !== null) {
|
||||||
$result[] = $this->queryDetail($tipProductCode);
|
$result[] = $this->queryDetail($tipProductCode, $lightweight);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($products as $product) {
|
foreach ($products as $product) {
|
||||||
$result[] = $this->getPartDetail($product);
|
$result[] = $this->getPartDetail($product, $lightweight);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|
@ -178,7 +191,7 @@ class LCSCProvider implements InfoProviderInterface
|
||||||
* @param array $product
|
* @param array $product
|
||||||
* @return PartDetailDTO
|
* @return PartDetailDTO
|
||||||
*/
|
*/
|
||||||
private function getPartDetail(array $product): PartDetailDTO
|
private function getPartDetail(array $product, bool $lightweight = false): PartDetailDTO
|
||||||
{
|
{
|
||||||
// Get product images in advance
|
// Get product images in advance
|
||||||
$product_images = $this->getProductImages($product['productImages'] ?? null);
|
$product_images = $this->getProductImages($product['productImages'] ?? null);
|
||||||
|
|
@ -214,10 +227,10 @@ class LCSCProvider implements InfoProviderInterface
|
||||||
manufacturing_status: null,
|
manufacturing_status: null,
|
||||||
provider_url: $this->getProductShortURL($product['productCode']),
|
provider_url: $this->getProductShortURL($product['productCode']),
|
||||||
footprint: $this->sanitizeField($footprint),
|
footprint: $this->sanitizeField($footprint),
|
||||||
datasheets: $this->getProductDatasheets($product['pdfUrl'] ?? null),
|
datasheets: $lightweight ? [] : $this->getProductDatasheets($product['pdfUrl'] ?? null),
|
||||||
images: $product_images,
|
images: $product_images, // Always include images - users need to see them
|
||||||
parameters: $this->attributesToParameters($product['paramVOList'] ?? []),
|
parameters: $lightweight ? [] : $this->attributesToParameters($product['paramVOList'] ?? []),
|
||||||
vendor_infos: $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []),
|
vendor_infos: $lightweight ? [] : $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []),
|
||||||
mass: $product['weight'] ?? null,
|
mass: $product['weight'] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -338,12 +351,86 @@ class LCSCProvider implements InfoProviderInterface
|
||||||
|
|
||||||
public function searchByKeyword(string $keyword): array
|
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('GET', self::ENDPOINT_URL . "/search/global", [
|
||||||
|
'headers' => [
|
||||||
|
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
||||||
|
],
|
||||||
|
'query' => [
|
||||||
|
'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
|
public function getDetails(string $id): PartDetailDTO
|
||||||
{
|
{
|
||||||
$tmp = $this->queryByTerm($id);
|
$tmp = $this->queryByTerm($id, false);
|
||||||
if (count($tmp) === 0) {
|
if (count($tmp) === 0) {
|
||||||
throw new \RuntimeException('No part found with ID ' . $id);
|
throw new \RuntimeException('No part found with ID ' . $id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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);
|
$tmp = $this->responseToDTOArray($response);
|
||||||
|
|
||||||
//Ensure that we have exactly one result
|
//Ensure that we have exactly one result
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,11 @@ use App\Entity\Parts\Manufacturer;
|
||||||
use App\Entity\Parts\MeasurementUnit;
|
use App\Entity\Parts\MeasurementUnit;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\Parts\PartLot;
|
use App\Entity\Parts\PartLot;
|
||||||
use App\Repository\PartRepository;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
|
||||||
|
|
||||||
use function Symfony\Component\Translation\t;
|
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
|
//When action starts with "export_" we have to redirect to the export controller
|
||||||
$matches = [];
|
$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));
|
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
|
||||||
$level = match ($target_id) {
|
$level = match ($target_id) {
|
||||||
2 => 'extended',
|
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:
|
//Iterate over the parts and apply the action to it:
|
||||||
foreach ($selected_parts as $part) {
|
foreach ($selected_parts as $part) {
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,11 @@ class ToolsTreeBuilder
|
||||||
$this->translator->trans('info_providers.search.title'),
|
$this->translator->trans('info_providers.search.title'),
|
||||||
$this->urlGenerator->generate('info_providers_search')
|
$this->urlGenerator->generate('info_providers_search')
|
||||||
))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down');
|
))->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;
|
return $nodes;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
<input type="hidden" name="ids" {{ stimulus_target('elements/datatables/parts', 'selectIDs') }} value="">
|
<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') }}>
|
<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> #}
|
<small class="text-muted">{% trans %}part_list.action.scrollable_hint{% endtrans %}</small>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<button class="btn btn-outline-secondary" type="button" {{ stimulus_action('elements/datatables/parts', 'invertSelection')}}
|
<button class="btn btn-outline-secondary" type="button" {{ stimulus_action('elements/datatables/parts', 'invertSelection')}}
|
||||||
|
|
@ -72,6 +72,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_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_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_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>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|
|
||||||
124
templates/info_providers/bulk_import/manage.html.twig
Normal file
124
templates/info_providers/bulk_import/manage.html.twig
Normal 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 %}
|
||||||
304
templates/info_providers/bulk_import/step1.html.twig
Normal file
304
templates/info_providers/bulk_import/step1.html.twig
Normal 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 %}
|
||||||
|
|
||||||
240
templates/info_providers/bulk_import/step2.html.twig
Normal file
240
templates/info_providers/bulk_import/step2.html.twig
Normal 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 %}
|
||||||
|
|
||||||
|
|
@ -4,6 +4,32 @@
|
||||||
{% trans with {'%name%': part.name|escape } %}part.edit.title{% endtrans %}
|
{% trans with {'%name%': part.name|escape } %}part.edit.title{% endtrans %}
|
||||||
{% endblock %}
|
{% 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 %}
|
{% block card_title %}
|
||||||
<i class="fas fa-edit fa-fw" aria-hidden="true"></i>
|
<i class="fas fa-edit fa-fw" aria-hidden="true"></i>
|
||||||
{% trans with {'%name%': part.name|escape } %}part.edit.card_title{% endtrans %}
|
{% trans with {'%name%': part.name|escape } %}part.edit.card_title{% endtrans %}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,19 @@
|
||||||
{% block card_border %}border-info{% endblock %}
|
{% block card_border %}border-info{% endblock %}
|
||||||
{% block card_type %}bg-info text-bg-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 %}
|
{% block title %}
|
||||||
{% trans %}info_providers.update_part.title{% endtrans %}: {{ merge_old_name }}
|
{% trans %}info_providers.update_part.title{% endtrans %}: {{ merge_old_name }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
</li>
|
||||||
{% endif %}
|
{% 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>
|
</ul>
|
||||||
|
|
||||||
{{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }}
|
{{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }}
|
||||||
|
|
@ -126,6 +131,13 @@
|
||||||
{{ form_row(filterForm.bomComment) }}
|
{{ form_row(filterForm.bomComment) }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
889
tests/Controller/BulkInfoProviderImportControllerTest.php
Normal file
889
tests/Controller/BulkInfoProviderImportControllerTest.php
Normal 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('name', ['lcsc'], 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
334
tests/Controller/PartControllerTest.php
Normal file
334
tests/Controller/PartControllerTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
71
tests/Entity/BulkImportJobStatusTest.php
Normal file
71
tests/Entity/BulkImportJobStatusTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
301
tests/Entity/BulkInfoProviderImportJobPartTest.php
Normal file
301
tests/Entity/BulkInfoProviderImportJobPartTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
368
tests/Entity/BulkInfoProviderImportJobTest.php
Normal file
368
tests/Entity/BulkInfoProviderImportJobTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
68
tests/Form/InfoProviderSystem/GlobalFieldMappingTypeTest.php
Normal file
68
tests/Form/InfoProviderSystem/GlobalFieldMappingTypeTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -112,7 +112,8 @@ class LogEntryRepositoryTest extends KernelTestCase
|
||||||
$this->assertCount(2, $logs);
|
$this->assertCount(2, $logs);
|
||||||
|
|
||||||
//The first one must be newer than the second one
|
//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
|
public function testGetElementExistedAtTimestamp(): void
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,12 @@ namespace App\Tests\Services;
|
||||||
use App\Entity\Attachments\PartAttachment;
|
use App\Entity\Attachments\PartAttachment;
|
||||||
use App\Entity\Base\AbstractDBElement;
|
use App\Entity\Base\AbstractDBElement;
|
||||||
use App\Entity\Base\AbstractNamedDBElement;
|
use App\Entity\Base\AbstractNamedDBElement;
|
||||||
|
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
|
||||||
use App\Entity\Parts\Category;
|
use App\Entity\Parts\Category;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use App\Exceptions\EntityNotSupportedException;
|
use App\Exceptions\EntityNotSupportedException;
|
||||||
use App\Services\Formatters\AmountFormatter;
|
|
||||||
use App\Services\ElementTypeNameGenerator;
|
use App\Services\ElementTypeNameGenerator;
|
||||||
|
use App\Services\Formatters\AmountFormatter;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
class ElementTypeNameGeneratorTest extends WebTestCase
|
class ElementTypeNameGeneratorTest extends WebTestCase
|
||||||
|
|
@ -50,12 +51,14 @@ class ElementTypeNameGeneratorTest extends WebTestCase
|
||||||
//We only test in english
|
//We only test in english
|
||||||
$this->assertSame('Part', $this->service->getLocalizedTypeLabel(new Part()));
|
$this->assertSame('Part', $this->service->getLocalizedTypeLabel(new Part()));
|
||||||
$this->assertSame('Category', $this->service->getLocalizedTypeLabel(new Category()));
|
$this->assertSame('Category', $this->service->getLocalizedTypeLabel(new Category()));
|
||||||
|
$this->assertSame('Bulk info provider import', $this->service->getLocalizedTypeLabel(new BulkInfoProviderImportJob()));
|
||||||
|
|
||||||
//Test inheritance
|
//Test inheritance
|
||||||
$this->assertSame('Attachment', $this->service->getLocalizedTypeLabel(new PartAttachment()));
|
$this->assertSame('Attachment', $this->service->getLocalizedTypeLabel(new PartAttachment()));
|
||||||
|
|
||||||
//Test for class name
|
//Test for class name
|
||||||
$this->assertSame('Part', $this->service->getLocalizedTypeLabel(Part::class));
|
$this->assertSame('Part', $this->service->getLocalizedTypeLabel(Part::class));
|
||||||
|
$this->assertSame('Bulk info provider import', $this->service->getLocalizedTypeLabel(BulkInfoProviderImportJob::class));
|
||||||
|
|
||||||
//Test exception for unknpwn type
|
//Test exception for unknpwn type
|
||||||
$this->expectException(EntityNotSupportedException::class);
|
$this->expectException(EntityNotSupportedException::class);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ use App\Entity\Parts\Category;
|
||||||
use App\Services\ImportExportSystem\EntityExporter;
|
use App\Services\ImportExportSystem\EntityExporter;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
|
||||||
class EntityExporterTest extends WebTestCase
|
class EntityExporterTest extends WebTestCase
|
||||||
{
|
{
|
||||||
|
|
@ -76,7 +77,40 @@ class EntityExporterTest extends WebTestCase
|
||||||
|
|
||||||
$this->assertSame('application/json', $response->headers->get('Content-Type'));
|
$this->assertSame('application/json', $response->headers->get('Content-Type'));
|
||||||
$this->assertNotEmpty($response->headers->get('Content-Disposition'));
|
$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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
use Symfony\Component\Validator\ConstraintViolation;
|
use Symfony\Component\Validator\ConstraintViolation;
|
||||||
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\File\File;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||||
|
|
||||||
#[Group('DB')]
|
#[Group('DB')]
|
||||||
class EntityImporterTest extends WebTestCase
|
class EntityImporterTest extends WebTestCase
|
||||||
|
|
@ -207,6 +210,10 @@ EOT;
|
||||||
yield ['json', 'json'];
|
yield ['json', 'json'];
|
||||||
yield ['yaml', 'yml'];
|
yield ['yaml', 'yml'];
|
||||||
yield ['yaml', 'YAML'];
|
yield ['yaml', 'YAML'];
|
||||||
|
yield ['xlsx', 'xlsx'];
|
||||||
|
yield ['xlsx', 'XLSX'];
|
||||||
|
yield ['xls', 'xls'];
|
||||||
|
yield ['xls', 'XLS'];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[DataProvider('formatDataProvider')]
|
#[DataProvider('formatDataProvider')]
|
||||||
|
|
@ -342,4 +349,41 @@ EOT;
|
||||||
$this->assertSame($category, $results[0]->getCategory());
|
$this->assertSame($category, $results[0]->getCategory());
|
||||||
$this->assertSame('test,test2', $results[0]->getTags());
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\InfoProviderSystem\DTOs;
|
||||||
|
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class BulkSearchFieldMappingDTOTest extends TestCase
|
||||||
|
{
|
||||||
|
|
||||||
|
public function testIsSupplierPartNumberField(): void
|
||||||
|
{
|
||||||
|
$fieldMapping = new BulkSearchFieldMappingDTO(field: 'reichelt_spn', providers: ['provider1'], priority: 1);
|
||||||
|
$this->assertTrue($fieldMapping->isSupplierPartNumberField());
|
||||||
|
|
||||||
|
$fieldMapping = new BulkSearchFieldMappingDTO(field: 'partNumber', providers: ['provider1'], priority: 1);
|
||||||
|
$this->assertFalse($fieldMapping->isSupplierPartNumberField());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToSerializableArray(): void
|
||||||
|
{
|
||||||
|
$fieldMapping = new BulkSearchFieldMappingDTO(field: 'test', providers: ['provider1', 'provider2'], priority: 3);
|
||||||
|
$array = $fieldMapping->toSerializableArray();
|
||||||
|
$this->assertIsArray($array);
|
||||||
|
$this->assertSame([
|
||||||
|
'field' => 'test',
|
||||||
|
'providers' => ['provider1', 'provider2'],
|
||||||
|
'priority' => 3,
|
||||||
|
], $array);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFromSerializableArray(): void
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'field' => 'test',
|
||||||
|
'providers' => ['provider1', 'provider2'],
|
||||||
|
'priority' => 3,
|
||||||
|
];
|
||||||
|
$fieldMapping = BulkSearchFieldMappingDTO::fromSerializableArray($data);
|
||||||
|
$this->assertInstanceOf(BulkSearchFieldMappingDTO::class, $fieldMapping);
|
||||||
|
$this->assertSame('test', $fieldMapping->field);
|
||||||
|
$this->assertSame(['provider1', 'provider2'], $fieldMapping->providers);
|
||||||
|
$this->assertSame(3, $fieldMapping->priority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\InfoProviderSystem\DTOs;
|
||||||
|
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class BulkSearchPartResultsDTOTest extends TestCase
|
||||||
|
{
|
||||||
|
|
||||||
|
public function testHasErrors(): void
|
||||||
|
{
|
||||||
|
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []);
|
||||||
|
$this->assertFalse($test->hasErrors());
|
||||||
|
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], ['error1']);
|
||||||
|
$this->assertTrue($test->hasErrors());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetErrorCount(): void
|
||||||
|
{
|
||||||
|
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []);
|
||||||
|
$this->assertCount(0, $test->errors);
|
||||||
|
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], ['error1', 'error2']);
|
||||||
|
$this->assertCount(2, $test->errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHasResults(): void
|
||||||
|
{
|
||||||
|
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []);
|
||||||
|
$this->assertFalse($test->hasResults());
|
||||||
|
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [ $this->createMock(\App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO::class) ], []);
|
||||||
|
$this->assertTrue($test->hasResults());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetResultCount(): void
|
||||||
|
{
|
||||||
|
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [], []);
|
||||||
|
$this->assertCount(0, $test->searchResults);
|
||||||
|
$test = new BulkSearchPartResultsDTO($this->createMock(\App\Entity\Parts\Part::class), [
|
||||||
|
$this->createMock(\App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO::class),
|
||||||
|
$this->createMock(\App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO::class)
|
||||||
|
], []);
|
||||||
|
$this->assertCount(2, $test->searchResults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,258 @@
|
||||||
|
<?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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Services\InfoProviderSystem\DTOs;
|
||||||
|
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
|
class BulkSearchResponseDTOTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
|
||||||
|
private EntityManagerInterface $entityManager;
|
||||||
|
|
||||||
|
private BulkSearchResponseDTO $dummyEmpty;
|
||||||
|
private BulkSearchResponseDTO $dummy;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->dummyEmpty = new BulkSearchResponseDTO(partResults: []);
|
||||||
|
$this->dummy = new BulkSearchResponseDTO(partResults: [
|
||||||
|
new BulkSearchPartResultsDTO(
|
||||||
|
part: $this->entityManager->find(Part::class, 1),
|
||||||
|
searchResults: [
|
||||||
|
new BulkSearchPartResultDTO(
|
||||||
|
searchResult: new SearchResultDTO(provider_key: "dummy", provider_id: "1234", name: "Test Part", description: "A part for testing"),
|
||||||
|
sourceField: "mpn", sourceKeyword: "1234", priority: 1
|
||||||
|
),
|
||||||
|
new BulkSearchPartResultDTO(
|
||||||
|
searchResult: new SearchResultDTO(provider_key: "test", provider_id: "test", name: "Test Part2", description: "A part for testing"),
|
||||||
|
sourceField: "name", sourceKeyword: "1234",
|
||||||
|
localPart: $this->entityManager->find(Part::class, 2), priority: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
errors: ['Error 1']
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSerializationBackAndForthEmpty(): void
|
||||||
|
{
|
||||||
|
$serialized = $this->dummyEmpty->toSerializableRepresentation();
|
||||||
|
//Ensure that it is json_encodable
|
||||||
|
$json = json_encode($serialized, JSON_THROW_ON_ERROR);
|
||||||
|
$this->assertJson($json);
|
||||||
|
$deserialized = BulkSearchResponseDTO::fromSerializableRepresentation(json_decode($json), $this->entityManager);
|
||||||
|
|
||||||
|
$this->assertEquals($this->dummyEmpty, $deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSerializationBackAndForth(): void
|
||||||
|
{
|
||||||
|
$serialized = $this->dummy->toSerializableRepresentation();
|
||||||
|
//Ensure that it is json_encodable
|
||||||
|
$this->assertJson(json_encode($serialized, JSON_THROW_ON_ERROR));
|
||||||
|
$deserialized = BulkSearchResponseDTO::fromSerializableRepresentation($serialized, $this->entityManager);
|
||||||
|
|
||||||
|
$this->assertEquals($this->dummy, $deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToSerializableRepresentation(): void
|
||||||
|
{
|
||||||
|
$serialized = $this->dummy->toSerializableRepresentation();
|
||||||
|
|
||||||
|
$expected = array (
|
||||||
|
0 =>
|
||||||
|
array (
|
||||||
|
'part_id' => 1,
|
||||||
|
'search_results' =>
|
||||||
|
array (
|
||||||
|
0 =>
|
||||||
|
array (
|
||||||
|
'dto' =>
|
||||||
|
array (
|
||||||
|
'provider_key' => 'dummy',
|
||||||
|
'provider_id' => '1234',
|
||||||
|
'name' => 'Test Part',
|
||||||
|
'description' => 'A part for testing',
|
||||||
|
'category' => NULL,
|
||||||
|
'manufacturer' => NULL,
|
||||||
|
'mpn' => NULL,
|
||||||
|
'preview_image_url' => NULL,
|
||||||
|
'manufacturing_status' => NULL,
|
||||||
|
'provider_url' => NULL,
|
||||||
|
'footprint' => NULL,
|
||||||
|
),
|
||||||
|
'source_field' => 'mpn',
|
||||||
|
'source_keyword' => '1234',
|
||||||
|
'localPart' => NULL,
|
||||||
|
'priority' => 1,
|
||||||
|
),
|
||||||
|
1 =>
|
||||||
|
array (
|
||||||
|
'dto' =>
|
||||||
|
array (
|
||||||
|
'provider_key' => 'test',
|
||||||
|
'provider_id' => 'test',
|
||||||
|
'name' => 'Test Part2',
|
||||||
|
'description' => 'A part for testing',
|
||||||
|
'category' => NULL,
|
||||||
|
'manufacturer' => NULL,
|
||||||
|
'mpn' => NULL,
|
||||||
|
'preview_image_url' => NULL,
|
||||||
|
'manufacturing_status' => NULL,
|
||||||
|
'provider_url' => NULL,
|
||||||
|
'footprint' => NULL,
|
||||||
|
),
|
||||||
|
'source_field' => 'name',
|
||||||
|
'source_keyword' => '1234',
|
||||||
|
'localPart' => 2,
|
||||||
|
'priority' => 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'errors' =>
|
||||||
|
array (
|
||||||
|
0 => 'Error 1',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals($expected, $serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFromSerializableRepresentation(): void
|
||||||
|
{
|
||||||
|
$input = array (
|
||||||
|
0 =>
|
||||||
|
array (
|
||||||
|
'part_id' => 1,
|
||||||
|
'search_results' =>
|
||||||
|
array (
|
||||||
|
0 =>
|
||||||
|
array (
|
||||||
|
'dto' =>
|
||||||
|
array (
|
||||||
|
'provider_key' => 'dummy',
|
||||||
|
'provider_id' => '1234',
|
||||||
|
'name' => 'Test Part',
|
||||||
|
'description' => 'A part for testing',
|
||||||
|
'category' => NULL,
|
||||||
|
'manufacturer' => NULL,
|
||||||
|
'mpn' => NULL,
|
||||||
|
'preview_image_url' => NULL,
|
||||||
|
'manufacturing_status' => NULL,
|
||||||
|
'provider_url' => NULL,
|
||||||
|
'footprint' => NULL,
|
||||||
|
),
|
||||||
|
'source_field' => 'mpn',
|
||||||
|
'source_keyword' => '1234',
|
||||||
|
'localPart' => NULL,
|
||||||
|
'priority' => 1,
|
||||||
|
),
|
||||||
|
1 =>
|
||||||
|
array (
|
||||||
|
'dto' =>
|
||||||
|
array (
|
||||||
|
'provider_key' => 'test',
|
||||||
|
'provider_id' => 'test',
|
||||||
|
'name' => 'Test Part2',
|
||||||
|
'description' => 'A part for testing',
|
||||||
|
'category' => NULL,
|
||||||
|
'manufacturer' => NULL,
|
||||||
|
'mpn' => NULL,
|
||||||
|
'preview_image_url' => NULL,
|
||||||
|
'manufacturing_status' => NULL,
|
||||||
|
'provider_url' => NULL,
|
||||||
|
'footprint' => NULL,
|
||||||
|
),
|
||||||
|
'source_field' => 'name',
|
||||||
|
'source_keyword' => '1234',
|
||||||
|
'localPart' => 2,
|
||||||
|
'priority' => 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'errors' =>
|
||||||
|
array (
|
||||||
|
0 => 'Error 1',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$deserialized = BulkSearchResponseDTO::fromSerializableRepresentation($input, $this->entityManager);
|
||||||
|
$this->assertEquals($this->dummy, $deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMerge(): void
|
||||||
|
{
|
||||||
|
$merged = BulkSearchResponseDTO::merge($this->dummy, $this->dummyEmpty);
|
||||||
|
$this->assertCount(1, $merged->partResults);
|
||||||
|
|
||||||
|
$merged = BulkSearchResponseDTO::merge($this->dummyEmpty, $this->dummyEmpty);
|
||||||
|
$this->assertCount(0, $merged->partResults);
|
||||||
|
|
||||||
|
$merged = BulkSearchResponseDTO::merge($this->dummy, $this->dummy, $this->dummy);
|
||||||
|
$this->assertCount(3, $merged->partResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReplaceResultsForPart(): void
|
||||||
|
{
|
||||||
|
$newPartResults = new BulkSearchPartResultsDTO(
|
||||||
|
part: $this->entityManager->find(Part::class, 1),
|
||||||
|
searchResults: [
|
||||||
|
new BulkSearchPartResultDTO(
|
||||||
|
searchResult: new SearchResultDTO(provider_key: "new", provider_id: "new", name: "New Part", description: "A new part"),
|
||||||
|
sourceField: "mpn", sourceKeyword: "new", priority: 1
|
||||||
|
)
|
||||||
|
],
|
||||||
|
errors: ['New Error']
|
||||||
|
);
|
||||||
|
|
||||||
|
$replaced = $this->dummy->replaceResultsForPart($newPartResults);
|
||||||
|
$this->assertCount(1, $replaced->partResults);
|
||||||
|
$this->assertSame($newPartResults, $replaced->partResults[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReplaceResultsForPartNotExisting(): void
|
||||||
|
{
|
||||||
|
$newPartResults = new BulkSearchPartResultsDTO(
|
||||||
|
part: $this->entityManager->find(Part::class, 1),
|
||||||
|
searchResults: [
|
||||||
|
new BulkSearchPartResultDTO(
|
||||||
|
searchResult: new SearchResultDTO(provider_key: "new", provider_id: "new", name: "New Part", description: "A new part"),
|
||||||
|
sourceField: "mpn", sourceKeyword: "new", priority: 1
|
||||||
|
)
|
||||||
|
],
|
||||||
|
errors: ['New Error']
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
|
||||||
|
$replaced = $this->dummyEmpty->replaceResultsForPart($newPartResults);
|
||||||
|
}
|
||||||
|
}
|
||||||
540
tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php
Normal file
540
tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php
Normal file
|
|
@ -0,0 +1,540 @@
|
||||||
|
<?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)
|
||||||
|
* Copyright (C) 2024 Nexrem (https://github.com/meganukebmp)
|
||||||
|
*
|
||||||
|
* 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\Services\InfoProviderSystem\Providers;
|
||||||
|
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||||
|
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||||
|
use App\Services\InfoProviderSystem\Providers\LCSCProvider;
|
||||||
|
use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
|
||||||
|
use App\Settings\InfoProviderSystem\LCSCSettings;
|
||||||
|
use App\Tests\SettingsTestHelper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class LCSCProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
private LCSCSettings $settings;
|
||||||
|
private LCSCProvider $provider;
|
||||||
|
private MockHttpClient $httpClient;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->httpClient = new MockHttpClient();
|
||||||
|
$this->settings = SettingsTestHelper::createSettingsDummy(LCSCSettings::class);
|
||||||
|
$this->settings->currency = 'USD';
|
||||||
|
$this->settings->enabled = true;
|
||||||
|
$this->provider = new LCSCProvider($this->httpClient, $this->settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetProviderInfo(): void
|
||||||
|
{
|
||||||
|
$info = $this->provider->getProviderInfo();
|
||||||
|
|
||||||
|
$this->assertIsArray($info);
|
||||||
|
$this->assertArrayHasKey('name', $info);
|
||||||
|
$this->assertArrayHasKey('description', $info);
|
||||||
|
$this->assertArrayHasKey('url', $info);
|
||||||
|
$this->assertArrayHasKey('disabled_help', $info);
|
||||||
|
$this->assertEquals('LCSC', $info['name']);
|
||||||
|
$this->assertEquals('https://www.lcsc.com/', $info['url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetProviderKey(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals('lcsc', $this->provider->getProviderKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsActiveWhenEnabled(): void
|
||||||
|
{
|
||||||
|
//Ensure that the settings are enabled
|
||||||
|
$this->settings->enabled = true;
|
||||||
|
$this->assertTrue($this->provider->isActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsActiveWhenDisabled(): void
|
||||||
|
{
|
||||||
|
//Ensure that the settings are disabled
|
||||||
|
$this->settings->enabled = false;
|
||||||
|
$this->assertFalse($this->provider->isActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetCapabilities(): void
|
||||||
|
{
|
||||||
|
$capabilities = $this->provider->getCapabilities();
|
||||||
|
|
||||||
|
$this->assertIsArray($capabilities);
|
||||||
|
$this->assertContains(ProviderCapabilities::BASIC, $capabilities);
|
||||||
|
$this->assertContains(ProviderCapabilities::PICTURE, $capabilities);
|
||||||
|
$this->assertContains(ProviderCapabilities::DATASHEET, $capabilities);
|
||||||
|
$this->assertContains(ProviderCapabilities::PRICE, $capabilities);
|
||||||
|
$this->assertContains(ProviderCapabilities::FOOTPRINT, $capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSearchByKeywordWithCCode(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productCode' => 'C123456',
|
||||||
|
'productModel' => 'Test Component',
|
||||||
|
'productIntroEn' => 'Test description',
|
||||||
|
'brandNameEn' => 'Test Manufacturer',
|
||||||
|
'encapStandard' => '0603',
|
||||||
|
'productImageUrl' => 'https://example.com/image.jpg',
|
||||||
|
'productImages' => ['https://example.com/image1.jpg'],
|
||||||
|
'productPriceList' => [
|
||||||
|
['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$']
|
||||||
|
],
|
||||||
|
'paramVOList' => [
|
||||||
|
['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΩ']
|
||||||
|
],
|
||||||
|
'pdfUrl' => 'https://example.com/datasheet.pdf',
|
||||||
|
'weight' => 0.001
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||||
|
|
||||||
|
$results = $this->provider->searchByKeyword('C123456');
|
||||||
|
|
||||||
|
$this->assertIsArray($results);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertInstanceOf(PartDetailDTO::class, $results[0]);
|
||||||
|
$this->assertEquals('C123456', $results[0]->provider_id);
|
||||||
|
$this->assertEquals('Test Component', $results[0]->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSearchByKeywordWithRegularTerm(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productSearchResultVO' => [
|
||||||
|
'productList' => [
|
||||||
|
[
|
||||||
|
'productCode' => 'C789012',
|
||||||
|
'productModel' => 'Regular Component',
|
||||||
|
'productIntroEn' => 'Regular description',
|
||||||
|
'brandNameEn' => 'Regular Manufacturer',
|
||||||
|
'encapStandard' => '0805',
|
||||||
|
'productImageUrl' => 'https://example.com/regular.jpg',
|
||||||
|
'productImages' => ['https://example.com/regular1.jpg'],
|
||||||
|
'productPriceList' => [
|
||||||
|
['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => '€']
|
||||||
|
],
|
||||||
|
'paramVOList' => [],
|
||||||
|
'pdfUrl' => null,
|
||||||
|
'weight' => null
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||||
|
|
||||||
|
$results = $this->provider->searchByKeyword('resistor');
|
||||||
|
|
||||||
|
$this->assertIsArray($results);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertInstanceOf(PartDetailDTO::class, $results[0]);
|
||||||
|
$this->assertEquals('C789012', $results[0]->provider_id);
|
||||||
|
$this->assertEquals('Regular Component', $results[0]->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSearchByKeywordWithTipProduct(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productSearchResultVO' => [
|
||||||
|
'productList' => []
|
||||||
|
],
|
||||||
|
'tipProductDetailUrlVO' => [
|
||||||
|
'productCode' => 'C555555'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$detailResponse = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productCode' => 'C555555',
|
||||||
|
'productModel' => 'Tip Component',
|
||||||
|
'productIntroEn' => 'Tip description',
|
||||||
|
'brandNameEn' => 'Tip Manufacturer',
|
||||||
|
'encapStandard' => '1206',
|
||||||
|
'productImageUrl' => null,
|
||||||
|
'productImages' => [],
|
||||||
|
'productPriceList' => [],
|
||||||
|
'paramVOList' => [],
|
||||||
|
'pdfUrl' => null,
|
||||||
|
'weight' => null
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->httpClient->setResponseFactory([$mockResponse, $detailResponse]);
|
||||||
|
|
||||||
|
$results = $this->provider->searchByKeyword('special');
|
||||||
|
|
||||||
|
$this->assertIsArray($results);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertInstanceOf(PartDetailDTO::class, $results[0]);
|
||||||
|
$this->assertEquals('C555555', $results[0]->provider_id);
|
||||||
|
$this->assertEquals('Tip Component', $results[0]->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSearchByKeywordsBatch(): void
|
||||||
|
{
|
||||||
|
$mockResponse1 = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productCode' => 'C123456',
|
||||||
|
'productModel' => 'Batch Component 1',
|
||||||
|
'productIntroEn' => 'Batch description 1',
|
||||||
|
'brandNameEn' => 'Batch Manufacturer',
|
||||||
|
'encapStandard' => '0603',
|
||||||
|
'productImageUrl' => null,
|
||||||
|
'productImages' => [],
|
||||||
|
'productPriceList' => [],
|
||||||
|
'paramVOList' => [],
|
||||||
|
'pdfUrl' => null,
|
||||||
|
'weight' => null
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$mockResponse2 = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productSearchResultVO' => [
|
||||||
|
'productList' => [
|
||||||
|
[
|
||||||
|
'productCode' => 'C789012',
|
||||||
|
'productModel' => 'Batch Component 2',
|
||||||
|
'productIntroEn' => 'Batch description 2',
|
||||||
|
'brandNameEn' => 'Batch Manufacturer',
|
||||||
|
'encapStandard' => '0805',
|
||||||
|
'productImageUrl' => null,
|
||||||
|
'productImages' => [],
|
||||||
|
'productPriceList' => [],
|
||||||
|
'paramVOList' => [],
|
||||||
|
'pdfUrl' => null,
|
||||||
|
'weight' => null
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->httpClient->setResponseFactory([$mockResponse1, $mockResponse2]);
|
||||||
|
|
||||||
|
$results = $this->provider->searchByKeywordsBatch(['C123456', 'resistor']);
|
||||||
|
|
||||||
|
$this->assertIsArray($results);
|
||||||
|
$this->assertArrayHasKey('C123456', $results);
|
||||||
|
$this->assertArrayHasKey('resistor', $results);
|
||||||
|
$this->assertCount(1, $results['C123456']);
|
||||||
|
$this->assertCount(1, $results['resistor']);
|
||||||
|
$this->assertEquals('C123456', $results['C123456'][0]->provider_id);
|
||||||
|
$this->assertEquals('C789012', $results['resistor'][0]->provider_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetDetails(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productCode' => 'C123456',
|
||||||
|
'productModel' => 'Detailed Component',
|
||||||
|
'productIntroEn' => 'Detailed description',
|
||||||
|
'brandNameEn' => 'Detailed Manufacturer',
|
||||||
|
'encapStandard' => '0603',
|
||||||
|
'productImageUrl' => 'https://example.com/detail.jpg',
|
||||||
|
'productImages' => ['https://example.com/detail1.jpg'],
|
||||||
|
'productPriceList' => [
|
||||||
|
['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$'],
|
||||||
|
['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => 'US$']
|
||||||
|
],
|
||||||
|
'paramVOList' => [
|
||||||
|
['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΩ'],
|
||||||
|
['paramNameEn' => 'Tolerance', 'paramValueEn' => '1%']
|
||||||
|
],
|
||||||
|
'pdfUrl' => 'https://example.com/datasheet.pdf',
|
||||||
|
'weight' => 0.001
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||||
|
|
||||||
|
$result = $this->provider->getDetails('C123456');
|
||||||
|
|
||||||
|
$this->assertInstanceOf(PartDetailDTO::class, $result);
|
||||||
|
$this->assertEquals('C123456', $result->provider_id);
|
||||||
|
$this->assertEquals('Detailed Component', $result->name);
|
||||||
|
$this->assertEquals('Detailed description', $result->description);
|
||||||
|
$this->assertEquals('Detailed Manufacturer', $result->manufacturer);
|
||||||
|
$this->assertEquals('0603', $result->footprint);
|
||||||
|
$this->assertEquals('https://www.lcsc.com/product-detail/C123456.html', $result->provider_url);
|
||||||
|
$this->assertCount(1, $result->images);
|
||||||
|
$this->assertCount(2, $result->parameters);
|
||||||
|
$this->assertCount(1, $result->vendor_infos);
|
||||||
|
$this->assertEquals('0.001', $result->mass);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetDetailsWithNoResults(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productSearchResultVO' => [
|
||||||
|
'productList' => []
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('No part found with ID INVALID');
|
||||||
|
|
||||||
|
$this->provider->getDetails('INVALID');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetDetailsWithMultipleResults(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productSearchResultVO' => [
|
||||||
|
'productList' => [
|
||||||
|
[
|
||||||
|
'productCode' => 'C123456',
|
||||||
|
'productModel' => 'Component 1',
|
||||||
|
'productIntroEn' => 'Description 1',
|
||||||
|
'brandNameEn' => 'Manufacturer 1',
|
||||||
|
'encapStandard' => '0603',
|
||||||
|
'productImageUrl' => null,
|
||||||
|
'productImages' => [],
|
||||||
|
'productPriceList' => [],
|
||||||
|
'paramVOList' => [],
|
||||||
|
'pdfUrl' => null,
|
||||||
|
'weight' => null
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'productCode' => 'C789012',
|
||||||
|
'productModel' => 'Component 2',
|
||||||
|
'productIntroEn' => 'Description 2',
|
||||||
|
'brandNameEn' => 'Manufacturer 2',
|
||||||
|
'encapStandard' => '0805',
|
||||||
|
'productImageUrl' => null,
|
||||||
|
'productImages' => [],
|
||||||
|
'productPriceList' => [],
|
||||||
|
'paramVOList' => [],
|
||||||
|
'pdfUrl' => null,
|
||||||
|
'weight' => null
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('Multiple parts found with ID ambiguous');
|
||||||
|
|
||||||
|
$this->provider->getDetails('ambiguous');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSanitizeFieldPrivateMethod(): void
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('sanitizeField');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$this->assertNull($method->invokeArgs($this->provider, [null]));
|
||||||
|
$this->assertEquals('Clean text', $method->invokeArgs($this->provider, ['Clean text']));
|
||||||
|
$this->assertEquals('Text without tags', $method->invokeArgs($this->provider, ['<b>Text</b> without <i>tags</i>']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetUsedCurrencyPrivateMethod(): void
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('getUsedCurrency');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$this->assertEquals('USD', $method->invokeArgs($this->provider, ['US$']));
|
||||||
|
$this->assertEquals('USD', $method->invokeArgs($this->provider, ['$']));
|
||||||
|
$this->assertEquals('EUR', $method->invokeArgs($this->provider, ['€']));
|
||||||
|
$this->assertEquals('GBP', $method->invokeArgs($this->provider, ['£']));
|
||||||
|
$this->assertEquals('USD', $method->invokeArgs($this->provider, ['UNKNOWN'])); // fallback to configured currency
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetProductShortURLPrivateMethod(): void
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('getProductShortURL');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invokeArgs($this->provider, ['C123456']);
|
||||||
|
$this->assertEquals('https://www.lcsc.com/product-detail/C123456.html', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetProductDatasheetsPrivateMethod(): void
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('getProductDatasheets');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invokeArgs($this->provider, [null]);
|
||||||
|
$this->assertIsArray($result);
|
||||||
|
$this->assertEmpty($result);
|
||||||
|
|
||||||
|
$result = $method->invokeArgs($this->provider, ['https://example.com/datasheet.pdf']);
|
||||||
|
$this->assertIsArray($result);
|
||||||
|
$this->assertCount(1, $result);
|
||||||
|
$this->assertInstanceOf(FileDTO::class, $result[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetProductImagesPrivateMethod(): void
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('getProductImages');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invokeArgs($this->provider, [null]);
|
||||||
|
$this->assertIsArray($result);
|
||||||
|
$this->assertEmpty($result);
|
||||||
|
|
||||||
|
$result = $method->invokeArgs($this->provider, [['https://example.com/image1.jpg', 'https://example.com/image2.jpg']]);
|
||||||
|
$this->assertIsArray($result);
|
||||||
|
$this->assertCount(2, $result);
|
||||||
|
$this->assertInstanceOf(FileDTO::class, $result[0]);
|
||||||
|
$this->assertInstanceOf(FileDTO::class, $result[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAttributesToParametersPrivateMethod(): void
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('attributesToParameters');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΩ'],
|
||||||
|
['paramNameEn' => 'Tolerance', 'paramValueEn' => '1%'],
|
||||||
|
['paramNameEn' => 'Empty', 'paramValueEn' => ''],
|
||||||
|
['paramNameEn' => 'Dash', 'paramValueEn' => '-']
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $method->invokeArgs($this->provider, [$attributes]);
|
||||||
|
$this->assertIsArray($result);
|
||||||
|
$this->assertCount(2, $result); // Only non-empty values
|
||||||
|
$this->assertInstanceOf(ParameterDTO::class, $result[0]);
|
||||||
|
$this->assertInstanceOf(ParameterDTO::class, $result[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPricesToVendorInfoPrivateMethod(): void
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass($this->provider);
|
||||||
|
$method = $reflection->getMethod('pricesToVendorInfo');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$prices = [
|
||||||
|
['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$'],
|
||||||
|
['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => 'US$']
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = $method->invokeArgs($this->provider, ['C123456', 'https://example.com', $prices]);
|
||||||
|
$this->assertIsArray($result);
|
||||||
|
$this->assertCount(1, $result);
|
||||||
|
$this->assertInstanceOf(PurchaseInfoDTO::class, $result[0]);
|
||||||
|
$this->assertEquals('LCSC', $result[0]->distributor_name);
|
||||||
|
$this->assertEquals('C123456', $result[0]->order_number);
|
||||||
|
$this->assertCount(2, $result[0]->prices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCategoryBuilding(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productCode' => 'C123456',
|
||||||
|
'productModel' => 'Test Component',
|
||||||
|
'productIntroEn' => 'Test description',
|
||||||
|
'brandNameEn' => 'Test Manufacturer',
|
||||||
|
'parentCatalogName' => 'Electronic Components',
|
||||||
|
'catalogName' => 'Resistors/SMT',
|
||||||
|
'encapStandard' => '0603',
|
||||||
|
'productImageUrl' => null,
|
||||||
|
'productImages' => [],
|
||||||
|
'productPriceList' => [],
|
||||||
|
'paramVOList' => [],
|
||||||
|
'pdfUrl' => null,
|
||||||
|
'weight' => null
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||||
|
|
||||||
|
$result = $this->provider->getDetails('C123456');
|
||||||
|
$this->assertEquals('Electronic Components -> Resistors -> SMT', $result->category);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyFootprintHandling(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'result' => [
|
||||||
|
'productCode' => 'C123456',
|
||||||
|
'productModel' => 'Test Component',
|
||||||
|
'productIntroEn' => 'Test description',
|
||||||
|
'brandNameEn' => 'Test Manufacturer',
|
||||||
|
'encapStandard' => '-',
|
||||||
|
'productImageUrl' => null,
|
||||||
|
'productImages' => [],
|
||||||
|
'productPriceList' => [],
|
||||||
|
'paramVOList' => [],
|
||||||
|
'pdfUrl' => null,
|
||||||
|
'weight' => null
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||||
|
|
||||||
|
$result = $this->provider->getDetails('C123456');
|
||||||
|
$this->assertNull($result->footprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSearchByKeywordsBatchWithEmptyKeywords(): void
|
||||||
|
{
|
||||||
|
$result = $this->provider->searchByKeywordsBatch([]);
|
||||||
|
$this->assertIsArray($result);
|
||||||
|
$this->assertEmpty($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSearchByKeywordsBatchWithException(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse('', ['http_code' => 500]);
|
||||||
|
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||||
|
|
||||||
|
$results = $this->provider->searchByKeywordsBatch(['error']);
|
||||||
|
$this->assertIsArray($results);
|
||||||
|
$this->assertArrayHasKey('error', $results);
|
||||||
|
$this->assertEmpty($results['error']);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
tests/Services/Parts/PartsTableActionHandlerTest.php
Normal file
62
tests/Services/Parts/PartsTableActionHandlerTest.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?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\Tests\Services\Parts;
|
||||||
|
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Services\Parts\PartsTableActionHandler;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
|
||||||
|
class PartsTableActionHandlerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private PartsTableActionHandler $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->service = self::getContainer()->get(PartsTableActionHandler::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExportActionsRedirectToExportController(): void
|
||||||
|
{
|
||||||
|
// Mock a Part entity with required properties
|
||||||
|
$part = $this->createMock(Part::class);
|
||||||
|
$part->method('getId')->willReturn(1);
|
||||||
|
$part->method('getName')->willReturn('Test Part');
|
||||||
|
|
||||||
|
$selected_parts = [$part];
|
||||||
|
|
||||||
|
// Test each export format, focusing on our new xlsx format
|
||||||
|
$formats = ['json', 'csv', 'xml', 'yaml', 'xlsx'];
|
||||||
|
|
||||||
|
foreach ($formats as $format) {
|
||||||
|
$action = "export_{$format}";
|
||||||
|
$result = $this->service->handleAction($action, $selected_parts, 1, '/test');
|
||||||
|
|
||||||
|
$this->assertInstanceOf(RedirectResponse::class, $result);
|
||||||
|
$this->assertStringContainsString('parts/export', $result->getTargetUrl());
|
||||||
|
$this->assertStringContainsString("format={$format}", $result->getTargetUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue