mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-22 09:42:36 +01:00
Compare commits
64 Commits
v2
...
feature/ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52444e05e4 | ||
|
|
4fcd55748f | ||
|
|
d57107ed3e | ||
|
|
0c7aa5e92a | ||
|
|
17f123ba8a | ||
|
|
1156bb52af | ||
|
|
71be75b3e7 | ||
|
|
5a4f151ca3 | ||
|
|
9729a43f2b | ||
|
|
4da403569c | ||
|
|
74be016b68 | ||
|
|
3896d3d9ab | ||
|
|
ed396765c8 | ||
|
|
cc9d50a8fe | ||
|
|
9b4d5e9c27 | ||
|
|
ccb837e4b4 | ||
|
|
2bc39e7791 | ||
|
|
fa7f3a1da1 | ||
|
|
c91d37d2a4 | ||
|
|
5ab7ac4d4b | ||
|
|
4c8940f9c3 | ||
|
|
aa29f10d51 | ||
|
|
78885ec3c5 | ||
|
|
1fb137e89f | ||
|
|
facfb37383 | ||
|
|
c5751b2aa6 | ||
|
|
aa4299041b | ||
|
|
c27f2246a3 | ||
|
|
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
|
||||
|
||||
|
||||
99
Makefile
Normal file
99
Makefile
Normal file
@@ -0,0 +1,99 @@
|
||||
# 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
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "PartDB Test Environment Management"
|
||||
@echo "=================================="
|
||||
@echo ""
|
||||
@echo "Available targets:"
|
||||
@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 "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 " help - Show this help message"
|
||||
|
||||
# Complete test environment setup
|
||||
test-setup: 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..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php 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
|
||||
|
||||
# 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: 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..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
|
||||
|
||||
dev-cache-clear:
|
||||
@echo "🗑️ Clearing development cache..."
|
||||
rm -rf var/cache/dev
|
||||
@echo "✅ Development cache cleared"
|
||||
|
||||
dev-warmup:
|
||||
@echo "🔥 Warming up development cache..."
|
||||
COMPOSER_MEMORY_LIMIT=-1 php bin/console cache:warmup --env dev -n --memory-limit=1G
|
||||
|
||||
dev-reset: dev-cache-clear dev-db-migrate
|
||||
@echo "✅ Development environment reset complete!"
|
||||
@@ -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,
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,11 @@ th.select-checkbox {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/** Add spacing between column visibility button and length menu */
|
||||
.buttons-colvis {
|
||||
margin-right: 0.2em !important;
|
||||
}
|
||||
|
||||
/** Fix datatables select-checkbox position */
|
||||
table.dataTable tr.selected td.select-checkbox:after
|
||||
{
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"omines/datatables-bundle": "^0.10.0",
|
||||
"paragonie/sodium_compat": "^1.21",
|
||||
"part-db/label-fonts": "^1.0",
|
||||
"phpoffice/phpspreadsheet": "^5.0.0",
|
||||
"rhukster/dom-sanitizer": "^1.0",
|
||||
"runtime/frankenphp-symfony": "^0.2.0",
|
||||
"s9e/text-formatter": "^2.1",
|
||||
@@ -80,7 +81,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.*",
|
||||
|
||||
472
composer.lock
generated
472
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "3b97b6338827ba56e0404860f3e98359",
|
||||
"content-hash": "6b107e35ab77a04c59b98c6297e9edef",
|
||||
"packages": [
|
||||
{
|
||||
"name": "amphp/amp",
|
||||
@@ -2500,6 +2500,85 @@
|
||||
],
|
||||
"time": "2022-01-17T14:14:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<1.11.10"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12 || ^2",
|
||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||
"phpunit/phpunit": "^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Composer\\Pcre\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "http://seld.be"
|
||||
}
|
||||
],
|
||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||
"keywords": [
|
||||
"PCRE",
|
||||
"preg",
|
||||
"regex",
|
||||
"regular expression"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/composer/pcre/issues",
|
||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://packagist.com",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-12T16:29:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "daverandom/libdns",
|
||||
"version": "v2.1.0",
|
||||
@@ -6514,6 +6593,191 @@
|
||||
},
|
||||
"time": "2023-07-31T13:36:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
|
||||
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"ext-zlib": "*",
|
||||
"php-64bit": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.16",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"vimeo/psalm": "^6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"guzzlehttp/psr7": "^2.4",
|
||||
"psr/http-message": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ZipStream\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paul Duncan",
|
||||
"email": "pabs@pablotron.org"
|
||||
},
|
||||
{
|
||||
"name": "Jonatan Männchen",
|
||||
"email": "jonatan@maennchen.ch"
|
||||
},
|
||||
{
|
||||
"name": "Jesse Donat",
|
||||
"email": "donatj@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "András Kolesár",
|
||||
"email": "kolesar@kolesar.hu"
|
||||
}
|
||||
],
|
||||
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||
"keywords": [
|
||||
"stream",
|
||||
"zip"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/maennchen",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-01-27T12:07:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/complex",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Complex\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@lange.demon.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with complex numbers",
|
||||
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||
"keywords": [
|
||||
"complex",
|
||||
"mathematics"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||
},
|
||||
"time": "2022-12-06T16:21:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/matrix",
|
||||
"version": "3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpdocumentor/phpdocumentor": "2.*",
|
||||
"phploc/phploc": "^4.0",
|
||||
"phpmd/phpmd": "2.*",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"sebastian/phpcpd": "^4.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Matrix\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@demon-angel.eu"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with matrices",
|
||||
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||
"keywords": [
|
||||
"mathematics",
|
||||
"matrix",
|
||||
"vector"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.0",
|
||||
@@ -8035,17 +8299,123 @@
|
||||
"time": "2024-11-09T15:12:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
"version": "2.2.0",
|
||||
"name": "phpoffice/phpspreadsheet",
|
||||
"version": "5.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
||||
"reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8"
|
||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||
"reference": "d88efcac2444cde18e17684178de02b25dff2050"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8",
|
||||
"reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8",
|
||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/d88efcac2444cde18e17684178de02b25dff2050",
|
||||
"reference": "d88efcac2444cde18e17684178de02b25dff2050",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/pcre": "^1||^2||^3",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"ext-zip": "*",
|
||||
"ext-zlib": "*",
|
||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||
"markbaker/complex": "^3.0",
|
||||
"markbaker/matrix": "^3.0",
|
||||
"php": "^8.1",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"mitoteam/jpgraph": "^10.3",
|
||||
"mpdf/mpdf": "^8.1.1",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"tecnickcom/tcpdf": "^6.5"
|
||||
},
|
||||
"suggest": {
|
||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||
"ext-intl": "PHP Internationalization Functions",
|
||||
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maarten Balliauw",
|
||||
"homepage": "https://blog.maartenballiauw.be"
|
||||
},
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"homepage": "https://markbakeruk.net"
|
||||
},
|
||||
{
|
||||
"name": "Franck Lefevre",
|
||||
"homepage": "https://rootslabs.net"
|
||||
},
|
||||
{
|
||||
"name": "Erik Tilt"
|
||||
},
|
||||
{
|
||||
"name": "Adrien Crivelli"
|
||||
}
|
||||
],
|
||||
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||
"keywords": [
|
||||
"OpenXML",
|
||||
"excel",
|
||||
"gnumeric",
|
||||
"ods",
|
||||
"php",
|
||||
"spreadsheet",
|
||||
"xls",
|
||||
"xlsx"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.0.0"
|
||||
},
|
||||
"time": "2025-08-10T06:18:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
"version": "2.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
||||
"reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495",
|
||||
"reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -8077,9 +8447,9 @@
|
||||
"description": "PHPDoc parser with support for nullable, intersection and generic types",
|
||||
"support": {
|
||||
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
|
||||
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.2.0"
|
||||
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0"
|
||||
},
|
||||
"time": "2025-07-13T07:04:09+00:00"
|
||||
"time": "2025-08-30T15:50:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
@@ -15206,90 +15576,6 @@
|
||||
],
|
||||
"time": "2025-06-27T19:55:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/ux-toggle-password",
|
||||
"version": "v2.30.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/ux-toggle-password.git",
|
||||
"reference": "414b1ea51b93c4c6c6cc3a485adbfc8764ea6dc8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/ux-toggle-password/zipball/414b1ea51b93c4c6c6cc3a485adbfc8764ea6dc8",
|
||||
"reference": "414b1ea51b93c4c6c6cc3a485adbfc8764ea6dc8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"symfony/config": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/form": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/options-resolver": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/translation": "^5.4|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0",
|
||||
"twig/twig": "^2.14.7|^3.0.4"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/ux",
|
||||
"name": "symfony/ux"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\UX\\TogglePassword\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Félix Eymonot",
|
||||
"email": "felix.eymonot@alximy.io"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Toggle visibility of password inputs for Symfony Forms",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"symfony-ux"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/ux-toggle-password/tree/v2.30.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-27T15:25:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/ux-translator",
|
||||
"version": "v2.30.0",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;manufacturing_status
|
||||
BC547;NPN transistor;Transistors -> NPN;very important notes;TO -> TO-92;NPN,Transistor;5;Room 1 -> Shelf 1 -> Box 2;10;;;Manufacturer;;You need to fill this line, to use spn and price;BC547C;2,3;0;;;;
|
||||
BC557;PNP transistor;<b>HTML</b>;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active
|
||||
Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter;
|
||||
name;description;category;notes;footprint;tags;quantity;storage_location;mass;ipn;mpn;manufacturing_status;manufacturer;supplier;spn;price;favorite;needs_review;minamount;partUnit;eda_info.reference_prefix;eda_info.value;eda_info.visibility;eda_info.exclude_from_bom;eda_info.exclude_from_board;eda_info.exclude_from_sim;eda_info.kicad_symbol;eda_info.kicad_footprint
|
||||
"MLCC; 0603; 0.22uF";Multilayer ceramic capacitor;Electrical Components->Passive Components->Capacitors_SMD;High quality MLCC;0603;Capacitor,SMD,MLCC,0603;500;Room 1->Shelf 1->Box 2;0.1;CL10B224KO8NNNC;CL10B224KO8NNNC;active;Samsung;LCSC;C160828;0.0023;0;0;1;pcs;C;0.22uF;1;0;0;0;Device:C;Capacitor_SMD:C_0603_1608Metric
|
||||
"MLCC; 0402; 10pF";Small MLCC for high frequency;Electrical Components->Passive Components->Capacitors_SMD;;0402;Capacitor,SMD,MLCC,0402;500;Room 1->Shelf 1->Box 3;0.05;FCC0402N100J500AT;FCC0402N100J500AT;active;Fenghua;LCSC;C5137557;0.0015;0;0;1;pcs;C;10pF;1;0;0;0;Device:C;Capacitor_SMD:C_0402_1005Metric
|
||||
"Diode; 1N4148W";Fast switching diode;Electrical Components->Semiconductors->Diodes;Fast recovery time;Diode_SMD:D_SOD-123;Diode,SMD,Schottky;100;Room 2->Box 1;0.2;1N4148W;1N4148W;active;Vishay;LCSC;C917030;0.008;0;0;1;pcs;D;1N4148W;1;0;0;0;Device:D;Diode_SMD:D_SOD-123
|
||||
BC547;NPN transistor;Transistors->NPN;very important notes;TO->TO-92;NPN,Transistor;5;Room 1->Shelf 1->Box 2;10;BC547;BC547;active;Generic;LCSC;BC547C;2.3;0;0;1;pcs;Q;BC547;1;0;0;0;Device:Q_NPN_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder
|
||||
BC557;PNP transistor;Transistors->PNP;PNP complement to BC547;TO->TO-92;PNP,Transistor;10;Room 2->Box 3;10;BC557;BC557;active;Generic;LCSC;BC557C;2.1;0;0;1;pcs;Q;BC557;1;0;0;0;Device:Q_PNP_EBC;TO_SOT_Packages_SMD:TO-92_HandSolder
|
||||
Copper Wire;Bare copper wire;Wire->Copper;For prototyping;Wire;Wire,Copper;50;Room 3->Spool Rack;0.5;CW-22AWG;CW-22AWG;active;Generic;Local Supplier;LS-CW-22;0.15;0;0;1;Meter;W;22AWG;1;0;0;0;Device:Wire;Connector_PinHeader_2.54mm:PinHeader_1x01_P2.54mm_Vertical
|
||||
|
||||
|
@@ -142,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
70
migrations/Version20250802205143.php
Normal file
70
migrations/Version20250802205143.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20250802205143 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add bulk info provider import jobs and job parts tables';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INT AUTO_INCREMENT NOT NULL, name LONGTEXT NOT NULL, field_mappings LONGTEXT NOT NULL, search_results LONGTEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details TINYINT(1) NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES `users` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
|
||||
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
|
||||
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INT AUTO_INCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason LONGTEXT DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id), CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES `parts` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name CLOB NOT NULL, field_mappings CLOB NOT NULL, search_results CLOB NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INTEGER NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
|
||||
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason CLOB DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INTEGER NOT NULL, part_id INTEGER NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id SERIAL PRIMARY KEY NOT NULL, name TEXT NOT NULL, field_mappings TEXT NOT NULL, search_results TEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
|
||||
|
||||
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id SERIAL PRIMARY KEY NOT NULL, status VARCHAR(20) NOT NULL, reason TEXT DEFAULT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES parts (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
|
||||
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
|
||||
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
705
src/Controller/BulkInfoProviderImportController.php
Normal file
705
src/Controller/BulkInfoProviderImportController.php
Normal file
@@ -0,0 +1,705 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\BulkInfoProviderImportJob;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\BulkImportJobStatus;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ExistingPartFinder;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Services\InfoProviderSystem\Providers\LCSCProvider;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpClient\Exception\ClientException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use App\Entity\UserSystem\User;
|
||||
|
||||
#[Route('/tools/bulk-info-provider-import')]
|
||||
class BulkInfoProviderImportController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PartInfoRetriever $infoRetriever,
|
||||
private readonly LCSCProvider $LCSCProvider,
|
||||
private readonly ExistingPartFinder $existingPartFinder,
|
||||
private readonly EntityManagerInterface $entityManager
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/step1', name: 'bulk_info_provider_step1')]
|
||||
public function step1(Request $request, LoggerInterface $exceptionLogger): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
// Increase execution time for bulk operations
|
||||
set_time_limit(600); // 10 minutes for large batches
|
||||
|
||||
$ids = $request->query->get('ids');
|
||||
if (!$ids) {
|
||||
$this->addFlash('error', 'No parts selected for bulk import');
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
// Get the selected parts
|
||||
$partIds = explode(',', $ids);
|
||||
$partRepository = $this->entityManager->getRepository(Part::class);
|
||||
$parts = $partRepository->getElementsFromIDArray($partIds);
|
||||
|
||||
if (empty($parts)) {
|
||||
$this->addFlash('error', 'No valid parts found for bulk import');
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
// Warn about large batches
|
||||
if (count($parts) > 50) {
|
||||
$this->addFlash('warning', 'Processing ' . count($parts) . ' parts may take several minutes and could timeout. Consider processing smaller batches.');
|
||||
}
|
||||
|
||||
// Generate field choices
|
||||
$fieldChoices = [
|
||||
'info_providers.bulk_search.field.mpn' => 'mpn',
|
||||
'info_providers.bulk_search.field.name' => 'name',
|
||||
];
|
||||
|
||||
// Add dynamic supplier fields
|
||||
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierKey = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName()));
|
||||
$fieldChoices["Supplier: " . $supplier->getName() . " (SPN)"] = $supplierKey . '_spn';
|
||||
}
|
||||
|
||||
// Initialize form with useful default mappings
|
||||
$initialData = [
|
||||
'field_mappings' => [
|
||||
['field' => 'mpn', 'providers' => [], 'priority' => 1]
|
||||
],
|
||||
'prefetch_details' => false
|
||||
];
|
||||
|
||||
$form = $this->createForm(GlobalFieldMappingType::class, $initialData, [
|
||||
'field_choices' => $fieldChoices
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$searchResults = null;
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$formData = $form->getData();
|
||||
$fieldMappings = $formData['field_mappings'];
|
||||
$prefetchDetails = $formData['prefetch_details'] ?? false;
|
||||
|
||||
// Debug logging
|
||||
$exceptionLogger->info('Form data received', [
|
||||
'prefetch_details' => $prefetchDetails,
|
||||
'prefetch_details_type' => gettype($prefetchDetails)
|
||||
]);
|
||||
|
||||
// Create and save the job
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setFieldMappings($fieldMappings);
|
||||
$job->setPrefetchDetails($prefetchDetails);
|
||||
$user = $this->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new \RuntimeException('User must be authenticated and of type User');
|
||||
}
|
||||
$job->setCreatedBy($user);
|
||||
|
||||
// Create job parts for each part
|
||||
foreach ($parts as $part) {
|
||||
$jobPart = new BulkInfoProviderImportJobPart($job, $part);
|
||||
$job->addJobPart($jobPart);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$searchResults = [];
|
||||
$hasAnyResults = false;
|
||||
|
||||
try {
|
||||
// Optimize: Use batch async requests for LCSC provider
|
||||
$lcscKeywords = [];
|
||||
$keywordToPartField = [];
|
||||
|
||||
// First, collect all LCSC keywords for batch processing
|
||||
foreach ($parts as $part) {
|
||||
foreach ($fieldMappings as $mapping) {
|
||||
$field = $mapping['field'];
|
||||
$providers = $mapping['providers'] ?? [];
|
||||
|
||||
if (in_array('lcsc', $providers, true)) {
|
||||
$keyword = $this->getKeywordFromField($part, $field);
|
||||
if ($keyword) {
|
||||
$lcscKeywords[] = $keyword;
|
||||
$keywordToPartField[$keyword] = [
|
||||
'part' => $part,
|
||||
'field' => $field
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch search LCSC keywords asynchronously
|
||||
$lcscBatchResults = [];
|
||||
if (!empty($lcscKeywords)) {
|
||||
try {
|
||||
// Try to get LCSC provider and use batch method if available
|
||||
$lcscBatchResults = $this->searchLcscBatch($lcscKeywords);
|
||||
} catch (\Exception $e) {
|
||||
$exceptionLogger->warning('LCSC batch search failed, falling back to individual requests', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Now process each part
|
||||
foreach ($parts as $part) {
|
||||
$partResult = [
|
||||
'part' => $part,
|
||||
'search_results' => [],
|
||||
'errors' => []
|
||||
];
|
||||
|
||||
// Collect all DTOs using priority-based search
|
||||
$allDtos = [];
|
||||
$dtoMetadata = []; // Store source field info separately
|
||||
|
||||
// Group mappings by priority (lower number = higher priority)
|
||||
$mappingsByPriority = [];
|
||||
foreach ($fieldMappings as $mapping) {
|
||||
$priority = $mapping['priority'] ?? 1;
|
||||
$mappingsByPriority[$priority][] = $mapping;
|
||||
}
|
||||
ksort($mappingsByPriority); // Sort by priority (1, 2, 3...)
|
||||
|
||||
// Try each priority level until we find results
|
||||
foreach ($mappingsByPriority as $priority => $mappings) {
|
||||
$priorityResults = [];
|
||||
|
||||
// For same priority, search all and combine results
|
||||
foreach ($mappings as $mapping) {
|
||||
$field = $mapping['field'];
|
||||
$providers = $mapping['providers'] ?? [];
|
||||
|
||||
if (empty($providers)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keyword = $this->getKeywordFromField($part, $field);
|
||||
|
||||
if ($keyword) {
|
||||
try {
|
||||
// Use batch results for LCSC if available
|
||||
if (in_array('lcsc', $providers, true) && isset($lcscBatchResults[$keyword])) {
|
||||
$dtos = $lcscBatchResults[$keyword];
|
||||
} else {
|
||||
// Fall back to regular search for non-LCSC providers
|
||||
$dtos = $this->infoRetriever->searchByKeyword(
|
||||
keyword: $keyword,
|
||||
providers: $providers
|
||||
);
|
||||
}
|
||||
|
||||
// Store field info for each DTO separately
|
||||
foreach ($dtos as $dto) {
|
||||
$dtoKey = $dto->provider_key . '|' . $dto->provider_id;
|
||||
$dtoMetadata[$dtoKey] = [
|
||||
'source_field' => $field,
|
||||
'source_keyword' => $keyword,
|
||||
'priority' => $priority
|
||||
];
|
||||
}
|
||||
|
||||
$priorityResults = array_merge($priorityResults, $dtos);
|
||||
} catch (ClientException $e) {
|
||||
$partResult['errors'][] = "Error searching with {$field} (priority {$priority}): " . $e->getMessage();
|
||||
$exceptionLogger->error('Error during bulk info provider search for part ' . $part->getId() . " field {$field}: " . $e->getMessage(), ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found results at this priority level, use them and stop
|
||||
if (!empty($priorityResults)) {
|
||||
$allDtos = $priorityResults;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates based on provider_key + provider_id
|
||||
$uniqueDtos = [];
|
||||
$seenKeys = [];
|
||||
foreach ($allDtos as $dto) {
|
||||
if ($dto === null || !isset($dto->provider_key, $dto->provider_id)) {
|
||||
continue;
|
||||
}
|
||||
$key = "{$dto->provider_key}|{$dto->provider_id}";
|
||||
if (!in_array($key, $seenKeys, true)) {
|
||||
$seenKeys[] = $key;
|
||||
$uniqueDtos[] = $dto;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert DTOs to result format with metadata
|
||||
$partResult['search_results'] = array_map(
|
||||
function ($dto) use ($dtoMetadata) {
|
||||
$dtoKey = $dto->provider_key . '|' . $dto->provider_id;
|
||||
$metadata = $dtoMetadata[$dtoKey] ?? [];
|
||||
return [
|
||||
'dto' => $dto,
|
||||
'localPart' => $this->existingPartFinder->findFirstExisting($dto),
|
||||
'source_field' => $metadata['source_field'] ?? null,
|
||||
'source_keyword' => $metadata['source_keyword'] ?? null
|
||||
];
|
||||
},
|
||||
$uniqueDtos
|
||||
);
|
||||
|
||||
if (!empty($partResult['search_results'])) {
|
||||
$hasAnyResults = true;
|
||||
}
|
||||
|
||||
$searchResults[] = $partResult;
|
||||
}
|
||||
|
||||
// Check if search was successful
|
||||
if (!$hasAnyResults) {
|
||||
$exceptionLogger->warning('Bulk import search returned no results for any parts', [
|
||||
'job_id' => $job->getId(),
|
||||
'parts_count' => count($parts)
|
||||
]);
|
||||
|
||||
// Delete the job since it has no useful results
|
||||
$this->entityManager->remove($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('error', 'No search results found for any of the selected parts. Please check your field mappings and provider selections.');
|
||||
return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]);
|
||||
}
|
||||
|
||||
// Save search results to job
|
||||
$job->setSearchResults($this->serializeSearchResults($searchResults));
|
||||
$job->markAsInProgress();
|
||||
$this->entityManager->flush();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$exceptionLogger->error('Critical error during bulk import search', [
|
||||
'job_id' => $job->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
'exception' => $e
|
||||
]);
|
||||
|
||||
// Delete the job on critical failure
|
||||
$this->entityManager->remove($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('error', 'Search failed due to an error: ' . $e->getMessage());
|
||||
return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]);
|
||||
}
|
||||
|
||||
// Prefetch details if requested
|
||||
if ($prefetchDetails) {
|
||||
$exceptionLogger->info('Prefetch details requested, starting prefetch for ' . count($searchResults) . ' parts');
|
||||
$this->prefetchDetailsForResults($searchResults, $exceptionLogger);
|
||||
} else {
|
||||
$exceptionLogger->info('Prefetch details not requested, skipping prefetch');
|
||||
}
|
||||
|
||||
// Redirect to step 2 with the job
|
||||
return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]);
|
||||
}
|
||||
|
||||
// Get existing in-progress jobs for current user
|
||||
$existingJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy(['createdBy' => $this->getUser(), 'status' => BulkImportJobStatus::IN_PROGRESS], ['createdAt' => 'DESC'], 10);
|
||||
|
||||
return $this->render('info_providers/bulk_import/step1.html.twig', [
|
||||
'form' => $form,
|
||||
'parts' => $parts,
|
||||
'search_results' => $searchResults,
|
||||
'existing_jobs' => $existingJobs,
|
||||
'fieldChoices' => $fieldChoices
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/manage', name: 'bulk_info_provider_manage')]
|
||||
public function manageBulkJobs(): Response
|
||||
{
|
||||
// Get all jobs for current user
|
||||
$allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy([], ['createdAt' => 'DESC']);
|
||||
|
||||
// Check and auto-complete jobs that should be completed
|
||||
// Also clean up jobs with no results (failed searches)
|
||||
$updatedJobs = false;
|
||||
$jobsToDelete = [];
|
||||
|
||||
foreach ($allJobs as $job) {
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
$updatedJobs = true;
|
||||
}
|
||||
|
||||
// Mark jobs with no results for deletion (failed searches)
|
||||
if ($job->getResultCount() === 0 && $job->isInProgress()) {
|
||||
$jobsToDelete[] = $job;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete failed jobs
|
||||
foreach ($jobsToDelete as $job) {
|
||||
$this->entityManager->remove($job);
|
||||
$updatedJobs = true;
|
||||
}
|
||||
|
||||
// Flush changes if any jobs were updated
|
||||
if ($updatedJobs) {
|
||||
$this->entityManager->flush();
|
||||
|
||||
if (!empty($jobsToDelete)) {
|
||||
$this->addFlash('info', 'Cleaned up ' . count($jobsToDelete) . ' failed job(s) with no results.');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('info_providers/bulk_import/manage.html.twig', [
|
||||
'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/delete', name: 'bulk_info_provider_delete', methods: ['DELETE'])]
|
||||
public function deleteJob(int $jobId): Response
|
||||
{
|
||||
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
|
||||
if (!$job || $job->getCreatedBy() !== $this->getUser()) {
|
||||
return $this->json(['error' => 'Job not found or access denied'], 404);
|
||||
}
|
||||
|
||||
// Only allow deletion of completed, failed, or stopped jobs
|
||||
if (!$job->isCompleted() && !$job->isFailed() && !$job->isStopped()) {
|
||||
return $this->json(['error' => 'Cannot delete active job'], 400);
|
||||
}
|
||||
|
||||
$this->entityManager->remove($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/stop', name: 'bulk_info_provider_stop', methods: ['POST'])]
|
||||
public function stopJob(int $jobId): Response
|
||||
{
|
||||
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
|
||||
if (!$job || $job->getCreatedBy() !== $this->getUser()) {
|
||||
return $this->json(['error' => 'Job not found or access denied'], 404);
|
||||
}
|
||||
|
||||
// Only allow stopping of pending or in-progress jobs
|
||||
if (!$job->canBeStopped()) {
|
||||
return $this->json(['error' => 'Cannot stop job in current status'], 400);
|
||||
}
|
||||
|
||||
$job->markAsStopped();
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
private function getKeywordFromField(Part $part, string $field): ?string
|
||||
{
|
||||
return match ($field) {
|
||||
'mpn' => $part->getManufacturerProductNumber(),
|
||||
'name' => $part->getName(),
|
||||
default => $this->getSupplierPartNumber($part, $field)
|
||||
};
|
||||
}
|
||||
|
||||
private function getSupplierPartNumber(Part $part, string $field): ?string
|
||||
{
|
||||
// Check if this is a supplier SPN field
|
||||
if (!str_ends_with($field, '_spn')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract supplier key (remove _spn suffix)
|
||||
$supplierKey = substr($field, 0, -4);
|
||||
|
||||
// Get all suppliers to find matching one
|
||||
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
|
||||
|
||||
foreach ($suppliers as $supplier) {
|
||||
$normalizedName = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName()));
|
||||
if ($normalizedName === $supplierKey) {
|
||||
// Find order detail for this supplier
|
||||
$orderDetail = $part->getOrderdetails()->filter(
|
||||
fn($od) => $od->getSupplier()?->getId() === $supplier->getId()
|
||||
)->first();
|
||||
|
||||
return $orderDetail ? $orderDetail->getSupplierpartnr() : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch details for all search results to populate cache
|
||||
*/
|
||||
private function prefetchDetailsForResults(array $searchResults, LoggerInterface $logger): void
|
||||
{
|
||||
$prefetchCount = 0;
|
||||
|
||||
foreach ($searchResults as $partResult) {
|
||||
foreach ($partResult['search_results'] as $result) {
|
||||
$dto = $result['dto'];
|
||||
|
||||
try {
|
||||
// This call will cache the details for later use
|
||||
$this->infoRetriever->getDetails($dto->provider_key, $dto->provider_id);
|
||||
$prefetchCount++;
|
||||
} catch (\Exception $e) {
|
||||
$logger->warning('Failed to prefetch details for provider part', [
|
||||
'provider_key' => $dto->provider_key,
|
||||
'provider_id' => $dto->provider_id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($prefetchCount > 0) {
|
||||
$this->addFlash('success', "Prefetched details for {$prefetchCount} search results");
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/step2/{jobId}', name: 'bulk_info_provider_step2')]
|
||||
public function step2(int $jobId): Response
|
||||
{
|
||||
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
|
||||
if (!$job) {
|
||||
$this->addFlash('error', 'Bulk import job not found');
|
||||
return $this->redirectToRoute('bulk_info_provider_step1');
|
||||
}
|
||||
|
||||
// Check if user owns this job
|
||||
if ($job->getCreatedBy() !== $this->getUser()) {
|
||||
$this->addFlash('error', 'Access denied to this bulk import job');
|
||||
return $this->redirectToRoute('bulk_info_provider_step1');
|
||||
}
|
||||
|
||||
// Get the parts and deserialize search results
|
||||
$parts = $job->getJobParts()->map(fn($jobPart) => $jobPart->getPart())->toArray();
|
||||
$searchResults = $this->deserializeSearchResults($job->getSearchResults(), $parts);
|
||||
|
||||
return $this->render('info_providers/bulk_import/step2.html.twig', [
|
||||
'job' => $job,
|
||||
'parts' => $parts,
|
||||
'search_results' => $searchResults,
|
||||
]);
|
||||
}
|
||||
|
||||
private function serializeSearchResults(array $searchResults): array
|
||||
{
|
||||
$serialized = [];
|
||||
|
||||
foreach ($searchResults as $partResult) {
|
||||
$partData = [
|
||||
'part_id' => $partResult['part']->getId(),
|
||||
'search_results' => [],
|
||||
'errors' => $partResult['errors']
|
||||
];
|
||||
|
||||
foreach ($partResult['search_results'] as $result) {
|
||||
$dto = $result['dto'];
|
||||
$partData['search_results'][] = [
|
||||
'dto' => [
|
||||
'provider_key' => $dto->provider_key,
|
||||
'provider_id' => $dto->provider_id,
|
||||
'name' => $dto->name,
|
||||
'description' => $dto->description,
|
||||
'manufacturer' => $dto->manufacturer,
|
||||
'mpn' => $dto->mpn,
|
||||
'provider_url' => $dto->provider_url,
|
||||
'preview_image_url' => $dto->preview_image_url,
|
||||
'_source_field' => $result['source_field'] ?? null,
|
||||
'_source_keyword' => $result['source_keyword'] ?? null,
|
||||
],
|
||||
'localPart' => $result['localPart'] ? $result['localPart']->getId() : null
|
||||
];
|
||||
}
|
||||
|
||||
$serialized[] = $partData;
|
||||
}
|
||||
|
||||
return $serialized;
|
||||
}
|
||||
|
||||
private function deserializeSearchResults(array $serializedResults, array $parts): array
|
||||
{
|
||||
$partsById = [];
|
||||
foreach ($parts as $part) {
|
||||
$partsById[$part->getId()] = $part;
|
||||
}
|
||||
|
||||
$searchResults = [];
|
||||
|
||||
foreach ($serializedResults as $partData) {
|
||||
$part = $partsById[$partData['part_id']] ?? null;
|
||||
if (!$part) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$partResult = [
|
||||
'part' => $part,
|
||||
'search_results' => [],
|
||||
'errors' => $partData['errors']
|
||||
];
|
||||
|
||||
foreach ($partData['search_results'] as $resultData) {
|
||||
$dtoData = $resultData['dto'];
|
||||
|
||||
$dto = new \App\Services\InfoProviderSystem\DTOs\SearchResultDTO(
|
||||
provider_key: $dtoData['provider_key'],
|
||||
provider_id: $dtoData['provider_id'],
|
||||
name: $dtoData['name'],
|
||||
description: $dtoData['description'],
|
||||
manufacturer: $dtoData['manufacturer'],
|
||||
mpn: $dtoData['mpn'],
|
||||
provider_url: $dtoData['provider_url'],
|
||||
preview_image_url: $dtoData['preview_image_url']
|
||||
);
|
||||
|
||||
$localPart = null;
|
||||
if ($resultData['localPart']) {
|
||||
$localPart = $this->entityManager->getRepository(Part::class)->find($resultData['localPart']);
|
||||
}
|
||||
|
||||
$partResult['search_results'][] = [
|
||||
'dto' => $dto,
|
||||
'localPart' => $localPart,
|
||||
'source_field' => $dtoData['_source_field'] ?? null,
|
||||
'source_keyword' => $dtoData['_source_keyword'] ?? null
|
||||
];
|
||||
}
|
||||
|
||||
$searchResults[] = $partResult;
|
||||
}
|
||||
|
||||
return $searchResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform batch LCSC search using async HTTP requests
|
||||
*/
|
||||
private function searchLcscBatch(array $keywords): array
|
||||
{
|
||||
return $this->LCSCProvider->searchByKeywordsBatch($keywords);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/mark-completed', name: 'bulk_info_provider_mark_completed', methods: ['POST'])]
|
||||
public function markPartCompleted(int $jobId, int $partId): Response
|
||||
{
|
||||
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
|
||||
if (!$job || $job->getCreatedBy() !== $this->getUser()) {
|
||||
return $this->json(['error' => 'Job not found or access denied'], 404);
|
||||
}
|
||||
|
||||
$job->markPartAsCompleted($partId);
|
||||
|
||||
// Auto-complete job if all parts are done
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted()
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/mark-skipped', name: 'bulk_info_provider_mark_skipped', methods: ['POST'])]
|
||||
public function markPartSkipped(int $jobId, int $partId, Request $request): Response
|
||||
{
|
||||
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
|
||||
if (!$job || $job->getCreatedBy() !== $this->getUser()) {
|
||||
return $this->json(['error' => 'Job not found or access denied'], 404);
|
||||
}
|
||||
|
||||
$reason = $request->request->get('reason', '');
|
||||
$job->markPartAsSkipped($partId, $reason);
|
||||
|
||||
// Auto-complete job if all parts are done
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'skipped_count' => $job->getSkippedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted()
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/mark-pending', name: 'bulk_info_provider_mark_pending', methods: ['POST'])]
|
||||
public function markPartPending(int $jobId, int $partId): Response
|
||||
{
|
||||
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
|
||||
if (!$job || $job->getCreatedBy() !== $this->getUser()) {
|
||||
return $this->json(['error' => 'Job not found or access denied'], 404);
|
||||
}
|
||||
|
||||
$job->markPartAsPending($partId);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'skipped_count' => $job->getSkippedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted()
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -65,12 +65,14 @@ use function Symfony\Component\Translation\t;
|
||||
#[Route(path: '/part')]
|
||||
class PartController extends AbstractController
|
||||
{
|
||||
public function __construct(protected PricedetailHelper $pricedetailHelper,
|
||||
public function __construct(
|
||||
protected PricedetailHelper $pricedetailHelper,
|
||||
protected PartPreviewGenerator $partPreviewGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
|
||||
protected EventCommentHelper $commentHelper)
|
||||
{
|
||||
private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
|
||||
private readonly EntityManagerInterface $em,
|
||||
protected EventCommentHelper $commentHelper
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,9 +81,16 @@ class PartController extends AbstractController
|
||||
*/
|
||||
#[Route(path: '/{id}/info/{timestamp}', name: 'part_info')]
|
||||
#[Route(path: '/{id}', requirements: ['id' => '\d+'])]
|
||||
public function show(Part $part, Request $request, TimeTravel $timeTravel, HistoryHelper $historyHelper,
|
||||
DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, PartLotWithdrawAddHelper $withdrawAddHelper, ?string $timestamp = null): Response
|
||||
{
|
||||
public function show(
|
||||
Part $part,
|
||||
Request $request,
|
||||
TimeTravel $timeTravel,
|
||||
HistoryHelper $historyHelper,
|
||||
DataTableFactory $dataTable,
|
||||
ParameterExtractor $parameterExtractor,
|
||||
PartLotWithdrawAddHelper $withdrawAddHelper,
|
||||
?string $timestamp = null
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('read', $part);
|
||||
|
||||
$timeTravel_timestamp = null;
|
||||
@@ -131,7 +140,43 @@ class PartController extends AbstractController
|
||||
{
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
|
||||
return $this->renderPartForm('edit', $request, $part);
|
||||
// Check if this is part of a bulk import job
|
||||
$jobId = $request->query->get('jobId');
|
||||
$bulkJob = null;
|
||||
if ($jobId) {
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
// Verify user owns this job
|
||||
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
$bulkJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderPartForm('edit', $request, $part, [], [
|
||||
'bulk_job' => $bulkJob
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/bulk-import-complete/{jobId}', name: 'part_bulk_import_complete', methods: ['POST'])]
|
||||
public function markBulkImportComplete(Part $part, int $jobId, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
|
||||
if (!$this->isCsrfTokenValid('bulk_complete_' . $part->getId(), $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token');
|
||||
}
|
||||
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
throw $this->createNotFoundException('Bulk import job not found');
|
||||
}
|
||||
|
||||
$bulkJob->markPartAsCompleted($part->getId());
|
||||
$this->em->persist($bulkJob);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', 'Part marked as completed in bulk import');
|
||||
|
||||
return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $jobId]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])]
|
||||
@@ -139,7 +184,7 @@ class PartController extends AbstractController
|
||||
{
|
||||
$this->denyAccessUnlessGranted('delete', $part);
|
||||
|
||||
if ($this->isCsrfTokenValid('delete'.$part->getID(), $request->request->get('_token'))) {
|
||||
if ($this->isCsrfTokenValid('delete' . $part->getID(), $request->request->get('_token'))) {
|
||||
|
||||
$this->commentHelper->setMessage($request->request->get('log_comment', null));
|
||||
|
||||
@@ -158,11 +203,15 @@ class PartController extends AbstractController
|
||||
#[Route(path: '/new', name: 'part_new')]
|
||||
#[Route(path: '/{id}/clone', name: 'part_clone')]
|
||||
#[Route(path: '/new_build_part/{project_id}', name: 'part_new_build_part')]
|
||||
public function new(Request $request, EntityManagerInterface $em, TranslatorInterface $translator,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, ProjectBuildPartHelper $projectBuildPartHelper,
|
||||
public function new(
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
TranslatorInterface $translator,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler,
|
||||
ProjectBuildPartHelper $projectBuildPartHelper,
|
||||
#[MapEntity(mapping: ['id' => 'id'])] ?Part $part = null,
|
||||
#[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null): Response
|
||||
{
|
||||
#[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null
|
||||
): Response {
|
||||
|
||||
if ($part instanceof Part) {
|
||||
//Clone part
|
||||
@@ -257,9 +306,14 @@ class PartController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/from_info_provider/{providerKey}/{providerId}/update', name: 'info_providers_update_part', requirements: ['providerId' => '.+'])]
|
||||
public function updateFromInfoProvider(Part $part, Request $request, string $providerKey, string $providerId,
|
||||
PartInfoRetriever $infoRetriever, PartMerger $partMerger): Response
|
||||
{
|
||||
public function updateFromInfoProvider(
|
||||
Part $part,
|
||||
Request $request,
|
||||
string $providerKey,
|
||||
string $providerId,
|
||||
PartInfoRetriever $infoRetriever,
|
||||
PartMerger $partMerger
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
@@ -273,10 +327,22 @@ class PartController extends AbstractController
|
||||
|
||||
$this->addFlash('notice', t('part.merge.flash.please_review'));
|
||||
|
||||
// Check if this is part of a bulk import job
|
||||
$jobId = $request->query->get('jobId');
|
||||
$bulkJob = null;
|
||||
if ($jobId) {
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
// Verify user owns this job
|
||||
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
$bulkJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderPartForm('update_from_ip', $request, $part, [
|
||||
'info_provider_dto' => $dto,
|
||||
], [
|
||||
'tname_before' => $old_name
|
||||
'tname_before' => $old_name,
|
||||
'bulk_job' => $bulkJob
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -311,7 +377,7 @@ class PartController extends AbstractController
|
||||
} catch (AttachmentDownloadException $attachmentDownloadException) {
|
||||
$this->addFlash(
|
||||
'error',
|
||||
$this->translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage()
|
||||
$this->translator->trans('attachment.download_failed') . ' ' . $attachmentDownloadException->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -352,6 +418,12 @@ class PartController extends AbstractController
|
||||
return $this->redirectToRoute('part_new');
|
||||
}
|
||||
|
||||
// Check if we're in bulk import mode and preserve jobId
|
||||
$jobId = $request->query->get('jobId');
|
||||
if ($jobId && isset($merge_infos['bulk_job'])) {
|
||||
return $this->redirectToRoute('part_edit', ['id' => $new_part->getID(), 'jobId' => $jobId]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]);
|
||||
}
|
||||
|
||||
@@ -370,13 +442,17 @@ class PartController extends AbstractController
|
||||
$template = 'parts/edit/update_from_ip.html.twig';
|
||||
}
|
||||
|
||||
return $this->render($template,
|
||||
return $this->render(
|
||||
$template,
|
||||
[
|
||||
'part' => $new_part,
|
||||
'form' => $form,
|
||||
'merge_old_name' => $merge_infos['tname_before'] ?? null,
|
||||
'merge_other' => $merge_infos['other_part'] ?? null
|
||||
]);
|
||||
'merge_other' => $merge_infos['other_part'] ?? null,
|
||||
'bulk_job' => $merge_infos['bulk_job'] ?? null,
|
||||
'jobId' => $request->query->get('jobId')
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -386,17 +462,17 @@ class PartController extends AbstractController
|
||||
if ($this->isCsrfTokenValid('part_withraw' . $part->getID(), $request->request->get('_csfr'))) {
|
||||
//Retrieve partlot from the request
|
||||
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
|
||||
if(!$partLot instanceof PartLot) {
|
||||
if (!$partLot instanceof PartLot) {
|
||||
throw new \RuntimeException('Part lot not found!');
|
||||
}
|
||||
//Ensure that the partlot belongs to the part
|
||||
if($partLot->getPart() !== $part) {
|
||||
if ($partLot->getPart() !== $part) {
|
||||
throw new \RuntimeException("The origin partlot does not belong to the part!");
|
||||
}
|
||||
|
||||
//Try to determine the target lot (used for move actions), if the parameter is existing
|
||||
$targetId = $request->request->get('target_id', null);
|
||||
$targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null;
|
||||
$targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null;
|
||||
if ($targetLot && $targetLot->getPart() !== $part) {
|
||||
throw new \RuntimeException("The target partlot does not belong to the part!");
|
||||
}
|
||||
@@ -410,12 +486,12 @@ class PartController extends AbstractController
|
||||
$timestamp = null;
|
||||
$timestamp_str = $request->request->getString('timestamp', '');
|
||||
//Try to parse the timestamp
|
||||
if($timestamp_str !== '') {
|
||||
if ($timestamp_str !== '') {
|
||||
$timestamp = new DateTime($timestamp_str);
|
||||
}
|
||||
|
||||
//Ensure that the timestamp is not in the future
|
||||
if($timestamp !== null && $timestamp > new DateTime("+20min")) {
|
||||
if ($timestamp !== null && $timestamp > new DateTime("+20min")) {
|
||||
throw new \LogicException("The timestamp must not be in the future!");
|
||||
}
|
||||
|
||||
@@ -459,7 +535,7 @@ class PartController extends AbstractController
|
||||
|
||||
err:
|
||||
//If a redirect was passed, then redirect there
|
||||
if($request->request->get('_redirect')) {
|
||||
if ($request->request->get('_redirect')) {
|
||||
return $this->redirect($request->request->get('_redirect'));
|
||||
}
|
||||
//Otherwise just redirect to the part page
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class BulkImportJobExistsConstraint extends AbstractConstraint
|
||||
{
|
||||
/** @var bool|null The value of our constraint */
|
||||
protected ?bool $value = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('bulk_import_job_exists');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of this constraint. Null means "don't filter", true means "filter for parts in bulk import jobs", false means "filter for parts not in bulk import jobs".
|
||||
*/
|
||||
public function getValue(): ?bool
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of this constraint. Null means "don't filter", true means "filter for parts in bulk import jobs", false means "filter for parts not in bulk import jobs".
|
||||
*/
|
||||
public function setValue(?bool $value): void
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->value !== null;
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
// Do not apply a filter if value is null (filter is set to ignore)
|
||||
if (!$this->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EXISTS subquery to avoid join conflicts
|
||||
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
|
||||
$existsSubquery->select('1')
|
||||
->from(BulkInfoProviderImportJobPart::class, 'bip_exists')
|
||||
->where('bip_exists.part = part.id');
|
||||
|
||||
if ($this->value === true) {
|
||||
// Filter for parts that ARE in bulk import jobs
|
||||
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
} else {
|
||||
// Filter for parts that are NOT in bulk import jobs
|
||||
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class BulkImportJobStatusConstraint extends AbstractConstraint
|
||||
{
|
||||
/** @var array The status values to filter by */
|
||||
protected array $values = [];
|
||||
|
||||
/** @var string|null The operator to use ('any_of', 'none_of', 'all_of') */
|
||||
protected ?string $operator = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('bulk_import_job_status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status values to filter by.
|
||||
*/
|
||||
public function getValues(): array
|
||||
{
|
||||
return $this->values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the status values to filter by.
|
||||
*/
|
||||
public function setValues(array $values): void
|
||||
{
|
||||
$this->values = $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the operator to use.
|
||||
*/
|
||||
public function getOperator(): ?string
|
||||
{
|
||||
return $this->operator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the operator to use.
|
||||
*/
|
||||
public function setOperator(?string $operator): void
|
||||
{
|
||||
$this->operator = $operator;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return !empty($this->values) && $this->operator !== null;
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
// Do not apply a filter if values are empty or operator is null
|
||||
if (!$this->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EXISTS subquery to check if part has a job with the specified status(es)
|
||||
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
|
||||
$existsSubquery->select('1')
|
||||
->from(BulkInfoProviderImportJobPart::class, 'bip_status')
|
||||
->join('bip_status.job', 'job_status')
|
||||
->where('bip_status.part = part.id');
|
||||
|
||||
// Add status conditions based on operator
|
||||
if ($this->operator === 'ANY') {
|
||||
$existsSubquery->andWhere('job_status.status IN (:job_status_values)');
|
||||
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('job_status_values', $this->values);
|
||||
} elseif ($this->operator === 'NONE') {
|
||||
$existsSubquery->andWhere('job_status.status IN (:job_status_values)');
|
||||
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('job_status_values', $this->values);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class BulkImportPartStatusConstraint extends AbstractConstraint
|
||||
{
|
||||
/** @var array The status values to filter by */
|
||||
protected array $values = [];
|
||||
|
||||
/** @var string|null The operator to use ('any_of', 'none_of', 'all_of') */
|
||||
protected ?string $operator = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('bulk_import_part_status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the status values to filter by.
|
||||
*/
|
||||
public function getValues(): array
|
||||
{
|
||||
return $this->values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the status values to filter by.
|
||||
*/
|
||||
public function setValues(array $values): void
|
||||
{
|
||||
$this->values = $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the operator to use.
|
||||
*/
|
||||
public function getOperator(): ?string
|
||||
{
|
||||
return $this->operator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the operator to use.
|
||||
*/
|
||||
public function setOperator(?string $operator): void
|
||||
{
|
||||
$this->operator = $operator;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return !empty($this->values) && $this->operator !== null;
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
// Do not apply a filter if values are empty or operator is null
|
||||
if (!$this->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EXISTS subquery to check if part has the specified status(es)
|
||||
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
|
||||
$existsSubquery->select('1')
|
||||
->from(BulkInfoProviderImportJobPart::class, 'bip_part_status')
|
||||
->where('bip_part_status.part = part.id');
|
||||
|
||||
// Add status conditions based on operator
|
||||
if ($this->operator === 'ANY') {
|
||||
$existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
|
||||
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('part_status_values', $this->values);
|
||||
} elseif ($this->operator === 'NONE') {
|
||||
$existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
|
||||
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('part_status_values', $this->values);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ use App\DataTables\Filters\Constraints\NumberConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\LessThanDesiredConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportJobExistsConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
|
||||
use App\DataTables\Filters\Constraints\TextConstraint;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\Parts\Category;
|
||||
@@ -42,6 +45,8 @@ use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Entity\BulkInfoProviderImportJob;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
@@ -101,6 +106,14 @@ class PartFilter implements FilterInterface
|
||||
public readonly TextConstraint $bomName;
|
||||
public readonly TextConstraint $bomComment;
|
||||
|
||||
/*************************************************
|
||||
* Bulk Import Job tab
|
||||
*************************************************/
|
||||
|
||||
public readonly BulkImportJobExistsConstraint $inBulkImportJob;
|
||||
public readonly BulkImportJobStatusConstraint $bulkImportJobStatus;
|
||||
public readonly BulkImportPartStatusConstraint $bulkImportPartStatus;
|
||||
|
||||
public function __construct(NodesListBuilder $nodesListBuilder)
|
||||
{
|
||||
$this->name = new TextConstraint('part.name');
|
||||
@@ -126,7 +139,7 @@ class PartFilter implements FilterInterface
|
||||
*/
|
||||
$this->amountSum = (new IntConstraint('(
|
||||
SELECT COALESCE(SUM(__partLot.amount), 0.0)
|
||||
FROM '.PartLot::class.' __partLot
|
||||
FROM ' . PartLot::class . ' __partLot
|
||||
WHERE __partLot.part = part.id
|
||||
AND __partLot.instock_unknown = false
|
||||
AND (__partLot.expiration_date IS NULL OR __partLot.expiration_date > CURRENT_DATE())
|
||||
@@ -162,6 +175,11 @@ class PartFilter implements FilterInterface
|
||||
$this->bomName = new TextConstraint('_projectBomEntries.name');
|
||||
$this->bomComment = new TextConstraint('_projectBomEntries.comment');
|
||||
|
||||
// Bulk Import Job filters
|
||||
$this->inBulkImportJob = new BulkImportJobExistsConstraint();
|
||||
$this->bulkImportJobStatus = new BulkImportJobStatusConstraint();
|
||||
$this->bulkImportPartStatus = new BulkImportPartStatusConstraint();
|
||||
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
|
||||
@@ -43,6 +43,7 @@ use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
@@ -142,23 +143,25 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
'label' => $this->translator->trans('part.table.storeLocations'),
|
||||
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
|
||||
'orderField' => 'NATSORT(MIN(_storelocations.name))',
|
||||
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
|
||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
|
||||
], alias: 'storage_location')
|
||||
|
||||
->add('amount', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.amount'),
|
||||
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
|
||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
|
||||
'orderField' => 'amountSum'
|
||||
])
|
||||
->add('minamount', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.minamount'),
|
||||
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value,
|
||||
$context->getPartUnit())),
|
||||
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format(
|
||||
$value,
|
||||
$context->getPartUnit()
|
||||
)),
|
||||
])
|
||||
->add('partUnit', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.partUnit'),
|
||||
'orderField' => 'NATSORT(_partUnit.name)',
|
||||
'render' => function($value, Part $context): string {
|
||||
'render' => function ($value, Part $context): string {
|
||||
$partUnit = $context->getPartUnit();
|
||||
if ($partUnit === null) {
|
||||
return '';
|
||||
@@ -167,7 +170,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
$tmp = htmlspecialchars($partUnit->getName());
|
||||
|
||||
if ($partUnit->getUnit()) {
|
||||
$tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')';
|
||||
$tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')';
|
||||
}
|
||||
return $tmp;
|
||||
}
|
||||
@@ -230,7 +233,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
}
|
||||
|
||||
if (count($projects) > $max) {
|
||||
$tmp .= ", + ".(count($projects) - $max);
|
||||
$tmp .= ", + " . (count($projects) - $max);
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
@@ -366,7 +369,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
$builder->addSelect(
|
||||
'(
|
||||
SELECT COALESCE(SUM(partLot.amount), 0.0)
|
||||
FROM '.PartLot::class.' partLot
|
||||
FROM ' . PartLot::class . ' partLot
|
||||
WHERE partLot.part = part.id
|
||||
AND partLot.instock_unknown = false
|
||||
AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE())
|
||||
@@ -423,6 +426,13 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
|
||||
//$builder->addGroupBy('_projectBomEntries');
|
||||
}
|
||||
if (str_contains($dql, '_jobPart')) {
|
||||
$builder->leftJoin('part.bulkImportJobParts', '_jobPart');
|
||||
$builder->leftJoin('_jobPart.job', '_bulkImportJob');
|
||||
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
|
||||
//$builder->addGroupBy('_jobPart');
|
||||
//$builder->addGroupBy('_bulkImportJob');
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
||||
406
src/Entity/BulkInfoProviderImportJob.php
Normal file
406
src/Entity/BulkInfoProviderImportJob.php
Normal file
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\UserSystem\User;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
enum BulkImportJobStatus: string
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case IN_PROGRESS = 'in_progress';
|
||||
case COMPLETED = 'completed';
|
||||
case STOPPED = 'stopped';
|
||||
case FAILED = 'failed';
|
||||
}
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'bulk_info_provider_import_jobs')]
|
||||
class BulkInfoProviderImportJob extends AbstractDBElement
|
||||
{
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $fieldMappings = [];
|
||||
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $searchResults = [];
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportJobStatus::class)]
|
||||
private BulkImportJobStatus $status = BulkImportJobStatus::PENDING;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $completedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
private bool $prefetchDetails = false;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $createdBy = null;
|
||||
|
||||
/** @var Collection<int, BulkInfoProviderImportJobPart> */
|
||||
#[ORM\OneToMany(targetEntity: BulkInfoProviderImportJobPart::class, mappedBy: 'job', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $jobParts;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->jobParts = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getDisplayNameKey(): string
|
||||
{
|
||||
return 'info_providers.bulk_import.job_name_template';
|
||||
}
|
||||
|
||||
public function getDisplayNameParams(): array
|
||||
{
|
||||
return ['%count%' => $this->getPartCount()];
|
||||
}
|
||||
|
||||
public function getFormattedTimestamp(): string
|
||||
{
|
||||
return $this->createdAt->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getJobParts(): Collection
|
||||
{
|
||||
return $this->jobParts;
|
||||
}
|
||||
|
||||
public function addJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if (!$this->jobParts->contains($jobPart)) {
|
||||
$this->jobParts->add($jobPart);
|
||||
$jobPart->setJob($this);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if ($this->jobParts->removeElement($jobPart)) {
|
||||
if ($jobPart->getJob() === $this) {
|
||||
$jobPart->setJob(null);
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPartIds(): array
|
||||
{
|
||||
return $this->jobParts->map(fn($jobPart) => $jobPart->getPart()->getId())->toArray();
|
||||
}
|
||||
|
||||
public function setPartIds(array $partIds): self
|
||||
{
|
||||
// This method is kept for backward compatibility but should be replaced with addJobPart
|
||||
// Clear existing job parts
|
||||
$this->jobParts->clear();
|
||||
|
||||
// Add new job parts (this would need the actual Part entities, not just IDs)
|
||||
// This is a simplified implementation - in practice, you'd want to pass Part entities
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addPart(Part $part): self
|
||||
{
|
||||
$jobPart = new BulkInfoProviderImportJobPart($this, $part);
|
||||
$this->addJobPart($jobPart);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFieldMappings(): array
|
||||
{
|
||||
return $this->fieldMappings;
|
||||
}
|
||||
|
||||
public function setFieldMappings(array $fieldMappings): self
|
||||
{
|
||||
$this->fieldMappings = $fieldMappings;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSearchResults(): array
|
||||
{
|
||||
return $this->searchResults;
|
||||
}
|
||||
|
||||
public function setSearchResults(array $searchResults): self
|
||||
{
|
||||
$this->searchResults = $searchResults;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): BulkImportJobStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(BulkImportJobStatus $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getCompletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->completedAt;
|
||||
}
|
||||
|
||||
public function setCompletedAt(?\DateTimeImmutable $completedAt): self
|
||||
{
|
||||
$this->completedAt = $completedAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPrefetchDetails(): bool
|
||||
{
|
||||
return $this->prefetchDetails;
|
||||
}
|
||||
|
||||
public function setPrefetchDetails(bool $prefetchDetails): self
|
||||
{
|
||||
$this->prefetchDetails = $prefetchDetails;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedBy(): User
|
||||
{
|
||||
return $this->createdBy;
|
||||
}
|
||||
|
||||
public function setCreatedBy(User $createdBy): self
|
||||
{
|
||||
$this->createdBy = $createdBy;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProgress(): array
|
||||
{
|
||||
$progress = [];
|
||||
foreach ($this->jobParts as $jobPart) {
|
||||
$progressData = [
|
||||
'status' => $jobPart->getStatus()->value
|
||||
];
|
||||
|
||||
// Only include completed_at if it's not null
|
||||
if ($jobPart->getCompletedAt() !== null) {
|
||||
$progressData['completed_at'] = $jobPart->getCompletedAt()->format('c');
|
||||
}
|
||||
|
||||
// Only include reason if it's not null
|
||||
if ($jobPart->getReason() !== null) {
|
||||
$progressData['reason'] = $jobPart->getReason();
|
||||
}
|
||||
|
||||
$progress[$jobPart->getPart()->getId()] = $progressData;
|
||||
}
|
||||
return $progress;
|
||||
}
|
||||
|
||||
public function setProgress(array $progress): self
|
||||
{
|
||||
// This method is kept for backward compatibility
|
||||
// The progress is now managed through the jobParts relationship
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsCompleted(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::COMPLETED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsFailed(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::FAILED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsStopped(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::STOPPED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsInProgress(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::IN_PROGRESS;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::PENDING;
|
||||
}
|
||||
|
||||
public function isInProgress(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::IN_PROGRESS;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::COMPLETED;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::FAILED;
|
||||
}
|
||||
|
||||
public function isStopped(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::STOPPED;
|
||||
}
|
||||
|
||||
public function canBeStopped(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::PENDING || $this->status === BulkImportJobStatus::IN_PROGRESS;
|
||||
}
|
||||
|
||||
public function getPartCount(): int
|
||||
{
|
||||
return $this->jobParts->count();
|
||||
}
|
||||
|
||||
public function getResultCount(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->searchResults as $partResult) {
|
||||
$count += count($partResult['search_results'] ?? []);
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function markPartAsCompleted(int $partId): self
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
if ($jobPart) {
|
||||
$jobPart->markAsCompleted();
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markPartAsSkipped(int $partId, string $reason = ''): self
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
if ($jobPart) {
|
||||
$jobPart->markAsSkipped($reason);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markPartAsPending(int $partId): self
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
if ($jobPart) {
|
||||
$jobPart->markAsPending();
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPartCompleted(int $partId): bool
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
return $jobPart ? $jobPart->isCompleted() : false;
|
||||
}
|
||||
|
||||
public function isPartSkipped(int $partId): bool
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
return $jobPart ? $jobPart->isSkipped() : false;
|
||||
}
|
||||
|
||||
public function getCompletedPartsCount(): int
|
||||
{
|
||||
return $this->jobParts->filter(fn($jobPart) => $jobPart->isCompleted())->count();
|
||||
}
|
||||
|
||||
public function getSkippedPartsCount(): int
|
||||
{
|
||||
return $this->jobParts->filter(fn($jobPart) => $jobPart->isSkipped())->count();
|
||||
}
|
||||
|
||||
private function findJobPartByPartId(int $partId): ?BulkInfoProviderImportJobPart
|
||||
{
|
||||
foreach ($this->jobParts as $jobPart) {
|
||||
if ($jobPart->getPart()->getId() === $partId) {
|
||||
return $jobPart;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getProgressPercentage(): float
|
||||
{
|
||||
$total = $this->getPartCount();
|
||||
if ($total === 0) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
$completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount();
|
||||
return round(($completed / $total) * 100, 1);
|
||||
}
|
||||
|
||||
public function isAllPartsCompleted(): bool
|
||||
{
|
||||
$total = $this->getPartCount();
|
||||
if ($total === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount();
|
||||
return $completed >= $total;
|
||||
}
|
||||
}
|
||||
172
src/Entity/BulkInfoProviderImportJobPart.php
Normal file
172
src/Entity/BulkInfoProviderImportJobPart.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?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\Entity;
|
||||
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
enum BulkImportPartStatus: string
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case COMPLETED = 'completed';
|
||||
case SKIPPED = 'skipped';
|
||||
case FAILED = 'failed';
|
||||
}
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'bulk_info_provider_import_job_parts')]
|
||||
#[ORM\UniqueConstraint(name: 'unique_job_part', columns: ['job_id', 'part_id'])]
|
||||
class BulkInfoProviderImportJobPart extends AbstractDBElement
|
||||
{
|
||||
#[ORM\ManyToOne(targetEntity: BulkInfoProviderImportJob::class, inversedBy: 'jobParts')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private BulkInfoProviderImportJob $job;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'bulkImportJobParts')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private Part $part;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportPartStatus::class)]
|
||||
private BulkImportPartStatus $status = BulkImportPartStatus::PENDING;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $completedAt = null;
|
||||
|
||||
public function __construct(BulkInfoProviderImportJob $job, Part $part)
|
||||
{
|
||||
$this->job = $job;
|
||||
$this->part = $part;
|
||||
}
|
||||
|
||||
public function getJob(): BulkInfoProviderImportJob
|
||||
{
|
||||
return $this->job;
|
||||
}
|
||||
|
||||
public function setJob(?BulkInfoProviderImportJob $job): self
|
||||
{
|
||||
$this->job = $job;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPart(): Part
|
||||
{
|
||||
return $this->part;
|
||||
}
|
||||
|
||||
public function setPart(?Part $part): self
|
||||
{
|
||||
$this->part = $part;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): BulkImportPartStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(BulkImportPartStatus $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function setReason(?string $reason): self
|
||||
{
|
||||
$this->reason = $reason;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCompletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->completedAt;
|
||||
}
|
||||
|
||||
public function setCompletedAt(?\DateTimeImmutable $completedAt): self
|
||||
{
|
||||
$this->completedAt = $completedAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsCompleted(): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::COMPLETED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsSkipped(string $reason = ''): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::SKIPPED;
|
||||
$this->reason = $reason;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsFailed(string $reason = ''): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::FAILED;
|
||||
$this->reason = $reason;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsPending(): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::PENDING;
|
||||
$this->reason = null;
|
||||
$this->completedAt = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::PENDING;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::COMPLETED;
|
||||
}
|
||||
|
||||
public function isSkipped(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::SKIPPED;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::FAILED;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ namespace App\Entity\LogSystem;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\BulkInfoProviderImportJob;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\LabelSystem\LabelProfile;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Entity\Parts\Category;
|
||||
@@ -67,6 +69,8 @@ enum LogTargetType: int
|
||||
case LABEL_PROFILE = 19;
|
||||
|
||||
case PART_ASSOCIATION = 20;
|
||||
case BULK_INFO_PROVIDER_IMPORT_JOB = 21;
|
||||
case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22;
|
||||
|
||||
/**
|
||||
* Returns the class name of the target type or null if the target type is NONE.
|
||||
@@ -96,6 +100,8 @@ enum LogTargetType: int
|
||||
self::PARAMETER => AbstractParameter::class,
|
||||
self::LABEL_PROFILE => LabelProfile::class,
|
||||
self::PART_ASSOCIATION => PartAssociation::class,
|
||||
self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class,
|
||||
self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => BulkInfoProviderImportJobPart::class,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ use App\Entity\Parts\PartTraits\ManufacturerTrait;
|
||||
use App\Entity\Parts\PartTraits\OrderTrait;
|
||||
use App\Entity\Parts\PartTraits\ProjectTrait;
|
||||
use App\EntityListeners\TreeCacheInvalidationListener;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use App\Repository\PartRepository;
|
||||
use App\Validator\Constraints\UniqueObjectCollection;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -83,8 +84,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read',
|
||||
'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'],
|
||||
new Get(normalizationContext: [
|
||||
'groups' => [
|
||||
'part:read',
|
||||
'provider_reference:read',
|
||||
'api:basic:read',
|
||||
'part_lot:read',
|
||||
'orderdetail:read',
|
||||
'pricedetail:read',
|
||||
'parameter:read',
|
||||
'attachment:read',
|
||||
'eda_info:read'
|
||||
],
|
||||
'openapi_definition_name' => 'Read',
|
||||
], security: 'is_granted("read", object)'),
|
||||
new GetCollection(security: 'is_granted("@parts.read")'),
|
||||
@@ -92,7 +103,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
new Patch(security: 'is_granted("edit", object)'),
|
||||
new Delete(security: 'is_granted("delete", object)'),
|
||||
],
|
||||
normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
|
||||
normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
@@ -100,7 +111,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])]
|
||||
#[ApiFilter(TagFilter::class, properties: ["tags"])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ["favorite", "needs_review"])]
|
||||
#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
|
||||
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
|
||||
@@ -160,6 +171,12 @@ class Part extends AttachmentContainingDBElement
|
||||
#[Groups(['part:read'])]
|
||||
protected ?\DateTimeImmutable $lastModified = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, BulkInfoProviderImportJobPart>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)]
|
||||
protected Collection $bulkImportJobParts;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -172,6 +189,7 @@ class Part extends AttachmentContainingDBElement
|
||||
|
||||
$this->associated_parts_as_owner = new ArrayCollection();
|
||||
$this->associated_parts_as_other = new ArrayCollection();
|
||||
$this->bulkImportJobParts = new ArrayCollection();
|
||||
|
||||
//By default, the part has no provider
|
||||
$this->providerReference = InfoProviderReference::noProvider();
|
||||
@@ -230,4 +248,38 @@ class Part extends AttachmentContainingDBElement
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bulk import job parts for this part
|
||||
* @return Collection<int, BulkInfoProviderImportJobPart>
|
||||
*/
|
||||
public function getBulkImportJobParts(): Collection
|
||||
{
|
||||
return $this->bulkImportJobParts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a bulk import job part to this part
|
||||
*/
|
||||
public function addBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if (!$this->bulkImportJobParts->contains($jobPart)) {
|
||||
$this->bulkImportJobParts->add($jobPart);
|
||||
$jobPart->setPart($this);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a bulk import job part from this part
|
||||
*/
|
||||
public function removeBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if ($this->bulkImportJobParts->removeElement($jobPart)) {
|
||||
if ($jobPart->getPart() === $this) {
|
||||
$jobPart->setPart(null);
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ class ImportType extends AbstractType
|
||||
'XML' => 'xml',
|
||||
'CSV' => 'csv',
|
||||
'YAML' => 'yaml',
|
||||
'XLSX' => 'xlsx',
|
||||
'XLS' => 'xls',
|
||||
],
|
||||
'label' => 'export.format',
|
||||
'disabled' => $disabled,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?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\Form\Filters\Constraints;
|
||||
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportJobExistsConstraint;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class BulkImportJobExistsConstraintType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'compound' => true,
|
||||
'data_class' => BulkImportJobExistsConstraint::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$choices = [
|
||||
'' => '',
|
||||
'part.filter.in_bulk_import_job.yes' => true,
|
||||
'part.filter.in_bulk_import_job.no' => false,
|
||||
];
|
||||
|
||||
$builder->add('value', ChoiceType::class, [
|
||||
'label' => 'part.filter.in_bulk_import_job',
|
||||
'choices' => $choices,
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
parent::buildView($view, $form, $options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?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\Form\Filters\Constraints;
|
||||
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class BulkImportJobStatusConstraintType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'compound' => true,
|
||||
'data_class' => BulkImportJobStatusConstraint::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$statusChoices = [
|
||||
'bulk_import.status.pending' => 'pending',
|
||||
'bulk_import.status.in_progress' => 'in_progress',
|
||||
'bulk_import.status.completed' => 'completed',
|
||||
'bulk_import.status.stopped' => 'stopped',
|
||||
'bulk_import.status.failed' => 'failed',
|
||||
];
|
||||
|
||||
$operatorChoices = [
|
||||
'filter.choice_constraint.operator.ANY' => 'ANY',
|
||||
'filter.choice_constraint.operator.NONE' => 'NONE',
|
||||
];
|
||||
|
||||
$builder->add('operator', ChoiceType::class, [
|
||||
'label' => 'filter.operator',
|
||||
'choices' => $operatorChoices,
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add('values', ChoiceType::class, [
|
||||
'label' => 'part.filter.bulk_import_job_status',
|
||||
'choices' => $statusChoices,
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
'attr' => [
|
||||
'data-controller' => 'elements--select-multiple',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
parent::buildView($view, $form, $options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?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\Form\Filters\Constraints;
|
||||
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class BulkImportPartStatusConstraintType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'compound' => true,
|
||||
'data_class' => BulkImportPartStatusConstraint::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$statusChoices = [
|
||||
'bulk_import.part_status.pending' => 'pending',
|
||||
'bulk_import.part_status.completed' => 'completed',
|
||||
'bulk_import.part_status.skipped' => 'skipped',
|
||||
'bulk_import.part_status.failed' => 'failed',
|
||||
];
|
||||
|
||||
$operatorChoices = [
|
||||
'filter.choice_constraint.operator.ANY' => 'ANY',
|
||||
'filter.choice_constraint.operator.NONE' => 'NONE',
|
||||
];
|
||||
|
||||
$builder->add('operator', ChoiceType::class, [
|
||||
'label' => 'filter.operator',
|
||||
'choices' => $operatorChoices,
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add('values', ChoiceType::class, [
|
||||
'label' => 'part.filter.bulk_import_part_status',
|
||||
'choices' => $statusChoices,
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
'attr' => [
|
||||
'data-controller' => 'elements--select-multiple',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
parent::buildView($view, $form, $options);
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ class LogFilterType extends AbstractType
|
||||
]);
|
||||
|
||||
$builder->add('user', UserEntityConstraintType::class, [
|
||||
'label' => 'log.user',
|
||||
'label' => 'log.user',
|
||||
]);
|
||||
|
||||
$builder->add('targetType', EnumConstraintType::class, [
|
||||
@@ -128,11 +128,13 @@ class LogFilterType extends AbstractType
|
||||
LogTargetType::PARAMETER => 'parameter.label',
|
||||
LogTargetType::LABEL_PROFILE => 'label_profile.label',
|
||||
LogTargetType::PART_ASSOCIATION => 'part_association.label',
|
||||
LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label',
|
||||
LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label',
|
||||
},
|
||||
]);
|
||||
|
||||
$builder->add('targetId', NumberConstraintType::class, [
|
||||
'label' => 'log.target_id',
|
||||
'label' => 'log.target_id',
|
||||
'min' => 1,
|
||||
'step' => 1,
|
||||
]);
|
||||
|
||||
@@ -32,7 +32,11 @@ use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\BulkInfoProviderImportJob;
|
||||
use App\Form\Filters\Constraints\BooleanConstraintType;
|
||||
use App\Form\Filters\Constraints\BulkImportJobExistsConstraintType;
|
||||
use App\Form\Filters\Constraints\BulkImportJobStatusConstraintType;
|
||||
use App\Form\Filters\Constraints\BulkImportPartStatusConstraintType;
|
||||
use App\Form\Filters\Constraints\ChoiceConstraintType;
|
||||
use App\Form\Filters\Constraints\DateTimeConstraintType;
|
||||
use App\Form\Filters\Constraints\NumberConstraintType;
|
||||
@@ -298,6 +302,23 @@ class PartFilterType extends AbstractType
|
||||
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* Bulk Import Job tab
|
||||
**************************************************************************/
|
||||
if ($this->security->isGranted('@info_providers.create_parts')) {
|
||||
$builder
|
||||
->add('inBulkImportJob', BulkImportJobExistsConstraintType::class, [
|
||||
'label' => 'part.filter.in_bulk_import_job',
|
||||
])
|
||||
->add('bulkImportJobStatus', BulkImportJobStatusConstraintType::class, [
|
||||
'label' => 'part.filter.bulk_import_job_status',
|
||||
])
|
||||
->add('bulkImportPartStatus', BulkImportPartStatusConstraintType::class, [
|
||||
'label' => 'part.filter.bulk_import_part_status',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'filter.submit',
|
||||
|
||||
62
src/Form/InfoProviderSystem/BulkProviderSearchType.php
Normal file
62
src/Form/InfoProviderSystem/BulkProviderSearchType.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\InfoProviderSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class BulkProviderSearchType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$parts = $options['parts'];
|
||||
|
||||
$builder->add('part_configurations', CollectionType::class, [
|
||||
'entry_type' => PartProviderConfigurationType::class,
|
||||
'entry_options' => [
|
||||
'label' => false,
|
||||
],
|
||||
'allow_add' => false,
|
||||
'allow_delete' => false,
|
||||
'label' => false,
|
||||
]);
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'info_providers.bulk_search.submit'
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'parts' => [],
|
||||
]);
|
||||
$resolver->setRequired('parts');
|
||||
}
|
||||
}
|
||||
72
src/Form/InfoProviderSystem/FieldToProviderMappingType.php
Normal file
72
src/Form/InfoProviderSystem/FieldToProviderMappingType.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\InfoProviderSystem;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class FieldToProviderMappingType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$fieldChoices = $options['field_choices'] ?? [];
|
||||
|
||||
$builder->add('field', ChoiceType::class, [
|
||||
'label' => 'info_providers.bulk_search.search_field',
|
||||
'choices' => $fieldChoices,
|
||||
'expanded' => false,
|
||||
'multiple' => false,
|
||||
'required' => false,
|
||||
'placeholder' => 'info_providers.bulk_search.field.select',
|
||||
]);
|
||||
|
||||
$builder->add('providers', ProviderSelectType::class, [
|
||||
'label' => 'info_providers.bulk_search.providers',
|
||||
'help' => 'info_providers.bulk_search.providers.help',
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add('priority', IntegerType::class, [
|
||||
'label' => 'info_providers.bulk_search.priority',
|
||||
'help' => 'info_providers.bulk_search.priority.help',
|
||||
'required' => false,
|
||||
'data' => 1, // Default priority
|
||||
'attr' => [
|
||||
'min' => 1,
|
||||
'max' => 10,
|
||||
'class' => 'form-control-sm',
|
||||
'style' => 'width: 80px;'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'field_choices' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
67
src/Form/InfoProviderSystem/GlobalFieldMappingType.php
Normal file
67
src/Form/InfoProviderSystem/GlobalFieldMappingType.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\InfoProviderSystem;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class GlobalFieldMappingType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$fieldChoices = $options['field_choices'] ?? [];
|
||||
|
||||
$builder->add('field_mappings', CollectionType::class, [
|
||||
'entry_type' => FieldToProviderMappingType::class,
|
||||
'entry_options' => [
|
||||
'label' => false,
|
||||
'field_choices' => $fieldChoices,
|
||||
],
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'prototype' => true,
|
||||
'label' => false,
|
||||
]);
|
||||
|
||||
$builder->add('prefetch_details', CheckboxType::class, [
|
||||
'label' => 'info_providers.bulk_import.prefetch_details',
|
||||
'required' => false,
|
||||
'help' => 'info_providers.bulk_import.prefetch_details_help',
|
||||
]);
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'info_providers.bulk_search.submit'
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'field_choices' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\InfoProviderSystem;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
class PartProviderConfigurationType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('part_id', HiddenType::class);
|
||||
|
||||
$builder->add('search_field', ChoiceType::class, [
|
||||
'label' => 'info_providers.bulk_search.search_field',
|
||||
'choices' => [
|
||||
'info_providers.bulk_search.field.mpn' => 'mpn',
|
||||
'info_providers.bulk_search.field.name' => 'name',
|
||||
'info_providers.bulk_search.field.digikey_spn' => 'digikey_spn',
|
||||
'info_providers.bulk_search.field.mouser_spn' => 'mouser_spn',
|
||||
'info_providers.bulk_search.field.lcsc_spn' => 'lcsc_spn',
|
||||
'info_providers.bulk_search.field.farnell_spn' => 'farnell_spn',
|
||||
],
|
||||
'expanded' => false,
|
||||
'multiple' => false,
|
||||
]);
|
||||
|
||||
$builder->add('providers', ProviderSelectType::class, [
|
||||
'label' => 'info_providers.bulk_search.providers',
|
||||
'help' => 'info_providers.bulk_search.providers.help',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\BulkInfoProviderImportJob;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\Contracts\NamedElementInterface;
|
||||
use App\Entity\Parts\PartAssociation;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
@@ -79,6 +81,8 @@ class ElementTypeNameGenerator
|
||||
AbstractParameter::class => $this->translator->trans('parameter.label'),
|
||||
LabelProfile::class => $this->translator->trans('label_profile.label'),
|
||||
PartAssociation::class => $this->translator->trans('part_association.label'),
|
||||
BulkInfoProviderImportJob::class => $this->translator->trans('bulk_info_provider_import_job.label'),
|
||||
BulkInfoProviderImportJobPart::class => $this->translator->trans('bulk_info_provider_import_job_part.label'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -130,10 +134,10 @@ class ElementTypeNameGenerator
|
||||
{
|
||||
$type = $this->getLocalizedTypeLabel($entity);
|
||||
if ($use_html) {
|
||||
return '<i>'.$type.':</i> '.htmlspecialchars($entity->getName());
|
||||
return '<i>' . $type . ':</i> ' . htmlspecialchars($entity->getName());
|
||||
}
|
||||
|
||||
return $type.': '.$entity->getName();
|
||||
return $type . ': ' . $entity->getName();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -100,7 +100,8 @@ class PartMerger implements EntityMergerInterface
|
||||
return $target;
|
||||
}
|
||||
|
||||
private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool {
|
||||
private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool
|
||||
{
|
||||
//We compare the translation keys, as it contains info about the type and other type info
|
||||
return $t->getOther() === $o->getOther()
|
||||
&& $t->getTypeTranslationKey() === $o->getTypeTranslationKey();
|
||||
@@ -141,40 +142,39 @@ class PartMerger implements EntityMergerInterface
|
||||
$owner->addAssociatedPartsAsOwner($clone);
|
||||
}
|
||||
|
||||
// Merge orderdetails, considering same supplier+part number as duplicates
|
||||
$this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) {
|
||||
//First check that the orderdetails infos are equal
|
||||
$tmp = $t->getSupplier() === $o->getSupplier()
|
||||
&& $t->getSupplierPartNr() === $o->getSupplierPartNr()
|
||||
&& $t->getSupplierProductUrl(false) === $o->getSupplierProductUrl(false);
|
||||
|
||||
if (!$tmp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//Check if the pricedetails are equal
|
||||
$t_pricedetails = $t->getPricedetails();
|
||||
$o_pricedetails = $o->getPricedetails();
|
||||
//Ensure that both pricedetails have the same length
|
||||
if (count($t_pricedetails) !== count($o_pricedetails)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//Check if all pricedetails are equal
|
||||
for ($n=0, $nMax = count($t_pricedetails); $n< $nMax; $n++) {
|
||||
$t_price = $t_pricedetails->get($n);
|
||||
$o_price = $o_pricedetails->get($n);
|
||||
|
||||
if (!$t_price->getPrice()->isEqualTo($o_price->getPrice())
|
||||
|| $t_price->getCurrency() !== $o_price->getCurrency()
|
||||
|| $t_price->getPriceRelatedQuantity() !== $o_price->getPriceRelatedQuantity()
|
||||
|| $t_price->getMinDiscountQuantity() !== $o_price->getMinDiscountQuantity()
|
||||
) {
|
||||
return false;
|
||||
// If supplier and part number match, merge the orderdetails
|
||||
if ($t->getSupplier() === $o->getSupplier() && $t->getSupplierPartNr() === $o->getSupplierPartNr()) {
|
||||
// Update URL if target doesn't have one
|
||||
if (empty($t->getSupplierProductUrl(false)) && !empty($o->getSupplierProductUrl(false))) {
|
||||
$t->setSupplierProductUrl($o->getSupplierProductUrl(false));
|
||||
}
|
||||
// Merge price details: add new ones, update empty ones, keep existing non-empty ones
|
||||
foreach ($o->getPricedetails() as $otherPrice) {
|
||||
$found = false;
|
||||
foreach ($t->getPricedetails() as $targetPrice) {
|
||||
if ($targetPrice->getMinDiscountQuantity() === $otherPrice->getMinDiscountQuantity()
|
||||
&& $targetPrice->getCurrency() === $otherPrice->getCurrency()) {
|
||||
// Only update price if the existing one is zero/empty (most logical)
|
||||
if ($targetPrice->getPrice()->isZero()) {
|
||||
$targetPrice->setPrice($otherPrice->getPrice());
|
||||
$targetPrice->setPriceRelatedQuantity($otherPrice->getPriceRelatedQuantity());
|
||||
}
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Add completely new price tiers
|
||||
if (!$found) {
|
||||
$clonedPrice = clone $otherPrice;
|
||||
$clonedPrice->setOrderdetail($t);
|
||||
$t->addPricedetail($clonedPrice);
|
||||
}
|
||||
}
|
||||
return true; // Consider them equal so the other one gets skipped
|
||||
}
|
||||
|
||||
//If all pricedetails are equal, the orderdetails are equal
|
||||
return true;
|
||||
return false; // Different supplier/part number, add as new
|
||||
});
|
||||
//The pricedetails are not correctly assigned to the new orderdetails, so fix that
|
||||
foreach ($target->getOrderdetails() as $orderdetail) {
|
||||
|
||||
@@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use function Symfony\Component\String\u;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xls;
|
||||
|
||||
/**
|
||||
* Use this class to export an entity to multiple file formats.
|
||||
@@ -52,7 +55,7 @@ class EntityExporter
|
||||
protected function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefault('format', 'csv');
|
||||
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
|
||||
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
|
||||
|
||||
$resolver->setDefault('csv_delimiter', ';');
|
||||
$resolver->setAllowedTypes('csv_delimiter', 'string');
|
||||
@@ -88,28 +91,35 @@ class EntityExporter
|
||||
|
||||
$options = $resolver->resolve($options);
|
||||
|
||||
//Handle Excel formats by converting from CSV
|
||||
if (in_array($options['format'], ['xlsx', 'xls'], true)) {
|
||||
return $this->exportToExcel($entities, $options);
|
||||
}
|
||||
|
||||
//If include children is set, then we need to add the include_children group
|
||||
$groups = [$options['level']];
|
||||
if ($options['include_children']) {
|
||||
$groups[] = 'include_children';
|
||||
}
|
||||
|
||||
return $this->serializer->serialize($entities, $options['format'],
|
||||
return $this->serializer->serialize(
|
||||
$entities,
|
||||
$options['format'],
|
||||
[
|
||||
'groups' => $groups,
|
||||
'as_collection' => true,
|
||||
'csv_delimiter' => $options['csv_delimiter'],
|
||||
'xml_root_node_name' => 'PartDBExport',
|
||||
'partdb_export' => true,
|
||||
//Skip the item normalizer, so that we dont get IRIs in the output
|
||||
//Skip the item normalizer, so that we dont get IRIs in the output
|
||||
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
|
||||
//Handle circular references
|
||||
//Handle circular references
|
||||
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function handleCircularReference(object $object, string $format, array $context): string
|
||||
private function handleCircularReference(object $object): string
|
||||
{
|
||||
if ($object instanceof AbstractStructuralDBElement) {
|
||||
return $object->getFullPath("->");
|
||||
@@ -119,7 +129,75 @@ class EntityExporter
|
||||
return $object->__toString();
|
||||
}
|
||||
|
||||
throw new CircularReferenceException('Circular reference detected for object of type '.get_class($object));
|
||||
throw new CircularReferenceException('Circular reference detected for object of type ' . get_class($object));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports entities to Excel format (xlsx or xls).
|
||||
*
|
||||
* @param AbstractNamedDBElement[] $entities The entities to export
|
||||
* @param array $options The export options
|
||||
*
|
||||
* @return string The Excel file content as binary string
|
||||
*/
|
||||
protected function exportToExcel(array $entities, array $options): string
|
||||
{
|
||||
//First get CSV data using existing serializer
|
||||
$groups = [$options['level']];
|
||||
if ($options['include_children']) {
|
||||
$groups[] = 'include_children';
|
||||
}
|
||||
|
||||
$csvData = $this->serializer->serialize(
|
||||
$entities,
|
||||
'csv',
|
||||
[
|
||||
'groups' => $groups,
|
||||
'as_collection' => true,
|
||||
'csv_delimiter' => $options['csv_delimiter'],
|
||||
'partdb_export' => true,
|
||||
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
|
||||
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...),
|
||||
]
|
||||
);
|
||||
|
||||
//Convert CSV to Excel
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$rows = explode("\n", $csvData);
|
||||
$rowIndex = 1;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (trim($row) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$columns = str_getcsv($row, $options['csv_delimiter'], '"', '\\');
|
||||
$colIndex = 1;
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
|
||||
$worksheet->setCellValue($cellCoordinate, $column);
|
||||
$colIndex++;
|
||||
}
|
||||
$rowIndex++;
|
||||
}
|
||||
|
||||
//Save to memory stream
|
||||
$writer = $options['format'] === 'xlsx' ? new Xlsx($spreadsheet) : new Xls($spreadsheet);
|
||||
|
||||
$memFile = fopen("php://temp", 'r+b');
|
||||
$writer->save($memFile);
|
||||
rewind($memFile);
|
||||
$content = stream_get_contents($memFile);
|
||||
fclose($memFile);
|
||||
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException('Failed to read Excel content from memory stream.');
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,19 +234,15 @@ class EntityExporter
|
||||
|
||||
//Determine the content type for the response
|
||||
|
||||
//Plain text should work for all types
|
||||
$content_type = 'text/plain';
|
||||
|
||||
//Try to use better content types based on the format
|
||||
$format = $options['format'];
|
||||
switch ($format) {
|
||||
case 'xml':
|
||||
$content_type = 'application/xml';
|
||||
break;
|
||||
case 'json':
|
||||
$content_type = 'application/json';
|
||||
break;
|
||||
}
|
||||
$content_type = match ($format) {
|
||||
'xml' => 'application/xml',
|
||||
'json' => 'application/json',
|
||||
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'xls' => 'application/vnd.ms-excel',
|
||||
default => 'text/plain',
|
||||
};
|
||||
$response->headers->set('Content-Type', $content_type);
|
||||
|
||||
//If view option is not specified, then download the file.
|
||||
@@ -186,7 +260,7 @@ class EntityExporter
|
||||
|
||||
$level = $options['level'];
|
||||
|
||||
$filename = 'export_'.$entity_name.'_'.$level.'.'.$format;
|
||||
$filename = "export_{$entity_name}_{$level}.{$format}";
|
||||
|
||||
//Sanitize the filename
|
||||
$filename = FilenameSanatizer::sanitizeFilename($filename);
|
||||
|
||||
@@ -38,6 +38,9 @@ use Symfony\Component\HttpFoundation\File\File;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\ImportExportSystem\EntityImporterTest
|
||||
@@ -50,7 +53,7 @@ class EntityImporter
|
||||
*/
|
||||
private const ENCODINGS = ["ASCII", "UTF-8", "ISO-8859-1", "ISO-8859-15", "Windows-1252", "UTF-16", "UTF-32"];
|
||||
|
||||
public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator)
|
||||
public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator, protected LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -102,7 +105,7 @@ class EntityImporter
|
||||
|
||||
foreach ($names as $name) {
|
||||
//Count indentation level (whitespace characters at the beginning of the line)
|
||||
$identSize = strlen($name)-strlen(ltrim($name));
|
||||
$identSize = strlen($name) - strlen(ltrim($name));
|
||||
|
||||
//If the line is intended more than the last line, we have a new parent element
|
||||
if ($identSize > end($indentations)) {
|
||||
@@ -195,16 +198,20 @@ class EntityImporter
|
||||
}
|
||||
|
||||
//The [] behind class_name denotes that we expect an array.
|
||||
$entities = $this->serializer->deserialize($data, $options['class'].'[]', $options['format'],
|
||||
$entities = $this->serializer->deserialize(
|
||||
$data,
|
||||
$options['class'] . '[]',
|
||||
$options['format'],
|
||||
[
|
||||
'groups' => $groups,
|
||||
'csv_delimiter' => $options['csv_delimiter'],
|
||||
'create_unknown_datastructures' => $options['create_unknown_datastructures'],
|
||||
'path_delimiter' => $options['path_delimiter'],
|
||||
'partdb_import' => true,
|
||||
//Disable API Platform normalizer, as we don't want to use it here
|
||||
//Disable API Platform normalizer, as we don't want to use it here
|
||||
SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
|
||||
]);
|
||||
]
|
||||
);
|
||||
|
||||
//Ensure we have an array of entity elements.
|
||||
if (!is_array($entities)) {
|
||||
@@ -279,7 +286,7 @@ class EntityImporter
|
||||
'path_delimiter' => '->', //The delimiter used to separate the path elements in the name of a structural element
|
||||
]);
|
||||
|
||||
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
|
||||
$resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml', 'xlsx', 'xls']);
|
||||
$resolver->setAllowedTypes('csv_delimiter', 'string');
|
||||
$resolver->setAllowedTypes('preserve_children', 'bool');
|
||||
$resolver->setAllowedTypes('class', 'string');
|
||||
@@ -335,6 +342,33 @@ class EntityImporter
|
||||
*/
|
||||
public function importFile(File $file, array $options = [], array &$errors = []): array
|
||||
{
|
||||
$resolver = new OptionsResolver();
|
||||
$this->configureOptions($resolver);
|
||||
$options = $resolver->resolve($options);
|
||||
|
||||
if (in_array($options['format'], ['xlsx', 'xls'], true)) {
|
||||
$this->logger->info('Converting Excel file to CSV', [
|
||||
'filename' => $file->getFilename(),
|
||||
'format' => $options['format'],
|
||||
'delimiter' => $options['csv_delimiter']
|
||||
]);
|
||||
|
||||
$csvData = $this->convertExcelToCsv($file, $options['csv_delimiter']);
|
||||
$options['format'] = 'csv';
|
||||
|
||||
$this->logger->debug('Excel to CSV conversion completed', [
|
||||
'csv_length' => strlen($csvData),
|
||||
'csv_lines' => substr_count($csvData, "\n") + 1
|
||||
]);
|
||||
|
||||
// Log the converted CSV for debugging (first 1000 characters)
|
||||
$this->logger->debug('Converted CSV preview', [
|
||||
'csv_preview' => substr($csvData, 0, 1000) . (strlen($csvData) > 1000 ? '...' : '')
|
||||
]);
|
||||
|
||||
return $this->importString($csvData, $options, $errors);
|
||||
}
|
||||
|
||||
return $this->importString($file->getContent(), $options, $errors);
|
||||
}
|
||||
|
||||
@@ -354,10 +388,103 @@ class EntityImporter
|
||||
'xml' => 'xml',
|
||||
'csv', 'tsv' => 'csv',
|
||||
'yaml', 'yml' => 'yaml',
|
||||
'xlsx' => 'xlsx',
|
||||
'xls' => 'xls',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Excel file to CSV format using PhpSpreadsheet.
|
||||
*
|
||||
* @param File $file The Excel file to convert
|
||||
* @param string $delimiter The CSV delimiter to use
|
||||
*
|
||||
* @return string The CSV data as string
|
||||
*/
|
||||
protected function convertExcelToCsv(File $file, string $delimiter = ';'): string
|
||||
{
|
||||
try {
|
||||
$this->logger->debug('Loading Excel file', ['path' => $file->getPathname()]);
|
||||
$spreadsheet = IOFactory::load($file->getPathname());
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$csvData = [];
|
||||
$highestRow = $worksheet->getHighestRow();
|
||||
$highestColumn = $worksheet->getHighestColumn();
|
||||
|
||||
$this->logger->debug('Excel file dimensions', [
|
||||
'rows' => $highestRow,
|
||||
'columns_detected' => $highestColumn,
|
||||
'worksheet_title' => $worksheet->getTitle()
|
||||
]);
|
||||
|
||||
$highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
|
||||
|
||||
for ($row = 1; $row <= $highestRow; $row++) {
|
||||
$rowData = [];
|
||||
|
||||
// Read all columns using numeric index
|
||||
for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) {
|
||||
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
|
||||
try {
|
||||
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
|
||||
$rowData[] = $cellValue ?? '';
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Error reading cell value', [
|
||||
'cell' => "{$col}{$row}",
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
$rowData[] = '';
|
||||
}
|
||||
}
|
||||
|
||||
$csvRow = implode($delimiter, array_map(function ($value) use ($delimiter) {
|
||||
$value = (string) $value;
|
||||
if (strpos($value, $delimiter) !== false || strpos($value, '"') !== false || strpos($value, "\n") !== false) {
|
||||
return '"' . str_replace('"', '""', $value) . '"';
|
||||
}
|
||||
return $value;
|
||||
}, $rowData));
|
||||
|
||||
$csvData[] = $csvRow;
|
||||
|
||||
// Log first few rows for debugging
|
||||
if ($row <= 3) {
|
||||
$this->logger->debug("Row {$row} converted", [
|
||||
'original_data' => $rowData,
|
||||
'csv_row' => $csvRow,
|
||||
'first_cell_raw' => $worksheet->getCell("A{$row}")->getValue(),
|
||||
'first_cell_calculated' => $worksheet->getCell("A{$row}")->getCalculatedValue()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$result = implode("\n", $csvData);
|
||||
|
||||
$this->logger->info('Excel to CSV conversion successful', [
|
||||
'total_rows' => count($csvData),
|
||||
'total_characters' => strlen($result)
|
||||
]);
|
||||
|
||||
$this->logger->debug('Full CSV data', [
|
||||
'csv_data' => $result
|
||||
]);
|
||||
|
||||
return $result;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to convert Excel to CSV', [
|
||||
'file' => $file->getFilename(),
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This functions corrects the parent setting based on the children value of the parent.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
|
||||
/**
|
||||
* This interface marks a provider as a info provider which can provide information directly in batch operations
|
||||
*/
|
||||
interface BatchInfoProviderInterface extends InfoProviderInterface
|
||||
{
|
||||
/**
|
||||
* Search for multiple keywords in a single batch operation and return the results, ordered by the keywords.
|
||||
* This allows for a more efficient search compared to running multiple single searches.
|
||||
* @param string[] $keywords
|
||||
* @return array<string, SearchResultDTO[]> An associative array where the key is the keyword and the value is the search results for that keyword
|
||||
*/
|
||||
public function searchByKeywordsBatch(array $keywords): array;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ use App\Settings\InfoProviderSystem\LCSCSettings;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class LCSCProvider implements InfoProviderInterface
|
||||
class LCSCProvider implements BatchInfoProviderInterface
|
||||
{
|
||||
|
||||
private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
|
||||
@@ -69,9 +69,10 @@ class LCSCProvider implements InfoProviderInterface
|
||||
|
||||
/**
|
||||
* @param string $id
|
||||
* @param bool $lightweight If true, skip expensive operations like datasheet resolution
|
||||
* @return PartDetailDTO
|
||||
*/
|
||||
private function queryDetail(string $id): PartDetailDTO
|
||||
private function queryDetail(string $id, bool $lightweight = false): PartDetailDTO
|
||||
{
|
||||
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
|
||||
'headers' => [
|
||||
@@ -89,7 +90,7 @@ class LCSCProvider implements InfoProviderInterface
|
||||
throw new \RuntimeException('Could not find product code: ' . $id);
|
||||
}
|
||||
|
||||
return $this->getPartDetail($product);
|
||||
return $this->getPartDetail($product, $lightweight);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,30 +100,42 @@ class LCSCProvider implements InfoProviderInterface
|
||||
private function getRealDatasheetUrl(?string $url): string
|
||||
{
|
||||
if ($url !== null && trim($url) !== '' && preg_match("/^https:\/\/(datasheet\.lcsc\.com|www\.lcsc\.com\/datasheet)\/.*(C\d+)\.pdf$/", $url, $matches) > 0) {
|
||||
if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) {
|
||||
$url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1];
|
||||
}
|
||||
$response = $this->lcscClient->request('GET', $url, [
|
||||
'headers' => [
|
||||
'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html'
|
||||
],
|
||||
]);
|
||||
if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) {
|
||||
//HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused
|
||||
//See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934
|
||||
$jsonObj = json_decode('{"' . $matches[1] . '": ' . $matches[2] . '}');
|
||||
$url = $jsonObj->previewPdfUrl;
|
||||
}
|
||||
if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) {
|
||||
$url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1];
|
||||
}
|
||||
$response = $this->lcscClient->request('GET', $url, [
|
||||
'headers' => [
|
||||
'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html'
|
||||
],
|
||||
]);
|
||||
if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) {
|
||||
//HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused
|
||||
//See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934
|
||||
$jsonObj = json_decode('{"' . $matches[1] . '": ' . $matches[2] . '}');
|
||||
$url = $jsonObj->previewPdfUrl;
|
||||
}
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $term
|
||||
* @param bool $lightweight If true, skip expensive operations like datasheet resolution
|
||||
* @return PartDetailDTO[]
|
||||
*/
|
||||
private function queryByTerm(string $term): array
|
||||
private function queryByTerm(string $term, bool $lightweight = false): array
|
||||
{
|
||||
// Optimize: If term looks like an LCSC part number (starts with C followed by digits),
|
||||
// use direct detail query instead of slower search
|
||||
if (preg_match('/^C\d+$/i', trim($term))) {
|
||||
try {
|
||||
return [$this->queryDetail(trim($term), $lightweight)];
|
||||
} catch (\Exception $e) {
|
||||
// If direct lookup fails, fall back to search
|
||||
// This handles cases where the C-code might not exist
|
||||
}
|
||||
}
|
||||
|
||||
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/search/global", [
|
||||
'headers' => [
|
||||
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
||||
@@ -145,11 +158,11 @@ class LCSCProvider implements InfoProviderInterface
|
||||
// detailed product listing. It does so utilizing a product tip field.
|
||||
// If product tip exists and there are no products in the product list try a detail query
|
||||
if (count($products) === 0 && $tipProductCode !== null) {
|
||||
$result[] = $this->queryDetail($tipProductCode);
|
||||
$result[] = $this->queryDetail($tipProductCode, $lightweight);
|
||||
}
|
||||
|
||||
foreach ($products as $product) {
|
||||
$result[] = $this->getPartDetail($product);
|
||||
$result[] = $this->getPartDetail($product, $lightweight);
|
||||
}
|
||||
|
||||
return $result;
|
||||
@@ -175,7 +188,7 @@ class LCSCProvider implements InfoProviderInterface
|
||||
* @param array $product
|
||||
* @return PartDetailDTO
|
||||
*/
|
||||
private function getPartDetail(array $product): PartDetailDTO
|
||||
private function getPartDetail(array $product, bool $lightweight = false): PartDetailDTO
|
||||
{
|
||||
// Get product images in advance
|
||||
$product_images = $this->getProductImages($product['productImages'] ?? null);
|
||||
@@ -214,10 +227,10 @@ class LCSCProvider implements InfoProviderInterface
|
||||
manufacturing_status: null,
|
||||
provider_url: $this->getProductShortURL($product['productCode']),
|
||||
footprint: $this->sanitizeField($footprint),
|
||||
datasheets: $this->getProductDatasheets($product['pdfUrl'] ?? null),
|
||||
images: $product_images,
|
||||
parameters: $this->attributesToParameters($product['paramVOList'] ?? []),
|
||||
vendor_infos: $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []),
|
||||
datasheets: $lightweight ? [] : $this->getProductDatasheets($product['pdfUrl'] ?? null),
|
||||
images: $product_images, // Always include images - users need to see them
|
||||
parameters: $lightweight ? [] : $this->attributesToParameters($product['paramVOList'] ?? []),
|
||||
vendor_infos: $lightweight ? [] : $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []),
|
||||
mass: $product['weight'] ?? null,
|
||||
);
|
||||
}
|
||||
@@ -286,7 +299,7 @@ class LCSCProvider implements InfoProviderInterface
|
||||
*/
|
||||
private function getProductShortURL(string $product_code): string
|
||||
{
|
||||
return 'https://www.lcsc.com/product-detail/' . $product_code .'.html';
|
||||
return 'https://www.lcsc.com/product-detail/' . $product_code . '.html';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,7 +340,7 @@ class LCSCProvider implements InfoProviderInterface
|
||||
|
||||
//Skip this attribute if it's empty
|
||||
if (in_array(trim((string) $attribute['paramValueEn']), ['', '-'], true)) {
|
||||
continue;
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = ParameterDTO::parseValueIncludingUnit(name: $attribute['paramNameEn'], value: $attribute['paramValueEn'], group: null);
|
||||
@@ -338,12 +351,86 @@ class LCSCProvider implements InfoProviderInterface
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
return $this->queryByTerm($keyword);
|
||||
return $this->queryByTerm($keyword, true); // Use lightweight mode for search
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch search multiple keywords asynchronously (like JavaScript Promise.all)
|
||||
* @param array $keywords Array of keywords to search
|
||||
* @return array Results indexed by keyword
|
||||
*/
|
||||
public function searchByKeywordsBatch(array $keywords): array
|
||||
{
|
||||
if (empty($keywords)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$responses = [];
|
||||
$results = [];
|
||||
|
||||
// Start all requests immediately (like JavaScript promises without await)
|
||||
foreach ($keywords as $keyword) {
|
||||
if (preg_match('/^C\d+$/i', trim($keyword))) {
|
||||
// Direct detail API call for C-codes
|
||||
$responses[$keyword] = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
|
||||
'headers' => [
|
||||
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
||||
],
|
||||
'query' => [
|
||||
'productCode' => trim($keyword),
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
// Search API call for other terms
|
||||
$responses[$keyword] = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/search/global", [
|
||||
'headers' => [
|
||||
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
||||
],
|
||||
'query' => [
|
||||
'keyword' => $keyword,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Now collect all results (like .then() in JavaScript)
|
||||
foreach ($responses as $keyword => $response) {
|
||||
try {
|
||||
$arr = $response->toArray(); // This waits for the response
|
||||
$results[$keyword] = $this->processSearchResponse($arr, $keyword);
|
||||
} catch (\Exception $e) {
|
||||
$results[$keyword] = []; // Empty results on error
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function processSearchResponse(array $arr, string $keyword): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
// Check if this looks like a detail response (direct C-code lookup)
|
||||
if (isset($arr['result']['productCode'])) {
|
||||
$product = $arr['result'];
|
||||
$result[] = $this->getPartDetail($product, true); // lightweight mode
|
||||
} else {
|
||||
// This is a search response
|
||||
$products = $arr['result']['productSearchResultVO']['productList'] ?? [];
|
||||
$tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null;
|
||||
|
||||
// If no products but has tip, we'd need another API call - skip for batch mode
|
||||
foreach ($products as $product) {
|
||||
$result[] = $this->getPartDetail($product, true); // lightweight mode
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
$tmp = $this->queryByTerm($id);
|
||||
$tmp = $this->queryByTerm($id, false);
|
||||
if (count($tmp) === 0) {
|
||||
throw new \RuntimeException('No part found with ID ' . $id);
|
||||
}
|
||||
|
||||
@@ -30,13 +30,11 @@ use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Repository\PartRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
@@ -100,7 +98,7 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
||||
|
||||
//When action starts with "export_" we have to redirect to the export controller
|
||||
$matches = [];
|
||||
if (preg_match('/^export_(json|yaml|xml|csv)$/', $action, $matches)) {
|
||||
if (preg_match('/^export_(json|yaml|xml|csv|xlsx)$/', $action, $matches)) {
|
||||
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
|
||||
$level = match ($target_id) {
|
||||
2 => 'extended',
|
||||
@@ -119,6 +117,16 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
||||
);
|
||||
}
|
||||
|
||||
if ($action === 'bulk_info_provider_import') {
|
||||
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->generate('bulk_info_provider_step1', [
|
||||
'ids' => $ids,
|
||||
'_redirect' => $redirect_url
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
//Iterate over the parts and apply the action to it:
|
||||
foreach ($selected_parts as $part) {
|
||||
|
||||
@@ -138,6 +138,11 @@ class ToolsTreeBuilder
|
||||
$this->translator->trans('info_providers.search.title'),
|
||||
$this->urlGenerator->generate('info_providers_search')
|
||||
))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down');
|
||||
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('info_providers.bulk_import.manage_jobs'),
|
||||
$this->urlGenerator->generate('bulk_info_provider_manage')
|
||||
))->setIcon('fa-treeview fa-fw fa-solid fa-tasks');
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
|
||||
@@ -729,9 +729,6 @@
|
||||
},
|
||||
"files": []
|
||||
},
|
||||
"symfony/ux-toggle-password": {
|
||||
"version": "v2.29.2"
|
||||
},
|
||||
"symfony/ux-translator": {
|
||||
"version": "2.9",
|
||||
"recipe": {
|
||||
|
||||
@@ -30,7 +30,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') }}>
|
||||
{# <span id="select_count"></span> #}
|
||||
<small class="text-muted">{% trans %}part_list.action.scrollable_hint{% endtrans %}</small>
|
||||
|
||||
<div class="input-group">
|
||||
<button class="btn btn-outline-secondary" type="button" {{ stimulus_action('elements/datatables/parts', 'invertSelection')}}
|
||||
@@ -72,6 +72,10 @@
|
||||
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_csv" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_csv{% endtrans %}</option>
|
||||
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_yaml" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_yaml{% endtrans %}</option>
|
||||
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_xml" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_xml{% endtrans %}</option>
|
||||
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_xlsx" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_xlsx{% endtrans %}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{% trans %}part_list.action.action.info_provider{% endtrans %}">
|
||||
<option {% if not is_granted('@info_providers.create_parts') %}disabled{% endif %} value="bulk_info_provider_import" data-url="{{ path('bulk_info_provider_step1')}}" data-turbo="false">{% trans %}part_list.action.bulk_info_provider_import{% endtrans %}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
168
templates/info_providers/bulk_import/manage.html.twig
Normal file
168
templates/info_providers/bulk_import/manage.html.twig
Normal file
@@ -0,0 +1,168 @@
|
||||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}info_providers.bulk_import.manage_jobs{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-tasks"></i> {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<p class="text-muted mb-0">
|
||||
{% trans %}info_providers.bulk_import.manage_jobs_description{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if jobs is not empty %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}info_providers.bulk_import.job_name{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.parts_count{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.results_count{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.progress{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.status{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.created_by{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.created_at{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.completed_at{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</strong>
|
||||
{% if job.isInProgress %}
|
||||
<span class="badge bg-info ms-2">Active</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ job.partCount }}</td>
|
||||
<td>{{ job.resultCount }}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="progress me-2" style="width: 80px; height: 12px;">
|
||||
<div class="progress-bar {% if job.isCompleted %}bg-success{% elseif job.isFailed %}bg-danger{% else %}bg-info{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ job.progressPercentage }}%"
|
||||
aria-valuenow="{{ job.progressPercentage }}"
|
||||
aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">{{ job.progressPercentage }}%</small>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if job.isPending %}
|
||||
<span class="badge bg-warning">{% trans %}info_providers.bulk_import.status.pending{% endtrans %}</span>
|
||||
{% elseif job.isInProgress %}
|
||||
<span class="badge bg-info">{% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}</span>
|
||||
{% elseif job.isCompleted %}
|
||||
<span class="badge bg-success">{% trans %}info_providers.bulk_import.status.completed{% endtrans %}</span>
|
||||
{% elseif job.isStopped %}
|
||||
<span class="badge bg-secondary">{% trans %}info_providers.bulk_import.status.stopped{% endtrans %}</span>
|
||||
{% elseif job.isFailed %}
|
||||
<span class="badge bg-danger">{% trans %}info_providers.bulk_import.status.failed{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ job.createdBy.username }}</td>
|
||||
<td>{{ job.createdAt|date('Y-m-d H:i') }}</td>
|
||||
<td>
|
||||
{% if job.completedAt %}
|
||||
{{ job.completedAt|date('Y-m-d H:i') }}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if job.isInProgress or job.isCompleted or job.isStopped %}
|
||||
<a href="{{ path('bulk_info_provider_step2', {'jobId': job.id}) }}" class="btn btn-primary">
|
||||
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_results{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if job.canBeStopped %}
|
||||
<button type="button" class="btn btn-warning" onclick="stopJob({{ job.id }})">
|
||||
<i class="fas fa-stop"></i> {% trans %}info_providers.bulk_import.action.stop{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if job.isCompleted or job.isFailed or job.isStopped %}
|
||||
<button type="button" class="btn btn-danger" onclick="deleteJob({{ job.id }})">
|
||||
<i class="fas fa-trash"></i> {% trans %}info_providers.bulk_import.action.delete{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{% trans %}info_providers.bulk_import.no_jobs_found{% endtrans %}
|
||||
<a href="{{ path('bulk_info_provider_step1') }}" class="alert-link">
|
||||
{% trans %}info_providers.bulk_import.create_first_job{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function deleteJob(jobId) {
|
||||
if (confirm('{% trans %}info_providers.bulk_import.confirm_delete_job{% endtrans %}')) {
|
||||
fetch(`{{ path('bulk_info_provider_delete', {'jobId': '__JOB_ID__'}) }}`.replace('__JOB_ID__', jobId), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error deleting job: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting job');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stopJob(jobId) {
|
||||
if (confirm('{% trans %}info_providers.bulk_import.confirm_stop_job{% endtrans %}')) {
|
||||
fetch(`{{ path('bulk_info_provider_stop', {'jobId': '__JOB_ID__'}) }}`.replace('__JOB_ID__', jobId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error stopping job: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error stopping job');
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
430
templates/info_providers/bulk_import/step1.html.twig
Normal file
430
templates/info_providers/bulk_import/step1.html.twig
Normal file
@@ -0,0 +1,430 @@
|
||||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% import "info_providers/providers.macro.html.twig" as providers_macro %}
|
||||
{% import "helper.twig" as helper %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}info_providers.bulk_import.step1.title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.bulk_import.step1.title{% endtrans %}
|
||||
<span class="badge bg-secondary">{{ parts|length }} {% trans %}info_providers.bulk_import.parts_selected{% endtrans %}</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
|
||||
<!-- Show existing jobs -->
|
||||
{% if existing_jobs is not empty %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{% trans %}info_providers.bulk_import.existing_jobs{% endtrans %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}info_providers.bulk_import.job_name{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.parts_count{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.results_count{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.progress{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.status{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.created_at{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in existing_jobs %}
|
||||
<tr>
|
||||
<td>{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</td>
|
||||
<td>{{ job.partCount }}</td>
|
||||
<td>{{ job.resultCount }}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="progress me-2" style="width: 60px; height: 8px;">
|
||||
<div class="progress-bar {% if job.isCompleted %}bg-success{% else %}bg-info{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ job.progressPercentage }}%"
|
||||
aria-valuenow="{{ job.progressPercentage }}"
|
||||
aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">{{ job.progressPercentage }}%</small>
|
||||
</div>
|
||||
<small class="text-muted">{{ job.completedPartsCount }}/{{ job.partCount }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if job.isPending %}
|
||||
<span class="badge bg-warning">{% trans %}info_providers.bulk_import.status.pending{% endtrans %}</span>
|
||||
{% elseif job.isInProgress %}
|
||||
<span class="badge bg-info">{% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}</span>
|
||||
{% elseif job.isCompleted %}
|
||||
<span class="badge bg-success">{% trans %}info_providers.bulk_import.status.completed{% endtrans %}</span>
|
||||
{% elseif job.isFailed %}
|
||||
<span class="badge bg-danger">{% trans %}info_providers.bulk_import.status.failed{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ job.createdAt|date('Y-m-d H:i') }}</td>
|
||||
<td>
|
||||
{% if job.isInProgress or job.isCompleted %}
|
||||
<a href="{{ path('bulk_info_provider_step2', {'jobId': job.id}) }}" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_results{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{% trans %}info_providers.bulk_import.step1.global_mapping_description{% endtrans %}
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success" role="alert">
|
||||
<i class="fas fa-lightbulb"></i>
|
||||
<strong>{% trans %}info_providers.bulk_import.priority_system.title{% endtrans %}:</strong> {% trans %}info_providers.bulk_import.priority_system.description{% endtrans %}
|
||||
<br><small class="text-muted">
|
||||
{% trans %}info_providers.bulk_import.priority_system.example{% endtrans %}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{% trans %}info_providers.bulk_import.step1.spn_recommendation{% endtrans %}
|
||||
</div>
|
||||
|
||||
<!-- Show selected parts -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{% trans %}info_providers.bulk_import.selected_parts{% endtrans %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% for part in parts %}
|
||||
{% set hasNoIdentifiers = part.manufacturerProductNumber is empty and part.orderdetails is empty %}
|
||||
<div class="col-md-6 col-lg-4 mb-2">
|
||||
<div class="d-flex align-items-center {% if hasNoIdentifiers %}text-danger{% endif %}">
|
||||
<i class="fas fa-microchip {% if hasNoIdentifiers %}text-danger{% else %}text-primary{% endif %} me-2"></i>
|
||||
<div>
|
||||
<a href="{{ path('app_part_show', {'id': part.id}) }}" class="text-decoration-none {% if hasNoIdentifiers %}text-danger{% endif %}">
|
||||
<strong>{{ part.name }}</strong>
|
||||
{% if part.manufacturerProductNumber %}
|
||||
<br><small class="{% if hasNoIdentifiers %}text-danger{% else %}text-muted{% endif %}">MPN: {{ part.manufacturerProductNumber }}</small>
|
||||
{% endif %}
|
||||
{% if part.orderdetails is not empty %}
|
||||
<br><small class="{% if hasNoIdentifiers %}text-danger{% else %}text-muted{% endif %}">
|
||||
SPNs: {{ part.orderdetails|map(od => od.supplierPartNr)|join(', ') }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ form_start(form) }}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{% trans %}info_providers.bulk_import.field_mappings{% endtrans %}</h5>
|
||||
<small class="text-muted">{% trans %}info_providers.bulk_import.field_mappings_help{% endtrans %}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans %}info_providers.bulk_search.search_field{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_search.providers{% endtrans %}</th>
|
||||
<th width="80">{% trans %}info_providers.bulk_search.priority{% endtrans %}</th>
|
||||
<th width="100">{% trans %}info_providers.bulk_import.actions.label{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="field-mappings-tbody" data-prototype="{{ form_widget(form.field_mappings.vars.prototype)|e('html_attr') }}">
|
||||
{% for mapping in form.field_mappings %}
|
||||
<tr class="mapping-row">
|
||||
<td>{{ form_widget(mapping.field) }}{{ form_errors(mapping.field) }}</td>
|
||||
<td>{{ form_widget(mapping.providers) }}{{ form_errors(mapping.providers) }}</td>
|
||||
<td>{{ form_widget(mapping.priority) }}{{ form_errors(mapping.priority) }}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeMapping(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" class="btn btn-success btn-sm" onclick="addMapping()">
|
||||
<i class="fas fa-plus"></i> {% trans %}info_providers.bulk_import.add_mapping{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 d-flex flex-column align-items-start gap-2">
|
||||
<div class="mb-2">
|
||||
<a href="{{ path('info_providers_list') }}">{% trans %}info_providers.search.info_providers_list{% endtrans %}</a>
|
||||
|
|
||||
<a href="{{ path('bulk_info_provider_manage') }}">{% trans %}info_providers.bulk_import.manage_jobs{% endtrans %}</a>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
{{ form_widget(form.prefetch_details, {'attr': {'class': 'form-check-input'}}) }}
|
||||
{{ form_label(form.prefetch_details, null, {'label_attr': {'class': 'form-check-label'}}) }}
|
||||
{{ form_help(form.prefetch_details) }}
|
||||
</div>
|
||||
|
||||
{{ form_widget(form.submit) }}
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
|
||||
{% if search_results is not null %}
|
||||
<hr>
|
||||
<h4>{% trans %}info_providers.bulk_import.search_results.title{% endtrans %}</h4>
|
||||
|
||||
{% for part_result in search_results %}
|
||||
{% set part = part_result.part %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">
|
||||
{{ part.name }}
|
||||
{% if part_result.errors is not empty %}
|
||||
<span class="badge bg-warning">{{ part_result.errors|length }} {% trans %}info_providers.bulk_import.errors{% endtrans %}</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-success">{{ part_result.search_results|length }} {% trans %}info_providers.bulk_import.results_found{% endtrans %}</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if part_result.errors is not empty %}
|
||||
{% for error in part_result.errors %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if part_result.search_results|length > 0 %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans %}name.label{% endtrans %}</th>
|
||||
<th>{% trans %}description.label{% endtrans %}</th>
|
||||
<th>{% trans %}manufacturer.label{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.table.provider.label{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.source_field{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for result in part_result.search_results %}
|
||||
{% set dto = result.dto %}
|
||||
{% set localPart = result.localPart %}
|
||||
<tr {% if localPart is not null %}class="table-warning"{% endif %}>
|
||||
<td>
|
||||
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
|
||||
class="hoverpic" style="max-width: 30px;" {{ stimulus_controller('elements/hoverpic') }}>
|
||||
</td>
|
||||
<td>
|
||||
{% if dto.provider_url is not null %}
|
||||
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">{{ dto.name }}</a>
|
||||
{% else %}
|
||||
{{ dto.name }}
|
||||
{% endif %}
|
||||
{% if dto.mpn is not null %}
|
||||
<br><small class="text-muted">{{ dto.mpn }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ dto.description }}</td>
|
||||
<td>{{ dto.manufacturer ?? '' }}</td>
|
||||
<td>
|
||||
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
|
||||
<br><small class="text-muted">{{ dto.provider_id }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ result.source_field ?? 'unknown' }}</span>
|
||||
{% if result.source_keyword %}
|
||||
<br><small class="text-muted">{{ result.source_keyword }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group-vertical btn-group-sm" role="group">
|
||||
{% set updateHref = path('info_providers_update_part',
|
||||
{'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %}
|
||||
<a class="btn btn-primary" href="{{ updateHref }}" target="_blank">
|
||||
<i class="fas fa-edit"></i> {% trans %}info_providers.bulk_import.update_part{% endtrans %}
|
||||
</a>
|
||||
|
||||
{% if localPart is not null %}
|
||||
<a class="btn btn-info btn-sm" href="{{ path('app_part_show', {'id': localPart.id}) }}" target="_blank">
|
||||
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_existing{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{% trans %}info_providers.search.no_results{% endtrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let mappingIndex = {{ form.field_mappings|length }};
|
||||
const maxMappings = {{ fieldChoices|length }};
|
||||
|
||||
function addMapping() {
|
||||
const tbody = document.getElementById('field-mappings-tbody');
|
||||
const currentMappings = tbody.querySelectorAll('.mapping-row').length;
|
||||
|
||||
// Check if we've reached the maximum number of mappings
|
||||
if (currentMappings >= maxMappings) {
|
||||
alert('{% trans %}info_providers.bulk_import.max_mappings_reached{% endtrans %}');
|
||||
return;
|
||||
}
|
||||
|
||||
const prototype = tbody.dataset.prototype;
|
||||
|
||||
// Replace __name__ placeholder with current index
|
||||
const newRowHtml = prototype.replace(/__name__/g, mappingIndex);
|
||||
|
||||
// Create temporary div to parse the prototype HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newRowHtml;
|
||||
|
||||
// Extract field, provider, and priority widgets
|
||||
const fieldWidget = tempDiv.querySelector('select[name*="[field]"]') || tempDiv.children[0];
|
||||
const providerWidget = tempDiv.querySelector('select[name*="[providers]"]') || tempDiv.children[1];
|
||||
const priorityWidget = tempDiv.querySelector('input[name*="[priority]"]') || tempDiv.children[2];
|
||||
|
||||
// Create new row
|
||||
const newRow = document.createElement('tr');
|
||||
newRow.className = 'mapping-row';
|
||||
newRow.innerHTML = `
|
||||
<td>${fieldWidget ? fieldWidget.outerHTML : ''}</td>
|
||||
<td>${providerWidget ? providerWidget.outerHTML : ''}</td>
|
||||
<td>${priorityWidget ? priorityWidget.outerHTML : ''}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeMapping(this)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(newRow);
|
||||
mappingIndex++;
|
||||
|
||||
// Add change listener to the new field select and clear its selection
|
||||
const newFieldSelect = newRow.querySelector('select[name*="[field]"]');
|
||||
if (newFieldSelect) {
|
||||
// Clear the selection for new rows - select the placeholder option
|
||||
newFieldSelect.value = '';
|
||||
addFieldChangeListener(newFieldSelect);
|
||||
}
|
||||
|
||||
// Update field options to hide already selected fields
|
||||
updateFieldOptions();
|
||||
|
||||
// Update add button state
|
||||
updateAddButtonState();
|
||||
}
|
||||
|
||||
function removeMapping(button) {
|
||||
const row = button.closest('tr');
|
||||
row.remove();
|
||||
updateFieldOptions();
|
||||
updateAddButtonState();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function updateFieldOptions() {
|
||||
const tbody = document.getElementById('field-mappings-tbody');
|
||||
const fieldSelects = tbody.querySelectorAll('select[name*="[field]"]');
|
||||
|
||||
// Get all currently selected field values (excluding empty values)
|
||||
const selectedFields = Array.from(fieldSelects)
|
||||
.map(select => select.value)
|
||||
.filter(value => value && value !== '');
|
||||
|
||||
console.log('Selected fields:', selectedFields);
|
||||
|
||||
// Update each field select to disable already selected options
|
||||
fieldSelects.forEach(select => {
|
||||
Array.from(select.options).forEach(option => {
|
||||
// Don't disable if this is the current select's value or if option is empty
|
||||
const isCurrentValue = option.value === select.value;
|
||||
const isEmptyOption = !option.value || option.value === '';
|
||||
const isAlreadySelected = selectedFields.includes(option.value);
|
||||
|
||||
if (!isEmptyOption && isAlreadySelected && !isCurrentValue) {
|
||||
option.disabled = true;
|
||||
option.style.display = 'none';
|
||||
} else {
|
||||
option.disabled = false;
|
||||
option.style.display = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateAddButtonState() {
|
||||
const tbody = document.getElementById('field-mappings-tbody');
|
||||
const addButton = document.querySelector('button[onclick="addMapping()"]');
|
||||
const currentMappings = tbody.querySelectorAll('.mapping-row').length;
|
||||
|
||||
if (addButton) {
|
||||
if (currentMappings >= maxMappings) {
|
||||
addButton.disabled = true;
|
||||
addButton.title = '{% trans %}info_providers.bulk_import.max_mappings_reached{% endtrans %}';
|
||||
} else {
|
||||
addButton.disabled = false;
|
||||
addButton.title = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener for field changes
|
||||
function addFieldChangeListener(select) {
|
||||
select.addEventListener('change', function() {
|
||||
updateFieldOptions();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize add button state on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateAddButtonState();
|
||||
updateFieldOptions();
|
||||
|
||||
// Add change listeners to existing field selects
|
||||
const fieldSelects = document.querySelectorAll('select[name*="[field]"]');
|
||||
fieldSelects.forEach(addFieldChangeListener);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
348
templates/info_providers/bulk_import/step2.html.twig
Normal file
348
templates/info_providers/bulk_import/step2.html.twig
Normal file
@@ -0,0 +1,348 @@
|
||||
{% extends "main_card.html.twig" %}
|
||||
|
||||
{% import "info_providers/providers.macro.html.twig" as providers_macro %}
|
||||
{% import "helper.twig" as helper %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}info_providers.bulk_import.step2.title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-search"></i> {% trans %}info_providers.bulk_import.step2.title{% endtrans %}
|
||||
<span class="badge bg-secondary">{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-1">{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ job.partCount }} {% trans %}info_providers.bulk_import.parts{% endtrans %} •
|
||||
{{ job.resultCount }} {% trans %}info_providers.bulk_import.results{% endtrans %} •
|
||||
{% trans %}info_providers.bulk_import.created_at{% endtrans %}: {{ job.createdAt|date('Y-m-d H:i') }}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
{% if job.isPending %}
|
||||
<span class="badge bg-warning">{% trans %}info_providers.bulk_import.status.pending{% endtrans %}</span>
|
||||
{% elseif job.isInProgress %}
|
||||
<span class="badge bg-info">{% trans %}info_providers.bulk_import.status.in_progress{% endtrans %}</span>
|
||||
{% elseif job.isCompleted %}
|
||||
<span class="badge bg-success">{% trans %}info_providers.bulk_import.status.completed{% endtrans %}</span>
|
||||
{% elseif job.isFailed %}
|
||||
<span class="badge bg-danger">{% trans %}info_providers.bulk_import.status.failed{% endtrans %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Progress</h6>
|
||||
<span id="progress-text">{{ job.completedPartsCount }} / {{ job.partCount }} completed</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 8px;">
|
||||
<div id="progress-bar" class="progress-bar" role="progressbar"
|
||||
style="width: {{ job.progressPercentage }}%"
|
||||
aria-valuenow="{{ job.progressPercentage }}" aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-2">
|
||||
<small class="text-muted">
|
||||
<span id="completed-count">{{ job.completedPartsCount }}</span> {% trans %}info_providers.bulk_import.completed{% endtrans %} •
|
||||
<span id="skipped-count">{{ job.skippedPartsCount }}</span> {% trans %}info_providers.bulk_import.skipped{% endtrans %}
|
||||
</small>
|
||||
<small class="text-muted"><span id="progress-percentage">{{ job.progressPercentage }}%</span></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tutorial/Instructions -->
|
||||
<div class="alert alert-info mb-4" role="alert">
|
||||
<h6 class="alert-heading">
|
||||
<i class="fas fa-info-circle"></i> {% trans %}info_providers.bulk_import.step2.instructions.title{% endtrans %}
|
||||
</h6>
|
||||
<p class="mb-2">{% trans %}info_providers.bulk_import.step2.instructions.description{% endtrans %}</p>
|
||||
<ul class="mb-0 ps-3">
|
||||
<li>{% trans %}info_providers.bulk_import.step2.instructions.step1{% endtrans %}</li>
|
||||
<li>{% trans %}info_providers.bulk_import.step2.instructions.step2{% endtrans %}</li>
|
||||
<li>{% trans %}info_providers.bulk_import.step2.instructions.step3{% endtrans %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% for part_result in search_results %}
|
||||
{% set part = part_result.part %}
|
||||
{% set isCompleted = job.isPartCompleted(part.id) %}
|
||||
{% set isSkipped = job.isPartSkipped(part.id) %}
|
||||
<div class="card mb-3 {% if isCompleted %}border-success{% elseif isSkipped %}border-warning{% endif %}"
|
||||
id="part-card-{{ part.id }}"
|
||||
{% if isCompleted %}style="background-color: rgba(25, 135, 84, 0.1);"{% endif %}>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="card-title mb-0">
|
||||
<a href="{{ path('app_part_show', {'id': part.id}) }}" class="text-decoration-none">
|
||||
{{ part.name }}
|
||||
</a>
|
||||
{% if isCompleted %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check"></i> {% trans %}info_providers.bulk_import.completed{% endtrans %}
|
||||
</span>
|
||||
{% elseif isSkipped %}
|
||||
<span class="badge bg-warning">
|
||||
<i class="fas fa-forward"></i> {% trans %}info_providers.bulk_import.skipped{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if part_result.errors is not empty %}
|
||||
<span class="badge bg-danger">{% trans with {'%count%': part_result.errors|length} %}info_providers.bulk_import.errors{% endtrans %}</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-info">{% trans with {'%count%': part_result.search_results|length} %}info_providers.bulk_import.results_found{% endtrans %}</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
{% if not isCompleted and not isSkipped %}
|
||||
<button type="button" class="btn btn-success btn-sm" onclick="markPartCompleted({{ job.id }}, {{ part.id }})">
|
||||
<i class="fas fa-check"></i> {% trans %}info_providers.bulk_import.mark_completed{% endtrans %}
|
||||
</button>
|
||||
<button type="button" class="btn btn-warning btn-sm" onclick="markPartSkipped({{ job.id }}, {{ part.id }})">
|
||||
<i class="fas fa-forward"></i> {% trans %}info_providers.bulk_import.mark_skipped{% endtrans %}
|
||||
</button>
|
||||
{% elseif isCompleted %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="markPartPending({{ job.id }}, {{ part.id }})">
|
||||
<i class="fas fa-undo"></i> {% trans %}info_providers.bulk_import.mark_pending{% endtrans %}
|
||||
</button>
|
||||
{% elseif isSkipped %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="markPartPending({{ job.id }}, {{ part.id }})">
|
||||
<i class="fas fa-undo"></i> {% trans %}info_providers.bulk_import.mark_pending{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if part_result.errors is not empty %}
|
||||
{% for error in part_result.errors %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if part_result.search_results|length > 0 %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>{% trans %}name.label{% endtrans %}</th>
|
||||
<th>{% trans %}description.label{% endtrans %}</th>
|
||||
<th>{% trans %}manufacturer.label{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.table.provider.label{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.source_field{% endtrans %}</th>
|
||||
<th>{% trans %}info_providers.bulk_import.action.label{% endtrans %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for result in part_result.search_results %}
|
||||
{% set dto = result.dto %}
|
||||
{% set localPart = result.localPart %}
|
||||
<tr>
|
||||
<td>
|
||||
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
|
||||
class="hoverpic" style="max-width: 30px;" {{ stimulus_controller('elements/hoverpic') }}>
|
||||
</td>
|
||||
<td>
|
||||
{% if dto.provider_url is not null %}
|
||||
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">{{ dto.name }}</a>
|
||||
{% else %}
|
||||
{{ dto.name }}
|
||||
{% endif %}
|
||||
{% if dto.mpn is not null %}
|
||||
<br><small class="text-muted">{{ dto.mpn }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ dto.description }}</td>
|
||||
<td>{{ dto.manufacturer ?? '' }}</td>
|
||||
<td>
|
||||
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
|
||||
<br><small class="text-muted">{{ dto.provider_id }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ result.source_field ?? 'unknown' }}</span>
|
||||
{% if result.source_keyword %}
|
||||
<br><small class="text-muted">{{ result.source_keyword }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group-vertical btn-group-sm" role="group">
|
||||
{% set updateHref = path('info_providers_update_part',
|
||||
{'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %}
|
||||
<a class="btn btn-primary{% if isCompleted %} disabled{% endif %}" href="{% if not isCompleted %}{{ updateHref }}{% else %}#{% endif %}"{% if isCompleted %} aria-disabled="true"{% endif %}>
|
||||
<i class="fas fa-edit"></i> {% trans %}info_providers.bulk_import.update_part{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{% trans %}info_providers.search.no_results{% endtrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function markPartCompleted(jobId, partId) {
|
||||
fetch(`{{ path('bulk_info_provider_mark_completed', {'jobId': '__JOB_ID__', 'partId': '__PART_ID__'}) }}`.replace('__JOB_ID__', jobId).replace('__PART_ID__', partId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
updatePartStatus(partId, 'completed');
|
||||
updateProgress(data);
|
||||
if (data.job_completed) {
|
||||
location.reload(); // Refresh to show completed status
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
}
|
||||
|
||||
function markPartSkipped(jobId, partId) {
|
||||
const reason = prompt('{% trans %}info_providers.bulk_import.skip_reason{% endtrans %}:', '');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('reason', reason || '');
|
||||
|
||||
fetch(`{{ path('bulk_info_provider_mark_skipped', {'jobId': '__JOB_ID__', 'partId': '__PART_ID__'}) }}`.replace('__JOB_ID__', jobId).replace('__PART_ID__', partId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
updatePartStatus(partId, 'skipped');
|
||||
updateProgress(data);
|
||||
if (data.job_completed) {
|
||||
location.reload(); // Refresh to show completed status
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
}
|
||||
|
||||
function markPartPending(jobId, partId) {
|
||||
fetch(`{{ path('bulk_info_provider_mark_pending', {'jobId': '__JOB_ID__', 'partId': '__PART_ID__'}) }}`.replace('__JOB_ID__', jobId).replace('__PART_ID__', partId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
updatePartStatus(partId, 'pending');
|
||||
updateProgress(data);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
}
|
||||
|
||||
function updatePartStatus(partId, status) {
|
||||
const card = document.getElementById(`part-card-${partId}`);
|
||||
const cardHeader = card.querySelector('.card-header');
|
||||
|
||||
// Remove existing status classes and background
|
||||
card.classList.remove('border-success', 'border-warning');
|
||||
card.style.backgroundColor = '';
|
||||
|
||||
// Remove existing status badges
|
||||
const existingBadges = cardHeader.querySelectorAll('.badge.bg-success, .badge.bg-warning');
|
||||
existingBadges.forEach(badge => {
|
||||
if (badge.innerHTML.includes('fas fa-check') || badge.innerHTML.includes('fas fa-forward')) {
|
||||
badge.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Add new status
|
||||
if (status === 'completed') {
|
||||
card.classList.add('border-success');
|
||||
card.style.backgroundColor = 'rgba(25, 135, 84, 0.1)';
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-success';
|
||||
badge.innerHTML = '<i class="fas fa-check"></i> {% trans %}info_providers.bulk_import.completed{% endtrans %}';
|
||||
cardHeader.querySelector('.card-title').appendChild(badge);
|
||||
} else if (status === 'skipped') {
|
||||
card.classList.add('border-warning');
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-warning';
|
||||
badge.innerHTML = '<i class="fas fa-forward"></i> {% trans %}info_providers.bulk_import.skipped{% endtrans %}';
|
||||
cardHeader.querySelector('.card-title').appendChild(badge);
|
||||
}
|
||||
|
||||
// Update buttons and Update Part button states
|
||||
const buttonGroup = cardHeader.querySelector('.btn-group');
|
||||
const updateButtons = card.querySelectorAll('.btn-primary');
|
||||
|
||||
if (status === 'completed' || status === 'skipped') {
|
||||
buttonGroup.innerHTML = `
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="markPartPending({{ job.id }}, ${partId})">
|
||||
<i class="fas fa-undo"></i> {% trans %}info_providers.bulk_import.mark_pending{% endtrans %}
|
||||
</button>
|
||||
`;
|
||||
// Disable Update Part buttons
|
||||
updateButtons.forEach(btn => {
|
||||
btn.classList.add('disabled');
|
||||
btn.setAttribute('aria-disabled', 'true');
|
||||
btn.href = '#';
|
||||
});
|
||||
} else {
|
||||
buttonGroup.innerHTML = `
|
||||
<button type="button" class="btn btn-success btn-sm" onclick="markPartCompleted({{ job.id }}, ${partId})">
|
||||
<i class="fas fa-check"></i> {% trans %}info_providers.bulk_import.mark_completed{% endtrans %}
|
||||
</button>
|
||||
<button type="button" class="btn btn-warning btn-sm" onclick="markPartSkipped({{ job.id }}, ${partId})">
|
||||
<i class="fas fa-forward"></i> {% trans %}info_providers.bulk_import.mark_skipped{% endtrans %}
|
||||
</button>
|
||||
`;
|
||||
// Enable Update Part buttons
|
||||
updateButtons.forEach(btn => {
|
||||
btn.classList.remove('disabled');
|
||||
btn.removeAttribute('aria-disabled');
|
||||
// Restore original href - this would need to be stored somewhere
|
||||
location.reload(); // For now, just reload to restore the original state
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress(data) {
|
||||
document.getElementById('progress-bar').style.width = data.progress + '%';
|
||||
document.getElementById('progress-bar').setAttribute('aria-valuenow', data.progress);
|
||||
document.getElementById('progress-percentage').textContent = data.progress + '%';
|
||||
document.getElementById('completed-count').textContent = data.completed_count;
|
||||
document.getElementById('progress-text').textContent = `${data.completed_count} / ${data.total_count} completed`;
|
||||
|
||||
if (data.skipped_count !== undefined) {
|
||||
document.getElementById('skipped-count').textContent = data.skipped_count;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,6 +4,32 @@
|
||||
{% trans with {'%name%': part.name|escape } %}part.edit.title{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block before_card %}
|
||||
{% if bulk_job and jobId %}
|
||||
<div class="alert alert-info mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<a href="{{ path('bulk_info_provider_step2', {jobId: bulk_job.id}) }}" class="btn btn-outline-primary btn-sm me-2">
|
||||
<i class="fas fa-arrow-left fa-fw" aria-hidden="true"></i>
|
||||
{% trans %}info_providers.bulk_import.back{% endtrans %}
|
||||
</a>
|
||||
<form method="post" action="{{ path('part_bulk_import_complete', {id: part.id, jobId: bulk_job.id}) }}" style="display: inline;">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('bulk_complete_' ~ part.id) }}">
|
||||
<button type="submit" class="btn btn-primary btn-sm me-3">
|
||||
<i class="fas fa-check fa-fw" aria-hidden="true"></i>
|
||||
{% trans %}info_providers.bulk_import.complete{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
<div>
|
||||
<i class="fas fa-cloud-download fa-fw" aria-hidden="true"></i>
|
||||
{% trans %}info_providers.bulk_import.editing_part{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-edit fa-fw" aria-hidden="true"></i>
|
||||
{% trans with {'%name%': part.name|escape } %}part.edit.card_title{% endtrans %}
|
||||
|
||||
@@ -5,6 +5,19 @@
|
||||
{% block card_border %}border-info{% endblock %}
|
||||
{% block card_type %}bg-info text-bg-info{% endblock %}
|
||||
|
||||
{% block before_card %}
|
||||
{% if bulk_job and jobId %}
|
||||
<div class="alert alert-info mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fas fa-cloud-download fa-fw" aria-hidden="true"></i>
|
||||
{% trans %}info_providers.bulk_import.editing_part{% endtrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}info_providers.update_part.title{% endtrans %}: {{ merge_old_name }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
<button class="nav-link" id="filter-projects-tab" data-bs-toggle="tab" data-bs-target="#filter-projects"><i class="fas fa-archive fa-fw"></i> {% trans %}project.labelp{% endtrans %}</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if filterForm.inBulkImportJob is defined %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="filter-bulk-import-tab" data-bs-toggle="tab" data-bs-target="#filter-bulk-import"><i class="fas fa-download fa-fw"></i> {% trans %}part.edit.tab.bulk_import{% endtrans %}</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
{{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }}
|
||||
@@ -126,6 +131,13 @@
|
||||
{{ form_row(filterForm.bomComment) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if filterForm.inBulkImportJob is defined %}
|
||||
<div class="tab-pane pt-3" id="filter-bulk-import" role="tabpanel" aria-labelledby="filter-bulk-import-tab" tabindex="0">
|
||||
{{ form_row(filterForm.inBulkImportJob) }}
|
||||
{{ form_row(filterForm.bulkImportJobStatus) }}
|
||||
{{ form_row(filterForm.bulkImportPartStatus) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
914
tests/Controller/BulkInfoProviderImportControllerTest.php
Normal file
914
tests/Controller/BulkInfoProviderImportControllerTest.php
Normal file
@@ -0,0 +1,914 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Controller\BulkInfoProviderImportController;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\BulkInfoProviderImportJob;
|
||||
use App\Entity\BulkImportJobStatus;
|
||||
use App\Entity\UserSystem\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @group slow
|
||||
* @group DB
|
||||
*/
|
||||
class BulkInfoProviderImportControllerTest extends WebTestCase
|
||||
{
|
||||
public function testStep1WithoutIds(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/step1');
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
}
|
||||
|
||||
public function testStep1WithInvalidIds(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/step1?ids=999999,888888');
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
}
|
||||
|
||||
public function testManagePage(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/manage');
|
||||
|
||||
// Follow any redirects (like locale redirects)
|
||||
if ($client->getResponse()->isRedirect()) {
|
||||
$client->followRedirect();
|
||||
}
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function testAccessControlForStep1(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/step1?ids=1');
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->loginAsUser($client, 'noread');
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/step1?ids=1');
|
||||
|
||||
// Follow redirects if any, then check for 403 or final response
|
||||
if ($client->getResponse()->isRedirect()) {
|
||||
$client->followRedirect();
|
||||
}
|
||||
|
||||
// The user might get redirected to an error page instead of direct 403
|
||||
$this->assertTrue(
|
||||
$client->getResponse()->getStatusCode() === Response::HTTP_FORBIDDEN ||
|
||||
$client->getResponse()->getStatusCode() === Response::HTTP_OK
|
||||
);
|
||||
}
|
||||
|
||||
public function testAccessControlForManage(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/manage');
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->loginAsUser($client, 'noread');
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/manage');
|
||||
|
||||
// Follow redirects if any, then check for 403 or final response
|
||||
if ($client->getResponse()->isRedirect()) {
|
||||
$client->followRedirect();
|
||||
}
|
||||
|
||||
// The user might get redirected to an error page instead of direct 403
|
||||
$this->assertTrue(
|
||||
$client->getResponse()->getStatusCode() === Response::HTTP_FORBIDDEN ||
|
||||
$client->getResponse()->getStatusCode() === Response::HTTP_OK
|
||||
);
|
||||
}
|
||||
|
||||
public function testStep2TemplateRendering(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
|
||||
// Use an existing part from test fixtures (ID 1 should exist)
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
// Get the admin user for the createdBy field
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped('Admin user not found in fixtures');
|
||||
}
|
||||
|
||||
// Create a test job with search results that include source_field and source_keyword
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($user);
|
||||
$job->addPart($part);
|
||||
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
|
||||
$job->setSearchResults([
|
||||
[
|
||||
'part_id' => $part->getId(),
|
||||
'search_results' => [
|
||||
[
|
||||
'dto' => [
|
||||
'provider_key' => 'test_provider',
|
||||
'provider_id' => 'TEST123',
|
||||
'name' => 'Test Component',
|
||||
'description' => 'Test component description',
|
||||
'manufacturer' => 'Test Manufacturer',
|
||||
'mpn' => 'TEST-MPN-123',
|
||||
'provider_url' => 'https://example.com/test',
|
||||
'preview_image_url' => null,
|
||||
'_source_field' => 'test_field',
|
||||
'_source_keyword' => 'test_keyword'
|
||||
],
|
||||
'localPart' => null
|
||||
]
|
||||
],
|
||||
'errors' => []
|
||||
]
|
||||
]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
// Test that step2 renders correctly with the search results
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/step2/' . $job->getId());
|
||||
|
||||
// Follow any redirects (like locale redirects)
|
||||
if ($client->getResponse()->isRedirect()) {
|
||||
$client->followRedirect();
|
||||
}
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
|
||||
// Verify the template rendered the source_field and source_keyword correctly
|
||||
$content = $client->getResponse()->getContent();
|
||||
$this->assertStringContainsString('test_field', $content);
|
||||
$this->assertStringContainsString('test_keyword', $content);
|
||||
|
||||
// Clean up - find by ID to avoid detached entity issues
|
||||
$jobId = $job->getId();
|
||||
$entityManager->clear(); // Clear all entities
|
||||
$jobToRemove = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
|
||||
if ($jobToRemove) {
|
||||
$entityManager->remove($jobToRemove);
|
||||
$entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function testStep1WithValidIds(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/step1?ids=' . $part->getId());
|
||||
|
||||
if ($client->getResponse()->isRedirect()) {
|
||||
$client->followRedirect();
|
||||
}
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
}
|
||||
|
||||
|
||||
public function testDeleteJobWithValidJob(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped('Admin user not found in fixtures');
|
||||
}
|
||||
|
||||
// Get a test part
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
// Create a completed job
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($user);
|
||||
$job->addPart($part);
|
||||
$job->setStatus(BulkImportJobStatus::COMPLETED);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('DELETE', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/delete');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertTrue($response['success']);
|
||||
}
|
||||
|
||||
public function testDeleteJobWithNonExistentJob(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$client->request('DELETE', '/en/tools/bulk-info-provider-import/job/999999/delete');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertArrayHasKey('error', $response);
|
||||
}
|
||||
|
||||
public function testDeleteJobWithActiveJob(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped('Admin user not found in fixtures');
|
||||
}
|
||||
|
||||
// Get test parts
|
||||
$parts = $this->getTestParts($entityManager, [1]);
|
||||
|
||||
// Create an active job
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($user);
|
||||
foreach ($parts as $part) {
|
||||
$job->addPart($part);
|
||||
}
|
||||
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('DELETE', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/delete');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertArrayHasKey('error', $response);
|
||||
|
||||
// Clean up
|
||||
$entityManager->remove($job);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
public function testStopJobWithValidJob(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped('Admin user not found in fixtures');
|
||||
}
|
||||
|
||||
// Get test parts
|
||||
$parts = $this->getTestParts($entityManager, [1]);
|
||||
|
||||
// Create an active job
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($user);
|
||||
foreach ($parts as $part) {
|
||||
$job->addPart($part);
|
||||
}
|
||||
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/stop');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertTrue($response['success']);
|
||||
|
||||
// Clean up
|
||||
$entityManager->remove($job);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
public function testStopJobWithNonExistentJob(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$client->request('POST', '/en/tools/bulk-info-provider-import/job/999999/stop');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertArrayHasKey('error', $response);
|
||||
}
|
||||
|
||||
public function testMarkPartCompleted(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped('Admin user not found in fixtures');
|
||||
}
|
||||
|
||||
// Get test parts
|
||||
$parts = $this->getTestParts($entityManager, [1, 2]);
|
||||
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($user);
|
||||
foreach ($parts as $part) {
|
||||
$job->addPart($part);
|
||||
}
|
||||
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/part/1/mark-completed');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertTrue($response['success']);
|
||||
$this->assertArrayHasKey('progress', $response);
|
||||
$this->assertArrayHasKey('completed_count', $response);
|
||||
|
||||
// Clean up
|
||||
$entityManager->remove($job);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
public function testMarkPartSkipped(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped('Admin user not found in fixtures');
|
||||
}
|
||||
|
||||
// Get test parts
|
||||
$parts = $this->getTestParts($entityManager, [1, 2]);
|
||||
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($user);
|
||||
foreach ($parts as $part) {
|
||||
$job->addPart($part);
|
||||
}
|
||||
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/part/1/mark-skipped', [
|
||||
'reason' => 'Test skip reason'
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertTrue($response['success']);
|
||||
$this->assertArrayHasKey('skipped_count', $response);
|
||||
|
||||
// Clean up
|
||||
$entityManager->remove($job);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
public function testMarkPartPending(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped('Admin user not found in fixtures');
|
||||
}
|
||||
|
||||
// Get test parts
|
||||
$parts = $this->getTestParts($entityManager, [1]);
|
||||
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($user);
|
||||
foreach ($parts as $part) {
|
||||
$job->addPart($part);
|
||||
}
|
||||
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/part/1/mark-pending');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertTrue($response['success']);
|
||||
|
||||
// Clean up
|
||||
$entityManager->remove($job);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
public function testStep2WithNonExistentJob(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/step2/999999');
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
}
|
||||
|
||||
public function testStep2WithUnauthorizedAccess(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$admin = $userRepository->findOneBy(['name' => 'admin']);
|
||||
$readonly = $userRepository->findOneBy(['name' => 'noread']);
|
||||
|
||||
if (!$admin || !$readonly) {
|
||||
$this->markTestSkipped('Required test users not found in fixtures');
|
||||
}
|
||||
|
||||
// Get test parts
|
||||
$parts = $this->getTestParts($entityManager, [1]);
|
||||
|
||||
// Create job as admin
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($admin);
|
||||
foreach ($parts as $part) {
|
||||
$job->addPart($part);
|
||||
}
|
||||
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
// Try to access as readonly user
|
||||
$this->loginAsUser($client, 'noread');
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/step2/' . $job->getId());
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
// Clean up
|
||||
$entityManager->remove($job);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
public function testJobAccessControlForDelete(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$admin = $userRepository->findOneBy(['name' => 'admin']);
|
||||
$readonly = $userRepository->findOneBy(['name' => 'noread']);
|
||||
|
||||
if (!$admin || !$readonly) {
|
||||
$this->markTestSkipped('Required test users not found in fixtures');
|
||||
}
|
||||
|
||||
// Get test parts
|
||||
$parts = $this->getTestParts($entityManager, [1]);
|
||||
|
||||
// Create job as readonly user
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($readonly);
|
||||
foreach ($parts as $part) {
|
||||
$job->addPart($part);
|
||||
}
|
||||
$job->setStatus(BulkImportJobStatus::COMPLETED);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
// Try to delete as admin (should fail due to ownership)
|
||||
$client->request('DELETE', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/delete');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||
|
||||
// Clean up
|
||||
$entityManager->remove($job);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
private function loginAsUser($client, string $username): void
|
||||
{
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => $username]);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped("User {$username} not found");
|
||||
}
|
||||
|
||||
$client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getTestParts($entityManager, array $ids): array
|
||||
{
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$parts = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$part = $partRepository->find($id);
|
||||
if (!$part) {
|
||||
$this->markTestSkipped("Test part with ID {$id} not found in fixtures");
|
||||
}
|
||||
$parts[] = $part;
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
public function testStep1Form(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/step1?ids=' . $part->getId());
|
||||
|
||||
if ($client->getResponse()->isRedirect()) {
|
||||
$client->followRedirect();
|
||||
}
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$this->assertStringContainsString('Bulk Info Provider Import', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testStep1FormSubmissionWithErrors(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/step1?ids=' . $part->getId());
|
||||
|
||||
if ($client->getResponse()->isRedirect()) {
|
||||
$client->followRedirect();
|
||||
}
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$this->assertStringContainsString('Bulk Info Provider Import', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testGetKeywordFromFieldPrivateMethod(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$controller = $client->getContainer()->get(BulkInfoProviderImportController::class);
|
||||
$reflection = new \ReflectionClass($controller);
|
||||
$method = $reflection->getMethod('getKeywordFromField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invokeArgs($controller, [$part, 'name']);
|
||||
$this->assertIsString($result);
|
||||
|
||||
$result = $method->invokeArgs($controller, [$part, 'mpn']);
|
||||
$this->assertIsString($result);
|
||||
}
|
||||
|
||||
public function testSerializeAndDeserializeSearchResults(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$controller = $client->getContainer()->get(BulkInfoProviderImportController::class);
|
||||
$reflection = new \ReflectionClass($controller);
|
||||
|
||||
$serializeMethod = $reflection->getMethod('serializeSearchResults');
|
||||
$serializeMethod->setAccessible(true);
|
||||
|
||||
$deserializeMethod = $reflection->getMethod('deserializeSearchResults');
|
||||
$deserializeMethod->setAccessible(true);
|
||||
|
||||
$searchResults = [[
|
||||
'part' => $part,
|
||||
'search_results' => [[
|
||||
'dto' => new \App\Services\InfoProviderSystem\DTOs\SearchResultDTO(
|
||||
provider_key: 'test',
|
||||
provider_id: 'TEST123',
|
||||
name: 'Test Component',
|
||||
description: 'Test description',
|
||||
manufacturer: 'Test Manufacturer',
|
||||
mpn: 'TEST-MPN',
|
||||
provider_url: 'https://example.com',
|
||||
preview_image_url: null
|
||||
),
|
||||
'localPart' => null,
|
||||
'source_field' => 'mpn',
|
||||
'source_keyword' => 'TEST123'
|
||||
]],
|
||||
'errors' => []
|
||||
]];
|
||||
|
||||
$serialized = $serializeMethod->invokeArgs($controller, [$searchResults]);
|
||||
$this->assertIsArray($serialized);
|
||||
$this->assertArrayHasKey(0, $serialized);
|
||||
$this->assertArrayHasKey('part_id', $serialized[0]);
|
||||
|
||||
$deserialized = $deserializeMethod->invokeArgs($controller, [$serialized, [$part]]);
|
||||
$this->assertIsArray($deserialized);
|
||||
$this->assertCount(1, $deserialized);
|
||||
}
|
||||
|
||||
public function testManagePageWithJobCleanup(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped('Admin user not found in fixtures');
|
||||
}
|
||||
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($user);
|
||||
$job->addPart($part);
|
||||
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('GET', '/tools/bulk-info-provider-import/manage');
|
||||
|
||||
if ($client->getResponse()->isRedirect()) {
|
||||
$client->followRedirect();
|
||||
}
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
|
||||
// Find job from database to avoid detached entity errors
|
||||
$jobId = $job->getId();
|
||||
$entityManager->clear();
|
||||
$persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
|
||||
if ($persistedJob) {
|
||||
$entityManager->remove($persistedJob);
|
||||
$entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetSupplierPartNumberPrivateMethod(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$controller = $client->getContainer()->get(BulkInfoProviderImportController::class);
|
||||
$reflection = new \ReflectionClass($controller);
|
||||
$method = $reflection->getMethod('getSupplierPartNumber');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invokeArgs($controller, [$part, 'invalid_field']);
|
||||
$this->assertNull($result);
|
||||
|
||||
$result = $method->invokeArgs($controller, [$part, 'test_supplier_spn']);
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testSearchLcscBatchPrivateMethod(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$controller = $client->getContainer()->get(BulkInfoProviderImportController::class);
|
||||
$reflection = new \ReflectionClass($controller);
|
||||
$method = $reflection->getMethod('searchLcscBatch');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invokeArgs($controller, [['TEST123', 'TEST456']]);
|
||||
$this->assertIsArray($result);
|
||||
}
|
||||
|
||||
public function testPrefetchDetailsForResultsPrivateMethod(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass(BulkInfoProviderImportController::class);
|
||||
$method = $reflection->getMethod('prefetchDetailsForResults');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Test the method exists and can be called
|
||||
$this->assertTrue($method->isPrivate());
|
||||
$this->assertEquals('prefetchDetailsForResults', $method->getName());
|
||||
}
|
||||
|
||||
public function testJobAccessControlForStopAndMarkOperations(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$admin = $userRepository->findOneBy(['name' => 'admin']);
|
||||
$readonly = $userRepository->findOneBy(['name' => 'noread']);
|
||||
|
||||
if (!$admin || !$readonly) {
|
||||
$this->markTestSkipped('Required test users not found in fixtures');
|
||||
}
|
||||
|
||||
$parts = $this->getTestParts($entityManager, [1]);
|
||||
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($readonly);
|
||||
foreach ($parts as $part) {
|
||||
$job->addPart($part);
|
||||
}
|
||||
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/stop');
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||
|
||||
$client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/part/1/mark-completed');
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||
|
||||
$client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/part/1/mark-skipped', [
|
||||
'reason' => 'Test reason'
|
||||
]);
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||
|
||||
$client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/part/1/mark-pending');
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||
|
||||
// Find job from database to avoid detached entity errors
|
||||
$jobId = $job->getId();
|
||||
$entityManager->clear();
|
||||
$persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId);
|
||||
if ($persistedJob) {
|
||||
$entityManager->remove($persistedJob);
|
||||
$entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function testOperationsOnCompletedJob(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped('Admin user not found in fixtures');
|
||||
}
|
||||
|
||||
$parts = $this->getTestParts($entityManager, [1]);
|
||||
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($user);
|
||||
foreach ($parts as $part) {
|
||||
$job->addPart($part);
|
||||
}
|
||||
$job->setStatus(BulkImportJobStatus::COMPLETED);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('POST', '/en/tools/bulk-info-provider-import/job/' . $job->getId() . '/stop');
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertArrayHasKey('error', $response);
|
||||
|
||||
$entityManager->remove($job);
|
||||
$entityManager->flush();
|
||||
}
|
||||
}
|
||||
334
tests/Controller/PartControllerTest.php
Normal file
334
tests/Controller/PartControllerTest.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Entity\BulkInfoProviderImportJob;
|
||||
use App\Entity\BulkImportJobStatus;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @group slow
|
||||
* @group DB
|
||||
*/
|
||||
class PartControllerTest extends WebTestCase
|
||||
{
|
||||
public function testShowPart(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/en/part/' . $part->getId());
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function testShowPartWithTimestamp(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$timestamp = time();
|
||||
$client->request('GET', "/en/part/{$part->getId()}/info/{$timestamp}");
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function testEditPart(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/en/part/' . $part->getId() . '/edit');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$this->assertSelectorExists('form[name="part_base"]');
|
||||
}
|
||||
|
||||
public function testEditPartWithBulkJob(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => 'admin']);
|
||||
|
||||
if (!$part || !$user) {
|
||||
$this->markTestSkipped('Required test data not found in fixtures');
|
||||
}
|
||||
|
||||
// Create a bulk job
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setCreatedBy($user);
|
||||
$job->setPartIds([$part->getId()]);
|
||||
$job->setStatus(BulkImportJobStatus::IN_PROGRESS);
|
||||
$job->setSearchResults([]);
|
||||
|
||||
$entityManager->persist($job);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('GET', '/en/part/' . $part->getId() . '/edit?jobId=' . $job->getId());
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
|
||||
// Clean up
|
||||
$entityManager->remove($job);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function testNewPart(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$client->request('GET', '/en/part/new');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$this->assertSelectorExists('form[name="part_base"]');
|
||||
}
|
||||
|
||||
public function testNewPartWithCategory(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$categoryRepository = $entityManager->getRepository(Category::class);
|
||||
$category = $categoryRepository->find(1);
|
||||
|
||||
if (!$category) {
|
||||
$this->markTestSkipped('Test category with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/en/part/new?category=' . $category->getId());
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function testNewPartWithFootprint(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$footprintRepository = $entityManager->getRepository(Footprint::class);
|
||||
$footprint = $footprintRepository->find(1);
|
||||
|
||||
if (!$footprint) {
|
||||
$this->markTestSkipped('Test footprint with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/en/part/new?footprint=' . $footprint->getId());
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function testNewPartWithManufacturer(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$manufacturerRepository = $entityManager->getRepository(Manufacturer::class);
|
||||
$manufacturer = $manufacturerRepository->find(1);
|
||||
|
||||
if (!$manufacturer) {
|
||||
$this->markTestSkipped('Test manufacturer with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/en/part/new?manufacturer=' . $manufacturer->getId());
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function testNewPartWithStorageLocation(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$storageLocationRepository = $entityManager->getRepository(StorageLocation::class);
|
||||
$storageLocation = $storageLocationRepository->find(1);
|
||||
|
||||
if (!$storageLocation) {
|
||||
$this->markTestSkipped('Test storage location with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/en/part/new?storelocation=' . $storageLocation->getId());
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function testNewPartWithSupplier(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$supplierRepository = $entityManager->getRepository(Supplier::class);
|
||||
$supplier = $supplierRepository->find(1);
|
||||
|
||||
if (!$supplier) {
|
||||
$this->markTestSkipped('Test supplier with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/en/part/new?supplier=' . $supplier->getId());
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function testClonePart(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/en/part/' . $part->getId() . '/clone');
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$this->assertSelectorExists('form[name="part_base"]');
|
||||
}
|
||||
|
||||
public function testMergeParts(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'admin');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$categoryRepository = $entityManager->getRepository(Category::class);
|
||||
$category = $categoryRepository->find(1);
|
||||
|
||||
if (!$category) {
|
||||
$this->markTestSkipped('Test category with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
// Create two test parts
|
||||
$targetPart = new Part();
|
||||
$targetPart->setName('Target Part');
|
||||
$targetPart->setCategory($category);
|
||||
|
||||
$otherPart = new Part();
|
||||
$otherPart->setName('Other Part');
|
||||
$otherPart->setCategory($category);
|
||||
|
||||
$entityManager->persist($targetPart);
|
||||
$entityManager->persist($otherPart);
|
||||
$entityManager->flush();
|
||||
|
||||
$client->request('GET', "/en/part/{$targetPart->getId()}/merge/{$otherPart->getId()}");
|
||||
|
||||
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||
$this->assertSelectorExists('form[name="part_base"]');
|
||||
|
||||
// Clean up
|
||||
$entityManager->remove($targetPart);
|
||||
$entityManager->remove($otherPart);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public function testAccessControlForUnauthorizedUser(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$this->loginAsUser($client, 'noread');
|
||||
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$partRepository = $entityManager->getRepository(Part::class);
|
||||
$part = $partRepository->find(1);
|
||||
|
||||
if (!$part) {
|
||||
$this->markTestSkipped('Test part with ID 1 not found in fixtures');
|
||||
}
|
||||
|
||||
$client->request('GET', '/en/part/' . $part->getId());
|
||||
|
||||
// Should either be forbidden or redirected to error page
|
||||
$this->assertTrue(
|
||||
$client->getResponse()->getStatusCode() === Response::HTTP_FORBIDDEN ||
|
||||
$client->getResponse()->isRedirect()
|
||||
);
|
||||
}
|
||||
|
||||
private function loginAsUser($client, string $username): void
|
||||
{
|
||||
$entityManager = $client->getContainer()->get('doctrine')->getManager();
|
||||
$userRepository = $entityManager->getRepository(User::class);
|
||||
$user = $userRepository->findOneBy(['name' => $username]);
|
||||
|
||||
if (!$user) {
|
||||
$this->markTestSkipped("User {$username} not found");
|
||||
}
|
||||
|
||||
$client->loginUser($user);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BulkImportJobStatusConstraintTest extends TestCase
|
||||
{
|
||||
private BulkImportJobStatusConstraint $constraint;
|
||||
private QueryBuilder $queryBuilder;
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->constraint = new BulkImportJobStatusConstraint();
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->queryBuilder = $this->createMock(QueryBuilder::class);
|
||||
|
||||
$this->queryBuilder->method('getEntityManager')
|
||||
->willReturn($this->entityManager);
|
||||
}
|
||||
|
||||
public function testConstructor(): void
|
||||
{
|
||||
$this->assertEquals([], $this->constraint->getValues());
|
||||
$this->assertNull($this->constraint->getOperator());
|
||||
$this->assertFalse($this->constraint->isEnabled());
|
||||
}
|
||||
|
||||
public function testGetAndSetValues(): void
|
||||
{
|
||||
$values = ['pending', 'in_progress'];
|
||||
$this->constraint->setValues($values);
|
||||
|
||||
$this->assertEquals($values, $this->constraint->getValues());
|
||||
}
|
||||
|
||||
public function testGetAndSetOperator(): void
|
||||
{
|
||||
$operator = 'ANY';
|
||||
$this->constraint->setOperator($operator);
|
||||
|
||||
$this->assertEquals($operator, $this->constraint->getOperator());
|
||||
}
|
||||
|
||||
public function testIsEnabledWithEmptyValues(): void
|
||||
{
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$this->assertFalse($this->constraint->isEnabled());
|
||||
}
|
||||
|
||||
public function testIsEnabledWithNullOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending']);
|
||||
|
||||
$this->assertFalse($this->constraint->isEnabled());
|
||||
}
|
||||
|
||||
public function testIsEnabledWithValuesAndOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending']);
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$this->assertTrue($this->constraint->isEnabled());
|
||||
}
|
||||
|
||||
public function testApplyWithEmptyValues(): void
|
||||
{
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$this->queryBuilder->expects($this->never())
|
||||
->method('andWhere');
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testApplyWithNullOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending']);
|
||||
|
||||
$this->queryBuilder->expects($this->never())
|
||||
->method('andWhere');
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testApplyWithAnyOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending', 'in_progress']);
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$subQueryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$subQueryBuilder->method('select')->willReturnSelf();
|
||||
$subQueryBuilder->method('from')->willReturnSelf();
|
||||
$subQueryBuilder->method('join')->willReturnSelf();
|
||||
$subQueryBuilder->method('where')->willReturnSelf();
|
||||
$subQueryBuilder->method('andWhere')->willReturnSelf();
|
||||
$subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
|
||||
|
||||
$this->entityManager->method('createQueryBuilder')
|
||||
->willReturn($subQueryBuilder);
|
||||
|
||||
$this->queryBuilder->expects($this->once())
|
||||
->method('andWhere')
|
||||
->with('EXISTS (EXISTS_SUBQUERY_DQL)');
|
||||
|
||||
$this->queryBuilder->expects($this->once())
|
||||
->method('setParameter')
|
||||
->with('job_status_values', ['pending', 'in_progress']);
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testApplyWithNoneOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['completed']);
|
||||
$this->constraint->setOperator('NONE');
|
||||
|
||||
$subQueryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$subQueryBuilder->method('select')->willReturnSelf();
|
||||
$subQueryBuilder->method('from')->willReturnSelf();
|
||||
$subQueryBuilder->method('join')->willReturnSelf();
|
||||
$subQueryBuilder->method('where')->willReturnSelf();
|
||||
$subQueryBuilder->method('andWhere')->willReturnSelf();
|
||||
$subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
|
||||
|
||||
$this->entityManager->method('createQueryBuilder')
|
||||
->willReturn($subQueryBuilder);
|
||||
|
||||
$this->queryBuilder->expects($this->once())
|
||||
->method('andWhere')
|
||||
->with('NOT EXISTS (EXISTS_SUBQUERY_DQL)');
|
||||
|
||||
$this->queryBuilder->expects($this->once())
|
||||
->method('setParameter')
|
||||
->with('job_status_values', ['completed']);
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testApplyWithUnsupportedOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending']);
|
||||
$this->constraint->setOperator('UNKNOWN');
|
||||
|
||||
$subQueryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$subQueryBuilder->method('select')->willReturnSelf();
|
||||
$subQueryBuilder->method('from')->willReturnSelf();
|
||||
$subQueryBuilder->method('join')->willReturnSelf();
|
||||
$subQueryBuilder->method('where')->willReturnSelf();
|
||||
$subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
|
||||
|
||||
$this->entityManager->method('createQueryBuilder')
|
||||
->willReturn($subQueryBuilder);
|
||||
|
||||
// Should not call andWhere for unsupported operator
|
||||
$this->queryBuilder->expects($this->never())
|
||||
->method('andWhere');
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testSubqueryStructure(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending']);
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$subQueryBuilder = $this->createMock(QueryBuilder::class);
|
||||
|
||||
$subQueryBuilder->expects($this->once())
|
||||
->method('select')
|
||||
->with('1')
|
||||
->willReturnSelf();
|
||||
|
||||
$subQueryBuilder->expects($this->once())
|
||||
->method('from')
|
||||
->with(BulkInfoProviderImportJobPart::class, 'bip_status')
|
||||
->willReturnSelf();
|
||||
|
||||
$subQueryBuilder->expects($this->once())
|
||||
->method('join')
|
||||
->with('bip_status.job', 'job_status')
|
||||
->willReturnSelf();
|
||||
|
||||
$subQueryBuilder->expects($this->once())
|
||||
->method('where')
|
||||
->with('bip_status.part = part.id')
|
||||
->willReturnSelf();
|
||||
|
||||
$subQueryBuilder->expects($this->once())
|
||||
->method('andWhere')
|
||||
->with('job_status.status IN (:job_status_values)')
|
||||
->willReturnSelf();
|
||||
|
||||
$subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
|
||||
|
||||
$this->entityManager->method('createQueryBuilder')
|
||||
->willReturn($subQueryBuilder);
|
||||
|
||||
$this->queryBuilder->method('andWhere');
|
||||
$this->queryBuilder->method('setParameter');
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testValuesAndOperatorMutation(): void
|
||||
{
|
||||
// Test that values and operator can be changed after creation
|
||||
$this->constraint->setValues(['pending']);
|
||||
$this->constraint->setOperator('ANY');
|
||||
$this->assertTrue($this->constraint->isEnabled());
|
||||
|
||||
$this->constraint->setValues([]);
|
||||
$this->assertFalse($this->constraint->isEnabled());
|
||||
|
||||
$this->constraint->setValues(['completed']);
|
||||
$this->assertTrue($this->constraint->isEnabled());
|
||||
|
||||
$this->constraint->setOperator(null);
|
||||
$this->assertFalse($this->constraint->isEnabled());
|
||||
|
||||
$this->constraint->setOperator('NONE');
|
||||
$this->assertTrue($this->constraint->isEnabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BulkImportPartStatusConstraintTest extends TestCase
|
||||
{
|
||||
private BulkImportPartStatusConstraint $constraint;
|
||||
private QueryBuilder $queryBuilder;
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->constraint = new BulkImportPartStatusConstraint();
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->queryBuilder = $this->createMock(QueryBuilder::class);
|
||||
|
||||
$this->queryBuilder->method('getEntityManager')
|
||||
->willReturn($this->entityManager);
|
||||
}
|
||||
|
||||
public function testConstructor(): void
|
||||
{
|
||||
$this->assertEquals([], $this->constraint->getValues());
|
||||
$this->assertNull($this->constraint->getOperator());
|
||||
$this->assertFalse($this->constraint->isEnabled());
|
||||
}
|
||||
|
||||
public function testGetAndSetValues(): void
|
||||
{
|
||||
$values = ['pending', 'completed', 'skipped'];
|
||||
$this->constraint->setValues($values);
|
||||
|
||||
$this->assertEquals($values, $this->constraint->getValues());
|
||||
}
|
||||
|
||||
public function testGetAndSetOperator(): void
|
||||
{
|
||||
$operator = 'ANY';
|
||||
$this->constraint->setOperator($operator);
|
||||
|
||||
$this->assertEquals($operator, $this->constraint->getOperator());
|
||||
}
|
||||
|
||||
public function testIsEnabledWithEmptyValues(): void
|
||||
{
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$this->assertFalse($this->constraint->isEnabled());
|
||||
}
|
||||
|
||||
public function testIsEnabledWithNullOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending']);
|
||||
|
||||
$this->assertFalse($this->constraint->isEnabled());
|
||||
}
|
||||
|
||||
public function testIsEnabledWithValuesAndOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending']);
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$this->assertTrue($this->constraint->isEnabled());
|
||||
}
|
||||
|
||||
public function testApplyWithEmptyValues(): void
|
||||
{
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$this->queryBuilder->expects($this->never())
|
||||
->method('andWhere');
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testApplyWithNullOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending']);
|
||||
|
||||
$this->queryBuilder->expects($this->never())
|
||||
->method('andWhere');
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testApplyWithAnyOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending', 'completed']);
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$subQueryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$subQueryBuilder->method('select')->willReturnSelf();
|
||||
$subQueryBuilder->method('from')->willReturnSelf();
|
||||
$subQueryBuilder->method('where')->willReturnSelf();
|
||||
$subQueryBuilder->method('andWhere')->willReturnSelf();
|
||||
$subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
|
||||
|
||||
$this->entityManager->method('createQueryBuilder')
|
||||
->willReturn($subQueryBuilder);
|
||||
|
||||
$this->queryBuilder->expects($this->once())
|
||||
->method('andWhere')
|
||||
->with('EXISTS (EXISTS_SUBQUERY_DQL)');
|
||||
|
||||
$this->queryBuilder->expects($this->once())
|
||||
->method('setParameter')
|
||||
->with('part_status_values', ['pending', 'completed']);
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testApplyWithNoneOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['failed']);
|
||||
$this->constraint->setOperator('NONE');
|
||||
|
||||
$subQueryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$subQueryBuilder->method('select')->willReturnSelf();
|
||||
$subQueryBuilder->method('from')->willReturnSelf();
|
||||
$subQueryBuilder->method('where')->willReturnSelf();
|
||||
$subQueryBuilder->method('andWhere')->willReturnSelf();
|
||||
$subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
|
||||
|
||||
$this->entityManager->method('createQueryBuilder')
|
||||
->willReturn($subQueryBuilder);
|
||||
|
||||
$this->queryBuilder->expects($this->once())
|
||||
->method('andWhere')
|
||||
->with('NOT EXISTS (EXISTS_SUBQUERY_DQL)');
|
||||
|
||||
$this->queryBuilder->expects($this->once())
|
||||
->method('setParameter')
|
||||
->with('part_status_values', ['failed']);
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testApplyWithUnsupportedOperator(): void
|
||||
{
|
||||
$this->constraint->setValues(['pending']);
|
||||
$this->constraint->setOperator('UNKNOWN');
|
||||
|
||||
$subQueryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$subQueryBuilder->method('select')->willReturnSelf();
|
||||
$subQueryBuilder->method('from')->willReturnSelf();
|
||||
$subQueryBuilder->method('where')->willReturnSelf();
|
||||
$subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
|
||||
|
||||
$this->entityManager->method('createQueryBuilder')
|
||||
->willReturn($subQueryBuilder);
|
||||
|
||||
// Should not call andWhere for unsupported operator
|
||||
$this->queryBuilder->expects($this->never())
|
||||
->method('andWhere');
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testSubqueryStructure(): void
|
||||
{
|
||||
$this->constraint->setValues(['completed', 'skipped']);
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$subQueryBuilder = $this->createMock(QueryBuilder::class);
|
||||
|
||||
$subQueryBuilder->expects($this->once())
|
||||
->method('select')
|
||||
->with('1')
|
||||
->willReturnSelf();
|
||||
|
||||
$subQueryBuilder->expects($this->once())
|
||||
->method('from')
|
||||
->with(BulkInfoProviderImportJobPart::class, 'bip_part_status')
|
||||
->willReturnSelf();
|
||||
|
||||
$subQueryBuilder->expects($this->once())
|
||||
->method('where')
|
||||
->with('bip_part_status.part = part.id')
|
||||
->willReturnSelf();
|
||||
|
||||
$subQueryBuilder->expects($this->once())
|
||||
->method('andWhere')
|
||||
->with('bip_part_status.status IN (:part_status_values)')
|
||||
->willReturnSelf();
|
||||
|
||||
$subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
|
||||
|
||||
$this->entityManager->method('createQueryBuilder')
|
||||
->willReturn($subQueryBuilder);
|
||||
|
||||
$this->queryBuilder->method('andWhere');
|
||||
$this->queryBuilder->method('setParameter');
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testValuesAndOperatorMutation(): void
|
||||
{
|
||||
// Test that values and operator can be changed after creation
|
||||
$this->constraint->setValues(['pending']);
|
||||
$this->constraint->setOperator('ANY');
|
||||
$this->assertTrue($this->constraint->isEnabled());
|
||||
|
||||
$this->constraint->setValues([]);
|
||||
$this->assertFalse($this->constraint->isEnabled());
|
||||
|
||||
$this->constraint->setValues(['completed', 'skipped']);
|
||||
$this->assertTrue($this->constraint->isEnabled());
|
||||
|
||||
$this->constraint->setOperator(null);
|
||||
$this->assertFalse($this->constraint->isEnabled());
|
||||
|
||||
$this->constraint->setOperator('NONE');
|
||||
$this->assertTrue($this->constraint->isEnabled());
|
||||
}
|
||||
|
||||
public function testDifferentFromJobStatusConstraint(): void
|
||||
{
|
||||
// This constraint should work differently from BulkImportJobStatusConstraint
|
||||
// It queries the part status directly, not the job status
|
||||
$this->constraint->setValues(['pending']);
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$subQueryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$subQueryBuilder->method('select')->willReturnSelf();
|
||||
$subQueryBuilder->method('from')->willReturnSelf();
|
||||
$subQueryBuilder->method('where')->willReturnSelf();
|
||||
$subQueryBuilder->method('andWhere')->willReturnSelf();
|
||||
$subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
|
||||
|
||||
$this->entityManager->method('createQueryBuilder')
|
||||
->willReturn($subQueryBuilder);
|
||||
|
||||
// Should use different alias than job status constraint
|
||||
$subQueryBuilder->expects($this->once())
|
||||
->method('from')
|
||||
->with(BulkInfoProviderImportJobPart::class, 'bip_part_status');
|
||||
|
||||
// Should not join with job table like job status constraint does
|
||||
$subQueryBuilder->expects($this->never())
|
||||
->method('join');
|
||||
|
||||
$this->queryBuilder->method('andWhere');
|
||||
$this->queryBuilder->method('setParameter');
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
}
|
||||
|
||||
public function testMultipleStatusValues(): void
|
||||
{
|
||||
$statusValues = ['pending', 'completed', 'skipped', 'failed'];
|
||||
$this->constraint->setValues($statusValues);
|
||||
$this->constraint->setOperator('ANY');
|
||||
|
||||
$subQueryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$subQueryBuilder->method('select')->willReturnSelf();
|
||||
$subQueryBuilder->method('from')->willReturnSelf();
|
||||
$subQueryBuilder->method('where')->willReturnSelf();
|
||||
$subQueryBuilder->method('andWhere')->willReturnSelf();
|
||||
$subQueryBuilder->method('getDQL')->willReturn('EXISTS_SUBQUERY_DQL');
|
||||
|
||||
$this->entityManager->method('createQueryBuilder')
|
||||
->willReturn($subQueryBuilder);
|
||||
|
||||
$this->queryBuilder->expects($this->once())
|
||||
->method('setParameter')
|
||||
->with('part_status_values', $statusValues);
|
||||
|
||||
$this->constraint->apply($this->queryBuilder);
|
||||
|
||||
$this->assertEquals($statusValues, $this->constraint->getValues());
|
||||
}
|
||||
}
|
||||
71
tests/Entity/BulkImportJobStatusTest.php
Normal file
71
tests/Entity/BulkImportJobStatusTest.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\BulkImportJobStatus;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BulkImportJobStatusTest extends TestCase
|
||||
{
|
||||
public function testEnumValues(): void
|
||||
{
|
||||
$this->assertEquals('pending', BulkImportJobStatus::PENDING->value);
|
||||
$this->assertEquals('in_progress', BulkImportJobStatus::IN_PROGRESS->value);
|
||||
$this->assertEquals('completed', BulkImportJobStatus::COMPLETED->value);
|
||||
$this->assertEquals('stopped', BulkImportJobStatus::STOPPED->value);
|
||||
$this->assertEquals('failed', BulkImportJobStatus::FAILED->value);
|
||||
}
|
||||
|
||||
public function testEnumCases(): void
|
||||
{
|
||||
$cases = BulkImportJobStatus::cases();
|
||||
|
||||
$this->assertCount(5, $cases);
|
||||
$this->assertContains(BulkImportJobStatus::PENDING, $cases);
|
||||
$this->assertContains(BulkImportJobStatus::IN_PROGRESS, $cases);
|
||||
$this->assertContains(BulkImportJobStatus::COMPLETED, $cases);
|
||||
$this->assertContains(BulkImportJobStatus::STOPPED, $cases);
|
||||
$this->assertContains(BulkImportJobStatus::FAILED, $cases);
|
||||
}
|
||||
|
||||
public function testFromString(): void
|
||||
{
|
||||
$this->assertEquals(BulkImportJobStatus::PENDING, BulkImportJobStatus::from('pending'));
|
||||
$this->assertEquals(BulkImportJobStatus::IN_PROGRESS, BulkImportJobStatus::from('in_progress'));
|
||||
$this->assertEquals(BulkImportJobStatus::COMPLETED, BulkImportJobStatus::from('completed'));
|
||||
$this->assertEquals(BulkImportJobStatus::STOPPED, BulkImportJobStatus::from('stopped'));
|
||||
$this->assertEquals(BulkImportJobStatus::FAILED, BulkImportJobStatus::from('failed'));
|
||||
}
|
||||
|
||||
public function testTryFromInvalidValue(): void
|
||||
{
|
||||
$this->assertNull(BulkImportJobStatus::tryFrom('invalid'));
|
||||
$this->assertNull(BulkImportJobStatus::tryFrom(''));
|
||||
}
|
||||
|
||||
public function testFromInvalidValueThrowsException(): void
|
||||
{
|
||||
$this->expectException(\ValueError::class);
|
||||
BulkImportJobStatus::from('invalid');
|
||||
}
|
||||
}
|
||||
301
tests/Entity/BulkInfoProviderImportJobPartTest.php
Normal file
301
tests/Entity/BulkInfoProviderImportJobPartTest.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\BulkInfoProviderImportJob;
|
||||
use App\Entity\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\BulkImportPartStatus;
|
||||
use App\Entity\Parts\Part;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BulkInfoProviderImportJobPartTest extends TestCase
|
||||
{
|
||||
private BulkInfoProviderImportJob $job;
|
||||
private Part $part;
|
||||
private BulkInfoProviderImportJobPart $jobPart;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->job = $this->createMock(BulkInfoProviderImportJob::class);
|
||||
$this->part = $this->createMock(Part::class);
|
||||
|
||||
$this->jobPart = new BulkInfoProviderImportJobPart($this->job, $this->part);
|
||||
}
|
||||
|
||||
public function testConstructor(): void
|
||||
{
|
||||
$this->assertSame($this->job, $this->jobPart->getJob());
|
||||
$this->assertSame($this->part, $this->jobPart->getPart());
|
||||
$this->assertEquals(BulkImportPartStatus::PENDING, $this->jobPart->getStatus());
|
||||
$this->assertNull($this->jobPart->getReason());
|
||||
$this->assertNull($this->jobPart->getCompletedAt());
|
||||
}
|
||||
|
||||
public function testGetAndSetJob(): void
|
||||
{
|
||||
$newJob = $this->createMock(BulkInfoProviderImportJob::class);
|
||||
|
||||
$result = $this->jobPart->setJob($newJob);
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertSame($newJob, $this->jobPart->getJob());
|
||||
}
|
||||
|
||||
public function testGetAndSetPart(): void
|
||||
{
|
||||
$newPart = $this->createMock(Part::class);
|
||||
|
||||
$result = $this->jobPart->setPart($newPart);
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertSame($newPart, $this->jobPart->getPart());
|
||||
}
|
||||
|
||||
public function testGetAndSetStatus(): void
|
||||
{
|
||||
$result = $this->jobPart->setStatus(BulkImportPartStatus::COMPLETED);
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertEquals(BulkImportPartStatus::COMPLETED, $this->jobPart->getStatus());
|
||||
}
|
||||
|
||||
public function testGetAndSetReason(): void
|
||||
{
|
||||
$reason = 'Test reason';
|
||||
|
||||
$result = $this->jobPart->setReason($reason);
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertEquals($reason, $this->jobPart->getReason());
|
||||
}
|
||||
|
||||
public function testGetAndSetCompletedAt(): void
|
||||
{
|
||||
$completedAt = new \DateTimeImmutable();
|
||||
|
||||
$result = $this->jobPart->setCompletedAt($completedAt);
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertSame($completedAt, $this->jobPart->getCompletedAt());
|
||||
}
|
||||
|
||||
public function testMarkAsCompleted(): void
|
||||
{
|
||||
$beforeTime = new \DateTimeImmutable();
|
||||
|
||||
$result = $this->jobPart->markAsCompleted();
|
||||
|
||||
$afterTime = new \DateTimeImmutable();
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertEquals(BulkImportPartStatus::COMPLETED, $this->jobPart->getStatus());
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
|
||||
$this->assertGreaterThanOrEqual($beforeTime, $this->jobPart->getCompletedAt());
|
||||
$this->assertLessThanOrEqual($afterTime, $this->jobPart->getCompletedAt());
|
||||
}
|
||||
|
||||
public function testMarkAsSkipped(): void
|
||||
{
|
||||
$reason = 'Skipped for testing';
|
||||
$beforeTime = new \DateTimeImmutable();
|
||||
|
||||
$result = $this->jobPart->markAsSkipped($reason);
|
||||
|
||||
$afterTime = new \DateTimeImmutable();
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertEquals(BulkImportPartStatus::SKIPPED, $this->jobPart->getStatus());
|
||||
$this->assertEquals($reason, $this->jobPart->getReason());
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
|
||||
$this->assertGreaterThanOrEqual($beforeTime, $this->jobPart->getCompletedAt());
|
||||
$this->assertLessThanOrEqual($afterTime, $this->jobPart->getCompletedAt());
|
||||
}
|
||||
|
||||
public function testMarkAsSkippedWithoutReason(): void
|
||||
{
|
||||
$result = $this->jobPart->markAsSkipped();
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertEquals(BulkImportPartStatus::SKIPPED, $this->jobPart->getStatus());
|
||||
$this->assertEquals('', $this->jobPart->getReason());
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
|
||||
}
|
||||
|
||||
public function testMarkAsFailed(): void
|
||||
{
|
||||
$reason = 'Failed for testing';
|
||||
$beforeTime = new \DateTimeImmutable();
|
||||
|
||||
$result = $this->jobPart->markAsFailed($reason);
|
||||
|
||||
$afterTime = new \DateTimeImmutable();
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertEquals(BulkImportPartStatus::FAILED, $this->jobPart->getStatus());
|
||||
$this->assertEquals($reason, $this->jobPart->getReason());
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
|
||||
$this->assertGreaterThanOrEqual($beforeTime, $this->jobPart->getCompletedAt());
|
||||
$this->assertLessThanOrEqual($afterTime, $this->jobPart->getCompletedAt());
|
||||
}
|
||||
|
||||
public function testMarkAsFailedWithoutReason(): void
|
||||
{
|
||||
$result = $this->jobPart->markAsFailed();
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertEquals(BulkImportPartStatus::FAILED, $this->jobPart->getStatus());
|
||||
$this->assertEquals('', $this->jobPart->getReason());
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
|
||||
}
|
||||
|
||||
public function testMarkAsPending(): void
|
||||
{
|
||||
// First mark as completed to have something to reset
|
||||
$this->jobPart->markAsCompleted();
|
||||
|
||||
$result = $this->jobPart->markAsPending();
|
||||
|
||||
$this->assertSame($this->jobPart, $result);
|
||||
$this->assertEquals(BulkImportPartStatus::PENDING, $this->jobPart->getStatus());
|
||||
$this->assertNull($this->jobPart->getReason());
|
||||
$this->assertNull($this->jobPart->getCompletedAt());
|
||||
}
|
||||
|
||||
public function testIsPending(): void
|
||||
{
|
||||
$this->assertTrue($this->jobPart->isPending());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::COMPLETED);
|
||||
$this->assertFalse($this->jobPart->isPending());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::SKIPPED);
|
||||
$this->assertFalse($this->jobPart->isPending());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::FAILED);
|
||||
$this->assertFalse($this->jobPart->isPending());
|
||||
}
|
||||
|
||||
public function testIsCompleted(): void
|
||||
{
|
||||
$this->assertFalse($this->jobPart->isCompleted());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::COMPLETED);
|
||||
$this->assertTrue($this->jobPart->isCompleted());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::SKIPPED);
|
||||
$this->assertFalse($this->jobPart->isCompleted());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::FAILED);
|
||||
$this->assertFalse($this->jobPart->isCompleted());
|
||||
}
|
||||
|
||||
public function testIsSkipped(): void
|
||||
{
|
||||
$this->assertFalse($this->jobPart->isSkipped());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::SKIPPED);
|
||||
$this->assertTrue($this->jobPart->isSkipped());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::COMPLETED);
|
||||
$this->assertFalse($this->jobPart->isSkipped());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::FAILED);
|
||||
$this->assertFalse($this->jobPart->isSkipped());
|
||||
}
|
||||
|
||||
public function testIsFailed(): void
|
||||
{
|
||||
$this->assertFalse($this->jobPart->isFailed());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::FAILED);
|
||||
$this->assertTrue($this->jobPart->isFailed());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::COMPLETED);
|
||||
$this->assertFalse($this->jobPart->isFailed());
|
||||
|
||||
$this->jobPart->setStatus(BulkImportPartStatus::SKIPPED);
|
||||
$this->assertFalse($this->jobPart->isFailed());
|
||||
}
|
||||
|
||||
public function testBulkImportPartStatusEnum(): void
|
||||
{
|
||||
$this->assertEquals('pending', BulkImportPartStatus::PENDING->value);
|
||||
$this->assertEquals('completed', BulkImportPartStatus::COMPLETED->value);
|
||||
$this->assertEquals('skipped', BulkImportPartStatus::SKIPPED->value);
|
||||
$this->assertEquals('failed', BulkImportPartStatus::FAILED->value);
|
||||
}
|
||||
|
||||
public function testStatusTransitions(): void
|
||||
{
|
||||
// Test pending -> completed
|
||||
$this->assertTrue($this->jobPart->isPending());
|
||||
$this->jobPart->markAsCompleted();
|
||||
$this->assertTrue($this->jobPart->isCompleted());
|
||||
|
||||
// Test completed -> pending
|
||||
$this->jobPart->markAsPending();
|
||||
$this->assertTrue($this->jobPart->isPending());
|
||||
|
||||
// Test pending -> skipped
|
||||
$this->jobPart->markAsSkipped('Test reason');
|
||||
$this->assertTrue($this->jobPart->isSkipped());
|
||||
|
||||
// Test skipped -> pending
|
||||
$this->jobPart->markAsPending();
|
||||
$this->assertTrue($this->jobPart->isPending());
|
||||
|
||||
// Test pending -> failed
|
||||
$this->jobPart->markAsFailed('Test error');
|
||||
$this->assertTrue($this->jobPart->isFailed());
|
||||
|
||||
// Test failed -> pending
|
||||
$this->jobPart->markAsPending();
|
||||
$this->assertTrue($this->jobPart->isPending());
|
||||
}
|
||||
|
||||
public function testReasonAndCompletedAtConsistency(): void
|
||||
{
|
||||
// Initially no reason or completion time
|
||||
$this->assertNull($this->jobPart->getReason());
|
||||
$this->assertNull($this->jobPart->getCompletedAt());
|
||||
|
||||
// After marking as skipped, should have reason and completion time
|
||||
$this->jobPart->markAsSkipped('Skipped reason');
|
||||
$this->assertEquals('Skipped reason', $this->jobPart->getReason());
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
|
||||
|
||||
// After marking as pending, reason and completion time should be cleared
|
||||
$this->jobPart->markAsPending();
|
||||
$this->assertNull($this->jobPart->getReason());
|
||||
$this->assertNull($this->jobPart->getCompletedAt());
|
||||
|
||||
// After marking as failed, should have reason and completion time
|
||||
$this->jobPart->markAsFailed('Failed reason');
|
||||
$this->assertEquals('Failed reason', $this->jobPart->getReason());
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
|
||||
|
||||
// After marking as completed, should have completion time (reason may remain from previous state)
|
||||
$this->jobPart->markAsCompleted();
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $this->jobPart->getCompletedAt());
|
||||
}
|
||||
}
|
||||
360
tests/Entity/BulkInfoProviderImportJobTest.php
Normal file
360
tests/Entity/BulkInfoProviderImportJobTest.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\BulkInfoProviderImportJob;
|
||||
use App\Entity\BulkImportJobStatus;
|
||||
use App\Entity\UserSystem\User;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BulkInfoProviderImportJobTest extends TestCase
|
||||
{
|
||||
private BulkInfoProviderImportJob $job;
|
||||
private User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->user = new User();
|
||||
$this->user->setName('test_user');
|
||||
|
||||
$this->job = new BulkInfoProviderImportJob();
|
||||
$this->job->setCreatedBy($this->user);
|
||||
}
|
||||
|
||||
private function createMockPart(int $id): \App\Entity\Parts\Part
|
||||
{
|
||||
$part = $this->createMock(\App\Entity\Parts\Part::class);
|
||||
$part->method('getId')->willReturn($id);
|
||||
$part->method('getName')->willReturn("Test Part {$id}");
|
||||
return $part;
|
||||
}
|
||||
|
||||
public function testConstruct(): void
|
||||
{
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $job->getCreatedAt());
|
||||
$this->assertEquals(BulkImportJobStatus::PENDING, $job->getStatus());
|
||||
$this->assertEmpty($job->getPartIds());
|
||||
$this->assertEmpty($job->getFieldMappings());
|
||||
$this->assertEmpty($job->getSearchResults());
|
||||
$this->assertEmpty($job->getProgress());
|
||||
$this->assertNull($job->getCompletedAt());
|
||||
$this->assertFalse($job->isPrefetchDetails());
|
||||
}
|
||||
|
||||
public function testBasicGettersSetters(): void
|
||||
{
|
||||
$this->job->setName('Test Job');
|
||||
$this->assertEquals('Test Job', $this->job->getName());
|
||||
|
||||
// Test with actual parts - this is what actually works
|
||||
$parts = [$this->createMockPart(1), $this->createMockPart(2), $this->createMockPart(3)];
|
||||
foreach ($parts as $part) {
|
||||
$this->job->addPart($part);
|
||||
}
|
||||
$this->assertEquals([1, 2, 3], $this->job->getPartIds());
|
||||
|
||||
$fieldMappings = ['field1' => 'provider1', 'field2' => 'provider2'];
|
||||
$this->job->setFieldMappings($fieldMappings);
|
||||
$this->assertEquals($fieldMappings, $this->job->getFieldMappings());
|
||||
|
||||
$searchResults = [
|
||||
1 => ['search_results' => [['name' => 'Part 1']]],
|
||||
2 => ['search_results' => [['name' => 'Part 2'], ['name' => 'Part 2 Alt']]]
|
||||
];
|
||||
$this->job->setSearchResults($searchResults);
|
||||
$this->assertEquals($searchResults, $this->job->getSearchResults());
|
||||
|
||||
$this->job->setPrefetchDetails(true);
|
||||
$this->assertTrue($this->job->isPrefetchDetails());
|
||||
|
||||
$this->assertEquals($this->user, $this->job->getCreatedBy());
|
||||
}
|
||||
|
||||
public function testStatusTransitions(): void
|
||||
{
|
||||
$this->assertTrue($this->job->isPending());
|
||||
$this->assertFalse($this->job->isInProgress());
|
||||
$this->assertFalse($this->job->isCompleted());
|
||||
$this->assertFalse($this->job->isFailed());
|
||||
$this->assertFalse($this->job->isStopped());
|
||||
|
||||
$this->job->markAsInProgress();
|
||||
$this->assertEquals(BulkImportJobStatus::IN_PROGRESS, $this->job->getStatus());
|
||||
$this->assertTrue($this->job->isInProgress());
|
||||
$this->assertFalse($this->job->isPending());
|
||||
|
||||
$this->job->markAsCompleted();
|
||||
$this->assertEquals(BulkImportJobStatus::COMPLETED, $this->job->getStatus());
|
||||
$this->assertTrue($this->job->isCompleted());
|
||||
$this->assertNotNull($this->job->getCompletedAt());
|
||||
|
||||
$job2 = new BulkInfoProviderImportJob();
|
||||
$job2->markAsFailed();
|
||||
$this->assertEquals(BulkImportJobStatus::FAILED, $job2->getStatus());
|
||||
$this->assertTrue($job2->isFailed());
|
||||
$this->assertNotNull($job2->getCompletedAt());
|
||||
|
||||
$job3 = new BulkInfoProviderImportJob();
|
||||
$job3->markAsStopped();
|
||||
$this->assertEquals(BulkImportJobStatus::STOPPED, $job3->getStatus());
|
||||
$this->assertTrue($job3->isStopped());
|
||||
$this->assertNotNull($job3->getCompletedAt());
|
||||
}
|
||||
|
||||
public function testCanBeStopped(): void
|
||||
{
|
||||
$this->assertTrue($this->job->canBeStopped());
|
||||
|
||||
$this->job->markAsInProgress();
|
||||
$this->assertTrue($this->job->canBeStopped());
|
||||
|
||||
$this->job->markAsCompleted();
|
||||
$this->assertFalse($this->job->canBeStopped());
|
||||
|
||||
$this->job->setStatus(BulkImportJobStatus::FAILED);
|
||||
$this->assertFalse($this->job->canBeStopped());
|
||||
|
||||
$this->job->setStatus(BulkImportJobStatus::STOPPED);
|
||||
$this->assertFalse($this->job->canBeStopped());
|
||||
}
|
||||
|
||||
public function testPartCount(): void
|
||||
{
|
||||
$this->assertEquals(0, $this->job->getPartCount());
|
||||
|
||||
// Test with actual parts - setPartIds doesn't actually add parts
|
||||
$parts = [
|
||||
$this->createMockPart(1),
|
||||
$this->createMockPart(2),
|
||||
$this->createMockPart(3),
|
||||
$this->createMockPart(4),
|
||||
$this->createMockPart(5)
|
||||
];
|
||||
foreach ($parts as $part) {
|
||||
$this->job->addPart($part);
|
||||
}
|
||||
$this->assertEquals(5, $this->job->getPartCount());
|
||||
}
|
||||
|
||||
public function testResultCount(): void
|
||||
{
|
||||
$this->assertEquals(0, $this->job->getResultCount());
|
||||
|
||||
$searchResults = [
|
||||
1 => ['search_results' => [['name' => 'Part 1']]],
|
||||
2 => ['search_results' => [['name' => 'Part 2'], ['name' => 'Part 2 Alt']]],
|
||||
3 => ['search_results' => []]
|
||||
];
|
||||
$this->job->setSearchResults($searchResults);
|
||||
$this->assertEquals(3, $this->job->getResultCount());
|
||||
}
|
||||
|
||||
public function testPartProgressTracking(): void
|
||||
{
|
||||
// Test with actual parts - setPartIds doesn't actually add parts
|
||||
$parts = [
|
||||
$this->createMockPart(1),
|
||||
$this->createMockPart(2),
|
||||
$this->createMockPart(3),
|
||||
$this->createMockPart(4)
|
||||
];
|
||||
foreach ($parts as $part) {
|
||||
$this->job->addPart($part);
|
||||
}
|
||||
|
||||
$this->assertFalse($this->job->isPartCompleted(1));
|
||||
$this->assertFalse($this->job->isPartSkipped(1));
|
||||
|
||||
$this->job->markPartAsCompleted(1);
|
||||
$this->assertTrue($this->job->isPartCompleted(1));
|
||||
$this->assertFalse($this->job->isPartSkipped(1));
|
||||
|
||||
$this->job->markPartAsSkipped(2, 'Not found');
|
||||
$this->assertFalse($this->job->isPartCompleted(2));
|
||||
$this->assertTrue($this->job->isPartSkipped(2));
|
||||
|
||||
$this->job->markPartAsPending(1);
|
||||
$this->assertFalse($this->job->isPartCompleted(1));
|
||||
$this->assertFalse($this->job->isPartSkipped(1));
|
||||
}
|
||||
|
||||
public function testProgressCounts(): void
|
||||
{
|
||||
// Test with actual parts - setPartIds doesn't actually add parts
|
||||
$parts = [
|
||||
$this->createMockPart(1),
|
||||
$this->createMockPart(2),
|
||||
$this->createMockPart(3),
|
||||
$this->createMockPart(4),
|
||||
$this->createMockPart(5)
|
||||
];
|
||||
foreach ($parts as $part) {
|
||||
$this->job->addPart($part);
|
||||
}
|
||||
|
||||
$this->assertEquals(0, $this->job->getCompletedPartsCount());
|
||||
$this->assertEquals(0, $this->job->getSkippedPartsCount());
|
||||
|
||||
$this->job->markPartAsCompleted(1);
|
||||
$this->job->markPartAsCompleted(2);
|
||||
$this->job->markPartAsSkipped(3, 'Error');
|
||||
|
||||
$this->assertEquals(2, $this->job->getCompletedPartsCount());
|
||||
$this->assertEquals(1, $this->job->getSkippedPartsCount());
|
||||
}
|
||||
|
||||
public function testProgressPercentage(): void
|
||||
{
|
||||
$emptyJob = new BulkInfoProviderImportJob();
|
||||
$this->assertEquals(100.0, $emptyJob->getProgressPercentage());
|
||||
|
||||
// Test with actual parts - setPartIds doesn't actually add parts
|
||||
$parts = [
|
||||
$this->createMockPart(1),
|
||||
$this->createMockPart(2),
|
||||
$this->createMockPart(3),
|
||||
$this->createMockPart(4),
|
||||
$this->createMockPart(5)
|
||||
];
|
||||
foreach ($parts as $part) {
|
||||
$this->job->addPart($part);
|
||||
}
|
||||
|
||||
$this->assertEquals(0.0, $this->job->getProgressPercentage());
|
||||
|
||||
$this->job->markPartAsCompleted(1);
|
||||
$this->job->markPartAsCompleted(2);
|
||||
$this->assertEquals(40.0, $this->job->getProgressPercentage());
|
||||
|
||||
$this->job->markPartAsSkipped(3, 'Error');
|
||||
$this->assertEquals(60.0, $this->job->getProgressPercentage());
|
||||
|
||||
$this->job->markPartAsCompleted(4);
|
||||
$this->job->markPartAsCompleted(5);
|
||||
$this->assertEquals(100.0, $this->job->getProgressPercentage());
|
||||
}
|
||||
|
||||
public function testIsAllPartsCompleted(): void
|
||||
{
|
||||
$emptyJob = new BulkInfoProviderImportJob();
|
||||
$this->assertTrue($emptyJob->isAllPartsCompleted());
|
||||
|
||||
// Test with actual parts - setPartIds doesn't actually add parts
|
||||
$parts = [
|
||||
$this->createMockPart(1),
|
||||
$this->createMockPart(2),
|
||||
$this->createMockPart(3)
|
||||
];
|
||||
foreach ($parts as $part) {
|
||||
$this->job->addPart($part);
|
||||
}
|
||||
|
||||
$this->assertFalse($this->job->isAllPartsCompleted());
|
||||
|
||||
$this->job->markPartAsCompleted(1);
|
||||
$this->assertFalse($this->job->isAllPartsCompleted());
|
||||
|
||||
$this->job->markPartAsCompleted(2);
|
||||
$this->job->markPartAsSkipped(3, 'Error');
|
||||
$this->assertTrue($this->job->isAllPartsCompleted());
|
||||
}
|
||||
|
||||
public function testDisplayNameMethods(): void
|
||||
{
|
||||
// Test with actual parts - setPartIds doesn't actually add parts
|
||||
$parts = [
|
||||
$this->createMockPart(1),
|
||||
$this->createMockPart(2),
|
||||
$this->createMockPart(3)
|
||||
];
|
||||
foreach ($parts as $part) {
|
||||
$this->job->addPart($part);
|
||||
}
|
||||
|
||||
$this->assertEquals('info_providers.bulk_import.job_name_template', $this->job->getDisplayNameKey());
|
||||
$this->assertEquals(['%count%' => 3], $this->job->getDisplayNameParams());
|
||||
}
|
||||
|
||||
public function testFormattedTimestamp(): void
|
||||
{
|
||||
$timestampRegex = '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/';
|
||||
$this->assertMatchesRegularExpression($timestampRegex, $this->job->getFormattedTimestamp());
|
||||
}
|
||||
|
||||
public function testProgressDataStructure(): void
|
||||
{
|
||||
$parts = [
|
||||
$this->createMockPart(1),
|
||||
$this->createMockPart(2),
|
||||
$this->createMockPart(3)
|
||||
];
|
||||
foreach ($parts as $part) {
|
||||
$this->job->addPart($part);
|
||||
}
|
||||
|
||||
$this->job->markPartAsCompleted(1);
|
||||
$this->job->markPartAsSkipped(2, 'Test reason');
|
||||
|
||||
$progress = $this->job->getProgress();
|
||||
|
||||
// The progress array should have keys for all part IDs, even if not completed/skipped
|
||||
$this->assertArrayHasKey(1, $progress, 'Progress should contain key for part 1');
|
||||
$this->assertArrayHasKey(2, $progress, 'Progress should contain key for part 2');
|
||||
$this->assertArrayHasKey(3, $progress, 'Progress should contain key for part 3');
|
||||
|
||||
// Part 1: completed
|
||||
$this->assertEquals('completed', $progress[1]['status']);
|
||||
$this->assertArrayHasKey('completed_at', $progress[1]);
|
||||
$this->assertArrayNotHasKey('reason', $progress[1]);
|
||||
|
||||
// Part 2: skipped
|
||||
$this->assertEquals('skipped', $progress[2]['status']);
|
||||
$this->assertEquals('Test reason', $progress[2]['reason']);
|
||||
$this->assertArrayHasKey('completed_at', $progress[2]);
|
||||
|
||||
// Part 3: should be present but not completed/skipped
|
||||
$this->assertEquals('pending', $progress[3]['status']);
|
||||
$this->assertArrayNotHasKey('completed_at', $progress[3]);
|
||||
$this->assertArrayNotHasKey('reason', $progress[3]);
|
||||
}
|
||||
|
||||
public function testCompletedAtTimestamp(): void
|
||||
{
|
||||
$this->assertNull($this->job->getCompletedAt());
|
||||
|
||||
$beforeCompletion = new \DateTimeImmutable();
|
||||
$this->job->markAsCompleted();
|
||||
$afterCompletion = new \DateTimeImmutable();
|
||||
|
||||
$completedAt = $this->job->getCompletedAt();
|
||||
$this->assertNotNull($completedAt);
|
||||
$this->assertGreaterThanOrEqual($beforeCompletion, $completedAt);
|
||||
$this->assertLessThanOrEqual($afterCompletion, $completedAt);
|
||||
|
||||
$customTime = new \DateTimeImmutable('2023-01-01 12:00:00');
|
||||
$this->job->setCompletedAt($customTime);
|
||||
$this->assertEquals($customTime, $this->job->getCompletedAt());
|
||||
}
|
||||
}
|
||||
68
tests/Form/InfoProviderSystem/GlobalFieldMappingTypeTest.php
Normal file
68
tests/Form/InfoProviderSystem/GlobalFieldMappingTypeTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\InfoProviderSystem;
|
||||
|
||||
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Form\FormFactoryInterface;
|
||||
|
||||
/**
|
||||
* @group slow
|
||||
* @group DB
|
||||
*/
|
||||
class GlobalFieldMappingTypeTest extends KernelTestCase
|
||||
{
|
||||
private FormFactoryInterface $formFactory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->formFactory = static::getContainer()->get(FormFactoryInterface::class);
|
||||
}
|
||||
|
||||
public function testFormCreation(): void
|
||||
{
|
||||
$form = $this->formFactory->create(GlobalFieldMappingType::class, null, [
|
||||
'field_choices' => [
|
||||
'MPN' => 'mpn',
|
||||
'Name' => 'name'
|
||||
],
|
||||
'csrf_protection' => false
|
||||
]);
|
||||
|
||||
$this->assertTrue($form->has('field_mappings'));
|
||||
$this->assertTrue($form->has('prefetch_details'));
|
||||
$this->assertTrue($form->has('submit'));
|
||||
}
|
||||
|
||||
public function testFormOptions(): void
|
||||
{
|
||||
$form = $this->formFactory->create(GlobalFieldMappingType::class, null, [
|
||||
'field_choices' => [],
|
||||
'csrf_protection' => false
|
||||
]);
|
||||
|
||||
$view = $form->createView();
|
||||
$this->assertFalse($view['prefetch_details']->vars['required']);
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,8 @@ class LogEntryRepositoryTest extends KernelTestCase
|
||||
$this->assertCount(2, $logs);
|
||||
|
||||
//The first one must be newer than the second one
|
||||
$this->assertGreaterThanOrEqual($logs[0]->getTimestamp(), $logs[1]->getTimestamp());
|
||||
$this->assertGreaterThanOrEqual($logs[1]->getTimestamp(), $logs[0]->getTimestamp());
|
||||
$this->assertGreaterThanOrEqual($logs[1]->getID(), $logs[0]->getID());
|
||||
}
|
||||
|
||||
public function testGetElementExistedAtTimestamp(): void
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace App\Tests\Services;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Entity\BulkInfoProviderImportJob;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Exceptions\EntityNotSupportedException;
|
||||
@@ -50,16 +51,18 @@ class ElementTypeNameGeneratorTest extends WebTestCase
|
||||
//We only test in english
|
||||
$this->assertSame('Part', $this->service->getLocalizedTypeLabel(new Part()));
|
||||
$this->assertSame('Category', $this->service->getLocalizedTypeLabel(new Category()));
|
||||
$this->assertSame('Bulk Info Provider Import', $this->service->getLocalizedTypeLabel(new BulkInfoProviderImportJob()));
|
||||
|
||||
//Test inheritance
|
||||
$this->assertSame('Attachment', $this->service->getLocalizedTypeLabel(new PartAttachment()));
|
||||
|
||||
//Test for class name
|
||||
$this->assertSame('Part', $this->service->getLocalizedTypeLabel(Part::class));
|
||||
$this->assertSame('Bulk Info Provider Import', $this->service->getLocalizedTypeLabel(BulkInfoProviderImportJob::class));
|
||||
|
||||
//Test exception for unknpwn type
|
||||
$this->expectException(EntityNotSupportedException::class);
|
||||
$this->service->getLocalizedTypeLabel(new class() extends AbstractDBElement {
|
||||
$this->service->getLocalizedTypeLabel(new class () extends AbstractDBElement {
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +77,7 @@ class ElementTypeNameGeneratorTest extends WebTestCase
|
||||
|
||||
//Test exception
|
||||
$this->expectException(EntityNotSupportedException::class);
|
||||
$this->service->getTypeNameCombination(new class() extends AbstractNamedDBElement {
|
||||
$this->service->getTypeNameCombination(new class () extends AbstractNamedDBElement {
|
||||
public function getIDString(): string
|
||||
{
|
||||
return 'Stub';
|
||||
|
||||
@@ -26,6 +26,7 @@ use App\Entity\Parts\Category;
|
||||
use App\Services\ImportExportSystem\EntityExporter;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class EntityExporterTest extends WebTestCase
|
||||
{
|
||||
@@ -76,7 +77,40 @@ class EntityExporterTest extends WebTestCase
|
||||
|
||||
$this->assertSame('application/json', $response->headers->get('Content-Type'));
|
||||
$this->assertNotEmpty($response->headers->get('Content-Disposition'));
|
||||
}
|
||||
|
||||
public function testExportToExcel(): void
|
||||
{
|
||||
$entities = $this->getEntities();
|
||||
|
||||
$xlsxData = $this->service->exportEntities($entities, ['format' => 'xlsx', 'level' => 'simple']);
|
||||
$this->assertNotEmpty($xlsxData);
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test_export') . '.xlsx';
|
||||
file_put_contents($tempFile, $xlsxData);
|
||||
|
||||
$spreadsheet = IOFactory::load($tempFile);
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$this->assertSame('name', $worksheet->getCell('A1')->getValue());
|
||||
$this->assertSame('full_name', $worksheet->getCell('B1')->getValue());
|
||||
|
||||
$this->assertSame('Enitity 1', $worksheet->getCell('A2')->getValue());
|
||||
$this->assertSame('Enitity 1', $worksheet->getCell('B2')->getValue());
|
||||
|
||||
unlink($tempFile);
|
||||
}
|
||||
|
||||
public function testExportExcelFromRequest(): void
|
||||
{
|
||||
$entities = $this->getEntities();
|
||||
|
||||
$request = new Request();
|
||||
$request->request->set('format', 'xlsx');
|
||||
$request->request->set('level', 'simple');
|
||||
$response = $this->service->exportEntityFromRequest($entities, $request);
|
||||
|
||||
$this->assertSame('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
|
||||
$this->assertStringContainsString('export_Category_simple.xlsx', $response->headers->get('Content-Disposition'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
#[Group('DB')]
|
||||
class EntityImporterTest extends WebTestCase
|
||||
@@ -207,6 +210,10 @@ EOT;
|
||||
yield ['json', 'json'];
|
||||
yield ['yaml', 'yml'];
|
||||
yield ['yaml', 'YAML'];
|
||||
yield ['xlsx', 'xlsx'];
|
||||
yield ['xlsx', 'XLSX'];
|
||||
yield ['xls', 'xls'];
|
||||
yield ['xls', 'XLS'];
|
||||
}
|
||||
|
||||
#[DataProvider('formatDataProvider')]
|
||||
@@ -342,4 +349,41 @@ EOT;
|
||||
$this->assertSame($category, $results[0]->getCategory());
|
||||
$this->assertSame('test,test2', $results[0]->getTags());
|
||||
}
|
||||
|
||||
public function testImportExcelFileProjects(): void
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
$worksheet->setCellValue('A1', 'name');
|
||||
$worksheet->setCellValue('B1', 'comment');
|
||||
$worksheet->setCellValue('A2', 'Test Excel 1');
|
||||
$worksheet->setCellValue('B2', 'Test Excel 1 notes');
|
||||
$worksheet->setCellValue('A3', 'Test Excel 2');
|
||||
$worksheet->setCellValue('B3', 'Test Excel 2 notes');
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test_excel') . '.xlsx';
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save($tempFile);
|
||||
|
||||
$file = new File($tempFile);
|
||||
|
||||
$errors = [];
|
||||
$results = $this->service->importFile($file, [
|
||||
'class' => Project::class,
|
||||
'format' => 'xlsx',
|
||||
'csv_delimiter' => ';',
|
||||
], $errors);
|
||||
|
||||
$this->assertCount(2, $results);
|
||||
$this->assertEmpty($errors);
|
||||
$this->assertContainsOnlyInstancesOf(Project::class, $results);
|
||||
|
||||
$this->assertSame('Test Excel 1', $results[0]->getName());
|
||||
$this->assertSame('Test Excel 1 notes', $results[0]->getComment());
|
||||
$this->assertSame('Test Excel 2', $results[1]->getName());
|
||||
$this->assertSame('Test Excel 2 notes', $results[1]->getComment());
|
||||
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
540
tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php
Normal file
540
tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php
Normal file
@@ -0,0 +1,540 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
* Copyright (C) 2024 Nexrem (https://github.com/meganukebmp)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\Providers\LCSCProvider;
|
||||
use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
|
||||
use App\Settings\InfoProviderSystem\LCSCSettings;
|
||||
use App\Tests\SettingsTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class LCSCProviderTest extends TestCase
|
||||
{
|
||||
private LCSCSettings $settings;
|
||||
private LCSCProvider $provider;
|
||||
private MockHttpClient $httpClient;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->httpClient = new MockHttpClient();
|
||||
$this->settings = SettingsTestHelper::createSettingsDummy(LCSCSettings::class);
|
||||
$this->settings->currency = 'USD';
|
||||
$this->settings->enabled = true;
|
||||
$this->provider = new LCSCProvider($this->httpClient, $this->settings);
|
||||
}
|
||||
|
||||
public function testGetProviderInfo(): void
|
||||
{
|
||||
$info = $this->provider->getProviderInfo();
|
||||
|
||||
$this->assertIsArray($info);
|
||||
$this->assertArrayHasKey('name', $info);
|
||||
$this->assertArrayHasKey('description', $info);
|
||||
$this->assertArrayHasKey('url', $info);
|
||||
$this->assertArrayHasKey('disabled_help', $info);
|
||||
$this->assertEquals('LCSC', $info['name']);
|
||||
$this->assertEquals('https://www.lcsc.com/', $info['url']);
|
||||
}
|
||||
|
||||
public function testGetProviderKey(): void
|
||||
{
|
||||
$this->assertEquals('lcsc', $this->provider->getProviderKey());
|
||||
}
|
||||
|
||||
public function testIsActiveWhenEnabled(): void
|
||||
{
|
||||
//Ensure that the settings are enabled
|
||||
$this->settings->enabled = true;
|
||||
$this->assertTrue($this->provider->isActive());
|
||||
}
|
||||
|
||||
public function testIsActiveWhenDisabled(): void
|
||||
{
|
||||
//Ensure that the settings are disabled
|
||||
$this->settings->enabled = false;
|
||||
$this->assertFalse($this->provider->isActive());
|
||||
}
|
||||
|
||||
public function testGetCapabilities(): void
|
||||
{
|
||||
$capabilities = $this->provider->getCapabilities();
|
||||
|
||||
$this->assertIsArray($capabilities);
|
||||
$this->assertContains(ProviderCapabilities::BASIC, $capabilities);
|
||||
$this->assertContains(ProviderCapabilities::PICTURE, $capabilities);
|
||||
$this->assertContains(ProviderCapabilities::DATASHEET, $capabilities);
|
||||
$this->assertContains(ProviderCapabilities::PRICE, $capabilities);
|
||||
$this->assertContains(ProviderCapabilities::FOOTPRINT, $capabilities);
|
||||
}
|
||||
|
||||
public function testSearchByKeywordWithCCode(): void
|
||||
{
|
||||
$mockResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productCode' => 'C123456',
|
||||
'productModel' => 'Test Component',
|
||||
'productIntroEn' => 'Test description',
|
||||
'brandNameEn' => 'Test Manufacturer',
|
||||
'encapStandard' => '0603',
|
||||
'productImageUrl' => 'https://example.com/image.jpg',
|
||||
'productImages' => ['https://example.com/image1.jpg'],
|
||||
'productPriceList' => [
|
||||
['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$']
|
||||
],
|
||||
'paramVOList' => [
|
||||
['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΩ']
|
||||
],
|
||||
'pdfUrl' => 'https://example.com/datasheet.pdf',
|
||||
'weight' => 0.001
|
||||
]
|
||||
]));
|
||||
|
||||
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||
|
||||
$results = $this->provider->searchByKeyword('C123456');
|
||||
|
||||
$this->assertIsArray($results);
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertInstanceOf(PartDetailDTO::class, $results[0]);
|
||||
$this->assertEquals('C123456', $results[0]->provider_id);
|
||||
$this->assertEquals('Test Component', $results[0]->name);
|
||||
}
|
||||
|
||||
public function testSearchByKeywordWithRegularTerm(): void
|
||||
{
|
||||
$mockResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productSearchResultVO' => [
|
||||
'productList' => [
|
||||
[
|
||||
'productCode' => 'C789012',
|
||||
'productModel' => 'Regular Component',
|
||||
'productIntroEn' => 'Regular description',
|
||||
'brandNameEn' => 'Regular Manufacturer',
|
||||
'encapStandard' => '0805',
|
||||
'productImageUrl' => 'https://example.com/regular.jpg',
|
||||
'productImages' => ['https://example.com/regular1.jpg'],
|
||||
'productPriceList' => [
|
||||
['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => '€']
|
||||
],
|
||||
'paramVOList' => [],
|
||||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]));
|
||||
|
||||
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||
|
||||
$results = $this->provider->searchByKeyword('resistor');
|
||||
|
||||
$this->assertIsArray($results);
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertInstanceOf(PartDetailDTO::class, $results[0]);
|
||||
$this->assertEquals('C789012', $results[0]->provider_id);
|
||||
$this->assertEquals('Regular Component', $results[0]->name);
|
||||
}
|
||||
|
||||
public function testSearchByKeywordWithTipProduct(): void
|
||||
{
|
||||
$mockResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productSearchResultVO' => [
|
||||
'productList' => []
|
||||
],
|
||||
'tipProductDetailUrlVO' => [
|
||||
'productCode' => 'C555555'
|
||||
]
|
||||
]
|
||||
]));
|
||||
|
||||
$detailResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productCode' => 'C555555',
|
||||
'productModel' => 'Tip Component',
|
||||
'productIntroEn' => 'Tip description',
|
||||
'brandNameEn' => 'Tip Manufacturer',
|
||||
'encapStandard' => '1206',
|
||||
'productImageUrl' => null,
|
||||
'productImages' => [],
|
||||
'productPriceList' => [],
|
||||
'paramVOList' => [],
|
||||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
]
|
||||
]));
|
||||
|
||||
$this->httpClient->setResponseFactory([$mockResponse, $detailResponse]);
|
||||
|
||||
$results = $this->provider->searchByKeyword('special');
|
||||
|
||||
$this->assertIsArray($results);
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertInstanceOf(PartDetailDTO::class, $results[0]);
|
||||
$this->assertEquals('C555555', $results[0]->provider_id);
|
||||
$this->assertEquals('Tip Component', $results[0]->name);
|
||||
}
|
||||
|
||||
public function testSearchByKeywordsBatch(): void
|
||||
{
|
||||
$mockResponse1 = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productCode' => 'C123456',
|
||||
'productModel' => 'Batch Component 1',
|
||||
'productIntroEn' => 'Batch description 1',
|
||||
'brandNameEn' => 'Batch Manufacturer',
|
||||
'encapStandard' => '0603',
|
||||
'productImageUrl' => null,
|
||||
'productImages' => [],
|
||||
'productPriceList' => [],
|
||||
'paramVOList' => [],
|
||||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
]
|
||||
]));
|
||||
|
||||
$mockResponse2 = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productSearchResultVO' => [
|
||||
'productList' => [
|
||||
[
|
||||
'productCode' => 'C789012',
|
||||
'productModel' => 'Batch Component 2',
|
||||
'productIntroEn' => 'Batch description 2',
|
||||
'brandNameEn' => 'Batch Manufacturer',
|
||||
'encapStandard' => '0805',
|
||||
'productImageUrl' => null,
|
||||
'productImages' => [],
|
||||
'productPriceList' => [],
|
||||
'paramVOList' => [],
|
||||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]));
|
||||
|
||||
$this->httpClient->setResponseFactory([$mockResponse1, $mockResponse2]);
|
||||
|
||||
$results = $this->provider->searchByKeywordsBatch(['C123456', 'resistor']);
|
||||
|
||||
$this->assertIsArray($results);
|
||||
$this->assertArrayHasKey('C123456', $results);
|
||||
$this->assertArrayHasKey('resistor', $results);
|
||||
$this->assertCount(1, $results['C123456']);
|
||||
$this->assertCount(1, $results['resistor']);
|
||||
$this->assertEquals('C123456', $results['C123456'][0]->provider_id);
|
||||
$this->assertEquals('C789012', $results['resistor'][0]->provider_id);
|
||||
}
|
||||
|
||||
public function testGetDetails(): void
|
||||
{
|
||||
$mockResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productCode' => 'C123456',
|
||||
'productModel' => 'Detailed Component',
|
||||
'productIntroEn' => 'Detailed description',
|
||||
'brandNameEn' => 'Detailed Manufacturer',
|
||||
'encapStandard' => '0603',
|
||||
'productImageUrl' => 'https://example.com/detail.jpg',
|
||||
'productImages' => ['https://example.com/detail1.jpg'],
|
||||
'productPriceList' => [
|
||||
['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$'],
|
||||
['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => 'US$']
|
||||
],
|
||||
'paramVOList' => [
|
||||
['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΩ'],
|
||||
['paramNameEn' => 'Tolerance', 'paramValueEn' => '1%']
|
||||
],
|
||||
'pdfUrl' => 'https://example.com/datasheet.pdf',
|
||||
'weight' => 0.001
|
||||
]
|
||||
]));
|
||||
|
||||
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||
|
||||
$result = $this->provider->getDetails('C123456');
|
||||
|
||||
$this->assertInstanceOf(PartDetailDTO::class, $result);
|
||||
$this->assertEquals('C123456', $result->provider_id);
|
||||
$this->assertEquals('Detailed Component', $result->name);
|
||||
$this->assertEquals('Detailed description', $result->description);
|
||||
$this->assertEquals('Detailed Manufacturer', $result->manufacturer);
|
||||
$this->assertEquals('0603', $result->footprint);
|
||||
$this->assertEquals('https://www.lcsc.com/product-detail/C123456.html', $result->provider_url);
|
||||
$this->assertCount(1, $result->images);
|
||||
$this->assertCount(2, $result->parameters);
|
||||
$this->assertCount(1, $result->vendor_infos);
|
||||
$this->assertEquals('0.001', $result->mass);
|
||||
}
|
||||
|
||||
public function testGetDetailsWithNoResults(): void
|
||||
{
|
||||
$mockResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productSearchResultVO' => [
|
||||
'productList' => []
|
||||
]
|
||||
]
|
||||
]));
|
||||
|
||||
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('No part found with ID INVALID');
|
||||
|
||||
$this->provider->getDetails('INVALID');
|
||||
}
|
||||
|
||||
public function testGetDetailsWithMultipleResults(): void
|
||||
{
|
||||
$mockResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productSearchResultVO' => [
|
||||
'productList' => [
|
||||
[
|
||||
'productCode' => 'C123456',
|
||||
'productModel' => 'Component 1',
|
||||
'productIntroEn' => 'Description 1',
|
||||
'brandNameEn' => 'Manufacturer 1',
|
||||
'encapStandard' => '0603',
|
||||
'productImageUrl' => null,
|
||||
'productImages' => [],
|
||||
'productPriceList' => [],
|
||||
'paramVOList' => [],
|
||||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
],
|
||||
[
|
||||
'productCode' => 'C789012',
|
||||
'productModel' => 'Component 2',
|
||||
'productIntroEn' => 'Description 2',
|
||||
'brandNameEn' => 'Manufacturer 2',
|
||||
'encapStandard' => '0805',
|
||||
'productImageUrl' => null,
|
||||
'productImages' => [],
|
||||
'productPriceList' => [],
|
||||
'paramVOList' => [],
|
||||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]));
|
||||
|
||||
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Multiple parts found with ID ambiguous');
|
||||
|
||||
$this->provider->getDetails('ambiguous');
|
||||
}
|
||||
|
||||
public function testSanitizeFieldPrivateMethod(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->provider);
|
||||
$method = $reflection->getMethod('sanitizeField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$this->assertNull($method->invokeArgs($this->provider, [null]));
|
||||
$this->assertEquals('Clean text', $method->invokeArgs($this->provider, ['Clean text']));
|
||||
$this->assertEquals('Text without tags', $method->invokeArgs($this->provider, ['<b>Text</b> without <i>tags</i>']));
|
||||
}
|
||||
|
||||
public function testGetUsedCurrencyPrivateMethod(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->provider);
|
||||
$method = $reflection->getMethod('getUsedCurrency');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$this->assertEquals('USD', $method->invokeArgs($this->provider, ['US$']));
|
||||
$this->assertEquals('USD', $method->invokeArgs($this->provider, ['$']));
|
||||
$this->assertEquals('EUR', $method->invokeArgs($this->provider, ['€']));
|
||||
$this->assertEquals('GBP', $method->invokeArgs($this->provider, ['£']));
|
||||
$this->assertEquals('USD', $method->invokeArgs($this->provider, ['UNKNOWN'])); // fallback to configured currency
|
||||
}
|
||||
|
||||
public function testGetProductShortURLPrivateMethod(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->provider);
|
||||
$method = $reflection->getMethod('getProductShortURL');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invokeArgs($this->provider, ['C123456']);
|
||||
$this->assertEquals('https://www.lcsc.com/product-detail/C123456.html', $result);
|
||||
}
|
||||
|
||||
public function testGetProductDatasheetsPrivateMethod(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->provider);
|
||||
$method = $reflection->getMethod('getProductDatasheets');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invokeArgs($this->provider, [null]);
|
||||
$this->assertIsArray($result);
|
||||
$this->assertEmpty($result);
|
||||
|
||||
$result = $method->invokeArgs($this->provider, ['https://example.com/datasheet.pdf']);
|
||||
$this->assertIsArray($result);
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertInstanceOf(FileDTO::class, $result[0]);
|
||||
}
|
||||
|
||||
public function testGetProductImagesPrivateMethod(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->provider);
|
||||
$method = $reflection->getMethod('getProductImages');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invokeArgs($this->provider, [null]);
|
||||
$this->assertIsArray($result);
|
||||
$this->assertEmpty($result);
|
||||
|
||||
$result = $method->invokeArgs($this->provider, [['https://example.com/image1.jpg', 'https://example.com/image2.jpg']]);
|
||||
$this->assertIsArray($result);
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertInstanceOf(FileDTO::class, $result[0]);
|
||||
$this->assertInstanceOf(FileDTO::class, $result[1]);
|
||||
}
|
||||
|
||||
public function testAttributesToParametersPrivateMethod(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->provider);
|
||||
$method = $reflection->getMethod('attributesToParameters');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$attributes = [
|
||||
['paramNameEn' => 'Resistance', 'paramValueEn' => '1kΩ'],
|
||||
['paramNameEn' => 'Tolerance', 'paramValueEn' => '1%'],
|
||||
['paramNameEn' => 'Empty', 'paramValueEn' => ''],
|
||||
['paramNameEn' => 'Dash', 'paramValueEn' => '-']
|
||||
];
|
||||
|
||||
$result = $method->invokeArgs($this->provider, [$attributes]);
|
||||
$this->assertIsArray($result);
|
||||
$this->assertCount(2, $result); // Only non-empty values
|
||||
$this->assertInstanceOf(ParameterDTO::class, $result[0]);
|
||||
$this->assertInstanceOf(ParameterDTO::class, $result[1]);
|
||||
}
|
||||
|
||||
public function testPricesToVendorInfoPrivateMethod(): void
|
||||
{
|
||||
$reflection = new \ReflectionClass($this->provider);
|
||||
$method = $reflection->getMethod('pricesToVendorInfo');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$prices = [
|
||||
['ladder' => 1, 'productPrice' => '0.10', 'currencySymbol' => 'US$'],
|
||||
['ladder' => 10, 'productPrice' => '0.08', 'currencySymbol' => 'US$']
|
||||
];
|
||||
|
||||
$result = $method->invokeArgs($this->provider, ['C123456', 'https://example.com', $prices]);
|
||||
$this->assertIsArray($result);
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertInstanceOf(PurchaseInfoDTO::class, $result[0]);
|
||||
$this->assertEquals('LCSC', $result[0]->distributor_name);
|
||||
$this->assertEquals('C123456', $result[0]->order_number);
|
||||
$this->assertCount(2, $result[0]->prices);
|
||||
}
|
||||
|
||||
public function testCategoryBuilding(): void
|
||||
{
|
||||
$mockResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productCode' => 'C123456',
|
||||
'productModel' => 'Test Component',
|
||||
'productIntroEn' => 'Test description',
|
||||
'brandNameEn' => 'Test Manufacturer',
|
||||
'parentCatalogName' => 'Electronic Components',
|
||||
'catalogName' => 'Resistors/SMT',
|
||||
'encapStandard' => '0603',
|
||||
'productImageUrl' => null,
|
||||
'productImages' => [],
|
||||
'productPriceList' => [],
|
||||
'paramVOList' => [],
|
||||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
]
|
||||
]));
|
||||
|
||||
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||
|
||||
$result = $this->provider->getDetails('C123456');
|
||||
$this->assertEquals('Electronic Components -> Resistors -> SMT', $result->category);
|
||||
}
|
||||
|
||||
public function testEmptyFootprintHandling(): void
|
||||
{
|
||||
$mockResponse = new MockResponse(json_encode([
|
||||
'result' => [
|
||||
'productCode' => 'C123456',
|
||||
'productModel' => 'Test Component',
|
||||
'productIntroEn' => 'Test description',
|
||||
'brandNameEn' => 'Test Manufacturer',
|
||||
'encapStandard' => '-',
|
||||
'productImageUrl' => null,
|
||||
'productImages' => [],
|
||||
'productPriceList' => [],
|
||||
'paramVOList' => [],
|
||||
'pdfUrl' => null,
|
||||
'weight' => null
|
||||
]
|
||||
]));
|
||||
|
||||
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||
|
||||
$result = $this->provider->getDetails('C123456');
|
||||
$this->assertNull($result->footprint);
|
||||
}
|
||||
|
||||
public function testSearchByKeywordsBatchWithEmptyKeywords(): void
|
||||
{
|
||||
$result = $this->provider->searchByKeywordsBatch([]);
|
||||
$this->assertIsArray($result);
|
||||
$this->assertEmpty($result);
|
||||
}
|
||||
|
||||
public function testSearchByKeywordsBatchWithException(): void
|
||||
{
|
||||
$mockResponse = new MockResponse('', ['http_code' => 500]);
|
||||
$this->httpClient->setResponseFactory([$mockResponse]);
|
||||
|
||||
$results = $this->provider->searchByKeywordsBatch(['error']);
|
||||
$this->assertIsArray($results);
|
||||
$this->assertArrayHasKey('error', $results);
|
||||
$this->assertEmpty($results['error']);
|
||||
}
|
||||
}
|
||||
@@ -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')]
|
||||
|
||||
62
tests/Services/Parts/PartsTableActionHandlerTest.php
Normal file
62
tests/Services/Parts/PartsTableActionHandlerTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
namespace App\Tests\Services\Parts;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Parts\PartsTableActionHandler;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class PartsTableActionHandlerTest extends WebTestCase
|
||||
{
|
||||
private PartsTableActionHandler $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->service = self::getContainer()->get(PartsTableActionHandler::class);
|
||||
}
|
||||
|
||||
public function testExportActionsRedirectToExportController(): void
|
||||
{
|
||||
// Mock a Part entity with required properties
|
||||
$part = $this->createMock(Part::class);
|
||||
$part->method('getId')->willReturn(1);
|
||||
$part->method('getName')->willReturn('Test Part');
|
||||
|
||||
$selected_parts = [$part];
|
||||
|
||||
// Test each export format, focusing on our new xlsx format
|
||||
$formats = ['json', 'csv', 'xml', 'yaml', 'xlsx'];
|
||||
|
||||
foreach ($formats as $format) {
|
||||
$action = "export_{$format}";
|
||||
$result = $this->service->handleAction($action, $selected_parts, 1, '/test');
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $result);
|
||||
$this->assertStringContainsString('parts/export', $result->getTargetUrl());
|
||||
$this->assertStringContainsString("format={$format}", $result->getTargetUrl());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12311,6 +12311,529 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<target>Profil gespeichert!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="XUlcyOb" name="settings.ips.element14">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.element14</source>
|
||||
<target>Element 14 / Farnell</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="1N9Wp0_" name="settings.ips.element14.apiKey">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.element14.apiKey</source>
|
||||
<target>API Key</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="p7LGAIX" name="settings.ips.element14.apiKey.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.element14.apiKey.help</source>
|
||||
<target>Sie können sich unter <a href="https://partner.element14.com/">https://partner.element14.com/</a> für einen API-Schlüssel registrieren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZdUHpZc" name="settings.ips.element14.storeId">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.element14.storeId</source>
|
||||
<target>Store Domain</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="XXGUxF6" name="settings.ips.element14.storeId.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.element14.storeId.help</source>
|
||||
<target>Die Domain des Shops, aus dem die Daten abgerufen werden sollen. Diese bestimmt die Sprache und Währung der Ergebnisse. Eine Liste der gültigen Domains finden Sie <a href="https://partner.element14.com/docs/Product_Search_API_REST__Description">hier</a>.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WKWZIm2" name="settings.ips.tme">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.tme</source>
|
||||
<target>TME</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="KgK78ks" name="settings.ips.tme.token">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.tme.token</source>
|
||||
<target>API Token</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="_pYLrPT" name="settings.ips.tme.token.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.tme.token.help</source>
|
||||
<target>Sie können einen API-Token und einen geheimen Schlüssel unter <a href="https://developers.tme.eu/en/">https://developers.tme.eu/en/</a> erhalten.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yswx4bq" name="settings.ips.tme.secret">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.tme.secret</source>
|
||||
<target>API Secret</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="edYZA5y" name="settings.ips.tme.currency">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.tme.currency</source>
|
||||
<target>Währung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="EsVqpUJ" name="settings.ips.tme.language">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.tme.language</source>
|
||||
<target>Sprache</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qFwUGJJ" name="settings.ips.tme.country">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.tme.country</source>
|
||||
<target>Land</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Mf2WKob" name="settings.ips.tme.grossPrices">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.tme.grossPrices</source>
|
||||
<target>Bruttopreise (inklusive Steuern) abrufen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="A.x1dyH" name="settings.ips.mouser">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser</source>
|
||||
<target>Mouser</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5ZxvqyY" name="settings.ips.mouser.apiKey">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.apiKey</source>
|
||||
<target>API Key</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="gu.JlpT" name="settings.ips.mouser.apiKey.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.apiKey.help</source>
|
||||
<target>Sie können sich unter <a href="https://eu.mouser.com/api-hub/">https://eu.mouser.com/api-hub/</a> für einen API-Schlüssel registrieren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Q66CNjw" name="settings.ips.mouser.searchLimit">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.searchLimit</source>
|
||||
<target>Suchlimit</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="aj5nhIi" name="settings.ips.mouser.searchLimit.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.searchLimit.help</source>
|
||||
<target>Die maximale Anzahl an Ergebnissen für eine einzelne Suche. Kann nicht höher als 50 sein.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="q8.Wpbu" name="settings.ips.mouser.searchOptions">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.searchOptions</source>
|
||||
<target>Suchfilter</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7ERXoOf" name="settings.ips.mouser.searchOptions.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.searchOptions.help</source>
|
||||
<target>Erlaubt es nur Teile mit einer bestimmten Verfügbarkeit und/oder Konformität anzuzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2_fx.rT" name="settings.ips.mouser.searchOptions.none">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.searchOptions.none</source>
|
||||
<target>Keine Filterung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".PbDhl9" name="settings.ips.mouser.searchOptions.rohs">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.searchOptions.rohs</source>
|
||||
<target>Nur RoHS konforme Bauteile</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4SPypVz" name="settings.ips.mouser.searchOptions.inStock">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.searchOptions.inStock</source>
|
||||
<target>Nur sofort verfügbare Bauteile</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xU8_Qw." name="settings.ips.mouser.searchOptions.rohsAndInStock">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.mouser.searchOptions.rohsAndInStock</source>
|
||||
<target>Sofort verfügbar & RoHS konform</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="fQYt0Om" name="settings.ips.lcsc">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.lcsc</source>
|
||||
<target>LCSC</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="iD6HBym" name="settings.ips.lcsc.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.lcsc.help</source>
|
||||
<target>Achtung: LCSC stellt keine offizielle API zur Verfügung. Dieser Anbieter nutzt die Webshop-API. LCSC hat die Nutzung dieser API nicht vorgesehen, und sie kann jederzeit ausfallen. Die Nutzung erfolgt daher auf eigene Gefahr.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="v7inGbo" name="settings.ips.lcsc.enabled">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.lcsc.enabled</source>
|
||||
<target>Aktivieren</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3KcMouQ" name="settings.ips.lcsc.currency">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.lcsc.currency</source>
|
||||
<target>Währung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kKv0J3." name="settings.system.attachments">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments</source>
|
||||
<target>Anhänge & Dateien</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="dsRff8T" name="settings.system.attachments.maxFileSize">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments.maxFileSize</source>
|
||||
<target>Maximale Dateigröße</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="uI1XsbJ" name="settings.system.attachments.maxFileSize.help">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments.maxFileSize.help</source>
|
||||
<target>Die maximale Größe der Dateien, die hochgeladen werden können. Bitte beachten Sie, dass dies auch durch die PHP-Konfiguration begrenzt ist.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sIt9Ovp" name="settings.system.attachments.allowDownloads">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments.allowDownloads</source>
|
||||
<target>Herunterladen externer Dateien zulassen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="T.PBu5P" name="settings.system.attachments.allowDownloads.help">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments.allowDownloads.help</source>
|
||||
<target>Mit dieser Option können Benutzer externe Dateien in die Part-DB herunterladen, indem sie eine URL angeben. <b>Achtung: Dies kann ein Sicherheitsrisiko darstellen, da Benutzer dadurch möglicherweise über die Part-DB auf Intranet-Ressourcen zugreifen können!</b></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".OyihML" name="settings.system.attachments.downloadByDefault">
|
||||
<segment state="translated">
|
||||
<source>settings.system.attachments.downloadByDefault</source>
|
||||
<target>Standardmäßig die URL für neue Anhänge herunterladen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="UuDCaUI" name="settings.system.customization">
|
||||
<segment state="translated">
|
||||
<source>settings.system.customization</source>
|
||||
<target>Anpassungen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VYMqQr5" name="settings.system.customization.instanceName">
|
||||
<segment state="translated">
|
||||
<source>settings.system.customization.instanceName</source>
|
||||
<target>Instanzname</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="0YFxSHZ" name="settings.system.customization.instanceName.help">
|
||||
<segment state="translated">
|
||||
<source>settings.system.customization.instanceName.help</source>
|
||||
<target>Name dieser Part-DB-Installation. Der Wert wird in der Navigationsleiste und in Titeln angezeigt.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="28n6rA7" name="settings.system.customization.banner">
|
||||
<segment state="translated">
|
||||
<source>settings.system.customization.banner</source>
|
||||
<target>Startseiten Banner</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cdwJbbA" name="settings.system.history">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history</source>
|
||||
<target>Ereignislog</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6TrsK5U" name="settings.system.history.saveChangedFields">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.saveChangedFields</source>
|
||||
<target>Speichern, welche Felder eines Elements in Protokolleinträgen geändert wurden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="IVP9YL8" name="settings.system.history.saveOldData">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.saveOldData</source>
|
||||
<target>Alten Daten in Protokolleinträgen bei Elementänderungen speichern</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="clqgFOW" name="settings.system.history.saveNewData">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.saveNewData</source>
|
||||
<target>Neue Daten in Logeinträgen bei Elementänderung/-erstellung speichern</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="MITi2KP" name="settings.system.history.saveRemovedData">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.saveRemovedData</source>
|
||||
<target>Gelöschte Daten in Logeintrag beim Löschen von Elementen speichern</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="pinyqu2" name="settings.system.customization.theme">
|
||||
<segment state="translated">
|
||||
<source>settings.system.customization.theme</source>
|
||||
<target>Globales Thema</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Aky9nXE" name="settings.system.history.enforceComments">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.enforceComments</source>
|
||||
<target>Kommentare für Aktionen erzwingen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="axivgKB" name="settings.system.history.enforceComments.description">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.enforceComments.description</source>
|
||||
<target>Mit dieser Option können Sie festlegen, für welche Aktionen Benutzer einen Grund angeben müssen, der protokolliert wird.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="eqQKTzQ" name="settings.system.history.enforceComments.type.part_edit">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.enforceComments.type.part_edit</source>
|
||||
<target>Bauteil bearbeiten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ip_kAO3" name="settings.system.history.enforceComments.type.part_create">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.enforceComments.type.part_create</source>
|
||||
<target>Bauteil erstellen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qEa.03L" name="settings.system.history.enforceComments.type.part_delete">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.enforceComments.type.part_delete</source>
|
||||
<target>Bauteil löschen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zjITvo2" name="settings.system.history.enforceComments.type.part_stock_operation">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.enforceComments.type.part_stock_operation</source>
|
||||
<target>Bauteilebestand ändern</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="hPbibkF" name="settings.system.history.enforceComments.type.datastructure_edit">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.enforceComments.type.datastructure_edit</source>
|
||||
<target>Datenstruktur bearbeiten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7mB_fP3" name="settings.system.history.enforceComments.type.datastructure_create">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.enforceComments.type.datastructure_create</source>
|
||||
<target>Datenstruktur erstellen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nt8eToz" name="settings.system.history.enforceComments.type.datastructure_delete">
|
||||
<segment state="translated">
|
||||
<source>settings.system.history.enforceComments.type.datastructure_delete</source>
|
||||
<target>Datenstruktur löschen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zQ8vq5N" name="settings.system.privacy.useGravatar">
|
||||
<segment state="translated">
|
||||
<source>settings.system.privacy.useGravatar</source>
|
||||
<target>Verwende Gravatar Avatare</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8IszKgp" name="settings.system.privacy.useGravatar.description">
|
||||
<segment state="translated">
|
||||
<source>settings.system.privacy.useGravatar.description</source>
|
||||
<target>Wenn ein Benutzer kein Avatar-Bild angegeben hat, verwenden Sie das Avatar von Gravatar basierend auf der E-Mail-Adresse des Benutzers. Dies führt dazu, dass der Browser Bilder von einem Drittanbieter lädt!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="rxHBzbv" name="settings.system.privacy.checkForUpdates">
|
||||
<segment state="translated">
|
||||
<source>settings.system.privacy.checkForUpdates</source>
|
||||
<target>Auf verfügbare Aktualisierungen von Part-DB prüfen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="LrAcb0O" name="settings.system.privacy.checkForUpdates.description">
|
||||
<segment state="translated">
|
||||
<source>settings.system.privacy.checkForUpdates.description</source>
|
||||
<target>Part-DB überprüft regelmäßig, ob auf GitHub eine neue Version verfügbar ist. Deaktivieren Sie diese Option hier, wenn Sie dies nicht wünschen oder wenn Ihr Server keine Verbindung zum Internet herstellen kann.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nMhHcYc" name="settings.system.localization.locale">
|
||||
<segment state="translated">
|
||||
<source>settings.system.localization.locale</source>
|
||||
<target>Standardsprache</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vWBNX1b" name="settings.system.localization">
|
||||
<segment state="translated">
|
||||
<source>settings.system.localization</source>
|
||||
<target>Lokalisierung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="7_M.HcK" name="settings.system.localization.timezone">
|
||||
<segment state="translated">
|
||||
<source>settings.system.localization.timezone</source>
|
||||
<target>Standardzeitzone</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="CCMOzX7" name="settings.system.localization.base_currency">
|
||||
<segment state="translated">
|
||||
<source>settings.system.localization.base_currency</source>
|
||||
<target>Basiswährung</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="0GRlEe5" name="settings.system.localization.base_currency_description">
|
||||
<segment state="translated">
|
||||
<source>settings.system.localization.base_currency_description</source>
|
||||
<target>Die Währung, in der Preisinformationen und Wechselkurse gespeichert werden. Diese Währung wird angenommen, wenn für eine Preisinformation keine Währung festgelegt ist.
|
||||
<b>Bitte beachten Sie, dass die Währungen bei einer Änderung dieses Wertes nicht umgerechnet werden. Wenn Sie also die Basiswährung ändern, nachdem Sie bereits Preisinformationen hinzugefügt haben, führt dies zu falschen Preisen!</b></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cvpTUeY" name="settings.system.privacy">
|
||||
<segment state="translated">
|
||||
<source>settings.system.privacy</source>
|
||||
<target>Datenschutz</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="TVAVZUl" name="settings.title">
|
||||
<segment state="translated">
|
||||
<source>settings.title</source>
|
||||
<target>Servereinstellungen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="xtw_ol7" name="settings.misc.kicad_eda">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda</source>
|
||||
<target>KiCAD Integration</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8u9_KDD" name="settings.misc.kicad_eda.category_depth">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.category_depth</source>
|
||||
<target>Kategorientiefe</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="w07P3Dt" name="settings.misc.kicad_eda.category_depth.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.kicad_eda.category_depth.help</source>
|
||||
<target>Dieser Wert bestimmt die Tiefe des Kategoriebaums, der in KiCad sichtbar ist. 0 bedeutet, dass nur die Kategorien der obersten Ebene sichtbar sind. Setzen Sie den Wert auf > 0, um weitere Ebenen anzuzeigen. Setzen Sie den Wert auf -1, um alle Teile der Part-DB innerhalb einer einzigen Kategorie in KiCad anzuzeigen.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VwvmcWE" name="settings.behavior.sidebar">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar</source>
|
||||
<target>Seitenleiste</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="FIneGzR" name="settings.behavior.sidebar.items">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar.items</source>
|
||||
<target>Seitenleisteneinträge</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="jc0JTvL" name="settings.behavior.sidebar.items.help">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar.items.help</source>
|
||||
<target>Die Menüs, die standardmäßig in der Seitenleiste angezeigt werden. Die Reihenfolge der Elemente kann per Drag & Drop geändert werden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="gVSWDkE" name="settings.behavior.sidebar.rootNodeEnabled">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar.rootNodeEnabled</source>
|
||||
<target>Stammknoten anzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="uk0yKdg" name="settings.behavior.sidebar.rootNodeEnabled.help">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar.rootNodeEnabled.help</source>
|
||||
<target>Ist diese Funktion aktiviert, werden alle Kategorien der obersten Ebene, Footprints usw. unter einem einzigen Stammknoten zusammengefasst. Ist sie deaktiviert, werden die Kategorien der obersten Ebene direkt im Menü angezeigt.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="brCNh0M" name="settings.behavior.sidebar.rootNodeExpanded">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar.rootNodeExpanded</source>
|
||||
<target>Stammknoten standardmäßig ausgeklappt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NkW8ELV" name="settings.behavior.table">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.table</source>
|
||||
<target>Tabellen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="1Zrv2eZ" name="settings.behavior.table.default_page_size">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.table.default_page_size</source>
|
||||
<target>Standardmäßige Seitengröße</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="lbx.zdp" name="settings.behavior.table.default_page_size.help">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.table.default_page_size.help</source>
|
||||
<target>Wie viele Einträge standardmäßig auf ganzseitigen Tabellen gezeigt werden. Setzen Sie den Wert auf -1, um standardmäßig alle Elemente ohne Seitenunterteilung anzuzeigen.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="G1N8QW3" name="settings.behavior.table.parts_default_columns">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.table.parts_default_columns</source>
|
||||
<target>Standardmäßige Spalten für Bauteiletabellen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="SUD8H3b" name="settings.behavior.table.parts_default_columns.help">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.table.parts_default_columns.help</source>
|
||||
<target>Die Spalten, die standardmäßig in Bauteiltabellen angezeigt werden sollen. Die Reihenfolge der Elemente kann per Drag & Drop geändert werden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="hazr_g5" name="settings.ips.oemsecrets">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.oemsecrets</source>
|
||||
<target>OEMSecrets</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ljRPsNX" name="settings.ips.oemsecrets.keepZeroPrices">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.oemsecrets.keepZeroPrices</source>
|
||||
<target>Händler mit Nullpreisen anzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="c7YTKEY" name="settings.ips.oemsecrets.keepZeroPrices.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.oemsecrets.keepZeroPrices.help</source>
|
||||
<target>Wenn dies nicht gesetzt ist, werden Händler, deren Preise 0 betragen, als ungültig verworfen.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ym9EcxF" name="settings.ips.oemsecrets.parseParams">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.oemsecrets.parseParams</source>
|
||||
<target>Parameter aus Beschreibung extrahieren</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="U4aFfJ3" name="settings.ips.oemsecrets.parseParams.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.oemsecrets.parseParams.help</source>
|
||||
<target>Wenn diese Option aktiviert ist, versucht der Anbieter, die unstrukturierten Beschreibungen von OEMSecrets in strukturierte Parameter umzuwandeln. Jeder Parameter in der Beschreibung sollte die Form „...;name1:value1;name2:value2“ haben.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="YMpgQPU" name="settings.ips.oemsecrets.sortMode">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.oemsecrets.sortMode</source>
|
||||
<target>Sortierung der Ergebnisse</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3VDLyjD" name="settings.ips.oemsecrets.sortMode.N">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.oemsecrets.sortMode.N</source>
|
||||
<target>Keine</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Oz.6cJY" name="settings.ips.oemsecrets.sortMode.C">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.oemsecrets.sortMode.C</source>
|
||||
<target>Vollständigkeit (Priorisierung von Elementen mit detaillierten Informationen)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="KLJYfJ0" name="settings.ips.oemsecrets.sortMode.M">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.oemsecrets.sortMode.M</source>
|
||||
<target>Vollständigkeit & Herstellername</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="8C9ijHM" name="entity.export.flash.error.no_entities">
|
||||
<segment state="translated">
|
||||
<source>entity.export.flash.error.no_entities</source>
|
||||
@@ -12365,5 +12888,167 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
|
||||
<target>Dieses Bauteil enthält mehr als einen Bestand. Ändere den Lagerort bei Hand, um auszuwählen, welcher Bestand geändert werden soll.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qn25jL." name="settings.ips.reichelt">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.reichelt</source>
|
||||
<target>Reichelt</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yYazL3j" name="settings.ips.reichelt.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.reichelt.help</source>
|
||||
<target>Reichelt.com bietet keine offizielle API an, daher extrahiert dieser Informationsanbieter die Informationen per Webscraping aus der Website. Dies kann jederzeit unterbrochen werden, die Nutzung erfolgt auf eigene Gefahr.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2EVJVc6" name="settings.ips.reichelt.include_vat">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.reichelt.include_vat</source>
|
||||
<target>Preise inkl. Mehrwertsteuer anzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ocp5ktF" name="settings.ips.pollin">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.pollin</source>
|
||||
<target>Pollin</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Xlj6kj2" name="settings.ips.pollin.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.pollin.help</source>
|
||||
<target>Pollin.de bietet keine offizielle API an, daher extrahiert dieser Informationsanbieter die Daten per Webscraping aus der Website. Dies kann jederzeit aufhören zu funktionieren, die Nutzung erfolgt auf eigene Gefahr.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="TEm7uIg" name="settings.behavior.sidebar.rootNodeRedirectsToNewEntity">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.sidebar.rootNodeRedirectsToNewEntity</source>
|
||||
<target>Wurzelknoten leitet zur Erstellung eines neuen Elements weiter</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="j7HiQ80" name="settings.ips.digikey">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.digikey</source>
|
||||
<target>Digikey</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="_ViyVdh" name="settings.ips.digikey.client_id">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.digikey.client_id</source>
|
||||
<target>Client ID</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="eB9dDyp" name="settings.ips.digikey.secret">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.digikey.secret</source>
|
||||
<target>Secret</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5xjmMzf" name="settings.ips.octopart">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.octopart</source>
|
||||
<target>Octopart / Nexar</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="vGv90iO" name="settings.ips.octopart.searchLimit">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.octopart.searchLimit</source>
|
||||
<target>Anzahl an Ergebnissen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="au4Yeps" name="settings.ips.octopart.searchLimit.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.octopart.searchLimit.help</source>
|
||||
<target>Die Anzahl der Ergebnisse, die Sie bei der Suche von Octopart erhalten möchten (bitte beachten Sie, dass dies auf Ihre API-Limits angerechnet wird)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Tiqmk.8" name="settings.ips.octopart.onlyAuthorizedSellers">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.octopart.onlyAuthorizedSellers</source>
|
||||
<target>Nur autorisierte Verkäufer</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ECQkeJy" name="settings.ips.octopart.onlyAuthorizedSellers.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.octopart.onlyAuthorizedSellers.help</source>
|
||||
<target>Anwählen, um nicht-autorisierte Angebote in den Ergebnissen anzuzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="iRDDtdU" name="settings.misc.exchange_rate">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.exchange_rate</source>
|
||||
<target>Wechselkurse</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="0REngfi" name="settings.misc.exchange_rate.fixer_api_key">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.exchange_rate.fixer_api_key</source>
|
||||
<target>Fixer.io API Key</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="COLhoWD" name="settings.misc.exchange_rate.fixer_api_key.help">
|
||||
<segment state="translated">
|
||||
<source>settings.misc.exchange_rate.fixer_api_key.help</source>
|
||||
<target>Wenn Sie Wechselkurse zwischen Nicht-Euro-Währungen benötigen, können Sie hier einen API-Schlüssel von fixer.io eingeben.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Ffr5xYM" name="settings.behavior.part_info">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.part_info</source>
|
||||
<target>Bauteileinfoseite</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="weH3j.a" name="settings.behavior.part_info.show_part_image_overlay">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.part_info.show_part_image_overlay</source>
|
||||
<target>Bildoverlay anzeigen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="SCUs3WS" name="settings.behavior.part_info.show_part_image_overlay.help">
|
||||
<segment state="translated">
|
||||
<source>settings.behavior.part_info.show_part_image_overlay.help</source>
|
||||
<target>Zeigen Sie die Bildoverlay mit den Details zum Anhang an, wenn Sie mit der Maus über die Teilebildgalerie fahren.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ALfPkeR" name="perm.config.change_system_settings">
|
||||
<segment state="translated">
|
||||
<source>perm.config.change_system_settings</source>
|
||||
<target>Systemeinstellungen bearbeiten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="TlHeIjk" name="tree.tools.system.settings">
|
||||
<segment state="translated">
|
||||
<source>tree.tools.system.settings</source>
|
||||
<target>Systemeinstellungen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3YsJ4i6" name="settings.tooltip.overrideable_by_env">
|
||||
<segment state="translated">
|
||||
<source>settings.tooltip.overrideable_by_env</source>
|
||||
<target>Der Wert dieses Parameters kann durch Setzen der Umgebungsvariable „%env%“ überschrieben werden.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="boWkQh7" name="settings.flash.saved">
|
||||
<segment state="translated">
|
||||
<source>settings.flash.saved</source>
|
||||
<target>Einstellungen erfolgreich gespeichert.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="tEYWrWD" name="settings.flash.invalid">
|
||||
<segment state="translated">
|
||||
<source>settings.flash.invalid</source>
|
||||
<target>Die Einstellungen sind ungültig. Bitte überprüfen Sie Ihre Eingabe!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="yRXWSRN" name="info_providers.settings.title">
|
||||
<segment state="translated">
|
||||
<source>info_providers.settings.title</source>
|
||||
<target>Informationsquelle-Einstellungen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VgSodKY" name="form.apikey.redacted">
|
||||
<segment state="translated">
|
||||
<source>form.apikey.redacted</source>
|
||||
<target>Aus Sicherheitsgründen ausgeblendet</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12344,5 +12344,29 @@ Por favor ten en cuenta que no puedes personificar a un usuario deshabilitado. S
|
||||
<target>Ver versión externa</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="X9HUFrv" name="part.table.actions.error">
|
||||
<segment state="translated">
|
||||
<source>part.table.actions.error</source>
|
||||
<target>Han ocurrido %count% errores mientras se ejecutaba la acción:</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id=".ppbsNn" name="part.table.actions.error_detail">
|
||||
<segment state="translated">
|
||||
<source>part.table.actions.error_detail</source>
|
||||
<target>%part_name% (ID: %part_id%): %message%</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4wpp6h." name="part_list.action.action.change_location">
|
||||
<segment state="translated">
|
||||
<source>part_list.action.action.change_location</source>
|
||||
<target>Cambiar de ubicación (solo para componentes con un único lote)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9_9I.m4" name="parts.table.action_handler.error.part_lots_multiple">
|
||||
<segment state="translated">
|
||||
<source>parts.table.action_handler.error.part_lots_multiple</source>
|
||||
<target>Este componente contiene más de un stock. Cambie la ubicación manualmente para seleccionar el stock deseado.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="cs">
|
||||
<file id="security.en">
|
||||
<unit id="aazoCks" name="user.login_error.user_disabled">
|
||||
<unit id="GrLNa9P" name="user.login_error.user_disabled">
|
||||
<segment state="translated">
|
||||
<source>user.login_error.user_disabled</source>
|
||||
<target>Váš účet je deaktivován! Pokud si myslíte, že je to špatně, kontaktujte správce.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Dpb9AmY" name="saml.error.cannot_login_local_user_per_saml">
|
||||
<unit id="IFQ5XrG" name="saml.error.cannot_login_local_user_per_saml">
|
||||
<segment state="translated">
|
||||
<source>saml.error.cannot_login_local_user_per_saml</source>
|
||||
<target>Přes SSO se nelze přihlásit jako místní uživatel! Místo toho použijte heslo místního uživatele.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="wOYPZmb" name="saml.error.cannot_login_saml_user_locally">
|
||||
<segment state="translated">
|
||||
<source>saml.error.cannot_login_saml_user_locally</source>
|
||||
<target>Pro přihlášení jako uživatel SAML nelze použít místní ověření! Místo toho použijte přihlášení SSO.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="cs">
|
||||
<file id="validators.en">
|
||||
<unit id="xevSdCK" name="part.master_attachment.must_be_picture">
|
||||
<unit id="cRbk.cm" name="part.master_attachment.must_be_picture">
|
||||
<notes>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Attachments\AttachmentContainingDBElement.php:0</note>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Attachments\AttachmentType.php:0</note>
|
||||
@@ -42,7 +42,7 @@
|
||||
<target>Příloha náhledu musí být platný obrázek!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="VJHTkxx" name="structural.entity.unique_name">
|
||||
<unit id="v8HkcJB" name="structural.entity.unique_name">
|
||||
<notes>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Attachments\AttachmentType.php:0</note>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Base\AbstractCompany.php:0</note>
|
||||
@@ -87,7 +87,7 @@
|
||||
<target>Prvek s tímto názvem již na této úrovni existuje!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3ODUtpU" name="parameters.validator.min_lesser_typical">
|
||||
<unit id="dW7b2B_" name="parameters.validator.min_lesser_typical">
|
||||
<notes>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\AbstractParameter.php:0</note>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\AttachmentTypeParameter.php:0</note>
|
||||
@@ -107,7 +107,7 @@
|
||||
<target>Hodnota musí být menší nebo rovna typické hodnotě ({{ compared_value }}).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="jDBA_WW" name="parameters.validator.min_lesser_max">
|
||||
<unit id="Yfp2uC5" name="parameters.validator.min_lesser_max">
|
||||
<notes>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\AbstractParameter.php:0</note>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\AttachmentTypeParameter.php:0</note>
|
||||
@@ -127,7 +127,7 @@
|
||||
<target>Hodnota musí být menší než maximální hodnota ({{ compared_value }}).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ygK_e_X" name="parameters.validator.max_greater_typical">
|
||||
<unit id="P6b.8Ou" name="parameters.validator.max_greater_typical">
|
||||
<notes>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\AbstractParameter.php:0</note>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\Parameters\AttachmentTypeParameter.php:0</note>
|
||||
@@ -147,7 +147,7 @@
|
||||
<target>Hodnota musí být větší nebo rovna typické hodnotě ({{ compared_value }}).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="isXL.ie" name="validator.user.username_already_used">
|
||||
<unit id="P41193Y" name="validator.user.username_already_used">
|
||||
<notes>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
|
||||
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
|
||||
@@ -157,7 +157,7 @@
|
||||
<target>Uživatel s tímto jménem již existuje</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NcM463r" name="user.invalid_username">
|
||||
<unit id="EKPQiyf" name="user.invalid_username">
|
||||
<notes>
|
||||
<note category="file-source" priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
|
||||
<note priority="1">Part-DB1\src\Entity\UserSystem\User.php:0</note>
|
||||
@@ -167,185 +167,203 @@
|
||||
<target>Uživatelské jméno musí obsahovat pouze písmena, číslice, podtržítka, tečky, plusy nebo mínusy!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="lZvhKYu" name="validator.noneofitschild.self">
|
||||
<unit id="_v.DMg." name="validator.noneofitschild.self">
|
||||
<notes>
|
||||
<note category="state" priority="1">obsolete</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>validator.noneofitschild.self</source>
|
||||
<target>Prvek nemůže být svým vlastním rodičem!</target>
|
||||
<target>Prvek nemůže být svým vlastním rodičem.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="pr07aV4" name="validator.noneofitschild.children">
|
||||
<unit id="W90LyFQ" name="validator.noneofitschild.children">
|
||||
<notes>
|
||||
<note category="state" priority="1">obsolete</note>
|
||||
</notes>
|
||||
<segment state="translated">
|
||||
<source>validator.noneofitschild.children</source>
|
||||
<target>Podřízený prvek nemůže být nadřazeným prvkem!</target>
|
||||
<target>Rodič nemůže být jedním ze svých potomků.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ayNr6QK" name="validator.select_valid_category">
|
||||
<unit id="GAUS.LK" name="validator.select_valid_category">
|
||||
<segment state="translated">
|
||||
<source>validator.select_valid_category</source>
|
||||
<target>Vyberte prosím platnou kategorii!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6vIlN5q" name="validator.part_lot.only_existing">
|
||||
<unit id="h6qELde" name="validator.part_lot.only_existing">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.only_existing</source>
|
||||
<target>Do tohoto umístění nelze přidávat nové díly, protože je označeno jako "Pouze existující".</target>
|
||||
<target>Úložiště bylo označeno jako "pouze existující", takže do něj nelze přidat novou část.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3xoKOIS" name="validator.part_lot.location_full.no_increase">
|
||||
<unit id="Prriyy0" name="validator.part_lot.location_full.no_increase">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.location_full.no_increase</source>
|
||||
<target>Místo je obsazeno. Množství nelze navýšit (nová hodnota musí být menší než {{ old_amount }}).</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="R6Ov4Yt" name="validator.part_lot.location_full">
|
||||
<unit id="eeEjB4s" name="validator.part_lot.location_full">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.location_full</source>
|
||||
<target>Místo je obsazeno. Nelze do něj přidávat nové díly.</target>
|
||||
<target>Úložiště bylo označeno jako plné, takže do něj nelze přidat nový díl.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BNQk2e7" name="validator.part_lot.single_part">
|
||||
<unit id="2yWi8eP" name="validator.part_lot.single_part">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.single_part</source>
|
||||
<target>Toto umístění může obsahovat pouze jeden díl, takže do něj nelze přídávat další!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="4gPskOG" name="validator.attachment.must_not_be_null">
|
||||
<unit id="A.TFhbb" name="validator.attachment.must_not_be_null">
|
||||
<segment state="translated">
|
||||
<source>validator.attachment.must_not_be_null</source>
|
||||
<target>Musíte vybrat typ přílohy!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="cDDVrWT" name="validator.orderdetail.supplier_must_not_be_null">
|
||||
<unit id=".lqKoij" name="validator.orderdetail.supplier_must_not_be_null">
|
||||
<segment state="translated">
|
||||
<source>validator.orderdetail.supplier_must_not_be_null</source>
|
||||
<target>Musíte si vybrat dodavatele!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="k5DDdB4" name="validator.measurement_unit.use_si_prefix_needs_unit">
|
||||
<unit id="bcNZzK." name="validator.measurement_unit.use_si_prefix_needs_unit">
|
||||
<segment state="translated">
|
||||
<source>validator.measurement_unit.use_si_prefix_needs_unit</source>
|
||||
<target>Chcete-li povolit předpony SI, musíte nastavit symbol jednotky!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="DuzIOCr" name="part.ipn.must_be_unique">
|
||||
<unit id="gZ5FFL1" name="part.ipn.must_be_unique">
|
||||
<segment state="translated">
|
||||
<source>part.ipn.must_be_unique</source>
|
||||
<target>Interní číslo dílu musí být jedinečné. {{ value }} se již používá!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Z4Kuuo2" name="validator.project.bom_entry.name_or_part_needed">
|
||||
<unit id="P31Yg.d" name="validator.project.bom_entry.name_or_part_needed">
|
||||
<segment state="translated">
|
||||
<source>validator.project.bom_entry.name_or_part_needed</source>
|
||||
<target>Musíte vybrat díl pro položku BOM dílu nebo nastavit název pro položku BOM bez dílu.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="WF_v4ih" name="project.bom_entry.name_already_in_bom">
|
||||
<unit id="5CEup_N" name="project.bom_entry.name_already_in_bom">
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.name_already_in_bom</source>
|
||||
<target>Již existuje položka BOM s tímto názvem!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="5v4p85H" name="project.bom_entry.part_already_in_bom">
|
||||
<unit id="jB3B50E" name="project.bom_entry.part_already_in_bom">
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.part_already_in_bom</source>
|
||||
<target>Tento díl již existuje v tomto BOM!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="3lM32Tw" name="project.bom_entry.mountnames_quantity_mismatch">
|
||||
<unit id="NdkzP1n" name="project.bom_entry.mountnames_quantity_mismatch">
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.mountnames_quantity_mismatch</source>
|
||||
<target>Počet názvů sestav musí odpovídat počtu komponent v BOM!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="x47D5WT" name="project.bom_entry.can_not_add_own_builds_part">
|
||||
<unit id="8teRCgR" name="project.bom_entry.can_not_add_own_builds_part">
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.can_not_add_own_builds_part</source>
|
||||
<target>Seznam BOM projektu nelze přidat do BOM.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2x2XDI_" name="project.bom_has_to_include_all_subelement_parts">
|
||||
<unit id="asBxPxe" name="project.bom_has_to_include_all_subelement_parts">
|
||||
<segment state="translated">
|
||||
<source>project.bom_has_to_include_all_subelement_parts</source>
|
||||
<target>BOM projektu musí obsahovat všechny výrobní díly dílčích projektů. Díl %part_name% projektu %project_name% chybí!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="U9b1EzD" name="project.bom_entry.price_not_allowed_on_parts">
|
||||
<unit id="uxaE9Ct" name="project.bom_entry.price_not_allowed_on_parts">
|
||||
<segment state="translated">
|
||||
<source>project.bom_entry.price_not_allowed_on_parts</source>
|
||||
<target>U položek komponent BOM nelze nastavit cenu. Zadejte cenu samotného dílu.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ID056SR" name="validator.project_build.lot_bigger_than_needed">
|
||||
<unit id="xZ68Nzl" name="validator.project_build.lot_bigger_than_needed">
|
||||
<segment state="translated">
|
||||
<source>validator.project_build.lot_bigger_than_needed</source>
|
||||
<target>Zvolili jste větší množství pro vychystávání, než je nutné. Odstraňte přebytečné množství</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="6hV5UqD" name="validator.project_build.lot_smaller_than_needed">
|
||||
<unit id="68_.V_X" name="validator.project_build.lot_smaller_than_needed">
|
||||
<segment state="translated">
|
||||
<source>validator.project_build.lot_smaller_than_needed</source>
|
||||
<target>Zvolili jste menší množství k odebrání, než je potřeba pro sestavení! Přidejte další množství.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="G9ZKt.4" name="part.name.must_match_category_regex">
|
||||
<unit id="yZGS8uZ" name="part.name.must_match_category_regex">
|
||||
<segment state="translated">
|
||||
<source>part.name.must_match_category_regex</source>
|
||||
<target>Název komponenty neodpovídá regulárnímu výrazu zadanému pro kategorii: %regex%</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="m8kMFhf" name="validator.attachment.name_not_blank">
|
||||
<unit id="Q8wP5Jd" name="validator.attachment.name_not_blank">
|
||||
<segment state="translated">
|
||||
<source>validator.attachment.name_not_blank</source>
|
||||
<target>Vyberte hodnotu nebo nahrajte soubor, aby se jeho název automaticky použil jako název této přílohy.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nwGaNBW" name="validator.part_lot.owner_must_match_storage_location_owner">
|
||||
<unit id="DH0IkNR" name="validator.part_lot.owner_must_match_storage_location_owner">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.owner_must_match_storage_location_owner</source>
|
||||
<target>Vlastník inventáře této komponenty a vybrané umístění se musí shodovat (%owner_name%)!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="HXSz3nQ" name="validator.part_lot.owner_must_not_be_anonymous">
|
||||
<unit id="TzySicw" name="validator.part_lot.owner_must_not_be_anonymous">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.owner_must_not_be_anonymous</source>
|
||||
<target>Vlastníkem nemůže být anonymní uživatel!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="N8aA0Uh" name="validator.part_association.must_set_an_value_if_type_is_other">
|
||||
<unit id="GthNWUb" name="validator.part_association.must_set_an_value_if_type_is_other">
|
||||
<segment state="translated">
|
||||
<source>validator.part_association.must_set_an_value_if_type_is_other</source>
|
||||
<target>Pokud nastavíte typ na "jiný", musíte pro něj nastavit popisnou hodnotu!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9VYNZ4v" name="validator.part_association.part_cannot_be_associated_with_itself">
|
||||
<unit id="Be4Im81" name="validator.part_association.part_cannot_be_associated_with_itself">
|
||||
<segment state="translated">
|
||||
<source>validator.part_association.part_cannot_be_associated_with_itself</source>
|
||||
<target>Díl nemůže být spojen sám se sebou!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="csc1PNn" name="validator.part_association.already_exists">
|
||||
<unit id="q5Ej6Xm" name="validator.part_association.already_exists">
|
||||
<segment state="translated">
|
||||
<source>validator.part_association.already_exists</source>
|
||||
<target>Asociace s tímto dílem již existuje!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sfW4NYE" name="validator.part_lot.vendor_barcode_must_be_unique">
|
||||
<unit id="HbI5bga" name="validator.part_lot.vendor_barcode_must_be_unique">
|
||||
<segment state="translated">
|
||||
<source>validator.part_lot.vendor_barcode_must_be_unique</source>
|
||||
<target>Tato hodnota čárového kódu dodavatele již byla použita v jiném inventáře. Čárový kód musí být jedinečný!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="o1qmPUm" name="validator.year_2038_bug_on_32bit">
|
||||
<unit id="ufQJh7E" name="validator.year_2038_bug_on_32bit">
|
||||
<segment state="translated">
|
||||
<source>validator.year_2038_bug_on_32bit</source>
|
||||
<target>Kvůli technickým omezením není možné na 32bitových systémech vybrat datumpo 19.1.2038!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="89nojXY" name="validator.fileSize.invalidFormat">
|
||||
<segment state="translated">
|
||||
<source>validator.fileSize.invalidFormat</source>
|
||||
<target>Neplatný formát velikosti souboru. Použijte celé číslo a jako příponu K, M, G pro kilobajty, megabajty nebo gigabajty.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="iXcU7ce" name="validator.invalid_range">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_range</source>
|
||||
<target>Zadaný rozsah není platný!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="m4gp2P_" name="validator.google_code.wrong_code">
|
||||
<segment state="translated">
|
||||
<source>validator.google_code.wrong_code</source>
|
||||
<target>Neplatný kód. Zkontrolujte, zda je vaše ověřovací aplikace správně nastavena a zda je čas správně nastaven jak na serveru, tak na ověřovacím zařízení.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -347,7 +347,13 @@
|
||||
<target>Aufgrund technischer Beschränkungen ist es nicht möglich, ein Datum nach dem 19.01.2038 auf 32-Bit Systemen auszuwählen!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="ZFxQ0BZ" name="validator.invalid_range">
|
||||
<unit id="89nojXY" name="validator.fileSize.invalidFormat">
|
||||
<segment state="translated">
|
||||
<source>validator.fileSize.invalidFormat</source>
|
||||
<target>Ungültige Angabe für die Dateigröße. Verwenden Sie eine ganze Zahl mit K, M, G als Suffix für Kilo, Mega oder Gigabytes.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="iXcU7ce" name="validator.invalid_range">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_range</source>
|
||||
<target>Der gegebene Bereich ist nicht gültig!</target>
|
||||
|
||||
@@ -348,13 +348,13 @@
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="89nojXY" name="validator.fileSize.invalidFormat">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.fileSize.invalidFormat</source>
|
||||
<target>Invalid file size format. Use an integer number plus K, M, G as suffix for Kilo, Mega or Gigabytes.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="iXcU7ce" name="validator.invalid_range">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_range</source>
|
||||
<target>The given range is not valid!</target>
|
||||
</segment>
|
||||
|
||||
Reference in New Issue
Block a user