Add makefile to help with development setup, change part_ids in bulk import jobs to junction table and implement filtering based on bulk import jobs status and its associated parts' statuses.

This commit is contained in:
barisgit 2025-08-02 23:35:30 +02:00 committed by Jan Böhmer
parent 9b4d5e9c27
commit cc9d50a8fe
22 changed files with 1357 additions and 120 deletions

99
Makefile Normal file
View file

@ -0,0 +1,99 @@
# PartDB Makefile for Test Environment Management
.PHONY: help test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset
# Default target
help:
@echo "PartDB Test Environment Management"
@echo "=================================="
@echo ""
@echo "Available targets:"
@echo " test-setup - Complete test environment setup (clean, create DB, migrate, load fixtures)"
@echo " test-clean - Clean test cache and database files"
@echo " test-db-create - Create test database (if not exists)"
@echo " test-db-migrate - Run database migrations for test environment"
@echo " test-cache-clear- Clear test cache"
@echo " test-fixtures - Load test fixtures"
@echo " test-run - Run PHPUnit tests"
@echo ""
@echo "Development Environment:"
@echo " dev-setup - Complete development environment setup (clean, create DB, migrate, warmup)"
@echo " dev-clean - Clean development cache and database files"
@echo " dev-db-create - Create development database (if not exists)"
@echo " dev-db-migrate - Run database migrations for development environment"
@echo " dev-cache-clear - Clear development cache"
@echo " dev-warmup - Warm up development cache"
@echo " dev-reset - Quick development reset (clean + migrate)"
@echo ""
@echo " help - Show this help message"
# Complete test environment setup
test-setup: test-clean test-db-create test-db-migrate test-fixtures
@echo "✅ Test environment setup complete!"
# Clean test environment
test-clean:
@echo "🧹 Cleaning test environment..."
rm -rf var/cache/test
rm -f var/app_test.db
@echo "✅ Test environment cleaned"
# Create test database
test-db-create:
@echo "🗄️ Creating test database..."
-php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
# Run database migrations for test environment
test-db-migrate:
@echo "🔄 Running database migrations..."
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test
# Clear test cache
test-cache-clear:
@echo "🗑️ Clearing test cache..."
rm -rf var/cache/test
@echo "✅ Test cache cleared"
# Load test fixtures
test-fixtures:
@echo "📦 Loading test fixtures..."
php bin/console partdb:fixtures:load -n --env test
# Run PHPUnit tests
test-run:
@echo "🧪 Running tests..."
php bin/phpunit
# Quick test reset (clean + migrate + fixtures, skip DB creation)
test-reset: test-cache-clear test-db-migrate test-fixtures
@echo "✅ Test environment reset complete!"
# Development helpers
dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup
@echo "✅ Development environment setup complete!"
dev-clean:
@echo "🧹 Cleaning development environment..."
rm -rf var/cache/dev
rm -f var/app_dev.db
@echo "✅ Development environment cleaned"
dev-db-create:
@echo "🗄️ Creating development database..."
-php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..."
dev-db-migrate:
@echo "🔄 Running database migrations..."
COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev
dev-cache-clear:
@echo "🗑️ Clearing development cache..."
rm -rf var/cache/dev
@echo "✅ Development cache cleared"
dev-warmup:
@echo "🔥 Warming up development cache..."
COMPOSER_MEMORY_LIMIT=-1 php bin/console cache:warmup --env dev -n --memory-limit=1G
dev-reset: dev-cache-clear dev-db-migrate
@echo "✅ Development environment reset complete!"

View file

