mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-01-17 23:59:33 +00:00
Merge branch 'Part-DB:master' into feature/initial-admin-api-key
This commit is contained in:
commit
f9e7fb4491
56 changed files with 4987 additions and 1216 deletions
|
|
@ -56,12 +56,16 @@ export default class MarkdownController extends Controller {
|
||||||
this.element.innerHTML = DOMPurify.sanitize(MarkdownController._marked.parse(this.unescapeHTML(raw)));
|
this.element.innerHTML = DOMPurify.sanitize(MarkdownController._marked.parse(this.unescapeHTML(raw)));
|
||||||
|
|
||||||
for(let a of this.element.querySelectorAll('a')) {
|
for(let a of this.element.querySelectorAll('a')) {
|
||||||
//Mark all links as external
|
// test if link is absolute
|
||||||
a.classList.add('link-external');
|
var r = new RegExp('^(?:[a-z+]+:)?//', 'i');
|
||||||
//Open links in new tag
|
if (r.test(a.getAttribute('href'))) {
|
||||||
a.setAttribute('target', '_blank');
|
//Mark all links as external
|
||||||
//Dont track
|
a.classList.add('link-external');
|
||||||
a.setAttribute('rel', 'noopener');
|
//Open links in new tag
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
//Dont track
|
||||||
|
a.setAttribute('rel', 'noopener');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Apply bootstrap styles to tables
|
//Apply bootstrap styles to tables
|
||||||
|
|
@ -108,4 +112,4 @@ export default class MarkdownController extends Controller {
|
||||||
gfm: true,
|
gfm: true,
|
||||||
});
|
});
|
||||||
}*/
|
}*/
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export default class extends Controller {
|
||||||
selectOnTab: true,
|
selectOnTab: true,
|
||||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
//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',
|
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||||
|
dropdownParent: 'body',
|
||||||
render: {
|
render: {
|
||||||
item: (data, escape) => {
|
item: (data, escape) => {
|
||||||
return '<span>' + escape(data.label) + '</span>';
|
return '<span>' + escape(data.label) + '</span>';
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export default class extends Controller {
|
||||||
searchField: ["name", "description", "category", "footprint"],
|
searchField: ["name", "description", "category", "footprint"],
|
||||||
valueField: "id",
|
valueField: "id",
|
||||||
labelField: "name",
|
labelField: "name",
|
||||||
|
dropdownParent: 'body',
|
||||||
preload: "focus",
|
preload: "focus",
|
||||||
render: {
|
render: {
|
||||||
item: (data, escape) => {
|
item: (data, escape) => {
|
||||||
|
|
@ -71,4 +72,4 @@ export default class extends Controller {
|
||||||
//Destroy the TomSelect instance
|
//Destroy the TomSelect instance
|
||||||
this._tomSelect.destroy();
|
this._tomSelect.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export default class extends Controller {
|
||||||
allowEmptyOption: true,
|
allowEmptyOption: true,
|
||||||
selectOnTab: true,
|
selectOnTab: true,
|
||||||
maxOptions: null,
|
maxOptions: null,
|
||||||
|
dropdownParent: 'body',
|
||||||
|
|
||||||
render: {
|
render: {
|
||||||
item: this.renderItem.bind(this),
|
item: this.renderItem.bind(this),
|
||||||
|
|
@ -108,4 +109,4 @@ export default class extends Controller {
|
||||||
//Destroy the TomSelect instance
|
//Destroy the TomSelect instance
|
||||||
this._tomSelect.destroy();
|
this._tomSelect.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export default class extends Controller {
|
||||||
this._tomSelect = new TomSelect(this.element, {
|
this._tomSelect = new TomSelect(this.element, {
|
||||||
maxItems: 1000,
|
maxItems: 1000,
|
||||||
allowEmptyOption: true,
|
allowEmptyOption: true,
|
||||||
|
dropdownParent: 'body',
|
||||||
plugins: ['remove_button'],
|
plugins: ['remove_button'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -39,4 +40,4 @@ export default class extends Controller {
|
||||||
this._tomSelect.destroy();
|
this._tomSelect.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export default class extends Controller {
|
||||||
valueField: 'text',
|
valueField: 'text',
|
||||||
searchField: 'text',
|
searchField: 'text',
|
||||||
orderField: 'text',
|
orderField: 'text',
|
||||||
|
dropdownParent: 'body',
|
||||||
|
|
||||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
//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',
|
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export default class extends Controller {
|
||||||
maxItems: 1,
|
maxItems: 1,
|
||||||
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
|
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
|
||||||
splitOn: null,
|
splitOn: null,
|
||||||
|
dropdownParent: 'body',
|
||||||
|
|
||||||
searchField: [
|
searchField: [
|
||||||
{field: "text", weight : 2},
|
{field: "text", weight : 2},
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export default class extends Controller {
|
||||||
selectOnTab: true,
|
selectOnTab: true,
|
||||||
createOnBlur: true,
|
createOnBlur: true,
|
||||||
create: true,
|
create: true,
|
||||||
|
dropdownParent: 'body',
|
||||||
};
|
};
|
||||||
|
|
||||||
if(this.element.dataset.autocomplete) {
|
if(this.element.dataset.autocomplete) {
|
||||||
|
|
@ -73,4 +74,4 @@ export default class extends Controller {
|
||||||
//Destroy the TomSelect instance
|
//Destroy the TomSelect instance
|
||||||
this._tomSelect.destroy();
|
this._tomSelect.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@
|
||||||
--ck-color-button-on-hover-background: var(--bs-secondary-bg);
|
--ck-color-button-on-hover-background: var(--bs-secondary-bg);
|
||||||
--ck-color-button-on-active-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-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);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,7 @@
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.0",
|
"doctrine/doctrine-migrations-bundle": "^3.0",
|
||||||
"doctrine/orm": "^3.2.0",
|
"doctrine/orm": "^3.2.0",
|
||||||
"dompdf/dompdf": "^v3.0.0",
|
"dompdf/dompdf": "^v3.0.0",
|
||||||
"florianv/swap": "^4.0",
|
"part-db/swap-bundle": "^6.0.0",
|
||||||
"florianv/swap-bundle": "dev-master",
|
|
||||||
"gregwar/captcha-bundle": "^2.1.0",
|
"gregwar/captcha-bundle": "^2.1.0",
|
||||||
"hshn/base64-encoded-file": "^5.0",
|
"hshn/base64-encoded-file": "^5.0",
|
||||||
"jbtronics/2fa-webauthn": "^3.0.0",
|
"jbtronics/2fa-webauthn": "^3.0.0",
|
||||||
|
|
|
||||||
651
composer.lock
generated
651
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -69,9 +69,3 @@ nelmio_security:
|
||||||
- 'data:'
|
- 'data:'
|
||||||
block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport
|
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
|
# 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
|
|
||||||
|
|
@ -5,6 +5,12 @@ florianv_swap:
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
european_central_bank: ~ # European Central Bank (only works for EUR base currency)
|
european_central_bank: ~ # European Central Bank (only works for EUR base currency)
|
||||||
fixer: # Fixer.io (needs an API key)
|
central_bank_of_czech_republic: ~
|
||||||
access_key: "%env(string:default:settings:exchange_rate:fixerApiKey:INVALID)%"
|
central_bank_of_republic_turkey: ~
|
||||||
#exchange_rates_api: ~
|
national_bank_of_romania: ~
|
||||||
|
|
||||||
|
fixer: # Fixer.io (needs an API key)
|
||||||
|
access_key: "%env(string:settings:exchange_rate:fixerApiKey)%"
|
||||||
|
|
||||||
|
frankfurter: ~
|
||||||
|
fawazahmed_currency_api: ~
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
||||||
label: "perm.revert_elements"
|
label: "perm.revert_elements"
|
||||||
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles', 'delete_profiles']
|
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles', 'delete_profiles']
|
||||||
apiTokenRole: ROLE_API_EDIT
|
apiTokenRole: ROLE_API_EDIT
|
||||||
|
import:
|
||||||
|
label: "perm.import"
|
||||||
|
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles' ]
|
||||||
|
apiTokenRole: ROLE_API_EDIT
|
||||||
|
|
||||||
api:
|
api:
|
||||||
label: "perm.api"
|
label: "perm.api"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
For the installation of Part-DB, we need some prerequisites. They can be installed by running the following command:
|
||||||
|
|
||||||
```bash
|
```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
|
### 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
|
Part-DB is written in [PHP](https://php.net) and therefore needs a PHP interpreter to run. Part-DB needs PHP 8.2 or
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
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.
|
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
112
makefile
Normal 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!"
|
||||||
|
|
@ -58,12 +58,15 @@ use Symfony\Component\Form\FormError;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
#[Route(path: '/label')]
|
#[Route(path: '/label')]
|
||||||
class LabelController extends AbstractController
|
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, [
|
$form = $this->createForm(LabelDialogType::class, null, [
|
||||||
'disable_options' => $disable_options,
|
'disable_options' => $disable_options,
|
||||||
|
'profile' => $profile
|
||||||
]);
|
]);
|
||||||
|
|
||||||
//Try to parse given target_type and target_id
|
//Try to parse given target_type and target_id
|
||||||
|
|
@ -120,13 +124,50 @@ class LabelController extends AbstractController
|
||||||
goto render;
|
goto render;
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = new LabelProfile();
|
$new_profile = new LabelProfile();
|
||||||
$profile->setName($form->get('save_profile_name')->getData());
|
$new_profile->setName($form->get('save_profile_name')->getData());
|
||||||
$profile->setOptions($form_options);
|
$new_profile->setOptions($form_options);
|
||||||
$this->em->persist($profile);
|
|
||||||
|
//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->em->flush();
|
||||||
$this->addFlash('success', 'label_generator.profile_saved');
|
$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', [
|
return $this->redirectToRoute('label_dialog_profile', [
|
||||||
'profile' => $profile->getID(),
|
'profile' => $profile->getID(),
|
||||||
'target_id' => (string) $form->get('target_id')->getData()
|
'target_id' => (string) $form->get('target_id')->getData()
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use League\Csv\SyntaxError;
|
use League\Csv\SyntaxError;
|
||||||
use Omines\DataTablesBundle\DataTableFactory;
|
use Omines\DataTablesBundle\DataTableFactory;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
|
@ -102,9 +103,14 @@ class ProjectController extends AbstractController
|
||||||
$this->addFlash('success', 'project.build.flash.success');
|
$this->addFlash('success', 'project.build.flash.success');
|
||||||
|
|
||||||
return $this->redirect(
|
return $this->redirect(
|
||||||
$request->get('_redirect',
|
$request->get(
|
||||||
$this->generateUrl('project_info', ['id' => $project->getID()]
|
'_redirect',
|
||||||
)));
|
$this->generateUrl(
|
||||||
|
'project_info',
|
||||||
|
['id' => $project->getID()]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->addFlash('error', 'project.build.flash.invalid_input');
|
$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+'])]
|
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
|
||||||
public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project,
|
public function importBOM(
|
||||||
BOMImporter $BOMImporter, ValidatorInterface $validator): Response
|
Request $request,
|
||||||
{
|
EntityManagerInterface $entityManager,
|
||||||
|
Project $project,
|
||||||
|
BOMImporter $BOMImporter,
|
||||||
|
ValidatorInterface $validator
|
||||||
|
): Response {
|
||||||
$this->denyAccessUnlessGranted('edit', $project);
|
$this->denyAccessUnlessGranted('edit', $project);
|
||||||
|
|
||||||
$builder = $this->createFormBuilder();
|
$builder = $this->createFormBuilder();
|
||||||
|
|
@ -138,6 +148,8 @@ class ProjectController extends AbstractController
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'choices' => [
|
'choices' => [
|
||||||
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
|
'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, [
|
$builder->add('clear_existing_bom', CheckboxType::class, [
|
||||||
|
|
@ -161,25 +173,40 @@ class ProjectController extends AbstractController
|
||||||
$entityManager->flush();
|
$entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$import_type = $form->get('type')->getData();
|
||||||
|
|
||||||
try {
|
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, [
|
$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');
|
$errors = $validator->validateProperty($project, 'bom_entries');
|
||||||
|
|
||||||
//If no validation errors occured, save the changes and redirect to edit page
|
// If no validation errors occurred, save the changes and redirect to edit page
|
||||||
if (count ($errors) === 0) {
|
if (count($errors) === 0) {
|
||||||
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
|
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
|
||||||
$entityManager->flush();
|
$entityManager->flush();
|
||||||
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
|
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'));
|
$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()]));
|
$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: '/add_parts', name: 'project_add_parts_no_id')]
|
||||||
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
|
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
|
||||||
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
|
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
|
||||||
{
|
{
|
||||||
if($project instanceof Project) {
|
if ($project instanceof Project) {
|
||||||
$this->denyAccessUnlessGranted('edit', $project);
|
$this->denyAccessUnlessGranted('edit', $project);
|
||||||
} else {
|
} else {
|
||||||
$this->denyAccessUnlessGranted('@projects.edit');
|
$this->denyAccessUnlessGranted('@projects.edit');
|
||||||
|
|
@ -242,7 +525,7 @@ class ProjectController extends AbstractController
|
||||||
|
|
||||||
$data = $form->getData();
|
$data = $form->getData();
|
||||||
$bom_entries = $data['bom_entries'];
|
$bom_entries = $data['bom_entries'];
|
||||||
foreach ($bom_entries as $bom_entry){
|
foreach ($bom_entries as $bom_entry) {
|
||||||
$target_project->addBOMEntry($bom_entry);
|
$target_project->addBOMEntry($bom_entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
64
src/DataFixtures/CurrencyFixtures.php
Normal file
64
src/DataFixtures/CurrencyFixtures.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, [
|
$builder->add('update', SubmitType::class, [
|
||||||
'label' => 'label_generator.update',
|
'label' => 'label_generator.update',
|
||||||
]);
|
]);
|
||||||
|
|
@ -97,5 +107,6 @@ class LabelDialogType extends AbstractType
|
||||||
parent::configureOptions($resolver);
|
parent::configureOptions($resolver);
|
||||||
$resolver->setDefault('mapped', false);
|
$resolver->setDefault('mapped', false);
|
||||||
$resolver->setDefault('disable_options', false);
|
$resolver->setDefault('disable_options', false);
|
||||||
|
$resolver->setDefault('profile', null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ use App\Entity\Attachments\UserAttachment;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\Authorization\Voter\Voter;
|
||||||
|
|
||||||
use function in_array;
|
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
|
//This voter only works for attachments
|
||||||
|
|
@ -65,7 +66,8 @@ final class AttachmentVoter extends Voter
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($attribute === 'show_private') {
|
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);
|
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;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ namespace App\Security\Voter;
|
||||||
use App\Entity\UserSystem\Group;
|
use App\Entity\UserSystem\Group;
|
||||||
use App\Services\UserSystem\VoterHelper;
|
use App\Services\UserSystem\VoterHelper;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\Authorization\Voter\Voter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -43,9 +44,9 @@ final class GroupVoter extends Voter
|
||||||
*
|
*
|
||||||
* @param string $attribute
|
* @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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ namespace App\Security\Voter;
|
||||||
use App\Entity\UserSystem\User;
|
use App\Entity\UserSystem\User;
|
||||||
use App\Services\UserSystem\VoterHelper;
|
use App\Services\UserSystem\VoterHelper;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\Authorization\Voter\Voter;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
|
@ -47,9 +48,16 @@ final class ImpersonateUserVoter extends Voter
|
||||||
&& $subject instanceof UserInterface;
|
&& $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
|
public function supportsAttribute(string $attribute): bool
|
||||||
|
|
@ -61,4 +69,4 @@ final class ImpersonateUserVoter extends Voter
|
||||||
{
|
{
|
||||||
return is_a($subjectType, User::class, true);
|
return is_a($subjectType, User::class, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ namespace App\Security\Voter;
|
||||||
use App\Entity\LabelSystem\LabelProfile;
|
use App\Entity\LabelSystem\LabelProfile;
|
||||||
use App\Services\UserSystem\VoterHelper;
|
use App\Services\UserSystem\VoterHelper;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\Authorization\Voter\Voter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,14 +59,15 @@ final class LabelProfileVoter extends Voter
|
||||||
'delete' => 'delete_profiles',
|
'delete' => 'delete_profiles',
|
||||||
'show_history' => 'show_history',
|
'show_history' => 'show_history',
|
||||||
'revert_element' => 'revert_element',
|
'revert_element' => 'revert_element',
|
||||||
|
'import' => 'import',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(private readonly VoterHelper $helper)
|
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
|
protected function supports($attribute, $subject): bool
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ use App\Services\UserSystem\VoterHelper;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use App\Entity\LogSystem\AbstractLogEntry;
|
use App\Entity\LogSystem\AbstractLogEntry;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\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);
|
$user = $this->helper->resolveUser($token);
|
||||||
|
|
||||||
|
|
@ -48,19 +49,19 @@ final class LogEntryVoter extends Voter
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('delete' === $attribute) {
|
if ('delete' === $attribute) {
|
||||||
return $this->helper->isGranted($token, 'system', 'delete_logs');
|
return $this->helper->isGranted($token, 'system', 'delete_logs', $vote);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('read' === $attribute) {
|
if ('read' === $attribute) {
|
||||||
//Allow read of the users own log entries
|
//Allow read of the users own log entries
|
||||||
if (
|
if (
|
||||||
$subject->getUser() === $user
|
$subject->getUser() === $user
|
||||||
&& $this->helper->isGranted($token, 'self', 'show_logs')
|
&& $this->helper->isGranted($token, 'self', 'show_logs', $vote)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->helper->isGranted($token, 'system', 'show_logs');
|
return $this->helper->isGranted($token, 'system', 'show_logs', $vote);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('show_details' === $attribute) {
|
if ('show_details' === $attribute) {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\PriceInformations\Orderdetail;
|
use App\Entity\PriceInformations\Orderdetail;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\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 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)) {
|
if (! is_a($subject, Orderdetail::class, true)) {
|
||||||
throw new \RuntimeException('This voter can only handle Orderdetail objects!');
|
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 we have no part associated use the generic part permission
|
||||||
if (is_string($subject) || !$subject->getPart() instanceof Part) {
|
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
|
//Otherwise vote on the part
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ use App\Entity\Parameters\StorageLocationParameter;
|
||||||
use App\Entity\Parameters\SupplierParameter;
|
use App\Entity\Parameters\SupplierParameter;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\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;
|
//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));
|
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
|
protected function supports(string $attribute, $subject): bool
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ use App\Services\UserSystem\VoterHelper;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\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 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) {
|
if (!is_string($subject) && !$subject instanceof PartAssociation) {
|
||||||
throw new \RuntimeException('Invalid subject type!');
|
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 we have no part associated use the generic part permission
|
||||||
if (is_string($subject) || !$subject->getOwner() instanceof Part) {
|
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
|
//Otherwise vote on the part
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\Parts\PartLot;
|
use App\Entity\Parts\PartLot;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\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 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);
|
$user = $this->helper->resolveUser($token);
|
||||||
|
|
||||||
if (in_array($attribute, ['withdraw', 'add', 'move'], true))
|
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;
|
$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.
|
//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();
|
$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;
|
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 we have no part associated use the generic part permission
|
||||||
if (is_string($subject) || !$subject->getPart() instanceof Part) {
|
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
|
//Otherwise vote on the part
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ namespace App\Security\Voter;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use App\Services\UserSystem\VoterHelper;
|
use App\Services\UserSystem\VoterHelper;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\Authorization\Voter\Voter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,10 +53,9 @@ final class PartVoter extends Voter
|
||||||
return false;
|
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, $vote);
|
||||||
return $this->helper->isGranted($token, 'parts', $attribute);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function supportsAttribute(string $attribute): bool
|
public function supportsAttribute(string $attribute): bool
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ namespace App\Security\Voter;
|
||||||
|
|
||||||
use App\Services\UserSystem\VoterHelper;
|
use App\Services\UserSystem\VoterHelper;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\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, '@');
|
$attribute = ltrim($attribute, '@');
|
||||||
[$perm, $op] = explode('.', $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
|
public function supportsAttribute(string $attribute): bool
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ use App\Entity\PriceInformations\Orderdetail;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\PriceInformations\Pricedetail;
|
use App\Entity\PriceInformations\Pricedetail;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\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 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) {
|
$operation = match ($attribute) {
|
||||||
'read' => 'read',
|
'read' => 'read',
|
||||||
|
|
@ -72,7 +73,7 @@ final class PricedetailVoter extends Voter
|
||||||
|
|
||||||
//If we have no part associated use the generic part permission
|
//If we have no part associated use the generic part permission
|
||||||
if (is_string($subject) || !$subject->getOrderdetail() instanceof Orderdetail || !$subject->getOrderdetail()->getPart() instanceof Part) {
|
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
|
//Otherwise vote on the part
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ use App\Entity\Parts\Supplier;
|
||||||
use App\Entity\PriceInformations\Currency;
|
use App\Entity\PriceInformations\Currency;
|
||||||
use App\Services\UserSystem\VoterHelper;
|
use App\Services\UserSystem\VoterHelper;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\Authorization\Voter\Voter;
|
||||||
|
|
||||||
use function is_object;
|
use function is_object;
|
||||||
|
|
@ -113,10 +114,10 @@ final class StructureVoter extends Voter
|
||||||
*
|
*
|
||||||
* @param string $attribute
|
* @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);
|
$permission_name = $this->instanceToPermissionName($subject);
|
||||||
//Just resolve the permission
|
//Just resolve the permission
|
||||||
return $this->helper->isGranted($token, $permission_name, $attribute);
|
return $this->helper->isGranted($token, $permission_name, $attribute, $vote);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ use App\Entity\UserSystem\User;
|
||||||
use App\Services\UserSystem\PermissionManager;
|
use App\Services\UserSystem\PermissionManager;
|
||||||
use App\Services\UserSystem\VoterHelper;
|
use App\Services\UserSystem\VoterHelper;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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\Authorization\Voter\Voter;
|
||||||
|
|
||||||
use function in_array;
|
use function in_array;
|
||||||
|
|
@ -79,7 +80,7 @@ final class UserVoter extends Voter
|
||||||
*
|
*
|
||||||
* @param string $attribute
|
* @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);
|
$user = $this->helper->resolveUser($token);
|
||||||
|
|
||||||
|
|
@ -97,7 +98,7 @@ final class UserVoter extends Voter
|
||||||
if (($subject instanceof User) && $subject->getID() === $user->getID() &&
|
if (($subject instanceof User) && $subject->getID() === $user->getID() &&
|
||||||
$this->helper->isValidOperation('self', $attribute)) {
|
$this->helper->isValidOperation('self', $attribute)) {
|
||||||
//Then we also need to check the self permission
|
//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:
|
//But if the self value is not allowed then use just the user value:
|
||||||
if ($tmp) {
|
if ($tmp) {
|
||||||
return $tmp;
|
return $tmp;
|
||||||
|
|
@ -106,7 +107,7 @@ final class UserVoter extends Voter
|
||||||
|
|
||||||
//Else just check user permission:
|
//Else just check user permission:
|
||||||
if ($this->helper->isValidOperation('users', $attribute)) {
|
if ($this->helper->isValidOperation('users', $attribute)) {
|
||||||
return $this->helper->isGranted($token, 'users', $attribute);
|
return $this->helper->isGranted($token, 'users', $attribute, $vote);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,13 @@ declare(strict_types=1);
|
||||||
*/
|
*/
|
||||||
namespace App\Services\ImportExportSystem;
|
namespace App\Services\ImportExportSystem;
|
||||||
|
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\ProjectSystem\Project;
|
use App\Entity\ProjectSystem\Project;
|
||||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use League\Csv\Reader;
|
use League\Csv\Reader;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\HttpFoundation\File\File;
|
use Symfony\Component\HttpFoundation\File\File;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
|
@ -44,14 +47,25 @@ class BOMImporter
|
||||||
5 => 'Supplier and ref',
|
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
|
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
|
||||||
{
|
{
|
||||||
$resolver->setRequired('type');
|
$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;
|
return $resolver;
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +96,23 @@ class BOMImporter
|
||||||
return $this->stringToBOMEntries($file->getContent(), $options);
|
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.
|
* Import string data into an array of BOM entries, which are not yet assigned to a project.
|
||||||
* @param string $data The data to import
|
* @param string $data The data to import
|
||||||
|
|
@ -95,12 +126,13 @@ class BOMImporter
|
||||||
$options = $resolver->resolve($options);
|
$options = $resolver->resolve($options);
|
||||||
|
|
||||||
return match ($options['type']) {
|
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!'),
|
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 = Reader::createFromString($data);
|
||||||
$csv->setDelimiter(';');
|
$csv->setDelimiter(';');
|
||||||
|
|
@ -113,17 +145,17 @@ class BOMImporter
|
||||||
$entry = $this->normalizeColumnNames($entry);
|
$entry = $this->normalizeColumnNames($entry);
|
||||||
|
|
||||||
//Ensure that the entry has all required fields
|
//Ensure that the entry has all required fields
|
||||||
if (!isset ($entry['Designator'])) {
|
if (!isset($entry['Designator'])) {
|
||||||
throw new \UnexpectedValueException('Designator missing at line '.($offset + 1).'!');
|
throw new \UnexpectedValueException('Designator missing at line ' . ($offset + 1) . '!');
|
||||||
}
|
}
|
||||||
if (!isset ($entry['Package'])) {
|
if (!isset($entry['Package'])) {
|
||||||
throw new \UnexpectedValueException('Package missing at line '.($offset + 1).'!');
|
throw new \UnexpectedValueException('Package missing at line ' . ($offset + 1) . '!');
|
||||||
}
|
}
|
||||||
if (!isset ($entry['Designation'])) {
|
if (!isset($entry['Designation'])) {
|
||||||
throw new \UnexpectedValueException('Designation missing at line '.($offset + 1).'!');
|
throw new \UnexpectedValueException('Designation missing at line ' . ($offset + 1) . '!');
|
||||||
}
|
}
|
||||||
if (!isset ($entry['Quantity'])) {
|
if (!isset($entry['Quantity'])) {
|
||||||
throw new \UnexpectedValueException('Quantity missing at line '.($offset + 1).'!');
|
throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!');
|
||||||
}
|
}
|
||||||
|
|
||||||
$bom_entry = new ProjectBOMEntry();
|
$bom_entry = new ProjectBOMEntry();
|
||||||
|
|
@ -138,6 +170,63 @@ class BOMImporter
|
||||||
return $bom_entries;
|
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.
|
* This function uses the order of the fields in the CSV files to make them locale independent.
|
||||||
* @param array $entry
|
* @param array $entry
|
||||||
|
|
@ -160,4 +249,482 @@ class BOMImporter
|
||||||
|
|
||||||
return $out;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
476
src/Services/ImportExportSystem/BOMValidationService.php
Normal file
476
src/Services/ImportExportSystem/BOMValidationService.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -123,11 +123,11 @@ class LCSCProvider implements InfoProviderInterface
|
||||||
*/
|
*/
|
||||||
private function queryByTerm(string $term): array
|
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' => [
|
'headers' => [
|
||||||
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
||||||
],
|
],
|
||||||
'query' => [
|
'json' => [
|
||||||
'keyword' => $term,
|
'keyword' => $term,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
@ -165,6 +165,9 @@ class LCSCProvider implements InfoProviderInterface
|
||||||
if ($field === null) {
|
if ($field === null) {
|
||||||
return 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);
|
return strip_tags($field);
|
||||||
}
|
}
|
||||||
|
|
@ -197,9 +200,6 @@ class LCSCProvider implements InfoProviderInterface
|
||||||
$category = $product['parentCatalogName'] ?? null;
|
$category = $product['parentCatalogName'] ?? null;
|
||||||
if (isset($product['catalogName'])) {
|
if (isset($product['catalogName'])) {
|
||||||
$category = ($category ?? '') . ' -> ' . $product['catalogName'];
|
$category = ($category ?? '') . ' -> ' . $product['catalogName'];
|
||||||
|
|
||||||
// Replace the / with a -> for better readability
|
|
||||||
$category = str_replace('/', ' -> ', $category);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PartDetailDTO(
|
return new PartDetailDTO(
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,8 @@ class PollinProvider implements InfoProviderInterface
|
||||||
category: $this->parseCategory($dom),
|
category: $this->parseCategory($dom),
|
||||||
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
|
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'),
|
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,
|
provider_url: $productPageUrl,
|
||||||
notes: $this->parseNotes($dom),
|
notes: $this->parseNotes($dom),
|
||||||
datasheets: $this->parseDatasheets($dom),
|
datasheets: $this->parseDatasheets($dom),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ use App\Entity\PriceInformations\Currency;
|
||||||
use App\Settings\SystemSettings\LocalizationSettings;
|
use App\Settings\SystemSettings\LocalizationSettings;
|
||||||
use Brick\Math\BigDecimal;
|
use Brick\Math\BigDecimal;
|
||||||
use Brick\Math\RoundingMode;
|
use Brick\Math\RoundingMode;
|
||||||
|
use Exchanger\Exception\UnsupportedCurrencyPairException;
|
||||||
|
use Exchanger\Exception\UnsupportedExchangeQueryException;
|
||||||
use Swap\Swap;
|
use Swap\Swap;
|
||||||
|
|
||||||
class ExchangeRateUpdater
|
class ExchangeRateUpdater
|
||||||
|
|
@ -39,15 +41,21 @@ class ExchangeRateUpdater
|
||||||
*/
|
*/
|
||||||
public function update(Currency $currency): Currency
|
public function update(Currency $currency): Currency
|
||||||
{
|
{
|
||||||
//Currency pairs are always in the format "BASE/QUOTE"
|
try {
|
||||||
$rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
|
//Try it in the direction QUOTE/BASE first, as most providers provide rates in this direction
|
||||||
//The rate says how many quote units are worth one base unit
|
$rate = $this->swap->latest($currency->getIsoCode().'/'.$this->localizationSettings->baseCurrency);
|
||||||
//So we need to invert it to get the exchange rate
|
$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_bd = BigDecimal::of($rate->getValue());
|
||||||
$rate_inverted = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
|
$effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
$currency->setExchangeRate($rate_inverted);
|
$currency->setExchangeRate($effective_rate);
|
||||||
|
|
||||||
return $currency;
|
return $currency;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ use App\Repository\UserRepository;
|
||||||
use App\Security\ApiTokenAuthenticatedToken;
|
use App\Security\ApiTokenAuthenticatedToken;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
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
|
* @see \App\Tests\Services\UserSystem\VoterHelperTest
|
||||||
|
|
@ -35,10 +38,14 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
final class VoterHelper
|
final class VoterHelper
|
||||||
{
|
{
|
||||||
private readonly UserRepository $userRepository;
|
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->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 TokenInterface $token The token to check
|
||||||
* @param string $permission The permission to check
|
* @param string $permission The permission to check
|
||||||
* @param string $operation The operation 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
|
* @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);
|
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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
12
symfony.lock
12
symfony.lock
|
|
@ -133,15 +133,6 @@
|
||||||
"ekino/phpstan-banned-code": {
|
"ekino/phpstan-banned-code": {
|
||||||
"version": "v0.3.1"
|
"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": {
|
"gregwar/captcha": {
|
||||||
"version": "v1.1.7"
|
"version": "v1.1.7"
|
||||||
},
|
},
|
||||||
|
|
@ -254,6 +245,9 @@
|
||||||
"./config/packages/datatables.yaml"
|
"./config/packages/datatables.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"part-db/swap-bundle": {
|
||||||
|
"version": "v6.0.0"
|
||||||
|
},
|
||||||
"php-http/discovery": {
|
"php-http/discovery": {
|
||||||
"version": "1.18",
|
"version": "1.18",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
{% extends "bundles/TwigBundle/Exception/error.html.twig" %}
|
{% extends "bundles/TwigBundle/Exception/error.html.twig" %}
|
||||||
|
|
||||||
{% block status_comment %}
|
{% 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>
|
<br> <small>If you think you should have access to this ressource, contact the adminstrator.</small>
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
{% if provider.providerInfo.settings_class is defined %}
|
{% 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 %}"
|
title="{% trans %}info_providers.settings.title{% endtrans %}"
|
||||||
><i class="fa-solid fa-cog"></i></a>
|
><i class="fa-solid fa-cog"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,10 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if form.update_profile is defined %}
|
||||||
|
{{ form_row(form.update_profile) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<div class="offset-sm-3 col-sm-9">
|
<div class="offset-sm-3 col-sm-9">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
|
|
@ -133,4 +137,4 @@
|
||||||
</object>
|
</object>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
186
templates/projects/_bom_validation_results.html.twig
Normal file
186
templates/projects/_bom_validation_results.html.twig
Normal 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 %}
|
||||||
204
templates/projects/import_bom_map_fields.html.twig
Normal file
204
templates/projects/import_bom_map_fields.html.twig
Normal 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 %}
|
||||||
|
|
@ -36,7 +36,7 @@ class CurrencyEndpointTest extends CrudEndpointTestCase
|
||||||
{
|
{
|
||||||
$this->_testGetCollection();
|
$this->_testGetCollection();
|
||||||
self::assertJsonContains([
|
self::assertJsonContains([
|
||||||
'hydra:totalItems' => 0,
|
'hydra:totalItems' => 4, //The 4 currencies from our fixtures
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ class CurrencyEndpointTest extends CrudEndpointTestCase
|
||||||
{
|
{
|
||||||
$this->_testPostItem([
|
$this->_testPostItem([
|
||||||
'name' => 'Test API',
|
'name' => 'Test API',
|
||||||
'iso_code' => 'USD',
|
'iso_code' => 'CAD',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,4 +61,4 @@ class CurrencyEndpointTest extends CrudEndpointTestCase
|
||||||
{
|
{
|
||||||
$this->_testDeleteItem(5);
|
$this->_testDeleteItem(5);
|
||||||
}*/
|
}*/
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
tests/Controller/AdminPages/CurrencyController.php
Normal file
35
tests/Controller/AdminPages/CurrencyController.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 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;
|
||||||
|
}
|
||||||
|
|
@ -22,9 +22,12 @@ declare(strict_types=1);
|
||||||
*/
|
*/
|
||||||
namespace App\Tests\Services\ImportExportSystem;
|
namespace App\Tests\Services\ImportExportSystem;
|
||||||
|
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Entity\Parts\Supplier;
|
||||||
use App\Entity\ProjectSystem\Project;
|
use App\Entity\ProjectSystem\Project;
|
||||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
use App\Services\ImportExportSystem\BOMImporter;
|
use App\Services\ImportExportSystem\BOMImporter;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
use Symfony\Component\HttpFoundation\File\File;
|
use Symfony\Component\HttpFoundation\File\File;
|
||||||
|
|
||||||
|
|
@ -36,11 +39,17 @@ class BOMImporterTest extends WebTestCase
|
||||||
*/
|
*/
|
||||||
protected $service;
|
protected $service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var EntityManagerInterface
|
||||||
|
*/
|
||||||
|
protected $entityManager;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
//Get a service instance.
|
//Get a service instance.
|
||||||
self::bootKernel();
|
self::bootKernel();
|
||||||
$this->service = self::getContainer()->get(BOMImporter::class);
|
$this->service = self::getContainer()->get(BOMImporter::class);
|
||||||
|
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testImportFileIntoProject(): void
|
public function testImportFileIntoProject(): void
|
||||||
|
|
@ -119,4 +128,489 @@ class BOMImporterTest extends WebTestCase
|
||||||
|
|
||||||
$this->service->stringToBOMEntries($input, ['type' => 'kicad_pcbnew']);
|
$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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
349
tests/Services/ImportExportSystem/BOMValidationServiceTest.php
Normal file
349
tests/Services/ImportExportSystem/BOMValidationServiceTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,26 +60,29 @@ class TimestampableElementProviderTest extends WebTestCase
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
self::bootKernel();
|
self::bootKernel();
|
||||||
\Locale::setDefault('en');
|
\Locale::setDefault('en_US');
|
||||||
$this->service = self::getContainer()->get(TimestampableElementProvider::class);
|
$this->service = self::getContainer()->get(TimestampableElementProvider::class);
|
||||||
$this->target = new class() implements TimeStampableInterface {
|
$this->target = new class () implements TimeStampableInterface {
|
||||||
public function getLastModified(): ?DateTime
|
public function getLastModified(): ?DateTime
|
||||||
{
|
{
|
||||||
return new \DateTime('2000-01-01');
|
return new DateTime('2000-01-01');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAddedDate(): ?DateTime
|
public function getAddedDate(): ?DateTime
|
||||||
{
|
{
|
||||||
return new \DateTime('2000-01-01');
|
return new DateTime('2000-01-01');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function dataProvider(): \Iterator
|
public static function dataProvider(): \Iterator
|
||||||
{
|
{
|
||||||
\Locale::setDefault('en');
|
\Locale::setDefault('en_US');
|
||||||
yield ['1/1/00, 12:00 AM', '[[LAST_MODIFIED]]'];
|
// Use IntlDateFormatter like the actual service does
|
||||||
yield ['1/1/00, 12:00 AM', '[[CREATION_DATE]]'];
|
$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')]
|
#[DataProvider('dataProvider')]
|
||||||
|
|
@ -87,4 +90,4 @@ class TimestampableElementProviderTest extends WebTestCase
|
||||||
{
|
{
|
||||||
$this->assertSame($expected, $this->service->replace($placeholder, $this->target));
|
$this->assertSame($expected, $this->service->replace($placeholder, $this->target));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -580,7 +580,7 @@
|
||||||
</notes>
|
</notes>
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>storelocation.new</source>
|
<source>storelocation.new</source>
|
||||||
<target>Nové místo skladování</target>
|
<target>Nové umístění</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="Rt3eY_7" name="supplier.caption">
|
<unit id="Rt3eY_7" name="supplier.caption">
|
||||||
|
|
@ -913,7 +913,7 @@ Související prvky budou přesunuty nahoru.</target>
|
||||||
</notes>
|
</notes>
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>edit.log_comment</source>
|
<source>edit.log_comment</source>
|
||||||
<target>Změnit komentář</target>
|
<target>Komentář ke změně</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="ZMmz8UB" name="entity.delete.recursive">
|
<unit id="ZMmz8UB" name="entity.delete.recursive">
|
||||||
|
|
@ -2502,7 +2502,7 @@ Související prvky budou přesunuty nahoru.</target>
|
||||||
</notes>
|
</notes>
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.needs_review.badge</source>
|
<source>part.needs_review.badge</source>
|
||||||
<target>Potřeba revize</target>
|
<target>Vyžaduje kontrolu</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="IttGv57" name="part.favorite.badge">
|
<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>
|
</notes>
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>search.regexmatching</source>
|
<source>search.regexmatching</source>
|
||||||
<target>RegEx. shoda</target>
|
<target>Reg.Ex. shoda</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="U5IhkwB" name="search.submit">
|
<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>
|
</notes>
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.table.needsReview</source>
|
<source>part.table.needsReview</source>
|
||||||
<target>Potřeba revize</target>
|
<target>Vyžaduje kontrolu</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="AtzzLFz" name="part.table.favorite">
|
<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>
|
</notes>
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.edit.needs_review</source>
|
<source>part.edit.needs_review</source>
|
||||||
<target>Potřeba revize</target>
|
<target>Vyžaduje kontrolu</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="TQbwkUd" name="part.edit.is_favorite">
|
<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>
|
</notes>
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>user.theme.label</source>
|
<source>user.theme.label</source>
|
||||||
<target>Téma</target>
|
<target>Vzhled</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="LQ7ihIX" name="user_settings.theme.placeholder">
|
<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>
|
</notes>
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>user_settings.theme.placeholder</source>
|
<source>user_settings.theme.placeholder</source>
|
||||||
<target>Serverové téma</target>
|
<target>Vzhled pro celý server</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="ZkXKucz" name="log.user_login.ip">
|
<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">
|
<unit id="OEVzOkv" name="part_list.action.action.group.needs_review">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part_list.action.action.group.needs_review</source>
|
<source>part_list.action.action.group.needs_review</source>
|
||||||
<target>Potřeba revize</target>
|
<target>Vyžaduje kontrolu</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="nkoTW_w" name="part_list.action.action.set_needs_review">
|
<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">
|
<unit id="VraT.Lo" name="log.element_edited.changed_fields.theme">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>log.element_edited.changed_fields.theme</source>
|
<source>log.element_edited.changed_fields.theme</source>
|
||||||
<target>Téma</target>
|
<target>Vzhled</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="QFESysH" name="log.element_edited.changed_fields.timezone">
|
<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">
|
<unit id="iF9ovqi" name="log.element_edited.changed_fields.needs_review">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>log.element_edited.changed_fields.needs_review</source>
|
<source>log.element_edited.changed_fields.needs_review</source>
|
||||||
<target>Potřeba revize</target>
|
<target>Vyžaduje kontrolu</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="wgJmpYG" name="log.element_edited.changed_fields.tags">
|
<unit id="wgJmpYG" name="log.element_edited.changed_fields.tags">
|
||||||
|
|
@ -10984,7 +10984,7 @@ Element 3</target>
|
||||||
<unit id="awbvhVq" name="parts.import.help">
|
<unit id="awbvhVq" name="parts.import.help">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>parts.import.help</source>
|
<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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="5.sq5ns" name="parts.import.flash.success">
|
<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">
|
<unit id="7dsEiOg" name="parts.import.part_needs_review.help">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>parts.import.part_needs_review.help</source>
|
<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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="Ie9LLKJ" name="project.bom_import.flash.success">
|
<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">
|
<unit id="duBTELg" name="part.info.withdraw_modal.delete_lot_if_empty">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.delete_lot_if_empty</source>
|
<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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="SMclulD" name="info_providers.search.error.client_exception">
|
<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">
|
<unit id="VYMqQr5" name="settings.system.customization.instanceName">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.system.customization.instanceName</source>
|
<source>settings.system.customization.instanceName</source>
|
||||||
<target>Instance name</target>
|
<target>Název instance</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="0YFxSHZ" name="settings.system.customization.instanceName.help">
|
<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">
|
<unit id="pinyqu2" name="settings.system.customization.theme">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.system.customization.theme</source>
|
<source>settings.system.customization.theme</source>
|
||||||
<target>Globální téma</target>
|
<target>Globální vzhed</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="Aky9nXE" name="settings.system.history.enforceComments">
|
<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">
|
<unit id="8IszKgp" name="settings.system.privacy.useGravatar.description">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.system.privacy.useGravatar.description</source>
|
<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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="rxHBzbv" name="settings.system.privacy.checkForUpdates">
|
<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">
|
<unit id="cvpTUeY" name="settings.system.privacy">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.system.privacy</source>
|
<source>settings.system.privacy</source>
|
||||||
<target>Ochrana osobních údajů</target>
|
<target>Soukromí</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="TVAVZUl" name="settings.title">
|
<unit id="TVAVZUl" name="settings.title">
|
||||||
|
|
|
||||||
|
|
@ -6504,7 +6504,7 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
|
||||||
</notes>
|
</notes>
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>flash.password_change_needed</source>
|
<source>flash.password_change_needed</source>
|
||||||
<target>Ihr Password muss geändert werden!</target>
|
<target>Ihr Passwort muss geändert werden!</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="8I8zHPK" name="attachment.table.type">
|
<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">
|
<segment state="translated">
|
||||||
<source>mass_creation.lines.placeholder</source>
|
<source>mass_creation.lines.placeholder</source>
|
||||||
<target>Element 1
|
<target>Element 1
|
||||||
|
Element 1.1
|
||||||
|
Element 1.1.1
|
||||||
|
Element 1.2
|
||||||
Element 2
|
Element 2
|
||||||
Element 3</target>
|
Element 3
|
||||||
|
|
||||||
|
Element 1 -> Element 1.1
|
||||||
|
Element 1 -> Element 1.2</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="TWSqPFi" name="entity.mass_creation.btn">
|
<unit id="TWSqPFi" name="entity.mass_creation.btn">
|
||||||
|
|
@ -9006,7 +9012,7 @@ Element 3</target>
|
||||||
<unit id="gaoMsrY" name="part_list.action.part_count">
|
<unit id="gaoMsrY" name="part_list.action.part_count">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part_list.action.part_count</source>
|
<source>part_list.action.part_count</source>
|
||||||
<target>%count% Bauteile ausgewählt!</target>
|
<target>%count% Bauteile ausgewählt</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="FhdheZY" name="company.edit.quick.website">
|
<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">
|
<unit id="TEm7uIg" name="settings.behavior.sidebar.rootNodeRedirectsToNewEntity">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source>
|
<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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="j7HiQ80" name="settings.ips.digikey">
|
<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>
|
<target>Aus Sicherheitsgründen ausgeblendet</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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 1–9 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>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
|
|
@ -7164,8 +7164,8 @@ Exampletown</target>
|
||||||
Element 2
|
Element 2
|
||||||
Element 3
|
Element 3
|
||||||
|
|
||||||
Element 1 -> Element 1.1
|
Element 1 -> Element 1.1
|
||||||
Element 1 -> Element 1.2]]></target>
|
Element 1 -> Element 1.2]]></target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="TWSqPFi" name="entity.mass_creation.btn">
|
<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">
|
<unit id="p7LGAIX" name="settings.ips.element14.apiKey.help">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.ips.element14.apiKey.help</source>
|
<source>settings.ips.element14.apiKey.help</source>
|
||||||
<target>You can register for an API key on <a href="https://partner.element14.com/">https://partner.element14.com/</a>.</target>
|
<target><![CDATA[You can register for an API key on <a href="https://partner.element14.com/">https://partner.element14.com/</a>.]]></target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="ZdUHpZc" name="settings.ips.element14.storeId">
|
<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">
|
<unit id="XXGUxF6" name="settings.ips.element14.storeId.help">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.ips.element14.storeId.help</source>
|
<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 <a href="https://partner.element14.com/docs/Product_Search_API_REST__Description">here</a> 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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="WKWZIm2" name="settings.ips.tme">
|
<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">
|
<unit id="_pYLrPT" name="settings.ips.tme.token.help">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.ips.tme.token.help</source>
|
<source>settings.ips.tme.token.help</source>
|
||||||
<target>You can get an API token and secret on <a href="https://developers.tme.eu/en/">https://developers.tme.eu/en/</a>.</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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="yswx4bq" name="settings.ips.tme.secret">
|
<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">
|
<unit id="gu.JlpT" name="settings.ips.mouser.apiKey.help">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.ips.mouser.apiKey.help</source>
|
<source>settings.ips.mouser.apiKey.help</source>
|
||||||
<target>You can register for an API key on <a href="https://eu.mouser.com/api-hub/">https://eu.mouser.com/api-hub/</a>.</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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="Q66CNjw" name="settings.ips.mouser.searchLimit">
|
<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">
|
<unit id="kKv0J3." name="settings.system.attachments">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.system.attachments</source>
|
<source>settings.system.attachments</source>
|
||||||
<target>Attachments & Files</target>
|
<target><![CDATA[Attachments & Files]]></target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="dsRff8T" name="settings.system.attachments.maxFileSize">
|
<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">
|
<unit id="T.PBu5P" name="settings.system.attachments.allowDownloads.help">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.system.attachments.allowDownloads.help</source>
|
<source>settings.system.attachments.allowDownloads.help</source>
|
||||||
<target>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>
|
<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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id=".OyihML" name="settings.system.attachments.downloadByDefault">
|
<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">
|
<unit id="0GRlEe5" name="settings.system.localization.base_currency_description">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.system.localization.base_currency_description</source>
|
<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.
|
<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>
|
<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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="cvpTUeY" name="settings.system.privacy">
|
<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">
|
<unit id="w07P3Dt" name="settings.misc.kicad_eda.category_depth.help">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.misc.kicad_eda.category_depth.help</source>
|
<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 > 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>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="VwvmcWE" name="settings.behavior.sidebar">
|
<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">
|
<unit id="jc0JTvL" name="settings.behavior.sidebar.items.help">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.behavior.sidebar.items.help</source>
|
<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 & drop.</target>
|
<target><![CDATA[The menus which appear at the sidebar by default. Order of items can be changed via drag & drop.]]></target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="gVSWDkE" name="settings.behavior.sidebar.rootNodeEnabled">
|
<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">
|
<unit id="SUD8H3b" name="settings.behavior.table.parts_default_columns.help">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.behavior.table.parts_default_columns.help</source>
|
<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 & drop.</target>
|
<target><![CDATA[The columns to show by default in part tables. Order of items can be changed via drag & drop.]]></target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="hazr_g5" name="settings.ips.oemsecrets">
|
<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">
|
<unit id="KLJYfJ0" name="settings.ips.oemsecrets.sortMode.M">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>settings.ips.oemsecrets.sortMode.M</source>
|
<source>settings.ips.oemsecrets.sortMode.M</source>
|
||||||
<target>Completeness & Manufacturer name</target>
|
<target><![CDATA[Completeness & Manufacturer name]]></target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
<unit id="8C9ijHM" name="entity.export.flash.error.no_entities">
|
<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>
|
<target>Redacted for security reasons</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue