diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..68c63d3f5 --- /dev/null +++ b/Makefile @@ -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!" \ No newline at end of file diff --git a/assets/css/app/tables.css b/assets/css/app/tables.css index ae892f508..eaa5e9e2f 100644 --- a/assets/css/app/tables.css +++ b/assets/css/app/tables.css @@ -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 { diff --git a/composer.json b/composer.json index e57ce6527..0d192ed2f 100644 --- a/composer.json +++ b/composer.json @@ -1,170 +1,171 @@ { - "name": "part-db/part-db-server", - "type": "project", - "license": "AGPL-3.0-or-later", - "require": { - "php": "^8.1", - "ext-ctype": "*", - "ext-dom": "*", - "ext-gd": "*", - "ext-iconv": "*", - "ext-intl": "*", - "ext-json": "*", - "ext-mbstring": "*", - "amphp/http-client": "^5.1", - "api-platform/core": "^3.1", - "beberlei/doctrineextensions": "^1.2", - "brick/math": "0.12.1 as 0.11.0", - "composer/ca-bundle": "^1.5", - "composer/package-versions-deprecated": "^1.11.99.5", - "doctrine/data-fixtures": "^2.0.0", - "doctrine/dbal": "^4.0.0", - "doctrine/doctrine-bundle": "^2.0", - "doctrine/doctrine-migrations-bundle": "^3.0", - "doctrine/orm": "^3.2.0", - "dompdf/dompdf": "^v3.0.0", - "erusev/parsedown": "^1.7", - "florianv/swap": "^4.0", - "florianv/swap-bundle": "dev-master", - "gregwar/captcha-bundle": "^2.1.0", - "hshn/base64-encoded-file": "^5.0", - "jbtronics/2fa-webauthn": "^v2.2.0", - "jbtronics/dompdf-font-loader-bundle": "^1.0.0", - "jfcherng/php-diff": "^6.14", - "knpuniversity/oauth2-client-bundle": "^2.15", - "league/csv": "^9.8.0", - "league/html-to-markdown": "^5.0.1", - "liip/imagine-bundle": "^2.2", - "nbgrp/onelogin-saml-bundle": "^1.3", - "nelexa/zip": "^4.0", - "nelmio/cors-bundle": "^2.3", - "nelmio/security-bundle": "^3.0", - "nyholm/psr7": "^1.1", - "omines/datatables-bundle": "^0.9.1", - "paragonie/sodium_compat": "^1.21", - "part-db/label-fonts": "^1.0", - "rhukster/dom-sanitizer": "^1.0", - "runtime/frankenphp-symfony": "^0.2.0", - "s9e/text-formatter": "^2.1", - "scheb/2fa-backup-code": "^6.8.0", - "scheb/2fa-bundle": "^6.8.0", - "scheb/2fa-google-authenticator": "^6.8.0", - "scheb/2fa-trusted-device": "^6.8.0", - "shivas/versioning-bundle": "^4.0", - "spatie/db-dumper": "^3.3.1", - "symfony/apache-pack": "^1.0", - "symfony/asset": "6.4.*", - "symfony/console": "6.4.*", - "symfony/css-selector": "6.4.*", - "symfony/dom-crawler": "6.4.*", - "symfony/dotenv": "6.4.*", - "symfony/expression-language": "6.4.*", - "symfony/flex": "^v2.3.1", - "symfony/form": "6.4.*", - "symfony/framework-bundle": "6.4.*", - "symfony/http-client": "6.4.*", - "symfony/http-kernel": "6.4.*", - "symfony/mailer": "6.4.*", - "symfony/monolog-bundle": "^3.1", - "symfony/polyfill-php82": "^1.28", - "symfony/process": "6.4.*", - "symfony/property-access": "6.4.*", - "symfony/property-info": "6.4.*", - "symfony/rate-limiter": "6.4.*", - "symfony/runtime": "6.4.*", - "symfony/security-bundle": "6.4.*", - "symfony/serializer": "6.4.*", - "symfony/string": "6.4.*", - "symfony/translation": "6.4.*", - "symfony/twig-bundle": "6.4.*", - "symfony/ux-translator": "^2.10", - "symfony/ux-turbo": "^2.0", - "symfony/validator": "6.4.*", - "symfony/web-link": "6.4.*", - "symfony/webpack-encore-bundle": "^v2.0.1", - "symfony/yaml": "6.4.*", - "tecnickcom/tc-lib-barcode": "^2.1.4", - "twig/cssinliner-extra": "^3.0", - "twig/extra-bundle": "^3.8", - "twig/html-extra": "^3.8", - "twig/inky-extra": "^3.0", - "twig/intl-extra": "^3.8", - "twig/markdown-extra": "^3.8", - "twig/string-extra": "^3.8", - "web-auth/webauthn-symfony-bundle": "^4.0.0" + "name": "part-db/part-db-server", + "type": "project", + "license": "AGPL-3.0-or-later", + "require": { + "php": "^8.1", + "ext-ctype": "*", + "ext-dom": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-intl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "amphp/http-client": "^5.1", + "api-platform/core": "^3.1", + "beberlei/doctrineextensions": "^1.2", + "brick/math": "0.12.1 as 0.11.0", + "composer/ca-bundle": "^1.5", + "composer/package-versions-deprecated": "^1.11.99.5", + "doctrine/data-fixtures": "^2.0.0", + "doctrine/dbal": "^4.0.0", + "doctrine/doctrine-bundle": "^2.0", + "doctrine/doctrine-migrations-bundle": "^3.0", + "doctrine/orm": "^3.2.0", + "dompdf/dompdf": "^v3.0.0", + "erusev/parsedown": "^1.7", + "florianv/swap": "^4.0", + "florianv/swap-bundle": "dev-master", + "gregwar/captcha-bundle": "^2.1.0", + "hshn/base64-encoded-file": "^5.0", + "jbtronics/2fa-webauthn": "^v2.2.0", + "jbtronics/dompdf-font-loader-bundle": "^1.0.0", + "jfcherng/php-diff": "^6.14", + "knpuniversity/oauth2-client-bundle": "^2.15", + "league/csv": "^9.8.0", + "league/html-to-markdown": "^5.0.1", + "liip/imagine-bundle": "^2.2", + "nbgrp/onelogin-saml-bundle": "^1.3", + "nelexa/zip": "^4.0", + "nelmio/cors-bundle": "^2.3", + "nelmio/security-bundle": "^3.0", + "nyholm/psr7": "^1.1", + "omines/datatables-bundle": "^0.9.1", + "paragonie/sodium_compat": "^1.21", + "part-db/label-fonts": "^1.0", + "phpoffice/phpspreadsheet": "*", + "rhukster/dom-sanitizer": "^1.0", + "runtime/frankenphp-symfony": "^0.2.0", + "s9e/text-formatter": "^2.1", + "scheb/2fa-backup-code": "^6.8.0", + "scheb/2fa-bundle": "^6.8.0", + "scheb/2fa-google-authenticator": "^6.8.0", + "scheb/2fa-trusted-device": "^6.8.0", + "shivas/versioning-bundle": "^4.0", + "spatie/db-dumper": "^3.3.1", + "symfony/apache-pack": "^1.0", + "symfony/asset": "6.4.*", + "symfony/console": "6.4.*", + "symfony/css-selector": "6.4.*", + "symfony/dom-crawler": "6.4.*", + "symfony/dotenv": "6.4.*", + "symfony/expression-language": "6.4.*", + "symfony/flex": "^v2.3.1", + "symfony/form": "6.4.*", + "symfony/framework-bundle": "6.4.*", + "symfony/http-client": "6.4.*", + "symfony/http-kernel": "6.4.*", + "symfony/mailer": "6.4.*", + "symfony/monolog-bundle": "^3.1", + "symfony/polyfill-php82": "^1.28", + "symfony/process": "6.4.*", + "symfony/property-access": "6.4.*", + "symfony/property-info": "6.4.*", + "symfony/rate-limiter": "6.4.*", + "symfony/runtime": "6.4.*", + "symfony/security-bundle": "6.4.*", + "symfony/serializer": "6.4.*", + "symfony/string": "6.4.*", + "symfony/translation": "6.4.*", + "symfony/twig-bundle": "6.4.*", + "symfony/ux-translator": "^2.10", + "symfony/ux-turbo": "^2.0", + "symfony/validator": "6.4.*", + "symfony/web-link": "6.4.*", + "symfony/webpack-encore-bundle": "^v2.0.1", + "symfony/yaml": "6.4.*", + "tecnickcom/tc-lib-barcode": "^2.1.4", + "twig/cssinliner-extra": "^3.0", + "twig/extra-bundle": "^3.8", + "twig/html-extra": "^3.8", + "twig/inky-extra": "^3.0", + "twig/intl-extra": "^3.8", + "twig/markdown-extra": "^3.8", + "twig/string-extra": "^3.8", + "web-auth/webauthn-symfony-bundle": "^4.0.0" + }, + "require-dev": { + "dama/doctrine-test-bundle": "^v8.0.0", + "doctrine/doctrine-fixtures-bundle": "^4.0.0", + "ekino/phpstan-banned-code": "^v3.0.0", + "jbtronics/translation-editor-bundle": "^1.0", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0.4", + "phpstan/phpstan-doctrine": "^2.0.1", + "phpstan/phpstan-strict-rules": "^2.0.1", + "phpstan/phpstan-symfony": "^2.0.0", + "phpunit/phpunit": "^9.5", + "rector/rector": "^2.0.4", + "roave/security-advisories": "dev-latest", + "symfony/browser-kit": "6.4.*", + "symfony/debug-bundle": "6.4.*", + "symfony/maker-bundle": "^1.13", + "symfony/phpunit-bridge": "6.4.*", + "symfony/stopwatch": "6.4.*", + "symfony/web-profiler-bundle": "6.4.*", + "symplify/easy-coding-standard": "^12.0" + }, + "suggest": { + "ext-bcmath": "Used to improve price calculation performance", + "ext-gmp": "Used to improve price calculation performanice" + }, + "config": { + "preferred-install": { + "*": "dist" }, - "require-dev": { - "dama/doctrine-test-bundle": "^v8.0.0", - "doctrine/doctrine-fixtures-bundle": "^4.0.0", - "ekino/phpstan-banned-code": "^v3.0.0", - "jbtronics/translation-editor-bundle": "^1.0", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0.4", - "phpstan/phpstan-doctrine": "^2.0.1", - "phpstan/phpstan-strict-rules": "^2.0.1", - "phpstan/phpstan-symfony": "^2.0.0", - "phpunit/phpunit": "^9.5", - "rector/rector": "^2.0.4", - "roave/security-advisories": "dev-latest", - "symfony/browser-kit": "6.4.*", - "symfony/debug-bundle": "6.4.*", - "symfony/maker-bundle": "^1.13", - "symfony/phpunit-bridge": "6.4.*", - "symfony/stopwatch": "6.4.*", - "symfony/web-profiler-bundle": "6.4.*", - "symplify/easy-coding-standard": "^12.0" + "platform": { + "php": "8.1.0" }, - "suggest": { - "ext-bcmath": "Used to improve price calculation performance", - "ext-gmp": "Used to improve price calculation performanice" - }, - "config": { - "preferred-install": { - "*": "dist" - }, - "platform": { - "php": "8.1.0" - }, - "sort-packages": true, - "allow-plugins": { - "composer/package-versions-deprecated": true, - "symfony/flex": true, - "phpstan/extension-installer": true, - "symfony/runtime": true, - "php-http/discovery": true - } - }, - "autoload": { - "psr-4": { - "App\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "App\\Tests\\": "tests/" - } - }, - "scripts": { - "auto-scripts": { - "cache:clear": "symfony-cmd", - "assets:install %PUBLIC_DIR%": "symfony-cmd" - }, - "post-install-cmd": [ - "@auto-scripts" - ], - "post-update-cmd": [ - "@auto-scripts" - ], - "phpstan": "vendor/bin/phpstan analyse src --level 5 --memory-limit 1G" - }, - "conflict": { - "symfony/symfony": "*" + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "symfony/flex": true, + "phpstan/extension-installer": true, + "symfony/runtime": true, + "php-http/discovery": true + } + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" }, - "extra": { - "symfony": { - "allow-contrib": false, - "require": "6.4.*", - "docker": true - } + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ], + "phpstan": "vendor/bin/phpstan analyse src --level 5 --memory-limit 1G" + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "6.4.*", + "docker": true } + } } diff --git a/composer.lock b/composer.lock index 7e8b34819..4409f9a67 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "27cd0d915eab5e7cb57215a4c0b529fb", + "content-hash": "74d80aa83c1a336f3a2b661e0c19c075", "packages": [ { "name": "amphp/amp", @@ -1525,6 +1525,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", @@ -5077,6 +5156,190 @@ }, "time": "2023-07-31T13:36:50+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "6187e9cc4493da94b9b63eb2315821552015fca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/6187e9cc4493da94b9b63eb2315821552015fca9", + "reference": "6187e9cc4493da94b9b63eb2315821552015fca9", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.1" + }, + "require-dev": { + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^10.0", + "vimeo/psalm": "^5.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.1" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2024-10-10T12:33:01+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.9.0", @@ -6446,6 +6709,112 @@ }, "time": "2024-11-09T15:12:26+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "4.5.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "2ea9786632e6fac1aee601b6e426bcc723d8ce13" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2ea9786632e6fac1aee601b6e426bcc723d8ce13", + "reference": "2ea9786632e6fac1aee601b6e426bcc723d8ce13", + "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/4.5.0" + }, + "time": "2025-07-24T05:15:59+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.1.0", @@ -19005,9 +19374,9 @@ "ext-json": "*", "ext-mbstring": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "8.1.0" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/docs/assets/usage/import_export/part_import_example.csv b/docs/assets/usage/import_export/part_import_example.csv index 087014260..14d4500f9 100644 --- a/docs/assets/usage/import_export/part_import_example.csv +++ b/docs/assets/usage/import_export/part_import_example.csv @@ -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;HTML;;TO -> TO-92;PNP,Transistor;10;Room 2-> Box 3;;Internal1234;;;;;;;;1;;;active -Copper Wire;;Wire;;;;;;;;;;;;;;;;;Meter; \ No newline at end of file +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 diff --git a/migrations/Version20250802205143.php b/migrations/Version20250802205143.php new file mode 100644 index 000000000..5eb09a77b --- /dev/null +++ b/migrations/Version20250802205143.php @@ -0,0 +1,70 @@ +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'); + } +} diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php new file mode 100644 index 000000000..d09e8d047 --- /dev/null +++ b/src/Controller/BulkInfoProviderImportController.php @@ -0,0 +1,713 @@ +. + */ + +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 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 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 + { + // Get LCSC provider through reflection since PartInfoRetriever doesn't expose it + $reflection = new \ReflectionClass($this->infoRetriever); + $registryProp = $reflection->getProperty('provider_registry'); + $registryProp->setAccessible(true); + $registry = $registryProp->getValue($this->infoRetriever); + + $lcscProvider = $registry->getProviderByKey('lcsc'); + if ($lcscProvider && method_exists($lcscProvider, 'searchByKeywordsBatch')) { + return $lcscProvider->searchByKeywordsBatch($keywords); + } + + return []; + } + + #[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() + ]); + } +} \ No newline at end of file diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index b11a5c900..d1087254f 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -65,12 +65,14 @@ #[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 @@ public function __construct(protected PricedetailHelper $pricedetailHelper, */ #[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 @@ public function edit(Part $part, Request $request): Response { $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 @@ public function delete(Request $request, Part $part): RedirectResponse { $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 @@ public function delete(Request $request, Part $part): RedirectResponse #[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 @@ public function merge(Request $request, Part $target, Part $other, PartMerger $p } #[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 @@ public function updateFromInfoProvider(Part $part, Request $request, string $pro $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 @@ private function renderPartForm(string $mode, Request $request, Part $data, arra } 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 @@ private function renderPartForm(string $mode, Request $request, Part $data, arra 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 @@ private function renderPartForm(string $mode, Request $request, Part $data, arra $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 @@ public function withdrawAddHandler(Part $part, Request $request, EntityManagerIn 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 @@ public function withdrawAddHandler(Part $part, Request $request, EntityManagerIn $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 @@ public function withdrawAddHandler(Part $part, Request $request, EntityManagerIn 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 diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php new file mode 100644 index 000000000..0e5a36967 --- /dev/null +++ b/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php @@ -0,0 +1,82 @@ +. + */ + +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() . ')'); + } + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php new file mode 100644 index 000000000..cc5c8ce0a --- /dev/null +++ b/src/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraint.php @@ -0,0 +1,105 @@ +. + */ + +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); + } + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php new file mode 100644 index 000000000..168934d60 --- /dev/null +++ b/src/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraint.php @@ -0,0 +1,104 @@ +. + */ + +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); + } + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index ff98c76f9..a13bb9295 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -31,6 +31,9 @@ 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\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 @@ public function __construct(NodesListBuilder $nodesListBuilder) */ $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 @@ public function __construct(NodesListBuilder $nodesListBuilder) $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 diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index 3163a38b6..9a1a200b2 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -43,6 +43,7 @@ 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 Doctrine\ORM\AbstractQuery; @@ -141,23 +142,25 @@ public function configure(DataTable $dataTable, array $options): void '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 ''; @@ -166,7 +169,7 @@ public function configure(DataTable $dataTable, array $options): void $tmp = htmlspecialchars($partUnit->getName()); if ($partUnit->getUnit()) { - $tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')'; + $tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')'; } return $tmp; } @@ -229,7 +232,7 @@ public function configure(DataTable $dataTable, array $options): void } if (count($projects) > $max) { - $tmp .= ", + ".(count($projects) - $max); + $tmp .= ", + " . (count($projects) - $max); } return $tmp; @@ -246,8 +249,11 @@ public function configure(DataTable $dataTable, array $options): void ]); //Apply the user configured order and visibility and add the columns to the table - $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns, - "TABLE_PARTS_DEFAULT_COLUMNS"); + $this->csh->applyVisibilityAndConfigureColumns( + $dataTable, + $this->visible_columns, + "TABLE_PARTS_DEFAULT_COLUMNS" + ); $dataTable->addOrderBy('name') ->createAdapter(TwoStepORMAdapter::class, [ @@ -365,7 +371,7 @@ private function addJoins(QueryBuilder $builder): QueryBuilder $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()) @@ -422,6 +428,13 @@ private function addJoins(QueryBuilder $builder): QueryBuilder //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; } diff --git a/src/Entity/BulkInfoProviderImportJob.php b/src/Entity/BulkInfoProviderImportJob.php new file mode 100644 index 000000000..2a6020305 --- /dev/null +++ b/src/Entity/BulkInfoProviderImportJob.php @@ -0,0 +1,406 @@ +. + */ + +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 */ + #[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; + } +} \ No newline at end of file diff --git a/src/Entity/BulkInfoProviderImportJobPart.php b/src/Entity/BulkInfoProviderImportJobPart.php new file mode 100644 index 000000000..3625f3773 --- /dev/null +++ b/src/Entity/BulkInfoProviderImportJobPart.php @@ -0,0 +1,172 @@ +. + */ + +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; + } +} \ No newline at end of file diff --git a/src/Entity/LogSystem/LogTargetType.php b/src/Entity/LogSystem/LogTargetType.php index 1c6e4f8c0..1e07ddc5d 100644 --- a/src/Entity/LogSystem/LogTargetType.php +++ b/src/Entity/LogSystem/LogTargetType.php @@ -24,6 +24,8 @@ 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 @@ public function toClass(): ?string 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, }; } diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 14a7903fc..98c1b8841 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -55,6 +55,7 @@ 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 @@ #[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 @@ 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 @@ #[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 + */ + #[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)] + protected Collection $bulkImportJobParts; + public function __construct() { @@ -172,6 +189,7 @@ public function __construct() $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 @@ public function validate(ExecutionContextInterface $context, $payload): void } } } + + /** + * Get all bulk import job parts for this part + * @return Collection + */ + 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; + } } diff --git a/src/Form/AdminPages/ImportType.php b/src/Form/AdminPages/ImportType.php index 3e87812c7..0bd3cea1e 100644 --- a/src/Form/AdminPages/ImportType.php +++ b/src/Form/AdminPages/ImportType.php @@ -59,6 +59,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'XML' => 'xml', 'CSV' => 'csv', 'YAML' => 'yaml', + 'XLSX' => 'xlsx', + 'XLS' => 'xls', ], 'label' => 'export.format', 'disabled' => $disabled, diff --git a/src/Form/Filters/Constraints/BulkImportJobExistsConstraintType.php b/src/Form/Filters/Constraints/BulkImportJobExistsConstraintType.php new file mode 100644 index 000000000..e26b5f5a8 --- /dev/null +++ b/src/Form/Filters/Constraints/BulkImportJobExistsConstraintType.php @@ -0,0 +1,63 @@ +. + */ + +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); + } +} \ No newline at end of file diff --git a/src/Form/Filters/Constraints/BulkImportJobStatusConstraintType.php b/src/Form/Filters/Constraints/BulkImportJobStatusConstraintType.php new file mode 100644 index 000000000..6809f98b1 --- /dev/null +++ b/src/Form/Filters/Constraints/BulkImportJobStatusConstraintType.php @@ -0,0 +1,80 @@ +. + */ + +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); + } +} \ No newline at end of file diff --git a/src/Form/Filters/Constraints/BulkImportPartStatusConstraintType.php b/src/Form/Filters/Constraints/BulkImportPartStatusConstraintType.php new file mode 100644 index 000000000..e02a3197c --- /dev/null +++ b/src/Form/Filters/Constraints/BulkImportPartStatusConstraintType.php @@ -0,0 +1,79 @@ +. + */ + +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); + } +} \ No newline at end of file diff --git a/src/Form/Filters/LogFilterType.php b/src/Form/Filters/LogFilterType.php index 42b367b74..c973ad0fb 100644 --- a/src/Form/Filters/LogFilterType.php +++ b/src/Form/Filters/LogFilterType.php @@ -100,7 +100,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]); $builder->add('user', UserEntityConstraintType::class, [ - 'label' => 'log.user', + 'label' => 'log.user', ]); $builder->add('targetType', EnumConstraintType::class, [ @@ -128,11 +128,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 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, ]); diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index dfe449d18..1515c61be 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -32,7 +32,11 @@ 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 @@ public function buildForm(FormBuilderInterface $builder, array $options): void } + /************************************************************************** + * 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', diff --git a/src/Form/InfoProviderSystem/BulkProviderSearchType.php b/src/Form/InfoProviderSystem/BulkProviderSearchType.php new file mode 100644 index 000000000..24a3cfb4b --- /dev/null +++ b/src/Form/InfoProviderSystem/BulkProviderSearchType.php @@ -0,0 +1,62 @@ +. + */ + +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'); + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/FieldToProviderMappingType.php b/src/Form/InfoProviderSystem/FieldToProviderMappingType.php new file mode 100644 index 000000000..fa7ee28bb --- /dev/null +++ b/src/Form/InfoProviderSystem/FieldToProviderMappingType.php @@ -0,0 +1,72 @@ +. + */ + +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' => [], + ]); + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/GlobalFieldMappingType.php b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php new file mode 100644 index 000000000..1f2af5b1d --- /dev/null +++ b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php @@ -0,0 +1,67 @@ +. + */ + +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' => [], + ]); + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/PartProviderConfigurationType.php b/src/Form/InfoProviderSystem/PartProviderConfigurationType.php new file mode 100644 index 000000000..cecf62a33 --- /dev/null +++ b/src/Form/InfoProviderSystem/PartProviderConfigurationType.php @@ -0,0 +1,55 @@ +. + */ + +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', + ]); + } +} \ No newline at end of file diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php index 142471457..21b91acdb 100644 --- a/src/Services/ElementTypeNameGenerator.php +++ b/src/Services/ElementTypeNameGenerator.php @@ -26,6 +26,8 @@ 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 @@ public function __construct(protected TranslatorInterface $translator, private r 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 @@ public function getTypeNameCombination(NamedElementInterface $entity, bool $use_ { $type = $this->getLocalizedTypeLabel($entity); if ($use_html) { - return ''.$type.': '.htmlspecialchars($entity->getName()); + return '' . $type . ': ' . htmlspecialchars($entity->getName()); } - return $type.': '.$entity->getName(); + return $type . ': ' . $entity->getName(); } diff --git a/src/Services/EntityMergers/Mergers/PartMerger.php b/src/Services/EntityMergers/Mergers/PartMerger.php index 4ce779e88..01b53e25e 100644 --- a/src/Services/EntityMergers/Mergers/PartMerger.php +++ b/src/Services/EntityMergers/Mergers/PartMerger.php @@ -100,7 +100,8 @@ public function merge(object $target, object $other, array $context = []): Part 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 @@ private function mergeCollectionFields(Part $target, Part $other, array $context $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) { diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php index c37db50c0..e654fdd19 100644 --- a/src/Services/ImportExportSystem/EntityExporter.php +++ b/src/Services/ImportExportSystem/EntityExporter.php @@ -38,6 +38,9 @@ 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 @@ public function __construct(protected SerializerInterface $serializer) 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 @@ public function exportEntities(AbstractNamedDBElement|array $entities, array $op $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,70 @@ private function handleCircularReference(object $object, string $format, array $ 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); + + ob_start(); + $writer->save('php://output'); + $content = ob_get_contents(); + ob_end_clean(); + + return $content; } /** @@ -156,19 +229,15 @@ public function exportEntityFromRequest(AbstractNamedDBElement|array $entities, //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 +255,7 @@ public function exportEntityFromRequest(AbstractNamedDBElement|array $entities, $level = $options['level']; - $filename = 'export_'.$entity_name.'_'.$level.'.'.$format; + $filename = "export_{$entity_name}_{$level}.{$format}"; //Sanitize the filename $filename = FilenameSanatizer::sanitizeFilename($filename); diff --git a/src/Services/ImportExportSystem/EntityImporter.php b/src/Services/ImportExportSystem/EntityImporter.php index cecab12dc..97d501eb7 100644 --- a/src/Services/ImportExportSystem/EntityImporter.php +++ b/src/Services/ImportExportSystem/EntityImporter.php @@ -38,6 +38,9 @@ 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) { } @@ -101,7 +104,7 @@ public function massCreation(string $lines, string $class_name, ?AbstractStructu 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)) { @@ -188,16 +191,20 @@ public function importString(string $data, array $options = [], array &$errors = } //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)) { @@ -272,7 +279,7 @@ protected function configureOptions(OptionsResolver $resolver): OptionsResolver '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'); @@ -328,6 +335,33 @@ public function importFileAndPersistToDB(File $file, array $options = [], array */ 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); } @@ -347,10 +381,103 @@ public function determineFormat(string $extension): ?string '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. * diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php index d903a8ddd..23b2d3067 100755 --- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php +++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php @@ -67,9 +67,10 @@ public function isActive(): bool /** * @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' => [ @@ -87,7 +88,7 @@ private function queryDetail(string $id): PartDetailDTO throw new \RuntimeException('Could not find product code: ' . $id); } - return $this->getPartDetail($product); + return $this->getPartDetail($product, $lightweight); } /** @@ -97,30 +98,42 @@ private function queryDetail(string $id): PartDetailDTO 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->currency) @@ -143,11 +156,11 @@ private function queryByTerm(string $term): array // 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; @@ -173,7 +186,7 @@ private function sanitizeField(?string $field): ?string * @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); @@ -212,10 +225,10 @@ private function getPartDetail(array $product): PartDetailDTO 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, ); } @@ -284,7 +297,7 @@ private function getUsedCurrency(string $currency): string */ 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'; } /** @@ -325,7 +338,7 @@ private function attributesToParameters(?array $attributes): array //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); @@ -336,12 +349,86 @@ private function attributesToParameters(?array $attributes): array 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->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->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); } diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index 616df2293..945cff7b7 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -30,13 +30,11 @@ 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 @@ public function handleAction(string $action, array $selected_parts, ?int $target //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 @@ public function handleAction(string $action, array $selected_parts, ?int $target ); } + 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) { diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 185713060..f2689cd5d 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -138,6 +138,11 @@ protected function getToolsNode(): array $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; diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index 5ce0f23f6..8d7e10f7b 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -30,7 +30,7 @@
- {# #} + {% trans %}part_list.action.scrollable_hint{% endtrans %}
+
+
+ + +
+ + +
+ {{ 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) }} +
+ + {{ form_widget(form.submit) }} +
+ + {{ form_end(form) }} + + {% if search_results is not null %} +
+

