mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-12-06 11:09:29 +00:00
Simple batch processing
This commit is contained in:
parent
aa29f10d51
commit
4c8940f9c3
9 changed files with 949 additions and 2 deletions
210
src/Controller/BulkInfoProviderImportController.php
Normal file
210
src/Controller/BulkInfoProviderImportController.php
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use App\Entity\Parts\Supplier;
|
||||||
|
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
|
||||||
|
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||||
|
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||||
|
use App\Services\InfoProviderSystem\ExistingPartFinder;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpClient\Exception\ClientException;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
use function Symfony\Component\Translation\t;
|
||||||
|
|
||||||
|
#[Route('/tools/bulk-info-provider-import')]
|
||||||
|
class BulkInfoProviderImportController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProviderRegistry $providerRegistry,
|
||||||
|
private readonly PartInfoRetriever $infoRetriever,
|
||||||
|
private readonly ExistingPartFinder $existingPartFinder,
|
||||||
|
private readonly EntityManagerInterface $entityManager
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/step1', name: 'bulk_info_provider_step1')]
|
||||||
|
public function step1(Request $request, LoggerInterface $exceptionLogger): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||||
|
|
||||||
|
$ids = $request->query->get('ids');
|
||||||
|
if (!$ids) {
|
||||||
|
$this->addFlash('error', 'No parts selected for bulk import');
|
||||||
|
return $this->redirectToRoute('homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the selected parts
|
||||||
|
$partIds = explode(',', $ids);
|
||||||
|
$partRepository = $this->entityManager->getRepository(Part::class);
|
||||||
|
$parts = $partRepository->getElementsFromIDArray($partIds);
|
||||||
|
|
||||||
|
if (empty($parts)) {
|
||||||
|
$this->addFlash('error', 'No valid parts found for bulk import');
|
||||||
|
return $this->redirectToRoute('homepage');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate field choices
|
||||||
|
$fieldChoices = [
|
||||||
|
'info_providers.bulk_search.field.mpn' => 'mpn',
|
||||||
|
'info_providers.bulk_search.field.name' => 'name',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add dynamic supplier fields
|
||||||
|
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
|
||||||
|
foreach ($suppliers as $supplier) {
|
||||||
|
$supplierKey = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName()));
|
||||||
|
$fieldChoices["Supplier: " . $supplier->getName() . " (SPN)"] = $supplierKey . '_spn';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize form with useful default mappings
|
||||||
|
$initialData = [
|
||||||
|
'field_mappings' => [
|
||||||
|
['field' => 'mpn', 'providers' => []]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$form = $this->createForm(GlobalFieldMappingType::class, $initialData, [
|
||||||
|
'field_choices' => $fieldChoices
|
||||||
|
]);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
$searchResults = null;
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$fieldMappings = $form->getData()['field_mappings'];
|
||||||
|
$searchResults = [];
|
||||||
|
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$partResult = [
|
||||||
|
'part' => $part,
|
||||||
|
'search_results' => [],
|
||||||
|
'errors' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
// Collect all DTOs from all applicable field mappings
|
||||||
|
$allDtos = [];
|
||||||
|
|
||||||
|
foreach ($fieldMappings as $mapping) {
|
||||||
|
$field = $mapping['field'];
|
||||||
|
$providers = $mapping['providers'] ?? [];
|
||||||
|
|
||||||
|
if (empty($providers)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyword = $this->getKeywordFromField($part, $field);
|
||||||
|
|
||||||
|
if ($keyword) {
|
||||||
|
try {
|
||||||
|
$dtos = $this->infoRetriever->searchByKeyword(
|
||||||
|
keyword: $keyword,
|
||||||
|
providers: $providers
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add field info to each DTO for tracking
|
||||||
|
foreach ($dtos as $dto) {
|
||||||
|
$dto->_source_field = $field;
|
||||||
|
$dto->_source_keyword = $keyword;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allDtos = array_merge($allDtos, $dtos);
|
||||||
|
} catch (ClientException $e) {
|
||||||
|
$partResult['errors'][] = "Error searching with {$field}: " . $e->getMessage();
|
||||||
|
$exceptionLogger->error('Error during bulk info provider search for part ' . $part->getId() . " field {$field}: " . $e->getMessage(), ['exception' => $e]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicates based on provider_key + provider_id
|
||||||
|
$uniqueDtos = [];
|
||||||
|
$seenKeys = [];
|
||||||
|
foreach ($allDtos as $dto) {
|
||||||
|
$key = $dto->provider_key . '|' . $dto->provider_id;
|
||||||
|
if (!in_array($key, $seenKeys)) {
|
||||||
|
$seenKeys[] = $key;
|
||||||
|
$uniqueDtos[] = $dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert DTOs to result format
|
||||||
|
$partResult['search_results'] = array_map(
|
||||||
|
fn($dto) => ['dto' => $dto, 'localPart' => $this->existingPartFinder->findFirstExisting($dto)],
|
||||||
|
$uniqueDtos
|
||||||
|
);
|
||||||
|
|
||||||
|
$searchResults[] = $partResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('info_providers/bulk_import/step1.html.twig', [
|
||||||
|
'form' => $form,
|
||||||
|
'parts' => $parts,
|
||||||
|
'search_results' => $searchResults,
|
||||||
|
'fieldChoices' => $fieldChoices
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getKeywordFromField(Part $part, string $field): ?string
|
||||||
|
{
|
||||||
|
return match ($field) {
|
||||||
|
'mpn' => $part->getManufacturerProductNumber(),
|
||||||
|
'name' => $part->getName(),
|
||||||
|
default => $this->getSupplierPartNumber($part, $field)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getSupplierPartNumber(Part $part, string $field): ?string
|
||||||
|
{
|
||||||
|
// Check if this is a supplier SPN field
|
||||||
|
if (!str_ends_with($field, '_spn')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract supplier key (remove _spn suffix)
|
||||||
|
$supplierKey = substr($field, 0, -4);
|
||||||
|
|
||||||
|
// Get all suppliers to find matching one
|
||||||
|
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
|
||||||
|
|
||||||
|
foreach ($suppliers as $supplier) {
|
||||||
|
$normalizedName = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName()));
|
||||||
|
if ($normalizedName === $supplierKey) {
|
||||||
|
// Find order detail for this supplier
|
||||||
|
$orderDetail = $part->getOrderdetails()->filter(
|
||||||
|
fn($od) => $od->getSupplier()?->getId() === $supplier->getId()
|
||||||
|
)->first();
|
||||||
|
|
||||||
|
return $orderDetail ? $orderDetail->getSupplierpartnr() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/Form/InfoProviderSystem/BulkProviderSearchType.php
Normal file
68
src/Form/InfoProviderSystem/BulkProviderSearchType.php
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\InfoProviderSystem;
|
||||||
|
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class BulkProviderSearchType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$parts = $options['parts'];
|
||||||
|
|
||||||
|
$builder->add('part_configurations', CollectionType::class, [
|
||||||
|
'entry_type' => PartProviderConfigurationType::class,
|
||||||
|
'entry_options' => [
|
||||||
|
'label' => false,
|
||||||
|
],
|
||||||
|
'allow_add' => false,
|
||||||
|
'allow_delete' => false,
|
||||||
|
'label' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$builder->add('submit', SubmitType::class, [
|
||||||
|
'label' => 'info_providers.bulk_search.submit'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'parts' => [],
|
||||||
|
]);
|
||||||
|
$resolver->setRequired('parts');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDefaultSearchField(Part $part): string
|
||||||
|
{
|
||||||
|
// Default to MPN if available, otherwise name
|
||||||
|
return $part->getManufacturerProductNumber() ? 'mpn' : 'name';
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Form/InfoProviderSystem/FieldToProviderMappingType.php
Normal file
58
src/Form/InfoProviderSystem/FieldToProviderMappingType.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\InfoProviderSystem;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class FieldToProviderMappingType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$fieldChoices = $options['field_choices'] ?? [];
|
||||||
|
|
||||||
|
$builder->add('field', ChoiceType::class, [
|
||||||
|
'label' => 'info_providers.bulk_search.search_field',
|
||||||
|
'choices' => $fieldChoices,
|
||||||
|
'expanded' => false,
|
||||||
|
'multiple' => false,
|
||||||
|
'required' => false,
|
||||||
|
'placeholder' => 'info_providers.bulk_search.field.select',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$builder->add('providers', ProviderSelectType::class, [
|
||||||
|
'label' => 'info_providers.bulk_search.providers',
|
||||||
|
'help' => 'info_providers.bulk_search.providers.help',
|
||||||
|
'required' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'field_choices' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/Form/InfoProviderSystem/GlobalFieldMappingType.php
Normal file
60
src/Form/InfoProviderSystem/GlobalFieldMappingType.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\InfoProviderSystem;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class GlobalFieldMappingType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$fieldChoices = $options['field_choices'] ?? [];
|
||||||
|
|
||||||
|
$builder->add('field_mappings', CollectionType::class, [
|
||||||
|
'entry_type' => FieldToProviderMappingType::class,
|
||||||
|
'entry_options' => [
|
||||||
|
'label' => false,
|
||||||
|
'field_choices' => $fieldChoices,
|
||||||
|
],
|
||||||
|
'allow_add' => true,
|
||||||
|
'allow_delete' => true,
|
||||||
|
'prototype' => true,
|
||||||
|
'label' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$builder->add('submit', SubmitType::class, [
|
||||||
|
'label' => 'info_providers.bulk_search.submit'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'field_choices' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\InfoProviderSystem;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
|
||||||
|
class PartProviderConfigurationType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder->add('part_id', HiddenType::class);
|
||||||
|
|
||||||
|
$builder->add('search_field', ChoiceType::class, [
|
||||||
|
'label' => 'info_providers.bulk_search.search_field',
|
||||||
|
'choices' => [
|
||||||
|
'info_providers.bulk_search.field.mpn' => 'mpn',
|
||||||
|
'info_providers.bulk_search.field.name' => 'name',
|
||||||
|
'info_providers.bulk_search.field.digikey_spn' => 'digikey_spn',
|
||||||
|
'info_providers.bulk_search.field.mouser_spn' => 'mouser_spn',
|
||||||
|
'info_providers.bulk_search.field.lcsc_spn' => 'lcsc_spn',
|
||||||
|
'info_providers.bulk_search.field.farnell_spn' => 'farnell_spn',
|
||||||
|
],
|
||||||
|
'expanded' => false,
|
||||||
|
'multiple' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$builder->add('providers', ProviderSelectType::class, [
|
||||||
|
'label' => 'info_providers.bulk_search.providers',
|
||||||
|
'help' => 'info_providers.bulk_search.providers.help',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -117,6 +117,16 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === 'bulk_info_provider_import') {
|
||||||
|
$ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
|
||||||
|
return new RedirectResponse(
|
||||||
|
$this->urlGenerator->generate('bulk_info_provider_step1', [
|
||||||
|
'ids' => $ids,
|
||||||
|
'_redirect' => $redirect_url
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//Iterate over the parts and apply the action to it:
|
//Iterate over the parts and apply the action to it:
|
||||||
foreach ($selected_parts as $part) {
|
foreach ($selected_parts as $part) {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
<input type="hidden" name="ids" {{ stimulus_target('elements/datatables/parts', 'selectIDs') }} value="">
|
<input type="hidden" name="ids" {{ stimulus_target('elements/datatables/parts', 'selectIDs') }} value="">
|
||||||
|
|
||||||
<div class="d-none mb-2" {{ stimulus_target('elements/datatables/parts', 'selectPanel') }}>
|
<div class="d-none mb-2" {{ stimulus_target('elements/datatables/parts', 'selectPanel') }}>
|
||||||
{# <span id="select_count"></span> #}
|
<small class="text-muted">{% trans %}part_list.action.scrollable_hint{% endtrans %}</small>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<button class="btn btn-outline-secondary" type="button" {{ stimulus_action('elements/datatables/parts', 'invertSelection')}}
|
<button class="btn btn-outline-secondary" type="button" {{ stimulus_action('elements/datatables/parts', 'invertSelection')}}
|
||||||
|
|
@ -74,6 +74,9 @@
|
||||||
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_xml" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_xml{% endtrans %}</option>
|
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_xml" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_xml{% endtrans %}</option>
|
||||||
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_xlsx" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_xlsx{% endtrans %}</option>
|
<option {% if not is_granted('@parts.read') %}disabled{% endif %} value="export_xlsx" data-url="{{ path('select_export_level')}}" data-turbo="false">{% trans %}part_list.action.export_xlsx{% endtrans %}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
<optgroup label="{% trans %}part_list.action.action.info_provider{% endtrans %}">
|
||||||
|
<option {% if not is_granted('@info_providers.create_parts') %}disabled{% endif %} value="bulk_info_provider_import" data-url="{{ path('bulk_info_provider_step1')}}" data-turbo="false">{% trans %}part_list.action.bulk_info_provider_import{% endtrans %}</option>
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select class="form-select d-none" data-controller="elements--structural-entity-select" name="target" {{ stimulus_target('elements/datatables/parts', 'selectTargetPicker') }}>
|
<select class="form-select d-none" data-controller="elements--structural-entity-select" name="target" {{ stimulus_target('elements/datatables/parts', 'selectTargetPicker') }}>
|
||||||
|
|
|
||||||
339
templates/info_providers/bulk_import/step1.html.twig
Normal file
339
templates/info_providers/bulk_import/step1.html.twig
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
{% 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.step1.title{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block card_title %}
|
||||||
|
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.bulk_import.step1.title{% endtrans %}
|
||||||
|
<span class="badge bg-secondary">{{ parts|length }} {% trans %}info_providers.bulk_import.parts_selected{% endtrans %}</span>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block card_content %}
|
||||||
|
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
{% trans %}info_providers.bulk_import.step1.global_mapping_description{% endtrans %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
{% trans %}info_providers.bulk_import.step1.spn_recommendation{% endtrans %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Show selected parts -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans %}info_providers.bulk_import.selected_parts{% endtrans %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
{% for part in parts %}
|
||||||
|
{% set hasNoIdentifiers = part.manufacturerProductNumber is empty and part.orderdetails is empty %}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-2">
|
||||||
|
<div class="d-flex align-items-center {% if hasNoIdentifiers %}text-danger{% endif %}">
|
||||||
|
<i class="fas fa-microchip {% if hasNoIdentifiers %}text-danger{% else %}text-primary{% endif %} me-2"></i>
|
||||||
|
<div>
|
||||||
|
<a href="{{ path('app_part_show', {'id': part.id}) }}" class="text-decoration-none {% if hasNoIdentifiers %}text-danger{% endif %}">
|
||||||
|
<strong>{{ part.name }}</strong>
|
||||||
|
{% if part.manufacturerProductNumber %}
|
||||||
|
<br><small class="{% if hasNoIdentifiers %}text-danger{% else %}text-muted{% endif %}">MPN: {{ part.manufacturerProductNumber }}</small>
|
||||||
|
{% endif %}
|
||||||
|
{% if part.orderdetails is not empty %}
|
||||||
|
<br><small class="{% if hasNoIdentifiers %}text-danger{% else %}text-muted{% endif %}">
|
||||||
|
SPNs: {{ part.orderdetails|map(od => od.supplierPartNr)|join(', ') }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form_start(form) }}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% trans %}info_providers.bulk_import.field_mappings{% endtrans %}</h5>
|
||||||
|
<small class="text-muted">{% trans %}info_providers.bulk_import.field_mappings_help{% endtrans %}</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans %}info_providers.bulk_search.search_field{% endtrans %}</th>
|
||||||
|
<th>{% trans %}info_providers.bulk_search.providers{% endtrans %}</th>
|
||||||
|
<th width="100">{% trans %}action.label{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="field-mappings-tbody" data-prototype="{{ form_widget(form.field_mappings.vars.prototype)|e('html_attr') }}">
|
||||||
|
{% for mapping in form.field_mappings %}
|
||||||
|
<tr class="mapping-row">
|
||||||
|
<td>{{ form_widget(mapping.field) }}{{ form_errors(mapping.field) }}</td>
|
||||||
|
<td>{{ form_widget(mapping.providers) }}{{ form_errors(mapping.providers) }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" onclick="removeMapping(this)">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="button" class="btn btn-success btn-sm" onclick="addMapping()">
|
||||||
|
<i class="fas fa-plus"></i> {% trans %}info_providers.bulk_import.add_mapping{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2 d-flex flex-column align-items-start gap-2">
|
||||||
|
<a class="mb-2" href="{{ path('info_providers_list') }}">{% trans %}info_providers.search.info_providers_list{% endtrans %}</a>
|
||||||
|
{{ form_widget(form.submit) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ form_end(form) }}
|
||||||
|
|
||||||
|
{% if search_results is not null %}
|
||||||
|
<hr>
|
||||||
|
<h4>{% trans %}info_providers.bulk_import.search_results.title{% endtrans %}</h4>
|
||||||
|
|
||||||
|
{% for part_result in search_results %}
|
||||||
|
{% set part = part_result.part %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
{{ part.name }}
|
||||||
|
{% if part_result.errors is not empty %}
|
||||||
|
<span class="badge bg-warning">{{ part_result.errors|length }} {% trans %}info_providers.bulk_import.errors{% endtrans %}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="badge bg-success">{{ part_result.search_results|length }} {% trans %}info_providers.bulk_import.results_found{% endtrans %}</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if part_result.errors is not empty %}
|
||||||
|
{% for error in part_result.errors %}
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if part_result.search_results|length > 0 %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>{% trans %}name.label{% endtrans %}</th>
|
||||||
|
<th>{% trans %}description.label{% endtrans %}</th>
|
||||||
|
<th>{% trans %}manufacturer.label{% endtrans %}</th>
|
||||||
|
<th>{% trans %}info_providers.table.provider.label{% endtrans %}</th>
|
||||||
|
<th>{% trans %}info_providers.bulk_import.source_field{% endtrans %}</th>
|
||||||
|
<th>{% trans %}action.label{% endtrans %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for result in part_result.search_results %}
|
||||||
|
{% set dto = result.dto %}
|
||||||
|
{% set localPart = result.localPart %}
|
||||||
|
<tr {% if localPart is not null %}class="table-warning"{% endif %}>
|
||||||
|
<td>
|
||||||
|
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
|
||||||
|
class="hoverpic" style="max-width: 30px;" {{ stimulus_controller('elements/hoverpic') }}>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if dto.provider_url is not null %}
|
||||||
|
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">{{ dto.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ dto.name }}
|
||||||
|
{% endif %}
|
||||||
|
{% if dto.mpn is not null %}
|
||||||
|
<br><small class="text-muted">{{ dto.mpn }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ dto.description }}</td>
|
||||||
|
<td>{{ dto.manufacturer ?? '' }}</td>
|
||||||
|
<td>
|
||||||
|
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
|
||||||
|
<br><small class="text-muted">{{ dto.provider_id }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info">{{ dto._source_field ?? 'unknown' }}</span>
|
||||||
|
{% if dto._source_keyword %}
|
||||||
|
<br><small class="text-muted">{{ dto._source_keyword }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group-vertical btn-group-sm" role="group">
|
||||||
|
{% set updateHref = path('info_providers_update_part',
|
||||||
|
{'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %}
|
||||||
|
<a class="btn btn-primary" href="{{ updateHref }}" target="_blank">
|
||||||
|
<i class="fas fa-edit"></i> {% trans %}info_providers.bulk_import.update_part{% endtrans %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if localPart is not null %}
|
||||||
|
<a class="btn btn-info btn-sm" href="{{ path('app_part_show', {'id': localPart.id}) }}" target="_blank">
|
||||||
|
<i class="fas fa-eye"></i> {% trans %}info_providers.bulk_import.view_existing{% endtrans %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
{% trans %}info_providers.search.no_results{% endtrans %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let mappingIndex = {{ form.field_mappings|length }};
|
||||||
|
const maxMappings = {{ fieldChoices|length }};
|
||||||
|
|
||||||
|
function addMapping() {
|
||||||
|
const tbody = document.getElementById('field-mappings-tbody');
|
||||||
|
const currentMappings = tbody.querySelectorAll('.mapping-row').length;
|
||||||
|
|
||||||
|
// Check if we've reached the maximum number of mappings
|
||||||
|
if (currentMappings >= maxMappings) {
|
||||||
|
alert('{% trans %}info_providers.bulk_import.max_mappings_reached{% endtrans %}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prototype = tbody.dataset.prototype;
|
||||||
|
|
||||||
|
// Replace __name__ placeholder with current index
|
||||||
|
const newRowHtml = prototype.replace(/__name__/g, mappingIndex);
|
||||||
|
|
||||||
|
// Create temporary div to parse the prototype HTML
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = newRowHtml;
|
||||||
|
|
||||||
|
// Extract field and provider widgets
|
||||||
|
const fieldWidget = tempDiv.querySelector('select[name*="[field]"]') || tempDiv.children[0];
|
||||||
|
const providerWidget = tempDiv.querySelector('select[name*="[providers]"]') || tempDiv.children[1];
|
||||||
|
|
||||||
|
// Create new row
|
||||||
|
const newRow = document.createElement('tr');
|
||||||
|
newRow.className = 'mapping-row';
|
||||||
|
newRow.innerHTML = `
|
||||||
|
<td>${fieldWidget ? fieldWidget.outerHTML : ''}</td>
|
||||||
|
<td>${providerWidget ? providerWidget.outerHTML : ''}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" onclick="removeMapping(this)">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.appendChild(newRow);
|
||||||
|
mappingIndex++;
|
||||||
|
|
||||||
|
// Add change listener to the new field select and clear its selection
|
||||||
|
const newFieldSelect = newRow.querySelector('select[name*="[field]"]');
|
||||||
|
if (newFieldSelect) {
|
||||||
|
// Clear the selection for new rows - select the placeholder option
|
||||||
|
newFieldSelect.value = '';
|
||||||
|
addFieldChangeListener(newFieldSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update field options to hide already selected fields
|
||||||
|
updateFieldOptions();
|
||||||
|
|
||||||
|
// Update add button state
|
||||||
|
updateAddButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMapping(button) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
row.remove();
|
||||||
|
updateFieldOptions();
|
||||||
|
updateAddButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function updateFieldOptions() {
|
||||||
|
const tbody = document.getElementById('field-mappings-tbody');
|
||||||
|
const fieldSelects = tbody.querySelectorAll('select[name*="[field]"]');
|
||||||
|
|
||||||
|
// Get all currently selected field values (excluding empty values)
|
||||||
|
const selectedFields = Array.from(fieldSelects)
|
||||||
|
.map(select => select.value)
|
||||||
|
.filter(value => value && value !== '');
|
||||||
|
|
||||||
|
console.log('Selected fields:', selectedFields);
|
||||||
|
|
||||||
|
// Update each field select to disable already selected options
|
||||||
|
fieldSelects.forEach(select => {
|
||||||
|
Array.from(select.options).forEach(option => {
|
||||||
|
// Don't disable if this is the current select's value or if option is empty
|
||||||
|
const isCurrentValue = option.value === select.value;
|
||||||
|
const isEmptyOption = !option.value || option.value === '';
|
||||||
|
const isAlreadySelected = selectedFields.includes(option.value);
|
||||||
|
|
||||||
|
if (!isEmptyOption && isAlreadySelected && !isCurrentValue) {
|
||||||
|
option.disabled = true;
|
||||||
|
option.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
option.disabled = false;
|
||||||
|
option.style.display = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAddButtonState() {
|
||||||
|
const tbody = document.getElementById('field-mappings-tbody');
|
||||||
|
const addButton = document.querySelector('button[onclick="addMapping()"]');
|
||||||
|
const currentMappings = tbody.querySelectorAll('.mapping-row').length;
|
||||||
|
|
||||||
|
if (addButton) {
|
||||||
|
if (currentMappings >= maxMappings) {
|
||||||
|
addButton.disabled = true;
|
||||||
|
addButton.title = '{% trans %}info_providers.bulk_import.max_mappings_reached{% endtrans %}';
|
||||||
|
} else {
|
||||||
|
addButton.disabled = false;
|
||||||
|
addButton.title = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for field changes
|
||||||
|
function addFieldChangeListener(select) {
|
||||||
|
select.addEventListener('change', function() {
|
||||||
|
updateFieldOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize add button state on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateAddButtonState();
|
||||||
|
updateFieldOptions();
|
||||||
|
|
||||||
|
// Add change listeners to existing field selects
|
||||||
|
const fieldSelects = document.querySelectorAll('select[name*="[field]"]');
|
||||||
|
fieldSelects.forEach(addFieldChangeListener);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -13063,5 +13063,149 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
||||||
<target>Redacted for security reasons</target>
|
<target>Redacted for security reasons</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bulk001" name="info_providers.bulk_import.step1.title">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.step1.title</source>
|
||||||
|
<target>Bulk Info Provider Import - Step 1</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk002" name="info_providers.bulk_import.parts_selected">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.parts_selected</source>
|
||||||
|
<target>parts selected</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk003" name="info_providers.bulk_import.step1.global_mapping_description">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.step1.global_mapping_description</source>
|
||||||
|
<target>Configure field mappings that will be applied to all selected parts. For example: "MPN → LCSC + Mouser" means search LCSC and Mouser providers using each part's MPN field.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk004" name="info_providers.bulk_import.selected_parts">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.selected_parts</source>
|
||||||
|
<target>Selected Parts</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk005" name="info_providers.bulk_import.field_mappings">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.field_mappings</source>
|
||||||
|
<target>Field Mappings</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk006" name="info_providers.bulk_import.field_mappings_help">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.field_mappings_help</source>
|
||||||
|
<target>Define which part fields to search with which info providers. Multiple mappings will be combined.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk007" name="info_providers.bulk_import.add_mapping">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.add_mapping</source>
|
||||||
|
<target>Add Mapping</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk008" name="info_providers.bulk_import.search_results.title">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.search_results.title</source>
|
||||||
|
<target>Search Results</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk009" name="info_providers.bulk_import.errors">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.errors</source>
|
||||||
|
<target>errors</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk010" name="info_providers.bulk_import.results_found">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.results_found</source>
|
||||||
|
<target>results found</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk011" name="info_providers.bulk_import.source_field">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.source_field</source>
|
||||||
|
<target>Source Field</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk012" name="info_providers.bulk_import.create_part">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.create_part</source>
|
||||||
|
<target>Create Part</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk013" name="info_providers.bulk_import.view_existing">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.view_existing</source>
|
||||||
|
<target>View Existing</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk014" name="info_providers.bulk_search.search_field">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_search.search_field</source>
|
||||||
|
<target>Search Field</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk015" name="info_providers.bulk_search.providers">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_search.providers</source>
|
||||||
|
<target>Info Providers</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk016" name="info_providers.bulk_search.providers.help">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_search.providers.help</source>
|
||||||
|
<target>Select which info providers to search when parts have this field.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk017" name="info_providers.bulk_search.submit">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_search.submit</source>
|
||||||
|
<target>Search All Parts</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk018" name="info_providers.bulk_search.field.mpn">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_search.field.mpn</source>
|
||||||
|
<target>Manufacturer Part Number (MPN)</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk019" name="info_providers.bulk_search.field.name">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_search.field.name</source>
|
||||||
|
<target>Part Name</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk020" name="part_list.action.action.info_provider">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>part_list.action.action.info_provider</source>
|
||||||
|
<target>Info Provider</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk021" name="part_list.action.bulk_info_provider_import">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>part_list.action.bulk_info_provider_import</source>
|
||||||
|
<target>Bulk Info Provider Import</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk022" name="info_providers.bulk_import.clear_selections">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.clear_selections</source>
|
||||||
|
<target>Clear All Selections</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk023" name="info_providers.bulk_import.clear_row">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.clear_row</source>
|
||||||
|
<target>Clear this row's selections</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bulk024" name="info_providers.bulk_import.step1.spn_recommendation">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>info_providers.bulk_import.step1.spn_recommendation</source>
|
||||||
|
<target>SPN (Supplier Part Number) is recommended for better results. Add a mapping for each supplier to use their SPNs.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue