Merge branch 'feature/upload-schematic-boms'

This commit is contained in:
Jan Böhmer 2025-09-06 23:34:55 +02:00
commit 890621b651
19 changed files with 3113 additions and 57 deletions

View file

@ -42,6 +42,7 @@ export default class extends Controller {
selectOnTab: true,
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
dropdownParent: 'body',
render: {
item: (data, escape) => {
return '<span>' + escape(data.label) + '</span>';

View file

@ -16,6 +16,7 @@ export default class extends Controller {
searchField: ["name", "description", "category", "footprint"],
valueField: "id",
labelField: "name",
dropdownParent: 'body',
preload: "focus",
render: {
item: (data, escape) => {
@ -71,4 +72,4 @@ export default class extends Controller {
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}
}

View file

@ -44,6 +44,7 @@ export default class extends Controller {
allowEmptyOption: true,
selectOnTab: true,
maxOptions: null,
dropdownParent: 'body',
render: {
item: this.renderItem.bind(this),
@ -108,4 +109,4 @@ export default class extends Controller {
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}
}

View file

@ -29,6 +29,7 @@ export default class extends Controller {
this._tomSelect = new TomSelect(this.element, {
maxItems: 1000,
allowEmptyOption: true,
dropdownParent: 'body',
plugins: ['remove_button'],
});
}
@ -39,4 +40,4 @@ export default class extends Controller {
this._tomSelect.destroy();
}
}
}

View file

@ -50,6 +50,7 @@ export default class extends Controller {
valueField: 'text',
searchField: 'text',
orderField: 'text',
dropdownParent: 'body',
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',

View file

@ -54,6 +54,7 @@ export default class extends Controller {
maxItems: 1,
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
splitOn: null,
dropdownParent: 'body',
searchField: [
{field: "text", weight : 2},

View file

@ -43,6 +43,7 @@ export default class extends Controller {
selectOnTab: true,
createOnBlur: true,
create: true,
dropdownParent: 'body',
};
if(this.element.dataset.autocomplete) {
@ -73,4 +74,4 @@ export default class extends Controller {
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}
}

View file

@ -69,9 +69,3 @@ nelmio_security:
- 'data:'
block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport
# upgrade-insecure-requests: true # defaults to false, upgrades HTTP requests to HTTPS transport
when@dev:
# disables the Content-Security-Policy header
nelmio_security:
csp:
enabled: false

View file

@ -34,3 +34,12 @@ select the BOM file you want to import and some options for the import process:
has a different format and does not work with this type.
You can generate this BOM file by going to "File" -> "Fabrication Outputs" -> "Bill of Materials" in Pcbnew and save
the file to your desired location.
* **KiCAD Schematic BOM (CSV file)**: A CSV file of the Bill of Material (BOM) generated
by [KiCAD Eeschema](https://www.kicad.org/).
You can generate this BOM file by going to "Tools" -> "Generate Bill of Materials" in Eeschema and save the file to your
desired location. In the next step you can customize the mapping of the fields in Part-DB, if you have any special fields
in your BOM to locate your fields correctly.
* **Generic CSV file**: A generic CSV file. You can use this option if you use some different ECAD software or wanna create
your own CSV file. You will need to specify at least the designators, quantity and value fields in the CSV. In the next
step you can customize the mapping of the fields in Part-DB, if you have any special fields in your BOM to locate your
parts correctly.

112
makefile Normal file
View file

@ -0,0 +1,112 @@
# 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
# Default target
help:
@echo "PartDB Test Environment Management"
@echo "=================================="
@echo ""
@echo "Available targets:"
@echo " deps-install - Install PHP dependencies with unlimited memory"
@echo ""
@echo "Development Environment:"
@echo " dev-setup - Complete development environment setup (clean, create DB, migrate, warmup)"
@echo " dev-clean - Clean development cache and database files"
@echo " dev-db-create - Create development database (if not exists)"
@echo " dev-db-migrate - Run database migrations for development environment"
@echo " dev-cache-clear - Clear development cache"
@echo " dev-warmup - Warm up development cache"
@echo " dev-reset - Quick development reset (clean + migrate)"
@echo ""
@echo "Test Environment:"
@echo " test-setup - Complete test environment setup (clean, create DB, migrate, load fixtures)"
@echo " test-clean - Clean test cache and database files"
@echo " test-db-create - Create test database (if not exists)"
@echo " test-db-migrate - Run database migrations for test environment"
@echo " test-cache-clear- Clear test cache"
@echo " test-fixtures - Load test fixtures"
@echo " test-run - Run PHPUnit tests"
@echo ""
@echo " help - Show this help message"
# Install PHP dependencies with unlimited memory
deps-install:
@echo "📦 Installing PHP dependencies..."
COMPOSER_MEMORY_LIMIT=-1 composer install
@echo "✅ Dependencies installed"
# Complete test environment setup
test-setup: deps-install test-clean test-db-create test-db-migrate test-fixtures
@echo "✅ Test environment setup complete!"
# Clean test environment
test-clean:
@echo "🧹 Cleaning test environment..."
rm -rf var/cache/test
rm -f var/app_test.db
@echo "✅ Test environment cleaned"
# Create test database
test-db-create:
@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:
@echo "🔄 Running database migrations..."
php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env test
# Clear test cache
test-cache-clear:
@echo "🗑️ Clearing test cache..."
rm -rf var/cache/test
@echo "✅ Test cache cleared"
# Load test fixtures
test-fixtures:
@echo "📦 Loading test fixtures..."
php bin/console partdb:fixtures:load -n --env test
# Run PHPUnit tests
test-run:
@echo "🧪 Running tests..."
php bin/phpunit
test-typecheck:
@echo "🧪 Running type checks..."
COMPOSER_MEMORY_LIMIT=-1 composer phpstan
# Quick test reset (clean + migrate + fixtures, skip DB creation)
test-reset: test-cache-clear test-db-migrate test-fixtures
@echo "✅ Test environment reset complete!"
# Development helpers
dev-setup: deps-install dev-clean dev-db-create dev-db-migrate dev-warmup
@echo "✅ Development environment setup complete!"
dev-clean:
@echo "🧹 Cleaning development environment..."
rm -rf var/cache/dev
rm -f var/app_dev.db
@echo "✅ Development environment cleaned"
dev-db-create:
@echo "🗄️ Creating development database..."
-php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
dev-db-migrate:
@echo "🔄 Running database migrations..."
php -d memory_limit=1G bin/console doctrine:migrations:migrate -n --env dev
dev-cache-clear:
@echo "🗑️ Clearing development cache..."
php -d memory_limit=1G bin/console cache:clear --env dev -n
@echo "✅ Development cache cleared"
dev-warmup:
@echo "🔥 Warming up development cache..."
php -d memory_limit=1G bin/console cache:warmup --env dev -n
dev-reset: dev-cache-clear dev-db-migrate
@echo "✅ Development environment reset complete!"

View file

@ -36,6 +36,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use League\Csv\SyntaxError;
use Omines\DataTablesBundle\DataTableFactory;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@ -102,9 +103,14 @@ class ProjectController extends AbstractController
$this->addFlash('success', 'project.build.flash.success');
return $this->redirect(
$request->get('_redirect',
$this->generateUrl('project_info', ['id' => $project->getID()]
)));
$request->get(
'_redirect',
$this->generateUrl(
'project_info',
['id' => $project->getID()]
)
)
);
}
$this->addFlash('error', 'project.build.flash.invalid_input');
@ -120,9 +126,13 @@ class ProjectController extends AbstractController
}
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project,
BOMImporter $BOMImporter, ValidatorInterface $validator): Response
{
public function importBOM(
Request $request,
EntityManagerInterface $entityManager,
Project $project,
BOMImporter $BOMImporter,
ValidatorInterface $validator
): Response {
$this->denyAccessUnlessGranted('edit', $project);
$builder = $this->createFormBuilder();
@ -138,6 +148,8 @@ class ProjectController extends AbstractController
'required' => true,
'choices' => [
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
'project.bom_import.type.generic_csv' => 'generic_csv',
]
]);
$builder->add('clear_existing_bom', CheckboxType::class, [
@ -161,25 +173,40 @@ class ProjectController extends AbstractController
$entityManager->flush();
}
$import_type = $form->get('type')->getData();
try {
// For schematic imports, redirect to field mapping step
if (in_array($import_type, ['kicad_schematic', 'generic_csv'], true)) {
// Store file content and options in session for field mapping step
$file_content = $form->get('file')->getData()->getContent();
$clear_existing = $form->get('clear_existing_bom')->getData();
$request->getSession()->set('bom_import_data', $file_content);
$request->getSession()->set('bom_import_clear', $clear_existing);
return $this->redirectToRoute('project_import_bom_map_fields', ['id' => $project->getID()]);
}
// For PCB imports, proceed directly
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
'type' => $form->get('type')->getData(),
'type' => $import_type,
]);
//Validate the project entries
// Validate the project entries
$errors = $validator->validateProperty($project, 'bom_entries');
//If no validation errors occured, save the changes and redirect to edit page
if (count ($errors) === 0) {
// If no validation errors occurred, save the changes and redirect to edit page
if (count($errors) === 0) {
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
}
//When we get here, there were validation errors
// When we get here, there were validation errors
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
} catch (\UnexpectedValueException|SyntaxError $e) {
} catch (\UnexpectedValueException | SyntaxError $e) {
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
}
}
@ -191,11 +218,267 @@ class ProjectController extends AbstractController
]);
}
#[Route(path: '/{id}/import_bom/map_fields', name: 'project_import_bom_map_fields', requirements: ['id' => '\d+'])]
public function importBOMMapFields(
Request $request,
EntityManagerInterface $entityManager,
Project $project,
BOMImporter $BOMImporter,
ValidatorInterface $validator,
LoggerInterface $logger
): Response {
$this->denyAccessUnlessGranted('edit', $project);
// Get stored data from session
$file_content = $request->getSession()->get('bom_import_data');
$clear_existing = $request->getSession()->get('bom_import_clear', false);
if (!$file_content) {
$this->addFlash('error', 'project.bom_import.flash.session_expired');
return $this->redirectToRoute('project_import_bom', ['id' => $project->getID()]);
}
// Detect fields and get suggestions
$detected_fields = $BOMImporter->detectFields($file_content);
$suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
// Create mapping of original field names to sanitized field names for template
$field_name_mapping = [];
foreach ($detected_fields as $field) {
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$field_name_mapping[$field] = $sanitized_field;
}
// Create form for field mapping
$builder = $this->createFormBuilder();
// Add delimiter selection
$builder->add('delimiter', ChoiceType::class, [
'label' => 'project.bom_import.delimiter',
'required' => true,
'data' => ',',
'choices' => [
'project.bom_import.delimiter.comma' => ',',
'project.bom_import.delimiter.semicolon' => ';',
'project.bom_import.delimiter.tab' => "\t",
]
]);
// Get dynamic field mapping targets from BOMImporter
$available_targets = $BOMImporter->getAvailableFieldTargets();
$target_fields = ['project.bom_import.field_mapping.ignore' => ''];
foreach ($available_targets as $target_key => $target_info) {
$target_fields[$target_info['label']] = $target_key;
}
foreach ($detected_fields as $field) {
// Sanitize field name for form use - replace invalid characters with underscores
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$builder->add('mapping_' . $sanitized_field, ChoiceType::class, [
'label' => $field,
'required' => false,
'choices' => $target_fields,
'data' => $suggested_mapping[$field] ?? '',
]);
}
$builder->add('submit', SubmitType::class, [
'label' => 'project.bom_import.preview',
]);
$form = $builder->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Build field mapping array with priority support
$field_mapping = [];
$field_priorities = [];
$delimiter = $form->get('delimiter')->getData();
foreach ($detected_fields as $field) {
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
$target = $form->get('mapping_' . $sanitized_field)->getData();
if (!empty($target)) {
$field_mapping[$field] = $target;
// Get priority from request (default to 10)
$priority = $request->request->get('priority_' . $sanitized_field, 10);
$field_priorities[$field] = (int) $priority;
}
}
// Validate field mapping
$validation = $BOMImporter->validateFieldMapping($field_mapping, $detected_fields);
if (!$validation['is_valid']) {
foreach ($validation['errors'] as $error) {
$this->addFlash('error', $error);
}
foreach ($validation['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form->createView(),
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
]);
}
// Show warnings but continue
foreach ($validation['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
try {
// Re-detect fields with chosen delimiter
$detected_fields = $BOMImporter->detectFields($file_content, $delimiter);
// Clear existing BOM entries if requested
if ($clear_existing) {
$existing_count = $project->getBomEntries()->count();
$logger->info('Clearing existing BOM entries', [
'existing_count' => $existing_count,
'project_id' => $project->getID(),
]);
$project->getBomEntries()->clear();
$entityManager->flush();
$logger->info('Existing BOM entries cleared');
} else {
$existing_count = $project->getBomEntries()->count();
$logger->info('Keeping existing BOM entries', [
'existing_count' => $existing_count,
'project_id' => $project->getID(),
]);
}
// Validate data before importing
$validation_result = $BOMImporter->validateBOMData($file_content, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,
'delimiter' => $delimiter,
]);
// Log validation results
$logger->info('BOM import validation completed', [
'total_entries' => $validation_result['total_entries'],
'valid_entries' => $validation_result['valid_entries'],
'invalid_entries' => $validation_result['invalid_entries'],
'error_count' => count($validation_result['errors']),
'warning_count' => count($validation_result['warnings']),
]);
// Show validation warnings to user
foreach ($validation_result['warnings'] as $warning) {
$this->addFlash('warning', $warning);
}
// If there are validation errors, show them and stop
if (!empty($validation_result['errors'])) {
foreach ($validation_result['errors'] as $error) {
$this->addFlash('error', $error);
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form->createView(),
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
'validation_result' => $validation_result,
]);
}
// Import with field mapping and priorities (validation already passed)
$entries = $BOMImporter->stringToBOMEntries($file_content, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,
'delimiter' => $delimiter,
]);
// Log entry details for debugging
$logger->info('BOM entries created', [
'total_entries' => count($entries),
]);
foreach ($entries as $index => $entry) {
$logger->debug("BOM entry {$index}", [
'name' => $entry->getName(),
'mountnames' => $entry->getMountnames(),
'quantity' => $entry->getQuantity(),
'comment' => $entry->getComment(),
'part_id' => $entry->getPart()?->getID(),
]);
}
// Assign entries to project
$logger->info('Adding BOM entries to project', [
'entries_count' => count($entries),
'project_id' => $project->getID(),
]);
foreach ($entries as $index => $entry) {
$logger->debug("Adding BOM entry {$index} to project", [
'name' => $entry->getName(),
'part_id' => $entry->getPart()?->getID(),
'quantity' => $entry->getQuantity(),
]);
$project->addBomEntry($entry);
}
// Validate the project entries (includes collection constraints)
$errors = $validator->validateProperty($project, 'bom_entries');
// If no validation errors occurred, save and redirect
if (count($errors) === 0) {
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
// Clear session data
$request->getSession()->remove('bom_import_data');
$request->getSession()->remove('bom_import_clear');
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
}
// When we get here, there were validation errors
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
//Print validation errors to log for debugging
foreach ($errors as $error) {
$logger->error('BOM entry validation error', [
'message' => $error->getMessage(),
'invalid_value' => $error->getInvalidValue(),
]);
//And show as flash message
$this->addFlash('error', $error->getMessage(),);
}
} catch (\UnexpectedValueException | SyntaxError $e) {
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
}
}
return $this->render('projects/import_bom_map_fields.html.twig', [
'project' => $project,
'form' => $form,
'detected_fields' => $detected_fields,
'suggested_mapping' => $suggested_mapping,
'field_name_mapping' => $field_name_mapping,
]);
}
#[Route(path: '/add_parts', name: 'project_add_parts_no_id')]
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
{
if($project instanceof Project) {
if ($project instanceof Project) {
$this->denyAccessUnlessGranted('edit', $project);
} else {
$this->denyAccessUnlessGranted('@projects.edit');
@ -242,7 +525,7 @@ class ProjectController extends AbstractController
$data = $form->getData();
$bom_entries = $data['bom_entries'];
foreach ($bom_entries as $bom_entry){
foreach ($bom_entries as $bom_entry) {
$target_project->addBOMEntry($bom_entry);
}

View file

@ -22,10 +22,13 @@ declare(strict_types=1);
*/
namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use League\Csv\Reader;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\OptionsResolver\OptionsResolver;
@ -44,14 +47,25 @@ class BOMImporter
5 => 'Supplier and ref',
];
public function __construct()
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly BOMValidationService $validationService
) {
}
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
{
$resolver->setRequired('type');
$resolver->setAllowedValues('type', ['kicad_pcbnew']);
$resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']);
// For flexible schematic import with field mapping
$resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']);
$resolver->setDefault('delimiter', ',');
$resolver->setDefault('field_priorities', []);
$resolver->setAllowedTypes('field_mapping', 'array');
$resolver->setAllowedTypes('field_priorities', 'array');
$resolver->setAllowedTypes('delimiter', 'string');
return $resolver;
}
@ -82,6 +96,23 @@ class BOMImporter
return $this->stringToBOMEntries($file->getContent(), $options);
}
/**
* Validate BOM data before importing
* @return array Validation result with errors, warnings, and info
*/
public function validateBOMData(string $data, array $options): array
{
$resolver = new OptionsResolver();
$resolver = $this->configureOptions($resolver);
$options = $resolver->resolve($options);
return match ($options['type']) {
'kicad_pcbnew' => $this->validateKiCADPCB($data),
'kicad_schematic' => $this->validateKiCADSchematicData($data, $options),
default => throw new InvalidArgumentException('Invalid import type!'),
};
}
/**
* Import string data into an array of BOM entries, which are not yet assigned to a project.
* @param string $data The data to import
@ -95,12 +126,13 @@ class BOMImporter
$options = $resolver->resolve($options);
return match ($options['type']) {
'kicad_pcbnew' => $this->parseKiCADPCB($data, $options),
'kicad_pcbnew' => $this->parseKiCADPCB($data),
'kicad_schematic' => $this->parseKiCADSchematic($data, $options),
default => throw new InvalidArgumentException('Invalid import type!'),
};
}
private function parseKiCADPCB(string $data, array $options = []): array
private function parseKiCADPCB(string $data): array
{
$csv = Reader::createFromString($data);
$csv->setDelimiter(';');
@ -113,17 +145,17 @@ class BOMImporter
$entry = $this->normalizeColumnNames($entry);
//Ensure that the entry has all required fields
if (!isset ($entry['Designator'])) {
throw new \UnexpectedValueException('Designator missing at line '.($offset + 1).'!');
if (!isset($entry['Designator'])) {
throw new \UnexpectedValueException('Designator missing at line ' . ($offset + 1) . '!');
}
if (!isset ($entry['Package'])) {
throw new \UnexpectedValueException('Package missing at line '.($offset + 1).'!');
if (!isset($entry['Package'])) {
throw new \UnexpectedValueException('Package missing at line ' . ($offset + 1) . '!');
}
if (!isset ($entry['Designation'])) {
throw new \UnexpectedValueException('Designation missing at line '.($offset + 1).'!');
if (!isset($entry['Designation'])) {
throw new \UnexpectedValueException('Designation missing at line ' . ($offset + 1) . '!');
}
if (!isset ($entry['Quantity'])) {
throw new \UnexpectedValueException('Quantity missing at line '.($offset + 1).'!');
if (!isset($entry['Quantity'])) {
throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!');
}
$bom_entry = new ProjectBOMEntry();
@ -138,6 +170,63 @@ class BOMImporter
return $bom_entries;
}
/**
* Validate KiCad PCB data
*/
private function validateKiCADPCB(string $data): array
{
$csv = Reader::createFromString($data);
$csv->setDelimiter(';');
$csv->setHeaderOffset(0);
$mapped_entries = [];
foreach ($csv->getRecords() as $offset => $entry) {
// Translate the german field names to english
$entry = $this->normalizeColumnNames($entry);
$mapped_entries[] = $entry;
}
return $this->validationService->validateBOMEntries($mapped_entries);
}
/**
* Validate KiCad schematic data
*/
private function validateKiCADSchematicData(string $data, array $options): array
{
$delimiter = $options['delimiter'] ?? ',';
$field_mapping = $options['field_mapping'] ?? [];
$field_priorities = $options['field_priorities'] ?? [];
// Handle potential BOM (Byte Order Mark) at the beginning
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
$csv = Reader::createFromString($data);
$csv->setDelimiter($delimiter);
$csv->setHeaderOffset(0);
// Handle quoted fields properly
$csv->setEscape('\\');
$csv->setEnclosure('"');
$mapped_entries = [];
foreach ($csv->getRecords() as $offset => $entry) {
// Apply field mapping to translate column names
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
// Extract footprint package name if it contains library prefix
if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
$mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
}
$mapped_entries[] = $mapped_entry;
}
return $this->validationService->validateBOMEntries($mapped_entries, $options);
}
/**
* This function uses the order of the fields in the CSV files to make them locale independent.
* @param array $entry
@ -160,4 +249,482 @@ class BOMImporter
return $out;
}
/**
* Parse KiCad schematic BOM with flexible field mapping
*/
private function parseKiCADSchematic(string $data, array $options = []): array
{
$delimiter = $options['delimiter'] ?? ',';
$field_mapping = $options['field_mapping'] ?? [];
$field_priorities = $options['field_priorities'] ?? [];
// Handle potential BOM (Byte Order Mark) at the beginning
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
$csv = Reader::createFromString($data);
$csv->setDelimiter($delimiter);
$csv->setHeaderOffset(0);
// Handle quoted fields properly
$csv->setEscape('\\');
$csv->setEnclosure('"');
$bom_entries = [];
$entries_by_key = []; // Track entries by name+part combination
$mapped_entries = []; // Collect all mapped entries for validation
foreach ($csv->getRecords() as $offset => $entry) {
// Apply field mapping to translate column names
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
// Extract footprint package name if it contains library prefix
if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
$mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
}
$mapped_entries[] = $mapped_entry;
}
// Validate all entries before processing
$validation_result = $this->validationService->validateBOMEntries($mapped_entries, $options);
// Log validation results
$this->logger->info('BOM import validation completed', [
'total_entries' => $validation_result['total_entries'],
'valid_entries' => $validation_result['valid_entries'],
'invalid_entries' => $validation_result['invalid_entries'],
'error_count' => count($validation_result['errors']),
'warning_count' => count($validation_result['warnings']),
]);
// If there are validation errors, throw an exception with detailed messages
if (!empty($validation_result['errors'])) {
$error_message = $this->validationService->getErrorMessage($validation_result);
throw new \UnexpectedValueException("BOM import validation failed:\n" . $error_message);
}
// Process validated entries
foreach ($mapped_entries as $offset => $mapped_entry) {
// Set name - prefer MPN, fall back to Value, then default format
$mpn = trim($mapped_entry['MPN'] ?? '');
$designation = trim($mapped_entry['Designation'] ?? '');
$value = trim($mapped_entry['Value'] ?? '');
// Use the first non-empty value, or 'Unknown Component' if all are empty
$name = '';
if (!empty($mpn)) {
$name = $mpn;
} elseif (!empty($designation)) {
$name = $designation;
} elseif (!empty($value)) {
$name = $value;
} else {
$name = 'Unknown Component';
}
if (isset($mapped_entry['Package']) && !empty(trim($mapped_entry['Package']))) {
$name .= ' (' . trim($mapped_entry['Package']) . ')';
}
// Set mountnames and quantity
// The Designator field contains comma-separated mount names for all instances
$designator = trim($mapped_entry['Designator']);
$quantity = (float) $mapped_entry['Quantity'];
// Get mountnames array (validation already ensured they match quantity)
$mountnames_array = array_map('trim', explode(',', $designator));
// Try to link existing Part-DB part if ID is provided
$part = null;
if (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
$partDbId = (int) $mapped_entry['Part-DB ID'];
$existingPart = $this->entityManager->getRepository(Part::class)->find($partDbId);
if ($existingPart) {
$part = $existingPart;
// Update name with actual part name
$name = $existingPart->getName();
}
}
// Create unique key for this entry (name + part ID)
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
// Check if we already have an entry with the same name and part
if (isset($entries_by_key[$entry_key])) {
// Merge with existing entry
$existing_entry = $entries_by_key[$entry_key];
// Combine mountnames
$existing_mountnames = $existing_entry->getMountnames();
$combined_mountnames = $existing_mountnames . ',' . $designator;
$existing_entry->setMountnames($combined_mountnames);
// Add quantities
$existing_quantity = $existing_entry->getQuantity();
$existing_entry->setQuantity($existing_quantity + $quantity);
$this->logger->info('Merged duplicate BOM entry', [
'name' => $name,
'part_id' => $part ? $part->getID() : null,
'original_quantity' => $existing_quantity,
'added_quantity' => $quantity,
'new_quantity' => $existing_quantity + $quantity,
'original_mountnames' => $existing_mountnames,
'added_mountnames' => $designator,
]);
continue; // Skip creating new entry
}
// Create new BOM entry
$bom_entry = new ProjectBOMEntry();
$bom_entry->setName($name);
$bom_entry->setMountnames($designator);
$bom_entry->setQuantity($quantity);
if ($part) {
$bom_entry->setPart($part);
}
// Set comment with additional info
$comment_parts = [];
if (isset($mapped_entry['Value']) && $mapped_entry['Value'] !== ($mapped_entry['MPN'] ?? '')) {
$comment_parts[] = 'Value: ' . $mapped_entry['Value'];
}
if (isset($mapped_entry['MPN'])) {
$comment_parts[] = 'MPN: ' . $mapped_entry['MPN'];
}
if (isset($mapped_entry['Manufacturer'])) {
$comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer'];
}
if (isset($mapped_entry['LCSC'])) {
$comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC'];
}
if (isset($mapped_entry['Supplier and ref'])) {
$comment_parts[] = $mapped_entry['Supplier and ref'];
}
if ($part) {
$comment_parts[] = "Part-DB ID: " . $part->getID();
} elseif (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
$comment_parts[] = "Part-DB ID: " . $mapped_entry['Part-DB ID'] . " (NOT FOUND)";
}
$bom_entry->setComment(implode(', ', $comment_parts));
$bom_entries[] = $bom_entry;
$entries_by_key[$entry_key] = $bom_entry;
}
return $bom_entries;
}
/**
* Get all available field mapping targets with descriptions
*/
public function getAvailableFieldTargets(): array
{
$targets = [
'Designator' => [
'label' => 'Designator',
'description' => 'Component reference designators (e.g., R1, C2, U3)',
'required' => true,
'multiple' => false,
],
'Quantity' => [
'label' => 'Quantity',
'description' => 'Number of components',
'required' => true,
'multiple' => false,
],
'Designation' => [
'label' => 'Designation',
'description' => 'Component designation/part number',
'required' => false,
'multiple' => true,
],
'Value' => [
'label' => 'Value',
'description' => 'Component value (e.g., 10k, 100nF)',
'required' => false,
'multiple' => true,
],
'Package' => [
'label' => 'Package',
'description' => 'Component package/footprint',
'required' => false,
'multiple' => true,
],
'MPN' => [
'label' => 'MPN',
'description' => 'Manufacturer Part Number',
'required' => false,
'multiple' => true,
],
'Manufacturer' => [
'label' => 'Manufacturer',
'description' => 'Component manufacturer name',
'required' => false,
'multiple' => true,
],
'Part-DB ID' => [
'label' => 'Part-DB ID',
'description' => 'Existing Part-DB part ID for linking',
'required' => false,
'multiple' => false,
],
'Comment' => [
'label' => 'Comment',
'description' => 'Additional component information',
'required' => false,
'multiple' => true,
],
];
// Add dynamic supplier fields based on available suppliers in the database
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$targets[$supplierName . ' SPN'] = [
'label' => $supplierName . ' SPN',
'description' => "Supplier part number for {$supplierName}",
'required' => false,
'multiple' => true,
'supplier_id' => $supplier->getID(),
];
}
return $targets;
}
/**
* Get suggested field mappings based on common field names
*/
public function getSuggestedFieldMapping(array $detected_fields): array
{
$suggestions = [];
$field_patterns = [
'Part-DB ID' => ['part-db id', 'partdb_id', 'part_db_id', 'db_id', 'partdb'],
'Designator' => ['reference', 'ref', 'designator', 'component', 'comp'],
'Quantity' => ['qty', 'quantity', 'count', 'number', 'amount'],
'Value' => ['value', 'val', 'component_value'],
'Designation' => ['designation', 'part_number', 'partnumber', 'part'],
'Package' => ['footprint', 'package', 'housing', 'fp'],
'MPN' => ['mpn', 'part_number', 'partnumber', 'manf#', 'mfr_part_number', 'manufacturer_part'],
'Manufacturer' => ['manufacturer', 'manf', 'mfr', 'brand', 'vendor'],
'Comment' => ['comment', 'comments', 'note', 'notes', 'description'],
];
// Add supplier-specific patterns
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$supplierLower = strtolower($supplierName);
// Create patterns for each supplier
$field_patterns[$supplierName . ' SPN'] = [
$supplierLower,
$supplierLower . '#',
$supplierLower . '_part',
$supplierLower . '_number',
$supplierLower . 'pn',
$supplierLower . '_spn',
$supplierLower . ' spn',
// Common abbreviations
$supplierLower === 'mouser' ? 'mouser' : null,
$supplierLower === 'digikey' ? 'dk' : null,
$supplierLower === 'farnell' ? 'farnell' : null,
$supplierLower === 'rs' ? 'rs' : null,
$supplierLower === 'lcsc' ? 'lcsc' : null,
];
// Remove null values
$field_patterns[$supplierName . ' SPN'] = array_filter($field_patterns[$supplierName . ' SPN'], fn($value) => $value !== null);
}
foreach ($detected_fields as $field) {
$field_lower = strtolower(trim($field));
foreach ($field_patterns as $target => $patterns) {
foreach ($patterns as $pattern) {
if (str_contains($field_lower, $pattern)) {
$suggestions[$field] = $target;
break 2; // Break both loops
}
}
}
}
return $suggestions;
}
/**
* Validate field mapping configuration
*/
public function validateFieldMapping(array $field_mapping, array $detected_fields): array
{
$errors = [];
$warnings = [];
$available_targets = $this->getAvailableFieldTargets();
// Check for required fields
$mapped_targets = array_values($field_mapping);
$required_fields = ['Designator', 'Quantity'];
foreach ($required_fields as $required) {
if (!in_array($required, $mapped_targets, true)) {
$errors[] = "Required field '{$required}' is not mapped from any CSV column.";
}
}
// Check for invalid target fields
foreach ($field_mapping as $csv_field => $target) {
if (!empty($target) && !isset($available_targets[$target])) {
$errors[] = "Invalid target field '{$target}' for CSV field '{$csv_field}'.";
}
}
// Check for unmapped fields (warnings)
$unmapped_fields = array_diff($detected_fields, array_keys($field_mapping));
if (!empty($unmapped_fields)) {
$warnings[] = "The following CSV fields are not mapped: " . implode(', ', $unmapped_fields);
}
return [
'errors' => $errors,
'warnings' => $warnings,
'is_valid' => empty($errors),
];
}
/**
* Apply field mapping with support for multiple fields and priority
*/
private function applyFieldMapping(array $entry, array $field_mapping, array $field_priorities = []): array
{
$mapped = [];
$field_groups = [];
// Group fields by target with priority information
foreach ($field_mapping as $csv_field => $target) {
if (!empty($target)) {
if (!isset($field_groups[$target])) {
$field_groups[$target] = [];
}
$priority = $field_priorities[$csv_field] ?? 10;
$field_groups[$target][] = [
'field' => $csv_field,
'priority' => $priority,
'value' => $entry[$csv_field] ?? ''
];
}
}
// Process each target field
foreach ($field_groups as $target => $field_data) {
// Sort by priority (lower number = higher priority)
usort($field_data, function ($a, $b) {
return $a['priority'] <=> $b['priority'];
});
$values = [];
$non_empty_values = [];
// Collect all non-empty values for this target
foreach ($field_data as $data) {
$value = trim($data['value']);
if (!empty($value)) {
$non_empty_values[] = $value;
}
$values[] = $value;
}
// Use the first non-empty value (highest priority)
if (!empty($non_empty_values)) {
$mapped[$target] = $non_empty_values[0];
// If multiple non-empty values exist, add alternatives to comment
if (count($non_empty_values) > 1) {
$mapped[$target . '_alternatives'] = array_slice($non_empty_values, 1);
}
}
}
return $mapped;
}
/**
* Detect available fields in CSV data for field mapping UI
*/
public function detectFields(string $data, ?string $delimiter = null): array
{
if ($delimiter === null) {
// Detect delimiter by counting occurrences in the first row (header)
$delimiters = [',', ';', "\t"];
$lines = explode("\n", $data, 2);
$header_line = $lines[0] ?? '';
$delimiter_counts = [];
foreach ($delimiters as $delim) {
$delimiter_counts[$delim] = substr_count($header_line, $delim);
}
// Choose the delimiter with the highest count, default to comma if all are zero
$max_count = max($delimiter_counts);
$delimiter = array_search($max_count, $delimiter_counts, true);
if ($max_count === 0 || $delimiter === false) {
$delimiter = ',';
}
}
// Handle potential BOM (Byte Order Mark) at the beginning
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
// Get first line only for header detection
$lines = explode("\n", $data);
$header_line = trim($lines[0] ?? '');
// Simple manual parsing for header detection
// This handles quoted CSV fields better than the library for detection
$fields = [];
$current_field = '';
$in_quotes = false;
$quote_char = '"';
for ($i = 0; $i < strlen($header_line); $i++) {
$char = $header_line[$i];
if ($char === $quote_char && !$in_quotes) {
$in_quotes = true;
} elseif ($char === $quote_char && $in_quotes) {
// Check for escaped quote (double quote)
if ($i + 1 < strlen($header_line) && $header_line[$i + 1] === $quote_char) {
$current_field .= $quote_char;
$i++; // Skip next quote
} else {
$in_quotes = false;
}
} elseif ($char === $delimiter && !$in_quotes) {
$fields[] = trim($current_field);
$current_field = '';
} else {
$current_field .= $char;
}
}
// Add the last field
if ($current_field !== '') {
$fields[] = trim($current_field);
}
// Clean up headers - remove quotes and trim whitespace
$headers = array_map(function ($header) {
return trim($header, '"\'');
}, $fields);
return array_values($headers);
}
}

