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 %} +

+
+ + {% if jobs is not empty %} +
+ + + + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + + + + {% endfor %} + +
{% 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 }} +
+
+
+
+
+ {{ job.progressPercentage }}% +
+ + {% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %} + +
+ {% 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 %} +
+
+
+ {% else %} + + {% endif %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/info_providers/bulk_import/step1.html.twig b/templates/info_providers/bulk_import/step1.html.twig index 34f7abb9..bb24f28f 100644 --- a/templates/info_providers/bulk_import/step1.html.twig +++ b/templates/info_providers/bulk_import/step1.html.twig @@ -14,6 +14,74 @@ {% block card_content %} + + {% if existing_jobs is not empty %} +
+
+
{% trans %}info_providers.bulk_import.existing_jobs{% endtrans %}
+
+
+
+ + + + + + + + + + + + + + {% for job in existing_jobs %} + + + + + + + + + + {% endfor %} + +
{% 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.progressPercentage }}% +
+ {{ job.completedPartsCount }}/{{ job.partCount }} +
+ {% 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 %} +
+
+
+
+ {% endif %} +
- {% trans %}info_providers.search.info_providers_list{% endtrans %} +
+ {% trans %}info_providers.search.info_providers_list{% endtrans %} + | + {% trans %}info_providers.bulk_import.manage_jobs{% endtrans %} +
+ +
+ {{ form_widget(form.prefetch_details, {'attr': {'class': 'form-check-input'}}) }} + {{ form_label(form.prefetch_details, null, {'label_attr': {'class': 'form-check-label'}}) }} + {{ form_help(form.prefetch_details) }} +
+ {{ form_widget(form.submit) }}
diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig new file mode 100644 index 00000000..51efeba8 --- /dev/null +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -0,0 +1,348 @@ +{% extends "main_card.html.twig" %} + +{% import "info_providers/providers.macro.html.twig" as providers_macro %} +{% import "helper.twig" as helper %} + +{% block title %} + {% trans %}info_providers.bulk_import.step2.title{% endtrans %} +{% endblock %} + +{% block card_title %} + {% trans %}info_providers.bulk_import.step2.title{% endtrans %} + {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} +{% endblock %} + +{% block card_content %} + +
+
+
{{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
+ + {{ job.partCount }} {% trans %}info_providers.bulk_import.parts{% endtrans %} • + {{ job.resultCount }} {% trans %}info_providers.bulk_import.results{% endtrans %} • + {% trans %}info_providers.bulk_import.created_at{% endtrans %}: {{ job.createdAt|date('Y-m-d H:i') }} + +
+
+ {% if job.isPending %} + {% trans %}info_providers.bulk_import.status.pending{% endtrans %} + {% elseif job.isInProgress %} + {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} + {% elseif job.isCompleted %} + {% trans %}info_providers.bulk_import.status.completed{% endtrans %} + {% elseif job.isFailed %} + {% trans %}info_providers.bulk_import.status.failed{% endtrans %} + {% endif %} +
+
+ + +
+
+
+
Progress
+ {{ job.completedPartsCount }} / {{ job.partCount }} completed +
+
+
+
+
+
+ + {{ job.completedPartsCount }} {% trans %}info_providers.bulk_import.completed{% endtrans %} • + {{ job.skippedPartsCount }} {% trans %}info_providers.bulk_import.skipped{% endtrans %} + + {{ job.progressPercentage }}% +
+
+
+ + + + + {% for part_result in search_results %} + {% set part = part_result.part %} + {% set isCompleted = job.isPartCompleted(part.id) %} + {% set isSkipped = job.isPartSkipped(part.id) %} +
+
+
+
+ + {{ part.name }} + + {% if isCompleted %} + + {% trans %}info_providers.bulk_import.completed{% endtrans %} + + {% elseif isSkipped %} + + {% trans %}info_providers.bulk_import.skipped{% endtrans %} + + {% endif %} + {% if part_result.errors is not empty %} + {% trans with {'%count%': part_result.errors|length} %}info_providers.bulk_import.errors{% endtrans %} + {% endif %} + {% trans with {'%count%': part_result.search_results|length} %}info_providers.bulk_import.results_found{% endtrans %} +
+
+
+ {% if not isCompleted and not isSkipped %} + + + {% elseif isCompleted %} + + {% elseif isSkipped %} + + {% endif %} +
+
+
+ {% if part_result.errors is not empty %} + {% for error in part_result.errors %} + + {% endfor %} + {% endif %} + + {% if part_result.search_results|length > 0 %} +
+ + + + + + + + + + + + + + {% for result in part_result.search_results %} + {% set dto = result.dto %} + {% set localPart = result.localPart %} + + + + + + + + + + {% endfor %} + +
{% trans %}name.label{% endtrans %}{% trans %}description.label{% endtrans %}{% trans %}manufacturer.label{% endtrans %}{% trans %}info_providers.table.provider.label{% endtrans %}{% trans %}info_providers.bulk_import.source_field{% endtrans %}{% trans %}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 %} + +
+
+
+ {% else %} + + {% endif %} +
+
+ {% endfor %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/parts/edit/edit_part_info.html.twig b/templates/parts/edit/edit_part_info.html.twig index 20cddbd7..28a88132 100644 --- a/templates/parts/edit/edit_part_info.html.twig +++ b/templates/parts/edit/edit_part_info.html.twig @@ -4,6 +4,32 @@ {% trans with {'%name%': part.name|escape } %}part.edit.title{% endtrans %} {% endblock %} +{% block before_card %} + {% if bulk_job and jobId %} +
+
+
+ + + {% trans %}info_providers.bulk_import.back{% endtrans %} + +
+ + +
+
+ + {% trans %}info_providers.bulk_import.editing_part{% endtrans %} +
+
+
+
+ {% endif %} +{% endblock %} + {% block card_title %} {% trans with {'%name%': part.name|escape } %}part.edit.card_title{% endtrans %} diff --git a/templates/parts/edit/update_from_ip.html.twig b/templates/parts/edit/update_from_ip.html.twig index fb1dfad3..1ab2ca59 100644 --- a/templates/parts/edit/update_from_ip.html.twig +++ b/templates/parts/edit/update_from_ip.html.twig @@ -5,6 +5,19 @@ {% block card_border %}border-info{% endblock %} {% block card_type %}bg-info text-bg-info{% endblock %} +{% block before_card %} + {% if bulk_job and jobId %} +
+
+
+ + {% trans %}info_providers.bulk_import.editing_part{% endtrans %} +
+
+
+ {% endif %} +{% endblock %} + {% block title %} {% trans %}info_providers.update_part.title{% endtrans %}: {{ merge_old_name }} {% endblock %} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index e18c48e4..875f8d42 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -8944,6 +8944,12 @@ Element 1 -> Element 1.2]]> Edit part + + + part_list.action.scrollable_hint + Scroll to see all actions + + part_list.action.action.title @@ -9334,6 +9340,84 @@ Element 1 -> Element 1.2]]> Attachment name + + + filter.bulk_import_job.label + Bulk Import Job + + + + + filter.bulk_import_job.job_status + Job Status + + + + + filter.bulk_import_job.part_status_in_job + Part Status in Job + + + + + filter.bulk_import_job.status.any + Any Status + + + + + filter.bulk_import_job.status.pending + Pending + + + + + filter.bulk_import_job.status.in_progress + In Progress + + + + + filter.bulk_import_job.status.completed + Completed + + + + + filter.bulk_import_job.status.stopped + Stopped + + + + + filter.bulk_import_job.status.failed + Failed + + + + + filter.bulk_import_job.part_status.any + Any Part Status + + + + + filter.bulk_import_job.part_status.pending + Pending + + + + + filter.bulk_import_job.part_status.completed + Completed + + + + + filter.bulk_import_job.part_status.skipped + Skipped + + filter.choice_constraint.operator.ANY @@ -13153,6 +13237,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g Info Providers + + + info_providers.bulk_import.actions.label + Actions + + info_providers.bulk_search.providers.help @@ -13165,6 +13255,12 @@ Please note, that you can not impersonate a disabled user. If you try you will g Search All Parts + + + info_providers.bulk_search.field.select + Select a field to search by + + info_providers.bulk_search.field.mpn @@ -13207,5 +13303,503 @@ Please note, that you can not impersonate a disabled user. If you try you will g SPN (Supplier Part Number) is recommended for better results. Add a mapping for each supplier to use their SPNs. + + + info_providers.bulk_import.update_part + Update Part + + + + + info_providers.bulk_import.prefetch_details + Prefetch Details + + + + + info_providers.bulk_import.prefetch_details_help + Prefetch details for all results. This will take longer, but will speed up workflow for updating parts. + + + + + info_providers.bulk_import.step2.title + Bulk import from info providers + + + + + info_providers.bulk_import.step2.card_title + Bulk import for %count% parts - %date% + + + + + info_providers.bulk_import.parts + parts + + + + + info_providers.bulk_import.results + results + + + + + info_providers.bulk_import.created_at + Created at + + + + + info_providers.bulk_import.status.in_progress + In Progress + + + + + info_providers.bulk_import.status.completed + Completed + + + + + info_providers.bulk_import.status.failed + Failed + + + + + info_providers.bulk_import.results_found + %count% results found + + + + + info_providers.bulk_import.table.name + Name + + + + + info_providers.bulk_import.table.description + Description + + + + + info_providers.bulk_import.table.manufacturer + Manufacturer + + + + + info_providers.bulk_import.table.provider + Provider + + + + + info_providers.bulk_import.table.source_field + Source Field + + + + + info_providers.bulk_import.table.action + Action + + + + + info_providers.bulk_import.action.select + Select + + + + + info_providers.bulk_import.action.deselect + Deselect + + + + + info_providers.bulk_import.action.view_details + View Details + + + + + info_providers.bulk_import.no_results + No results found + + + + + info_providers.bulk_import.processing + Processing... + + + + + info_providers.bulk_import.error + Error occurred during import + + + + + info_providers.bulk_import.success + Import completed successfully + + + + + info_providers.bulk_import.partial_success + Import completed with some errors + + + + + info_providers.bulk_import.retry + Retry + + + + + info_providers.bulk_import.cancel + Cancel + + + + + info_providers.bulk_import.confirm + Confirm Import + + + + + info_providers.bulk_import.back + Back + + + + + info_providers.bulk_import.next + Next + + + + + info_providers.bulk_import.finish + Finish + + + + + info_providers.bulk_import.progress + Progress: + + + + + info_providers.bulk_import.time_remaining + Estimated time remaining: %time% + + + + + info_providers.bulk_import.details_modal.title + Part Details + + + + + info_providers.bulk_import.details_modal.close + Close + + + + + info_providers.bulk_import.details_modal.select_this_part + Select This Part + + + + + info_providers.bulk_import.status.pending + Pending + + + + + info_providers.bulk_import.completed + completed + + + + + info_providers.bulk_import.skipped + skipped + + + + + info_providers.bulk_import.errors + errors + + + + + info_providers.bulk_import.mark_completed + Mark Completed + + + + + info_providers.bulk_import.mark_skipped + Mark Skipped + + + + + info_providers.bulk_import.mark_pending + Mark Pending + + + + + info_providers.bulk_import.skip_reason + Skip reason + + + + + info_providers.bulk_import.source_field + Source Field + + + + + info_providers.bulk_import.update_part + Update Part + + + + + info_providers.bulk_import.view_existing + View Existing + + + + + info_providers.search.no_results + No results found + + + + + info_providers.table.provider.label + Provider + + + + + info_providers.bulk_import.editing_part + Editing part as part of bulk import + + + + + info_providers.bulk_import.complete + Complete + + + + + info_providers.bulk_import.existing_jobs + Existing Jobs + + + + + info_providers.bulk_import.job_name + Job Name + + + + + info_providers.bulk_import.parts_count + Parts Count + + + + + info_providers.bulk_import.results_count + Results Count + + + + + info_providers.bulk_import.progress_label + Progress: %current%/%total% + + + + + info_providers.bulk_import.manage_jobs + Manage Bulk Import Jobs + + + + + info_providers.bulk_import.view_results + View Results + + + + + info_providers.bulk_import.status + Status + + + + + info_providers.bulk_import.manage_jobs_description + View and manage all your bulk import jobs. To create a new job, select parts and click "Bulk import from info providers". + + + + + info_providers.bulk_import.no_jobs_found + No bulk import jobs found. + + + + + info_providers.bulk_import.create_first_job + Create your first bulk import job + + + + + info_providers.bulk_import.confirm_delete_job + Are you sure you want to delete this job? + + + + + info_providers.bulk_import.job_name_template + Bulk import for %count% parts + + + + + info_providers.bulk_import.step2.instructions.title + How to use bulk import + + + + + info_providers.bulk_import.step2.instructions.description + Follow these steps to efficiently update your parts: + + + + + info_providers.bulk_import.step2.instructions.step1 + Click "Update Part" to edit a part with the supplier data + + + + + info_providers.bulk_import.step2.instructions.step2 + Review and modify the part information as needed. Note: You need to click "Save" twice to save the changes. + + + + + info_providers.bulk_import.step2.instructions.step3 + Click "Complete" to mark the part as done and return to this overview + + + + + info_providers.bulk_import.created_by + Created By + + + + + info_providers.bulk_import.completed_at + Completed At + + + + + info_providers.bulk_import.action.label + Action + + + + + info_providers.bulk_import.action.delete + Delete + + + + + info_providers.bulk_import.status.active + Active + + + + + info_providers.bulk_import.progress.title + Progress + + + + + info_providers.bulk_import.progress.completed_text + %completed% / %total% completed + + + + + info_providers.bulk_import.error.deleting_job + Error deleting job + + + + + info_providers.bulk_import.error.unknown + Unknown error + + + + + info_providers.bulk_import.error.deleting_job_with_details + Error deleting job: %error% + + + + + info_providers.bulk_import.status.stopped + Stopped + + + + + info_providers.bulk_import.action.stop + Stop + + + + + info_providers.bulk_import.confirm_stop_job + Are you sure you want to stop this job? + + \ No newline at end of file