@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250802153643 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add bulk info provider import jobs table';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INT AUTO_INCREMENT NOT NULL, name LONGTEXT NOT NULL, 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');
}
}

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250802205143 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add bulk info provider import jobs and job parts tables';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INT AUTO_INCREMENT NOT NULL, name LONGTEXT NOT NULL, field_mappings LONGTEXT NOT NULL, search_results LONGTEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details TINYINT(1) NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES `users` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INT AUTO_INCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason LONGTEXT DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id), CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES `parts` (id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name CLOB NOT NULL, field_mappings CLOB NOT NULL, search_results CLOB NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, completed_at DATETIME DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INTEGER NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, status VARCHAR(20) NOT NULL, reason CLOB DEFAULT NULL, completed_at DATETIME DEFAULT NULL, job_id INTEGER NOT NULL, part_id INTEGER NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql('CREATE TABLE bulk_info_provider_import_jobs (id SERIAL PRIMARY KEY NOT NULL, name TEXT NOT NULL, field_mappings TEXT NOT NULL, search_results TEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, prefetch_details BOOLEAN NOT NULL, created_by_id INT NOT NULL, CONSTRAINT FK_7F58C1EDB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_7F58C1EDB03A8386 ON bulk_info_provider_import_jobs (created_by_id)');
$this->addSql('CREATE TABLE bulk_info_provider_import_job_parts (id SERIAL PRIMARY KEY NOT NULL, status VARCHAR(20) NOT NULL, reason TEXT DEFAULT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, job_id INT NOT NULL, part_id INT NOT NULL, CONSTRAINT FK_CD93F28FBE04EA9 FOREIGN KEY (job_id) REFERENCES bulk_info_provider_import_jobs (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_CD93F28F4CE34BEC FOREIGN KEY (part_id) REFERENCES parts (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_CD93F28FBE04EA9 ON bulk_info_provider_import_job_parts (job_id)');
$this->addSql('CREATE INDEX IDX_CD93F28F4CE34BEC ON bulk_info_provider_import_job_parts (part_id)');
$this->addSql('CREATE UNIQUE INDEX unique_job_part ON bulk_info_provider_import_job_parts (job_id, part_id)');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('DROP TABLE bulk_info_provider_import_job_parts');
$this->addSql('DROP TABLE bulk_info_provider_import_jobs');
}
}

View file

@ -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', [

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\Entity\BulkInfoProviderImportJobPart;
use Doctrine\ORM\QueryBuilder;
class BulkImportJobExistsConstraint extends AbstractConstraint
{
/** @var bool|null The value of our constraint */
protected ?bool $value = null;
public function __construct()
{
parent::__construct('bulk_import_job_exists');
}
/**
* Gets the value of this constraint. Null means "don't filter", true means "filter for parts in bulk import jobs", false means "filter for parts not in bulk import jobs".
*/
public function getValue(): ?bool
{
return $this->value;
}
/**
* Sets the value of this constraint. Null means "don't filter", true means "filter for parts in bulk import jobs", false means "filter for parts not in bulk import jobs".
*/
public function setValue(?bool $value): void
{
$this->value = $value;
}
public function isEnabled(): bool
{
return $this->value !== null;
}
public function apply(QueryBuilder $queryBuilder): void
{
// Do not apply a filter if value is null (filter is set to ignore)
if (!$this->isEnabled()) {
return;
}
// Use EXISTS subquery to avoid join conflicts
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
$existsSubquery->select('1')
->from(BulkInfoProviderImportJobPart::class, 'bip_exists')
->where('bip_exists.part = part.id');
if ($this->value === true) {
// Filter for parts that ARE in bulk import jobs
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
} else {
// Filter for parts that are NOT in bulk import jobs
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
}
}
}

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\Entity\BulkInfoProviderImportJobPart;
use Doctrine\ORM\QueryBuilder;
class BulkImportJobStatusConstraint extends AbstractConstraint
{
/** @var array The status values to filter by */
protected array $values = [];
/** @var string|null The operator to use ('any_of', 'none_of', 'all_of') */
protected ?string $operator = null;
public function __construct()
{
parent::__construct('bulk_import_job_status');
}
/**
* Gets the status values to filter by.
*/
public function getValues(): array
{
return $this->values;
}
/**
* Sets the status values to filter by.
*/
public function setValues(array $values): void
{
$this->values = $values;
}
/**
* Gets the operator to use.
*/
public function getOperator(): ?string
{
return $this->operator;
}
/**
* Sets the operator to use.
*/
public function setOperator(?string $operator): void
{
$this->operator = $operator;
}
public function isEnabled(): bool
{
return !empty($this->values) && $this->operator !== null;
}
public function apply(QueryBuilder $queryBuilder): void
{
// Do not apply a filter if values are empty or operator is null
if (!$this->isEnabled()) {
return;
}
// Use EXISTS subquery to check if part has a job with the specified status(es)
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
$existsSubquery->select('1')
->from(BulkInfoProviderImportJobPart::class, 'bip_status')
->join('bip_status.job', 'job_status')
->where('bip_status.part = part.id');
// Add status conditions based on operator
if ($this->operator === 'ANY') {
$existsSubquery->andWhere('job_status.status IN (:job_status_values)');
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
$queryBuilder->setParameter('job_status_values', $this->values);
} elseif ($this->operator === 'NONE') {
$existsSubquery->andWhere('job_status.status IN (:job_status_values)');
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
$queryBuilder->setParameter('job_status_values', $this->values);
}
}
}

