Skip to content

Commit 92e4976

Browse files
Treeedjonajbtronics
authored
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 <[email protected]> Co-authored-by: Jan Böhmer <[email protected]>
1 parent e9efbff commit 92e4976

File tree

4 files changed

+195
-28
lines changed

4 files changed

+195
-28
lines changed

src/Controller/InfoProviderController.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@
2323

2424
namespace App\Controller;
2525

26+
use App\Entity\Parts\Manufacturer;
2627
use App\Entity\Parts\Part;
2728
use App\Form\InfoProviderSystem\PartSearchType;
29+
use App\Services\InfoProviderSystem\ExistingPartFinder;
2830
use App\Services\InfoProviderSystem\PartInfoRetriever;
2931
use App\Services\InfoProviderSystem\ProviderRegistry;
32+
use Doctrine\ORM\EntityManagerInterface;
3033
use Psr\Log\LoggerInterface;
3134
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
3235
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -42,7 +45,9 @@ class InfoProviderController extends AbstractController
4245
{
4346

4447
public function __construct(private readonly ProviderRegistry $providerRegistry,
45-
private readonly PartInfoRetriever $infoRetriever)
48+
private readonly PartInfoRetriever $infoRetriever,
49+
private readonly ExistingPartFinder $existingPartFinder
50+
)
4651
{
4752

4853
}
@@ -79,14 +84,26 @@ public function search(Request $request, #[MapEntity(id: 'target')] ?Part $updat
7984
$keyword = $form->get('keyword')->getData();
8085
$providers = $form->get('providers')->getData();
8186

87+
$dtos = [];
88+
8289
try {
83-
$results = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers);
90+
$dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers);
8491
} catch (ClientException $e) {
8592
$this->addFlash('error', t('info_providers.search.error.client_exception'));
8693
$this->addFlash('error',$e->getMessage());
8794
//Log the exception
8895
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
8996
}
97+
98+
// modify the array to an array of arrays that has a field for a matching local Part
99+
// the advantage to use that format even when we don't look for local parts is that we
100+
// always work with the same interface
101+
$results = array_map(function ($result) {return ['dto' => $result, 'localPart' => null];}, $dtos);
102+
if(!$update_target) {
103+
foreach ($results as $index => $result) {
104+
$results[$index]['localPart'] = $this->existingPartFinder->findFirstExisting($result['dto']);
105+
}
106+
}
90107
}
91108