View file

@ -0,0 +1,476 @@
<?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\Services\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Service for validating BOM import data with comprehensive validation rules
* and user-friendly error messages.
*/
class BOMValidationService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator
) {
}
/**
* Validation result structure
*/
public static function createValidationResult(): array
{
return [
'errors' => [],
'warnings' => [],
'info' => [],
'is_valid' => true,
'total_entries' => 0,
'valid_entries' => 0,
'invalid_entries' => 0,
];
}
/**
* Validate a single BOM entry with comprehensive checks
*/
public function validateBOMEntry(array $mapped_entry, int $line_number, array $options = []): array
{
$result = [
'line_number' => $line_number,
'errors' => [],
'warnings' => [],
'info' => [],
'is_valid' => true,
];
// Run all validation rules
$this->validateRequiredFields($mapped_entry, $result);
$this->validateDesignatorFormat($mapped_entry, $result);
$this->validateQuantityFormat($mapped_entry, $result);
$this->validateDesignatorQuantityMatch($mapped_entry, $result);
$this->validatePartDBLink($mapped_entry, $result);
$this->validateComponentName($mapped_entry, $result);
$this->validatePackageFormat($mapped_entry, $result);
$this->validateNumericFields($mapped_entry, $result);
$result['is_valid'] = empty($result['errors']);
return $result;
}
/**
* Validate multiple BOM entries and provide summary
*/
public function validateBOMEntries(array $mapped_entries, array $options = []): array
{
$result = self::createValidationResult();
$result['total_entries'] = count($mapped_entries);
$line_results = [];
$all_errors = [];
$all_warnings = [];
$all_info = [];
foreach ($mapped_entries as $index => $entry) {
$line_number = $index + 1;
$line_result = $this->validateBOMEntry($entry, $line_number, $options);
$line_results[] = $line_result;
if ($line_result['is_valid']) {
$result['valid_entries']++;
} else {
$result['invalid_entries']++;
}
// Collect all messages
$all_errors = array_merge($all_errors, $line_result['errors']);
$all_warnings = array_merge($all_warnings, $line_result['warnings']);
$all_info = array_merge($all_info, $line_result['info']);
}
// Add summary messages
$this->addSummaryMessages($result, $all_errors, $all_warnings, $all_info);
$result['errors'] = $all_errors;
$result['warnings'] = $all_warnings;
$result['info'] = $all_info;
$result['line_results'] = $line_results;
$result['is_valid'] = empty($all_errors);
return $result;
}
/**
* Validate required fields are present
*/
private function validateRequiredFields(array $entry, array &$result): void
{
$required_fields = ['Designator', 'Quantity'];
foreach ($required_fields as $field) {
if (!isset($entry[$field]) || trim($entry[$field]) === '') {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.required_field_missing', [
'%line%' => $result['line_number'],
'%field%' => $field
]);
}
}
}
/**
* Validate designator format and content
*/
private function validateDesignatorFormat(array $entry, array &$result): void
{
if (!isset($entry['Designator']) || trim($entry['Designator']) === '') {
return; // Already handled by required fields validation
}
$designator = trim($entry['Designator']);
$mountnames = array_map('trim', explode(',', $designator));
// Remove empty entries
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
if (empty($mountnames)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.no_valid_designators', [
'%line%' => $result['line_number']
]);
return;
}
// Validate each mountname format (allow 1-2 uppercase letters, followed by 1+ digits)
$invalid_mountnames = [];
foreach ($mountnames as $mountname) {
if (!preg_match('/^[A-Z]{1,2}[0-9]+$/', $mountname)) {
$invalid_mountnames[] = $mountname;
}
}
if (!empty($invalid_mountnames)) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.unusual_designator_format', [
'%line%' => $result['line_number'],
'%designators%' => implode(', ', $invalid_mountnames)
]);
}
// Check for duplicate mountnames within the same line
$duplicates = array_diff_assoc($mountnames, array_unique($mountnames));
if (!empty($duplicates)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.duplicate_designators', [
'%line%' => $result['line_number'],
'%designators%' => implode(', ', array_unique($duplicates))
]);
}
}
/**
* Validate quantity format and value
*/
private function validateQuantityFormat(array $entry, array &$result): void
{
if (!isset($entry['Quantity']) || trim($entry['Quantity']) === '') {
return; // Already handled by required fields validation
}
$quantity_str = trim($entry['Quantity']);
// Check if it's a valid number
if (!is_numeric($quantity_str)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_quantity', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str
]);
return;
}
$quantity = (float) $quantity_str;
// Check for reasonable quantity values
if ($quantity <= 0) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_zero_or_negative', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str
]);
} elseif ($quantity > 10000) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_unusually_high', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str
]);
}
// Check if quantity is a whole number when it should be
if (isset($entry['Designator'])) {
$designator = trim($entry['Designator']);
$mountnames = array_map('trim', explode(',', $designator));
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
if (count($mountnames) > 0 && $quantity != (int) $quantity) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_not_whole_number', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str,
'%count%' => count($mountnames)
]);
}
}
}
/**
* Validate that designator count matches quantity
*/
private function validateDesignatorQuantityMatch(array $entry, array &$result): void
{
if (!isset($entry['Designator']) || !isset($entry['Quantity'])) {
return; // Already handled by required fields validation
}
$designator = trim($entry['Designator']);
$quantity_str = trim($entry['Quantity']);
if (!is_numeric($quantity_str)) {
return; // Already handled by quantity validation
}
$mountnames = array_map('trim', explode(',', $designator));
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
$mountnames_count = count($mountnames);
$quantity = (float) $quantity_str;
if ($mountnames_count !== (int) $quantity) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_designator_mismatch', [
'%line%' => $result['line_number'],
'%quantity%' => $quantity_str,
'%count%' => $mountnames_count,
'%designators%' => $designator
]);
}
}
/**
* Validate Part-DB ID link
*/
private function validatePartDBLink(array $entry, array &$result): void
{
if (!isset($entry['Part-DB ID']) || trim($entry['Part-DB ID']) === '') {
return;
}
$part_db_id = trim($entry['Part-DB ID']);
if (!is_numeric($part_db_id)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_partdb_id', [
'%line%' => $result['line_number'],
'%id%' => $part_db_id
]);
return;
}
$part_id = (int) $part_db_id;
if ($part_id <= 0) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.partdb_id_zero_or_negative', [
'%line%' => $result['line_number'],
'%id%' => $part_id
]);
return;
}
// Check if part exists in database
$existing_part = $this->entityManager->getRepository(Part::class)->find($part_id);
if (!$existing_part) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.partdb_id_not_found', [
'%line%' => $result['line_number'],
'%id%' => $part_id
]);
} else {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.partdb_link_success', [
'%line%' => $result['line_number'],
'%name%' => $existing_part->getName(),
'%id%' => $part_id
]);
}
}
/**
* Validate component name/designation
*/
private function validateComponentName(array $entry, array &$result): void
{
$name_fields = ['MPN', 'Designation', 'Value'];
$has_name = false;
foreach ($name_fields as $field) {
if (isset($entry[$field]) && trim($entry[$field]) !== '') {
$has_name = true;
break;
}
}
if (!$has_name) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.no_component_name', [
'%line%' => $result['line_number']
]);
}
}
/**
* Validate package format
*/
private function validatePackageFormat(array $entry, array &$result): void
{
if (!isset($entry['Package']) || trim($entry['Package']) === '') {
return;
}
$package = trim($entry['Package']);
// Check for common package format issues
if (strlen($package) > 100) {
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.package_name_too_long', [
'%line%' => $result['line_number'],
'%package%' => $package
]);
}
// Check for library prefixes (KiCad format)
if (str_contains($package, ':')) {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.library_prefix_detected', [
'%line%' => $result['line_number'],
'%package%' => $package
]);
}
}
/**
* Validate numeric fields
*/
private function validateNumericFields(array $entry, array &$result): void
{
$numeric_fields = ['Quantity', 'Part-DB ID'];
foreach ($numeric_fields as $field) {
if (isset($entry[$field]) && trim($entry[$field]) !== '') {
$value = trim($entry[$field]);
if (!is_numeric($value)) {
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.non_numeric_field', [
'%line%' => $result['line_number'],
'%field%' => $field,
'%value%' => $value
]);
}
}
}
}
/**
* Add summary messages to validation result
*/
private function addSummaryMessages(array &$result, array $errors, array $warnings, array $info): void
{
$total_entries = $result['total_entries'];
$valid_entries = $result['valid_entries'];
$invalid_entries = $result['invalid_entries'];
// Add summary info
if ($total_entries > 0) {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.import_summary', [
'%total%' => $total_entries,
'%valid%' => $valid_entries,
'%invalid%' => $invalid_entries
]);
}
// Add error summary
if (!empty($errors)) {
$error_count = count($errors);
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.summary', [
'%count%' => $error_count
]);
}
// Add warning summary
if (!empty($warnings)) {
$warning_count = count($warnings);
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.summary', [
'%count%' => $warning_count
]);
}
// Add success message if all entries are valid
if ($total_entries > 0 && $invalid_entries === 0) {
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.all_valid');
}
}
/**
* Get user-friendly error message for a validation result
*/
public function getErrorMessage(array $validation_result): string
{
if ($validation_result['is_valid']) {
return '';
}
$messages = [];
if (!empty($validation_result['errors'])) {
$messages[] = 'Errors:';
foreach ($validation_result['errors'] as $error) {
$messages[] = '• ' . $error;
}
}
if (!empty($validation_result['warnings'])) {
$messages[] = 'Warnings:';
foreach ($validation_result['warnings'] as $warning) {
$messages[] = '• ' . $warning;
}
}
return implode("\n", $messages);
}
/**
* Get validation statistics
*/
public function getValidationStats(array $validation_result): array
{
return [
'total_entries' => $validation_result['total_entries'] ?? 0,
'valid_entries' => $validation_result['valid_entries'] ?? 0,
'invalid_entries' => $validation_result['invalid_entries'] ?? 0,
'error_count' => count($validation_result['errors'] ?? []),
'warning_count' => count($validation_result['warnings'] ?? []),
'info_count' => count($validation_result['info'] ?? []),
'success_rate' => $validation_result['total_entries'] > 0
? round(($validation_result['valid_entries'] / $validation_result['total_entries']) * 100, 1)
: 0,
];
}
}