View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\Entity\BulkInfoProviderImportJobPart;
use Doctrine\ORM\QueryBuilder;
class BulkImportPartStatusConstraint extends AbstractConstraint
{
/** @var array The status values to filter by */
protected array $values = [];
/** @var string|null The operator to use ('any_of', 'none_of', 'all_of') */
protected ?string $operator = null;
public function __construct()
{
parent::__construct('bulk_import_part_status');
}
/**
* Gets the status values to filter by.
*/
public function getValues(): array
{
return $this->values;
}
/**
* Sets the status values to filter by.
*/
public function setValues(array $values): void
{
$this->values = $values;
}
/**
* Gets the operator to use.
*/
public function getOperator(): ?string
{
return $this->operator;
}
/**
* Sets the operator to use.
*/
public function setOperator(?string $operator): void
{
$this->operator = $operator;
}
public function isEnabled(): bool
{
return !empty($this->values) && $this->operator !== null;
}
public function apply(QueryBuilder $queryBuilder): void
{
// Do not apply a filter if values are empty or operator is null
if (!$this->isEnabled()) {
return;
}
// Use EXISTS subquery to check if part has the specified status(es)
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
$existsSubquery->select('1')
->from(BulkInfoProviderImportJobPart::class, 'bip_part_status')
->where('bip_part_status.part = part.id');
// Add status conditions based on operator
if ($this->operator === 'ANY') {
$existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
$queryBuilder->setParameter('part_status_values', $this->values);
} elseif ($this->operator === 'NONE') {
$existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
$queryBuilder->setParameter('part_status_values', $this->values);
}
}
}

View file

@ -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

View file

@ -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;
}

View file

@ -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<int, BulkInfoProviderImportJobPart> */
#[ORM\OneToMany(targetEntity: BulkInfoProviderImportJobPart::class, mappedBy: 'job', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $jobParts;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->jobParts = new ArrayCollection();
}
public function getName(): string
@ -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

View file

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Entity;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Part;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
enum BulkImportPartStatus: string
{
case PENDING = 'pending';
case COMPLETED = 'completed';
case SKIPPED = 'skipped';
case FAILED = 'failed';
}
#[ORM\Entity]
#[ORM\Table(name: 'bulk_info_provider_import_job_parts')]
#[ORM\UniqueConstraint(name: 'unique_job_part', columns: ['job_id', 'part_id'])]
class BulkInfoProviderImportJobPart extends AbstractDBElement
{
#[ORM\ManyToOne(targetEntity: BulkInfoProviderImportJob::class, inversedBy: 'jobParts')]
#[ORM\JoinColumn(nullable: false)]
private BulkInfoProviderImportJob $job;
#[ORM\ManyToOne(targetEntity: Part::class)]
#[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;
}
}

View file

@ -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,
};
}

View file

