mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-26 19:52:37 +01:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcdeb0479a | ||
|
|
79ac318d0f | ||
|
|
6765c110c6 | ||
|
|
f6f83cc111 | ||
|
|
c6d5fb3f57 | ||
|
|
4b8ef4b0fa | ||
|
|
23cad8261b | ||
|
|
c52126ccf8 | ||
|
|
8eec606589 | ||
|
|
cdc58507db | ||
|
|
03f7ad66d2 | ||
|
|
3b01af1247 | ||
|
|
8d2ff6f5d7 | ||
|
|
6ff7f64384 | ||
|
|
c2cbbee0df | ||
|
|
e81c8470be | ||
|
|
ecd2abe00e | ||
|
|
0d1ae030be | ||
|
|
1f669a9c53 | ||
|
|
8ff2fc5a82 | ||
|
|
c7ec8adc31 | ||
|
|
cee6d355e8 | ||
|
|
4b00697f02 | ||
|
|
617ae03b48 | ||
|
|
71629a696c | ||
|
|
14cc0b9e9a | ||
|
|
c5a1df37b9 | ||
|
|
46d1a0cb1b | ||
|
|
a18ec373d2 | ||
|
|
ced16620ec | ||
|
|
890621b651 | ||
|
|
5a5691a8c4 | ||
|
|
fb92db8c05 | ||
|
|
2b28aa8ba9 | ||
|
|
90f83273da | ||
|
|
76f3c379b5 | ||
|
|
1d33d95c57 | ||
|
|
72e3766be5 | ||
|
|
7c1ab6460d | ||
|
|
d0f2422e0d | ||
|
|
4277f42285 | ||
|
|
0e9558e331 | ||
|
|
4e9e82d9f1 | ||
|
|
411ac500ba | ||
|
|
b1443a817b | ||
|
|
3e8ca06177 | ||
|
|
c1b7272ab1 | ||
|
|
b093866d15 | ||
|
|
065ef9f8ae | ||
|
|
9b17efc12c | ||
|
|
fe7910a2f2 | ||
|
|
eb4258053e | ||
|
|
117ff4484d | ||
|
|
ba7d139f8a | ||
|
|
d657b2ff04 | ||
|
|
0637c05053 | ||
|
|
88fbc46325 | ||
|
|
379155e839 | ||
|
|
0717239296 | ||
|
|
d3e3c4e3f8 | ||
|
|
c9a1febc56 | ||
|
|
7f099972e1 | ||
|
|
a6be786d5d | ||
|
|
578a030175 | ||
|
|
f858e68f12 | ||
|
|
bdd88700d4 | ||
|
|
87cf75f67d | ||
|
|
c3cc7cb0d6 | ||
|
|
e1600cdec9 | ||
|
|
431cf23600 | ||
|
|
08ce1795fc | ||
|
|
e369ce6db9 | ||
|
|
af4ea17faa | ||
|
|
bb13ebc0ec | ||
|
|
3b42d7a2c8 | ||
|
|
e98d988c45 | ||
|
|
cc70e77dee | ||
|
|
7a86109d66 | ||
|
|
5238be1460 | ||
|
|
6edc8056ec | ||
|
|
b19cc13897 | ||
|
|
50f478f7ef | ||
|
|
80482f7294 | ||
|
|
dc864fad04 | ||
|
|
6d495b38b4 | ||
|
|
1c838d1e42 | ||
|
|
652c7abbce | ||
|
|
d925fd8913 | ||
|
|
9a8e34cbe3 | ||
|
|
34ae83cc8c | ||
|
|
e26e6da15d | ||
|
|
d45cd23c0f | ||
|
|
b60a1070e9 | ||
|
|
7f04827a0b | ||
|
|
da11c9b793 | ||
|
|
d9ef9cd7b7 | ||
|
|
8bcebf57c4 | ||
|
|
cf791cff1d |
@@ -40,7 +40,7 @@ if [ -d /var/www/html/var/db ]; then
|
||||
fi
|
||||
|
||||
# Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
|
||||
service phpPHP_VERSION-fpm start
|
||||
php-fpmPHP_VERSION -F &
|
||||
|
||||
|
||||
# Run migrations if automigration is enabled via env variable DB_AUTOMIGRATE
|
||||
@@ -90,4 +90,4 @@ if [ "${1#-}" != "$1" ]; then
|
||||
fi
|
||||
|
||||
# Pass to the original entrypoint
|
||||
exec "$@"
|
||||
exec "$@"
|
||||
|
||||
@@ -24,34 +24,6 @@
|
||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||
|
||||
# Pass the configuration from the docker env to the PHP environment (here you should list all .env options)
|
||||
PassEnv APP_ENV APP_DEBUG APP_SECRET REDIRECT_TO_HTTPS DISABLE_YEAR2038_BUG_CHECK
|
||||
PassEnv TRUSTED_PROXIES TRUSTED_HOSTS LOCK_DSN
|
||||
PassEnv DATABASE_URL ENFORCE_CHANGE_COMMENTS_FOR DATABASE_MYSQL_USE_SSL_CA DATABASE_MYSQL_SSL_VERIFY_CERT
|
||||
PassEnv DEFAULT_LANG DEFAULT_TIMEZONE BASE_CURRENCY INSTANCE_NAME ALLOW_ATTACHMENT_DOWNLOADS USE_GRAVATAR MAX_ATTACHMENT_FILE_SIZE DEFAULT_URI CHECK_FOR_UPDATES ATTACHMENT_DOWNLOAD_BY_DEFAULT
|
||||
PassEnv MAILER_DSN ALLOW_EMAIL_PW_RESET EMAIL_SENDER_EMAIL EMAIL_SENDER_NAME
|
||||
PassEnv HISTORY_SAVE_CHANGED_FIELDS HISTORY_SAVE_CHANGED_DATA HISTORY_SAVE_REMOVED_DATA HISTORY_SAVE_NEW_DATA
|
||||
PassEnv ERROR_PAGE_ADMIN_EMAIL ERROR_PAGE_SHOW_HELP
|
||||
PassEnv DEMO_MODE NO_URL_REWRITE_AVAILABLE FIXER_API_KEY BANNER
|
||||
# In old version the SAML sp private key env, was wrongly named SAMLP_SP_PRIVATE_KEY, keep it for backward compatibility
|
||||
PassEnv SAML_ENABLED SAML_BEHIND_PROXY SAML_ROLE_MAPPING SAML_UPDATE_GROUP_ON_LOGIN SAML_IDP_ENTITY_ID SAML_IDP_SINGLE_SIGN_ON_SERVICE SAML_IDP_SINGLE_LOGOUT_SERVICE SAML_IDP_X509_CERT SAML_SP_ENTITY_ID SAML_SP_X509_CERT SAML_SP_PRIVATE_KEY SAMLP_SP_PRIVATE_KEY
|
||||
PassEnv TABLE_DEFAULT_PAGE_SIZE TABLE_PARTS_DEFAULT_COLUMNS
|
||||
|
||||
PassEnv PROVIDER_DIGIKEY_CLIENT_ID PROVIDER_DIGIKEY_SECRET PROVIDER_DIGIKEY_CURRENCY PROVIDER_DIGIKEY_LANGUAGE PROVIDER_DIGIKEY_COUNTRY
|
||||
PassEnv PROVIDER_ELEMENT14_KEY PROVIDER_ELEMENT14_STORE_ID
|
||||
PassEnv PROVIDER_TME_KEY PROVIDER_TME_SECRET PROVIDER_TME_CURRENCY PROVIDER_TME_LANGUAGE PROVIDER_TME_COUNTRY PROVIDER_TME_GET_GROSS_PRICES
|
||||
PassEnv PROVIDER_OCTOPART_CLIENT_ID PROVIDER_OCTOPART_SECRET PROVIDER_OCTOPART_CURRENCY PROVIDER_OCTOPART_COUNTRY PROVIDER_OCTOPART_SEARCH_LIMIT PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS
|
||||
PassEnv PROVIDER_MOUSER_KEY PROVIDER_MOUSER_SEARCH_OPTION PROVIDER_MOUSER_SEARCH_LIMIT PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE
|
||||
PassEnv PROVIDER_LCSC_ENABLED PROVIDER_LCSC_CURRENCY
|
||||
PassEnv PROVIDER_OEMSECRETS_KEY PROVIDER_OEMSECRETS_COUNTRY_CODE PROVIDER_OEMSECRETS_CURRENCY PROVIDER_OEMSECRETS_ZERO_PRICE PROVIDER_OEMSECRETS_SET_PARAM PROVIDER_OEMSECRETS_SORT_CRITERIA
|
||||
PassEnv PROVIDER_REICHELT_ENABLED PROVIDER_REICHELT_CURRENCY PROVIDER_REICHELT_COUNTRY PROVIDER_REICHELT_LANGUAGE PROVIDER_REICHELT_INCLUDE_VAT
|
||||
PassEnv PROVIDER_POLLIN_ENABLED
|
||||
PassEnv EDA_KICAD_CATEGORY_DEPTH
|
||||
PassEnv SHOW_PART_IMAGE_OVERLAY
|
||||
|
||||
# Proxy configuration env
|
||||
PassEnv NO_PROXY HTTPS_PROXY HTTP_PROXY http_proxy https_proxy ALL_PROXY all_proxy
|
||||
|
||||
# For most configuration files from conf-available/, which are
|
||||
# enabled or disabled at a global level, it is possible to
|
||||
# include a line for only one particular virtual host. For example the
|
||||
|
||||
18
.env
18
.env
@@ -31,13 +31,6 @@ DATABASE_EMULATE_NATURAL_SORT=0
|
||||
# General settings
|
||||
###################################################################################
|
||||
|
||||
# The language to use serverwide as default (en, de, ru, etc.)
|
||||
#DEFAULT_LANG="en"
|
||||
# The default timezone to use serverwide (e.g. Europe/Berlin)
|
||||
#DEFAULT_TIMEZONE="Europe/Berlin"
|
||||
# The currency that is used inside the DB (and is assumed when no currency is set). This can not be changed later, so be sure to set it the currency used in your country
|
||||
#BASE_CURRENCY="EUR"
|
||||
|
||||
# The public reachable URL of this Part-DB installation. This is used for generating links in SAML and email templates
|
||||
# This must end with a slash!
|
||||
DEFAULT_URI="https://partdb.changeme.invalid/"
|
||||
@@ -68,15 +61,6 @@ ERROR_PAGE_ADMIN_EMAIL=''
|
||||
ERROR_PAGE_SHOW_HELP=1
|
||||
|
||||
|
||||
##################################################################################
|
||||
# EDA integration related settings
|
||||
##################################################################################
|
||||
|
||||
# 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
|
||||
#EDA_KICAD_CATEGORY_DEPTH=0
|
||||
|
||||
###################################################################################
|
||||
# SAML Single sign on-settings
|
||||
###################################################################################
|
||||
@@ -149,5 +133,5 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
APP_ENV=prod
|
||||
APP_SECRET=
|
||||
APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
5
.github/workflows/assets_artifact_build.yml
vendored
5
.github/workflows/assets_artifact_build.yml
vendored
@@ -1,5 +1,8 @@
|
||||
name: Build assets artifact
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -19,7 +22,7 @@ jobs:
|
||||
APP_ENV: prod
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
7
.github/workflows/docker_build.yml
vendored
7
.github/workflows/docker_build.yml
vendored
@@ -1,5 +1,8 @@
|
||||
name: Docker Image Build
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
#schedule:
|
||||
# - cron: '0 10 * * *' # everyday at 10am
|
||||
@@ -17,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
-
|
||||
name: Docker meta
|
||||
id: docker_meta
|
||||
@@ -73,4 +76,4 @@ jobs:
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
7
.github/workflows/docker_frankenphp.yml
vendored
7
.github/workflows/docker_frankenphp.yml
vendored
@@ -1,5 +1,8 @@
|
||||
name: Docker Image Build (FrankenPHP)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
#schedule:
|
||||
# - cron: '0 10 * * *' # everyday at 10am
|
||||
@@ -17,7 +20,7 @@ jobs:
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
-
|
||||
name: Docker meta
|
||||
id: docker_meta
|
||||
@@ -74,4 +77,4 @@ jobs:
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
15
.github/workflows/static_analysis.yml
vendored
15
.github/workflows/static_analysis.yml
vendored
@@ -1,5 +1,8 @@
|
||||
name: Static analysis
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -16,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
@@ -30,20 +33,20 @@ jobs:
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Lint config files
|
||||
run: ./bin/console lint:yaml config --parse-tags
|
||||
|
||||
|
||||
- name: Lint twig templates
|
||||
run: ./bin/console lint:twig templates --env=prod
|
||||
|
||||
@@ -53,13 +56,13 @@ jobs:
|
||||
|
||||
- name: Check dependencies for security
|
||||
uses: symfonycorp/security-checker-action@v5
|
||||
|
||||
|
||||
- name: Check doctrine mapping
|
||||
run: ./bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction
|
||||
|
||||
# Use the -d option to raise the max nesting level
|
||||
- name: Generate dev container
|
||||
run: php -d xdebug.max_nesting_level=1000 ./bin/console cache:clear --env dev
|
||||
|
||||
|
||||
- name: Run PHPstan
|
||||
run: composer phpstan
|
||||
|
||||
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
@@ -1,5 +1,8 @@
|
||||
name: PHPUnit Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -43,7 +46,7 @@ jobs:
|
||||
if: matrix.db-type == 'postgres'
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
@@ -119,12 +119,12 @@ realpath_cache_size=4096K
|
||||
realpath_cache_ttl=600
|
||||
EOF
|
||||
|
||||
# Increase upload limit and enable preloading
|
||||
# Increase upload limit and enable preloading (disabled for now, as it does not seem to work properly, and require prod env anyway)
|
||||
COPY <<EOF /etc/php/${PHP_VERSION}/fpm/conf.d/partdb.ini
|
||||
upload_max_filesize=256M
|
||||
post_max_size=300M
|
||||
opcache.preload_user=www-data
|
||||
opcache.preload=/var/www/html/config/preload.php
|
||||
;opcache.preload_user=www-data
|
||||
;opcache.preload=/var/www/html/config/preload.php
|
||||
log_limit=8096
|
||||
EOF
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||