View file

@ -0,0 +1,186 @@
{# BOM Validation Results Component #}
{#
Usage:
{% include 'projects/_bom_validation_results.html.twig' with {
validation_result: validation_result,
show_summary: true,
show_details: true
} %}
#}
{% if validation_result is defined and validation_result is not empty %}
{% set stats = validation_result %}
{# Validation Summary #}
{% if show_summary is defined and show_summary %}
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa-solid fa-chart-bar fa-fw"></i>
{% trans %}project.bom_import.validation.summary{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="text-center">
<div class="h3 text-primary">{{ stats.total_entries }}</div>
<small class="text-muted">{% trans %}project.bom_import.validation.total_entries{% endtrans %}</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h3 text-success">{{ stats.valid_entries }}</div>
<small class="text-muted">{% trans %}project.bom_import.validation.valid_entries{% endtrans %}</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h3 text-warning">{{ stats.invalid_entries }}</div>
<small class="text-muted">{% trans %}project.bom_import.validation.invalid_entries{% endtrans %}</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h3 text-info">
{% if stats.total_entries > 0 %}
{{ ((stats.valid_entries / stats.total_entries) * 100) | round(1) }}%
{% else %}
0%
{% endif %}
</div>
<small class="text-muted">{% trans %}project.bom_import.validation.success_rate{% endtrans %}</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{# Validation Messages #}
{% if validation_result.errors is defined and validation_result.errors is not empty %}
<div class="alert alert-danger">
<h4><i class="fa-solid fa-exclamation-triangle fa-fw"></i> {% trans %}project.bom_import.validation.errors.title{% endtrans %}</h4>
<p class="mb-2">{% trans %}project.bom_import.validation.errors.description{% endtrans %}</p>
<ul class="mb-0">
{% for error in validation_result.errors %}
<li>{{ error|raw }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if validation_result.warnings is defined and validation_result.warnings is not empty %}
<div class="alert alert-warning">
<h4><i class="fa-solid fa-exclamation-circle fa-fw"></i> {% trans %}project.bom_import.validation.warnings.title{% endtrans %}</h4>
<p class="mb-2">{% trans %}project.bom_import.validation.warnings.description{% endtrans %}</p>
<ul class="mb-0">
{% for warning in validation_result.warnings %}
<li>{{ warning|raw }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if validation_result.info is defined and validation_result.info is not empty %}
<div class="alert alert-info">
<h4><i class="fa-solid fa-info-circle fa-fw"></i> {% trans %}project.bom_import.validation.info.title{% endtrans %}</h4>
<ul class="mb-0">
{% for info in validation_result.info %}
<li>{{ info|raw }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# Detailed Line-by-Line Results #}
{% if show_details is defined and show_details and validation_result.line_results is defined %}
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa-solid fa-list fa-fw"></i>
{% trans %}project.bom_import.validation.details.title{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>{% trans %}project.bom_import.validation.details.line{% endtrans %}</th>
<th>{% trans %}project.bom_import.validation.details.status{% endtrans %}</th>
<th>{% trans %}project.bom_import.validation.details.messages{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for line_result in validation_result.line_results %}
<tr class="{% if line_result.is_valid %}table-success{% else %}table-danger{% endif %}">
<td>
<strong>{{ line_result.line_number }}</strong>
</td>
<td>
{% if line_result.is_valid %}
<span class="badge bg-success">
<i class="fa-solid fa-check fa-fw"></i>
{% trans %}project.bom_import.validation.details.valid{% endtrans %}
</span>
{% else %}
<span class="badge bg-danger">
<i class="fa-solid fa-times fa-fw"></i>
{% trans %}project.bom_import.validation.details.invalid{% endtrans %}
</span>
{% endif %}
</td>
<td>
{% if line_result.errors is not empty %}
<div class="text-danger">
{% for error in line_result.errors %}
<div><i class="fa-solid fa-exclamation-triangle fa-fw"></i> {{ error|raw }}</div>
{% endfor %}
</div>
{% endif %}
{% if line_result.warnings is not empty %}
<div class="text-warning">
{% for warning in line_result.warnings %}
<div><i class="fa-solid fa-exclamation-circle fa-fw"></i> {{ warning|raw }}</div>
{% endfor %}
</div>
{% endif %}
{% if line_result.info is not empty %}
<div class="text-info">
{% for info in line_result.info %}
<div><i class="fa-solid fa-info-circle fa-fw"></i> {{ info|raw }}</div>
{% endfor %}
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# Action Buttons #}
{% if validation_result.is_valid is defined %}
<div class="mt-3">
{% if validation_result.is_valid %}
<div class="alert alert-success">
<i class="fa-solid fa-check-circle fa-fw"></i>
{% trans %}project.bom_import.validation.all_valid{% endtrans %}
</div>
{% else %}
<div class="alert alert-danger">
<i class="fa-solid fa-exclamation-triangle fa-fw"></i>
{% trans %}project.bom_import.validation.fix_errors{% endtrans %}
</div>
{% endif %}
</div>
{% endif %}
{% endif %}

View file

@ -0,0 +1,204 @@
{% extends "main_card.html.twig" %}
{% block title %}{% trans %}project.bom_import.map_fields{% endtrans %}{% endblock %}
{% block card_title %}
<i class="fa-solid fa-arrows-left-right fa-fw"></i>
{% trans %}project.bom_import.map_fields{% endtrans %}{% if project %}: <i>{{ project.name }}</i>{% endif %}
{% endblock %}
{% block card_content %}
{% if validation_result is defined %}
{% include 'projects/_bom_validation_results.html.twig' with {
validation_result: validation_result,
show_summary: true,
show_details: false
} %}
{% endif %}
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-info">
<i class="fa-solid fa-info-circle fa-fw"></i>
{% trans %}project.bom_import.map_fields.help{% endtrans %}
</div>
<div class="alert alert-warning">
<i class="fa-solid fa-lightbulb fa-fw"></i>
{% trans %}project.bom_import.field_mapping.priority_note{% endtrans %}
</div>
</div>
</div>
{{ form_start(form) }}
<div class="row mb-3">
<div class="col-md-6">
{{ form_row(form.delimiter) }}
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa-solid fa-table-columns fa-fw"></i>
{% trans %}project.bom_import.field_mapping.title{% endtrans %}
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{% trans %}project.bom_import.field_mapping.csv_field{% endtrans %}</th>
<th>{% trans %}project.bom_import.field_mapping.maps_to{% endtrans %}</th>
<th>{% trans %}project.bom_import.field_mapping.suggestion{% endtrans %}</th>
<th>{% trans %}project.bom_import.field_mapping.priority{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for field in detected_fields %}
<tr>
<td>
<code>{{ field }}</code>
</td>
<td>
{{ form_widget(form['mapping_' ~ field_name_mapping[field]], {
'attr': {
'class': 'form-select field-mapping-select',
'data-field': field
}
}) }}
</td>
<td>
{% if suggested_mapping[field] is defined %}
<span class="badge bg-success">
<i class="fa-solid fa-magic fa-fw"></i>
{{ suggested_mapping[field] }}
</span>
{% else %}
<span class="text-muted">
<i class="fa-solid fa-question fa-fw"></i>
{% trans %}project.bom_import.field_mapping.no_suggestion{% endtrans %}
</span>
{% endif %}
</td>
<td>
<input type="number"
class="form-control form-control-sm priority-input"
min="1"
value="10"
style="width: 80px;"
data-field="{{ field }}"
title="{% trans %}project.bom_import.field_mapping.priority_help{% endtrans %}">
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-3">
<h6>{% trans %}project.bom_import.field_mapping.summary{% endtrans %}:</h6>
<div id="mapping-summary" class="alert alert-info">
<i class="fa-solid fa-info-circle fa-fw"></i>
{% trans %}project.bom_import.field_mapping.select_to_see_summary{% endtrans %}
</div>
</div>
</div>
</div>
<div class="mt-3">
{{ form_widget(form.submit, {
'attr': {
'class': 'btn btn-primary'
}
}) }}
<a href="{{ path('project_import_bom', {'id': project.id}) }}" class="btn btn-secondary">
<i class="fa-solid fa-arrow-left fa-fw"></i>
{% trans %}common.back{% endtrans %}
</a>
</div>
{{ form_end(form) }}
<script nonce="{{ csp_nonce('script') }}">
// Function to initialize the field mapping page
function initializeFieldMapping() {
const suggestions = {{ suggested_mapping|json_encode|raw }};
const fieldNameMapping = {{ field_name_mapping|json_encode|raw }};
Object.keys(suggestions).forEach(function(field) {
// Use the sanitized field name from the server-side mapping
const sanitizedField = fieldNameMapping[field];
const select = document.querySelector('[name="form[mapping_' + sanitizedField + ']"]');
if (select && !select.value) {
select.value = suggestions[field];
}
});
// Update mapping summary
updateMappingSummary();
// Add event listeners for dynamic updates
document.querySelectorAll('.field-mapping-select').forEach(function(select) {
select.addEventListener('change', updateMappingSummary);
});
document.querySelectorAll('.priority-input').forEach(function(input) {
input.addEventListener('change', updateMappingSummary);
});
}
// Initialize on both DOMContentLoaded and Turbo events
document.addEventListener('DOMContentLoaded', initializeFieldMapping);
document.addEventListener('turbo:load', initializeFieldMapping);
document.addEventListener('turbo:frame-load', function(event) {
// Only initialize if this frame contains our field mapping content
if (event.target.id === 'content' || event.target.closest('#content')) {
initializeFieldMapping();
}
});
function updateMappingSummary() {
const summary = document.getElementById('mapping-summary');
const mappings = {};
const priorities = {};
// Collect all mappings and priorities
document.querySelectorAll('.field-mapping-select').forEach(function(select) {
const field = select.getAttribute('data-field');
const target = select.value;
const priorityInput = document.querySelector('.priority-input[data-field="' + field + '"]');
const priority = priorityInput ? parseInt(priorityInput.value) || 10 : 10;
if (target && target !== '') {
if (!mappings[target]) {
mappings[target] = [];
}
mappings[target].push({
field: field,
priority: priority
});
}
});
// Sort by priority and build summary
let summaryHtml = '<div class="row">';
Object.keys(mappings).forEach(function(target) {
const fieldMappings = mappings[target].sort((a, b) => a.priority - b.priority);
const fieldList = fieldMappings.map(m => m.field + ' (' + '{{ "project.bom_import.field_mapping.priority_short"|trans }}' + m.priority + ')').join(', ');
summaryHtml += '<div class="col-md-6 mb-2">';
summaryHtml += '<strong>' + target + ':</strong> ' + fieldList;
summaryHtml += '</div>';
});
summaryHtml += '</div>';
if (Object.keys(mappings).length === 0) {
summary.innerHTML = '<i class="fa-solid fa-info-circle fa-fw"></i> {{ "project.bom_import.field_mapping.select_to_see_summary"|trans }}';
} else {
summary.innerHTML = summaryHtml;
}
}
</script>
{% endblock %}

View file

@ -22,9 +22,12 @@ declare(strict_types=1);
*/
namespace App\Tests\Services\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Entity\Parts\Supplier;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\ImportExportSystem\BOMImporter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\File;
@ -36,11 +39,17 @@ class BOMImporterTest extends WebTestCase
*/
protected $service;
/**
* @var EntityManagerInterface
*/
protected $entityManager;
protected function setUp(): void
{
//Get a service instance.
self::bootKernel();
$this->service = self::getContainer()->get(BOMImporter::class);
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
public function testImportFileIntoProject(): void
@ -119,4 +128,489 @@ class BOMImporterTest extends WebTestCase
$this->service->stringToBOMEntries($input, ['type' => 'kicad_pcbnew']);
}
public function testDetectFields(): void
{
$input = <<<CSV
"Reference","Value","Footprint","Quantity","MPN","Manufacturer","LCSC SPN","Mouser SPN"
CSV;
$fields = $this->service->detectFields($input);
$this->assertIsArray($fields);
$this->assertCount(8, $fields);
$this->assertContains('Reference', $fields);
$this->assertContains('Value', $fields);
$this->assertContains('Footprint', $fields);
$this->assertContains('Quantity', $fields);
$this->assertContains('MPN', $fields);
$this->assertContains('Manufacturer', $fields);
$this->assertContains('LCSC SPN', $fields);
$this->assertContains('Mouser SPN', $fields);
}
public function testDetectFieldsWithQuotes(): void
{
$input = <<<CSV
"Reference","Value","Footprint","Quantity","MPN","Manufacturer","LCSC SPN","Mouser SPN"
CSV;
$fields = $this->service->detectFields($input);
$this->assertIsArray($fields);
$this->assertCount(8, $fields);
$this->assertEquals('Reference', $fields[0]);
$this->assertEquals('Value', $fields[1]);
}
public function testDetectFieldsWithSemicolon(): void
{
$input = <<<CSV
"Reference";"Value";"Footprint";"Quantity";"MPN";"Manufacturer";"LCSC SPN";"Mouser SPN"
CSV;
$fields = $this->service->detectFields($input, ';');
$this->assertIsArray($fields);
$this->assertCount(8, $fields);
$this->assertEquals('Reference', $fields[0]);
$this->assertEquals('Value', $fields[1]);
}
public function testGetAvailableFieldTargets(): void
{
$targets = $this->service->getAvailableFieldTargets();
$this->assertIsArray($targets);
$this->assertArrayHasKey('Designator', $targets);
$this->assertArrayHasKey('Quantity', $targets);
$this->assertArrayHasKey('Value', $targets);
$this->assertArrayHasKey('Package', $targets);
$this->assertArrayHasKey('MPN', $targets);
$this->assertArrayHasKey('Manufacturer', $targets);
$this->assertArrayHasKey('Part-DB ID', $targets);
$this->assertArrayHasKey('Comment', $targets);
// Check structure of a target
$this->assertArrayHasKey('label', $targets['Designator']);
$this->assertArrayHasKey('description', $targets['Designator']);
$this->assertArrayHasKey('required', $targets['Designator']);
$this->assertArrayHasKey('multiple', $targets['Designator']);
$this->assertTrue($targets['Designator']['required']);
$this->assertTrue($targets['Quantity']['required']);
$this->assertFalse($targets['Value']['required']);
}
public function testGetAvailableFieldTargetsWithSuppliers(): void
{
// Create test suppliers
$supplier1 = new Supplier();
$supplier1->setName('LCSC');
$supplier2 = new Supplier();
$supplier2->setName('Mouser');
$this->entityManager->persist($supplier1);
$this->entityManager->persist($supplier2);
$this->entityManager->flush();
$targets = $this->service->getAvailableFieldTargets();
$this->assertArrayHasKey('LCSC SPN', $targets);
$this->assertArrayHasKey('Mouser SPN', $targets);
$this->assertEquals('LCSC SPN', $targets['LCSC SPN']['label']);
$this->assertEquals('Mouser SPN', $targets['Mouser SPN']['label']);
$this->assertFalse($targets['LCSC SPN']['required']);
$this->assertTrue($targets['LCSC SPN']['multiple']);
// Clean up
$this->entityManager->remove($supplier1);
$this->entityManager->remove($supplier2);
$this->entityManager->flush();
}
public function testGetSuggestedFieldMapping(): void
{
$detected_fields = [
'Reference',
'Value',
'Footprint',
'Quantity',
'MPN',
'Manufacturer',
'LCSC',
'Mouser',
'Part-DB ID',
'Comment'
];
$suggestions = $this->service->getSuggestedFieldMapping($detected_fields);
$this->assertIsArray($suggestions);
$this->assertEquals('Designator', $suggestions['Reference']);
$this->assertEquals('Value', $suggestions['Value']);
$this->assertEquals('Package', $suggestions['Footprint']);
$this->assertEquals('Quantity', $suggestions['Quantity']);
$this->assertEquals('MPN', $suggestions['MPN']);
$this->assertEquals('Manufacturer', $suggestions['Manufacturer']);
$this->assertEquals('Part-DB ID', $suggestions['Part-DB ID']);
$this->assertEquals('Comment', $suggestions['Comment']);
}
public function testGetSuggestedFieldMappingWithSuppliers(): void
{
// Create test suppliers
$supplier1 = new Supplier();
$supplier1->setName('LCSC');
$supplier2 = new Supplier();
$supplier2->setName('Mouser');
$this->entityManager->persist($supplier1);
$this->entityManager->persist($supplier2);
$this->entityManager->flush();
$detected_fields = [
'Reference',
'LCSC',
'Mouser',
'lcsc_part',
'mouser_spn'
];
$suggestions = $this->service->getSuggestedFieldMapping($detected_fields);
$this->assertIsArray($suggestions);
$this->assertEquals('Designator', $suggestions['Reference']);
// Note: The exact mapping depends on the pattern matching logic
// We just check that supplier fields are mapped to something
$this->assertArrayHasKey('LCSC', $suggestions);
$this->assertArrayHasKey('Mouser', $suggestions);
$this->assertArrayHasKey('lcsc_part', $suggestions);
$this->assertArrayHasKey('mouser_spn', $suggestions);
// Clean up
$this->entityManager->remove($supplier1);
$this->entityManager->remove($supplier2);
$this->entityManager->flush();
}
public function testValidateFieldMappingValid(): void
{
$field_mapping = [
'Reference' => 'Designator',
'Quantity' => 'Quantity',
'Value' => 'Value'
];
$detected_fields = ['Reference', 'Quantity', 'Value', 'MPN'];
$result = $this->service->validateFieldMapping($field_mapping, $detected_fields);
$this->assertIsArray($result);
$this->assertArrayHasKey('errors', $result);
$this->assertArrayHasKey('warnings', $result);
$this->assertArrayHasKey('is_valid', $result);
$this->assertTrue($result['is_valid']);
$this->assertEmpty($result['errors']);
$this->assertNotEmpty($result['warnings']); // Should warn about unmapped MPN
}
public function testValidateFieldMappingMissingRequired(): void
{
$field_mapping = [
'Value' => 'Value',
'MPN' => 'MPN'
];
$detected_fields = ['Value', 'MPN'];
$result = $this->service->validateFieldMapping($field_mapping, $detected_fields);
$this->assertFalse($result['is_valid']);
$this->assertNotEmpty($result['errors']);
$this->assertContains("Required field 'Designator' is not mapped from any CSV column.", $result['errors']);
$this->assertContains("Required field 'Quantity' is not mapped from any CSV column.", $result['errors']);
}
public function testValidateFieldMappingInvalidTarget(): void
{
$field_mapping = [
'Reference' => 'Designator',
'Quantity' => 'Quantity',
'Value' => 'InvalidTarget'
];
$detected_fields = ['Reference', 'Quantity', 'Value'];
$result = $this->service->validateFieldMapping($field_mapping, $detected_fields);
$this->assertFalse($result['is_valid']);
$this->assertNotEmpty($result['errors']);
$this->assertContains("Invalid target field 'InvalidTarget' for CSV field 'Value'.", $result['errors']);
}
public function testStringToBOMEntriesKiCADSchematic(): void
{
$input = <<<CSV
"Reference","Value","Footprint","Quantity","MPN","Manufacturer","LCSC SPN","Mouser SPN"
"R1,R2","10k","R_0805_2012Metric",2,"CRCW080510K0FKEA","Vishay","C123456","123-M10K"
"C1","100nF","C_0805_2012Metric",1,"CL21A104KOCLRNC","Samsung","C789012","80-CL21A104KOCLRNC"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'Footprint' => 'Package',
'Quantity' => 'Quantity',
'MPN' => 'MPN',
'Manufacturer' => 'Manufacturer',
'LCSC SPN' => 'LCSC SPN',
'Mouser SPN' => 'Mouser SPN'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(2, $bom_entries);
// Check first entry
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
$this->assertEquals(2.0, $bom_entries[0]->getQuantity());
$this->assertEquals('CRCW080510K0FKEA (R_0805_2012Metric)', $bom_entries[0]->getName());
$this->assertStringContainsString('Value: 10k', $bom_entries[0]->getComment());
$this->assertStringContainsString('MPN: CRCW080510K0FKEA', $bom_entries[0]->getComment());
$this->assertStringContainsString('Manf: Vishay', $bom_entries[0]->getComment());
// Check second entry
$this->assertEquals('C1', $bom_entries[1]->getMountnames());
$this->assertEquals(1.0, $bom_entries[1]->getQuantity());
}
public function testStringToBOMEntriesKiCADSchematicWithPriority(): void
{
$input = <<<CSV
"Reference","Value","MPN1","MPN2","Quantity"
"R1,R2","10k","CRCW080510K0FKEA","","2"
"C1","100nF","","CL21A104KOCLRNC","1"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'MPN1' => 'MPN',
'MPN2' => 'MPN',
'Quantity' => 'Quantity'
];
$field_priorities = [
'MPN1' => 1,
'MPN2' => 2
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(2, $bom_entries);
// First entry should use MPN1 (higher priority)
$this->assertEquals('CRCW080510K0FKEA', $bom_entries[0]->getName());
// Second entry should use MPN2 (MPN1 is empty)
$this->assertEquals('CL21A104KOCLRNC', $bom_entries[1]->getName());
}
public function testStringToBOMEntriesKiCADSchematicWithPartDBID(): void
{
// Create a test part with required fields
$part = new Part();
$part->setName('Test Part');
$part->setCategory($this->getDefaultCategory($this->entityManager));
$this->entityManager->persist($part);
$this->entityManager->flush();
$input = <<<CSV
"Reference","Value","Part-DB ID","Quantity"
"R1,R2","10k","{$part->getID()}","2"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'Part-DB ID' => 'Part-DB ID',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(1, $bom_entries);
$this->assertEquals('Test Part', $bom_entries[0]->getName());
$this->assertSame($part, $bom_entries[0]->getPart());
$this->assertStringContainsString("Part-DB ID: {$part->getID()}", $bom_entries[0]->getComment());
// Clean up
$this->entityManager->remove($part);
$this->entityManager->flush();
}
public function testStringToBOMEntriesKiCADSchematicWithInvalidPartDBID(): void
{
$input = <<<CSV
"Reference","Value","Part-DB ID","Quantity"
"R1,R2","10k","99999","2"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'Part-DB ID' => 'Part-DB ID',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(1, $bom_entries);
$this->assertEquals('10k', $bom_entries[0]->getName()); // Should use Value as name
$this->assertNull($bom_entries[0]->getPart()); // Should not link to part
$this->assertStringContainsString("Part-DB ID: 99999 (NOT FOUND)", $bom_entries[0]->getComment());
}
public function testStringToBOMEntriesKiCADSchematicMergeDuplicates(): void
{
$input = <<<CSV
"Reference","Value","MPN","Quantity"
"R1","10k","CRCW080510K0FKEA","1"
"R2","10k","CRCW080510K0FKEA","1"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'MPN' => 'MPN',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(1, $bom_entries); // Should merge into one entry
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
$this->assertEquals(2.0, $bom_entries[0]->getQuantity());
$this->assertEquals('CRCW080510K0FKEA', $bom_entries[0]->getName());
}
public function testStringToBOMEntriesKiCADSchematicMissingRequired(): void
{
$input = <<<CSV
"Value","MPN"
"10k","CRCW080510K0FKEA"
CSV;
$field_mapping = [
'Value' => 'Value',
'MPN' => 'MPN'
];
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('Required field "Designator" is missing or empty');
$this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
}
public function testStringToBOMEntriesKiCADSchematicQuantityMismatch(): void
{
$input = <<<CSV
"Reference","Value","Quantity"
"R1,R2,R3","10k","2"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'Quantity' => 'Quantity'
];
$this->expectException(\UnexpectedValueException::class);
$this->expectExceptionMessage('Mismatch between quantity and component references');
$this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
}
public function testStringToBOMEntriesKiCADSchematicWithBOM(): void
{
// Test with BOM (Byte Order Mark)
$input = "\xEF\xBB\xBF" . <<<CSV
"Reference","Value","Quantity"
"R1,R2","10k","2"
CSV;
$field_mapping = [
'Reference' => 'Designator',
'Value' => 'Value',
'Quantity' => 'Quantity'
];
$bom_entries = $this->service->stringToBOMEntries($input, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'delimiter' => ','
]);
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
$this->assertCount(1, $bom_entries);
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
}
private function getDefaultCategory(EntityManagerInterface $entityManager)
{
// Get the first available category or create a default one
$categoryRepo = $entityManager->getRepository(\App\Entity\Parts\Category::class);
$categories = $categoryRepo->findAll();
if (empty($categories)) {
// Create a default category if none exists
$category = new \App\Entity\Parts\Category();
$category->setName('Default Category');
$entityManager->persist($category);
$entityManager->flush();
return $category;
}
return $categories[0];
}
}

View file

@ -0,0 +1,349 @@
<?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\ImportExportSystem;
use App\Entity\Parts\Part;
use App\Services\ImportExportSystem\BOMValidationService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @see \App\Services\ImportExportSystem\BOMValidationService
*/
class BOMValidationServiceTest extends WebTestCase
{
private BOMValidationService $validationService;
private EntityManagerInterface $entityManager;
private TranslatorInterface $translator;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->translator = self::getContainer()->get(TranslatorInterface::class);
$this->validationService = new BOMValidationService($this->entityManager, $this->translator);
}
public function testValidateBOMEntryWithValidData(): void
{
$entry = [
'Designator' => 'R1,C2,R3',
'Quantity' => '3',
'MPN' => 'RES-10K',
'Package' => '0603',
'Value' => '10k',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']);
$this->assertEmpty($result['errors']);
$this->assertEquals(1, $result['line_number']);
}
public function testValidateBOMEntryWithMissingRequiredFields(): void
{
$entry = [
'MPN' => 'RES-10K',
'Package' => '0603',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertCount(2, $result['errors']);
$this->assertStringContainsString('Designator', (string) $result['errors'][0]);
$this->assertStringContainsString('Quantity', (string) $result['errors'][1]);
}
public function testValidateBOMEntryWithQuantityMismatch(): void
{
$entry = [
'Designator' => 'R1,C2,R3,C4',
'Quantity' => '3',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertCount(1, $result['errors']);
$this->assertStringContainsString('Mismatch between quantity and component references', (string) $result['errors'][0]);
}
public function testValidateBOMEntryWithInvalidQuantity(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => 'abc',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertGreaterThanOrEqual(1, count($result['errors']));
$this->assertStringContainsString('not a valid number', implode(' ', array_map('strval', $result['errors'])));
}
public function testValidateBOMEntryWithZeroQuantity(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '0',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertGreaterThanOrEqual(1, count($result['errors']));
$this->assertStringContainsString('must be greater than 0', implode(' ', array_map('strval', $result['errors'])));
}
public function testValidateBOMEntryWithDuplicateDesignators(): void
{
$entry = [
'Designator' => 'R1,R1,C2',
'Quantity' => '3',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertCount(1, $result['errors']);
$this->assertStringContainsString('Duplicate component references', (string) $result['errors'][0]);
}
public function testValidateBOMEntryWithInvalidDesignatorFormat(): void
{
$entry = [
'Designator' => 'R1,invalid,C2',
'Quantity' => '3',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']); // Warnings don't make it invalid
$this->assertCount(1, $result['warnings']);
$this->assertStringContainsString('unusual format', (string) $result['warnings'][0]);
}
public function testValidateBOMEntryWithEmptyDesignator(): void
{
$entry = [
'Designator' => '',
'Quantity' => '1',
'MPN' => 'RES-10K',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertGreaterThanOrEqual(1, count($result['errors']));
$this->assertStringContainsString('Required field "Designator" is missing or empty', implode(' ', array_map('strval', $result['errors'])));
}
public function testValidateBOMEntryWithInvalidPartDBID(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
'Part-DB ID' => 'abc',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertFalse($result['is_valid']);
$this->assertGreaterThanOrEqual(1, count($result['errors']));
$this->assertStringContainsString('not a valid number', implode(' ', array_map('strval', $result['errors'])));
}
public function testValidateBOMEntryWithNonExistentPartDBID(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
'Part-DB ID' => '999999', // Use very high ID that doesn't exist
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']); // Warnings don't make it invalid
$this->assertCount(1, $result['warnings']);
$this->assertStringContainsString('not found in database', (string) $result['warnings'][0]);
}
public function testValidateBOMEntryWithNoComponentName(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '1',
'Package' => '0603',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']); // Warnings don't make it invalid
$this->assertCount(1, $result['warnings']);
$this->assertStringContainsString('No component name/designation', (string) $result['warnings'][0]);
}
public function testValidateBOMEntryWithLongPackageName(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
'Package' => str_repeat('A', 150), // Very long package name
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']); // Warnings don't make it invalid
$this->assertCount(1, $result['warnings']);
$this->assertStringContainsString('unusually long', (string) $result['warnings'][0]);
}
public function testValidateBOMEntryWithLibraryPrefix(): void
{
$entry = [
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
'Package' => 'Resistor_SMD:R_0603_1608Metric',
];
$result = $this->validationService->validateBOMEntry($entry, 1);
$this->assertTrue($result['is_valid']);
$this->assertCount(1, $result['info']);
$this->assertStringContainsString('library prefix', $result['info'][0]);
}
public function testValidateBOMEntriesWithMultipleEntries(): void
{
$entries = [
[
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
],
[
'Designator' => 'C1,C2',
'Quantity' => '2',
'MPN' => 'CAP-100nF',
],
];
$result = $this->validationService->validateBOMEntries($entries);
$this->assertTrue($result['is_valid']);
$this->assertEquals(2, $result['total_entries']);
$this->assertEquals(2, $result['valid_entries']);
$this->assertEquals(0, $result['invalid_entries']);
$this->assertCount(2, $result['line_results']);
}
public function testValidateBOMEntriesWithMixedResults(): void
{
$entries = [
[
'Designator' => 'R1',
'Quantity' => '1',
'MPN' => 'RES-10K',
],
[
'Designator' => 'C1,C2',
'Quantity' => '1', // Mismatch
'MPN' => 'CAP-100nF',
],
];
$result = $this->validationService->validateBOMEntries($entries);
$this->assertFalse($result['is_valid']);
$this->assertEquals(2, $result['total_entries']);
$this->assertEquals(1, $result['valid_entries']);
$this->assertEquals(1, $result['invalid_entries']);
$this->assertCount(1, $result['errors']);
}
public function testGetValidationStats(): void
{
$validation_result = [
'total_entries' => 10,
'valid_entries' => 8,
'invalid_entries' => 2,
'errors' => ['Error 1', 'Error 2'],
'warnings' => ['Warning 1'],
'info' => ['Info 1', 'Info 2'],
];
$stats = $this->validationService->getValidationStats($validation_result);
$this->assertEquals(10, $stats['total_entries']);
$this->assertEquals(8, $stats['valid_entries']);
$this->assertEquals(2, $stats['invalid_entries']);
$this->assertEquals(2, $stats['error_count']);
$this->assertEquals(1, $stats['warning_count']);
$this->assertEquals(2, $stats['info_count']);
$this->assertEquals(80.0, $stats['success_rate']);
}
public function testGetErrorMessage(): void
{
$validation_result = [
'is_valid' => false,
'errors' => ['Error 1', 'Error 2'],
'warnings' => ['Warning 1'],
];
$message = $this->validationService->getErrorMessage($validation_result);
$this->assertStringContainsString('Errors:', $message);
$this->assertStringContainsString('• Error 1', $message);
$this->assertStringContainsString('• Error 2', $message);
$this->assertStringContainsString('Warnings:', $message);
$this->assertStringContainsString('• Warning 1', $message);
}
public function testGetErrorMessageWithValidResult(): void
{
$validation_result = [
'is_valid' => true,
'errors' => [],
'warnings' => [],
];
$message = $this->validationService->getErrorMessage($validation_result);
$this->assertEquals('', $message);
}
}

View file

@ -60,26 +60,29 @@ class TimestampableElementProviderTest extends WebTestCase
protected function setUp(): void
{
self::bootKernel();
\Locale::setDefault('en');
\Locale::setDefault('en_US');
$this->service = self::getContainer()->get(TimestampableElementProvider::class);
$this->target = new class() implements TimeStampableInterface {
$this->target = new class () implements TimeStampableInterface {
public function getLastModified(): ?DateTime
{
return new \DateTime('2000-01-01');
return new DateTime('2000-01-01');
}
public function getAddedDate(): ?DateTime
{
return new \DateTime('2000-01-01');
return new DateTime('2000-01-01');
}
};
}
public static function dataProvider(): \Iterator
{
\Locale::setDefault('en');
yield ['1/1/00, 12:00 AM', '[[LAST_MODIFIED]]'];
yield ['1/1/00, 12:00 AM', '[[CREATION_DATE]]'];
\Locale::setDefault('en_US');
// Use IntlDateFormatter like the actual service does
$formatter = new \IntlDateFormatter(\Locale::getDefault(), \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT);
$expectedFormat = $formatter->format(new DateTime('2000-01-01'));
yield [$expectedFormat, '[[LAST_MODIFIED]]'];
yield [$expectedFormat, '[[CREATION_DATE]]'];
}
#[DataProvider('dataProvider')]
@ -87,4 +90,4 @@ class TimestampableElementProviderTest extends WebTestCase
{
$this->assertSame($expected, $this->service->replace($placeholder, $this->target));
}
}
}

View file

@ -12333,7 +12333,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="p7LGAIX" name="settings.ips.element14.apiKey.help">
<segment state="translated">
<source>settings.ips.element14.apiKey.help</source>
<target>You can register for an API key on &lt;a href="https://partner.element14.com/"&gt;https://partner.element14.com/&lt;/a&gt;.</target>
<target><![CDATA[You can register for an API key on <a href="https://partner.element14.com/">https://partner.element14.com/</a>.]]></target>
</segment>
</unit>
<unit id="ZdUHpZc" name="settings.ips.element14.storeId">
@ -12345,7 +12345,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="XXGUxF6" name="settings.ips.element14.storeId.help">
<segment state="translated">
<source>settings.ips.element14.storeId.help</source>
<target>The store domain to retrieve the data from. This decides the language and currency of results. See &lt;a href="https://partner.element14.com/docs/Product_Search_API_REST__Description"&gt;here&lt;/a&gt; for a list of valid domains.</target>
<target><![CDATA[The store domain to retrieve the data from. This decides the language and currency of results. See <a href="https://partner.element14.com/docs/Product_Search_API_REST__Description">here</a> for a list of valid domains.]]></target>
</segment>
</unit>
<unit id="WKWZIm2" name="settings.ips.tme">
@ -12363,7 +12363,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="_pYLrPT" name="settings.ips.tme.token.help">
<segment state="translated">
<source>settings.ips.tme.token.help</source>
<target>You can get an API token and secret on &lt;a href="https://developers.tme.eu/en/"&gt;https://developers.tme.eu/en/&lt;/a&gt;.</target>
<target><![CDATA[You can get an API token and secret on <a href="https://developers.tme.eu/en/">https://developers.tme.eu/en/</a>.]]></target>
</segment>
</unit>
<unit id="yswx4bq" name="settings.ips.tme.secret">
@ -12411,7 +12411,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="gu.JlpT" name="settings.ips.mouser.apiKey.help">
<segment state="translated">
<source>settings.ips.mouser.apiKey.help</source>
<target>You can register for an API key on &lt;a href="https://eu.mouser.com/api-hub/"&gt;https://eu.mouser.com/api-hub/&lt;/a&gt;.</target>
<target><![CDATA[You can register for an API key on <a href="https://eu.mouser.com/api-hub/">https://eu.mouser.com/api-hub/</a>.]]></target>
</segment>
</unit>
<unit id="Q66CNjw" name="settings.ips.mouser.searchLimit">
@ -12489,7 +12489,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="kKv0J3." name="settings.system.attachments">
<segment state="translated">
<source>settings.system.attachments</source>
<target>Attachments &amp; Files</target>
<target><![CDATA[Attachments & Files]]></target>
</segment>
</unit>
<unit id="dsRff8T" name="settings.system.attachments.maxFileSize">
@ -12513,7 +12513,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="T.PBu5P" name="settings.system.attachments.allowDownloads.help">
<segment state="translated">
<source>settings.system.attachments.allowDownloads.help</source>
<target>With this option users can download external files into Part-DB by providing an URL. &lt;b&gt;Attention: This can be a security issue, as it might allow users to access intranet ressources via Part-DB!&lt;/b&gt;</target>
<target><![CDATA[With this option users can download external files into Part-DB by providing an URL. <b>Attention: This can be a security issue, as it might allow users to access intranet ressources via Part-DB!</b>]]></target>
</segment>
</unit>
<unit id=".OyihML" name="settings.system.attachments.downloadByDefault">
@ -12687,8 +12687,8 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="0GRlEe5" name="settings.system.localization.base_currency_description">
<segment state="translated">
<source>settings.system.localization.base_currency_description</source>
<target>The currency that is used to store price information and exchange rates in. This currency is assumed, when no currency is set for a price information.
&lt;b&gt;Please note that the currencies are not converted, when changing this value. So changing the default currency after you already added price information, will result in wrong prices!&lt;/b&gt;</target>
<target><![CDATA[The currency that is used to store price information and exchange rates in. This currency is assumed, when no currency is set for a price information.
<b>Please note that the currencies are not converted, when changing this value. So changing the default currency after you already added price information, will result in wrong prices!</b>]]></target>
</segment>
</unit>
<unit id="cvpTUeY" name="settings.system.privacy">
@ -12718,7 +12718,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="w07P3Dt" name="settings.misc.kicad_eda.category_depth.help">
<segment state="translated">
<source>settings.misc.kicad_eda.category_depth.help</source>
<target>This value determines the depth of the category tree, that is visible inside KiCad. 0 means that only the top level categories are visible. Set to a value &gt; 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad.</target>
<target><![CDATA[This value determines the depth of the category tree, that is visible inside KiCad. 0 means that only the top level categories are visible. Set to a value > 0 to show more levels. Set to -1, to show all parts of Part-DB inside a sigle cnategory in KiCad.]]></target>
</segment>
</unit>
<unit id="VwvmcWE" name="settings.behavior.sidebar">
@ -12736,7 +12736,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="jc0JTvL" name="settings.behavior.sidebar.items.help">
<segment state="translated">
<source>settings.behavior.sidebar.items.help</source>
<target>The menus which appear at the sidebar by default. Order of items can be changed via drag &amp; drop.</target>
<target><![CDATA[The menus which appear at the sidebar by default. Order of items can be changed via drag & drop.]]></target>
</segment>
</unit>
<unit id="gVSWDkE" name="settings.behavior.sidebar.rootNodeEnabled">
@ -12784,7 +12784,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="SUD8H3b" name="settings.behavior.table.parts_default_columns.help">
<segment state="translated">
<source>settings.behavior.table.parts_default_columns.help</source>
<target>The columns to show by default in part tables. Order of items can be changed via drag &amp; drop.</target>
<target><![CDATA[The columns to show by default in part tables. Order of items can be changed via drag & drop.]]></target>
</segment>
</unit>
<unit id="hazr_g5" name="settings.ips.oemsecrets">
@ -12838,7 +12838,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<unit id="KLJYfJ0" name="settings.ips.oemsecrets.sortMode.M">
<segment state="translated">
<source>settings.ips.oemsecrets.sortMode.M</source>
<target>Completeness &amp; Manufacturer name</target>
<target><![CDATA[Completeness & Manufacturer name]]></target>
</segment>
</unit>
<unit id="8C9ijHM" name="entity.export.flash.error.no_entities">
@ -13057,5 +13057,377 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>Redacted for security reasons</target>
</segment>
</unit>
<unit id="J716Oh4" name="project.bom_import.map_fields">
<segment state="translated">
<source>project.bom_import.map_fields</source>
<target>Map Fields</target>
</segment>
</unit>
<unit id="wvT5Xvn" name="project.bom_import.map_fields.help">
<segment state="translated">
<source>project.bom_import.map_fields.help</source>
<target>Configure how CSV columns map to BOM fields</target>
</segment>
</unit>
<unit id="nh7uLPe" name="project.bom_import.delimiter">
<segment state="translated">
<source>project.bom_import.delimiter</source>
<target>Delimiter</target>
</segment>
</unit>
<unit id="MSlSeE4" name="project.bom_import.delimiter.comma">
<segment state="translated">
<source>project.bom_import.delimiter.comma</source>
<target>Comma (,)</target>
</segment>
</unit>
<unit id="UdDSB0h" name="project.bom_import.delimiter.semicolon">
<segment state="translated">
<source>project.bom_import.delimiter.semicolon</source>
<target>Semicolon (;)</target>
</segment>
</unit>
<unit id="Wpa4Gmf" name="project.bom_import.delimiter.tab">
<segment state="translated">
<source>project.bom_import.delimiter.tab</source>
<target>Tab</target>
</segment>
</unit>
<unit id="tjrG_vU" name="project.bom_import.field_mapping.title">
<segment state="translated">
<source>project.bom_import.field_mapping.title</source>
<target>Field Mapping</target>
</segment>
</unit>
<unit id="nWu8B7R" name="project.bom_import.field_mapping.csv_field">
<segment state="translated">
<source>project.bom_import.field_mapping.csv_field</source>
<target>CSV Field</target>
</segment>
</unit>
<unit id="D0KlFqm" name="project.bom_import.field_mapping.maps_to">
<segment state="translated">
<source>project.bom_import.field_mapping.maps_to</source>
<target>Maps To</target>
</segment>
</unit>
<unit id="rhUpmxC" name="project.bom_import.field_mapping.suggestion">
<segment state="translated">
<source>project.bom_import.field_mapping.suggestion</source>
<target>Suggestion</target>
</segment>
</unit>
<unit id="TM0mgKr" name="project.bom_import.field_mapping.priority">
<segment state="translated">
<source>project.bom_import.field_mapping.priority</source>
<target>Priority</target>
</segment>
</unit>
<unit id="xY8NSRr" name="project.bom_import.field_mapping.priority_help">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_help</source>
<target>Priority (lower number = higher priority)</target>
</segment>
</unit>
<unit id="CuXg8WU" name="project.bom_import.field_mapping.priority_short">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_short</source>
<target>P</target>
</segment>
</unit>
<unit id="jOHOShN" name="project.bom_import.field_mapping.priority_note">
<segment state="translated">
<source>project.bom_import.field_mapping.priority_note</source>
<target>Priority Tip: Lower numbers = higher priority. Default priority is 10. Use priorities 1-9 for most important fields, 10+ for normal priority.</target>
</segment>
</unit>
<unit id="pvmv3OT" name="project.bom_import.field_mapping.summary">
<segment state="translated">
<source>project.bom_import.field_mapping.summary</source>
<target>Field Mapping Summary</target>
</segment>
</unit>
<unit id="VjLf1Pf" name="project.bom_import.field_mapping.select_to_see_summary">
<segment state="translated">
<source>project.bom_import.field_mapping.select_to_see_summary</source>
<target>Select field mappings to see summary</target>
</segment>
</unit>
<unit id="JOPny6T" name="project.bom_import.field_mapping.no_suggestion">
<segment state="translated">
<source>project.bom_import.field_mapping.no_suggestion</source>
<target>No suggestion</target>
</segment>
</unit>
<unit id="1LWEtqL" name="project.bom_import.preview">
<segment state="translated">
<source>project.bom_import.preview</source>
<target>Preview</target>
</segment>
</unit>
<unit id="1CJA0aY" name="project.bom_import.flash.session_expired">
<segment state="translated">
<source>project.bom_import.flash.session_expired</source>
<target>Import session has expired. Please upload your file again.</target>
</segment>
</unit>
<unit id="BLN7XRN" name="project.bom_import.field_mapping.ignore">
<segment state="translated">
<source>project.bom_import.field_mapping.ignore</source>
<target>Ignore</target>
</segment>
</unit>
<unit id="yiIB3yV" name="project.bom_import.type.kicad_schematic">
<segment>
<source>project.bom_import.type.kicad_schematic</source>
<target>KiCAD Schematic BOM (CSV file)</target>
</segment>
</unit>
<unit id="ltE6xPP" name="common.back">
<segment>
<source>common.back</source>
<target>Back</target>
</segment>
</unit>
<unit id="HCTqjZO" name="project.bom_import.validation.errors.required_field_missing">
<segment>
<source>project.bom_import.validation.errors.required_field_missing</source>
<target>Line %line%: Required field "%field%" is missing or empty. Please ensure this field is mapped and contains data.</target>
</segment>
</unit>
<unit id="_ua5YM7" name="project.bom_import.validation.errors.no_valid_designators">
<segment>
<source>project.bom_import.validation.errors.no_valid_designators</source>
<target>Line %line%: Designator field contains no valid component references. Expected format: "R1,C2,U3" or "R1, C2, U3".</target>
</segment>
</unit>
<unit id="xpkq3rW" name="project.bom_import.validation.warnings.unusual_designator_format">
<segment>
<source>project.bom_import.validation.warnings.unusual_designator_format</source>
<target>Line %line%: Some component references may have unusual format: %designators%. Expected format: "R1", "C2", "U3", etc.</target>
</segment>
</unit>
<unit id="M54Ud5d" name="project.bom_import.validation.errors.duplicate_designators">
<segment>
<source>project.bom_import.validation.errors.duplicate_designators</source>
<target>Line %line%: Duplicate component references found: %designators%. Each component should be referenced only once per line.</target>
</segment>
</unit>
<unit id="ZULFZh2" name="project.bom_import.validation.errors.invalid_quantity">
<segment>
<source>project.bom_import.validation.errors.invalid_quantity</source>
<target>Line %line%: Quantity "%quantity%" is not a valid number. Please enter a numeric value (e.g., 1, 2.5, 10).</target>
</segment>
</unit>
<unit id="3WbRgsl" name="project.bom_import.validation.errors.quantity_zero_or_negative">
<segment>
<source>project.bom_import.validation.errors.quantity_zero_or_negative</source>
<target>Line %line%: Quantity must be greater than 0, got %quantity%.</target>
</segment>
</unit>
<unit id="_nWGsSU" name="project.bom_import.validation.warnings.quantity_unusually_high">
<segment>
<source>project.bom_import.validation.warnings.quantity_unusually_high</source>
<target>Line %line%: Quantity %quantity% seems unusually high. Please verify this is correct.</target>
</segment>
</unit>
<unit id="iJWw39f" name="project.bom_import.validation.warnings.quantity_not_whole_number">
<segment>
<source>project.bom_import.validation.warnings.quantity_not_whole_number</source>
<target>Line %line%: Quantity %quantity% is not a whole number, but you have %count% component references. This may indicate a mismatch.</target>
</segment>
</unit>
<unit id="Zffmgvv" name="project.bom_import.validation.errors.quantity_designator_mismatch">
<segment>
<source>project.bom_import.validation.errors.quantity_designator_mismatch</source>
<target>Line %line%: Mismatch between quantity and component references. Quantity: %quantity%, References: %count% (%designators%). These should match. Either adjust the quantity or check your component references.</target>
</segment>
</unit>
<unit id="JqHpDIo" name="project.bom_import.validation.errors.invalid_partdb_id">
<segment>
<source>project.bom_import.validation.errors.invalid_partdb_id</source>
<target>Line %line%: Part-DB ID "%id%" is not a valid number. Please enter a numeric ID.</target>
</segment>
</unit>
<unit id="Mr7W6r0" name="project.bom_import.validation.errors.partdb_id_zero_or_negative">
<segment>
<source>project.bom_import.validation.errors.partdb_id_zero_or_negative</source>
<target>Line %line%: Part-DB ID must be greater than 0, got %id%.</target>
</segment>
</unit>
<unit id="gRH6IeR" name="project.bom_import.validation.warnings.partdb_id_not_found">
<segment>
<source>project.bom_import.validation.warnings.partdb_id_not_found</source>
<target>Line %line%: Part-DB ID %id% not found in database. The component will be imported without linking to an existing part.</target>
</segment>
</unit>
<unit id="eMfaBYo" name="project.bom_import.validation.info.partdb_link_success">
<segment>
<source>project.bom_import.validation.info.partdb_link_success</source>
<target>Line %line%: Successfully linked to Part-DB part "%name%" (ID: %id%).</target>
</segment>
</unit>
<unit id="b_m9p.f" name="project.bom_import.validation.warnings.no_component_name">
<segment>
<source>project.bom_import.validation.warnings.no_component_name</source>
<target>Line %line%: No component name/designation provided (MPN, Designation, or Value). Component will be named "Unknown Component".</target>
</segment>
</unit>
<unit id=".LAGdMe" name="project.bom_import.validation.warnings.package_name_too_long">
<segment>
<source>project.bom_import.validation.warnings.package_name_too_long</source>
<target>Line %line%: Package name "%package%" is unusually long. Please verify this is correct.</target>
</segment>
</unit>
<unit id="wFF49kl" name="project.bom_import.validation.info.library_prefix_detected">
<segment>
<source>project.bom_import.validation.info.library_prefix_detected</source>
<target>Line %line%: Package "%package%" contains library prefix. This will be automatically removed during import.</target>
</segment>
</unit>
<unit id="hEO2sxC" name="project.bom_import.validation.errors.non_numeric_field">
<segment>
<source>project.bom_import.validation.errors.non_numeric_field</source>
<target>Line %line%: Field "%field%" contains non-numeric value "%value%". Please enter a valid number.</target>
</segment>
</unit>
<unit id="Ear_sUX" name="project.bom_import.validation.info.import_summary">
<segment>
<source>project.bom_import.validation.info.import_summary</source>
<target>Import summary: %total% total entries, %valid% valid, %invalid% with issues.</target>
</segment>
</unit>
<unit id="SqJZ97A" name="project.bom_import.validation.errors.summary">
<segment>
<source>project.bom_import.validation.errors.summary</source>
<target>Found %count% validation error(s) that must be fixed before import can proceed.</target>
</segment>
</unit>
<unit id="s7dzFnp" name="project.bom_import.validation.warnings.summary">
<segment>
<source>project.bom_import.validation.warnings.summary</source>
<target>Found %count% warning(s). Please review these issues before proceeding.</target>
</segment>
</unit>
<unit id="2KaAHmS" name="project.bom_import.validation.info.all_valid">
<segment>
<source>project.bom_import.validation.info.all_valid</source>
<target>All entries passed validation successfully!</target>
</segment>
</unit>
<unit id="Lyb3BjK" name="project.bom_import.validation.summary">
<segment>
<source>project.bom_import.validation.summary</source>
<target>Validation Summary</target>
</segment>
</unit>
<unit id="DUM8GoE" name="project.bom_import.validation.total_entries">
<segment>
<source>project.bom_import.validation.total_entries</source>
<target>Total Entries</target>
</segment>
</unit>
<unit id="AtLGGU2" name="project.bom_import.validation.valid_entries">
<segment>
<source>project.bom_import.validation.valid_entries</source>
<target>Valid Entries</target>
</segment>
</unit>
<unit id="mgdn5v3" name="project.bom_import.validation.invalid_entries">
<segment>
<source>project.bom_import.validation.invalid_entries</source>
<target>Invalid Entries</target>
</segment>
</unit>
<unit id="qmJ5BfF" name="project.bom_import.validation.success_rate">
<segment>
<source>project.bom_import.validation.success_rate</source>
<target>Success Rate</target>
</segment>
</unit>
<unit id="QyDsrFV" name="project.bom_import.validation.errors.title">
<segment>
<source>project.bom_import.validation.errors.title</source>
<target>Validation Errors</target>
</segment>
</unit>
<unit id="d.Mvu0a" name="project.bom_import.validation.errors.description">
<segment>
<source>project.bom_import.validation.errors.description</source>
<target>The following errors must be fixed before the import can proceed:</target>
</segment>
</unit>
<unit id="MGFfKr." name="project.bom_import.validation.warnings.title">
<segment>
<source>project.bom_import.validation.warnings.title</source>
<target>Validation Warnings</target>
</segment>
</unit>
<unit id="tt4VxY6" name="project.bom_import.validation.warnings.description">
<segment>
<source>project.bom_import.validation.warnings.description</source>
<target>The following warnings should be reviewed before proceeding:</target>
</segment>
</unit>
<unit id="YuAdYeh" name="project.bom_import.validation.info.title">
<segment>
<source>project.bom_import.validation.info.title</source>
<target>Information</target>
</segment>
</unit>
<unit id="1pUfi7Q" name="project.bom_import.validation.details.title">
<segment>
<source>project.bom_import.validation.details.title</source>
<target>Detailed Validation Results</target>
</segment>
</unit>
<unit id="4Vv0Xns" name="project.bom_import.validation.details.line">
<segment>
<source>project.bom_import.validation.details.line</source>
<target>Line</target>
</segment>
</unit>
<unit id="RbvD2zF" name="project.bom_import.validation.details.status">
<segment>
<source>project.bom_import.validation.details.status</source>
<target>Status</target>
</segment>
</unit>
<unit id="iGtKkH_" name="project.bom_import.validation.details.messages">
<segment>
<source>project.bom_import.validation.details.messages</source>
<target>Messages</target>
</segment>
</unit>
<unit id="IgdgZd8" name="project.bom_import.validation.details.valid">
<segment>
<source>project.bom_import.validation.details.valid</source>
<target>Valid</target>
</segment>
</unit>
<unit id="lWQEtkq" name="project.bom_import.validation.details.invalid">
<segment>
<source>project.bom_import.validation.details.invalid</source>
<target>Invalid</target>
</segment>
</unit>
<unit id="tPn6Ind" name="project.bom_import.validation.all_valid">
<segment>
<source>project.bom_import.validation.all_valid</source>
<target>All entries are valid and ready for import!</target>
</segment>
</unit>
<unit id="fzoGCaf" name="project.bom_import.validation.fix_errors">
<segment>
<source>project.bom_import.validation.fix_errors</source>
<target>Please fix the validation errors before proceeding with the import.</target>
</segment>
</unit>
<unit id="nc9MqUc" name="project.bom_import.type.generic_csv">
<segment>
<source>project.bom_import.type.generic_csv</source>
<target>Generic CSV</target>
</segment>
</unit>
</file>
</xliff>