92109
return $this->render('info_providers/search/part_search.html.twig', [
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
namespace App\Services\InfoProviderSystem;
4+
5+
use App\Entity\Parts\Manufacturer;
6+
use App\Entity\Parts\Part;
7+
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
8+
use Doctrine\ORM\EntityManagerInterface;
9+
10+
/**
11+
* This service assists in finding existing local parts for a SearchResultDTO, so that the user
12+
* does not accidentally add a duplicate.
13+
*
14+
* A part is considered to be a duplicate, if the provider reference matches, or if the manufacturer and the MPN of the
15+
* DTO and the local part match. This checks also for alternative names of the manufacturer and the part name (as alternative
16+
* for the MPN).
17+
*/
18+
final class ExistingPartFinder
19+
{
20+
public function __construct(private readonly EntityManagerInterface $em)
21+
{
22+
23+
}
24+
25+
/**
26+
* Return the first existing local part, that matches the search result.
27+
* If no part is found, return null.
28+
* @param SearchResultDTO $dto
29+
* @return Part|null
30+
*/
31+
public function findFirstExisting(SearchResultDTO $dto): ?Part
32+
{
33+
$results = $this->findAllExisting($dto);
34+
return count($results) > 0 ? $results[0] : null;
35+
}
36+
37+
/**
38+
* Returns all existing local parts that match the search result.
39+
* If no part is found, return an empty array.
40+
* @param SearchResultDTO $dto
41+
* @return Part[]
42+
*/
43+
public function findAllExisting(SearchResultDTO $dto): array
44+
{
45+
$qb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
46+
$qb->select('part')
47+
->leftJoin('part.manufacturer', 'manufacturer')
48+
->Orwhere($qb->expr()->andX(
49+
'part.providerReference.provider_key = :providerKey',
50+
'part.providerReference.provider_id = :providerId',
51+
))
52+
53+
//Or the manufacturer (allowing for alternative names) and the MPN (or part name) must match
54+
->OrWhere(
55+
$qb->expr()->andX(
56+
$qb->expr()->orX(
57+
"ILIKE(manufacturer.name, :manufacturerName) = TRUE",
58+
"ILIKE(manufacturer.alternative_names, :manufacturerAltNames) = TRUE",
59+
),
60+
$qb->expr()->orX(
61+
"ILIKE(part.manufacturer_product_number, :mpn) = TRUE",
62+
"ILIKE(part.name, :mpn) = TRUE",
63+
)
64+
)
65+
)
66+
;
67+
68+
$qb->setParameter('providerKey', $dto->provider_key);
69+
$qb->setParameter('providerId', $dto->provider_id);
70+
71+
$qb->setParameter('manufacturerName', $dto->manufacturer);
72+
$qb->setParameter('manufacturerAltNames', '%'.$dto->manufacturer.'%');
73+
$qb->setParameter('mpn', $dto->mpn);
74+
75+
return $qb->getQuery()->getResult();
76+
}
77+
}

templates/info_providers/search/part_search.html.twig

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -56,61 +56,104 @@
5656
</thead>
5757
<tbody>
5858
{% for result in results %}
59-
<tr>
59+
{% set dto = result["dto"] %}
60+
{# @var App\Entity\Parts\Part localPart #}
61+
{% set localPart = result["localPart"] %}
62+
63+
<tr {% if localPart is not null %}class="table-warning"{% endif %}>
6064
<td>
61-
<img src="{{ result.preview_image_url }}" data-thumbnail="{{ result.preview_image_url }}"
65+
<img src="{{ dto.preview_image_url }}" data-thumbnail="{{ dto.preview_image_url }}"
6266
class="hoverpic" style="max-width: 45px;" {{ stimulus_controller('elements/hoverpic') }}>
6367
</td>
6468
<td>
65-
{% if result.provider_url is not null %}
66-
<a href="{{ result.provider_url }}" target="_blank" rel="noopener">{{ result.name }}</a>
69+
{% if dto.provider_url is not null %}
70+
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">{{ dto.name }}</a>
6771
{% else %}
68-
{{ result.name }}
72+
{{ dto.name }}
6973
{% endif %}
7074

71-
{% if result.mpn is not null %}
75+
{% if dto.mpn is not null %}
7276
<br>
73-
<small class="text-muted" title="{% trans %}part.table.mpn{% endtrans %}">{{ result.mpn }}</small>
77+
<small class="text-muted" title="{% trans %}part.table.mpn{% endtrans %}">{{ dto.mpn }}</small>
78+
{% endif %}
79+
{% if result["localPart"] is not null %}
80+
7481
{% endif %}
7582
</td>
7683
<td>
77-
{{ result.description }}
78-
{% if result.category is not null %}
84+
{{ dto.description }}
85+
{% if dto.category is not null %}
7986
<br>
80-
<small class="text-muted">{{ result.category }}</small>
87+
<small class="text-muted">{{ dto.category }}</small>
8188
{% endif %}
8289
</td>
8390
<td>
84-
{{ result.manufacturer ?? '' }}
85-
{% if result.footprint is not null %}
91+
{{ dto.manufacturer ?? '' }}
92+
{% if dto.footprint is not null %}
8693
<br>
87-
<small class="text-muted">{{ result.footprint }}</small>
94+
<small class="text-muted">{{ dto.footprint }}</small>
8895
{% endif %}
8996
</td>
90-
<td>{{ helper.m_status_to_badge(result.manufacturing_status) }}</td>
97+
<td>{{ helper.m_status_to_badge(dto.manufacturing_status) }}</td>
9198
<td>
92-
{% if result.provider_url %}
93-
<a href="{{ result.provider_url }}" target="_blank" rel="noopener">
94-
{{ info_provider_label(result.provider_key)|default(result.provider_key) }}
99+
{% if dto.provider_url %}
100+
<a href="{{ dto.provider_url }}" target="_blank" rel="noopener">
101+
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
95102
</a>
96103
{% else %}
97-
{{ info_provider_label(result.provider_key)|default(result.provider_key) }}
104+
{{ info_provider_label(dto.provider_key)|default(dto.provider_key) }}
98105
{% endif %}
99106
<br>
100-
<small class="text-muted">{{ result.provider_id }}</small>
101-
<td>
107+
<small class="text-muted">{{ dto.provider_id }}</small>
108+
</td>
109+
<td class="text-center">
110+
102111
{% if update_target %} {# We update an existing part #}
103112
{% set href = path('info_providers_update_part',
104-
{'providerKey': result.provider_key, 'providerId': result.provider_id, 'id': update_target.iD}) %}
113+
{'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'id': update_target.iD}) %}
105114
{% else %} {# Create a fresh part #}
106115
{% set href = path('info_providers_create_part',
107-
{'providerKey': result.provider_key, 'providerId': result.provider_id}) %}
116+
{'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %}
108117
{% endif %}
109118

110-
<a class="btn btn-primary" href="{{ href }}"
111-
target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
112-
<i class="fa-solid fa-plus-square"></i>
113-
</a>
119+
{# If we have no local part, then we can just show the create button #}
120+
{% if localPart is null %}
121+
<a class="btn btn-primary" href="{{ href }}"
122+
target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
123+
<i class="fa-solid fa-plus-square"></i>
124+
</a>
125+
{% else %} {# Otherwise add a button group with all three buttons #}
126+
<span class="badge text-bg-warning mb-1 d-block" title="{% trans %}info_providers.search.existing_part_found{% endtrans %}">
127+
<i class="fa-solid fa-circle-info fa-fw"></i>
128+
{% trans %}info_providers.search.existing_part_found.short{% endtrans %}
129+
</span>
130+
131+
<div class="btn-group" role="group">
132+
<a class="btn btn-primary" href="{{ path('app_part_show', {'id': localPart.id}) }}"
133+
target="_blank" title="{% trans %}info_providers.search.show_existing_part{% endtrans %}">
134+
<i class="fa-solid fa-search"></i>
135+
</a>
136+
<a class="btn btn-primary" href="{{ path("info_providers_update_part", {'id': localPart.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) }}"
137+
target="_blank" title="{% trans %}info_providers.search.update_existing_part{% endtrans %}">
138+
<i class="fa-solid fa-arrows-rotate"></i>
139+
</a>
140+
<div class="btn-group" role="group">
141+
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"></button>
142+
143+
<ul class="dropdown-menu">
144+
<li><a class="dropdown-item" href="{{ path('part_edit', {'id': localPart.id}) }}" target="_blank">
145+
<i class="fa-solid fa-pencil fa-fw"></i> {% trans %}info_providers.search.edit_existing_part{% endtrans %}
146+
</a></li>
147+
<li>
148+
<a class="dropdown-item" href="{{ href }}" target="_blank">
149+
<i class="fa-solid fa-plus-square fa-fw"></i> {% trans %}part.create.btn{% endtrans %}
150+
</a>
151+
</li>
152+
</ul>
153+
</div>
154+
</div>
155+
156+
{% endif %}
114157
</td>
115158
</tr>
116159
{% endfor %}

translations/messages.en.xlf

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12227,5 +12227,35 @@ Please note, that you can not impersonate a disabled user. If you try you will g
1222712227
<target>Generated code</target>
1222812228
</segment>
1222912229
</unit>
12230+
<unit id="5fgkpRc" name="info_providers.search.show_existing_part">
12231+
<segment>
12232+
<source>info_providers.search.show_existing_part</source>
12233+
<target>Show existing part</target>
12234+
</segment>
12235+
</unit>
12236+
<unit id="iPO8lit" name="info_providers.search.edit_existing_part">
12237+
<segment>
12238+
<source>info_providers.search.edit_existing_part</source>
12239+
<target>Edit existing part</target>
12240+
</segment>
12241+
</unit>
12242+
<unit id="gUMm8CJ" name="info_providers.search.existing_part_found.short">
12243+
<segment>
12244+
<source>info_providers.search.existing_part_found.short</source>
12245+
<target>Part already existing</target>
12246+
</segment>
12247+
</unit>
12248+
<unit id="bT1nkI9" name="info_providers.search.existing_part_found">
12249+
<segment>
12250+
<source>info_providers.search.existing_part_found</source>
12251+
<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>
12252+
</segment>
12253+
</unit>
12254+
<unit id="TDxYuTP" name="info_providers.search.update_existing_part">
12255+
<segment>
12256+
<source>info_providers.search.update_existing_part</source>
12257+
<target>Update existing part from info provider</target>
12258+
</segment>
12259+
</unit>
1223012260
</file>
1223112261
</xliff>

0 commit comments

Comments
 (0)