Merge branch 'Part-DB:master' into feature/initial-admin-api-key

This commit is contained in:
Ryan Malloy 2025-09-07 10:09:53 -06:00 committed by GitHub
commit f9e7fb4491
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 4987 additions and 1216 deletions

View file

@ -56,12 +56,16 @@ export default class MarkdownController extends Controller {
this.element.innerHTML = DOMPurify.sanitize(MarkdownController._marked.parse(this.unescapeHTML(raw)));
for(let a of this.element.querySelectorAll('a')) {
//Mark all links as external
a.classList.add('link-external');
//Open links in new tag
a.setAttribute('target', '_blank');
//Dont track
a.setAttribute('rel', 'noopener');
// test if link is absolute
var r = new RegExp('^(?:[a-z+]+:)?//', 'i');
if (r.test(a.getAttribute('href'))) {
//Mark all links as external
a.classList.add('link-external');
//Open links in new tag
a.setAttribute('target', '_blank');
//Dont track
a.setAttribute('rel', 'noopener');
}
}
//Apply bootstrap styles to tables
@ -108,4 +112,4 @@ export default class MarkdownController extends Controller {
gfm: true,
});
}*/
}
}

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

@ -71,6 +71,8 @@
--ck-color-button-on-hover-background: var(--bs-secondary-bg);
--ck-color-button-on-active-background: var(--bs-secondary-bg);
--ck-color-button-on-disabled-background: var(--bs-secondary-bg);
--ck-color-button-on-color: var(--bs-primary)
--ck-color-button-on-color: var(--bs-primary);
}
--ck-content-font-color: var(--ck-color-base-text);
}

View file

@ -25,8 +25,7 @@
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^3.2.0",
"dompdf/dompdf": "^v3.0.0",
"florianv/swap": "^4.0",
"florianv/swap-bundle": "dev-master",
"part-db/swap-bundle": "^6.0.0",
"gregwar/captcha-bundle": "^2.1.0",
"hshn/base64-encoded-file": "^5.0",
"jbtronics/2fa-webauthn": "^3.0.0",

651
composer.lock generated

File diff suppressed because it is too large Load diff

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

@ -5,6 +5,12 @@ florianv_swap:
providers:
european_central_bank: ~ # European Central Bank (only works for EUR base currency)
fixer: # Fixer.io (needs an API key)
access_key: "%env(string:default:settings:exchange_rate:fixerApiKey:INVALID)%"
#exchange_rates_api: ~
central_bank_of_czech_republic: ~
central_bank_of_republic_turkey: ~
national_bank_of_romania: ~
fixer: # Fixer.io (needs an API key)
access_key: "%env(string:settings:exchange_rate:fixerApiKey)%"
frankfurter: ~
fawazahmed_currency_api: ~

View file

@ -359,6 +359,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
label: "perm.revert_elements"
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles', 'delete_profiles']
apiTokenRole: ROLE_API_EDIT
import:
label: "perm.import"
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles' ]
apiTokenRole: ROLE_API_EDIT
api:
label: "perm.api"

View file

@ -28,9 +28,14 @@ It is recommended to install Part-DB on a 64-bit system, as the 32-bit version o
For the installation of Part-DB, we need some prerequisites. They can be installed by running the following command:
```bash
sudo apt install git curl zip ca-certificates software-properties-common apt-transport-https lsb-release nano wget
sudo apt update && apt upgrade
sudo apt install git curl zip ca-certificates software-properties-common \
apt-transport-https lsb-release nano wget sqlite3
```
Please run `sqlite3 --version` to assert that the SQLite version is 3.35 or higher.
Otherwise some database migrations will not succeed.
### Install PHP and apache2
Part-DB is written in [PHP](https://php.net) and therefore needs a PHP interpreter to run. Part-DB needs PHP 8.2 or

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

@ -58,12 +58,15 @@ use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/label')]
class LabelController extends AbstractController
{
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator)
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator,
private readonly ValidatorInterface $validator
)
{
}
@ -85,6 +88,7 @@ class LabelController extends AbstractController
$form = $this->createForm(LabelDialogType::class, null, [
'disable_options' => $disable_options,
'profile' => $profile
]);
//Try to parse given target_type and target_id
@ -120,13 +124,50 @@ class LabelController extends AbstractController
goto render;
}
$profile = new LabelProfile();
$profile->setName($form->get('save_profile_name')->getData());
$profile->setOptions($form_options);
$this->em->persist($profile);
$new_profile = new LabelProfile();
$new_profile->setName($form->get('save_profile_name')->getData());
$new_profile->setOptions($form_options);
//Validate the profile name
$errors = $this->validator->validate($new_profile);
if (count($errors) > 0) {
foreach ($errors as $error) {
$form->get('save_profile_name')->addError(new FormError($error->getMessage()));
}
goto render;
}
$this->em->persist($new_profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_saved');
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $new_profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()
]);
}
//Check if the current profile should be updated
if ($form->has('update_profile')
&& $form->get('update_profile')->isClicked() //@phpstan-ignore-line Phpstan does not recognize the isClicked method
&& $profile instanceof LabelProfile
&& $this->isGranted('edit', $profile)) {
//Update the profile options
$profile->setOptions($form_options);
//Validate the profile name
$errors = $this->validator->validate($profile);
if (count($errors) > 0) {
foreach ($errors as $error) {
$this->addFlash('error', $error->getMessage());
}
goto render;
}
$this->em->persist($profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_updated');
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()

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

@ -0,0 +1,64 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\PriceInformations\Currency;
use Brick\Math\BigDecimal;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class CurrencyFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
$currency1 = new Currency();
$currency1->setName('US-Dollar');
$currency1->setIsoCode('USD');
$manager->persist($currency1);
$currency2 = new Currency();
$currency2->setName('Swiss Franc');
$currency2->setIsoCode('CHF');
$currency2->setExchangeRate(BigDecimal::of('0.91'));
$manager->persist($currency2);
$currency3 = new Currency();
$currency3->setName('Great British Pound');
$currency3->setIsoCode('GBP');
$currency3->setExchangeRate(BigDecimal::of('0.78'));
$manager->persist($currency3);
$currency7 = new Currency();
$currency7->setName('Test Currency with long name');
$currency7->setIsoCode('CNY');
$manager->persist($currency7);
$manager->flush();
//Ensure that currency 7 gets ID 7
$manager->getRepository(Currency::class)->changeID($currency7, 7);
$manager->flush();
}
}