|
||||
[](https://codecov.io/gh/Part-DB/Part-DB-server)
|
||||

|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
{
|
||||
"controllers": {
|
||||
"@symfony/ux-toggle-password": {
|
||||
"toggle-password": {
|
||||
"enabled": true,
|
||||
"fetch": "eager",
|
||||
"autoimport": {
|
||||
"@symfony/ux-toggle-password/dist/style.min.css": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@symfony/ux-turbo": {
|
||||
"turbo-core": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -56,12 +56,16 @@ export default class MarkdownController extends Controller {
|
||||
this.element.innerHTML = DOMPurify.sanitize(MarkdownController._marked.parse(this.unescapeHTML(raw)));
|
||||
|
||||
for(let a of this.element.querySelectorAll('a')) {
|
||||
//Mark all links as external
|
||||
a.classList.add('link-external');
|
||||
//Open links in new tag
|
||||
a.setAttribute('target', '_blank');
|
||||
//Dont track
|
||||
a.setAttribute('rel', 'noopener');
|
||||
// test if link is absolute
|
||||
var r = new RegExp('^(?:[a-z+]+:)?//', 'i');
|
||||
if (r.test(a.getAttribute('href'))) {
|
||||
//Mark all links as external
|
||||
a.classList.add('link-external');
|
||||
//Open links in new tag
|
||||
a.setAttribute('target', '_blank');
|
||||
//Dont track
|
||||
a.setAttribute('rel', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
//Apply bootstrap styles to tables
|
||||
@@ -108,4 +112,4 @@ export default class MarkdownController extends Controller {
|
||||
gfm: true,
|
||||
});
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export default class extends Controller {
|
||||
selectOnTab: true,
|
||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||
dropdownParent: 'body',
|
||||
render: {
|
||||
item: (data, escape) => {
|
||||
return '<span>' + escape(data.label) + '</span>';
|
||||
|
||||
@@ -45,8 +45,10 @@ export default class extends DatatablesController {
|
||||
//Hide/Unhide panel with the selection tools
|
||||
if (count > 0) {
|
||||
selectPanel.classList.remove('d-none');
|
||||
selectPanel.classList.add('sticky-select-bar');
|
||||
} else {
|
||||
selectPanel.classList.add('d-none');
|
||||
selectPanel.classList.remove('sticky-select-bar');
|
||||
}
|
||||
|
||||
//Update selection count text
|
||||
|
||||
@@ -16,6 +16,7 @@ export default class extends Controller {
|
||||
searchField: ["name", "description", "category", "footprint"],
|
||||
valueField: "id",
|
||||
labelField: "name",
|
||||
dropdownParent: 'body',
|
||||
preload: "focus",
|
||||
render: {
|
||||
item: (data, escape) => {
|
||||
@@ -71,4 +72,4 @@ export default class extends Controller {
|
||||
//Destroy the TomSelect instance
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export default class extends Controller {
|
||||
allowEmptyOption: true,
|
||||
selectOnTab: true,
|
||||
maxOptions: null,
|
||||
dropdownParent: 'body',
|
||||
|
||||
render: {
|
||||
item: this.renderItem.bind(this),
|
||||
@@ -108,4 +109,4 @@ export default class extends Controller {
|
||||
//Destroy the TomSelect instance
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export default class extends Controller {
|
||||
this._tomSelect = new TomSelect(this.element, {
|
||||
maxItems: 1000,
|
||||
allowEmptyOption: true,
|
||||
dropdownParent: 'body',
|
||||
plugins: ['remove_button'],
|
||||
});
|
||||
}
|
||||
@@ -39,4 +40,4 @@ export default class extends Controller {
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export default class extends Controller {
|
||||
valueField: 'text',
|
||||
searchField: 'text',
|
||||
orderField: 'text',
|
||||
dropdownParent: 'body',
|
||||
|
||||
//This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
|
||||
delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
|
||||
|
||||
@@ -54,6 +54,7 @@ export default class extends Controller {
|
||||
maxItems: 1,
|
||||
delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
|
||||
splitOn: null,
|
||||
dropdownParent: 'body',
|
||||
|
||||
searchField: [
|
||||
{field: "text", weight : 2},
|
||||
|
||||
@@ -43,6 +43,7 @@ export default class extends Controller {
|
||||
selectOnTab: true,
|
||||
createOnBlur: true,
|
||||
create: true,
|
||||
dropdownParent: 'body',
|
||||
};
|
||||
|
||||
if(this.element.dataset.autocomplete) {
|
||||
@@ -73,4 +74,4 @@ export default class extends Controller {
|
||||
//Destroy the TomSelect instance
|
||||
this._tomSelect.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
assets/controllers/toggle_password_controller.js
Normal file
86
assets/controllers/toggle_password_controller.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import '../css/components/toggle_password.css';
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
visibleLabel: { type: String, default: 'Show' },
|
||||
visibleIcon: { type: String, default: 'Default' },
|
||||
hiddenLabel: { type: String, default: 'Hide' },
|
||||
hiddenIcon: { type: String, default: 'Default' },
|
||||
buttonClasses: Array,
|
||||
};
|
||||
|
||||
isDisplayed = false;
|
||||
visibleIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="toggle-password-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" />
|
||||
</svg>`;
|
||||
hiddenIcon = `<svg xmlns="http://www.w3.org/2000/svg" class="toggle-password-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd" />
|
||||
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
|
||||
</svg>`;
|
||||
|
||||
connect() {
|
||||
if (this.visibleIconValue !== 'Default') {
|
||||
this.visibleIcon = this.visibleIconValue;
|
||||
}
|
||||
|
||||
if (this.hiddenIconValue !== 'Default') {
|
||||
this.hiddenIcon = this.hiddenIconValue;
|
||||
}
|
||||
|
||||
const button = this.createButton();
|
||||
|
||||
this.element.insertAdjacentElement('afterend', button);
|
||||
this.dispatchEvent('connect', { element: this.element, button });
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {HTMLButtonElement}
|
||||
*/
|
||||
createButton() {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.classList.add(...this.buttonClassesValue);
|
||||
button.setAttribute('tabindex', '-1');
|
||||
button.addEventListener('click', this.toggle.bind(this));
|
||||
button.innerHTML = `${this.visibleIcon} ${this.visibleLabelValue}`;
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle input type between "text" or "password" and update label accordingly
|
||||
*/
|
||||
toggle(event) {
|
||||
this.isDisplayed = !this.isDisplayed;
|
||||
const toggleButtonElement = event.currentTarget;
|
||||
toggleButtonElement.innerHTML = this.isDisplayed
|
||||
? `${this.hiddenIcon} ${this.hiddenLabelValue}`
|
||||
: `${this.visibleIcon} ${this.visibleLabelValue}`;
|
||||
this.element.setAttribute('type', this.isDisplayed ? 'text' : 'password');
|
||||
this.dispatchEvent(this.isDisplayed ? 'show' : 'hide', { element: this.element, button: toggleButtonElement });
|
||||
}
|
||||
|
||||
dispatchEvent(name, payload) {
|
||||
this.dispatch(name, { detail: payload, prefix: 'toggle-password' });
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
*/
|
||||
|
||||
.hoverpic {
|
||||
min-width: 10px;
|
||||
max-width: 30px;
|
||||
min-width: var(--table-image-preview-min-size, 20px);
|
||||
max-width: var(--table-image-preview-max-size, 35px);
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
|
||||
.part-table-image {
|
||||
max-height: 40px;
|
||||
max-height: calc(1.2*var(--table-image-preview-max-size, 35px)); /** Aspect ratio of maximum 1.2 */
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/****************************************
|
||||
* Action bar
|
||||
****************************************/
|
||||
|
||||
.sticky-select-bar {
|
||||
position: sticky;
|
||||
top: 120px;
|
||||
z-index: 1000; /* Ensure the bar is above other content */
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* Tables
|
||||
****************************************/
|
||||
@@ -109,4 +119,4 @@ Classes for Datatables export
|
||||
#export-messageTop,
|
||||
.export-helper{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
--ck-color-button-on-hover-background: var(--bs-secondary-bg);
|
||||
--ck-color-button-on-active-background: var(--bs-secondary-bg);
|
||||
--ck-color-button-on-disabled-background: var(--bs-secondary-bg);
|
||||
--ck-color-button-on-color: var(--bs-primary)
|
||||
--ck-color-button-on-color: var(--bs-primary);
|
||||
|
||||
}
|
||||
--ck-content-font-color: var(--ck-color-base-text);
|
||||
|
||||
}
|
||||
|
||||
41
assets/css/components/toggle_password.css
Normal file
41
assets/css/components/toggle_password.css
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.toggle-password-container {
|
||||
position: relative;
|
||||
}
|
||||
.toggle-password-icon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
.toggle-password-button {
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
column-gap: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: 0.875rem;
|
||||
justify-items: center;
|
||||
height: 1rem;
|
||||
line-height: 1.25rem;
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: -1.25rem;
|
||||
}
|
||||
@@ -75,11 +75,10 @@
|
||||
request._dt = config.name;
|
||||
|
||||
//Try to resolve the original column index when the column was reordered (using the ColReorder plugin)
|
||||
//Only do this when _ColReorder_iOrigCol is available
|
||||
if (settings.aoColumns && settings.aoColumns.length && settings.aoColumns[0]._ColReorder_iOrigCol !== undefined) {
|
||||
if (dt.colReorder && dt.colReorder.transpose) {
|
||||
if (request.order && request.order.length) {
|
||||
request.order.forEach(function (order) {
|
||||
order.column = settings.aoColumns[order.column]._ColReorder_iOrigCol;
|
||||
order.column = dt.colReorder.transpose(order.column, "toOriginal");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
"doctrine/doctrine-migrations-bundle": "^3.0",
|
||||
"doctrine/orm": "^3.2.0",
|
||||
"dompdf/dompdf": "^v3.0.0",
|
||||
"florianv/swap": "^4.0",
|
||||
"florianv/swap-bundle": "dev-master",
|
||||
"part-db/swap-bundle": "^6.0.0",
|
||||
"gregwar/captcha-bundle": "^2.1.0",
|
||||
"hshn/base64-encoded-file": "^5.0",
|
||||
"jbtronics/2fa-webauthn": "^3.0.0",
|
||||
@@ -80,7 +79,6 @@
|
||||
"symfony/string": "7.3.*",
|
||||
"symfony/translation": "7.3.*",
|
||||
"symfony/twig-bundle": "7.3.*",
|
||||
"symfony/ux-toggle-password": "^2.29",
|
||||
"symfony/ux-translator": "^2.10",
|
||||
"symfony/ux-turbo": "^2.0",
|
||||
"symfony/validator": "7.3.*",
|
||||
|
||||
787
composer.lock
generated
787
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -33,5 +33,4 @@ return [
|
||||
Jbtronics\SettingsBundle\JbtronicsSettingsBundle::class => ['all' => true],
|
||||
Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true],
|
||||
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
|
||||
Symfony\UX\TogglePassword\TogglePasswordBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -18,7 +18,7 @@ datatables:
|
||||
>
|
||||
<'row' <'col mt-2 input-group flex-nowrap' B l > <'col-auto mt-2' < p >>>"
|
||||
pagingType: 'simple_numbers'
|
||||
searching: true
|
||||
searching: false
|
||||
stateSave: true
|
||||
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ when@docker:
|
||||
excluded_http_codes: [404, 405]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
include_stacktraces: true
|
||||
channels: ["!deprecation"]
|
||||
nested:
|
||||
type: stream
|
||||
path: "php://stderr"
|
||||
|
||||
@@ -69,9 +69,3 @@ nelmio_security:
|
||||
- 'data:'
|
||||
block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport
|
||||
# upgrade-insecure-requests: true # defaults to false, upgrades HTTP requests to HTTPS transport
|
||||
|
||||
when@dev:
|
||||
# disables the Content-Security-Policy header
|
||||
nelmio_security:
|
||||
csp:
|
||||
enabled: false
|
||||
@@ -5,4 +5,11 @@ jbtronics_settings:
|
||||
default_cacheable: true
|
||||
|
||||
orm_storage:
|
||||
default_entity_class: App\Entity\SettingsEntry
|
||||
default_entity_class: App\Entity\SettingsEntry
|
||||
|
||||
|
||||
# Disable caching for development environment
|
||||
when@dev:
|
||||
jbtronics_settings:
|
||||
cache:
|
||||
default_cacheable: false
|
||||
|
||||
@@ -5,6 +5,12 @@ florianv_swap:
|
||||
|
||||
providers:
|
||||
european_central_bank: ~ # European Central Bank (only works for EUR base currency)
|
||||
fixer: # Fixer.io (needs an API key)
|
||||
access_key: "%env(string:default:settings:exchange_rate:fixerApiKey:INVALID)%"
|
||||
#exchange_rates_api: ~
|
||||
central_bank_of_czech_republic: ~
|
||||
central_bank_of_republic_turkey: ~
|
||||
national_bank_of_romania: ~
|
||||
|
||||
fixer: # Fixer.io (needs an API key)
|
||||
access_key: "%env(string:settings:exchange_rate:fixerApiKey)%"
|
||||
|
||||
frankfurter: ~
|
||||
fawazahmed_currency_api: ~
|
||||
|
||||
@@ -359,6 +359,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
|
||||
label: "perm.revert_elements"
|
||||
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles', 'delete_profiles']
|
||||
apiTokenRole: ROLE_API_EDIT
|
||||
import:
|
||||
label: "perm.import"
|
||||
alsoSet: ['read_profiles', 'edit_profiles', 'create_profiles' ]
|
||||
apiTokenRole: ROLE_API_EDIT
|
||||
|
||||
api:
|
||||
label: "perm.api"
|
||||
|
||||
@@ -142,28 +142,12 @@ services:
|
||||
# This feature is currently experimental, so use it at your own risk!
|
||||
# - DB_AUTOMIGRATE=true
|
||||
|
||||
# You can configure Part-DB using environment variables
|
||||
# Below you can find the most essential ones predefined
|
||||
# You can configure Part-DB using the webUI or environment variables
|
||||
# However you can add add any other environment configuration you want here
|
||||
# See .env file for all available options or https://docs.part-db.de/configuration.html
|
||||
|
||||
# The language to use serverwide as default (en, de, ru, etc.)
|
||||
- DEFAULT_LANG=en
|
||||
# The default timezone to use serverwide (e.g. Europe/Berlin)
|
||||
- DEFAULT_TIMEZONE=Europe/Berlin
|
||||
# The currency that is used inside the DB (and is assumed when no currency is set). This can not be changed later, so be sure to set it the currency used in your country
|
||||
- BASE_CURRENCY=EUR
|
||||
# The name of this installation. This will be shown as title in the browser and in the header of the website
|
||||
- INSTANCE_NAME=Part-DB
|
||||
|
||||
# Allow users to download attachments to the server by providing an URL
|
||||
# This could be a potential security issue, as the user can retrieve any file the server has access to (via internet)
|
||||
- ALLOW_ATTACHMENT_DOWNLOADS=0
|
||||
# Use gravatars for user avatars, when user has no own avatar defined
|
||||
- USE_GRAVATAR=0
|
||||
|
||||
# Override value if you want to show to show a given text on homepage.
|
||||
# When this is empty the content of config/banner.md is used as banner
|
||||
# When this is outcommented the webUI can be used to configure the banner
|
||||
#- BANNER=This is a test banner<br>with a line break
|
||||
|
||||
database:
|
||||
|
||||
@@ -28,9 +28,14 @@ It is recommended to install Part-DB on a 64-bit system, as the 32-bit version o
|
||||
For the installation of Part-DB, we need some prerequisites. They can be installed by running the following command:
|
||||
|
||||
```bash
|
||||
sudo apt install git curl zip ca-certificates software-properties-common apt-transport-https lsb-release nano wget
|
||||
sudo apt update && apt upgrade
|
||||
sudo apt install git curl zip ca-certificates software-properties-common \
|
||||
apt-transport-https lsb-release nano wget sqlite3
|
||||
```
|
||||
|
||||
Please run `sqlite3 --version` to assert that the SQLite version is 3.35 or higher.
|
||||
Otherwise some database migrations will not succeed.
|
||||
|
||||
### Install PHP and apache2
|
||||
|
||||
Part-DB is written in [PHP](https://php.net) and therefore needs a PHP interpreter to run. Part-DB needs PHP 8.2 or
|
||||
|
||||
@@ -3,6 +3,7 @@ layout: default
|
||||
title: Upgrade from Part-DB 1.x to 2.x
|
||||
nav_order: 1
|
||||
has_children: false
|
||||
parent: Upgrade
|
||||
---
|
||||
|
||||
# Upgrade from Part-DB 1.x to 2.x
|
||||
|
||||
@@ -3,6 +3,7 @@ layout: default
|
||||
title: Upgrade from legacy Part-DB version (<1.0)
|
||||
nav_order: 100
|
||||
redirect_from: /upgrade_legacy
|
||||
parent: Upgrade
|
||||
---
|
||||
|
||||
# Upgrade from legacy Part-DB version
|
||||
|
||||
@@ -34,3 +34,12 @@ select the BOM file you want to import and some options for the import process:
|
||||
has a different format and does not work with this type.
|
||||
You can generate this BOM file by going to "File" -> "Fabrication Outputs" -> "Bill of Materials" in Pcbnew and save
|
||||
the file to your desired location.
|
||||
* **KiCAD Schematic BOM (CSV file)**: A CSV file of the Bill of Material (BOM) generated
|
||||
by [KiCAD Eeschema](https://www.kicad.org/).
|
||||
You can generate this BOM file by going to "Tools" -> "Generate Bill of Materials" in Eeschema and save the file to your
|
||||
desired location. In the next step you can customize the mapping of the fields in Part-DB, if you have any special fields
|
||||
in your BOM to locate your fields correctly.
|
||||
* **Generic CSV file**: A generic CSV file. You can use this option if you use some different ECAD software or wanna create
|
||||
your own CSV file. You will need to specify at least the designators, quantity and value fields in the CSV. In the next
|
||||
step you can customize the mapping of the fields in Part-DB, if you have any special fields in your BOM to locate your
|
||||
parts correctly.
|
||||
|
||||
@@ -20,7 +20,7 @@ Part-DB. Data can also be exported from Part-DB into various formats.
|
||||
> individually in the permissions settings.
|
||||
|
||||
If you want to import data from PartKeepr you might want to look into the [PartKeepr migration guide]({% link
|
||||
upgrade_legacy.md %}).
|
||||
upgrade/upgrade_legacy.md %}).
|
||||
|
||||
### Import parts
|
||||
|
||||
@@ -158,4 +158,4 @@ information, this can lead to very large export files.
|
||||
You can export parts in all part tables. Select the parts you want via the checkbox in the table line and select the
|
||||
export format and level in the appearing menu.
|
||||
|
||||
See the section about exporting data structures for more information about the export formats and levels.
|
||||
See the section about exporting data structures for more information about the export formats and levels.
|
||||
|
||||
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!"
|
||||
@@ -7,7 +7,6 @@
|
||||
"@hotwired/turbo": "^8.0.1",
|
||||
"@popperjs/core": "^2.10.2",
|
||||
"@symfony/stimulus-bridge": "^4.0.0",
|
||||
"@symfony/ux-toggle-password": "file:vendor/symfony/ux-toggle-password/assets",
|
||||
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
|
||||
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
|
||||
"@symfony/webpack-encore": "^5.0.0",
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace App\Controller;
|
||||
|
||||
use App\DataTables\AttachmentDataTable;
|
||||
use App\DataTables\Filters\AttachmentFilter;
|
||||
use App\DataTables\PartsDataTable;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Form\Filters\AttachmentFilterType;
|
||||
use App\Services\Attachments\AttachmentManager;
|
||||
@@ -112,7 +113,7 @@ class AttachmentFileController extends AbstractController
|
||||
|
||||
$filterForm->handleRequest($formRequest);
|
||||
|
||||
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize])
|
||||
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
|
||||
->handleRequest($request);
|
||||
|
||||
if ($table->isCallback()) {
|
||||
|
||||
@@ -30,6 +30,7 @@ use App\Services\InfoProviderSystem\ExistingPartFinder;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Settings\AppSettings;
|
||||
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
|
||||
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
|
||||
@@ -113,7 +114,7 @@ class InfoProviderController extends AbstractController
|
||||
|
||||
#[Route('/search', name: 'info_providers_search')]
|
||||
#[Route('/update/{target}', name: 'info_providers_update_part_search')]
|
||||
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger): Response
|
||||
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger, InfoProviderGeneralSettings $infoProviderSettings): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
@@ -144,6 +145,23 @@ class InfoProviderController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
//If the providers form is still empty, use our default value from the settings
|
||||
if (count($form->get('providers')->getData() ?? []) === 0) {
|
||||
$default_providers = $infoProviderSettings->defaultSearchProviders;
|
||||
$provider_objects = [];
|
||||
foreach ($default_providers as $provider_key) {
|
||||
try {
|
||||
$tmp = $this->providerRegistry->getProviderByKey($provider_key);
|
||||
if ($tmp->isActive()) {
|
||||
$provider_objects[] = $tmp;
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
//If the provider is not found, just ignore it
|
||||
}
|
||||
}
|
||||
$form->get('providers')->setData($provider_objects);
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$keyword = $form->get('keyword')->getData();
|
||||
$providers = $form->get('providers')->getData();
|
||||
|
||||
@@ -58,12 +58,15 @@ use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
#[Route(path: '/label')]
|
||||
class LabelController extends AbstractController
|
||||
{
|
||||
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator)
|
||||
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator,
|
||||
private readonly ValidatorInterface $validator
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -85,6 +88,7 @@ class LabelController extends AbstractController
|
||||
|
||||
$form = $this->createForm(LabelDialogType::class, null, [
|
||||
'disable_options' => $disable_options,
|
||||
'profile' => $profile
|
||||
]);
|
||||
|
||||
//Try to parse given target_type and target_id
|
||||
@@ -120,13 +124,50 @@ class LabelController extends AbstractController
|
||||
goto render;
|
||||
}
|
||||
|
||||
$profile = new LabelProfile();
|
||||
$profile->setName($form->get('save_profile_name')->getData());
|
||||
$profile->setOptions($form_options);
|
||||
$this->em->persist($profile);
|
||||
$new_profile = new LabelProfile();
|
||||
$new_profile->setName($form->get('save_profile_name')->getData());
|
||||
$new_profile->setOptions($form_options);
|
||||
|
||||
//Validate the profile name
|
||||
$errors = $this->validator->validate($new_profile);
|
||||
if (count($errors) > 0) {
|
||||
foreach ($errors as $error) {
|
||||
$form->get('save_profile_name')->addError(new FormError($error->getMessage()));
|
||||
}
|
||||
goto render;
|
||||
}
|
||||
|
||||
$this->em->persist($new_profile);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'label_generator.profile_saved');
|
||||
|
||||
return $this->redirectToRoute('label_dialog_profile', [
|
||||
'profile' => $new_profile->getID(),
|
||||
'target_id' => (string) $form->get('target_id')->getData()
|
||||
]);
|
||||
}
|
||||
|
||||
//Check if the current profile should be updated
|
||||
if ($form->has('update_profile')
|
||||
&& $form->get('update_profile')->isClicked() //@phpstan-ignore-line Phpstan does not recognize the isClicked method
|
||||
&& $profile instanceof LabelProfile
|
||||
&& $this->isGranted('edit', $profile)) {
|
||||
//Update the profile options
|
||||
$profile->setOptions($form_options);
|
||||
|
||||
//Validate the profile name
|
||||
$errors = $this->validator->validate($profile);
|
||||
if (count($errors) > 0) {
|
||||
foreach ($errors as $error) {
|
||||
$this->addFlash('error', $error->getMessage());
|
||||
}
|
||||
goto render;
|
||||
}
|
||||
|
||||
$this->em->persist($profile);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'label_generator.profile_updated');
|
||||
|
||||
return $this->redirectToRoute('label_dialog_profile', [
|
||||
'profile' => $profile->getID(),
|
||||
'target_id' => (string) $form->get('target_id')->getData()
|
||||
|
||||
@@ -46,6 +46,7 @@ use App\Services\Parameters\ParameterExtractor;
|
||||
use App\Services\Parts\PartLotWithdrawAddHelper;
|
||||
use App\Services\Parts\PricedetailHelper;
|
||||
use App\Services\ProjectSystem\ProjectBuildPartHelper;
|
||||
use App\Settings\BehaviorSettings\PartInfoSettings;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
@@ -69,7 +70,7 @@ class PartController extends AbstractController
|
||||
protected PartPreviewGenerator $partPreviewGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
|
||||
protected EventCommentHelper $commentHelper)
|
||||
protected EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -119,8 +120,8 @@ class PartController extends AbstractController
|
||||
'pricedetail_helper' => $this->pricedetailHelper,
|
||||
'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part),
|
||||
'timeTravel' => $timeTravel_timestamp,
|
||||
'description_params' => $parameterExtractor->extractParameters($part->getDescription()),
|
||||
'comment_params' => $parameterExtractor->extractParameters($part->getComment()),
|
||||
'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
|
||||
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
|
||||
'withdraw_add_helper' => $withdrawAddHelper,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -161,7 +161,9 @@ class PartListsController extends AbstractController
|
||||
|
||||
$filterForm->handleRequest($formRequest);
|
||||
|
||||
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(['filter' => $filter], $additional_table_vars), ['pageLength' => $this->tableSettings->fullDefaultPageSize])
|
||||
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(
|
||||
['filter' => $filter], $additional_table_vars),
|
||||
['pageLength' => $this->tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
|
||||
->handleRequest($request);
|
||||
|
||||
if ($table->isCallback()) {
|
||||
|
||||
@@ -36,6 +36,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Csv\SyntaxError;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
@@ -102,9 +103,14 @@ class ProjectController extends AbstractController
|
||||
$this->addFlash('success', 'project.build.flash.success');
|
||||
|
||||
return $this->redirect(
|
||||
$request->get('_redirect',
|
||||
$this->generateUrl('project_info', ['id' => $project->getID()]
|
||||
)));
|
||||
$request->get(
|
||||
'_redirect',
|
||||
$this->generateUrl(
|
||||
'project_info',
|
||||
['id' => $project->getID()]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$this->addFlash('error', 'project.build.flash.invalid_input');
|
||||
@@ -120,9 +126,13 @@ class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
|
||||
public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project,
|
||||
BOMImporter $BOMImporter, ValidatorInterface $validator): Response
|
||||
{
|
||||
public function importBOM(
|
||||
Request $request,
|
||||
EntityManagerInterface $entityManager,
|
||||
Project $project,
|
||||
BOMImporter $BOMImporter,
|
||||
ValidatorInterface $validator
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('edit', $project);
|
||||
|
||||
$builder = $this->createFormBuilder();
|
||||
@@ -138,6 +148,8 @@ class ProjectController extends AbstractController
|
||||
'required' => true,
|
||||
'choices' => [
|
||||
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
|
||||
'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
|
||||
'project.bom_import.type.generic_csv' => 'generic_csv',
|
||||
]
|
||||
]);
|
||||
$builder->add('clear_existing_bom', CheckboxType::class, [
|
||||
@@ -161,25 +173,40 @@ class ProjectController extends AbstractController
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
$import_type = $form->get('type')->getData();
|
||||
|
||||
try {
|
||||
// For schematic imports, redirect to field mapping step
|
||||
if (in_array($import_type, ['kicad_schematic', 'generic_csv'], true)) {
|
||||
// Store file content and options in session for field mapping step
|
||||
$file_content = $form->get('file')->getData()->getContent();
|
||||
$clear_existing = $form->get('clear_existing_bom')->getData();
|
||||
|
||||
$request->getSession()->set('bom_import_data', $file_content);
|
||||
$request->getSession()->set('bom_import_clear', $clear_existing);
|
||||
|
||||
return $this->redirectToRoute('project_import_bom_map_fields', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
// For PCB imports, proceed directly
|
||||
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
|
||||
'type' => $form->get('type')->getData(),
|
||||
'type' => $import_type,
|
||||
]);
|
||||
|
||||
//Validate the project entries
|
||||
// Validate the project entries
|
||||
$errors = $validator->validateProperty($project, 'bom_entries');
|
||||
|
||||
//If no validation errors occured, save the changes and redirect to edit page
|
||||
if (count ($errors) === 0) {
|
||||
// If no validation errors occurred, save the changes and redirect to edit page
|
||||
if (count($errors) === 0) {
|
||||
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
|
||||
$entityManager->flush();
|
||||
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
//When we get here, there were validation errors
|
||||
// When we get here, there were validation errors
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
|
||||
|
||||
} catch (\UnexpectedValueException|SyntaxError $e) {
|
||||
} catch (\UnexpectedValueException | SyntaxError $e) {
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
@@ -191,11 +218,267 @@ class ProjectController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/import_bom/map_fields', name: 'project_import_bom_map_fields', requirements: ['id' => '\d+'])]
|
||||
public function importBOMMapFields(
|
||||
Request $request,
|
||||
EntityManagerInterface $entityManager,
|
||||
Project $project,
|
||||
BOMImporter $BOMImporter,
|
||||
ValidatorInterface $validator,
|
||||
LoggerInterface $logger
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('edit', $project);
|
||||
|
||||
// Get stored data from session
|
||||
$file_content = $request->getSession()->get('bom_import_data');
|
||||
$clear_existing = $request->getSession()->get('bom_import_clear', false);
|
||||
|
||||
|
||||
if (!$file_content) {
|
||||
$this->addFlash('error', 'project.bom_import.flash.session_expired');
|
||||
return $this->redirectToRoute('project_import_bom', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
// Detect fields and get suggestions
|
||||
$detected_fields = $BOMImporter->detectFields($file_content);
|
||||
$suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
|
||||
|
||||
// Create mapping of original field names to sanitized field names for template
|
||||
$field_name_mapping = [];
|
||||
foreach ($detected_fields as $field) {
|
||||
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
|
||||
$field_name_mapping[$field] = $sanitized_field;
|
||||
}
|
||||
|
||||
// Create form for field mapping
|
||||
$builder = $this->createFormBuilder();
|
||||
|
||||
// Add delimiter selection
|
||||
$builder->add('delimiter', ChoiceType::class, [
|
||||
'label' => 'project.bom_import.delimiter',
|
||||
'required' => true,
|
||||
'data' => ',',
|
||||
'choices' => [
|
||||
'project.bom_import.delimiter.comma' => ',',
|
||||
'project.bom_import.delimiter.semicolon' => ';',
|
||||
'project.bom_import.delimiter.tab' => "\t",
|
||||
]
|
||||
]);
|
||||
|
||||
// Get dynamic field mapping targets from BOMImporter
|
||||
$available_targets = $BOMImporter->getAvailableFieldTargets();
|
||||
$target_fields = ['project.bom_import.field_mapping.ignore' => ''];
|
||||
|
||||
foreach ($available_targets as $target_key => $target_info) {
|
||||
$target_fields[$target_info['label']] = $target_key;
|
||||
}
|
||||
|
||||
foreach ($detected_fields as $field) {
|
||||
// Sanitize field name for form use - replace invalid characters with underscores
|
||||
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
|
||||
$builder->add('mapping_' . $sanitized_field, ChoiceType::class, [
|
||||
'label' => $field,
|
||||
'required' => false,
|
||||
'choices' => $target_fields,
|
||||
'data' => $suggested_mapping[$field] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'project.bom_import.preview',
|
||||
]);
|
||||
|
||||
$form = $builder->getForm();
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// Build field mapping array with priority support
|
||||
$field_mapping = [];
|
||||
$field_priorities = [];
|
||||
$delimiter = $form->get('delimiter')->getData();
|
||||
|
||||
foreach ($detected_fields as $field) {
|
||||
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
|
||||
$target = $form->get('mapping_' . $sanitized_field)->getData();
|
||||
if (!empty($target)) {
|
||||
$field_mapping[$field] = $target;
|
||||
|
||||
// Get priority from request (default to 10)
|
||||
$priority = $request->request->get('priority_' . $sanitized_field, 10);
|
||||
$field_priorities[$field] = (int) $priority;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field mapping
|
||||
$validation = $BOMImporter->validateFieldMapping($field_mapping, $detected_fields);
|
||||
|
||||
if (!$validation['is_valid']) {
|
||||
foreach ($validation['errors'] as $error) {
|
||||
$this->addFlash('error', $error);
|
||||
}
|
||||
foreach ($validation['warnings'] as $warning) {
|
||||
$this->addFlash('warning', $warning);
|
||||
}
|
||||
|
||||
return $this->render('projects/import_bom_map_fields.html.twig', [
|
||||
'project' => $project,
|
||||
'form' => $form->createView(),
|
||||
'detected_fields' => $detected_fields,
|
||||
'suggested_mapping' => $suggested_mapping,
|
||||
'field_name_mapping' => $field_name_mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
// Show warnings but continue
|
||||
foreach ($validation['warnings'] as $warning) {
|
||||
$this->addFlash('warning', $warning);
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-detect fields with chosen delimiter
|
||||
$detected_fields = $BOMImporter->detectFields($file_content, $delimiter);
|
||||
|
||||
// Clear existing BOM entries if requested
|
||||
if ($clear_existing) {
|
||||
$existing_count = $project->getBomEntries()->count();
|
||||
$logger->info('Clearing existing BOM entries', [
|
||||
'existing_count' => $existing_count,
|
||||
'project_id' => $project->getID(),
|
||||
]);
|
||||
$project->getBomEntries()->clear();
|
||||
$entityManager->flush();
|
||||
$logger->info('Existing BOM entries cleared');
|
||||
} else {
|
||||
$existing_count = $project->getBomEntries()->count();
|
||||
$logger->info('Keeping existing BOM entries', [
|
||||
'existing_count' => $existing_count,
|
||||
'project_id' => $project->getID(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Validate data before importing
|
||||
$validation_result = $BOMImporter->validateBOMData($file_content, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'field_priorities' => $field_priorities,
|
||||
'delimiter' => $delimiter,
|
||||
]);
|
||||
|
||||
// Log validation results
|
||||
$logger->info('BOM import validation completed', [
|
||||
'total_entries' => $validation_result['total_entries'],
|
||||
'valid_entries' => $validation_result['valid_entries'],
|
||||
'invalid_entries' => $validation_result['invalid_entries'],
|
||||
'error_count' => count($validation_result['errors']),
|
||||
'warning_count' => count($validation_result['warnings']),
|
||||
]);
|
||||
|
||||
// Show validation warnings to user
|
||||
foreach ($validation_result['warnings'] as $warning) {
|
||||
$this->addFlash('warning', $warning);
|
||||
}
|
||||
|
||||
// If there are validation errors, show them and stop
|
||||
if (!empty($validation_result['errors'])) {
|
||||
foreach ($validation_result['errors'] as $error) {
|
||||
$this->addFlash('error', $error);
|
||||
}
|
||||
|
||||
return $this->render('projects/import_bom_map_fields.html.twig', [
|
||||
'project' => $project,
|
||||
'form' => $form->createView(),
|
||||
'detected_fields' => $detected_fields,
|
||||
'suggested_mapping' => $suggested_mapping,
|
||||
'field_name_mapping' => $field_name_mapping,
|
||||
'validation_result' => $validation_result,
|
||||
]);
|
||||
}
|
||||
|
||||
// Import with field mapping and priorities (validation already passed)
|
||||
$entries = $BOMImporter->stringToBOMEntries($file_content, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'field_priorities' => $field_priorities,
|
||||
'delimiter' => $delimiter,
|
||||
]);
|
||||
|
||||
// Log entry details for debugging
|
||||
$logger->info('BOM entries created', [
|
||||
'total_entries' => count($entries),
|
||||
]);
|
||||
|
||||
foreach ($entries as $index => $entry) {
|
||||
$logger->debug("BOM entry {$index}", [
|
||||
'name' => $entry->getName(),
|
||||
'mountnames' => $entry->getMountnames(),
|
||||
'quantity' => $entry->getQuantity(),
|
||||
'comment' => $entry->getComment(),
|
||||
'part_id' => $entry->getPart()?->getID(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Assign entries to project
|
||||
$logger->info('Adding BOM entries to project', [
|
||||
'entries_count' => count($entries),
|
||||
'project_id' => $project->getID(),
|
||||
]);
|
||||
|
||||
foreach ($entries as $index => $entry) {
|
||||
$logger->debug("Adding BOM entry {$index} to project", [
|
||||
'name' => $entry->getName(),
|
||||
'part_id' => $entry->getPart()?->getID(),
|
||||
'quantity' => $entry->getQuantity(),
|
||||
]);
|
||||
$project->addBomEntry($entry);
|
||||
}
|
||||
|
||||
// Validate the project entries (includes collection constraints)
|
||||
$errors = $validator->validateProperty($project, 'bom_entries');
|
||||
|
||||
// If no validation errors occurred, save and redirect
|
||||
if (count($errors) === 0) {
|
||||
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
|
||||
$entityManager->flush();
|
||||
|
||||
// Clear session data
|
||||
$request->getSession()->remove('bom_import_data');
|
||||
$request->getSession()->remove('bom_import_clear');
|
||||
|
||||
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
// When we get here, there were validation errors
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
|
||||
|
||||
//Print validation errors to log for debugging
|
||||
foreach ($errors as $error) {
|
||||
$logger->error('BOM entry validation error', [
|
||||
'message' => $error->getMessage(),
|
||||
'invalid_value' => $error->getInvalidValue(),
|
||||
]);
|
||||
//And show as flash message
|
||||
$this->addFlash('error', $error->getMessage(),);
|
||||
}
|
||||
|
||||
} catch (\UnexpectedValueException | SyntaxError $e) {
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('projects/import_bom_map_fields.html.twig', [
|
||||
'project' => $project,
|
||||
'form' => $form,
|
||||
'detected_fields' => $detected_fields,
|
||||
'suggested_mapping' => $suggested_mapping,
|
||||
'field_name_mapping' => $field_name_mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/add_parts', name: 'project_add_parts_no_id')]
|
||||
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
|
||||
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
|
||||
{
|
||||
if($project instanceof Project) {
|
||||
if ($project instanceof Project) {
|
||||
$this->denyAccessUnlessGranted('edit', $project);
|
||||
} else {
|
||||
$this->denyAccessUnlessGranted('@projects.edit');
|
||||
@@ -242,7 +525,7 @@ class ProjectController extends AbstractController
|
||||
|
||||
$data = $form->getData();
|
||||
$bom_entries = $data['bom_entries'];
|
||||
foreach ($bom_entries as $bom_entry){
|
||||
foreach ($bom_entries as $bom_entry) {
|
||||
$target_project->addBOMEntry($bom_entry);
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ declare(strict_types=1);
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Form;
|
||||
namespace App\Form\Extension;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -1,4 +1,22 @@
|
||||
<?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);
|
||||
|
||||
@@ -20,7 +38,7 @@ declare(strict_types=1);
|
||||
* 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\Form;
|
||||
namespace App\Form\Extension;
|
||||
|
||||
use Symfony\Component\Form\AbstractTypeExtension;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
@@ -21,7 +21,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Form;
|
||||
namespace App\Form\Extension;
|
||||
|
||||
use Symfony\Component\Form\AbstractTypeExtension;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
@@ -57,4 +57,4 @@ class SelectTypeOrderExtension extends AbstractTypeExtension
|
||||
$view->vars['attr']['data-ordered-value'] = json_encode($form->getViewData(), JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/Form/Extension/TogglePasswordTypeExtension.php
Normal file
122
src/Form/Extension/TogglePasswordTypeExtension.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?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\Form\Extension;
|
||||
|
||||
use Symfony\Component\Form\AbstractTypeExtension;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class TogglePasswordTypeExtension extends AbstractTypeExtension
|
||||
{
|
||||
public function __construct(private readonly ?TranslatorInterface $translator)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getExtendedTypes(): iterable
|
||||
{
|
||||
return [PasswordType::class];
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'toggle' => false,
|
||||
'hidden_label' => 'Hide',
|
||||
'visible_label' => 'Show',
|
||||
'hidden_icon' => 'Default',
|
||||
'visible_icon' => 'Default',
|
||||
'button_classes' => ['toggle-password-button'],
|
||||
'toggle_container_classes' => ['toggle-password-container'],
|
||||
'toggle_translation_domain' => null,
|
||||
'use_toggle_form_theme' => true,
|
||||
]);
|
||||
|
||||
$resolver->setNormalizer(
|
||||
'toggle_translation_domain',
|
||||
static fn (Options $options, $labelTranslationDomain) => $labelTranslationDomain ?? $options['translation_domain'],
|
||||
);
|
||||
|
||||
$resolver->setAllowedTypes('toggle', ['bool']);
|
||||
$resolver->setAllowedTypes('hidden_label', ['string', TranslatableMessage::class, 'null']);
|
||||
$resolver->setAllowedTypes('visible_label', ['string', TranslatableMessage::class, 'null']);
|
||||
$resolver->setAllowedTypes('hidden_icon', ['string', 'null']);
|
||||
$resolver->setAllowedTypes('visible_icon', ['string', 'null']);
|
||||
$resolver->setAllowedTypes('button_classes', ['string[]']);
|
||||
$resolver->setAllowedTypes('toggle_container_classes', ['string[]']);
|
||||
$resolver->setAllowedTypes('toggle_translation_domain', ['string', 'bool', 'null']);
|
||||
$resolver->setAllowedTypes('use_toggle_form_theme', ['bool']);
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$view->vars['toggle'] = $options['toggle'];
|
||||
|
||||
if (!$options['toggle']) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($options['use_toggle_form_theme']) {
|
||||
array_splice($view->vars['block_prefixes'], -1, 0, 'toggle_password');
|
||||
}
|
||||
|
||||
$controllerName = 'toggle-password';
|
||||
$controllerValues = [];
|
||||
$view->vars['attr']['data-controller'] = trim(\sprintf('%s %s', $view->vars['attr']['data-controller'] ?? '', $controllerName));
|
||||
|
||||
if (false !== $options['toggle_translation_domain']) {
|
||||
$controllerValues['hidden-label'] = $this->translateLabel($options['hidden_label'], $options['toggle_translation_domain']);
|
||||
$controllerValues['visible-label'] = $this->translateLabel($options['visible_label'], $options['toggle_translation_domain']);
|
||||
} else {
|
||||
$controllerValues['hidden-label'] = $options['hidden_label'];
|
||||
$controllerValues['visible-label'] = $options['visible_label'];
|
||||
}
|
||||
|
||||
$controllerValues['hidden-icon'] = $options['hidden_icon'];
|
||||
$controllerValues['visible-icon'] = $options['visible_icon'];
|
||||
$controllerValues['button-classes'] = json_encode($options['button_classes'], \JSON_THROW_ON_ERROR);
|
||||
|
||||
foreach ($controllerValues as $name => $value) {
|
||||
$view->vars['attr'][\sprintf('data-%s-%s-value', $controllerName, $name)] = $value;
|
||||
}
|
||||
|
||||
$view->vars['toggle_container_classes'] = $options['toggle_container_classes'];
|
||||
}
|
||||
|
||||
private function translateLabel(string|TranslatableMessage|null $label, ?string $translationDomain): ?string
|
||||
{
|
||||
if (null === $this->translator || null === $label) {
|
||||
return $label;
|
||||
}
|
||||
|
||||
if ($label instanceof TranslatableMessage) {
|
||||
return $label->trans($this->translator);
|
||||
}
|
||||
|
||||
return $this->translator->trans($label, domain: $translationDomain);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\ChoiceList\ChoiceList;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ProviderSelectType extends AbstractType
|
||||
@@ -44,13 +45,43 @@ class ProviderSelectType extends AbstractType
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'choices' => $this->providerRegistry->getActiveProviders(),
|
||||
'choice_label' => ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']),
|
||||
'choice_value' => ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()),
|
||||
$providers = $this->providerRegistry->getActiveProviders();
|
||||
|
||||
'multiple' => true,
|
||||
]);
|
||||
$resolver->setDefault('input', 'object');
|
||||
$resolver->setAllowedTypes('input', 'string');
|
||||
//Either the form returns the provider objects or their keys
|
||||
$resolver->setAllowedValues('input', ['object', 'string']);
|
||||
$resolver->setDefault('multiple', true);
|
||||
|
||||
$resolver->setDefault('choices', function (Options $options) use ($providers) {
|
||||
if ('object' === $options['input']) {
|
||||
return $this->providerRegistry->getActiveProviders();
|
||||
}
|
||||
|
||||
$tmp = [];
|
||||
foreach ($providers as $provider) {
|
||||
$name = $provider->getProviderInfo()['name'];
|
||||
$tmp[$name] = $provider->getProviderKey();
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
});
|
||||
|
||||
//The choice_label and choice_value only needs to be set if we want the objects
|
||||
$resolver->setDefault('choice_label', function (Options $options){
|
||||
if ('object' === $options['input']) {
|
||||
return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
$resolver->setDefault('choice_value', function (Options $options) {
|
||||
if ('object' === $options['input']) {
|
||||
return ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,16 @@ class LabelDialogType extends AbstractType
|
||||
]
|
||||
]);
|
||||
|
||||
if ($options['profile'] !== null) {
|
||||
$builder->add('update_profile', SubmitType::class, [
|
||||
'label' => 'label_generator.update_profile',
|
||||
'disabled' => !$this->security->isGranted('edit', $options['profile']),
|
||||
'attr' => [
|
||||
'class' => 'btn btn-outline-success'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
$builder->add('update', SubmitType::class, [
|
||||
'label' => 'label_generator.update',
|
||||
]);
|
||||
@@ -97,5 +107,6 @@ class LabelDialogType extends AbstractType
|
||||
parent::configureOptions($resolver);
|
||||
$resolver->setDefault('mapped', false);
|
||||
$resolver->setDefault('disable_options', false);
|
||||
$resolver->setDefault('profile', null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ use App\Entity\Attachments\UserAttachment;
|
||||
use RuntimeException;
|
||||
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
use function in_array;
|
||||
@@ -56,7 +57,7 @@ final class AttachmentVoter extends Voter
|
||||
{
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
|
||||
//This voter only works for attachments
|
||||
@@ -65,7 +66,8 @@ final class AttachmentVoter extends Voter
|
||||
}
|
||||
|
||||
if ($attribute === 'show_private') {
|
||||
return $this->helper->isGranted($token, 'attachments', 'show_private');
|
||||
$vote?->addReason('User is not allowed to view private attachments.');
|
||||
return $this->helper->isGranted($token, 'attachments', 'show_private', $vote);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +113,8 @@ final class AttachmentVoter extends Voter
|
||||
throw new RuntimeException('Encountered unknown Parameter type: ' . $subject);
|
||||
}
|
||||
|
||||
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute));
|
||||
$vote?->addReason('User is not allowed to '.$this->mapOperation($attribute).' attachments of type '.$param.'.');
|
||||
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute), $vote);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace App\Security\Voter;
|
||||
use App\Entity\UserSystem\Group;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -43,9 +44,9 @@ final class GroupVoter extends Voter
|
||||
*
|
||||
* @param string $attribute
|
||||
*/
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
return $this->helper->isGranted($token, 'groups', $attribute);
|
||||
return $this->helper->isGranted($token, 'groups', $attribute, $vote);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace App\Security\Voter;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
@@ -47,9 +48,16 @@ final class ImpersonateUserVoter extends Voter
|
||||
&& $subject instanceof UserInterface;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
return $this->helper->isGranted($token, 'users', 'impersonate');
|
||||
$result = $this->helper->isGranted($token, 'users', 'impersonate');
|
||||
|
||||
if ($result === false) {
|
||||
$vote?->addReason('User is not allowed to impersonate other users.');
|
||||
$this->helper->addReason($vote, 'users', 'impersonate');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function supportsAttribute(string $attribute): bool
|
||||
@@ -61,4 +69,4 @@ final class ImpersonateUserVoter extends Voter
|
||||
{
|
||||
return is_a($subjectType, User::class, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace App\Security\Voter;
|
||||
use App\Entity\LabelSystem\LabelProfile;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -58,14 +59,15 @@ final class LabelProfileVoter extends Voter
|
||||
'delete' => 'delete_profiles',
|
||||
'show_history' => 'show_history',
|
||||
'revert_element' => 'revert_element',
|
||||
'import' => 'import',
|
||||
];
|
||||
|
||||
public function __construct(private readonly VoterHelper $helper)
|
||||
{}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute]);
|
||||
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute], $vote);
|
||||
}
|
||||
|
||||
protected function supports($attribute, $subject): bool
|
||||
|
||||
@@ -26,6 +26,7 @@ use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\LogSystem\AbstractLogEntry;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -39,7 +40,7 @@ final class LogEntryVoter extends Voter
|
||||
{
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $this->helper->resolveUser($token);
|
||||
|
||||
@@ -48,19 +49,19 @@ final class LogEntryVoter extends Voter
|
||||
}
|
||||
|
||||
if ('delete' === $attribute) {
|
||||
return $this->helper->isGranted($token, 'system', 'delete_logs');
|
||||
return $this->helper->isGranted($token, 'system', 'delete_logs', $vote);
|
||||
}
|
||||
|
||||
if ('read' === $attribute) {
|
||||
//Allow read of the users own log entries
|
||||
if (
|
||||
$subject->getUser() === $user
|
||||
&& $this->helper->isGranted($token, 'self', 'show_logs')
|
||||
&& $this->helper->isGranted($token, 'self', 'show_logs', $vote)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->helper->isGranted($token, 'system', 'show_logs');
|
||||
return $this->helper->isGranted($token, 'system', 'show_logs', $vote);
|
||||
}
|
||||
|
||||
if ('show_details' === $attribute) {
|
||||
|
||||
@@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -59,7 +60,7 @@ final class OrderdetailVoter extends Voter
|
||||
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
if (! is_a($subject, Orderdetail::class, true)) {
|
||||
throw new \RuntimeException('This voter can only handle Orderdetail objects!');
|
||||
@@ -75,7 +76,7 @@ final class OrderdetailVoter extends Voter
|
||||
|
||||
//If we have no part associated use the generic part permission
|
||||
if (is_string($subject) || !$subject->getPart() instanceof Part) {
|
||||
return $this->helper->isGranted($token, 'parts', $operation);
|
||||
return $this->helper->isGranted($token, 'parts', $operation, $vote);
|
||||
}
|
||||
|
||||
//Otherwise vote on the part
|
||||
|
||||
@@ -39,6 +39,7 @@ use App\Entity\Parameters\StorageLocationParameter;
|
||||
use App\Entity\Parameters\SupplierParameter;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,7 @@ final class ParameterVoter extends Voter
|
||||
{
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
|
||||
|
||||
@@ -108,7 +109,7 @@ final class ParameterVoter extends Voter
|
||||
throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? $subject::class : $subject));
|
||||
}
|
||||
|
||||
return $this->helper->isGranted($token, $param, $attribute);
|
||||
return $this->helper->isGranted($token, $param, $attribute, $vote);
|
||||
}
|
||||
|
||||
protected function supports(string $attribute, $subject): bool
|
||||
|
||||
@@ -46,6 +46,7 @@ use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Parts\Part;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -61,7 +62,7 @@ final class PartAssociationVoter extends Voter
|
||||
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
if (!is_string($subject) && !$subject instanceof PartAssociation) {
|
||||
throw new \RuntimeException('Invalid subject type!');
|
||||
@@ -77,7 +78,7 @@ final class PartAssociationVoter extends Voter
|
||||
|
||||
//If we have no part associated use the generic part permission
|
||||
if (is_string($subject) || !$subject->getOwner() instanceof Part) {
|
||||
return $this->helper->isGranted($token, 'parts', $operation);
|
||||
return $this->helper->isGranted($token, 'parts', $operation, $vote);
|
||||
}
|
||||
|
||||
//Otherwise vote on the part
|
||||
|
||||
@@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -59,13 +60,13 @@ final class PartLotVoter extends Voter
|
||||
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $this->helper->resolveUser($token);
|
||||
|
||||
if (in_array($attribute, ['withdraw', 'add', 'move'], true))
|
||||
{
|
||||
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute);
|
||||
$base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute, $vote);
|
||||
|
||||
$lot_permission = true;
|
||||
//If the lot has an owner, we need to check if the user is the owner of the lot to be allowed to withdraw it.
|
||||
@@ -73,6 +74,10 @@ final class PartLotVoter extends Voter
|
||||
$lot_permission = $subject->getOwner() === $user || $subject->getOwner()->getID() === $user->getID();
|
||||
}
|
||||
|
||||
if (!$lot_permission) {
|
||||
$vote->addReason('User is not the owner of the lot.');
|
||||
}
|
||||
|
||||
return $base_permission && $lot_permission;
|
||||
}
|
||||
|
||||
@@ -86,7 +91,7 @@ final class PartLotVoter extends Voter
|
||||
|
||||
//If we have no part associated use the generic part permission
|
||||
if (is_string($subject) || !$subject->getPart() instanceof Part) {
|
||||
return $this->helper->isGranted($token, 'parts', $operation);
|
||||
return $this->helper->isGranted($token, 'parts', $operation, $vote);
|
||||
}
|
||||
|
||||
//Otherwise vote on the part
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace App\Security\Voter;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -52,10 +53,9 @@ final class PartVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
//Null concealing operator means, that no
|
||||
return $this->helper->isGranted($token, 'parts', $attribute);
|
||||
return $this->helper->isGranted($token, 'parts', $attribute, $vote);
|
||||
}
|
||||
|
||||
public function supportsAttribute(string $attribute): bool
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace App\Security\Voter;
|
||||
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -39,12 +40,17 @@ final class PermissionVoter extends Voter
|
||||
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$attribute = ltrim($attribute, '@');
|
||||
[$perm, $op] = explode('.', $attribute);
|
||||
|
||||
return $this->helper->isGranted($token, $perm, $op);
|
||||
$result = $this->helper->isGranted($token, $perm, $op);
|
||||
if ($result === false) {
|
||||
$this->helper->addReason($vote, $perm, $op);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function supportsAttribute(string $attribute): bool
|
||||
|
||||
@@ -47,6 +47,7 @@ use App\Entity\PriceInformations\Orderdetail;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\PriceInformations\Pricedetail;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
@@ -60,7 +61,7 @@ final class PricedetailVoter extends Voter
|
||||
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$operation = match ($attribute) {
|
||||
'read' => 'read',
|
||||
@@ -72,7 +73,7 @@ final class PricedetailVoter extends Voter
|
||||
|
||||
//If we have no part associated use the generic part permission
|
||||
if (is_string($subject) || !$subject->getOrderdetail() instanceof Orderdetail || !$subject->getOrderdetail()->getPart() instanceof Part) {
|
||||
return $this->helper->isGranted($token, 'parts', $operation);
|
||||
return $this->helper->isGranted($token, 'parts', $operation, $vote);
|
||||
}
|
||||
|
||||
//Otherwise vote on the part
|
||||
|
||||
@@ -33,6 +33,7 @@ use App\Entity\Parts\Supplier;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
use function is_object;
|
||||
@@ -113,10 +114,10 @@ final class StructureVoter extends Voter
|
||||
*
|
||||
* @param string $attribute
|
||||
*/
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$permission_name = $this->instanceToPermissionName($subject);
|
||||
//Just resolve the permission
|
||||
return $this->helper->isGranted($token, $permission_name, $attribute);
|
||||
return $this->helper->isGranted($token, $permission_name, $attribute, $vote);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use App\Entity\UserSystem\User;
|
||||
use App\Services\UserSystem\PermissionManager;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
use function in_array;
|
||||
@@ -79,7 +80,7 @@ final class UserVoter extends Voter
|
||||
*
|
||||
* @param string $attribute
|
||||
*/
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $this->helper->resolveUser($token);
|
||||
|
||||
@@ -97,7 +98,7 @@ final class UserVoter extends Voter
|
||||
if (($subject instanceof User) && $subject->getID() === $user->getID() &&
|
||||
$this->helper->isValidOperation('self', $attribute)) {
|
||||
//Then we also need to check the self permission
|
||||
$tmp = $this->helper->isGranted($token, 'self', $attribute);
|
||||
$tmp = $this->helper->isGranted($token, 'self', $attribute, $vote);
|
||||
//But if the self value is not allowed then use just the user value:
|
||||
if ($tmp) {
|
||||
return $tmp;
|
||||
@@ -106,7 +107,7 @@ final class UserVoter extends Voter
|
||||
|
||||
//Else just check user permission:
|
||||
if ($this->helper->isValidOperation('users', $attribute)) {
|
||||
return $this->helper->isGranted($token, 'users', $attribute);
|
||||
return $this->helper->isGranted($token, 'users', $attribute, $vote);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -22,10 +22,13 @@ declare(strict_types=1);
|
||||
*/
|
||||
namespace App\Services\ImportExportSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use League\Csv\Reader;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -44,14 +47,25 @@ class BOMImporter
|
||||
5 => 'Supplier and ref',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly BOMValidationService $validationService
|
||||
) {
|
||||
}
|
||||
|
||||
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
|
||||
{
|
||||
$resolver->setRequired('type');
|
||||
$resolver->setAllowedValues('type', ['kicad_pcbnew']);
|
||||
$resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']);
|
||||
|
||||
// For flexible schematic import with field mapping
|
||||
$resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']);
|
||||
$resolver->setDefault('delimiter', ',');
|
||||
$resolver->setDefault('field_priorities', []);
|
||||
$resolver->setAllowedTypes('field_mapping', 'array');
|
||||
$resolver->setAllowedTypes('field_priorities', 'array');
|
||||
$resolver->setAllowedTypes('delimiter', 'string');
|
||||
|
||||
return $resolver;
|
||||
}
|
||||
@@ -82,6 +96,23 @@ class BOMImporter
|
||||
return $this->stringToBOMEntries($file->getContent(), $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate BOM data before importing
|
||||
* @return array Validation result with errors, warnings, and info
|
||||
*/
|
||||
public function validateBOMData(string $data, array $options): array
|
||||
{
|
||||
$resolver = new OptionsResolver();
|
||||
$resolver = $this->configureOptions($resolver);
|
||||
$options = $resolver->resolve($options);
|
||||
|
||||
return match ($options['type']) {
|
||||
'kicad_pcbnew' => $this->validateKiCADPCB($data),
|
||||
'kicad_schematic' => $this->validateKiCADSchematicData($data, $options),
|
||||
default => throw new InvalidArgumentException('Invalid import type!'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import string data into an array of BOM entries, which are not yet assigned to a project.
|
||||
* @param string $data The data to import
|
||||
@@ -95,12 +126,13 @@ class BOMImporter
|
||||
$options = $resolver->resolve($options);
|
||||
|
||||
return match ($options['type']) {
|
||||
'kicad_pcbnew' => $this->parseKiCADPCB($data, $options),
|
||||
'kicad_pcbnew' => $this->parseKiCADPCB($data),
|
||||
'kicad_schematic' => $this->parseKiCADSchematic($data, $options),
|
||||
default => throw new InvalidArgumentException('Invalid import type!'),
|
||||
};
|
||||
}
|
||||
|
||||
private function parseKiCADPCB(string $data, array $options = []): array
|
||||
private function parseKiCADPCB(string $data): array
|
||||
{
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv->setDelimiter(';');
|
||||
@@ -113,17 +145,17 @@ class BOMImporter
|
||||
$entry = $this->normalizeColumnNames($entry);
|
||||
|
||||
//Ensure that the entry has all required fields
|
||||
if (!isset ($entry['Designator'])) {
|
||||
throw new \UnexpectedValueException('Designator missing at line '.($offset + 1).'!');
|
||||
if (!isset($entry['Designator'])) {
|
||||
throw new \UnexpectedValueException('Designator missing at line ' . ($offset + 1) . '!');
|
||||
}
|
||||
if (!isset ($entry['Package'])) {
|
||||
throw new \UnexpectedValueException('Package missing at line '.($offset + 1).'!');
|
||||
if (!isset($entry['Package'])) {
|
||||
throw new \UnexpectedValueException('Package missing at line ' . ($offset + 1) . '!');
|
||||
}
|
||||
if (!isset ($entry['Designation'])) {
|
||||
throw new \UnexpectedValueException('Designation missing at line '.($offset + 1).'!');
|
||||
if (!isset($entry['Designation'])) {
|
||||
throw new \UnexpectedValueException('Designation missing at line ' . ($offset + 1) . '!');
|
||||
}
|
||||
if (!isset ($entry['Quantity'])) {
|
||||
throw new \UnexpectedValueException('Quantity missing at line '.($offset + 1).'!');
|
||||
if (!isset($entry['Quantity'])) {
|
||||
throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!');
|
||||
}
|
||||
|
||||
$bom_entry = new ProjectBOMEntry();
|
||||
@@ -138,6 +170,63 @@ class BOMImporter
|
||||
return $bom_entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate KiCad PCB data
|
||||
*/
|
||||
private function validateKiCADPCB(string $data): array
|
||||
{
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv->setDelimiter(';');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
$mapped_entries = [];
|
||||
|
||||
foreach ($csv->getRecords() as $offset => $entry) {
|
||||
// Translate the german field names to english
|
||||
$entry = $this->normalizeColumnNames($entry);
|
||||
$mapped_entries[] = $entry;
|
||||
}
|
||||
|
||||
return $this->validationService->validateBOMEntries($mapped_entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate KiCad schematic data
|
||||
*/
|
||||
private function validateKiCADSchematicData(string $data, array $options): array
|
||||
{
|
||||
$delimiter = $options['delimiter'] ?? ',';
|
||||
$field_mapping = $options['field_mapping'] ?? [];
|
||||
$field_priorities = $options['field_priorities'] ?? [];
|
||||
|
||||
// Handle potential BOM (Byte Order Mark) at the beginning
|
||||
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
|
||||
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv->setDelimiter($delimiter);
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
// Handle quoted fields properly
|
||||
$csv->setEscape('\\');
|
||||
$csv->setEnclosure('"');
|
||||
|
||||
$mapped_entries = [];
|
||||
|
||||
foreach ($csv->getRecords() as $offset => $entry) {
|
||||
// Apply field mapping to translate column names
|
||||
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
|
||||
|
||||
// Extract footprint package name if it contains library prefix
|
||||
if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
|
||||
$mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
|
||||
}
|
||||
|
||||
$mapped_entries[] = $mapped_entry;
|
||||
}
|
||||
|
||||
return $this->validationService->validateBOMEntries($mapped_entries, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function uses the order of the fields in the CSV files to make them locale independent.
|
||||
* @param array $entry
|
||||
@@ -160,4 +249,482 @@ class BOMImporter
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse KiCad schematic BOM with flexible field mapping
|
||||
*/
|
||||
private function parseKiCADSchematic(string $data, array $options = []): array
|
||||
{
|
||||
$delimiter = $options['delimiter'] ?? ',';
|
||||
$field_mapping = $options['field_mapping'] ?? [];
|
||||
$field_priorities = $options['field_priorities'] ?? [];
|
||||
|
||||
// Handle potential BOM (Byte Order Mark) at the beginning
|
||||
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
|
||||
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv->setDelimiter($delimiter);
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
// Handle quoted fields properly
|
||||
$csv->setEscape('\\');
|
||||
$csv->setEnclosure('"');
|
||||
|
||||
$bom_entries = [];
|
||||
$entries_by_key = []; // Track entries by name+part combination
|
||||
$mapped_entries = []; // Collect all mapped entries for validation
|
||||
|
||||
foreach ($csv->getRecords() as $offset => $entry) {
|
||||
// Apply field mapping to translate column names
|
||||
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
|
||||
|
||||
// Extract footprint package name if it contains library prefix
|
||||
if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
|
||||
$mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
|
||||
}
|
||||
|
||||
$mapped_entries[] = $mapped_entry;
|
||||
}
|
||||
|
||||
// Validate all entries before processing
|
||||
$validation_result = $this->validationService->validateBOMEntries($mapped_entries, $options);
|
||||
|
||||
// Log validation results
|
||||
$this->logger->info('BOM import validation completed', [
|
||||
'total_entries' => $validation_result['total_entries'],
|
||||
'valid_entries' => $validation_result['valid_entries'],
|
||||
'invalid_entries' => $validation_result['invalid_entries'],
|
||||
'error_count' => count($validation_result['errors']),
|
||||
'warning_count' => count($validation_result['warnings']),
|
||||
]);
|
||||
|
||||
// If there are validation errors, throw an exception with detailed messages
|
||||
if (!empty($validation_result['errors'])) {
|
||||
$error_message = $this->validationService->getErrorMessage($validation_result);
|
||||
throw new \UnexpectedValueException("BOM import validation failed:\n" . $error_message);
|
||||
}
|
||||
|
||||
// Process validated entries
|
||||
foreach ($mapped_entries as $offset => $mapped_entry) {
|
||||
|
||||
// Set name - prefer MPN, fall back to Value, then default format
|
||||
$mpn = trim($mapped_entry['MPN'] ?? '');
|
||||
$designation = trim($mapped_entry['Designation'] ?? '');
|
||||
$value = trim($mapped_entry['Value'] ?? '');
|
||||
|
||||
// Use the first non-empty value, or 'Unknown Component' if all are empty
|
||||
$name = '';
|
||||
if (!empty($mpn)) {
|
||||
$name = $mpn;
|
||||
} elseif (!empty($designation)) {
|
||||
$name = $designation;
|
||||
} elseif (!empty($value)) {
|
||||
$name = $value;
|
||||
} else {
|
||||
$name = 'Unknown Component';
|
||||
}
|
||||
|
||||
if (isset($mapped_entry['Package']) && !empty(trim($mapped_entry['Package']))) {
|
||||
$name .= ' (' . trim($mapped_entry['Package']) . ')';
|
||||
}
|
||||
|
||||
// Set mountnames and quantity
|
||||
// The Designator field contains comma-separated mount names for all instances
|
||||
$designator = trim($mapped_entry['Designator']);
|
||||
$quantity = (float) $mapped_entry['Quantity'];
|
||||
|
||||
// Get mountnames array (validation already ensured they match quantity)
|
||||
$mountnames_array = array_map('trim', explode(',', $designator));
|
||||
|
||||
// Try to link existing Part-DB part if ID is provided
|
||||
$part = null;
|
||||
if (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
|
||||
$partDbId = (int) $mapped_entry['Part-DB ID'];
|
||||
$existingPart = $this->entityManager->getRepository(Part::class)->find($partDbId);
|
||||
|
||||
if ($existingPart) {
|
||||
$part = $existingPart;
|
||||
// Update name with actual part name
|
||||
$name = $existingPart->getName();
|
||||
}
|
||||
}
|
||||
|
||||
// Create unique key for this entry (name + part ID)
|
||||
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
|
||||
|
||||
// Check if we already have an entry with the same name and part
|
||||
if (isset($entries_by_key[$entry_key])) {
|
||||
// Merge with existing entry
|
||||
$existing_entry = $entries_by_key[$entry_key];
|
||||
|
||||
// Combine mountnames
|
||||
$existing_mountnames = $existing_entry->getMountnames();
|
||||
$combined_mountnames = $existing_mountnames . ',' . $designator;
|
||||
$existing_entry->setMountnames($combined_mountnames);
|
||||
|
||||
// Add quantities
|
||||
$existing_quantity = $existing_entry->getQuantity();
|
||||
$existing_entry->setQuantity($existing_quantity + $quantity);
|
||||
|
||||
$this->logger->info('Merged duplicate BOM entry', [
|
||||
'name' => $name,
|
||||
'part_id' => $part ? $part->getID() : null,
|
||||
'original_quantity' => $existing_quantity,
|
||||
'added_quantity' => $quantity,
|
||||
'new_quantity' => $existing_quantity + $quantity,
|
||||
'original_mountnames' => $existing_mountnames,
|
||||
'added_mountnames' => $designator,
|
||||
]);
|
||||
|
||||
continue; // Skip creating new entry
|
||||
}
|
||||
|
||||
// Create new BOM entry
|
||||
$bom_entry = new ProjectBOMEntry();
|
||||
$bom_entry->setName($name);
|
||||
$bom_entry->setMountnames($designator);
|
||||
$bom_entry->setQuantity($quantity);
|
||||
|
||||
if ($part) {
|
||||
$bom_entry->setPart($part);
|
||||
}
|
||||
|
||||
// Set comment with additional info
|
||||
$comment_parts = [];
|
||||
if (isset($mapped_entry['Value']) && $mapped_entry['Value'] !== ($mapped_entry['MPN'] ?? '')) {
|
||||
$comment_parts[] = 'Value: ' . $mapped_entry['Value'];
|
||||
}
|
||||
if (isset($mapped_entry['MPN'])) {
|
||||
$comment_parts[] = 'MPN: ' . $mapped_entry['MPN'];
|
||||
}
|
||||
if (isset($mapped_entry['Manufacturer'])) {
|
||||
$comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer'];
|
||||
}
|
||||
if (isset($mapped_entry['LCSC'])) {
|
||||
$comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC'];
|
||||
}
|
||||
if (isset($mapped_entry['Supplier and ref'])) {
|
||||
$comment_parts[] = $mapped_entry['Supplier and ref'];
|
||||
}
|
||||
|
||||
if ($part) {
|
||||
$comment_parts[] = "Part-DB ID: " . $part->getID();
|
||||
} elseif (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
|
||||
$comment_parts[] = "Part-DB ID: " . $mapped_entry['Part-DB ID'] . " (NOT FOUND)";
|
||||
}
|
||||
|
||||
$bom_entry->setComment(implode(', ', $comment_parts));
|
||||
|
||||
$bom_entries[] = $bom_entry;
|
||||
$entries_by_key[$entry_key] = $bom_entry;
|
||||
}
|
||||
|
||||
return $bom_entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available field mapping targets with descriptions
|
||||
*/
|
||||
public function getAvailableFieldTargets(): array
|
||||
{
|
||||
$targets = [
|
||||
'Designator' => [
|
||||
'label' => 'Designator',
|
||||
'description' => 'Component reference designators (e.g., R1, C2, U3)',
|
||||
'required' => true,
|
||||
'multiple' => false,
|
||||
],
|
||||
'Quantity' => [
|
||||
'label' => 'Quantity',
|
||||
'description' => 'Number of components',
|
||||
'required' => true,
|
||||
'multiple' => false,
|
||||
],
|
||||
'Designation' => [
|
||||
'label' => 'Designation',
|
||||
'description' => 'Component designation/part number',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
'Value' => [
|
||||
'label' => 'Value',
|
||||
'description' => 'Component value (e.g., 10k, 100nF)',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
'Package' => [
|
||||
'label' => 'Package',
|
||||
'description' => 'Component package/footprint',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
'MPN' => [
|
||||
'label' => 'MPN',
|
||||
'description' => 'Manufacturer Part Number',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
'Manufacturer' => [
|
||||
'label' => 'Manufacturer',
|
||||
'description' => 'Component manufacturer name',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
'Part-DB ID' => [
|
||||
'label' => 'Part-DB ID',
|
||||
'description' => 'Existing Part-DB part ID for linking',
|
||||
'required' => false,
|
||||
'multiple' => false,
|
||||
],
|
||||
'Comment' => [
|
||||
'label' => 'Comment',
|
||||
'description' => 'Additional component information',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
];
|
||||
|
||||
// Add dynamic supplier fields based on available suppliers in the database
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierName = $supplier->getName();
|
||||
$targets[$supplierName . ' SPN'] = [
|
||||
'label' => $supplierName . ' SPN',
|
||||
'description' => "Supplier part number for {$supplierName}",
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
'supplier_id' => $supplier->getID(),
|
||||
];
|
||||
}
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested field mappings based on common field names
|
||||
*/
|
||||
public function getSuggestedFieldMapping(array $detected_fields): array
|
||||
{
|
||||
$suggestions = [];
|
||||
|
||||
$field_patterns = [
|
||||
'Part-DB ID' => ['part-db id', 'partdb_id', 'part_db_id', 'db_id', 'partdb'],
|
||||
'Designator' => ['reference', 'ref', 'designator', 'component', 'comp'],
|
||||
'Quantity' => ['qty', 'quantity', 'count', 'number', 'amount'],
|
||||
'Value' => ['value', 'val', 'component_value'],
|
||||
'Designation' => ['designation', 'part_number', 'partnumber', 'part'],
|
||||
'Package' => ['footprint', 'package', 'housing', 'fp'],
|
||||
'MPN' => ['mpn', 'part_number', 'partnumber', 'manf#', 'mfr_part_number', 'manufacturer_part'],
|
||||
'Manufacturer' => ['manufacturer', 'manf', 'mfr', 'brand', 'vendor'],
|
||||
'Comment' => ['comment', 'comments', 'note', 'notes', 'description'],
|
||||
];
|
||||
|
||||
// Add supplier-specific patterns
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierName = $supplier->getName();
|
||||
$supplierLower = strtolower($supplierName);
|
||||
|
||||
// Create patterns for each supplier
|
||||
$field_patterns[$supplierName . ' SPN'] = [
|
||||
$supplierLower,
|
||||
$supplierLower . '#',
|
||||
$supplierLower . '_part',
|
||||
$supplierLower . '_number',
|
||||
$supplierLower . 'pn',
|
||||
$supplierLower . '_spn',
|
||||
$supplierLower . ' spn',
|
||||
// Common abbreviations
|
||||
$supplierLower === 'mouser' ? 'mouser' : null,
|
||||
$supplierLower === 'digikey' ? 'dk' : null,
|
||||
$supplierLower === 'farnell' ? 'farnell' : null,
|
||||
$supplierLower === 'rs' ? 'rs' : null,
|
||||
$supplierLower === 'lcsc' ? 'lcsc' : null,
|
||||
];
|
||||
|
||||
// Remove null values
|
||||
$field_patterns[$supplierName . ' SPN'] = array_filter($field_patterns[$supplierName . ' SPN'], fn($value) => $value !== null);
|
||||
}
|
||||
|
||||
foreach ($detected_fields as $field) {
|
||||
$field_lower = strtolower(trim($field));
|
||||
|
||||
foreach ($field_patterns as $target => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (str_contains($field_lower, $pattern)) {
|
||||
$suggestions[$field] = $target;
|
||||
break 2; // Break both loops
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate field mapping configuration
|
||||
*/
|
||||
public function validateFieldMapping(array $field_mapping, array $detected_fields): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
$available_targets = $this->getAvailableFieldTargets();
|
||||
|
||||
// Check for required fields
|
||||
$mapped_targets = array_values($field_mapping);
|
||||
$required_fields = ['Designator', 'Quantity'];
|
||||
|
||||
foreach ($required_fields as $required) {
|
||||
if (!in_array($required, $mapped_targets, true)) {
|
||||
$errors[] = "Required field '{$required}' is not mapped from any CSV column.";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for invalid target fields
|
||||
foreach ($field_mapping as $csv_field => $target) {
|
||||
if (!empty($target) && !isset($available_targets[$target])) {
|
||||
$errors[] = "Invalid target field '{$target}' for CSV field '{$csv_field}'.";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unmapped fields (warnings)
|
||||
$unmapped_fields = array_diff($detected_fields, array_keys($field_mapping));
|
||||
if (!empty($unmapped_fields)) {
|
||||
$warnings[] = "The following CSV fields are not mapped: " . implode(', ', $unmapped_fields);
|
||||
}
|
||||
|
||||
return [
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
'is_valid' => empty($errors),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply field mapping with support for multiple fields and priority
|
||||
*/
|
||||
private function applyFieldMapping(array $entry, array $field_mapping, array $field_priorities = []): array
|
||||
{
|
||||
$mapped = [];
|
||||
$field_groups = [];
|
||||
|
||||
// Group fields by target with priority information
|
||||
foreach ($field_mapping as $csv_field => $target) {
|
||||
if (!empty($target)) {
|
||||
if (!isset($field_groups[$target])) {
|
||||
$field_groups[$target] = [];
|
||||
}
|
||||
$priority = $field_priorities[$csv_field] ?? 10;
|
||||
$field_groups[$target][] = [
|
||||
'field' => $csv_field,
|
||||
'priority' => $priority,
|
||||
'value' => $entry[$csv_field] ?? ''
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Process each target field
|
||||
foreach ($field_groups as $target => $field_data) {
|
||||
// Sort by priority (lower number = higher priority)
|
||||
usort($field_data, function ($a, $b) {
|
||||
return $a['priority'] <=> $b['priority'];
|
||||
});
|
||||
|
||||
$values = [];
|
||||
$non_empty_values = [];
|
||||
|
||||
// Collect all non-empty values for this target
|
||||
foreach ($field_data as $data) {
|
||||
$value = trim($data['value']);
|
||||
if (!empty($value)) {
|
||||
$non_empty_values[] = $value;
|
||||
}
|
||||
$values[] = $value;
|
||||
}
|
||||
|
||||
// Use the first non-empty value (highest priority)
|
||||
if (!empty($non_empty_values)) {
|
||||
$mapped[$target] = $non_empty_values[0];
|
||||
|
||||
// If multiple non-empty values exist, add alternatives to comment
|
||||
if (count($non_empty_values) > 1) {
|
||||
$mapped[$target . '_alternatives'] = array_slice($non_empty_values, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect available fields in CSV data for field mapping UI
|
||||
*/
|
||||
public function detectFields(string $data, ?string $delimiter = null): array
|
||||
{
|
||||
if ($delimiter === null) {
|
||||
// Detect delimiter by counting occurrences in the first row (header)
|
||||
$delimiters = [',', ';', "\t"];
|
||||
$lines = explode("\n", $data, 2);
|
||||
$header_line = $lines[0] ?? '';
|
||||
$delimiter_counts = [];
|
||||
foreach ($delimiters as $delim) {
|
||||
$delimiter_counts[$delim] = substr_count($header_line, $delim);
|
||||
}
|
||||
// Choose the delimiter with the highest count, default to comma if all are zero
|
||||
$max_count = max($delimiter_counts);
|
||||
$delimiter = array_search($max_count, $delimiter_counts, true);
|
||||
if ($max_count === 0 || $delimiter === false) {
|
||||
$delimiter = ',';
|
||||
}
|
||||
}
|
||||
// Handle potential BOM (Byte Order Mark) at the beginning
|
||||
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
|
||||
|
||||
// Get first line only for header detection
|
||||
$lines = explode("\n", $data);
|
||||
$header_line = trim($lines[0] ?? '');
|
||||
|
||||
|
||||
// Simple manual parsing for header detection
|
||||
// This handles quoted CSV fields better than the library for detection
|
||||
$fields = [];
|
||||
$current_field = '';
|
||||
$in_quotes = false;
|
||||
$quote_char = '"';
|
||||
|
||||
for ($i = 0; $i < strlen($header_line); $i++) {
|
||||
$char = $header_line[$i];
|
||||
|
||||
if ($char === $quote_char && !$in_quotes) {
|
||||
$in_quotes = true;
|
||||
} elseif ($char === $quote_char && $in_quotes) {
|
||||
// Check for escaped quote (double quote)
|
||||
if ($i + 1 < strlen($header_line) && $header_line[$i + 1] === $quote_char) {
|
||||
$current_field .= $quote_char;
|
||||
$i++; // Skip next quote
|
||||
} else {
|
||||
$in_quotes = false;
|
||||
}
|
||||
} elseif ($char === $delimiter && !$in_quotes) {
|
||||
$fields[] = trim($current_field);
|
||||
$current_field = '';
|
||||
} else {
|
||||
$current_field .= $char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last field
|
||||
if ($current_field !== '') {
|
||||
$fields[] = trim($current_field);
|
||||
}
|
||||
|
||||
// Clean up headers - remove quotes and trim whitespace
|
||||
$headers = array_map(function ($header) {
|
||||
return trim($header, '"\'');
|
||||
}, $fields);
|
||||
|
||||
|
||||
return array_values($headers);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/search/global", [
|
||||
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
|
||||
'headers' => [
|
||||
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
||||
],
|
||||
'query' => [
|
||||
'json' => [
|
||||
'keyword' => $term,
|
||||
],
|
||||
]);
|
||||
@@ -165,6 +165,9 @@ class LCSCProvider implements InfoProviderInterface
|
||||
if ($field === null) {
|
||||
return null;
|
||||
}
|
||||
// Replace "range" indicators with mathematical tilde symbols
|
||||
// so they don't get rendered as strikethrough by Markdown
|
||||
$field = preg_replace("/~/", "\u{223c}", $field);
|
||||
|
||||
return strip_tags($field);
|
||||
}
|
||||
@@ -197,9 +200,6 @@ class LCSCProvider implements InfoProviderInterface
|
||||
$category = $product['parentCatalogName'] ?? null;
|
||||
if (isset($product['catalogName'])) {
|
||||
$category = ($category ?? '') . ' -> ' . $product['catalogName'];
|
||||
|
||||
// Replace the / with a -> for better readability
|
||||
$category = str_replace('/', ' -> ', $category);
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
|
||||
@@ -158,7 +158,8 @@ class PollinProvider implements InfoProviderInterface
|
||||
category: $this->parseCategory($dom),
|
||||
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
|
||||
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
|
||||
manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
|
||||
//TODO: Find another way to determine the manufacturing status, as the itemprop="availability" is often is not existing anymore in the page
|
||||
//manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
|
||||
provider_url: $productPageUrl,
|
||||
notes: $this->parseNotes($dom),
|
||||
datasheets: $this->parseDatasheets($dom),
|
||||
|
||||
@@ -26,6 +26,8 @@ use App\Entity\PriceInformations\Currency;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Exchanger\Exception\UnsupportedCurrencyPairException;
|
||||
use Exchanger\Exception\UnsupportedExchangeQueryException;
|
||||
use Swap\Swap;
|
||||
|
||||
class ExchangeRateUpdater
|
||||
@@ -39,15 +41,21 @@ class ExchangeRateUpdater
|
||||
*/
|
||||
public function update(Currency $currency): Currency
|
||||
{
|
||||
//Currency pairs are always in the format "BASE/QUOTE"
|
||||
$rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
|
||||
//The rate says how many quote units are worth one base unit
|
||||
//So we need to invert it to get the exchange rate
|
||||
try {
|
||||
//Try it in the direction QUOTE/BASE first, as most providers provide rates in this direction
|
||||
$rate = $this->swap->latest($currency->getIsoCode().'/'.$this->localizationSettings->baseCurrency);
|
||||
$effective_rate = BigDecimal::of($rate->getValue());
|
||||
} catch (UnsupportedCurrencyPairException|UnsupportedExchangeQueryException $exception) {
|
||||
//Otherwise try to get it inverse and calculate it ourselfes, from the format "BASE/QUOTE"
|
||||
$rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
|
||||
//The rate says how many quote units are worth one base unit
|
||||
//So we need to invert it to get the exchange rate
|
||||
|
||||
$rate_bd = BigDecimal::of($rate->getValue());
|
||||
$rate_inverted = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
|
||||
$rate_bd = BigDecimal::of($rate->getValue());
|
||||
$effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
|
||||
}
|
||||
|
||||
$currency->setExchangeRate($rate_inverted);
|
||||
$currency->setExchangeRate($effective_rate);
|
||||
|
||||
return $currency;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ use App\Repository\UserRepository;
|
||||
use App\Security\ApiTokenAuthenticatedToken;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\UserSystem\VoterHelperTest
|
||||
@@ -35,10 +38,14 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
final class VoterHelper
|
||||
{
|
||||
private readonly UserRepository $userRepository;
|
||||
private readonly array $permissionStructure;
|
||||
|
||||
public function __construct(private readonly PermissionManager $permissionManager, private readonly EntityManagerInterface $entityManager)
|
||||
public function __construct(private readonly PermissionManager $permissionManager,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
$this->userRepository = $this->entityManager->getRepository(User::class);
|
||||
$this->permissionStructure = $this->permissionManager->getPermissionStructure();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,11 +54,16 @@ final class VoterHelper
|
||||
* @param TokenInterface $token The token to check
|
||||
* @param string $permission The permission to check
|
||||
* @param string $operation The operation to check
|
||||
* @param Vote|null $vote The vote object to add reasons to (optional). If null, no reasons are added.
|
||||
* @return bool
|
||||
*/
|
||||
public function isGranted(TokenInterface $token, string $permission, string $operation): bool
|
||||
public function isGranted(TokenInterface $token, string $permission, string $operation, ?Vote $vote = null): bool
|
||||
{
|
||||
return $this->isGrantedTrinary($token, $permission, $operation) ?? false;
|
||||
$tmp = $this->isGrantedTrinary($token, $permission, $operation) ?? false;
|
||||
if ($tmp === false) {
|
||||
$this->addReason($vote, $permission, $operation);
|
||||
}
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,4 +136,17 @@ final class VoterHelper
|
||||
{
|
||||
return $this->permissionManager->isValidOperation($permission, $operation);
|
||||
}
|
||||
}
|
||||
|
||||
public function addReason(?Vote $voter, string $permission, $operation): void
|
||||
{
|
||||
if ($voter !== null) {
|
||||
$voter->addReason(sprintf("User does not have permission %s -> %s -> %s (%s.%s).",
|
||||
$this->translator->trans('perm.group.'.($this->permissionStructure['perms'][$permission]['group'] ?? 'unknown') ),
|
||||
$this->translator->trans($this->permissionStructure['perms'][$permission]['label'] ?? $permission),
|
||||
$this->translator->trans($this->permissionStructure['perms'][$permission]['operations'][$operation]['label'] ?? $operation),
|
||||
$permission,
|
||||
$operation
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,4 +40,10 @@ class PartInfoSettings
|
||||
#[SettingsParameter(label: new TM("settings.behavior.part_info.show_part_image_overlay"), description: new TM("settings.behavior.part_info.show_part_image_overlay.help"),
|
||||
envVar: "bool:SHOW_PART_IMAGE_OVERLAY", envVarMode: EnvVarMode::OVERWRITE)]
|
||||
public bool $showPartImageOverlay = true;
|
||||
}
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.behavior.part_info.extract_params_from_description"))]
|
||||
public bool $extractParamsFromDescription = true;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.behavior.part_info.extract_params_from_notes"))]
|
||||
public bool $extractParamsFromNotes = true;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,20 @@ class TableSettings
|
||||
PartTableColumns::CATEGORY, PartTableColumns::FOOTPRINT, PartTableColumns::MANUFACTURER,
|
||||
PartTableColumns::LOCATION, PartTableColumns::AMOUNT];
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.behavior.table.preview_image_min_width"),
|
||||
formOptions: ['attr' => ['min' => 1, 'max' => 100]],
|
||||
envVar: "int:TABLE_IMAGE_PREVIEW_MIN_SIZE", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
#[Assert\Range(min: 1, max: 100)]
|
||||
public int $previewImageMinWidth = 20;
|
||||
|
||||
#[SettingsParameter(label: new TM("settings.behavior.table.preview_image_max_width"),
|
||||
formOptions: ['attr' => ['min' => 1, 'max' => 100]],
|
||||
envVar: "int:TABLE_IMAGE_PREVIEW_MAX_SIZE", envVarMode: EnvVarMode::OVERWRITE
|
||||
)]
|
||||
#[Assert\Range(min: 1, max: 100)]
|
||||
#[Assert\GreaterThanOrEqual(propertyPath: 'previewImageMinWidth')]
|
||||
public int $previewImageMaxWidth = 35;
|
||||
|
||||
public static function mapPartsDefaultColumnsEnv(string $columns): array
|
||||
{
|
||||
@@ -87,4 +101,4 @@ class TableSettings
|
||||
return $ret;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?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\Settings\InfoProviderSystem;
|
||||
|
||||
use App\Form\InfoProviderSystem\ProviderSelectType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\StringType;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
|
||||
#[Settings(label: new TM("settings.ips.general"))]
|
||||
#[SettingsIcon("fa-magnifying-glass")]
|
||||
class InfoProviderGeneralSettings
|
||||
{
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
#[SettingsParameter(type: ArrayType::class, label: new TM("settings.ips.default_providers"),
|
||||
description: new TM("settings.ips.default_providers.help"), options: ['type' => StringType::class],
|
||||
formType: ProviderSelectType::class, formOptions: ['input' => 'string', 'required' => false, 'empty_data' => []])]
|
||||
public array $defaultSearchProviders = [];
|
||||
}
|
||||
@@ -25,6 +25,7 @@ namespace App\Settings\InfoProviderSystem;
|
||||
|
||||
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
|
||||
#[Settings()]
|
||||
@@ -32,6 +33,9 @@ class InfoProviderSettings
|
||||
{
|
||||
use SettingsTrait;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?InfoProviderGeneralSettings $general = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?DigikeySettings $digikey = null;
|
||||
|
||||
@@ -58,4 +62,4 @@ class InfoProviderSettings
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?PollinSettings $pollin = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,13 @@ use App\Form\Type\ThemeChoiceType;
|
||||
use App\Settings\SettingsIcon;
|
||||
use App\Validator\Constraints\ValidTheme;
|
||||
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
|
||||
use Jbtronics\SettingsBundle\ParameterTypes\EnumType;
|
||||
use Jbtronics\SettingsBundle\Settings\Settings;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
|
||||
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
|
||||
use Symfony\Component\Translation\TranslatableMessage as TM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[Settings(name: "customization", label: new TM("settings.system.customization"))]
|
||||
#[SettingsIcon("fa-paint-roller")]
|
||||
@@ -46,6 +49,13 @@ class CustomizationSettings
|
||||
)]
|
||||
public string $instanceName = "Part-DB";
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.system.customization.theme"),
|
||||
formType: ThemeChoiceType::class, formOptions: ['placeholder' => false]
|
||||
)]
|
||||
#[ValidTheme]
|
||||
public string $theme = 'bootstrap';
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.system.customization.banner"),
|
||||
formType: RichTextEditorType::class, formOptions: ['mode' => 'markdown-full'],
|
||||
@@ -53,10 +63,22 @@ class CustomizationSettings
|
||||
)]
|
||||
public ?string $banner = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.system.customization.theme"),
|
||||
formType: ThemeChoiceType::class, formOptions: ['placeholder' => false]
|
||||
/**
|
||||
* @var HomepageItems[] The items to show in the sidebar.
|
||||
*/
|
||||
#[SettingsParameter(ArrayType::class,
|
||||
label: new TM("settings.behavior.hompepage.items"),
|
||||
description: new TM("settings.behavior.homepage.items.help"),
|
||||
options: ['type' => EnumType::class, 'options' => ['class' => HomepageItems::class]],
|
||||
formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class,
|
||||
formOptions: ['class' => HomepageItems::class, 'multiple' => true, 'ordered' => true]
|
||||
)]
|
||||
#[ValidTheme]
|
||||
public string $theme = 'bootstrap';
|
||||
#[Assert\NotBlank()]
|
||||
#[Assert\Unique()]
|
||||
public array $homepageitems = [HomepageItems::SEARCH, HomepageItems::BANNER, HomepageItems::FIRST_STEPS, HomepageItems::LICENSE, HomepageItems::LAST_ACTIVITY];
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.system.customization.showVersionOnHomepage")
|
||||
)]
|
||||
public bool $showVersionOnHomepage = true;
|
||||
}
|
||||
|
||||
51
src/Settings/SystemSettings/HomepageItems.php
Normal file
51
src/Settings/SystemSettings/HomepageItems.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?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\Settings\SystemSettings;
|
||||
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
enum HomepageItems: string implements TranslatableInterface
|
||||
{
|
||||
case SEARCH = 'search';
|
||||
case BANNER = 'banner';
|
||||
case LICENSE = 'license';
|
||||
case FIRST_STEPS = 'first_steps';
|
||||
case LAST_ACTIVITY = 'last_activity';
|
||||
|
||||
public function trans(TranslatorInterface $translator, ?string $locale = null): string
|
||||
{
|
||||
$key = match($this) {
|
||||
self::SEARCH => 'search.placeholder',
|
||||
self::BANNER => 'settings.system.customization.banner',
|
||||
self::LICENSE => 'homepage.license',
|
||||
self::FIRST_STEPS => 'homepage.first_steps.title',
|
||||
self::LAST_ACTIVITY => 'homepage.last_activity',
|
||||
};
|
||||
|
||||
return $translator->trans($key, locale: $locale);
|
||||
}
|
||||
}
|
||||
15
symfony.lock
15
symfony.lock
@@ -133,15 +133,6 @@
|
||||
"ekino/phpstan-banned-code": {
|
||||
"version": "v0.3.1"
|
||||
},
|
||||
"florianv/exchanger": {
|
||||
"version": "1.4.1"
|
||||
},
|
||||
"florianv/swap": {
|
||||
"version": "3.5.0"
|
||||
},
|
||||
"florianv/swap-bundle": {
|
||||
"version": "5.0.x-dev"
|
||||
},
|
||||
"gregwar/captcha": {
|
||||
"version": "v1.1.7"
|
||||
},
|
||||
@@ -254,6 +245,9 @@
|
||||
"./config/packages/datatables.yaml"
|
||||
]
|
||||
},
|
||||
"part-db/swap-bundle": {
|
||||
"version": "v6.0.0"
|
||||
},
|
||||
"php-http/discovery": {
|
||||
"version": "1.18",
|
||||
"recipe": {
|
||||
@@ -729,9 +723,6 @@
|
||||
},
|
||||
"files": []
|
||||
},
|
||||
"symfony/ux-toggle-password": {
|
||||
"version": "v2.29.2"
|
||||
},
|
||||
"symfony/ux-translator": {
|
||||
"version": "2.9",
|
||||
"recipe": {
|
||||
|
||||
@@ -53,6 +53,14 @@
|
||||
{% endif %}
|
||||
|
||||
{{ encore_entry_link_tags('app') }}
|
||||
|
||||
{% set table_settings = settings_instance('table') %}
|
||||
<style>
|
||||
:root {
|
||||
--table-image-preview-min-size: {{ table_settings.previewImageMinWidth }}px;
|
||||
--table-image-preview-max-size: {{ table_settings.previewImageMaxWidth }}px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "bundles/TwigBundle/Exception/error.html.twig" %}
|
||||
|
||||
{% block status_comment %}
|
||||
Nice try! But you are not allowed to do this!
|
||||
Nice try! But you are not allowed to do this!<br>
|
||||
<code>{{ exception.message }}</code>
|
||||
<br> <small>If you think you should have access to this ressource, contact the adminstrator.</small>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<input type="hidden" name="ids" {{ stimulus_target('elements/datatables/parts', 'selectIDs') }} value="">
|
||||
|
||||
<div class="d-none mb-2" {{ stimulus_target('elements/datatables/parts', 'selectPanel') }}>
|
||||
<div class="d-none mb-2 bg-body-tertiary shadow-sm border border-secondary rounded mx-2 p-2" {{ stimulus_target('elements/datatables/parts', 'selectPanel') }}>
|
||||
{# <span id="select_count"></span> #}
|
||||
|
||||
<div class="input-group">
|
||||
@@ -95,4 +95,4 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endmacro %}
|
||||
{% endmacro %}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{% extends 'bootstrap_5_horizontal_layout.html.twig' %}
|
||||
|
||||
{%- block toggle_password_widget -%}
|
||||
<div class="{{ toggle_container_classes|join(' ') }}">{{ block('password_widget') }}</div>
|
||||
{%- endblock toggle_password_widget -%}
|
||||
|
||||
{# Make form rows smaller #}
|
||||
{% block form_row -%}
|
||||
{%- set row_attr = row_attr|merge({"class": "mb-2"}) -%}
|
||||
@@ -139,4 +143,4 @@
|
||||
{% else %}
|
||||
{{- parent() -}}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,26 +4,23 @@
|
||||
{% import "components/search.macro.html.twig" as search %}
|
||||
{% import "vars.macro.twig" as vars %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if is_granted('@system.show_updates') %}
|
||||
{{ nv.new_version_alert(new_version_available, new_version, new_version_url) }}
|
||||
{% endif %}
|
||||
|
||||
{% block item_search %}
|
||||
{% if is_granted('@parts.read') %}
|
||||
{{ search.search_form("standalone") }}
|
||||
<div class="mb-2"></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block item_banner %}
|
||||
<div class="rounded p-4 bg-body-secondary">
|
||||
<h1 class="display-3">{{ vars.partdb_title() }}</h1>
|
||||
<h4>
|
||||
{% trans %}version.caption{% endtrans %}: {{ shivas_app_version }}
|
||||
{% if git_branch is not empty or git_commit is not empty %}
|
||||
({{ git_branch ?? '' }}/{{ git_commit ?? '' }})
|
||||
{% endif %}
|
||||
</h4>
|
||||
{% if settings_instance('customization').showVersionOnHomepage %}
|
||||
<h4>
|
||||
{% trans %}version.caption{% endtrans %}: {{ shivas_app_version }}
|
||||
{% if git_branch is not empty or git_commit is not empty %}
|
||||
({{ git_branch ?? '' }}/{{ git_commit ?? '' }})
|
||||
{% endif %}
|
||||
</h4>
|
||||
{% endif %}
|
||||
{% if banner is not empty %}
|
||||
<hr>
|
||||
<div class="latex" data-controller="common--latex">
|
||||
@@ -31,9 +28,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block item_first_steps %}
|
||||
{% if show_first_steps %}
|
||||
<div class="card border-info mt-3">
|
||||
<div class="card border-info">
|
||||
<div class="card-header bg-info ">
|
||||
<h4><i class="fa fa-circle-play fa-fw " aria-hidden="true"></i> {% trans %}homepage.first_steps.title{% endtrans %}</h4>
|
||||
</div>
|
||||
@@ -51,8 +50,10 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="card border-primary mt-3">
|
||||
{% block item_license %}
|
||||
<div class="card border-primary">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4><i class="fa fa-book fa-fw" aria-hidden="true"></i> {% trans %}homepage.license{% endtrans %}</h4>
|
||||
</div>
|
||||
@@ -68,9 +69,11 @@
|
||||
<strong><i class="fas fa-comments fa-fw"></i> {% trans %}homepage.forum.caption{% endtrans %}:</strong> {% trans with {'%href%': 'https://github.com/Part-DB/Part-DB-server/discussions'}%}homepage.forum.text{% endtrans %}<br>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block item_last_activity %}
|
||||
{% if datatable is not null %}
|
||||
<div class="card mt-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="fas fa-fw fa-history"></i> {% trans %}homepage.last_activity{% endtrans %}</div>
|
||||
<div class="card-body">
|
||||
{% import "components/history_log_macros.html.twig" as log %}
|
||||
@@ -78,4 +81,23 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if is_granted('@system.show_updates') %}
|
||||
{{ nv.new_version_alert(new_version_available, new_version, new_version_url) }}
|
||||
{% endif %}
|
||||
|
||||
{% for item in settings_instance('customization').homepageitems %}
|
||||
{% if block('item_' ~ item.value) is defined %}
|
||||
{{ block('item_' ~ item.value) }}
|
||||
<div class="mb-2"></div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mt-3" role="alert">
|
||||
Alert: The homepage item "{{ item.value }}" is not defined!
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div class="col-6">
|
||||
{% if provider.providerInfo.settings_class is defined %}
|
||||
<a href="{{ path('info_providers_provider_settings', {'provider': provider.providerKey}) }}" class="btn btn-primary btn-sm"
|
||||
<a href="{{ path('info_providers_provider_settings', {'provider': provider.providerKey}) }}" class="btn btn-primary btn-sm {% if not is_granted('@config.change_system_settings') %}disabled{% endif %}"
|
||||
title="{% trans %}info_providers.settings.title{% endtrans %}"
|
||||
><i class="fa-solid fa-cog"></i></a>
|
||||
{% endif %}
|
||||
|
||||
@@ -100,6 +100,10 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.update_profile is defined %}
|
||||
{{ form_row(form.update_profile) }}
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="offset-sm-3 col-sm-9">
|
||||
<div class="input-group">
|
||||
@@ -133,4 +137,4 @@
|
||||
</object>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
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();
|
||||
self::assertJsonContains([
|
||||
'hydra:totalItems' => 0,
|
||||
'hydra:totalItems' => 4, //The 4 currencies from our fixtures
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class CurrencyEndpointTest extends CrudEndpointTestCase
|
||||
{
|
||||
$this->_testPostItem([
|
||||
'name' => 'Test API',
|
||||
'iso_code' => 'USD',
|
||||
'iso_code' => 'CAD',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -61,4 +61,4 @@ class CurrencyEndpointTest extends CrudEndpointTestCase
|
||||
{
|
||||
$this->_testDeleteItem(5);
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Services\ImportExportSystem\BOMImporter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
|
||||
@@ -36,11 +39,17 @@ class BOMImporterTest extends WebTestCase
|
||||
*/
|
||||
protected $service;
|
||||
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
protected $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
//Get a service instance.
|
||||
self::bootKernel();
|
||||
$this->service = self::getContainer()->get(BOMImporter::class);
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
public function testImportFileIntoProject(): void
|
||||
@@ -119,4 +128,489 @@ class BOMImporterTest extends WebTestCase
|
||||
|
||||
$this->service->stringToBOMEntries($input, ['type' => 'kicad_pcbnew']);
|
||||
}
|
||||
|
||||
public function testDetectFields(): void
|
||||
{
|
||||
$input = <<<CSV
|
||||
"Reference","Value","Footprint","Quantity","MPN","Manufacturer","LCSC SPN","Mouser SPN"
|
||||
CSV;
|
||||
|
||||
$fields = $this->service->detectFields($input);
|
||||
|
||||
$this->assertIsArray($fields);
|
||||
$this->assertCount(8, $fields);
|
||||
$this->assertContains('Reference', $fields);
|
||||
$this->assertContains('Value', $fields);
|
||||
$this->assertContains('Footprint', $fields);
|
||||
$this->assertContains('Quantity', $fields);
|
||||
$this->assertContains('MPN', $fields);
|
||||
$this->assertContains('Manufacturer', $fields);
|
||||
$this->assertContains('LCSC SPN', $fields);
|
||||
$this->assertContains('Mouser SPN', $fields);
|
||||
}
|
||||
|
||||
public function testDetectFieldsWithQuotes(): void
|
||||
{
|
||||
$input = <<<CSV
|
||||
"Reference","Value","Footprint","Quantity","MPN","Manufacturer","LCSC SPN","Mouser SPN"
|
||||
CSV;
|
||||
|
||||
$fields = $this->service->detectFields($input);
|
||||
|
||||
$this->assertIsArray($fields);
|
||||
$this->assertCount(8, $fields);
|
||||
$this->assertEquals('Reference', $fields[0]);
|
||||
$this->assertEquals('Value', $fields[1]);
|
||||
}
|
||||
|
||||
public function testDetectFieldsWithSemicolon(): void
|
||||
{
|
||||
$input = <<<CSV
|
||||
"Reference";"Value";"Footprint";"Quantity";"MPN";"Manufacturer";"LCSC SPN";"Mouser SPN"
|
||||
CSV;
|
||||
|
||||
$fields = $this->service->detectFields($input, ';');
|
||||
|
||||
$this->assertIsArray($fields);
|
||||
$this->assertCount(8, $fields);
|
||||
$this->assertEquals('Reference', $fields[0]);
|
||||
$this->assertEquals('Value', $fields[1]);
|
||||
}
|
||||
|
||||
public function testGetAvailableFieldTargets(): void
|
||||
{
|
||||
$targets = $this->service->getAvailableFieldTargets();
|
||||
|
||||
$this->assertIsArray($targets);
|
||||
$this->assertArrayHasKey('Designator', $targets);
|
||||
$this->assertArrayHasKey('Quantity', $targets);
|
||||
$this->assertArrayHasKey('Value', $targets);
|
||||
$this->assertArrayHasKey('Package', $targets);
|
||||
$this->assertArrayHasKey('MPN', $targets);
|
||||
$this->assertArrayHasKey('Manufacturer', $targets);
|
||||
$this->assertArrayHasKey('Part-DB ID', $targets);
|
||||
$this->assertArrayHasKey('Comment', $targets);
|
||||
|
||||
// Check structure of a target
|
||||
$this->assertArrayHasKey('label', $targets['Designator']);
|
||||
$this->assertArrayHasKey('description', $targets['Designator']);
|
||||
$this->assertArrayHasKey('required', $targets['Designator']);
|
||||
$this->assertArrayHasKey('multiple', $targets['Designator']);
|
||||
|
||||
$this->assertTrue($targets['Designator']['required']);
|
||||
$this->assertTrue($targets['Quantity']['required']);
|
||||
$this->assertFalse($targets['Value']['required']);
|
||||
}
|
||||
|
||||
public function testGetAvailableFieldTargetsWithSuppliers(): void
|
||||
{
|
||||
// Create test suppliers
|
||||
$supplier1 = new Supplier();
|
||||
$supplier1->setName('LCSC');
|
||||
$supplier2 = new Supplier();
|
||||
$supplier2->setName('Mouser');
|
||||
|
||||
$this->entityManager->persist($supplier1);
|
||||
$this->entityManager->persist($supplier2);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$targets = $this->service->getAvailableFieldTargets();
|
||||
|
||||
$this->assertArrayHasKey('LCSC SPN', $targets);
|
||||
$this->assertArrayHasKey('Mouser SPN', $targets);
|
||||
|
||||
$this->assertEquals('LCSC SPN', $targets['LCSC SPN']['label']);
|
||||
$this->assertEquals('Mouser SPN', $targets['Mouser SPN']['label']);
|
||||
$this->assertFalse($targets['LCSC SPN']['required']);
|
||||
$this->assertTrue($targets['LCSC SPN']['multiple']);
|
||||
|
||||
// Clean up
|
||||
$this->entityManager->remove($supplier1);
|
||||
$this->entityManager->remove($supplier2);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testGetSuggestedFieldMapping(): void
|
||||
{
|
||||
$detected_fields = [
|
||||
'Reference',
|
||||
'Value',
|
||||
'Footprint',
|
||||
'Quantity',
|
||||
'MPN',
|
||||
'Manufacturer',
|
||||
'LCSC',
|
||||
'Mouser',
|
||||
'Part-DB ID',
|
||||
'Comment'
|
||||
];
|
||||
|
||||
$suggestions = $this->service->getSuggestedFieldMapping($detected_fields);
|
||||
|
||||
$this->assertIsArray($suggestions);
|
||||
$this->assertEquals('Designator', $suggestions['Reference']);
|
||||
$this->assertEquals('Value', $suggestions['Value']);
|
||||
$this->assertEquals('Package', $suggestions['Footprint']);
|
||||
$this->assertEquals('Quantity', $suggestions['Quantity']);
|
||||
$this->assertEquals('MPN', $suggestions['MPN']);
|
||||
$this->assertEquals('Manufacturer', $suggestions['Manufacturer']);
|
||||
$this->assertEquals('Part-DB ID', $suggestions['Part-DB ID']);
|
||||
$this->assertEquals('Comment', $suggestions['Comment']);
|
||||
}
|
||||
|
||||
public function testGetSuggestedFieldMappingWithSuppliers(): void
|
||||
{
|
||||
// Create test suppliers
|
||||
$supplier1 = new Supplier();
|
||||
$supplier1->setName('LCSC');
|
||||
$supplier2 = new Supplier();
|
||||
$supplier2->setName('Mouser');
|
||||
|
||||
$this->entityManager->persist($supplier1);
|
||||
$this->entityManager->persist($supplier2);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$detected_fields = [
|
||||
'Reference',
|
||||
'LCSC',
|
||||
'Mouser',
|
||||
'lcsc_part',
|
||||
'mouser_spn'
|
||||
];
|
||||
|
||||
$suggestions = $this->service->getSuggestedFieldMapping($detected_fields);
|
||||
|
||||
$this->assertIsArray($suggestions);
|
||||
$this->assertEquals('Designator', $suggestions['Reference']);
|
||||
// Note: The exact mapping depends on the pattern matching logic
|
||||
// We just check that supplier fields are mapped to something
|
||||
$this->assertArrayHasKey('LCSC', $suggestions);
|
||||
$this->assertArrayHasKey('Mouser', $suggestions);
|
||||
$this->assertArrayHasKey('lcsc_part', $suggestions);
|
||||
$this->assertArrayHasKey('mouser_spn', $suggestions);
|
||||
|
||||
// Clean up
|
||||
$this->entityManager->remove($supplier1);
|
||||
$this->entityManager->remove($supplier2);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testValidateFieldMappingValid(): void
|
||||
{
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Quantity' => 'Quantity',
|
||||
'Value' => 'Value'
|
||||
];
|
||||
|
||||
$detected_fields = ['Reference', 'Quantity', 'Value', 'MPN'];
|
||||
|
||||
$result = $this->service->validateFieldMapping($field_mapping, $detected_fields);
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey('errors', $result);
|
||||
$this->assertArrayHasKey('warnings', $result);
|
||||
$this->assertArrayHasKey('is_valid', $result);
|
||||
|
||||
$this->assertTrue($result['is_valid']);
|
||||
$this->assertEmpty($result['errors']);
|
||||
$this->assertNotEmpty($result['warnings']); // Should warn about unmapped MPN
|
||||
}
|
||||
|
||||
public function testValidateFieldMappingMissingRequired(): void
|
||||
{
|
||||
$field_mapping = [
|
||||
'Value' => 'Value',
|
||||
'MPN' => 'MPN'
|
||||
];
|
||||
|
||||
$detected_fields = ['Value', 'MPN'];
|
||||
|
||||
$result = $this->service->validateFieldMapping($field_mapping, $detected_fields);
|
||||
|
||||
$this->assertFalse($result['is_valid']);
|
||||
$this->assertNotEmpty($result['errors']);
|
||||
$this->assertContains("Required field 'Designator' is not mapped from any CSV column.", $result['errors']);
|
||||
$this->assertContains("Required field 'Quantity' is not mapped from any CSV column.", $result['errors']);
|
||||
}
|
||||
|
||||
public function testValidateFieldMappingInvalidTarget(): void
|
||||
{
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Quantity' => 'Quantity',
|
||||
'Value' => 'InvalidTarget'
|
||||
];
|
||||
|
||||
$detected_fields = ['Reference', 'Quantity', 'Value'];
|
||||
|
||||
$result = $this->service->validateFieldMapping($field_mapping, $detected_fields);
|
||||
|
||||
$this->assertFalse($result['is_valid']);
|
||||
$this->assertNotEmpty($result['errors']);
|
||||
$this->assertContains("Invalid target field 'InvalidTarget' for CSV field 'Value'.", $result['errors']);
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematic(): void
|
||||
{
|
||||
$input = <<<CSV
|
||||
"Reference","Value","Footprint","Quantity","MPN","Manufacturer","LCSC SPN","Mouser SPN"
|
||||
"R1,R2","10k","R_0805_2012Metric",2,"CRCW080510K0FKEA","Vishay","C123456","123-M10K"
|
||||
"C1","100nF","C_0805_2012Metric",1,"CL21A104KOCLRNC","Samsung","C789012","80-CL21A104KOCLRNC"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Value' => 'Value',
|
||||
'Footprint' => 'Package',
|
||||
'Quantity' => 'Quantity',
|
||||
'MPN' => 'MPN',
|
||||
'Manufacturer' => 'Manufacturer',
|
||||
'LCSC SPN' => 'LCSC SPN',
|
||||
'Mouser SPN' => 'Mouser SPN'
|
||||
];
|
||||
|
||||
$bom_entries = $this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
|
||||
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
|
||||
$this->assertCount(2, $bom_entries);
|
||||
|
||||
// Check first entry
|
||||
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
|
||||
$this->assertEquals(2.0, $bom_entries[0]->getQuantity());
|
||||
$this->assertEquals('CRCW080510K0FKEA (R_0805_2012Metric)', $bom_entries[0]->getName());
|
||||
$this->assertStringContainsString('Value: 10k', $bom_entries[0]->getComment());
|
||||
$this->assertStringContainsString('MPN: CRCW080510K0FKEA', $bom_entries[0]->getComment());
|
||||
$this->assertStringContainsString('Manf: Vishay', $bom_entries[0]->getComment());
|
||||
|
||||
// Check second entry
|
||||
$this->assertEquals('C1', $bom_entries[1]->getMountnames());
|
||||
$this->assertEquals(1.0, $bom_entries[1]->getQuantity());
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicWithPriority(): void
|
||||
{
|
||||
$input = <<<CSV
|
||||
"Reference","Value","MPN1","MPN2","Quantity"
|
||||
"R1,R2","10k","CRCW080510K0FKEA","","2"
|
||||
"C1","100nF","","CL21A104KOCLRNC","1"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Value' => 'Value',
|
||||
'MPN1' => 'MPN',
|
||||
'MPN2' => 'MPN',
|
||||
'Quantity' => 'Quantity'
|
||||
];
|
||||
|
||||
$field_priorities = [
|
||||
'MPN1' => 1,
|
||||
'MPN2' => 2
|
||||
];
|
||||
|
||||
$bom_entries = $this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'field_priorities' => $field_priorities,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
|
||||
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
|
||||
$this->assertCount(2, $bom_entries);
|
||||
|
||||
// First entry should use MPN1 (higher priority)
|
||||
$this->assertEquals('CRCW080510K0FKEA', $bom_entries[0]->getName());
|
||||
|
||||
// Second entry should use MPN2 (MPN1 is empty)
|
||||
$this->assertEquals('CL21A104KOCLRNC', $bom_entries[1]->getName());
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicWithPartDBID(): void
|
||||
{
|
||||
// Create a test part with required fields
|
||||
$part = new Part();
|
||||
$part->setName('Test Part');
|
||||
$part->setCategory($this->getDefaultCategory($this->entityManager));
|
||||
$this->entityManager->persist($part);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$input = <<<CSV
|
||||
"Reference","Value","Part-DB ID","Quantity"
|
||||
"R1,R2","10k","{$part->getID()}","2"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Value' => 'Value',
|
||||
'Part-DB ID' => 'Part-DB ID',
|
||||
'Quantity' => 'Quantity'
|
||||
];
|
||||
|
||||
$bom_entries = $this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
|
||||
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
|
||||
$this->assertCount(1, $bom_entries);
|
||||
|
||||
$this->assertEquals('Test Part', $bom_entries[0]->getName());
|
||||
$this->assertSame($part, $bom_entries[0]->getPart());
|
||||
$this->assertStringContainsString("Part-DB ID: {$part->getID()}", $bom_entries[0]->getComment());
|
||||
|
||||
// Clean up
|
||||
$this->entityManager->remove($part);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicWithInvalidPartDBID(): void
|
||||
{
|
||||
$input = <<<CSV
|
||||
"Reference","Value","Part-DB ID","Quantity"
|
||||
"R1,R2","10k","99999","2"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Value' => 'Value',
|
||||
'Part-DB ID' => 'Part-DB ID',
|
||||
'Quantity' => 'Quantity'
|
||||
];
|
||||
|
||||
$bom_entries = $this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
|
||||
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
|
||||
$this->assertCount(1, $bom_entries);
|
||||
|
||||
$this->assertEquals('10k', $bom_entries[0]->getName()); // Should use Value as name
|
||||
$this->assertNull($bom_entries[0]->getPart()); // Should not link to part
|
||||
$this->assertStringContainsString("Part-DB ID: 99999 (NOT FOUND)", $bom_entries[0]->getComment());
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicMergeDuplicates(): void
|
||||
{
|
||||
$input = <<<CSV
|
||||
"Reference","Value","MPN","Quantity"
|
||||
"R1","10k","CRCW080510K0FKEA","1"
|
||||
"R2","10k","CRCW080510K0FKEA","1"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Value' => 'Value',
|
||||
'MPN' => 'MPN',
|
||||
'Quantity' => 'Quantity'
|
||||
];
|
||||
|
||||
$bom_entries = $this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
|
||||
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
|
||||
$this->assertCount(1, $bom_entries); // Should merge into one entry
|
||||
|
||||
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
|
||||
$this->assertEquals(2.0, $bom_entries[0]->getQuantity());
|
||||
$this->assertEquals('CRCW080510K0FKEA', $bom_entries[0]->getName());
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicMissingRequired(): void
|
||||
{
|
||||
$input = <<<CSV
|
||||
"Value","MPN"
|
||||
"10k","CRCW080510K0FKEA"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Value' => 'Value',
|
||||
'MPN' => 'MPN'
|
||||
];
|
||||
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessage('Required field "Designator" is missing or empty');
|
||||
|
||||
$this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicQuantityMismatch(): void
|
||||
{
|
||||
$input = <<<CSV
|
||||
"Reference","Value","Quantity"
|
||||
"R1,R2,R3","10k","2"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Value' => 'Value',
|
||||
'Quantity' => 'Quantity'
|
||||
];
|
||||
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessage('Mismatch between quantity and component references');
|
||||
|
||||
$this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
}
|
||||
|
||||
public function testStringToBOMEntriesKiCADSchematicWithBOM(): void
|
||||
{
|
||||
// Test with BOM (Byte Order Mark)
|
||||
$input = "\xEF\xBB\xBF" . <<<CSV
|
||||
"Reference","Value","Quantity"
|
||||
"R1,R2","10k","2"
|
||||
CSV;
|
||||
|
||||
$field_mapping = [
|
||||
'Reference' => 'Designator',
|
||||
'Value' => 'Value',
|
||||
'Quantity' => 'Quantity'
|
||||
];
|
||||
|
||||
$bom_entries = $this->service->stringToBOMEntries($input, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'delimiter' => ','
|
||||
]);
|
||||
|
||||
$this->assertContainsOnlyInstancesOf(ProjectBOMEntry::class, $bom_entries);
|
||||
$this->assertCount(1, $bom_entries);
|
||||
$this->assertEquals('R1,R2', $bom_entries[0]->getMountnames());
|
||||
}
|
||||
|
||||
private function getDefaultCategory(EntityManagerInterface $entityManager)
|
||||
{
|
||||
// Get the first available category or create a default one
|
||||
$categoryRepo = $entityManager->getRepository(\App\Entity\Parts\Category::class);
|
||||
$categories = $categoryRepo->findAll();
|
||||
|
||||
if (empty($categories)) {
|
||||
// Create a default category if none exists
|
||||
$category = new \App\Entity\Parts\Category();
|
||||
$category->setName('Default Category');
|
||||
$entityManager->persist($category);
|
||||
$entityManager->flush();
|
||||
return $category;
|
||||
}
|
||||
|
||||
return $categories[0];
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
self::bootKernel();
|
||||
\Locale::setDefault('en');
|
||||
\Locale::setDefault('en_US');
|
||||
$this->service = self::getContainer()->get(TimestampableElementProvider::class);
|
||||
$this->target = new class() implements TimeStampableInterface {
|
||||
$this->target = new class () implements TimeStampableInterface {
|
||||
public function getLastModified(): ?DateTime
|
||||
{
|
||||
return new \DateTime('2000-01-01');
|
||||
return new DateTime('2000-01-01');
|
||||
}
|
||||
|
||||
public function getAddedDate(): ?DateTime
|
||||
{
|
||||
return new \DateTime('2000-01-01');
|
||||
return new DateTime('2000-01-01');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static function dataProvider(): \Iterator
|
||||
{
|
||||
\Locale::setDefault('en');
|
||||
yield ['1/1/00, 12:00 AM', '[[LAST_MODIFIED]]'];
|
||||
yield ['1/1/00, 12:00 AM', '[[CREATION_DATE]]'];
|
||||
\Locale::setDefault('en_US');
|
||||
// Use IntlDateFormatter like the actual service does
|
||||
$formatter = new \IntlDateFormatter(\Locale::getDefault(), \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT);
|
||||
$expectedFormat = $formatter->format(new DateTime('2000-01-01'));
|
||||
yield [$expectedFormat, '[[LAST_MODIFIED]]'];
|
||||
yield [$expectedFormat, '[[CREATION_DATE]]'];
|
||||
}
|
||||
|
||||
#[DataProvider('dataProvider')]
|
||||
@@ -87,4 +90,4 @@ class TimestampableElementProviderTest extends WebTestCase
|
||||
{
|
||||
$this->assertSame($expected, $this->service->replace($placeholder, $this->target));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user