diff --git a/assets/controllers/elements/delete_btn_controller.js b/assets/controllers/elements/delete_btn_controller.js
index 1b28de13..9ab15f7d 100644
--- a/assets/controllers/elements/delete_btn_controller.js
+++ b/assets/controllers/elements/delete_btn_controller.js
@@ -43,7 +43,8 @@ export default class extends Controller
const message = this.element.dataset.deleteMessage;
const title = this.element.dataset.deleteTitle;
- const form = this.element;
+ //Use event target, to find the form, where the submit button was clicked
+ const form = event.target;
const submitter = event.submitter;
const that = this;
diff --git a/assets/controllers/pages/part_merge_modal_controller.js b/assets/controllers/pages/part_merge_modal_controller.js
new file mode 100644
index 00000000..e9e41302
--- /dev/null
+++ b/assets/controllers/pages/part_merge_modal_controller.js
@@ -0,0 +1,68 @@
+/*
+ * 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 .
+ */
+
+import {Controller} from "@hotwired/stimulus";
+
+export default class extends Controller
+{
+ static targets = ['link', 'mode', 'otherSelect'];
+ static values = {
+ targetId: Number,
+ };
+
+ connect() {
+ }
+
+ update() {
+ const link = this.linkTarget;
+ const other_select = this.otherSelectTarget;
+
+ //Extract the mode using the mode radio buttons (we filter the array to get the checked one)
+ const mode = (this.modeTargets.filter((e)=>e.checked))[0].value;
+
+ if (other_select.value === '') {
+ link.classList.add('disabled');
+ return;
+ }
+
+ //Extract href template from data attribute on link target
+ let href = link.getAttribute('data-href-template');
+
+ let target, other;
+ if (mode === '1') {
+ target = this.targetIdValue;
+ other = other_select.value;
+ } else if (mode === '2') {
+ target = other_select.value;
+ other = this.targetIdValue;
+ } else {
+ throw 'Invalid mode';
+ }
+
+ //Replace placeholder with actual target id
+ href = href.replace('__target__', target);
+ //Replace placeholder with selected value of the select (the event sender)
+ href = href.replace('__other__', other);
+
+ //Assign new href to link
+ link.setAttribute('href', href);
+ //Make link clickable
+ link.classList.remove('disabled');
+ }
+}
\ No newline at end of file
diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php
index cb95377b..3e8dba3e 100644
--- a/src/Controller/InfoProviderController.php
+++ b/src/Controller/InfoProviderController.php
@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Controller;
+use App\Entity\Parts\Part;
use App\Exceptions\AttachmentDownloadException;
use App\Form\InfoProviderSystem\PartSearchType;
use App\Form\Part\PartBaseType;
@@ -32,6 +33,7 @@ use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\Parts\PartFormHelper;
use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -61,7 +63,8 @@ class InfoProviderController extends AbstractController
}
#[Route('/search', name: 'info_providers_search')]
- public function search(Request $request): Response
+ #[Route('/update/{target}', name: 'info_providers_update_part_search')]
+ public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target): Response
{
$this->denyAccessUnlessGranted('@info_providers.create_parts');
@@ -70,6 +73,12 @@ class InfoProviderController extends AbstractController
$results = null;
+ //When we are updating a part, use its name as keyword, to make searching easier
+ //However we can only do this, if the form was not submitted yet
+ if ($update_target !== null && !$form->isSubmitted()) {
+ $form->get('keyword')->setData($update_target->getName());
+ }
+
if ($form->isSubmitted() && $form->isValid()) {
$keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData();
@@ -80,6 +89,7 @@ class InfoProviderController extends AbstractController
return $this->render('info_providers/search/part_search.html.twig', [
'form' => $form,
'results' => $results,
+ 'update_target' => $update_target
]);
}
}
\ No newline at end of file
diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php
index 5a965401..53fc01eb 100644
--- a/src/Controller/PartController.php
+++ b/src/Controller/PartController.php
@@ -36,6 +36,7 @@ use App\Exceptions\AttachmentDownloadException;
use App\Form\Part\PartBaseType;
use App\Services\Attachments\AttachmentSubmitHandler;
use App\Services\Attachments\PartPreviewGenerator;
+use App\Services\EntityMergers\Mergers\PartMerger;
use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\LogSystem\HistoryHelper;
@@ -233,6 +234,48 @@ class PartController extends AbstractController
]);
}
+ #[Route('/{target}/merge/{other}', name: 'part_merge')]
+ public function merge(Request $request, Part $target, Part $other, PartMerger $partMerger): Response
+ {
+ $this->denyAccessUnlessGranted('edit', $target);
+ $this->denyAccessUnlessGranted('delete', $other);
+
+ //Save the old name of the target part for the template
+ $target_name = $target->getName();
+
+ $this->addFlash('notice', t('part.merge.flash.please_review'));
+
+ $merged = $partMerger->merge($target, $other);
+ return $this->renderPartForm('merge', $request, $merged, [], [
+ 'tname_before' => $target_name,
+ 'other_part' => $other,
+ ]);
+ }
+
+ #[Route(path: '/{id}/from_info_provider/{providerKey}/{providerId}/update', name: 'info_providers_update_part', requirements: ['providerId' => '.+'])]
+ public function updateFromInfoProvider(Part $part, Request $request, string $providerKey, string $providerId,
+ PartInfoRetriever $infoRetriever, PartMerger $partMerger): Response
+ {
+ $this->denyAccessUnlessGranted('edit', $part);
+ $this->denyAccessUnlessGranted('@info_providers.create_parts');
+
+ //Save the old name of the target part for the template
+ $old_name = $part->getName();
+
+ $dto = $infoRetriever->getDetails($providerKey, $providerId);
+ $provider_part = $infoRetriever->dtoToPart($dto);
+
+ $part = $partMerger->merge($part, $provider_part);
+
+ $this->addFlash('notice', t('part.merge.flash.please_review'));
+
+ return $this->renderPartForm('update_from_ip', $request, $part, [
+ 'info_provider_dto' => $dto,
+ ], [
+ 'tname_before' => $old_name
+ ]);
+ }
+
/**
* This function provides a common implementation for methods, which use the part form.
* @param Request $request
@@ -240,10 +283,10 @@ class PartController extends AbstractController
* @param array $form_options
* @return Response
*/
- private function renderPartForm(string $mode, Request $request, Part $data, array $form_options = []): Response
+ private function renderPartForm(string $mode, Request $request, Part $data, array $form_options = [], array $merge_infos = []): Response
{
//Ensure that mode is either 'new' or 'edit
- if (!in_array($mode, ['new', 'edit'], true)) {
+ if (!in_array($mode, ['new', 'edit', 'merge', 'update_from_ip'], true)) {
throw new \InvalidArgumentException('Invalid mode given');
}
@@ -276,6 +319,12 @@ class PartController extends AbstractController
$this->commentHelper->setMessage($form['log_comment']->getData());
$this->em->persist($new_part);
+
+ //When we are in merge mode, we have to remove the other part
+ if ($mode === 'merge') {
+ $this->em->remove($merge_infos['other_part']);
+ }
+
$this->em->flush();
if ($mode === 'new') {
$this->addFlash('success', 'part.created_flash');
@@ -310,12 +359,18 @@ class PartController extends AbstractController
$template = 'parts/edit/new_part.html.twig';
} else if ($mode === 'edit') {
$template = 'parts/edit/edit_part_info.html.twig';
+ } else if ($mode === 'merge') {
+ $template = 'parts/edit/merge_parts.html.twig';
+ } else if ($mode === 'update_from_ip') {
+ $template = 'parts/edit/update_from_ip.html.twig';
}
return $this->render($template,
[
'part' => $new_part,
'form' => $form,
+ 'merge_old_name' => $merge_infos['tname_before'] ?? null,
+ 'merge_other' => $merge_infos['other_part'] ?? null
]);
}
diff --git a/src/Services/EntityMergers/EntityMerger.php b/src/Services/EntityMergers/EntityMerger.php
new file mode 100644
index 00000000..0f1355ea
--- /dev/null
+++ b/src/Services/EntityMergers/EntityMerger.php
@@ -0,0 +1,76 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\EntityMergers;
+
+use App\Services\EntityMergers\Mergers\EntityMergerInterface;
+use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
+
+/**
+ * This service is used to merge two entities together.
+ * It automatically finds the correct merger (implementing EntityMergerInterface) for the two entities if one exists.
+ */
+class EntityMerger
+{
+ public function __construct(#[TaggedIterator('app.entity_merger')] protected iterable $mergers)
+ {
+ }
+
+ /**
+ * This function finds the first merger that supports merging the other entity into the target entity.
+ * @param object $target
+ * @param object $other
+ * @param array $context
+ * @return EntityMergerInterface|null
+ */
+ public function findMergerForObject(object $target, object $other, array $context = []): ?EntityMergerInterface
+ {
+ foreach ($this->mergers as $merger) {
+ if ($merger->supports($target, $other, $context)) {
+ return $merger;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * This function merges the other entity into the target entity. If no merger is found an exception is thrown.
+ * The target entity will be modified and returned.
+ * @param object $target
+ * @param object $other
+ * @param array $context
+ * @template T of object
+ * @phpstan-param T $target
+ * @phpstan-param T $other
+ * @phpstan-return T
+ * @return object
+ */
+ public function merge(object $target, object $other, array $context = []): object
+ {
+ $merger = $this->findMergerForObject($target, $other, $context);
+ if ($merger === null) {
+ throw new \RuntimeException('No merger found for merging '.get_class($other).' into '.get_class($target));
+ }
+ return $merger->merge($target, $other, $context);
+ }
+}
\ No newline at end of file
diff --git a/src/Services/EntityMergers/Mergers/EntityMergerHelperTrait.php b/src/Services/EntityMergers/Mergers/EntityMergerHelperTrait.php
new file mode 100644
index 00000000..940f6fea
--- /dev/null
+++ b/src/Services/EntityMergers/Mergers/EntityMergerHelperTrait.php
@@ -0,0 +1,374 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\EntityMergers\Mergers;
+
+use App\Entity\Attachments\Attachment;
+use App\Entity\Attachments\AttachmentContainingDBElement;
+use App\Entity\Base\AbstractNamedDBElement;
+use App\Entity\Base\AbstractStructuralDBElement;
+use App\Entity\Parameters\AbstractParameter;
+use App\Entity\Parts\Part;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+use Symfony\Contracts\Service\Attribute\Required;
+
+use function Symfony\Component\String\u;
+
+/**
+ * This trait provides helper methods for entity mergers.
+ * By default, it uses the value from the target entity, unless it not fullfills a condition.
+ */
+trait EntityMergerHelperTrait
+{
+ protected PropertyAccessorInterface $property_accessor;
+
+ #[Required]
+ public function setPropertyAccessor(PropertyAccessorInterface $property_accessor): void
+ {
+ $this->property_accessor = $property_accessor;
+ }
+
+ /**
+ * Choice the value to use from the target or the other entity by using a callback function.
+ *
+ * @param callable $callback The callback to use. The signature is: function($target_value, $other_value, $target, $other, $field). The callback should return the value to use.
+ * @param object $target The target entity
+ * @param object $other The other entity
+ * @param string $field The field to use
+ * @return object The target entity with the value set
+ */
+ protected function useCallback(callable $callback, object $target, object $other, string $field): object
+ {
+ //Get the values from the entities
+ $target_value = $this->property_accessor->getValue($target, $field);
+ $other_value = $this->property_accessor->getValue($other, $field);
+
+ //Call the callback, with the signature: function($target_value, $other_value, $target, $other, $field)
+ //The callback should return the value to use
+ $value = $callback($target_value, $other_value, $target, $other, $field);
+
+ //Set the value
+ $this->property_accessor->setValue($target, $field, $value);
+
+ return $target;
+ }
+
+ /**
+ * Use the value from the other entity, if the value from the target entity is null.
+ *
+ * @param object $target The target entity
+ * @param object $other The other entity
+ * @param string $field The field to use
+ * @return object The target entity with the value set
+ */
+ protected function useOtherValueIfNotNull(object $target, object $other, string $field): object
+ {
+ return $this->useCallback(
+ function ($target_value, $other_value) {
+ return $target_value ?? $other_value;
+ },
+ $target,
+ $other,
+ $field
+ );
+
+ }
+
+ /**
+ * Use the value from the other entity, if the value from the target entity is empty.
+ *
+ * @param object $target The target entity
+ * @param object $other The other entity
+ * @param string $field The field to use
+ * @return object The target entity with the value set
+ */
+ protected function useOtherValueIfNotEmtpy(object $target, object $other, string $field): object
+ {
+ return $this->useCallback(
+ function ($target_value, $other_value) {
+ return empty($target_value) ? $other_value : $target_value;
+ },
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Use the larger value from the target and the other entity for the given field.
+ *
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @return object
+ */
+ protected function useLargerValue(object $target, object $other, string $field): object
+ {
+ return $this->useCallback(
+ function ($target_value, $other_value) {
+ return max($target_value, $other_value);
+ },
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Use the smaller value from the target and the other entity for the given field.
+ *
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @return object
+ */
+ protected function useSmallerValue(object $target, object $other, string $field): object
+ {
+ return $this->useCallback(
+ function ($target_value, $other_value) {
+ return min($target_value, $other_value);
+ },
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Perform an OR operation on the boolean values from the target and the other entity for the given field.
+ * This effectively means that the value is true, if it is true in at least one of the entities.
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @return object
+ */
+ protected function useTrueValue(object $target, object $other, string $field): object
+ {
+ return $this->useCallback(
+ function (bool $target_value, bool $other_value): bool {
+ return $target_value || $other_value;
+ },
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Perform a merge of comma separated lists from the target and the other entity for the given field.
+ * The values are merged and duplicates are removed.
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @return object
+ */
+ protected function mergeTags(object $target, object $other, string $field, string $separator = ','): object
+ {
+ return $this->useCallback(
+ function (string|null $t, string|null $o) use ($separator): string {
+ //Explode the strings into arrays
+ $t_array = explode($separator, $t ?? '');
+ $o_array = explode($separator, $o ?? '');
+
+ //Merge the arrays and remove duplicates
+ $tmp = array_unique(array_merge($t_array, $o_array));
+
+ //Implode the array back to a string
+ return implode($separator, $tmp);
+ },
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Merge the collections from the target and the other entity for the given field and put all items into the target collection.
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @param callable|null $equal_fn A function, which checks if two items are equal. The signature is: function(object $target, object other): bool.
+ * Return true if the items are equal, false otherwise. If two items are equal, the item from the other collection is not added to the target collection.
+ * If null, the items are compared by (instance) identity.
+ * @return object
+ */
+ protected function mergeCollections(object $target, object $other, string $field, ?callable $equal_fn = null): object
+ {
+ $target_collection = $this->property_accessor->getValue($target, $field);
+ $other_collection = $this->property_accessor->getValue($other, $field);
+
+ if (!$target_collection instanceof Collection) {
+ throw new \InvalidArgumentException("The target field $field is not a collection");
+ }
+
+ //Clone the items from the other collection
+ $clones = [];
+ foreach ($other_collection as $item) {
+ //Check if the item is already in the target collection
+ if ($equal_fn !== null) {
+ foreach ($target_collection as $target_item) {
+ if ($equal_fn($target_item, $item)) {
+ continue 2;
+ }
+ }
+ } else {
+ if ($target_collection->contains($item)) {
+ continue;
+ }
+ }
+
+ $clones[] = clone $item;
+ }
+
+ $tmp = array_merge($target_collection->toArray(), $clones);
+
+ //Create a new collection with the clones and merge it into the target collection
+ $this->property_accessor->setValue($target, $field, $tmp);
+
+ return $target;
+ }
+
+ /**
+ * Merge the attachments from the target and the other entity.
+ * @param AttachmentContainingDBElement $target
+ * @param AttachmentContainingDBElement $other
+ * @return object
+ */
+ protected function mergeAttachments(AttachmentContainingDBElement $target, AttachmentContainingDBElement $other): object
+ {
+ return $this->mergeCollections($target, $other, 'attachments', function (Attachment $t, Attachment $o): bool {
+ return $t->getName() === $o->getName()
+ && $t->getAttachmentType() === $o->getAttachmentType()
+ && $t->getPath() === $o->getPath();
+ });
+ }
+
+ /**
+ * Merge the parameters from the target and the other entity.
+ * @param AbstractStructuralDBElement|Part $target
+ * @param AbstractStructuralDBElement|Part $other
+ * @return object
+ */
+ protected function mergeParameters(AbstractStructuralDBElement|Part $target, AbstractStructuralDBElement|Part $other): object
+ {
+ return $this->mergeCollections($target, $other, 'parameters', function (AbstractParameter $t, AbstractParameter $o): bool {
+ return $t->getName() === $o->getName()
+ && $t->getSymbol() === $o->getSymbol()
+ && $t->getUnit() === $o->getUnit()
+ && $t->getValueMax() === $o->getValueMax()
+ && $t->getValueMin() === $o->getValueMin()
+ && $t->getValueTypical() === $o->getValueTypical()
+ && $t->getValueText() === $o->getValueText()
+ && $t->getGroup() === $o->getGroup();
+ });
+ }
+
+ /**
+ * Check if the two strings have equal content.
+ * This method is case-insensitive and ignores whitespace.
+ * @param string|\Stringable $t
+ * @param string|\Stringable $o
+ * @return bool
+ */
+ protected function areStringsEqual(string|\Stringable $t, string|\Stringable $o): bool
+ {
+ $t_str = u($t)->trim()->folded();
+ $o_str = u($o)->trim()->folded();
+
+ return $t_str->equalsTo($o_str);
+ }
+
+ /**
+ * Merge the text from the target and the other entity for the given field by attaching the other text to the target text via the given separator.
+ * For example, if the target text is "Hello" and the other text is "World", the result is "Hello / World".
+ * If the text is the same in both entities, the target text is returned.
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @param string $separator
+ * @return object
+ */
+ protected function mergeTextWithSeparator(object $target, object $other, string $field, string $separator = ' / '): object
+ {
+ return $this->useCallback(
+ function (string $t, string $o) use ($separator): string {
+ //Check if the strings are equal
+ if ($this->areStringsEqual($t, $o)) {
+ return $t;
+ }
+
+ //Skip empty strings
+ if (trim($t) === '') {
+ return trim($o);
+ }
+ if (trim($o) === '') {
+ return trim($t);
+ }
+
+ return trim($t) . $separator . trim($o);
+ },
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Merge two comments from the target and the other entity for the given field.
+ * The comments of the both entities get concated, while the second part get a headline with the name of the old part.
+ * @param AbstractNamedDBElement $target
+ * @param AbstractNamedDBElement $other
+ * @param string $field
+ * @return object
+ */
+ protected function mergeComment(AbstractNamedDBElement $target, AbstractNamedDBElement $other, string $field = 'comment'): object
+ {
+ return $this->useCallback(
+ function (string $t, string $o) use ($other): string {
+ //Check if the strings are equal
+ if ($this->areStringsEqual($t, $o)) {
+ return $t;
+ }
+
+ //Skip empty strings
+ if (trim($t) === '') {
+ return trim($o);
+ }
+ if (trim($o) === '') {
+ return trim($t);
+ }
+
+ return sprintf("%s\n\n%s:\n%s",
+ trim($t),
+ $other->getName(),
+ trim($o)
+ );
+ },
+ $target,
+ $other,
+ $field
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/Services/EntityMergers/Mergers/EntityMergerInterface.php b/src/Services/EntityMergers/Mergers/EntityMergerInterface.php
new file mode 100644
index 00000000..046fc0ea
--- /dev/null
+++ b/src/Services/EntityMergers/Mergers/EntityMergerInterface.php
@@ -0,0 +1,58 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\EntityMergers\Mergers;
+
+
+use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
+
+/**
+ * @template T of object
+ */
+#[AutoconfigureTag('app.entity_merger')]
+interface EntityMergerInterface
+{
+ /**
+ * Determines if this merger supports merging the other entity into the target entity.
+ * @param object $target
+ * @phpstan-param T $target
+ * @param object $other
+ * @phpstan-param T $other
+ * @param array $context
+ * @return bool True if this merger supports merging the other entity into the target entity, false otherwise
+ */
+ public function supports(object $target, object $other, array $context = []): bool;
+
+ /**
+ * Merge the other entity into the target entity.
+ * The target entity will be modified and returned.
+ * @param object $target
+ * @phpstan-param T $target
+ * @param object $other
+ * @phpstan-param T $other
+ * @param array $context
+ * @phpstan-return T
+ * @return object
+ */
+ public function merge(object $target, object $other, array $context = []): object;
+}
\ No newline at end of file
diff --git a/src/Services/EntityMergers/Mergers/PartMerger.php b/src/Services/EntityMergers/Mergers/PartMerger.php
new file mode 100644
index 00000000..c29ed960
--- /dev/null
+++ b/src/Services/EntityMergers/Mergers/PartMerger.php
@@ -0,0 +1,187 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\EntityMergers\Mergers;
+
+use App\Entity\Parts\InfoProviderReference;
+use App\Entity\Parts\ManufacturingStatus;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartAssociation;
+use App\Entity\Parts\PartLot;
+use App\Entity\PriceInformations\Orderdetail;
+use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
+
+/**
+ * This class merges two parts together.
+ *
+ * @implements EntityMergerInterface
+ */
+class PartMerger implements EntityMergerInterface
+{
+
+ use EntityMergerHelperTrait;
+
+ public function supports(object $target, object $other, array $context = []): bool
+ {
+ return $target instanceof Part && $other instanceof Part;
+ }
+
+ public function merge(object $target, object $other, array $context = []): Part
+ {
+ if (!$target instanceof Part || !$other instanceof Part) {
+ throw new \InvalidArgumentException('The target and the other entity must be instances of Part');
+ }
+
+ //Merge basic fields
+ $this->mergeTextWithSeparator($target, $other, 'name');
+ $this->mergeTextWithSeparator($target, $other, 'description');
+ $this->mergeComment($target, $other);
+ $this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_url');
+ $this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_number');
+ $this->useOtherValueIfNotEmtpy($target, $other, 'mass');
+ $this->useOtherValueIfNotEmtpy($target, $other, 'ipn');
+
+ //Merge relations to other entities
+ $this->useOtherValueIfNotNull($target, $other, 'manufacturer');
+ $this->useOtherValueIfNotNull($target, $other, 'footprint');
+ $this->useOtherValueIfNotNull($target, $other, 'category');
+ $this->useOtherValueIfNotNull($target, $other, 'partUnit');
+
+ //We assume that the higher value is the correct one for minimum instock
+ $this->useLargerValue($target, $other, 'minamount');
+
+ //We assume that a part needs review and is a favorite if one of the parts is
+ $this->useTrueValue($target, $other, 'needs_review');
+ $this->useTrueValue($target, $other, 'favorite');
+
+ //Merge the tags using the tag merger
+ $this->mergeTags($target, $other, 'tags');
+
+ //Merge manufacturing status
+ $this->useCallback(function (?ManufacturingStatus $t, ?ManufacturingStatus $o): ManufacturingStatus {
+ //Use the other value, if the target value is not set
+ if ($t === ManufacturingStatus::NOT_SET || $t === null) {
+ return $o ?? ManufacturingStatus::NOT_SET;
+ }
+
+ return $t;
+ }, $target, $other, 'manufacturing_status');
+
+ //Merge provider reference
+ $this->useCallback(function (InfoProviderReference $t, InfoProviderReference $o): InfoProviderReference {
+ if (!$t->isProviderCreated() && $o->isProviderCreated()) {
+ return $o;
+ }
+ return $t;
+ }, $target, $other, 'providerReference');
+
+ //Merge the collections
+ $this->mergeCollectionFields($target, $other, $context);
+
+ return $target;
+ }
+
+ private static function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool {
+ //We compare the translation keys, as it contains info about the type and other type info
+ return $t->getOther() === $o->getOther()
+ && $t->getTypeTranslationKey() === $o->getTypeTranslationKey();
+ }
+
+ private function mergeCollectionFields(Part $target, Part $other, array $context): void
+ {
+ /********************************************************************************
+ * Merge collections
+ ********************************************************************************/
+
+ //Lots from different parts are never considered equal, so we just merge them together
+ $this->mergeCollections($target, $other, 'partLots');
+ $this->mergeAttachments($target, $other);
+ $this->mergeParameters($target, $other);
+
+ //Merge the associations
+ $this->mergeCollections($target, $other, 'associated_parts_as_owner', self::comparePartAssociations(...));
+
+ //We have to recreate the associations towards the other part, as they are not created by the merger
+ foreach ($other->getAssociatedPartsAsOther() as $association) {
+ //Clone the association
+ $clone = clone $association;
+ //Set the target part as the other part
+ $clone->setOther($target);
+ $owner = $clone->getOwner();
+ if (!$owner) {
+ continue;
+ }
+ //Ensure that the association is not already present
+ foreach ($owner->getAssociatedPartsAsOwner() as $existing_association) {
+ if (self::comparePartAssociations($existing_association, $clone)) {
+ continue 2;
+ }
+ }
+
+ //Add the association to the owner
+ $owner->addAssociatedPartsAsOwner($clone);
+ }
+
+ $this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) {
+ //First check that the orderdetails infos are equal
+ $tmp = $t->getSupplier() === $o->getSupplier()
+ && $t->getSupplierPartNr() === $o->getSupplierPartNr()
+ && $t->getSupplierProductUrl(false) === $o->getSupplierProductUrl(false);
+
+ if (!$tmp) {
+ return false;
+ }
+
+ //Check if the pricedetails are equal
+ $t_pricedetails = $t->getPricedetails();
+ $o_pricedetails = $o->getPricedetails();
+ //Ensure that both pricedetails have the same length
+ if (count($t_pricedetails) !== count($o_pricedetails)) {
+ return false;
+ }
+
+ //Check if all pricedetails are equal
+ for ($n=0, $nMax = count($t_pricedetails); $n< $nMax; $n++) {
+ $t_price = $t_pricedetails->get($n);
+ $o_price = $o_pricedetails->get($n);
+
+ if (!$t_price->getPrice()->isEqualTo($o_price->getPrice())
+ || $t_price->getCurrency() !== $o_price->getCurrency()
+ || $t_price->getPriceRelatedQuantity() !== $o_price->getPriceRelatedQuantity()
+ || $t_price->getMinDiscountQuantity() !== $o_price->getMinDiscountQuantity()
+ ) {
+ return false;
+ }
+ }
+
+ //If all pricedetails are equal, the orderdetails are equal
+ return true;
+ });
+ //The pricedetails are not correctly assigned to the new orderdetails, so fix that
+ foreach ($target->getOrderdetails() as $orderdetail) {
+ foreach ($orderdetail->getPricedetails() as $pricedetail) {
+ $pricedetail->setOrderdetail($orderdetail);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/templates/components/collection_type.macro.html.twig b/templates/components/collection_type.macro.html.twig
index 1db04763..fde2b961 100644
--- a/templates/components/collection_type.macro.html.twig
+++ b/templates/components/collection_type.macro.html.twig
@@ -29,4 +29,13 @@
{% macro delete_btn() %}
{{ stimulus_action('elements/collection_type', 'deleteElement') }}
+{% endmacro %}
+
+{% macro new_element_indicator(value) %}
+ {% if value.id is not defined or value.id is null %}
+
+ New alerts
+
+ {% endif %}
{% endmacro %}
\ No newline at end of file
diff --git a/templates/form/collection_types_layout.html.twig b/templates/form/collection_types_layout.html.twig
index 9cc1297b..96b71bf0 100644
--- a/templates/form/collection_types_layout.html.twig
+++ b/templates/form/collection_types_layout.html.twig
@@ -50,8 +50,9 @@
{{ form_errors(form.name) }}
-
diff --git a/templates/info_providers/search/part_search.html.twig b/templates/info_providers/search/part_search.html.twig
index c28235c7..78e06283 100644
--- a/templates/info_providers/search/part_search.html.twig
+++ b/templates/info_providers/search/part_search.html.twig
@@ -3,16 +3,25 @@
{% import "info_providers/providers.macro.html.twig" as providers_macro %}
{% import "helper.twig" as helper %}
-{% block title %}{% trans %}info_providers.search.title{% endtrans %}{% endblock %}
+{% block title %}
+ {% if update_target %}
+ {% trans %}info_providers.update_part.title{% endtrans %}
+ {% else %}
+ {% trans %}info_providers.search.title{% endtrans %}
+ {% endif %}
+{% endblock %}
{% block card_title %}
- {% trans %}info_providers.search.title{% endtrans %}
+ {% if update_target %} {# If update_target is set, we update an existing part #}
+ {% trans %}info_providers.update_part.title{% endtrans %}:
+ {{ update_target.name }}
+ {% else %} {# Create a fresh part #}
+ {% trans %}info_providers.search.title{% endtrans %}
+ {% endif %}
{% endblock %}
{% block card_content %}
-
-
{{ form_start(form) }}
{{ form_row(form.keyword) }}
@@ -86,7 +95,15 @@
{{ result.provider_id }}