View file

@ -87,6 +87,16 @@ class LabelDialogType extends AbstractType
]
]);
if ($options['profile'] !== null) {
$builder->add('update_profile', SubmitType::class, [
'label' => 'label_generator.update_profile',
'disabled' => !$this->security->isGranted('edit', $options['profile']),
'attr' => [
'class' => 'btn btn-outline-success'
]
]);
}
$builder->add('update', SubmitType::class, [
'label' => 'label_generator.update',
]);
@ -97,5 +107,6 @@ class LabelDialogType extends AbstractType
parent::configureOptions($resolver);
$resolver->setDefault('mapped', false);
$resolver->setDefault('disable_options', false);
$resolver->setDefault('profile', null);
}
}

View file

@ -41,6 +41,7 @@ use App\Entity\Attachments\UserAttachment;
use RuntimeException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array;
@ -56,7 +57,7 @@ final class AttachmentVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//This voter only works for attachments
@ -65,7 +66,8 @@ final class AttachmentVoter extends Voter
}
if ($attribute === 'show_private') {
return $this->helper->isGranted($token, 'attachments', 'show_private');
$vote?->addReason('User is not allowed to view private attachments.');
return $this->helper->isGranted($token, 'attachments', 'show_private', $vote);
}
@ -111,7 +113,8 @@ final class AttachmentVoter extends Voter
throw new RuntimeException('Encountered unknown Parameter type: ' . $subject);
}
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute));
$vote?->addReason('User is not allowed to '.$this->mapOperation($attribute).' attachments of type '.$param.'.');
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute), $vote);
}
return false;

View file

@ -25,6 +25,7 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\Group;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -43,9 +44,9 @@ final class GroupVoter extends Voter
*
* @param string $attribute
*/
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return $this->helper->isGranted($token, 'groups', $attribute);
return $this->helper->isGranted($token, 'groups', $attribute, $vote);
}
/**

View file

@ -26,6 +26,7 @@ namespace App\Security\Voter;
use App\Entity\UserSystem\User;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
@ -47,9 +48,16 @@ final class ImpersonateUserVoter extends Voter
&& $subject instanceof UserInterface;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return $this->helper->isGranted($token, 'users', 'impersonate');
$result = $this->helper->isGranted($token, 'users', 'impersonate');
if ($result === false) {
$vote?->addReason('User is not allowed to impersonate other users.');
$this->helper->addReason($vote, 'users', 'impersonate');
}
return $result;
}
public function supportsAttribute(string $attribute): bool
@ -61,4 +69,4 @@ final class ImpersonateUserVoter extends Voter
{
return is_a($subjectType, User::class, true);
}
}
}

View file

@ -44,6 +44,7 @@ namespace App\Security\Voter;
use App\Entity\LabelSystem\LabelProfile;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -58,14 +59,15 @@ final class LabelProfileVoter extends Voter
'delete' => 'delete_profiles',
'show_history' => 'show_history',
'revert_element' => 'revert_element',
'import' => 'import',
];
public function __construct(private readonly VoterHelper $helper)
{}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute]);
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute], $vote);
}
protected function supports($attribute, $subject): bool

View file

@ -26,6 +26,7 @@ use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\LogSystem\AbstractLogEntry;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -39,7 +40,7 @@ final class LogEntryVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
@ -48,19 +49,19 @@ final class LogEntryVoter extends Voter
}
if ('delete' === $attribute) {
return $this->helper->isGranted($token, 'system', 'delete_logs');
return $this->helper->isGranted($token, 'system', 'delete_logs', $vote);
}
if ('read' === $attribute) {
//Allow read of the users own log entries
if (
$subject->getUser() === $user
&& $this->helper->isGranted($token, 'self', 'show_logs')
&& $this->helper->isGranted($token, 'self', 'show_logs', $vote)
) {
return true;
}
return $this->helper->isGranted($token, 'system', 'show_logs');
return $this->helper->isGranted($token, 'system', 'show_logs', $vote);
}
if ('show_details' === $attribute) {

View file

@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Orderdetail;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -59,7 +60,7 @@ final class OrderdetailVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (! is_a($subject, Orderdetail::class, true)) {
throw new \RuntimeException('This voter can only handle Orderdetail objects!');
@ -75,7 +76,7 @@ final class OrderdetailVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View file

@ -39,6 +39,7 @@ use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
use RuntimeException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -53,7 +54,7 @@ final class ParameterVoter extends Voter
{
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
@ -108,7 +109,7 @@ final class ParameterVoter extends Voter
throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? $subject::class : $subject));
}
return $this->helper->isGranted($token, $param, $attribute);
return $this->helper->isGranted($token, $param, $attribute, $vote);
}
protected function supports(string $attribute, $subject): bool

View file

@ -46,6 +46,7 @@ use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -61,7 +62,7 @@ final class PartAssociationVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
if (!is_string($subject) && !$subject instanceof PartAssociation) {
throw new \RuntimeException('Invalid subject type!');
@ -77,7 +78,7 @@ final class PartAssociationVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOwner() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View file

@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -59,13 +60,13 @@ final class PartLotVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
if (in_array($attribute, ['withdraw', 'add', 'move'], true))
{
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute);
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);
$lot_permission = true;
//If the lot has an owner, we need to check if the user is the owner of the lot to be allowed to withdraw it.
@ -73,6 +74,10 @@ final class PartLotVoter extends Voter
$lot_permission = $subject->getOwner() === $user || $subject->getOwner()->getID() === $user->getID();
}
if (!$lot_permission) {
$vote->addReason('User is not the owner of the lot.');
}
return $base_permission && $lot_permission;
}
@ -86,7 +91,7 @@ final class PartLotVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View file

@ -25,6 +25,7 @@ namespace App\Security\Voter;
use App\Entity\Parts\Part;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -52,10 +53,9 @@ final class PartVoter extends Voter
return false;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
//Null concealing operator means, that no
return $this->helper->isGranted($token, 'parts', $attribute);
return $this->helper->isGranted($token, 'parts', $attribute, $vote);
}
public function supportsAttribute(string $attribute): bool

View file

@ -24,6 +24,7 @@ namespace App\Security\Voter;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -39,12 +40,17 @@ final class PermissionVoter extends Voter
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$attribute = ltrim($attribute, '@');
[$perm, $op] = explode('.', $attribute);
return $this->helper->isGranted($token, $perm, $op);
$result = $this->helper->isGranted($token, $perm, $op);
if ($result === false) {
$this->helper->addReason($vote, $perm, $op);
}
return $result;
}
public function supportsAttribute(string $attribute): bool

View file

@ -47,6 +47,7 @@ use App\Entity\PriceInformations\Orderdetail;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Pricedetail;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
@ -60,7 +61,7 @@ final class PricedetailVoter extends Voter
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$operation = match ($attribute) {
'read' => 'read',
@ -72,7 +73,7 @@ final class PricedetailVoter extends Voter
//If we have no part associated use the generic part permission
if (is_string($subject) || !$subject->getOrderdetail() instanceof Orderdetail || !$subject->getOrderdetail()->getPart() instanceof Part) {
return $this->helper->isGranted($token, 'parts', $operation);
return $this->helper->isGranted($token, 'parts', $operation, $vote);
}
//Otherwise vote on the part

View file

@ -33,6 +33,7 @@ use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function is_object;
@ -113,10 +114,10 @@ final class StructureVoter extends Voter
*
* @param string $attribute
*/
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$permission_name = $this->instanceToPermissionName($subject);
//Just resolve the permission
return $this->helper->isGranted($token, $permission_name, $attribute);
return $this->helper->isGranted($token, $permission_name, $attribute, $vote);
}
}

