diff --git a/migrations/Version20250802153643.php b/migrations/Version20250802153643.php new file mode 100644 index 00000000..70cbd527 --- /dev/null +++ b/migrations/Version20250802153643.php @@ -0,0 +1,32 @@ +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 down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE bulk_info_provider_import_jobs'); + } +} diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php index 6893de93..38739d71 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -22,6 +22,8 @@ declare(strict_types=1); namespace App\Controller; +use App\Entity\BulkInfoProviderImportJob; +use App\Entity\BulkImportJobStatus; use App\Entity\Parts\Part; use App\Entity\Parts\Supplier; use App\Form\InfoProviderSystem\GlobalFieldMappingType; @@ -36,8 +38,6 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use function Symfony\Component\Translation\t; - #[Route('/tools/bulk-info-provider-import')] class BulkInfoProviderImportController extends AbstractController { @@ -87,7 +87,8 @@ class BulkInfoProviderImportController extends AbstractController $initialData = [ 'field_mappings' => [ ['field' => 'mpn', 'providers' => []] - ] + ], + 'prefetch_details' => false ]; $form = $this->createForm(GlobalFieldMappingType::class, $initialData, [ @@ -98,7 +99,20 @@ class BulkInfoProviderImportController extends AbstractController $searchResults = null; if ($form->isSubmitted() && $form->isValid()) { - $fieldMappings = $form->getData()['field_mappings']; + $formData = $form->getData(); + $fieldMappings = $formData['field_mappings']; + $prefetchDetails = $formData['prefetch_details'] ?? false; + + // Create and save the job + $job = new BulkInfoProviderImportJob(); + $job->setPartIds(array_map(fn($part) => $part->getId(), $parts)); + $job->setFieldMappings($fieldMappings); + $job->setPrefetchDetails($prefetchDetails); + $job->setCreatedBy($this->getUser()); + + $this->entityManager->persist($job); + $this->entityManager->flush(); + $searchResults = []; foreach ($parts as $part) { @@ -161,16 +175,100 @@ class BulkInfoProviderImportController extends AbstractController $searchResults[] = $partResult; } + + // Save search results to job + $job->setSearchResults($this->serializeSearchResults($searchResults)); + $job->markAsInProgress(); + $this->entityManager->flush(); + + // Prefetch details if requested + if ($prefetchDetails && !empty($searchResults)) { + $this->prefetchDetailsForResults($searchResults, $exceptionLogger); + } + + // Redirect to step 2 with the job + return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]); } + // Get existing in-progress jobs for current user + $existingJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class) + ->findBy(['createdBy' => $this->getUser(), 'status' => BulkImportJobStatus::IN_PROGRESS], ['createdAt' => 'DESC'], 10); + return $this->render('info_providers/bulk_import/step1.html.twig', [ 'form' => $form, 'parts' => $parts, 'search_results' => $searchResults, + 'existing_jobs' => $existingJobs, 'fieldChoices' => $fieldChoices ]); } + #[Route('/manage', name: 'bulk_info_provider_manage')] + public function manageBulkJobs(): Response + { + // Get all jobs for current user + $allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class) + ->findBy([], ['createdAt' => 'DESC']); + + // Check and auto-complete jobs that should be completed + $updatedJobs = false; + foreach ($allJobs as $job) { + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + $updatedJobs = true; + } + } + + // Flush changes if any jobs were updated + if ($updatedJobs) { + $this->entityManager->flush(); + } + + return $this->render('info_providers/bulk_import/manage.html.twig', [ + 'jobs' => $allJobs + ]); + } + + #[Route('/job/{jobId}/delete', name: 'bulk_info_provider_delete', methods: ['DELETE'])] + public function deleteJob(int $jobId): Response + { + $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId); + + if (!$job || $job->getCreatedBy() !== $this->getUser()) { + return $this->json(['error' => 'Job not found or access denied'], 404); + } + + // Only allow deletion of completed, failed, or stopped jobs + if (!$job->isCompleted() && !$job->isFailed() && !$job->isStopped()) { + return $this->json(['error' => 'Cannot delete active job'], 400); + } + + $this->entityManager->remove($job); + $this->entityManager->flush(); + + return $this->json(['success' => true]); + } + + #[Route('/job/{jobId}/stop', name: 'bulk_info_provider_stop', methods: ['POST'])] + public function stopJob(int $jobId): Response + { + $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId); + + if (!$job || $job->getCreatedBy() !== $this->getUser()) { + return $this->json(['error' => 'Job not found or access denied'], 404); + } + + // Only allow stopping of pending or in-progress jobs + if (!$job->canBeStopped()) { + return $this->json(['error' => 'Cannot stop job in current status'], 400); + } + + $job->markAsStopped(); + $this->entityManager->flush(); + + return $this->json(['success' => true]); + } + private function getKeywordFromField(Part $part, string $field): ?string { return match ($field) { @@ -207,4 +305,232 @@ class BulkInfoProviderImportController extends AbstractController return null; } + + /** + * Prefetch details for all search results to populate cache + */ + private function prefetchDetailsForResults(array $searchResults, LoggerInterface $logger): void + { + $prefetchCount = 0; + + foreach ($searchResults as $partResult) { + foreach ($partResult['search_results'] as $result) { + $dto = $result['dto']; + + try { + // This call will cache the details for later use + $this->infoRetriever->getDetails($dto->provider_key, $dto->provider_id); + $prefetchCount++; + } catch (\Exception $e) { + $logger->warning('Failed to prefetch details for provider part', [ + 'provider_key' => $dto->provider_key, + 'provider_id' => $dto->provider_id, + 'error' => $e->getMessage() + ]); + } + } + } + + if ($prefetchCount > 0) { + $this->addFlash('success', "Prefetched details for {$prefetchCount} search results"); + } + } + + #[Route('/step2/{jobId}', name: 'bulk_info_provider_step2')] + public function step2(int $jobId): Response + { + $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId); + + if (!$job) { + $this->addFlash('error', 'Bulk import job not found'); + return $this->redirectToRoute('bulk_info_provider_step1'); + } + + // Check if user owns this job + if ($job->getCreatedBy() !== $this->getUser()) { + $this->addFlash('error', 'Access denied to this bulk import job'); + return $this->redirectToRoute('bulk_info_provider_step1'); + } + + // Get the parts and deserialize search results + $partRepository = $this->entityManager->getRepository(Part::class); + $parts = $partRepository->getElementsFromIDArray($job->getPartIds()); + $searchResults = $this->deserializeSearchResults($job->getSearchResults(), $parts); + + return $this->render('info_providers/bulk_import/step2.html.twig', [ + 'job' => $job, + 'parts' => $parts, + 'search_results' => $searchResults, + ]); + } + + private function serializeSearchResults(array $searchResults): array + { + $serialized = []; + + foreach ($searchResults as $partResult) { + $partData = [ + 'part_id' => $partResult['part']->getId(), + 'search_results' => [], + 'errors' => $partResult['errors'] + ]; + + foreach ($partResult['search_results'] as $result) { + $dto = $result['dto']; + $partData['search_results'][] = [ + 'dto' => [ + 'provider_key' => $dto->provider_key, + 'provider_id' => $dto->provider_id, + 'name' => $dto->name, + 'description' => $dto->description, + 'manufacturer' => $dto->manufacturer, + 'mpn' => $dto->mpn, + 'provider_url' => $dto->provider_url, + 'preview_image_url' => $dto->preview_image_url, + '_source_field' => $dto->_source_field ?? null, + '_source_keyword' => $dto->_source_keyword ?? null, + ], + 'localPart' => $result['localPart'] ? $result['localPart']->getId() : null + ]; + } + + $serialized[] = $partData; + } + + return $serialized; + } + + private function deserializeSearchResults(array $serializedResults, array $parts): array + { + $partsById = []; + foreach ($parts as $part) { + $partsById[$part->getId()] = $part; + } + + $searchResults = []; + + foreach ($serializedResults as $partData) { + $part = $partsById[$partData['part_id']] ?? null; + if (!$part) { + continue; + } + + $partResult = [ + 'part' => $part, + 'search_results' => [], + 'errors' => $partData['errors'] + ]; + + foreach ($partData['search_results'] as $resultData) { + $dtoData = $resultData['dto']; + + $dto = new \App\Services\InfoProviderSystem\DTOs\SearchResultDTO( + provider_key: $dtoData['provider_key'], + provider_id: $dtoData['provider_id'], + name: $dtoData['name'], + description: $dtoData['description'], + manufacturer: $dtoData['manufacturer'], + mpn: $dtoData['mpn'], + provider_url: $dtoData['provider_url'], + preview_image_url: $dtoData['preview_image_url'] + ); + + // Add the source field info + $dto->_source_field = $dtoData['_source_field']; + $dto->_source_keyword = $dtoData['_source_keyword']; + + $localPart = null; + if ($resultData['localPart']) { + $localPart = $this->entityManager->getRepository(Part::class)->find($resultData['localPart']); + } + + $partResult['search_results'][] = [ + 'dto' => $dto, + 'localPart' => $localPart + ]; + } + + $searchResults[] = $partResult; + } + + return $searchResults; + } + + #[Route('/job/{jobId}/part/{partId}/mark-completed', name: 'bulk_info_provider_mark_completed', methods: ['POST'])] + public function markPartCompleted(int $jobId, int $partId): Response + { + $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId); + + if (!$job || $job->getCreatedBy() !== $this->getUser()) { + return $this->json(['error' => 'Job not found or access denied'], 404); + } + + $job->markPartAsCompleted($partId); + + // Auto-complete job if all parts are done + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + } + + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted() + ]); + } + + #[Route('/job/{jobId}/part/{partId}/mark-skipped', name: 'bulk_info_provider_mark_skipped', methods: ['POST'])] + public function markPartSkipped(int $jobId, int $partId, Request $request): Response + { + $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId); + + if (!$job || $job->getCreatedBy() !== $this->getUser()) { + return $this->json(['error' => 'Job not found or access denied'], 404); + } + + $reason = $request->request->get('reason', ''); + $job->markPartAsSkipped($partId, $reason); + + // Auto-complete job if all parts are done + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + } + + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'skipped_count' => $job->getSkippedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted() + ]); + } + + #[Route('/job/{jobId}/part/{partId}/mark-pending', name: 'bulk_info_provider_mark_pending', methods: ['POST'])] + public function markPartPending(int $jobId, int $partId): Response + { + $job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId); + + if (!$job || $job->getCreatedBy() !== $this->getUser()) { + return $this->json(['error' => 'Job not found or access denied'], 404); + } + + $job->markPartAsPending($partId); + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'skipped_count' => $job->getSkippedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted() + ]); + } } \ No newline at end of file diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index b11a5c90..e9c577f0 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -131,7 +131,43 @@ class PartController extends AbstractController { $this->denyAccessUnlessGranted('edit', $part); - return $this->renderPartForm('edit', $request, $part); + // Check if this is part of a bulk import job + $jobId = $request->query->get('jobId'); + $bulkJob = null; + if ($jobId) { + $bulkJob = $this->em->getRepository(\App\Entity\BulkInfoProviderImportJob::class)->find($jobId); + // Verify user owns this job + if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) { + $bulkJob = null; + } + } + + return $this->renderPartForm('edit', $request, $part, [], [ + 'bulk_job' => $bulkJob + ]); + } + + #[Route(path: '/{id}/bulk-import-complete/{jobId}', name: 'part_bulk_import_complete', methods: ['POST'])] + public function markBulkImportComplete(Part $part, int $jobId, Request $request): Response + { + $this->denyAccessUnlessGranted('edit', $part); + + if (!$this->isCsrfTokenValid('bulk_complete_' . $part->getId(), $request->request->get('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token'); + } + + $bulkJob = $this->em->getRepository(\App\Entity\BulkInfoProviderImportJob::class)->find($jobId); + if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) { + throw $this->createNotFoundException('Bulk import job not found'); + } + + $bulkJob->markPartAsCompleted($part->getId()); + $this->em->persist($bulkJob); + $this->em->flush(); + + $this->addFlash('success', 'Part marked as completed in bulk import'); + + return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $jobId]); } #[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])] @@ -273,10 +309,22 @@ class PartController extends AbstractController $this->addFlash('notice', t('part.merge.flash.please_review')); + // Check if this is part of a bulk import job + $jobId = $request->query->get('jobId'); + $bulkJob = null; + if ($jobId) { + $bulkJob = $this->em->getRepository(\App\Entity\BulkInfoProviderImportJob::class)->find($jobId); + // Verify user owns this job + if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) { + $bulkJob = null; + } + } + return $this->renderPartForm('update_from_ip', $request, $part, [ 'info_provider_dto' => $dto, ], [ - 'tname_before' => $old_name + 'tname_before' => $old_name, + 'bulk_job' => $bulkJob ]); } @@ -352,6 +400,12 @@ class PartController extends AbstractController return $this->redirectToRoute('part_new'); } + // Check if we're in bulk import mode and preserve jobId + $jobId = $request->query->get('jobId'); + if ($jobId && isset($merge_infos['bulk_job'])) { + return $this->redirectToRoute('part_edit', ['id' => $new_part->getID(), 'jobId' => $jobId]); + } + return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]); } @@ -375,7 +429,9 @@ class PartController extends AbstractController 'part' => $new_part, 'form' => $form, 'merge_old_name' => $merge_infos['tname_before'] ?? null, - 'merge_other' => $merge_infos['other_part'] ?? null + 'merge_other' => $merge_infos['other_part'] ?? null, + 'bulk_job' => $merge_infos['bulk_job'] ?? null, + 'jobId' => $request->query->get('jobId') ]); } diff --git a/src/Entity/BulkInfoProviderImportJob.php b/src/Entity/BulkInfoProviderImportJob.php new file mode 100644 index 00000000..9ab5c5ce --- /dev/null +++ b/src/Entity/BulkInfoProviderImportJob.php @@ -0,0 +1,336 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity; + +use App\Entity\Base\AbstractDBElement; +use App\Entity\UserSystem\User; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +enum BulkImportJobStatus: string +{ + case PENDING = 'pending'; + case IN_PROGRESS = 'in_progress'; + case COMPLETED = 'completed'; + case STOPPED = 'stopped'; + case FAILED = 'failed'; +} + +#[ORM\Entity] +#[ORM\Table(name: 'bulk_info_provider_import_jobs')] +class BulkInfoProviderImportJob extends AbstractDBElement +{ + #[ORM\Column(type: Types::TEXT)] + private string $name = ''; + + #[ORM\Column(type: Types::JSON)] + private array $partIds = []; + + #[ORM\Column(type: Types::JSON)] + private array $fieldMappings = []; + + #[ORM\Column(type: Types::JSON)] + private array $searchResults = []; + + #[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportJobStatus::class)] + private BulkImportJobStatus $status = BulkImportJobStatus::PENDING; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $completedAt = null; + + #[ORM\Column(type: Types::BOOLEAN)] + private bool $prefetchDetails = false; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false)] + private User $createdBy; + + #[ORM\Column(type: Types::JSON)] + private array $progress = []; + + public function __construct() + { + $this->createdAt = new \DateTimeImmutable(); + } + + public function getName(): string + { + return $this->name; + } + + public function getDisplayNameKey(): string + { + return 'info_providers.bulk_import.job_name_template'; + } + + public function getDisplayNameParams(): array + { + return ['%count%' => $this->getPartCount()]; + } + + public function getFormattedTimestamp(): string + { + return $this->createdAt->format('Y-m-d H:i:s'); + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getPartIds(): array + { + return $this->partIds; + } + + public function setPartIds(array $partIds): self + { + $this->partIds = $partIds; + return $this; + } + + public function getFieldMappings(): array + { + return $this->fieldMappings; + } + + public function setFieldMappings(array $fieldMappings): self + { + $this->fieldMappings = $fieldMappings; + return $this; + } + + public function getSearchResults(): array + { + return $this->searchResults; + } + + public function setSearchResults(array $searchResults): self + { + $this->searchResults = $searchResults; + return $this; + } + + public function getStatus(): BulkImportJobStatus + { + return $this->status; + } + + public function setStatus(BulkImportJobStatus $status): self + { + $this->status = $status; + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getCompletedAt(): ?\DateTimeImmutable + { + return $this->completedAt; + } + + public function setCompletedAt(?\DateTimeImmutable $completedAt): self + { + $this->completedAt = $completedAt; + return $this; + } + + public function isPrefetchDetails(): bool + { + return $this->prefetchDetails; + } + + public function setPrefetchDetails(bool $prefetchDetails): self + { + $this->prefetchDetails = $prefetchDetails; + return $this; + } + + public function getCreatedBy(): User + { + return $this->createdBy; + } + + public function setCreatedBy(User $createdBy): self + { + $this->createdBy = $createdBy; + return $this; + } + + public function getProgress(): array + { + return $this->progress; + } + + public function setProgress(array $progress): self + { + $this->progress = $progress; + return $this; + } + + public function markAsCompleted(): self + { + $this->status = BulkImportJobStatus::COMPLETED; + $this->completedAt = new \DateTimeImmutable(); + return $this; + } + + public function markAsFailed(): self + { + $this->status = BulkImportJobStatus::FAILED; + $this->completedAt = new \DateTimeImmutable(); + return $this; + } + + public function markAsStopped(): self + { + $this->status = BulkImportJobStatus::STOPPED; + $this->completedAt = new \DateTimeImmutable(); + return $this; + } + + public function markAsInProgress(): self + { + $this->status = BulkImportJobStatus::IN_PROGRESS; + return $this; + } + + public function isPending(): bool + { + return $this->status === BulkImportJobStatus::PENDING; + } + + public function isInProgress(): bool + { + return $this->status === BulkImportJobStatus::IN_PROGRESS; + } + + public function isCompleted(): bool + { + return $this->status === BulkImportJobStatus::COMPLETED; + } + + public function isFailed(): bool + { + return $this->status === BulkImportJobStatus::FAILED; + } + + public function isStopped(): bool + { + return $this->status === BulkImportJobStatus::STOPPED; + } + + public function canBeStopped(): bool + { + return $this->status === BulkImportJobStatus::PENDING || $this->status === BulkImportJobStatus::IN_PROGRESS; + } + + public function getPartCount(): int + { + return count($this->partIds); + } + + public function getResultCount(): int + { + $count = 0; + foreach ($this->searchResults as $partResult) { + $count += count($partResult['search_results'] ?? []); + } + return $count; + } + + public function markPartAsCompleted(int $partId): self + { + $this->progress[$partId] = [ + 'status' => 'completed', + 'completed_at' => (new \DateTimeImmutable())->format('c') + ]; + return $this; + } + + public function markPartAsSkipped(int $partId, string $reason = ''): self + { + $this->progress[$partId] = [ + 'status' => 'skipped', + 'reason' => $reason, + 'completed_at' => (new \DateTimeImmutable())->format('c') + ]; + return $this; + } + + public function markPartAsPending(int $partId): self + { + // Remove from progress array to mark as pending + unset($this->progress[$partId]); + return $this; + } + + public function isPartCompleted(int $partId): bool + { + return isset($this->progress[$partId]) && $this->progress[$partId]['status'] === 'completed'; + } + + public function isPartSkipped(int $partId): bool + { + return isset($this->progress[$partId]) && $this->progress[$partId]['status'] === 'skipped'; + } + + public function getCompletedPartsCount(): int + { + return count(array_filter($this->progress, fn($p) => $p['status'] === 'completed')); + } + + public function getSkippedPartsCount(): int + { + return count(array_filter($this->progress, fn($p) => $p['status'] === 'skipped')); + } + + public function getProgressPercentage(): float + { + $total = $this->getPartCount(); + if ($total === 0) { + return 100.0; + } + + $completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount(); + return round(($completed / $total) * 100, 1); + } + + public function isAllPartsCompleted(): bool + { + $total = $this->getPartCount(); + if ($total === 0) { + return true; + } + + $completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount(); + return $completed >= $total; + } +} \ No newline at end of file diff --git a/src/Entity/LogSystem/LogTargetType.php b/src/Entity/LogSystem/LogTargetType.php index 1c6e4f8c..55c18c1b 100644 --- a/src/Entity/LogSystem/LogTargetType.php +++ b/src/Entity/LogSystem/LogTargetType.php @@ -24,6 +24,7 @@ namespace App\Entity\LogSystem; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; +use App\Entity\BulkInfoProviderImportJob; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\Category; @@ -67,6 +68,7 @@ enum LogTargetType: int case LABEL_PROFILE = 19; case PART_ASSOCIATION = 20; + case BULK_INFO_PROVIDER_IMPORT_JOB = 21; /** * Returns the class name of the target type or null if the target type is NONE. @@ -96,6 +98,7 @@ enum LogTargetType: int self::PARAMETER => AbstractParameter::class, self::LABEL_PROFILE => LabelProfile::class, self::PART_ASSOCIATION => PartAssociation::class, + self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class, }; } diff --git a/src/Form/InfoProviderSystem/GlobalFieldMappingType.php b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php index ecc3dbc9..1f2af5b1 100644 --- a/src/Form/InfoProviderSystem/GlobalFieldMappingType.php +++ b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Form\InfoProviderSystem; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; @@ -46,6 +47,12 @@ class GlobalFieldMappingType extends AbstractType 'label' => false, ]); + $builder->add('prefetch_details', CheckboxType::class, [ + 'label' => 'info_providers.bulk_import.prefetch_details', + 'required' => false, + 'help' => 'info_providers.bulk_import.prefetch_details_help', + ]); + $builder->add('submit', SubmitType::class, [ 'label' => 'info_providers.bulk_search.submit' ]); diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php index 14247145..558fa0e4 100644 --- a/src/Services/ElementTypeNameGenerator.php +++ b/src/Services/ElementTypeNameGenerator.php @@ -26,6 +26,7 @@ use App\Entity\Attachments\AttachmentContainingDBElement; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractDBElement; +use App\Entity\BulkInfoProviderImportJob; use App\Entity\Contracts\NamedElementInterface; use App\Entity\Parts\PartAssociation; use App\Entity\ProjectSystem\Project; @@ -79,6 +80,7 @@ class ElementTypeNameGenerator AbstractParameter::class => $this->translator->trans('parameter.label'), LabelProfile::class => $this->translator->trans('label_profile.label'), PartAssociation::class => $this->translator->trans('part_association.label'), + BulkInfoProviderImportJob::class => $this->translator->trans('bulk_info_provider_import_job.label'), ]; } diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index f7a9d1c4..036797f6 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -138,6 +138,11 @@ class ToolsTreeBuilder $this->translator->trans('info_providers.search.title'), $this->urlGenerator->generate('info_providers_search') ))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down'); + + $nodes[] = (new TreeViewNode( + $this->translator->trans('info_providers.bulk_import.manage_jobs'), + $this->urlGenerator->generate('bulk_info_provider_manage') + ))->setIcon('fa-treeview fa-fw fa-solid fa-tasks'); } return $nodes; diff --git a/templates/info_providers/bulk_import/manage.html.twig b/templates/info_providers/bulk_import/manage.html.twig new file mode 100644 index 00000000..0a21f211 --- /dev/null +++ b/templates/info_providers/bulk_import/manage.html.twig @@ -0,0 +1,168 @@ +{% extends "main_card.html.twig" %} + +{% block title %} + {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %} +{% endblock %} + +{% block card_title %} + {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %} +{% endblock %} + +{% block card_content %} + +
+ {% trans %}info_providers.bulk_import.manage_jobs_description{% endtrans %} +
+| {% trans %}info_providers.bulk_import.job_name{% endtrans %} | +{% trans %}info_providers.bulk_import.parts_count{% endtrans %} | +{% trans %}info_providers.bulk_import.results_count{% endtrans %} | +{% trans %}info_providers.bulk_import.progress{% endtrans %} | +{% trans %}info_providers.bulk_import.status{% endtrans %} | +{% trans %}info_providers.bulk_import.created_by{% endtrans %} | +{% trans %}info_providers.bulk_import.created_at{% endtrans %} | +{% trans %}info_providers.bulk_import.completed_at{% endtrans %} | +{% trans %}info_providers.bulk_import.action.label{% endtrans %} | +
|---|---|---|---|---|---|---|---|---|
| + {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} + {% if job.isInProgress %} + Active + {% endif %} + | +{{ job.partCount }} | +{{ job.resultCount }} | +
+
+
+
+ {% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %}
+
+
+
+
+ {{ job.progressPercentage }}%
+ |
+ + {% if job.isPending %} + {% trans %}info_providers.bulk_import.status.pending{% endtrans %} + {% elseif job.isInProgress %} + {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} + {% elseif job.isCompleted %} + {% trans %}info_providers.bulk_import.status.completed{% endtrans %} + {% elseif job.isStopped %} + {% trans %}info_providers.bulk_import.status.stopped{% endtrans %} + {% elseif job.isFailed %} + {% trans %}info_providers.bulk_import.status.failed{% endtrans %} + {% endif %} + | +{{ job.createdBy.username }} | +{{ job.createdAt|date('Y-m-d H:i') }} | ++ {% if job.completedAt %} + {{ job.completedAt|date('Y-m-d H:i') }} + {% else %} + - + {% endif %} + | +
+
+ {% if job.isInProgress or job.isCompleted or job.isStopped %}
+
+ {% trans %}info_providers.bulk_import.view_results{% endtrans %}
+
+ {% endif %}
+ {% if job.canBeStopped %}
+
+ {% endif %}
+ {% if job.isCompleted or job.isFailed or job.isStopped %}
+
+ {% endif %}
+
+ |
+
| {% trans %}info_providers.bulk_import.job_name{% endtrans %} | +{% trans %}info_providers.bulk_import.parts_count{% endtrans %} | +{% trans %}info_providers.bulk_import.results_count{% endtrans %} | +{% trans %}info_providers.bulk_import.progress{% endtrans %} | +{% trans %}info_providers.bulk_import.status{% endtrans %} | +{% trans %}info_providers.bulk_import.created_at{% endtrans %} | +{% trans %}action.label{% endtrans %} | +
|---|---|---|---|---|---|---|
| {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} | +{{ job.partCount }} | +{{ job.resultCount }} | +
+
+
+ {{ job.completedPartsCount }}/{{ job.partCount }}
+
+
+
+ {{ job.progressPercentage }}%
+ |
+ + {% if job.isPending %} + {% trans %}info_providers.bulk_import.status.pending{% endtrans %} + {% elseif job.isInProgress %} + {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} + {% elseif job.isCompleted %} + {% trans %}info_providers.bulk_import.status.completed{% endtrans %} + {% elseif job.isFailed %} + {% trans %}info_providers.bulk_import.status.failed{% endtrans %} + {% endif %} + | +{{ job.createdAt|date('Y-m-d H:i') }} | ++ {% if job.isInProgress or job.isCompleted %} + + {% trans %}info_providers.bulk_import.view_results{% endtrans %} + + {% endif %} + | +
{% trans %}info_providers.bulk_import.step2.instructions.description{% endtrans %}
+| + | {% trans %}name.label{% endtrans %} | +{% trans %}description.label{% endtrans %} | +{% trans %}manufacturer.label{% endtrans %} | +{% trans %}info_providers.table.provider.label{% endtrans %} | +{% trans %}info_providers.bulk_import.source_field{% endtrans %} | +{% trans %}action.label{% endtrans %} | +
|---|---|---|---|---|---|---|
|
+ |
+
+ {% if dto.provider_url is not null %}
+ {{ dto.name }}
+ {% else %}
+ {{ dto.name }}
+ {% endif %}
+ {% if dto.mpn is not null %}
+ {{ dto.mpn }} + {% endif %} + |
+ {{ dto.description }} | +{{ dto.manufacturer ?? '' }} | +
+ {{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
+ {{ dto.provider_id }} + |
+
+ {{ dto._source_field ?? 'unknown' }}
+ {% if dto._source_keyword %}
+ {{ dto._source_keyword }} + {% endif %} + |
+
+
+ {% set updateHref = path('info_providers_update_part',
+ {'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %}
+
+ {% trans %}info_providers.bulk_import.update_part{% endtrans %}
+
+
+ |
+