Show when parts from info provider already exist (#810)

* added button to show existing part with same manufacturer and mpn in provider list

* added button to edit existing part in provider list

* added docstring and comments

* replaced unnecessary double quotes

* Introduced a new twig variable localPart to split up the result

* Highlight a row, if the part is already existing

* Made buttons translatable

* Improved styling of the buttons and added a badge to show a hint

* Extracted database queries for part matching into its own service and optimized the query reducing the required queries by factor 2

* Allow to find existing parts via the stored providerReference

This should allow the database to more quickly find entries

* Allow to use part name and manufacturer alternative names for mapping

* Added a button to update a local part from the info provider and moved some buttons into dropdown menu

---------

Co-authored-by: jona <a@b.c>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
Treeed 2024-12-31 18:03:36 +01:00 committed by GitHub
parent e9efbff912
commit 92e4976396
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 195 additions and 28 deletions

View file

@ -23,10 +23,13 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Form\InfoProviderSystem\PartSearchType;
use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -42,7 +45,9 @@ class InfoProviderController extends AbstractController
{
public function __construct(private readonly ProviderRegistry $providerRegistry,
private readonly PartInfoRetriever $infoRetriever)
private readonly PartInfoRetriever $infoRetriever,
private readonly ExistingPartFinder $existingPartFinder
)
{
}
@ -79,14 +84,26 @@ class InfoProviderController extends AbstractController
$keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData();
$dtos = [];
try {
$results = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers);
$dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers);
} catch (ClientException $e) {
$this->addFlash('error', t('info_providers.search.error.client_exception'));
$this->addFlash('error',$e->getMessage());
//Log the exception
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
}
// modify the array to an array of arrays that has a field for a matching local Part
// the advantage to use that format even when we don't look for local parts is that we
// always work with the same interface
$results = array_map(function ($result) {return ['dto' => $result, 'localPart' => null];}, $dtos);
if(!$update_target) {
foreach ($results as $index => $result) {
$results[$index]['localPart'] = $this->existingPartFinder->findFirstExisting($result['dto']);
}
}
}
return $this->render('info_providers/search/part_search.html.twig', [

View file

@ -0,0 +1,77 @@
<?php
namespace App\Services\InfoProviderSystem;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use Doctrine\ORM\EntityManagerInterface;
/**
* This service assists in finding existing local parts for a SearchResultDTO, so that the user
* does not accidentally add a duplicate.
*
* A part is considered to be a duplicate, if the provider reference matches, or if the manufacturer and the MPN of the
* DTO and the local part match. This checks also for alternative names of the manufacturer and the part name (as alternative
* for the MPN).
*/
final class ExistingPartFinder
{
public function __construct(private readonly EntityManagerInterface $em)
{
}
/**
* Return the first existing local part, that matches the search result.
* If no part is found, return null.
* @param SearchResultDTO $dto
* @return Part|null
*/
public function findFirstExisting(SearchResultDTO $dto): ?Part
{
$results = $this->findAllExisting($dto);
return count($results) > 0 ? $results[0] : null;
}
/**
* Returns all existing local parts that match the search result.
* If no part is found, return an empty array.
* @param SearchResultDTO $dto
* @return Part[]
*/
public function findAllExisting(SearchResultDTO $dto): array
{
$qb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
$qb->select('part')
->leftJoin('part.manufacturer', 'manufacturer')
->Orwhere($qb->expr()->andX(
'part.providerReference.provider_key = :providerKey',
'part.providerReference.provider_id = :providerId',
))
//Or the manufacturer (allowing for alternative names) and the MPN (or part name) must match
->OrWhere(
$qb->expr()->andX(
$qb->expr()->orX(
"ILIKE(manufacturer.name, :manufacturerName) = TRUE",
"ILIKE(manufacturer.alternative_names, :manufacturerAltNames) = TRUE",
),
$qb->expr()->orX(
"ILIKE(part.manufacturer_product_number, :mpn) = TRUE",
"ILIKE(part.name, :mpn) = TRUE",
)
)
)
;
$qb->setParameter('providerKey', $dto->provider_key);
$qb->setParameter('providerId', $dto->provider_id);
$qb->setParameter('manufacturerName', $dto->manufacturer);
$qb->setParameter('manufacturerAltNames', '%'.$dto->manufacturer.'%');
$qb->setParameter('mpn', $dto->mpn);
return $qb->getQuery()->getResult();
}
}

View file

@ -56,61 +56,104 @@
</thead>
<tbody>
{% for result in results %}
<tr>
{% set dto = result["dto"] %}
{# @var App\Entity\Parts\Part localPart #}
{% set localPart = result["localPart"] %}
<tr {% if localPart is not null %}class="table-warning"{% endif %}>
<td>
<img src="{{ result.preview_image_url }}" data-thumbnail="{{ result.preview_image_url }}"
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
class="hoverpic" style="max-width: 45px;" {{ stimulus_controller('elements/hoverpic') }}>
</td>
<td>
{% if result.provider_url is not null %}
<a href="{{ result.provider_url }}" target="_blank" rel="noopener">{{ result.name }}</a>
{% if dto.provider_url is not null %}
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">{{ dto.name }}</a>
{% else %}
{{ result.name }}
{{ dto.name }}
{% endif %}
{% if result.mpn is not null %}
{% if dto.mpn is not null %}
<br>
<small class="text-muted" title="{% trans %}part.table.mpn{% endtrans %}">{{ result.mpn }}</small>
<small class="text-muted" title="{% trans %}part.table.mpn{% endtrans %}">{{ dto.mpn }}</small>
{% endif %}
{% if result["localPart"] is not null %}
{% endif %}
</td>
<td>
{{ result.description }}
{% if result.category is not null %}
{{ dto.description }}
{% if dto.category is not null %}
<br>
<small class="text-muted">{{ result.category }}</small>
<small class="text-muted">{{ dto.category }}</small>
{% endif %}
</td>
<td>
{{ result.manufacturer ?? '' }}
{% if result.footprint is not null %}
{{ dto.manufacturer ?? '' }}
{% if dto.footprint is not null %}
<br>
<small class="text-muted">{{ result.footprint }}</small>
<small class="text-muted">{{ dto.footprint }}</small>
{% endif %}
</td>
<td>{{ helper.m_status_to_badge(result.manufacturing_status) }}</td>
<td>{{ helper.m_status_to_badge(dto.manufacturing_status) }}</td>
<td>
{% if result.provider_url %}
<a href="{{ result.provider_url }}" target="_blank" rel="noopener">
{{ info_provider_label(result.provider_key)|default(result.provider_key) }}
{% if dto.provider_url %}
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
</a>
{% else %}
{{ info_provider_label(result.provider_key)|default(result.provider_key) }}
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
{% endif %}
<br>
<small class="text-muted">{{ result.provider_id }}</small>
<td>
<small class="text-muted">{{ dto.provider_id }}</small>
</td>
<td class="text-center">
{% if update_target %} {# We update an existing part #}
{% set href = path('info_providers_update_part',
{'providerKey': result.provider_key, 'providerId': result.provider_id, 'id': update_target.iD}) %}
{'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'id': update_target.iD}) %}
{% else %} {# Create a fresh part #}
{% set href = path('info_providers_create_part',
{'providerKey': result.provider_key, 'providerId': result.provider_id}) %}
{'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %}
{% endif %}
<a class="btn btn-primary" href="{{ href }}"
target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
<i class="fa-solid fa-plus-square"></i>
</a>
{# If we have no local part, then we can just show the create button #}
{% if localPart is null %}
<a class="btn btn-primary" href="{{ href }}"
target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
<i class="fa-solid fa-plus-square"></i>
</a>
{% else %} {# Otherwise add a button group with all three buttons #}
<span class="badge text-bg-warning mb-1 d-block" title="{% trans %}info_providers.search.existing_part_found{% endtrans %}">
<i class="fa-solid fa-circle-info fa-fw"></i>
{% trans %}info_providers.search.existing_part_found.short{% endtrans %}
</span>
<div class="btn-group" role="group">
<a class="btn btn-primary" href="{{ path('app_part_show', {'id': localPart.id}) }}"
target="_blank" title="{% trans %}info_providers.search.show_existing_part{% endtrans %}">
<i class="fa-solid fa-search"></i>
</a>
<a class="btn btn-primary" href="{{ path("info_providers_update_part", {'id': localPart.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) }}"
target="_blank" title="{% trans %}info_providers.search.update_existing_part{% endtrans %}">
<i class="fa-solid fa-arrows-rotate"></i>
</a>
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"></button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ path('part_edit', {'id': localPart.id}) }}" target="_blank">
<i class="fa-solid fa-pencil fa-fw"></i> {% trans %}info_providers.search.edit_existing_part{% endtrans %}
</a></li>
<li>
<a class="dropdown-item" href="{{ href }}" target="_blank">
<i class="fa-solid fa-plus-square fa-fw"></i> {% trans %}part.create.btn{% endtrans %}
</a>
</li>
</ul>
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}

View file

@ -12227,5 +12227,35 @@ Please note, that you can not impersonate a disabled user. If you try you will g
<target>Generated code</target>
</segment>
</unit>
<unit id="5fgkpRc" name="info_providers.search.show_existing_part">
<segment>
<source>info_providers.search.show_existing_part</source>
<target>Show existing part</target>
</segment>
</unit>
<unit id="iPO8lit" name="info_providers.search.edit_existing_part">
<segment>
<source>info_providers.search.edit_existing_part</source>
<target>Edit existing part</target>
</segment>
</unit>
<unit id="gUMm8CJ" name="info_providers.search.existing_part_found.short">
<segment>
<source>info_providers.search.existing_part_found.short</source>
<target>Part already existing</target>
</segment>
</unit>
<unit id="bT1nkI9" name="info_providers.search.existing_part_found">
<segment>
<source>info_providers.search.existing_part_found</source>
<target>This part (or a very similar one) was already found in the database. Please check if it is the same and if you want to create it again!</target>
</segment>
</unit>
<unit id="TDxYuTP" name="info_providers.search.update_existing_part">
<segment>
<source>info_providers.search.update_existing_part</source>
<target>Update existing part from info provider</target>
</segment>
</unit>
</file>
</xliff>