{% trans %}info_providers.bulk_import.search_results.title{% endtrans %}

+ + {% for part_result in search_results %} + {% set part = part_result.part %} +
+
+
+ {{ part.name }} + {% if part_result.errors is not empty %} + {{ part_result.errors|length }} {% trans %}info_providers.bulk_import.errors{% endtrans %} + {% endif %} + {{ part_result.search_results|length }} {% trans %}info_providers.bulk_import.results_found{% endtrans %} +
+
+
+ {% if part_result.errors is not empty %} + {% for error in part_result.errors %} + + {% endfor %} + {% endif %} + + {% if part_result.search_results|length > 0 %} +
+ + + + + + + + + + + + + + {% for result in part_result.search_results %} + {% set dto = result.dto %} + {% set localPart = result.localPart %} + + + + + + + + + + {% endfor %} + +
{% trans %}name.label{% endtrans %}{% trans %}description.label{% endtrans %}{% trans %}manufacturer.label{% endtrans %}{% trans %}info_providers.table.provider.label{% endtrans %}{% trans %}info_providers.bulk_import.source_field{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
+ + + {% if dto.provider_url is not null %} + {{ dto.name }} + {% else %} + {{ dto.name }} + {% endif %} + {% if dto.mpn is not null %} +
{{ dto.mpn }} + {% endif %} +
{{ dto.description }}{{ dto.manufacturer ?? '' }} + {{ info_provider_label(dto.provider_key)|default(dto.provider_key) }} +
{{ dto.provider_id }} +
+ {{ result.source_field ?? 'unknown' }} + {% if result.source_keyword %} +
{{ result.source_keyword }} + {% endif %} +
+
+ {% set updateHref = path('info_providers_update_part', + {'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %} + + {% trans %}info_providers.bulk_import.update_part{% endtrans %} + + + {% if localPart is not null %} + + {% trans %}info_providers.bulk_import.view_existing{% endtrans %} + + {% endif %} +
+
+
+ {% else %} + + {% endif %} +
+
+ {% endfor %} + {% endif %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} + diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig new file mode 100644 index 000000000..f2ab8ad73 --- /dev/null +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -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 %} + {% trans %}info_providers.bulk_import.step2.title{% endtrans %} + {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} +{% endblock %} + +{% block card_content %} + +
+
+
{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
+ + {{ 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') }} + +
+
+ {% if job.isPending %} + {% trans %}info_providers.bulk_import.status.pending{% endtrans %} + {% elseif job.isInProgress %} + {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} + {% elseif job.isCompleted %} + {% trans %}info_providers.bulk_import.status.completed{% endtrans %} + {% elseif job.isFailed %} + {% trans %}info_providers.bulk_import.status.failed{% endtrans %} + {% endif %} +
+
+ + +
+
+
+
Progress
+ {{ job.completedPartsCount }} / {{ job.partCount }} completed +
+
+
+
+
+
+ + {{ job.completedPartsCount }} {% trans %}info_providers.bulk_import.completed{% endtrans %} • + {{ job.skippedPartsCount }} {% trans %}info_providers.bulk_import.skipped{% endtrans %} + + {{ job.progressPercentage }}% +
+
+
+ + + + + {% for part_result in search_results %} + {% set part = part_result.part %} + {% set isCompleted = job.isPartCompleted(part.id) %} + {% set isSkipped = job.isPartSkipped(part.id) %} +
+
+
+
+ + {{ part.name }} + + {% if isCompleted %} + + {% trans %}info_providers.bulk_import.completed{% endtrans %} + + {% elseif isSkipped %} + + {% trans %}info_providers.bulk_import.skipped{% endtrans %} + + {% endif %} + {% if part_result.errors is not empty %} + {% trans with {'%count%': part_result.errors|length} %}info_providers.bulk_import.errors{% endtrans %} + {% endif %} + {% trans with {'%count%': part_result.search_results|length} %}info_providers.bulk_import.results_found{% endtrans %} +
+
+
+ {% if not isCompleted and not isSkipped %} + + + {% elseif isCompleted %} + + {% elseif isSkipped %} + + {% endif %} +
+
+
+ {% if part_result.errors is not empty %} + {% for error in part_result.errors %} + + {% endfor %} + {% endif %} + + {% if part_result.search_results|length > 0 %} +
+ + + + + + + + + + + + + + {% for result in part_result.search_results %} + {% set dto = result.dto %} + {% set localPart = result.localPart %} + + + + + + + + + + {% endfor %} + +
{% trans %}name.label{% endtrans %}{% trans %}description.label{% endtrans %}{% trans %}manufacturer.label{% endtrans %}{% trans %}info_providers.table.provider.label{% endtrans %}{% trans %}info_providers.bulk_import.source_field{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
+ + + {% if dto.provider_url is not null %} + {{ dto.name }} + {% else %} + {{ dto.name }} + {% endif %} + {% if dto.mpn is not null %} +
{{ dto.mpn }} + {% endif %} +
{{ dto.description }}{{ dto.manufacturer ?? '' }} + {{ info_provider_label(dto.provider_key)|default(dto.provider_key) }} +
{{ dto.provider_id }} +
+ {{ result.source_field ?? 'unknown' }} + {% if result.source_keyword %} +
{{ result.source_keyword }} + {% endif %} +
+
+ {% set updateHref = path('info_providers_update_part', + {'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %} + + {% trans %}info_providers.bulk_import.update_part{% endtrans %} + +
+
+
+ {% else %} + + {% endif %} +
+
+ {% endfor %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/parts/edit/edit_part_info.html.twig b/templates/parts/edit/edit_part_info.html.twig index 20cddbd73..28a88132a 100644 --- a/templates/parts/edit/edit_part_info.html.twig +++ b/templates/parts/edit/edit_part_info.html.twig @@ -4,6 +4,32 @@ {% trans with {'%name%': part.name|escape } %}part.edit.title{% endtrans %} {% endblock %} +{% block before_card %} + {% if bulk_job and jobId %} +
+
+
+ + + {% trans %}info_providers.bulk_import.back{% endtrans %} + +
+ + +
+
+ + {% trans %}info_providers.bulk_import.editing_part{% endtrans %} +
+
+
+
+ {% endif %} +{% endblock %} + {% block card_title %} {% trans with {'%name%': part.name|escape } %}part.edit.card_title{% endtrans %} diff --git a/templates/parts/edit/update_from_ip.html.twig b/templates/parts/edit/update_from_ip.html.twig index fb1dfad34..1ab2ca596 100644 --- a/templates/parts/edit/update_from_ip.html.twig +++ b/templates/parts/edit/update_from_ip.html.twig @@ -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 %} +
+
+
+ + {% trans %}info_providers.bulk_import.editing_part{% endtrans %} +
+
+
+ {% endif %} +{% endblock %} + {% block title %} {% trans %}info_providers.update_part.title{% endtrans %}: {{ merge_old_name }} {% endblock %} diff --git a/templates/parts/lists/_filter.html.twig b/templates/parts/lists/_filter.html.twig index c29e8ecdc..ba9168d16 100644 --- a/templates/parts/lists/_filter.html.twig +++ b/templates/parts/lists/_filter.html.twig @@ -31,6 +31,11 @@ {% endif %} + {% if filterForm.inBulkImportJob is defined %} + + {% endif %} {{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }} @@ -126,6 +131,13 @@ {{ form_row(filterForm.bomComment) }} {% endif %} + {% if filterForm.inBulkImportJob is defined %} +
+ {{ form_row(filterForm.inBulkImportJob) }} + {{ form_row(filterForm.bulkImportJobStatus) }} + {{ form_row(filterForm.bulkImportPartStatus) }} +
+ {% endif %} diff --git a/tests/Controller/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php new file mode 100644 index 000000000..7d67e05e8 --- /dev/null +++ b/tests/Controller/BulkInfoProviderImportControllerTest.php @@ -0,0 +1,914 @@ +. + */ + +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(); + } +} \ No newline at end of file diff --git a/tests/Controller/PartControllerTest.php b/tests/Controller/PartControllerTest.php new file mode 100644 index 000000000..b6a1ec19e --- /dev/null +++ b/tests/Controller/PartControllerTest.php @@ -0,0 +1,334 @@ +. + */ + +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); + } + +} \ No newline at end of file diff --git a/tests/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraintTest.php b/tests/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraintTest.php new file mode 100644 index 000000000..4090d7f7d --- /dev/null +++ b/tests/DataTables/Filters/Constraints/Part/BulkImportJobStatusConstraintTest.php @@ -0,0 +1,251 @@ +. + */ + +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()); + } +} \ No newline at end of file diff --git a/tests/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraintTest.php b/tests/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraintTest.php new file mode 100644 index 000000000..eb48fb63c --- /dev/null +++ b/tests/DataTables/Filters/Constraints/Part/BulkImportPartStatusConstraintTest.php @@ -0,0 +1,299 @@ +. + */ + +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()); + } +} \ No newline at end of file diff --git a/tests/Entity/BulkImportJobStatusTest.php b/tests/Entity/BulkImportJobStatusTest.php new file mode 100644 index 000000000..48f5d8b4e --- /dev/null +++ b/tests/Entity/BulkImportJobStatusTest.php @@ -0,0 +1,71 @@ +. + */ + +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'); + } +} \ No newline at end of file diff --git a/tests/Entity/BulkInfoProviderImportJobPartTest.php b/tests/Entity/BulkInfoProviderImportJobPartTest.php new file mode 100644 index 000000000..a539aebc3 --- /dev/null +++ b/tests/Entity/BulkInfoProviderImportJobPartTest.php @@ -0,0 +1,301 @@ +. + */ + +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()); + } +} \ No newline at end of file diff --git a/tests/Entity/BulkInfoProviderImportJobTest.php b/tests/Entity/BulkInfoProviderImportJobTest.php new file mode 100644 index 000000000..48678bf71 --- /dev/null +++ b/tests/Entity/BulkInfoProviderImportJobTest.php @@ -0,0 +1,360 @@ +. + */ + +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()); + } +} \ No newline at end of file diff --git a/tests/Form/InfoProviderSystem/GlobalFieldMappingTypeTest.php b/tests/Form/InfoProviderSystem/GlobalFieldMappingTypeTest.php new file mode 100644 index 000000000..52e0b1d29 --- /dev/null +++ b/tests/Form/InfoProviderSystem/GlobalFieldMappingTypeTest.php @@ -0,0 +1,68 @@ +. + */ + +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']); + } +} \ No newline at end of file diff --git a/tests/Services/ElementTypeNameGeneratorTest.php b/tests/Services/ElementTypeNameGeneratorTest.php index 934a3bbd7..c893fe2ad 100644 --- a/tests/Services/ElementTypeNameGeneratorTest.php +++ b/tests/Services/ElementTypeNameGeneratorTest.php @@ -25,6 +25,7 @@ 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 @@ public function testGetLocalizedTypeNameCombination(): void //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 @@ public function testGetTypeNameCombination(): void //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'; diff --git a/tests/Services/ImportExportSystem/EntityExporterTest.php b/tests/Services/ImportExportSystem/EntityExporterTest.php index 004971aba..e9b924b1e 100644 --- a/tests/Services/ImportExportSystem/EntityExporterTest.php +++ b/tests/Services/ImportExportSystem/EntityExporterTest.php @@ -26,6 +26,7 @@ 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 @@ public function testExportEntityFromRequest(): void $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')); } } diff --git a/tests/Services/ImportExportSystem/EntityImporterTest.php b/tests/Services/ImportExportSystem/EntityImporterTest.php index 31859b6af..db0b496b8 100644 --- a/tests/Services/ImportExportSystem/EntityImporterTest.php +++ b/tests/Services/ImportExportSystem/EntityImporterTest.php @@ -34,6 +34,9 @@ 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 @@ -182,6 +185,10 @@ public function formatDataProvider(): \Iterator yield ['json', 'json']; yield ['yaml', 'yml']; yield ['yaml', 'YAML']; + yield ['xlsx', 'xlsx']; + yield ['xlsx', 'XLSX']; + yield ['xls', 'xls']; + yield ['xls', 'XLS']; } /** @@ -319,4 +326,41 @@ public function testImportStringParts(): void $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); + } } diff --git a/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php b/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php new file mode 100644 index 000000000..2e709c96a --- /dev/null +++ b/tests/Services/InfoProviderSystem/Providers/LCSCProviderTest.php @@ -0,0 +1,532 @@ +. + */ + +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 PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +class LCSCProviderTest extends TestCase +{ + private LCSCProvider $provider; + private MockHttpClient $httpClient; + + protected function setUp(): void + { + $this->httpClient = new MockHttpClient(); + $this->provider = new LCSCProvider($this->httpClient, 'USD', true); + } + + 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 + { + $enabledProvider = new LCSCProvider($this->httpClient, 'USD', true); + $this->assertTrue($enabledProvider->isActive()); + } + + public function testIsActiveWhenDisabled(): void + { + $disabledProvider = new LCSCProvider($this->httpClient, 'USD', false); + $this->assertFalse($disabledProvider->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, ['Text without tags'])); + } + + 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']); + } +} \ No newline at end of file diff --git a/tests/Services/LabelSystem/PlaceholderProviders/TimestampableElementProviderTest.php b/tests/Services/LabelSystem/PlaceholderProviders/TimestampableElementProviderTest.php index 07bb42708..eb80828b1 100644 --- a/tests/Services/LabelSystem/PlaceholderProviders/TimestampableElementProviderTest.php +++ b/tests/Services/LabelSystem/PlaceholderProviders/TimestampableElementProviderTest.php @@ -59,26 +59,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 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]]']; } /** diff --git a/tests/Services/Parts/PartsTableActionHandlerTest.php b/tests/Services/Parts/PartsTableActionHandlerTest.php new file mode 100644 index 000000000..c5105cd7b --- /dev/null +++ b/tests/Services/Parts/PartsTableActionHandlerTest.php @@ -0,0 +1,62 @@ +. + */ +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()); + } + } + +} \ No newline at end of file diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index e974d34a9..57aff345f 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -242,7 +242,9 @@ part.info.timetravel_hint - This is how the part appeared before %timestamp%. <i>Please note that this feature is experimental, so the info may not be correct.</i> + Please note that this feature is experimental, so the info may not be correct. + ]]> @@ -731,10 +733,12 @@ user.edit.tfa.disable_tfa_message - This will disable <b>all active two-factor authentication methods of the user</b> and delete the <b>backup codes</b>! -<br> -The user will have to set up all two-factor authentication methods again and print new backup codes! <br><br> -<b>Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker!</b> + all active two-factor authentication methods of the user and delete the backup codes! +
+The user will have to set up all two-factor authentication methods again and print new backup codes!