@ -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")'),
@ -100,7 +111,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])]
#[ApiFilter(TagFilter::class, properties: ["tags"])]
#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])]
#[ApiFilter(BooleanFilter::class, properties: ["favorite", "needs_review"])]
#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
@ -160,6 +171,12 @@ class Part extends AttachmentContainingDBElement
#[Groups(['part:read'])]
protected ?\DateTimeImmutable $lastModified = null;
/**
* @var Collection<int, BulkInfoProviderImportJobPart>
*/
#[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)]
protected Collection $bulkImportJobParts;
public function __construct()
{
@ -172,6 +189,7 @@ class Part extends AttachmentContainingDBElement
$this->associated_parts_as_owner = new ArrayCollection();
$this->associated_parts_as_other = new ArrayCollection();
$this->bulkImportJobParts = new ArrayCollection();
//By default, the part has no provider
$this->providerReference = InfoProviderReference::noProvider();
@ -230,4 +248,38 @@ class Part extends AttachmentContainingDBElement
}
}
}
/**
* Get all bulk import job parts for this part
* @return Collection<int, BulkInfoProviderImportJobPart>
*/
public function getBulkImportJobParts(): Collection
{
return $this->bulkImportJobParts;
}
/**
* Add a bulk import job part to this part
*/
public function addBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
{
if (!$this->bulkImportJobParts->contains($jobPart)) {
$this->bulkImportJobParts->add($jobPart);
$jobPart->setPart($this);
}
return $this;
}
/**
* Remove a bulk import job part from this part
*/
public function removeBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
{
if ($this->bulkImportJobParts->removeElement($jobPart)) {
if ($jobPart->getPart() === $this) {
$jobPart->setPart(null);
}
}
return $this;
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\Part\BulkImportJobExistsConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BulkImportJobExistsConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => BulkImportJobExistsConstraint::class,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$choices = [
'' => '',
'part.filter.in_bulk_import_job.yes' => true,
'part.filter.in_bulk_import_job.no' => false,
];
$builder->add('value', ChoiceType::class, [
'label' => 'part.filter.in_bulk_import_job',
'choices' => $choices,
'required' => false,
]);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
parent::buildView($view, $form, $options);
}
}

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BulkImportJobStatusConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => BulkImportJobStatusConstraint::class,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$statusChoices = [
'bulk_import.status.pending' => 'pending',
'bulk_import.status.in_progress' => 'in_progress',
'bulk_import.status.completed' => 'completed',
'bulk_import.status.stopped' => 'stopped',
'bulk_import.status.failed' => 'failed',
];
$operatorChoices = [
'filter.choice_constraint.operator.ANY' => 'ANY',
'filter.choice_constraint.operator.NONE' => 'NONE',
];
$builder->add('operator', ChoiceType::class, [
'label' => 'filter.operator',
'choices' => $operatorChoices,
'required' => false,
]);
$builder->add('values', ChoiceType::class, [
'label' => 'part.filter.bulk_import_job_status',
'choices' => $statusChoices,
'required' => false,
'multiple' => true,
'attr' => [
'data-controller' => 'elements--select-multiple',
]
]);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
parent::buildView($view, $form, $options);
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BulkImportPartStatusConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => BulkImportPartStatusConstraint::class,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$statusChoices = [
'bulk_import.part_status.pending' => 'pending',
'bulk_import.part_status.completed' => 'completed',
'bulk_import.part_status.skipped' => 'skipped',
'bulk_import.part_status.failed' => 'failed',
];
$operatorChoices = [
'filter.choice_constraint.operator.ANY' => 'ANY',
'filter.choice_constraint.operator.NONE' => 'NONE',
];
$builder->add('operator', ChoiceType::class, [
'label' => 'filter.operator',
'choices' => $operatorChoices,
'required' => false,
]);
$builder->add('values', ChoiceType::class, [
'label' => 'part.filter.bulk_import_part_status',
'choices' => $statusChoices,
'required' => false,
'multiple' => true,
'attr' => [
'data-controller' => 'elements--select-multiple',
]
]);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
parent::buildView($view, $form, $options);
}
}

View file

@ -129,6 +129,7 @@ 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',
},
]);

View file

@ -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',

View file

