diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..68c63d3f --- /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/migrations/Version20250802153643.php b/migrations/Version20250802153643.php deleted file mode 100644 index 2b2873f9..00000000 --- a/migrations/Version20250802153643.php +++ /dev/null @@ -1,52 +0,0 @@ -addSql('CREATE TABLE bulk_info_provider_import_jobs (id INT AUTO_INCREMENT NOT NULL, name LONGTEXT NOT NULL, part_ids 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, progress LONGTEXT 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)'); - } - - public function mySQLDown(Schema $schema): void - { - $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, part_ids 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, progress CLOB 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)'); - } - - public function sqLiteDown(Schema $schema): void - { - $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, part_ids 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, progress TEXT 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)'); - } - - public function postgreSQLDown(Schema $schema): void - { - $this->addSql('DROP TABLE bulk_info_provider_import_jobs'); - } -} diff --git a/migrations/Version20250802205143.php b/migrations/Version20250802205143.php new file mode 100644 index 00000000..5eb09a77 --- /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 index 82ff21c9..6c434191 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -23,6 +23,7 @@ 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; @@ -104,7 +105,6 @@ class BulkInfoProviderImportController extends AbstractController // Create and save the job $job = new BulkInfoProviderImportJob(); - $job->setPartIds(array_map(fn($part) => $part->getId(), $parts)); $job->setFieldMappings($fieldMappings); $job->setPrefetchDetails($prefetchDetails); $user = $this->getUser(); @@ -113,6 +113,12 @@ class BulkInfoProviderImportController extends AbstractController } $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(); @@ -179,7 +185,7 @@ class BulkInfoProviderImportController extends AbstractController // Convert DTOs to result format with metadata $partResult['search_results'] = array_map( - function($dto) use ($dtoMetadata) { + function ($dto) use ($dtoMetadata) { $dtoKey = $dto->provider_key . '|' . $dto->provider_id; $metadata = $dtoMetadata[$dtoKey] ?? []; return [ @@ -372,8 +378,7 @@ class BulkInfoProviderImportController extends AbstractController } // Get the parts and deserialize search results - $partRepository = $this->entityManager->getRepository(Part::class); - $parts = $partRepository->getElementsFromIDArray($job->getPartIds()); + $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', [ diff --git a/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php b/src/DataTables/Filters/Constraints/Part/BulkImportJobExistsConstraint.php new file mode 100644 index 00000000..0e5a3696 --- /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 00000000..cc5c8ce0 --- /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 00000000..168934d6 --- /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 ff98c76f..a13bb929 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -31,6 +31,9 @@ use App\DataTables\Filters\Constraints\NumberConstraint; use App\DataTables\Filters\Constraints\Part\LessThanDesiredConstraint; use App\DataTables\Filters\Constraints\Part\ParameterConstraint; use App\DataTables\Filters\Constraints\Part\TagsConstraint; +use App\DataTables\Filters\Constraints\Part\BulkImportJobExistsConstraint; +use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint; +use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint; use App\DataTables\Filters\Constraints\TextConstraint; use App\Entity\Attachments\AttachmentType; use App\Entity\Parts\Category; @@ -42,6 +45,8 @@ use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Entity\ProjectSystem\Project; use App\Entity\UserSystem\User; +use App\Entity\BulkInfoProviderImportJob; +use App\Entity\BulkInfoProviderImportJobPart; use App\Services\Trees\NodesListBuilder; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\QueryBuilder; @@ -101,6 +106,14 @@ class PartFilter implements FilterInterface public readonly TextConstraint $bomName; public readonly TextConstraint $bomComment; + /************************************************* + * Bulk Import Job tab + *************************************************/ + + public readonly BulkImportJobExistsConstraint $inBulkImportJob; + public readonly BulkImportJobStatusConstraint $bulkImportJobStatus; + public readonly BulkImportPartStatusConstraint $bulkImportPartStatus; + public function __construct(NodesListBuilder $nodesListBuilder) { $this->name = new TextConstraint('part.name'); @@ -126,7 +139,7 @@ class PartFilter implements FilterInterface */ $this->amountSum = (new IntConstraint('( SELECT COALESCE(SUM(__partLot.amount), 0.0) - FROM '.PartLot::class.' __partLot + FROM ' . PartLot::class . ' __partLot WHERE __partLot.part = part.id AND __partLot.instock_unknown = false AND (__partLot.expiration_date IS NULL OR __partLot.expiration_date > CURRENT_DATE()) @@ -162,6 +175,11 @@ class PartFilter implements FilterInterface $this->bomName = new TextConstraint('_projectBomEntries.name'); $this->bomComment = new TextConstraint('_projectBomEntries.comment'); + // Bulk Import Job filters + $this->inBulkImportJob = new BulkImportJobExistsConstraint(); + $this->bulkImportJobStatus = new BulkImportJobStatusConstraint(); + $this->bulkImportPartStatus = new BulkImportPartStatusConstraint(); + } public function apply(QueryBuilder $queryBuilder): void diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index f0decf27..f63cb9a4 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -43,6 +43,7 @@ use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; use App\Entity\ProjectSystem\Project; +use App\Entity\BulkInfoProviderImportJobPart; use App\Services\EntityURLGenerator; use App\Services\Formatters\AmountFormatter; use App\Settings\BehaviorSettings\TableSettings; @@ -142,23 +143,25 @@ final class PartsDataTable implements DataTableTypeInterface 'label' => $this->translator->trans('part.table.storeLocations'), //We need to use a aggregate function to get the first store location, as we have a one-to-many relation 'orderField' => 'NATSORT(MIN(_storelocations.name))', - 'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context), + 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context), ], alias: 'storage_location') ->add('amount', TextColumn::class, [ 'label' => $this->translator->trans('part.table.amount'), - 'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderAmount($context), + 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context), 'orderField' => 'amountSum' ]) ->add('minamount', TextColumn::class, [ 'label' => $this->translator->trans('part.table.minamount'), - 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value, - $context->getPartUnit())), + 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format( + $value, + $context->getPartUnit() + )), ]) ->add('partUnit', TextColumn::class, [ 'label' => $this->translator->trans('part.table.partUnit'), 'orderField' => 'NATSORT(_partUnit.name)', - 'render' => function($value, Part $context): string { + 'render' => function ($value, Part $context): string { $partUnit = $context->getPartUnit(); if ($partUnit === null) { return ''; @@ -167,7 +170,7 @@ final class PartsDataTable implements DataTableTypeInterface $tmp = htmlspecialchars($partUnit->getName()); if ($partUnit->getUnit()) { - $tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')'; + $tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')'; } return $tmp; } @@ -230,7 +233,7 @@ final class PartsDataTable implements DataTableTypeInterface } if (count($projects) > $max) { - $tmp .= ", + ".(count($projects) - $max); + $tmp .= ", + " . (count($projects) - $max); } return $tmp; @@ -366,7 +369,7 @@ final class PartsDataTable implements DataTableTypeInterface $builder->addSelect( '( SELECT COALESCE(SUM(partLot.amount), 0.0) - FROM '.PartLot::class.' partLot + FROM ' . PartLot::class . ' partLot WHERE partLot.part = part.id AND partLot.instock_unknown = false AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE()) @@ -423,6 +426,13 @@ final class PartsDataTable implements DataTableTypeInterface //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_projectBomEntries'); } + if (str_contains($dql, '_jobPart')) { + $builder->leftJoin('part.bulkImportJobParts', '_jobPart'); + $builder->leftJoin('_jobPart.job', '_bulkImportJob'); + //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 + //$builder->addGroupBy('_jobPart'); + //$builder->addGroupBy('_bulkImportJob'); + } return $builder; } diff --git a/src/Entity/BulkInfoProviderImportJob.php b/src/Entity/BulkInfoProviderImportJob.php index 0525a3b7..2a602030 100644 --- a/src/Entity/BulkInfoProviderImportJob.php +++ b/src/Entity/BulkInfoProviderImportJob.php @@ -23,7 +23,10 @@ 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; @@ -43,9 +46,6 @@ class BulkInfoProviderImportJob extends AbstractDBElement #[ORM\Column(type: Types::TEXT)] private string $name = ''; - #[ORM\Column(type: Types::JSON)] - private array $partIds = []; - #[ORM\Column(type: Types::JSON)] private array $fieldMappings = []; @@ -68,12 +68,14 @@ class BulkInfoProviderImportJob extends AbstractDBElement #[ORM\JoinColumn(nullable: false)] private ?User $createdBy = null; - #[ORM\Column(type: Types::JSON)] - private array $progress = []; + /** @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 @@ -102,14 +104,50 @@ class BulkInfoProviderImportJob extends AbstractDBElement 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->partIds; + return $this->jobParts->map(fn($jobPart) => $jobPart->getPart()->getId())->toArray(); } public function setPartIds(array $partIds): self { - $this->partIds = $partIds; + // 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; } @@ -186,12 +224,31 @@ class BulkInfoProviderImportJob extends AbstractDBElement public function getProgress(): array { - return $this->progress; + $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->progress = $progress; + // This method is kept for backward compatibility + // The progress is now managed through the jobParts relationship return $this; } @@ -254,7 +311,7 @@ class BulkInfoProviderImportJob extends AbstractDBElement public function getPartCount(): int { - return count($this->partIds); + return $this->jobParts->count(); } public function getResultCount(): int @@ -268,48 +325,61 @@ class BulkInfoProviderImportJob extends AbstractDBElement public function markPartAsCompleted(int $partId): self { - $this->progress[$partId] = [ - 'status' => 'completed', - 'completed_at' => (new \DateTimeImmutable())->format('c') - ]; + $jobPart = $this->findJobPartByPartId($partId); + if ($jobPart) { + $jobPart->markAsCompleted(); + } return $this; } public function markPartAsSkipped(int $partId, string $reason = ''): self { - $this->progress[$partId] = [ - 'status' => 'skipped', - 'reason' => $reason, - 'completed_at' => (new \DateTimeImmutable())->format('c') - ]; + $jobPart = $this->findJobPartByPartId($partId); + if ($jobPart) { + $jobPart->markAsSkipped($reason); + } return $this; } public function markPartAsPending(int $partId): self { - // Remove from progress array to mark as pending - unset($this->progress[$partId]); + $jobPart = $this->findJobPartByPartId($partId); + if ($jobPart) { + $jobPart->markAsPending(); + } return $this; } public function isPartCompleted(int $partId): bool { - return isset($this->progress[$partId]) && $this->progress[$partId]['status'] === 'completed'; + $jobPart = $this->findJobPartByPartId($partId); + return $jobPart ? $jobPart->isCompleted() : false; } public function isPartSkipped(int $partId): bool { - return isset($this->progress[$partId]) && $this->progress[$partId]['status'] === 'skipped'; + $jobPart = $this->findJobPartByPartId($partId); + return $jobPart ? $jobPart->isSkipped() : false; } public function getCompletedPartsCount(): int { - return count(array_filter($this->progress, fn($p) => $p['status'] === 'completed')); + return $this->jobParts->filter(fn($jobPart) => $jobPart->isCompleted())->count(); } public function getSkippedPartsCount(): int { - return count(array_filter($this->progress, fn($p) => $p['status'] === 'skipped')); + 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 diff --git a/src/Entity/BulkInfoProviderImportJobPart.php b/src/Entity/BulkInfoProviderImportJobPart.php new file mode 100644 index 00000000..df99aa19 --- /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)] + #[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 55c18c1b..1e07ddc5 100644 --- a/src/Entity/LogSystem/LogTargetType.php +++ b/src/Entity/LogSystem/LogTargetType.php @@ -25,6 +25,7 @@ namespace App\Entity\LogSystem; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; use App\Entity\BulkInfoProviderImportJob; +use App\Entity\BulkInfoProviderImportJobPart; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\Category; @@ -69,6 +70,7 @@ enum LogTargetType: int 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. @@ -99,6 +101,7 @@ enum LogTargetType: int 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 14a7903f..98c1b884 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -55,6 +55,7 @@ use App\Entity\Parts\PartTraits\ManufacturerTrait; use App\Entity\Parts\PartTraits\OrderTrait; use App\Entity\Parts\PartTraits\ProjectTrait; use App\EntityListeners\TreeCacheInvalidationListener; +use App\Entity\BulkInfoProviderImportJobPart; use App\Repository\PartRepository; use App\Validator\Constraints\UniqueObjectCollection; use Doctrine\Common\Collections\ArrayCollection; @@ -83,8 +84,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')] #[ApiResource( operations: [ - new Get(normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read', - 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'], + new Get(normalizationContext: [ + 'groups' => [ + 'part:read', + 'provider_reference:read', + 'api:basic:read', + 'part_lot:read', + 'orderdetail:read', + 'pricedetail:read', + 'parameter:read', + 'attachment:read', + 'eda_info:read' + ], 'openapi_definition_name' => 'Read', ], security: 'is_granted("read", object)'), new GetCollection(security: 'is_granted("@parts.read")'), @@ -92,7 +103,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; new Patch(security: 'is_granted("edit", object)'), new Delete(security: 'is_granted("delete", object)'), ], - normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'], + normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'], denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], )] #[ApiFilter(PropertyFilter::class)] @@ -100,7 +111,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])] #[ApiFilter(TagFilter::class, properties: ["tags"])] -#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])] +#[ApiFilter(BooleanFilter::class, properties: ["favorite", "needs_review"])] #[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])] #[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] #[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])] @@ -160,6 +171,12 @@ class Part extends AttachmentContainingDBElement #[Groups(['part:read'])] protected ?\DateTimeImmutable $lastModified = null; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)] + protected Collection $bulkImportJobParts; + public function __construct() { @@ -172,6 +189,7 @@ class Part extends AttachmentContainingDBElement $this->associated_parts_as_owner = new ArrayCollection(); $this->associated_parts_as_other = new ArrayCollection(); + $this->bulkImportJobParts = new ArrayCollection(); //By default, the part has no provider $this->providerReference = InfoProviderReference::noProvider(); @@ -230,4 +248,38 @@ class Part extends AttachmentContainingDBElement } } } + + /** + * Get all bulk import job parts for this part + * @return Collection + */ + 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/Filters/Constraints/BulkImportJobExistsConstraintType.php b/src/Form/Filters/Constraints/BulkImportJobExistsConstraintType.php new file mode 100644 index 00000000..e26b5f5a --- /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 00000000..6809f98b --- /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 00000000..e02a3197 --- /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 45b1d6dc..c973ad0f 100644 --- a/src/Form/Filters/LogFilterType.php +++ b/src/Form/Filters/LogFilterType.php @@ -100,7 +100,7 @@ class LogFilterType extends AbstractType ]); $builder->add('user', UserEntityConstraintType::class, [ - 'label' => 'log.user', + 'label' => 'log.user', ]); $builder->add('targetType', EnumConstraintType::class, [ @@ -129,11 +129,12 @@ class LogFilterType extends AbstractType 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 dfe449d1..1515c61b 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -32,7 +32,11 @@ use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Entity\ProjectSystem\Project; +use App\Entity\BulkInfoProviderImportJob; use App\Form\Filters\Constraints\BooleanConstraintType; +use App\Form\Filters\Constraints\BulkImportJobExistsConstraintType; +use App\Form\Filters\Constraints\BulkImportJobStatusConstraintType; +use App\Form\Filters\Constraints\BulkImportPartStatusConstraintType; use App\Form\Filters\Constraints\ChoiceConstraintType; use App\Form\Filters\Constraints\DateTimeConstraintType; use App\Form\Filters\Constraints\NumberConstraintType; @@ -298,6 +302,23 @@ class PartFilterType extends AbstractType } + /************************************************************************** + * Bulk Import Job tab + **************************************************************************/ + if ($this->security->isGranted('@info_providers.create_parts')) { + $builder + ->add('inBulkImportJob', BulkImportJobExistsConstraintType::class, [ + 'label' => 'part.filter.in_bulk_import_job', + ]) + ->add('bulkImportJobStatus', BulkImportJobStatusConstraintType::class, [ + 'label' => 'part.filter.bulk_import_job_status', + ]) + ->add('bulkImportPartStatus', BulkImportPartStatusConstraintType::class, [ + 'label' => 'part.filter.bulk_import_part_status', + ]) + ; + } + $builder->add('submit', SubmitType::class, [ 'label' => 'filter.submit', diff --git a/templates/parts/lists/_filter.html.twig b/templates/parts/lists/_filter.html.twig index c29e8ecd..ba9168d1 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 index 17a1c235..0cf57696 100644 --- a/tests/Controller/BulkInfoProviderImportControllerTest.php +++ b/tests/Controller/BulkInfoProviderImportControllerTest.php @@ -140,7 +140,7 @@ class BulkInfoProviderImportControllerTest extends WebTestCase // Create a test job with search results that include source_field and source_keyword $job = new BulkInfoProviderImportJob(); $job->setCreatedBy($user); - $job->setPartIds([$part->getId()]); + $job->addPart($part); $job->setStatus(BulkImportJobStatus::IN_PROGRESS); $job->setSearchResults([ [ @@ -230,10 +230,18 @@ class BulkInfoProviderImportControllerTest extends WebTestCase $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->setPartIds([1]); + $job->addPart($part); $job->setStatus(BulkImportJobStatus::COMPLETED); $job->setSearchResults([]); @@ -272,10 +280,15 @@ class BulkInfoProviderImportControllerTest extends WebTestCase $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); - $job->setPartIds([1]); + foreach ($parts as $part) { + $job->addPart($part); + } $job->setStatus(BulkImportJobStatus::IN_PROGRESS); $job->setSearchResults([]); @@ -306,10 +319,15 @@ class BulkInfoProviderImportControllerTest extends WebTestCase $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); - $job->setPartIds([1]); + foreach ($parts as $part) { + $job->addPart($part); + } $job->setStatus(BulkImportJobStatus::IN_PROGRESS); $job->setSearchResults([]); @@ -352,9 +370,14 @@ class BulkInfoProviderImportControllerTest extends WebTestCase $this->markTestSkipped('Admin user not found in fixtures'); } + // Get test parts + $parts = $this->getTestParts($entityManager, [1, 2]); + $job = new BulkInfoProviderImportJob(); $job->setCreatedBy($user); - $job->setPartIds([1, 2]); + foreach ($parts as $part) { + $job->addPart($part); + } $job->setStatus(BulkImportJobStatus::IN_PROGRESS); $job->setSearchResults([]); @@ -387,9 +410,14 @@ class BulkInfoProviderImportControllerTest extends WebTestCase $this->markTestSkipped('Admin user not found in fixtures'); } + // Get test parts + $parts = $this->getTestParts($entityManager, [1, 2]); + $job = new BulkInfoProviderImportJob(); $job->setCreatedBy($user); - $job->setPartIds([1, 2]); + foreach ($parts as $part) { + $job->addPart($part); + } $job->setStatus(BulkImportJobStatus::IN_PROGRESS); $job->setSearchResults([]); @@ -423,9 +451,14 @@ class BulkInfoProviderImportControllerTest extends WebTestCase $this->markTestSkipped('Admin user not found in fixtures'); } + // Get test parts + $parts = $this->getTestParts($entityManager, [1]); + $job = new BulkInfoProviderImportJob(); $job->setCreatedBy($user); - $job->setPartIds([1]); + foreach ($parts as $part) { + $job->addPart($part); + } $job->setStatus(BulkImportJobStatus::IN_PROGRESS); $job->setSearchResults([]); @@ -467,10 +500,15 @@ class BulkInfoProviderImportControllerTest extends WebTestCase $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); - $job->setPartIds([1]); + foreach ($parts as $part) { + $job->addPart($part); + } $job->setStatus(BulkImportJobStatus::IN_PROGRESS); $job->setSearchResults([]); @@ -502,10 +540,15 @@ class BulkInfoProviderImportControllerTest extends WebTestCase $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); - $job->setPartIds([1]); + foreach ($parts as $part) { + $job->addPart($part); + } $job->setStatus(BulkImportJobStatus::COMPLETED); $job->setSearchResults([]); @@ -534,4 +577,20 @@ class BulkInfoProviderImportControllerTest extends WebTestCase $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; + } } \ No newline at end of file diff --git a/tests/Entity/BulkInfoProviderImportJobTest.php b/tests/Entity/BulkInfoProviderImportJobTest.php index bf82b413..48678bf7 100644 --- a/tests/Entity/BulkInfoProviderImportJobTest.php +++ b/tests/Entity/BulkInfoProviderImportJobTest.php @@ -36,15 +36,23 @@ class BulkInfoProviderImportJobTest extends TestCase { $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()); @@ -60,9 +68,12 @@ class BulkInfoProviderImportJobTest extends TestCase $this->job->setName('Test Job'); $this->assertEquals('Test Job', $this->job->getName()); - $partIds = [1, 2, 3]; - $this->job->setPartIds($partIds); - $this->assertEquals($partIds, $this->job->getPartIds()); + // 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); @@ -133,7 +144,17 @@ class BulkInfoProviderImportJobTest extends TestCase { $this->assertEquals(0, $this->job->getPartCount()); - $this->job->setPartIds([1, 2, 3, 4, 5]); + // 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()); } @@ -152,7 +173,16 @@ class BulkInfoProviderImportJobTest extends TestCase public function testPartProgressTracking(): void { - $this->job->setPartIds([1, 2, 3, 4]); + // 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)); @@ -172,7 +202,17 @@ class BulkInfoProviderImportJobTest extends TestCase public function testProgressCounts(): void { - $this->job->setPartIds([1, 2, 3, 4, 5]); + // 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()); @@ -190,7 +230,18 @@ class BulkInfoProviderImportJobTest extends TestCase $emptyJob = new BulkInfoProviderImportJob(); $this->assertEquals(100.0, $emptyJob->getProgressPercentage()); - $this->job->setPartIds([1, 2, 3, 4, 5]); + // 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); @@ -210,7 +261,16 @@ class BulkInfoProviderImportJobTest extends TestCase $emptyJob = new BulkInfoProviderImportJob(); $this->assertTrue($emptyJob->isAllPartsCompleted()); - $this->job->setPartIds([1, 2, 3]); + // 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); @@ -223,8 +283,16 @@ class BulkInfoProviderImportJobTest extends TestCase public function testDisplayNameMethods(): void { - $this->job->setPartIds([1, 2, 3]); - + // 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()); } @@ -237,19 +305,39 @@ class BulkInfoProviderImportJobTest extends TestCase 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(); - - $this->assertArrayHasKey(1, $progress); + + // 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]); - $this->assertArrayHasKey(2, $progress); + // 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 diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 875f8d42..3d304fb5 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13801,5 +13801,101 @@ Please note, that you can not impersonate a disabled user. If you try you will g 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 + + \ No newline at end of file