+Only do this if you are absolutely sure about the identity of the user (seeking help), otherwise the account could be compromised by an attacker! + ]]>
@@ -885,9 +889,11 @@ The user will have to set up all two-factor authentication methods again and pri entity.delete.message - This can not be undone! -<br> -Sub elements will be moved upwards. + +Sub elements will be moved upwards. + ]]> @@ -1441,7 +1447,9 @@ Sub elements will be moved upwards. homepage.github.text - Source, downloads, bug reports, to-do-list etc. can be found on <a href="%href%" class="link-external" target="_blank">GitHub project page</a> + GitHub project page + ]]> @@ -1463,7 +1471,9 @@ Sub elements will be moved upwards. homepage.help.text - Help and tips can be found in Wiki the <a href="%href%" class="link-external" target="_blank">GitHub page</a> + GitHub page + ]]> @@ -1705,7 +1715,9 @@ Sub elements will be moved upwards. email.pw_reset.fallback - If this does not work for you, go to <a href="%url%">%url%</a> and enter the following info + %url% and enter the following info + ]]> @@ -1735,7 +1747,9 @@ Sub elements will be moved upwards. email.pw_reset.valid_unit %date% - The reset token will be valid until <i>%date%</i>. + %date%. + ]]> @@ -3578,8 +3592,10 @@ Sub elements will be moved upwards. tfa_google.disable.confirm_message - If you disable the Authenticator App, all backup codes will be deleted, so you may need to reprint them.<br> -Also note that without two-factor authentication, your account is no longer as well protected against attackers! + +Also note that without two-factor authentication, your account is no longer as well protected against attackers! + ]]> @@ -3599,7 +3615,9 @@ Also note that without two-factor authentication, your account is no longer as w tfa_google.step.download - Download an authenticator app (e.g. <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a> oder <a class="link-external" target="_blank" href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp">FreeOTP Authenticator</a>) + Google Authenticator oder FreeOTP Authenticator) + ]]> @@ -3841,8 +3859,10 @@ Also note that without two-factor authentication, your account is no longer as w tfa_trustedDevices.explanation - When checking the second factor, the current computer can be marked as trustworthy, so no more two-factor checks on this computer are needed. -If you have done this incorrectly or if a computer is no longer trusted, you can reset the status of <i>all </i>computers here. + all computers here. + ]]> @@ -5313,7 +5333,9 @@ If you have done this incorrectly or if a computer is no longer trusted, you can label_options.lines_mode.help - If you select Twig here, the content field is interpreted as Twig template. See <a href="https://twig.symfony.com/doc/3.x/templates.html">Twig documentation</a> and <a href="https://docs.part-db.de/usage/labels.html#twig-mode">Wiki</a> for more information. + Twig documentation and Wiki for more information. + ]]> @@ -8941,6 +8963,12 @@ Element 3 Edit part + + + part_list.action.scrollable_hint + Scroll to see all actions + + part_list.action.action.title @@ -9331,6 +9359,84 @@ Element 3 Attachment name + + + filter.bulk_import_job.label + Bulk Import Job + + + + + filter.bulk_import_job.job_status + Job Status + + + + + filter.bulk_import_job.part_status_in_job + Part Status in Job + + + + + filter.bulk_import_job.status.any + Any Status + + + + + filter.bulk_import_job.status.pending + Pending + + + + + filter.bulk_import_job.status.in_progress + In Progress + + + + + filter.bulk_import_job.status.completed + Completed + + + + + filter.bulk_import_job.status.stopped + Stopped + + + + + filter.bulk_import_job.status.failed + Failed + + + + + filter.bulk_import_job.part_status.any + Any Part Status + + + + + filter.bulk_import_job.part_status.pending + Pending + + + + + filter.bulk_import_job.part_status.completed + Completed + + + + + filter.bulk_import_job.part_status.skipped + Skipped + + filter.choice_constraint.operator.ANY @@ -9388,25 +9494,33 @@ Element 3 filter.parameter_value_constraint.operator.< - Typ. Value < + filter.parameter_value_constraint.operator.> - Typ. Value > + + ]]> filter.parameter_value_constraint.operator.<= - Typ. Value <= + filter.parameter_value_constraint.operator.>= - Typ. Value >= + = + ]]> @@ -9514,7 +9628,9 @@ Element 3 parts_list.search.searching_for - Searching parts with keyword <b>%keyword%</b> + %keyword% + ]]> @@ -10174,13 +10290,17 @@ Element 3 project.builds.number_of_builds_possible - You have enough stocked to build <b>%max_builds%</b> builds of this project. + %max_builds% builds of this project. + ]]> project.builds.check_project_status - The current project status is <b>"%project_status%"</b>. You should check if you really want to build the project with this status! + "%project_status%". You should check if you really want to build the project with this status! + ]]> @@ -10282,7 +10402,9 @@ Element 3 entity.select.add_hint - Use -> to create nested structures, e.g. "Node 1->Node 1.1" + to create nested structures, e.g. "Node 1->Node 1.1" + ]]> @@ -10306,13 +10428,17 @@ Element 3 homepage.first_steps.introduction - Your database is still empty. You might want to read the <a href="%url%">documentation</a> or start to creating the following data structures: + documentation or start to creating the following data structures: + ]]> homepage.first_steps.create_part - Or you can directly <a href="%url%">create a new part</a>. + create a new part. + ]]> @@ -10324,7 +10450,9 @@ Element 3 homepage.forum.text - For questions about Part-DB use the <a href="%href%" class="link-external" target="_blank">discussion forum</a> + discussion forum + ]]> @@ -10903,6 +11031,12 @@ Element 3 Export to XML + + + part_list.action.export_xlsx + Export to Excel + + parts.import.title @@ -10978,7 +11112,9 @@ Element 3 parts.import.help_documentation - See the <a href="%link%">documentation</a> for more information on the file format. + documentation for more information on the file format. + ]]> @@ -11158,7 +11294,9 @@ Element 3 part.filter.lessThanDesired - In stock less than desired (total amount < min. amount) + @@ -11970,13 +12108,17 @@ Please note, that you can not impersonate a disabled user. If you try you will g part.merge.confirm.title - Do you really want to merge <b>%other%</b> into <b>%target%</b>? + %other% into %target%? + ]]> part.merge.confirm.message - <b>%other%</b> will be deleted, and the part will be saved with the shown information. + %other% will be deleted, and the part will be saved with the shown information. + ]]> @@ -12210,7 +12352,7 @@ Please note, that you can not impersonate a disabled user. If you try you will g info_providers.search.no_results - No results found at the selected providers! Check your search term or try to choose additional providers. + No results found @@ -12369,5 +12511,755 @@ Please note, that you can not impersonate a disabled user. If you try you will g This part contains more than one stock. Change the location by hand to select, which stock to choose. + + + info_providers.bulk_import.step1.title + Bulk Info Provider Import - Step 1 + + + + + info_providers.bulk_import.parts_selected + parts selected + + + + + info_providers.bulk_import.step1.global_mapping_description + Configure field mappings that will be applied to all selected parts. For example: "MPN → LCSC + Mouser" means search LCSC and Mouser providers using each part's MPN field. + + + + + info_providers.bulk_import.selected_parts + Selected Parts + + + + + info_providers.bulk_import.field_mappings + Field Mappings + + + + + info_providers.bulk_import.field_mappings_help + Define which part fields to search with which info providers. Multiple mappings will be combined. + + + + + info_providers.bulk_import.add_mapping + Add Mapping + + + + + info_providers.bulk_import.search_results.title + Search Results + + + + + info_providers.bulk_import.errors + errors + + + + + info_providers.bulk_import.results_found + %count% results found + + + + + info_providers.bulk_import.source_field + Source Field + + + + + info_providers.bulk_import.create_part + Create Part + + + + + info_providers.bulk_import.view_existing + View Existing + + + + + info_providers.bulk_search.search_field + Search Field + + + + + info_providers.bulk_search.providers + Info Providers + + + + + info_providers.bulk_import.actions.label + Actions + + + + + info_providers.bulk_search.providers.help + Select which info providers to search when parts have this field. + + + + + info_providers.bulk_search.submit + Search All Parts + + + + + info_providers.bulk_search.field.select + Select a field to search by + + + + + info_providers.bulk_search.field.mpn + Manufacturer Part Number (MPN) + + + + + info_providers.bulk_search.field.name + Part Name + + + + + part_list.action.action.info_provider + Info Provider + + + + + part_list.action.bulk_info_provider_import + Bulk Info Provider Import + + + + + info_providers.bulk_import.clear_selections + Clear All Selections + + + + + info_providers.bulk_import.clear_row + Clear this row's selections + + + + + info_providers.bulk_import.step1.spn_recommendation + SPN (Supplier Part Number) is recommended for better results. Add a mapping for each supplier to use their SPNs. + + + + + info_providers.bulk_import.update_part + Update Part + + + + + info_providers.bulk_import.prefetch_details + Prefetch Details + + + + + info_providers.bulk_import.prefetch_details_help + Prefetch details for all results. This will take longer, but will speed up workflow for updating parts. + + + + + info_providers.bulk_import.step2.title + Bulk import from info providers + + + + + info_providers.bulk_import.step2.card_title + Bulk import for %count% parts - %date% + + + + + info_providers.bulk_import.parts + parts + + + + + info_providers.bulk_import.results + results + + + + + info_providers.bulk_import.created_at + Created at + + + + + info_providers.bulk_import.status.in_progress + In Progress + + + + + info_providers.bulk_import.status.completed + Completed + + + + + info_providers.bulk_import.status.failed + Failed + + + + + info_providers.bulk_import.table.name + Name + + + + + info_providers.bulk_import.table.description + Description + + + + + info_providers.bulk_import.table.manufacturer + Manufacturer + + + + + info_providers.bulk_import.table.provider + Provider + + + + + info_providers.bulk_import.table.source_field + Source Field + + + + + info_providers.bulk_import.table.action + Action + + + + + info_providers.bulk_import.action.select + Select + + + + + info_providers.bulk_import.action.deselect + Deselect + + + + + info_providers.bulk_import.action.view_details + View Details + + + + + info_providers.bulk_import.no_results + No results found + + + + + info_providers.bulk_import.processing + Processing... + + + + + info_providers.bulk_import.error + Error occurred during import + + + + + info_providers.bulk_import.success + Import completed successfully + + + + + info_providers.bulk_import.partial_success + Import completed with some errors + + + + + info_providers.bulk_import.retry + Retry + + + + + info_providers.bulk_import.cancel + Cancel + + + + + info_providers.bulk_import.confirm + Confirm Import + + + + + info_providers.bulk_import.back + Back + + + + + info_providers.bulk_import.next + Next + + + + + info_providers.bulk_import.finish + Finish + + + + + info_providers.bulk_import.progress + Progress: + + + + + info_providers.bulk_import.time_remaining + Estimated time remaining: %time% + + + + + info_providers.bulk_import.details_modal.title + Part Details + + + + + info_providers.bulk_import.details_modal.close + Close + + + + + info_providers.bulk_import.details_modal.select_this_part + Select This Part + + + + + info_providers.bulk_import.status.pending + Pending + + + + + info_providers.bulk_import.completed + completed + + + + + info_providers.bulk_import.skipped + skipped + + + + + info_providers.bulk_import.mark_completed + Mark Completed + + + + + info_providers.bulk_import.mark_skipped + Mark Skipped + + + + + info_providers.bulk_import.mark_pending + Mark Pending + + + + + info_providers.bulk_import.skip_reason + Skip reason + + + + + info_providers.bulk_import.editing_part + Editing part as part of bulk import + + + + + info_providers.bulk_import.complete + Complete + + + + + info_providers.bulk_import.existing_jobs + Existing Jobs + + + + + info_providers.bulk_import.job_name + Job Name + + + + + info_providers.bulk_import.parts_count + Parts Count + + + + + info_providers.bulk_import.results_count + Results Count + + + + + info_providers.bulk_import.progress_label + Progress: %current%/%total% + + + + + info_providers.bulk_import.manage_jobs + Manage Bulk Import Jobs + + + + + info_providers.bulk_import.view_results + View Results + + + + + info_providers.bulk_import.status + Status + + + + + info_providers.bulk_import.manage_jobs_description + View and manage all your bulk import jobs. To create a new job, select parts and click "Bulk import from info providers". + + + + + info_providers.bulk_import.no_jobs_found + No bulk import jobs found. + + + + + info_providers.bulk_import.create_first_job + Create your first bulk import job + + + + + info_providers.bulk_import.confirm_delete_job + Are you sure you want to delete this job? + + + + + info_providers.bulk_import.job_name_template + Bulk import for %count% parts + + + + + info_providers.bulk_import.step2.instructions.title + How to use bulk import + + + + + info_providers.bulk_import.step2.instructions.description + Follow these steps to efficiently update your parts: + + + + + info_providers.bulk_import.step2.instructions.step1 + Click "Update Part" to edit a part with the supplier data + + + + + info_providers.bulk_import.step2.instructions.step2 + Review and modify the part information as needed. Note: You need to click "Save" twice to save the changes. + + + + + info_providers.bulk_import.step2.instructions.step3 + Click "Complete" to mark the part as done and return to this overview + + + + + info_providers.bulk_import.created_by + Created By + + + + + info_providers.bulk_import.completed_at + Completed At + + + + + info_providers.bulk_import.action.label + Action + + + + + info_providers.bulk_import.action.delete + Delete + + + + + info_providers.bulk_import.status.active + Active + + + + + info_providers.bulk_import.progress.title + Progress + + + + + info_providers.bulk_import.progress.completed_text + %completed% / %total% completed + + + + + info_providers.bulk_import.error.deleting_job + Error deleting job + + + + + info_providers.bulk_import.error.unknown + Unknown error + + + + + info_providers.bulk_import.error.deleting_job_with_details + Error deleting job: %error% + + + + + info_providers.bulk_import.status.stopped + Stopped + + + + + info_providers.bulk_import.action.stop + Stop + + + + + info_providers.bulk_import.confirm_stop_job + Are you sure you want to stop this job? + + + + + part.filter.in_bulk_import_job + In Bulk Import Job + + + + + part.filter.in_bulk_import_job.yes + Yes + + + + + part.filter.in_bulk_import_job.no + No + + + + + part.filter.bulk_import_job_status + Bulk Import Job Status + + + + + part.filter.bulk_import_part_status + Bulk Import Part Status + + + + + part.edit.tab.bulk_import + Bulk Import Job + + + + + 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 + + + + + bulk_import.part_status.pending + Pending + + + + + bulk_import.part_status.completed + Completed + + + + + bulk_import.part_status.skipped + Skipped + + + + + bulk_import.part_status.failed + Failed + + + + + filter.operator + Operator + + + + + bulk_info_provider_import_job.label + Bulk Info Provider Import + + + + + bulk_info_provider_import_job_part.label + Bulk Import Job Part + + + + + info_providers.bulk_search.priority + Priority + + + + + info_providers.bulk_search.priority.help + Lower numbers = higher priority. Same priority = combine results. Different priorities = try highest first, fallback if no results. + + + + + info_providers.bulk_import.priority_system.title + Priority System + + + + + info_providers.bulk_import.priority_system.description + Lower numbers = higher priority. Same priority = combine results. Different priorities = try highest first, fallback if no results. + + + + + info_providers.bulk_import.priority_system.example + Example: Priority 1: "LCSC SPN → LCSC", Priority 2: "MPN → LCSC + Mouser", Priority 3: "Name → All providers" + +