@ -31,6 +31,11 @@
<button class="nav-link" id="filter-projects-tab" data-bs-toggle="tab" data-bs-target="#filter-projects"><i class="fas fa-archive fa-fw"></i> {% trans %}project.labelp{% endtrans %}</button>
</li>
{% endif %}
{% if filterForm.inBulkImportJob is defined %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="filter-bulk-import-tab" data-bs-toggle="tab" data-bs-target="#filter-bulk-import"><i class="fas fa-download fa-fw"></i> {% trans %}part.edit.tab.bulk_import{% endtrans %}</button>
</li>
{% endif %}
</ul>
{{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }}
@ -126,6 +131,13 @@
{{ form_row(filterForm.bomComment) }}
</div>
{% endif %}
{% if filterForm.inBulkImportJob is defined %}
<div class="tab-pane pt-3" id="filter-bulk-import" role="tabpanel" aria-labelledby="filter-bulk-import-tab" tabindex="0">
{{ form_row(filterForm.inBulkImportJob) }}
{{ form_row(filterForm.bulkImportJobStatus) }}
{{ form_row(filterForm.bulkImportPartStatus) }}
</div>
{% endif %}
</div>

View file

@ -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;
}
}

View file

@ -41,6 +41,14 @@ class BulkInfoProviderImportJobTest extends TestCase
$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();
@ -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,7 +283,15 @@ 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

View file

@ -13801,5 +13801,101 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>Are you sure you want to stop this job?</target>
</segment>
</unit>
<unit id="bulk109" name="part.filter.in_bulk_import_job">
<segment state="translated">
<source>part.filter.in_bulk_import_job</source>
<target>In Bulk Import Job</target>
</segment>
</unit>
<unit id="bulk110" name="part.filter.in_bulk_import_job.yes">
<segment state="translated">
<source>part.filter.in_bulk_import_job.yes</source>
<target>Yes</target>
</segment>
</unit>
<unit id="bulk111" name="part.filter.in_bulk_import_job.no">
<segment state="translated">
<source>part.filter.in_bulk_import_job.no</source>
<target>No</target>
</segment>
</unit>
<unit id="bulk112" name="part.filter.bulk_import_job_status">
<segment state="translated">
<source>part.filter.bulk_import_job_status</source>
<target>Bulk Import Job Status</target>
</segment>
</unit>
<unit id="bulk113" name="part.filter.bulk_import_part_status">
<segment state="translated">
<source>part.filter.bulk_import_part_status</source>
<target>Bulk Import Part Status</target>
</segment>
</unit>
<unit id="bulk114" name="part.edit.tab.bulk_import">
<segment state="translated">
<source>part.edit.tab.bulk_import</source>
<target>Bulk Import Job</target>
</segment>
</unit>
<unit id="bulk115" name="bulk_import.status.pending">
<segment state="translated">
<source>bulk_import.status.pending</source>
<target>Pending</target>
</segment>
</unit>
<unit id="bulk116" name="bulk_import.status.in_progress">
<segment state="translated">
<source>bulk_import.status.in_progress</source>
<target>In Progress</target>
</segment>
</unit>
<unit id="bulk117" name="bulk_import.status.completed">
<segment state="translated">
<source>bulk_import.status.completed</source>
<target>Completed</target>
</segment>
</unit>
<unit id="bulk118" name="bulk_import.status.stopped">
<segment state="translated">
<source>bulk_import.status.stopped</source>
<target>Stopped</target>
</segment>
</unit>
<unit id="bulk119" name="bulk_import.status.failed">
<segment state="translated">
<source>bulk_import.status.failed</source>
<target>Failed</target>
</segment>
</unit>
<unit id="bulk120" name="bulk_import.part_status.pending">
<segment state="translated">
<source>bulk_import.part_status.pending</source>
<target>Pending</target>
</segment>
</unit>
<unit id="bulk121" name="bulk_import.part_status.completed">
<segment state="translated">
<source>bulk_import.part_status.completed</source>
<target>Completed</target>
</segment>
</unit>
<unit id="bulk122" name="bulk_import.part_status.skipped">
<segment state="translated">
<source>bulk_import.part_status.skipped</source>
<target>Skipped</target>
</segment>
</unit>
<unit id="bulk123" name="bulk_import.part_status.failed">
<segment state="translated">
<source>bulk_import.part_status.failed</source>
<target>Failed</target>
</segment>
</unit>
<unit id="bulk124" name="filter.operator">
<segment state="translated">
<source>filter.operator</source>
<target>Operator</target>
</segment>
</unit>
</file>
</xliff>