View file

@ -26,6 +26,7 @@ use App\Entity\UserSystem\User;
use App\Services\UserSystem\PermissionManager;
use App\Services\UserSystem\VoterHelper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array;
@ -79,7 +80,7 @@ final class UserVoter extends Voter
*
* @param string $attribute
*/
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->helper->resolveUser($token);
@ -97,7 +98,7 @@ final class UserVoter extends Voter
if (($subject instanceof User) && $subject->getID() === $user->getID() &&
$this->helper->isValidOperation('self', $attribute)) {
//Then we also need to check the self permission
$tmp = $this->helper->isGranted($token, 'self', $attribute);
$tmp = $this->helper->isGranted($token, 'self', $attribute, $vote);
//But if the self value is not allowed then use just the user value:
if ($tmp) {
return $tmp;
@ -106,7 +107,7 @@ final class UserVoter extends Voter
//Else just check user permission:
if ($this->helper->isValidOperation('users', $attribute)) {
return $this->helper->isGranted($token, 'users', $attribute);
return $this->helper->isGranted($token, 'users', $attribute, $vote);
}
return false;

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

@ -123,11 +123,11 @@ class LCSCProvider implements InfoProviderInterface
*/
private function queryByTerm(string $term): array
{
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/search/global", [
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
'headers' => [
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
],
'query' => [
'json' => [
'keyword' => $term,
],
]);
@ -165,6 +165,9 @@ class LCSCProvider implements InfoProviderInterface
if ($field === null) {
return null;
}
// Replace "range" indicators with mathematical tilde symbols
// so they don't get rendered as strikethrough by Markdown
$field = preg_replace("/~/", "\u{223c}", $field);
return strip_tags($field);
}
@ -197,9 +200,6 @@ class LCSCProvider implements InfoProviderInterface
$category = $product['parentCatalogName'] ?? null;
if (isset($product['catalogName'])) {
$category = ($category ?? '') . ' -> ' . $product['catalogName'];
// Replace the / with a -> for better readability
$category = str_replace('/', ' -> ', $category);
}
return new PartDetailDTO(

View file

@ -158,7 +158,8 @@ class PollinProvider implements InfoProviderInterface
category: $this->parseCategory($dom),
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
//TODO: Find another way to determine the manufacturing status, as the itemprop="availability" is often is not existing anymore in the page
//manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
provider_url: $productPageUrl,
notes: $this->parseNotes($dom),
datasheets: $this->parseDatasheets($dom),

View file

@ -26,6 +26,8 @@ use App\Entity\PriceInformations\Currency;
use App\Settings\SystemSettings\LocalizationSettings;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Exchanger\Exception\UnsupportedCurrencyPairException;
use Exchanger\Exception\UnsupportedExchangeQueryException;
use Swap\Swap;
class ExchangeRateUpdater
@ -39,15 +41,21 @@ class ExchangeRateUpdater
*/
public function update(Currency $currency): Currency
{
//Currency pairs are always in the format "BASE/QUOTE"
$rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
//The rate says how many quote units are worth one base unit
//So we need to invert it to get the exchange rate
try {
//Try it in the direction QUOTE/BASE first, as most providers provide rates in this direction
$rate = $this->swap->latest($currency->getIsoCode().'/'.$this->localizationSettings->baseCurrency);
$effective_rate = BigDecimal::of($rate->getValue());
} catch (UnsupportedCurrencyPairException|UnsupportedExchangeQueryException $exception) {
//Otherwise try to get it inverse and calculate it ourselfes, from the format "BASE/QUOTE"
$rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
//The rate says how many quote units are worth one base unit
//So we need to invert it to get the exchange rate
$rate_bd = BigDecimal::of($rate->getValue());
$rate_inverted = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
$rate_bd = BigDecimal::of($rate->getValue());
$effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
}
$currency->setExchangeRate($rate_inverted);
$currency->setExchangeRate($effective_rate);
return $currency;
}

View file

@ -28,6 +28,9 @@ use App\Repository\UserRepository;
use App\Security\ApiTokenAuthenticatedToken;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @see \App\Tests\Services\UserSystem\VoterHelperTest
@ -35,10 +38,14 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
final class VoterHelper
{
private readonly UserRepository $userRepository;
private readonly array $permissionStructure;
public function __construct(private readonly PermissionManager $permissionManager, private readonly EntityManagerInterface $entityManager)
public function __construct(private readonly PermissionManager $permissionManager,
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $entityManager)
{
$this->userRepository = $this->entityManager->getRepository(User::class);
$this->permissionStructure = $this->permissionManager->getPermissionStructure();
}
/**
@ -47,11 +54,16 @@ final class VoterHelper
* @param TokenInterface $token The token to check
* @param string $permission The permission to check
* @param string $operation The operation to check
* @param Vote|null $vote The vote object to add reasons to (optional). If null, no reasons are added.
* @return bool
*/
public function isGranted(TokenInterface $token, string $permission, string $operation): bool
public function isGranted(TokenInterface $token, string $permission, string $operation, ?Vote $vote = null): bool
{
return $this->isGrantedTrinary($token, $permission, $operation) ?? false;
$tmp = $this->isGrantedTrinary($token, $permission, $operation) ?? false;
if ($tmp === false) {
$this->addReason($vote, $permission, $operation);
}
return $tmp;
}
/**
@ -124,4 +136,17 @@ final class VoterHelper
{
return $this->permissionManager->isValidOperation($permission, $operation);
}
}
public function addReason(?Vote $voter, string $permission, $operation): void
{
if ($voter !== null) {
$voter->addReason(sprintf("User does not have permission %s -> %s -> %s (%s.%s).",
$this->translator->trans('perm.group.'.($this->permissionStructure['perms'][$permission]['group'] ?? 'unknown') ),
$this->translator->trans($this->permissionStructure['perms'][$permission]['label'] ?? $permission),
$this->translator->trans($this->permissionStructure['perms'][$permission]['operations'][$operation]['label'] ?? $operation),
$permission,
$operation
));
}
}
}

View file

@ -133,15 +133,6 @@
"ekino/phpstan-banned-code": {
"version": "v0.3.1"
},
"florianv/exchanger": {
"version": "1.4.1"
},
"florianv/swap": {
"version": "3.5.0"
},
"florianv/swap-bundle": {
"version": "5.0.x-dev"
},
"gregwar/captcha": {
"version": "v1.1.7"
},
@ -254,6 +245,9 @@
"./config/packages/datatables.yaml"
]
},
"part-db/swap-bundle": {
"version": "v6.0.0"
},
"php-http/discovery": {
"version": "1.18",
"recipe": {

View file

@ -1,6 +1,9 @@
{% extends "bundles/TwigBundle/Exception/error.html.twig" %}
{% block status_comment %}
Nice try! But you are not allowed to do this!
Nice try! But you are not allowed to do this!<br>
<code>{{ exception.message }}</code>
<br> <small>If you think you should have access to this ressource, contact the adminstrator.</small>
{% endblock %}
{% endblock %}

View file

@ -23,7 +23,7 @@
</div>
<div class="col-6">
{% if provider.providerInfo.settings_class is defined %}
<a href="{{ path('info_providers_provider_settings', {'provider': provider.providerKey}) }}" class="btn btn-primary btn-sm"
<a href="{{ path('info_providers_provider_settings', {'provider': provider.providerKey}) }}" class="btn btn-primary btn-sm {% if not is_granted('@config.change_system_settings') %}disabled{% endif %}"
title="{% trans %}info_providers.settings.title{% endtrans %}"
><i class="fa-solid fa-cog"></i></a>
{% endif %}

View file

@ -100,6 +100,10 @@
</div>
{% endif %}
{% if form.update_profile is defined %}
{{ form_row(form.update_profile) }}
{% endif %}
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<div class="input-group">
@ -133,4 +137,4 @@
</object>
</div>
{% endif %}
{% endblock %}
{% endblock %}

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

@ -36,7 +36,7 @@ class CurrencyEndpointTest extends CrudEndpointTestCase
{
$this->_testGetCollection();
self::assertJsonContains([
'hydra:totalItems' => 0,
'hydra:totalItems' => 4, //The 4 currencies from our fixtures
]);
}
@ -45,7 +45,7 @@ class CurrencyEndpointTest extends CrudEndpointTestCase
{
$this->_testPostItem([
'name' => 'Test API',
'iso_code' => 'USD',
'iso_code' => 'CAD',
]);
}
@ -61,4 +61,4 @@ class CurrencyEndpointTest extends CrudEndpointTestCase
{
$this->_testDeleteItem(5);
}*/
}
}

View file

@ -0,0 +1,35 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2020 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Controller\AdminPages;
use App\Entity\PriceInformations\Currency;
use PHPUnit\Framework\Attributes\Group;
use App\Entity\Parts\Manufacturer;
#[Group('slow')]
#[Group('DB')]
class CurrencyController extends AbstractAdminController
{
protected static string $base_path = '/en/currency';
protected static string $entity_class = Currency::class;
}

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

@ -580,7 +580,7 @@
</notes>
<segment state="translated">
<source>storelocation.new</source>
<target>Nové místo skladování</target>
<target>Nové umístění</target>
</segment>
</unit>
<unit id="Rt3eY_7" name="supplier.caption">
@ -913,7 +913,7 @@ Související prvky budou přesunuty nahoru.</target>
</notes>
<segment state="translated">
<source>edit.log_comment</source>
<target>Změnit komentář</target>
<target>Komentář ke změně</target>
</segment>
</unit>
<unit id="ZMmz8UB" name="entity.delete.recursive">
@ -2502,7 +2502,7 @@ Související prvky budou přesunuty nahoru.</target>
</notes>
<segment state="translated">
<source>part.needs_review.badge</source>
<target>Potřeba revize</target>
<target>Vyžaduje kontrolu</target>
</segment>
</unit>
<unit id="IttGv57" name="part.favorite.badge">
@ -4019,7 +4019,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn
</notes>
<segment state="translated">
<source>search.regexmatching</source>
<target>RegEx. shoda</target>
<target>Reg.Ex. shoda</target>
</segment>
</unit>
<unit id="U5IhkwB" name="search.submit">
@ -4858,7 +4858,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn
</notes>
<segment state="translated">
<source>part.table.needsReview</source>
<target>Potřeba revize</target>
<target>Vyžaduje kontrolu</target>
</segment>
</unit>
<unit id="AtzzLFz" name="part.table.favorite">
@ -5662,7 +5662,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn
</notes>
<segment state="translated">
<source>part.edit.needs_review</source>
<target>Potřeba revize</target>
<target>Vyžaduje kontrolu</target>
</segment>
</unit>
<unit id="TQbwkUd" name="part.edit.is_favorite">
@ -6357,7 +6357,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn
</notes>
<segment state="translated">
<source>user.theme.label</source>
<target>Téma</target>
<target>Vzhled</target>
</segment>
</unit>
<unit id="LQ7ihIX" name="user_settings.theme.placeholder">
@ -6368,7 +6368,7 @@ Pokud jste to provedli nesprávně nebo pokud počítač již není důvěryhodn
</notes>
<segment state="translated">
<source>user_settings.theme.placeholder</source>
<target>Serverové téma</target>
<target>Vzhled pro celý server</target>
</segment>
</unit>
<unit id="ZkXKucz" name="log.user_login.ip">
@ -9718,7 +9718,7 @@ Element 3</target>
<unit id="OEVzOkv" name="part_list.action.action.group.needs_review">
<segment state="translated">
<source>part_list.action.action.group.needs_review</source>
<target>Potřeba revize</target>
<target>Vyžaduje kontrolu</target>
</segment>
</unit>
<unit id="nkoTW_w" name="part_list.action.action.set_needs_review">
@ -10678,7 +10678,7 @@ Element 3</target>
<unit id="VraT.Lo" name="log.element_edited.changed_fields.theme">
<segment state="translated">
<source>log.element_edited.changed_fields.theme</source>
<target>Téma</target>
<target>Vzhled</target>
</segment>
</unit>
<unit id="QFESysH" name="log.element_edited.changed_fields.timezone">
@ -10774,7 +10774,7 @@ Element 3</target>
<unit id="iF9ovqi" name="log.element_edited.changed_fields.needs_review">
<segment state="translated">
<source>log.element_edited.changed_fields.needs_review</source>
<target>Potřeba revize</target>
<target>Vyžaduje kontrolu</target>
</segment>
</unit>
<unit id="wgJmpYG" name="log.element_edited.changed_fields.tags">
@ -10984,7 +10984,7 @@ Element 3</target>
<unit id="awbvhVq" name="parts.import.help">
<segment state="translated">
<source>parts.import.help</source>
<target>Pomocí tohoto nástroje můžete importovat díly z existujících souborů. Díly budou zapsány přímo do databáze, proto před nahráním souboru sem zkontrolujte, zda je jeho obsah správný.</target>
<target>Pomocí tohoto nástroje můžete importovat součásti z existujících souborů. Součásti budou přímo zapsány do databáze, proto před nahráním souboru zkontrolujte jeho správný obsah.</target>
</segment>
</unit>
<unit id="5.sq5ns" name="parts.import.flash.success">
@ -11014,7 +11014,7 @@ Element 3</target>
<unit id="7dsEiOg" name="parts.import.part_needs_review.help">
<segment state="translated">
<source>parts.import.part_needs_review.help</source>
<target>Pokud je tato možnost vybrána, budou všechny díly označeny jako "Potřeba revize" bez ohledu na to, co bylo nastaveno v údajích.</target>
<target>Pokud je tato možnost vybrána, budou všechny díly označeny jako "Vyžaduje kontrolu" bez ohledu na to, co bylo nastaveno v údajích.</target>
</segment>
</unit>
<unit id="Ie9LLKJ" name="project.bom_import.flash.success">
@ -12060,7 +12060,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="duBTELg" name="part.info.withdraw_modal.delete_lot_if_empty">
<segment state="translated">
<source>part.info.withdraw_modal.delete_lot_if_empty</source>
<target>Vymazat tento inventář, až se vyprázdní</target>
<target>Smazat tuto položku, pokud se vyprázdní</target>
</segment>
</unit>
<unit id="SMclulD" name="info_providers.search.error.client_exception">
@ -12528,7 +12528,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="VYMqQr5" name="settings.system.customization.instanceName">
<segment state="translated">
<source>settings.system.customization.instanceName</source>
<target>Instance name</target>
<target>Název instance</target>
</segment>
</unit>
<unit id="0YFxSHZ" name="settings.system.customization.instanceName.help">
@ -12576,7 +12576,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="pinyqu2" name="settings.system.customization.theme">
<segment state="translated">
<source>settings.system.customization.theme</source>
<target>Globální téma</target>
<target>Globální vzhed</target>
</segment>
</unit>
<unit id="Aky9nXE" name="settings.system.history.enforceComments">
@ -12642,7 +12642,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="8IszKgp" name="settings.system.privacy.useGravatar.description">
<segment state="translated">
<source>settings.system.privacy.useGravatar.description</source>
<target>Pokud uživatel nemá nastavený avatar, použijte avatar z Gravataru na základě e-mailové adresy uživatele. To způsobí, že prohlížeč načte obrázky od třetí strany!</target>
<target>Pokud uživatel nemá zadaný obrázek avatara, použije se avatar z Gravataru na základě e-mailu uživatele. To způsobí, že prohlížeč načte obrázky ze třetí strany!</target>
</segment>
</unit>
<unit id="rxHBzbv" name="settings.system.privacy.checkForUpdates">
@ -12691,7 +12691,7 @@ Vezměte prosím na vědomí, že se nemůžete vydávat za uživatele se zakáz
<unit id="cvpTUeY" name="settings.system.privacy">
<segment state="translated">
<source>settings.system.privacy</source>
<target>Ochrana osobních údajů</target>
<target>Soukromí</target>
</segment>
</unit>
<unit id="TVAVZUl" name="settings.title">

View file

@ -6504,7 +6504,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
</notes>
<segment state="translated">
<source>flash.password_change_needed</source>
<target>Ihr Password muss geändert werden!</target>
<target>Ihr Passwort muss geändert werden!</target>
</segment>
</unit>
<unit id="8I8zHPK" name="attachment.table.type">
@ -7157,8 +7157,14 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
<segment state="translated">
<source>mass_creation.lines.placeholder</source>
<target>Element 1
Element 1.1
Element 1.1.1
Element 1.2
Element 2
Element 3</target>
Element 3
Element 1 -&gt; Element 1.1
Element 1 -&gt; Element 1.2</target>
</segment>
</unit>
<unit id="TWSqPFi" name="entity.mass_creation.btn">
@ -9006,7 +9012,7 @@ Element 3</target>
<unit id="gaoMsrY" name="part_list.action.part_count">
<segment state="translated">
<source>part_list.action.part_count</source>
<target>%count% Bauteile ausgewählt!</target>
<target>%count% Bauteile ausgewählt</target>
</segment>
</unit>
<unit id="FhdheZY" name="company.edit.quick.website">
@ -12921,7 +12927,7 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
<unit id="TEm7uIg" name="settings.behavior.sidebar.rootNodeRedirectsToNewEntity">
<segment state="translated">
<source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source>
<target>Wurzelknoten leitet zur Erstellung eines neuen Elements weiter</target>
<target>Stammknoten leitet zur Erstellung eines neuen Elements weiter</target>
</segment>
</unit>
<unit id="j7HiQ80" name="settings.ips.digikey">
@ -13050,5 +13056,389 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
<target>Aus Sicherheitsgründen ausgeblendet</target>
</segment>
</unit>
<unit id="J716Oh4" name="project.bom_import.map_fields">
<segment state="translated">
<source>project.bom_import.map_fields</source>
<target>Spalten zuordnen</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>Wählen Sie aus, wie CSV Spalten auf BOM Felder gemappt werden</target>
</segment>
</unit>
<unit id="nh7uLPe" name="project.bom_import.delimiter">
<segment state="translated">
<source>project.bom_import.delimiter</source>
<target>Trennzeichen</target>
</segment>
</unit>
<unit id="MSlSeE4" name="project.bom_import.delimiter.comma">
<segment state="translated">
<source>project.bom_import.delimiter.comma</source>
<target>Komma (,)</target>
</segment>
</unit>
<unit id="UdDSB0h" name="project.bom_import.delimiter.semicolon">
<segment state="translated">
<source>project.bom_import.delimiter.semicolon</source>
<target>Semikolon (;)</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>Spaltenzuordnung</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 Spalte</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>Mappt auf</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>Vorschlag</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>Priorität</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>Priorität (kleinere Nummer = höhere Priorität)</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>Prioritätstipp: Niedrigere Zahlen = höhere Priorität. Die Standardpriorität ist 10. Verwenden Sie die Prioritäten 19 für die wichtigsten Felder und 10+ für normale Priorität.</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>Zusammenfassung der Zuordnung</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>Wählen Sie Zuordnungen aus, um eine Zusammenfassung anzuzeigen.</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>Kein Vorschlag</target>
</segment>
</unit>
<unit id="1LWEtqL" name="project.bom_import.preview">
<segment state="translated">
<source>project.bom_import.preview</source>
<target>Vorschau</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>Die Import-Sitzung ist abgelaufen. Bitte laden Sie Ihre Datei erneut hoch.</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>Ignorieren</target>
</segment>
</unit>
<unit id="yiIB3yV" name="project.bom_import.type.kicad_schematic">
<segment state="translated">
<source>project.bom_import.type.kicad_schematic</source>
<target>KiCAD Schaltplaneditor BOM (CSV Datei)</target>
</segment>
</unit>
<unit id="ltE6xPP" name="common.back">
<segment state="translated">
<source>common.back</source>
<target>Zurück</target>
</segment>
</unit>
<unit id="HCTqjZO" name="project.bom_import.validation.errors.required_field_missing">
<segment state="translated">
<source>project.bom_import.validation.errors.required_field_missing</source>
<target>Zeile %line%: Das Pflichtfeld „%field%“ fehlt oder ist leer. Bitte stellen Sie sicher, dass dieses Feld zugeordnet ist und Daten enthält.</target>
</segment>
</unit>
<unit id="_ua5YM7" name="project.bom_import.validation.errors.no_valid_designators">
<segment state="translated">
<source>project.bom_import.validation.errors.no_valid_designators</source>
<target>Zeile %line%: Das Bezeichnungsfeld enthält keine gültigen Komponentenreferenzen. Erwartetes Format: „R1,C2,U3“ oder „R1, C2, U3“.</target>
</segment>
</unit>
<unit id="xpkq3rW" name="project.bom_import.validation.warnings.unusual_designator_format">
<segment state="translated">
<source>project.bom_import.validation.warnings.unusual_designator_format</source>
<target>Zeile %line%: Einige Komponentenreferenzen haben möglicherweise ein ungewöhnliches Format: %designators%. Erwartetes Format: „R1“, „C2“, „U3“ usw.</target>
</segment>
</unit>
<unit id="M54Ud5d" name="project.bom_import.validation.errors.duplicate_designators">
<segment state="translated">
<source>project.bom_import.validation.errors.duplicate_designators</source>
<target>Zeile %line%: Doppelte Komponentenreferenzen gefunden: %designators%. Jede Komponente sollte nur einmal pro Zeile referenziert werden.</target>
</segment>
</unit>
<unit id="ZULFZh2" name="project.bom_import.validation.errors.invalid_quantity">
<segment state="translated">
<source>project.bom_import.validation.errors.invalid_quantity</source>
<target>Zeile %line%: Die Menge „%quantity%“ ist keine gültige Zahl. Bitte geben Sie einen numerischen Wert ein (z. B. 1, 2,5, 10).</target>
</segment>
</unit>
<unit id="3WbRgsl" name="project.bom_import.validation.errors.quantity_zero_or_negative">
<segment state="translated">
<source>project.bom_import.validation.errors.quantity_zero_or_negative</source>
<target>Zeile %line%: Die Menge muss größer als 0 sein, erhaltene Menge %quantity%.</target>
</segment>
</unit>
<unit id="_nWGsSU" name="project.bom_import.validation.warnings.quantity_unusually_high">
<segment state="translated">
<source>project.bom_import.validation.warnings.quantity_unusually_high</source>
<target>Zeile %line%: Die Menge %quantity% erscheint ungewöhnlich hoch. Bitte überprüfen Sie, ob dies korrekt ist.</target>
</segment>
</unit>
<unit id="iJWw39f" name="project.bom_import.validation.warnings.quantity_not_whole_number">
<segment state="translated">
<source>project.bom_import.validation.warnings.quantity_not_whole_number</source>
<target>Zeile %line%: Die Menge %quantity% ist keine ganze Zahl, aber Sie haben %count% Komponentenreferenzen. Dies kann auf eine Nichtübereinstimmung hindeuten.</target>
</segment>
</unit>
<unit id="Zffmgvv" name="project.bom_import.validation.errors.quantity_designator_mismatch">
<segment state="translated">
<source>project.bom_import.validation.errors.quantity_designator_mismatch</source>
<target>Zeile %line%: Diskrepanz zwischen Menge und Komponentenreferenzen. Menge: %quantity%, Referenzen: %count% (%designators%). Diese sollten übereinstimmen. Passen Sie entweder die Menge an oder überprüfen Sie Ihre Komponentenreferenzen.</target>
</segment>
</unit>
<unit id="JqHpDIo" name="project.bom_import.validation.errors.invalid_partdb_id">
<segment state="translated">
<source>project.bom_import.validation.errors.invalid_partdb_id</source>
<target>Zeile %line%: Part-DB ID „%id%“ ist keine gültige Zahl. Bitte geben Sie eine numerische ID ein.</target>
</segment>
</unit>
<unit id="Mr7W6r0" name="project.bom_import.validation.errors.partdb_id_zero_or_negative">
<segment state="translated">
<source>project.bom_import.validation.errors.partdb_id_zero_or_negative</source>
<target>Zeile %line%: Die Part-DB ID muss größer als 0 sein, erhaltene ID lautet %id%.</target>
</segment>
</unit>
<unit id="gRH6IeR" name="project.bom_import.validation.warnings.partdb_id_not_found">
<segment state="translated">
<source>project.bom_import.validation.warnings.partdb_id_not_found</source>
<target>Zeile %line%: Teil-DB-ID %id% nicht in der Datenbank gefunden. Die Komponente wird ohne Verknüpfung mit einem vorhandenen Teil importiert.</target>
</segment>
</unit>
<unit id="eMfaBYo" name="project.bom_import.validation.info.partdb_link_success">
<segment state="translated">
<source>project.bom_import.validation.info.partdb_link_success</source>
<target>Zeile %line%: Erfolgreich mit dem Bauteil „%name%“ (ID: %id%) verknüpft.</target>
</segment>
</unit>
<unit id="b_m9p.f" name="project.bom_import.validation.warnings.no_component_name">
<segment state="translated">
<source>project.bom_import.validation.warnings.no_component_name</source>
<target>Zeile %line%: Kein Komponentenname/keine Komponentenbezeichnung angegeben (MPN, Bezeichnung oder Wert). Die Komponente wird als „Unbekanntes Bauteil” bezeichnet.</target>
</segment>
</unit>
<unit id=".LAGdMe" name="project.bom_import.validation.warnings.package_name_too_long">
<segment state="translated">
<source>project.bom_import.validation.warnings.package_name_too_long</source>
<target>Zeile %line%: Der Footprintname „%package%“ ist ungewöhnlich lang. Bitte überprüfen Sie, ob er korrekt ist.</target>
</segment>
</unit>
<unit id="wFF49kl" name="project.bom_import.validation.info.library_prefix_detected">
<segment state="translated">
<source>project.bom_import.validation.info.library_prefix_detected</source>
<target>Zeile %line%: Das Footprint „%package%“ enthält ein Bibliothekspräfix. Dieses wird beim Import automatisch entfernt.</target>
</segment>
</unit>
<unit id="hEO2sxC" name="project.bom_import.validation.errors.non_numeric_field">
<segment state="translated">
<source>project.bom_import.validation.errors.non_numeric_field</source>
<target>Zeile %line%: Das Feld „%field%“ enthält den nicht numerischen Wert „%value%“. Bitte geben Sie eine gültige Zahl ein.</target>
</segment>
</unit>
<unit id="Ear_sUX" name="project.bom_import.validation.info.import_summary">
<segment state="translated">
<source>project.bom_import.validation.info.import_summary</source>
<target>Importübersicht: %total% Einträge insgesamt, %valid% gültig, %invalid% mit Problemen.</target>
</segment>
</unit>
<unit id="SqJZ97A" name="project.bom_import.validation.errors.summary">
<segment state="translated">
<source>project.bom_import.validation.errors.summary</source>
<target>Es wurden %count% Validierungsfehler gefunden, die behoben werden müssen, bevor der Import fortgesetzt werden kann.</target>
</segment>
</unit>
<unit id="s7dzFnp" name="project.bom_import.validation.warnings.summary">
<segment state="translated">
<source>project.bom_import.validation.warnings.summary</source>
<target>Es wurden %count% Warnungen gefunden. Bitte überprüfen Sie diese Probleme, bevor Sie fortfahren.</target>
</segment>
</unit>
<unit id="2KaAHmS" name="project.bom_import.validation.info.all_valid">
<segment state="translated">
<source>project.bom_import.validation.info.all_valid</source>
<target>Alle Einträge haben die Validierung erfolgreich bestanden!</target>
</segment>
</unit>
<unit id="Lyb3BjK" name="project.bom_import.validation.summary">
<segment state="translated">
<source>project.bom_import.validation.summary</source>
<target>Validierungsübersicht</target>
</segment>
</unit>
<unit id="DUM8GoE" name="project.bom_import.validation.total_entries">
<segment state="translated">
<source>project.bom_import.validation.total_entries</source>
<target>Gesamtzahl der Einträge</target>
</segment>
</unit>
<unit id="AtLGGU2" name="project.bom_import.validation.valid_entries">
<segment state="translated">
<source>project.bom_import.validation.valid_entries</source>
<target>Gültige Einträge</target>
</segment>
</unit>
<unit id="mgdn5v3" name="project.bom_import.validation.invalid_entries">
<segment state="translated">
<source>project.bom_import.validation.invalid_entries</source>
<target>Ungültige Einträge</target>
</segment>
</unit>
<unit id="qmJ5BfF" name="project.bom_import.validation.success_rate">
<segment state="translated">
<source>project.bom_import.validation.success_rate</source>
<target>Erfolgsquote</target>
</segment>
</unit>
<unit id="QyDsrFV" name="project.bom_import.validation.errors.title">
<segment state="translated">
<source>project.bom_import.validation.errors.title</source>
<target>Validierungsfehler</target>
</segment>
</unit>
<unit id="d.Mvu0a" name="project.bom_import.validation.errors.description">
<segment state="translated">
<source>project.bom_import.validation.errors.description</source>
<target>Die folgenden Fehler müssen behoben werden, bevor der Import fortgesetzt werden kann:</target>
</segment>
</unit>
<unit id="MGFfKr." name="project.bom_import.validation.warnings.title">
<segment state="translated">
<source>project.bom_import.validation.warnings.title</source>
<target>Validierungswarnungen</target>
</segment>
</unit>
<unit id="tt4VxY6" name="project.bom_import.validation.warnings.description">
<segment state="translated">
<source>project.bom_import.validation.warnings.description</source>
<target>Die folgenden Warnhinweise sollten vor dem Fortfahren gelesen werden:</target>
</segment>
</unit>
<unit id="YuAdYeh" name="project.bom_import.validation.info.title">
<segment state="translated">
<source>project.bom_import.validation.info.title</source>
<target>Informationen</target>
</segment>
</unit>
<unit id="1pUfi7Q" name="project.bom_import.validation.details.title">
<segment state="translated">
<source>project.bom_import.validation.details.title</source>
<target>Detaillierte Validierungsergebnisse</target>
</segment>
</unit>
<unit id="4Vv0Xns" name="project.bom_import.validation.details.line">
<segment state="translated">
<source>project.bom_import.validation.details.line</source>
<target>Zeile</target>
</segment>
</unit>
<unit id="RbvD2zF" name="project.bom_import.validation.details.status">
<segment state="translated">
<source>project.bom_import.validation.details.status</source>
<target>Status</target>
</segment>
</unit>
<unit id="iGtKkH_" name="project.bom_import.validation.details.messages">
<segment state="translated">
<source>project.bom_import.validation.details.messages</source>
<target>Meldungen</target>
</segment>
</unit>
<unit id="IgdgZd8" name="project.bom_import.validation.details.valid">
<segment state="translated">
<source>project.bom_import.validation.details.valid</source>
<target>Gültig</target>
</segment>
</unit>
<unit id="lWQEtkq" name="project.bom_import.validation.details.invalid">
<segment state="translated">
<source>project.bom_import.validation.details.invalid</source>
<target>Ungültig</target>
</segment>
</unit>
<unit id="tPn6Ind" name="project.bom_import.validation.all_valid">
<segment state="translated">
<source>project.bom_import.validation.all_valid</source>
<target>Alle Einträge sind gültig und bereit zum Import!</target>
</segment>
</unit>
<unit id="fzoGCaf" name="project.bom_import.validation.fix_errors">
<segment state="translated">
<source>project.bom_import.validation.fix_errors</source>
<target>Bitte beheben Sie die Validierungsfehler, bevor Sie mit dem Import fortfahren.</target>
</segment>
</unit>
<unit id="nc9MqUc" name="project.bom_import.type.generic_csv">
<segment state="translated">
<source>project.bom_import.type.generic_csv</source>
<target>Generische CSV-Datei</target>
</segment>
</unit>
<unit id=".N35Pvs" name="label_generator.update_profile">
<segment state="translated">
<source>label_generator.update_profile</source>
<target>Profil mit aktuellen Einstellungen aktualisieren</target>
</segment>
</unit>
<unit id="ulTo6Aa" name="label_generator.profile_updated">
<segment state="translated">
<source>label_generator.profile_updated</source>
<target>Labelprofil aktualisiert</target>
</segment>
</unit>
</file>
</xliff>

View file

@ -7164,8 +7164,8 @@ Exampletown</target>
Element 2
Element 3
Element 1 -&gt; Element 1.1
Element 1 -&gt; Element 1.2]]></target>
Element 1 -> Element 1.1
Element 1 -> Element 1.2]]></target>
</segment>
</unit>
<unit id="TWSqPFi" name="entity.mass_creation.btn">
@ -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,389 @@ 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>
<unit id=".N35Pvs" name="label_generator.update_profile">
<segment>
<source>label_generator.update_profile</source>
<target>Update profile with current settings</target>
</segment>
</unit>
<unit id="ulTo6Aa" name="label_generator.profile_updated">
<segment>
<source>label_generator.profile_updated</source>
<target>Label profile updated successfully.</target>
</segment>
</unit>
</file>
</xliff>

1518
yarn.lock

File diff suppressed because it is too large Load diff