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) }} - {{ form_errors(form) }} 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 }} - diff --git a/templates/parts/edit/edit_form_styles.html.twig b/templates/parts/edit/edit_form_styles.html.twig index 4e68ae5f..6658fa80 100644 --- a/templates/parts/edit/edit_form_styles.html.twig +++ b/templates/parts/edit/edit_form_styles.html.twig @@ -14,9 +14,10 @@ {{ form_widget(form.price_related_quantity, {'attr': {'class': 'form-control-sm'}}) }} {{ form_errors(form.price_related_quantity) }} - {{ form_errors(form) }} @@ -57,8 +58,9 @@ - {{ form_errors(form) }} @@ -77,8 +79,10 @@ {{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }} {{ form_widget(form.group) }}{{ form_errors(form.group) }} - {{ form_errors(form) }} @@ -108,9 +112,10 @@ - {{ form_errors(form) }} @@ -140,9 +145,10 @@ - {% set attach = form.vars.value %} @@ -210,9 +216,10 @@ - {{ form_errors(form) }} diff --git a/templates/parts/edit/merge_parts.html.twig b/templates/parts/edit/merge_parts.html.twig new file mode 100644 index 00000000..a91b6423 --- /dev/null +++ b/templates/parts/edit/merge_parts.html.twig @@ -0,0 +1,27 @@ +{% extends "parts/edit/edit_part_info.html.twig" %} + +{# @var merge_other \App\Entity\Parts\Part #} + +{% block card_border %}border-info{% endblock %} +{% block card_type %}bg-info text-bg-info{% endblock %} + +{% block title %} + {% trans %}part.merge.title{% endtrans %} {{ merge_other.name }} {% trans %}part.merge.title.into{% endtrans %} {{ merge_old_name }} +{% endblock %} + +{% block card_title %} + + {% trans %}part.merge.title{% endtrans %} + {{ merge_other.name }} (ID: {{ merge_other.iD }}) + {% trans %}part.merge.title.into{% endtrans %} + {{ merge_old_name }} (ID: {{ part.id }}) +{% endblock %} + +{% block card_content %} +
+ {{ parent() }} +
+ +{% endblock %} \ No newline at end of file diff --git a/templates/parts/edit/new_part.html.twig b/templates/parts/edit/new_part.html.twig index 278c57c3..2c11c7a0 100644 --- a/templates/parts/edit/new_part.html.twig +++ b/templates/parts/edit/new_part.html.twig @@ -3,6 +3,10 @@ {% block card_border %}border-success{% endblock %} {% block card_type %}bg-success text-white{% endblock %} +{% block title %} + {% trans %}part.new.card_title{% endtrans %} +{% endblock %} + {% block card_title %} {% trans %}part.new.card_title{% endtrans %} diff --git a/templates/parts/edit/update_from_ip.html.twig b/templates/parts/edit/update_from_ip.html.twig new file mode 100644 index 00000000..fb1dfad3 --- /dev/null +++ b/templates/parts/edit/update_from_ip.html.twig @@ -0,0 +1,16 @@ +{% extends "parts/edit/edit_part_info.html.twig" %} + +{# @var merge_other \App\Entity\Parts\Part #} + +{% block card_border %}border-info{% endblock %} +{% block card_type %}bg-info text-bg-info{% endblock %} + +{% block title %} + {% trans %}info_providers.update_part.title{% endtrans %}: {{ merge_old_name }} +{% endblock %} + +{% block card_title %} + + {% trans %}info_providers.update_part.title{% endtrans %}: + {{ merge_old_name }} +{% endblock %} \ No newline at end of file diff --git a/templates/parts/info/_merge_modal.html.twig b/templates/parts/info/_merge_modal.html.twig new file mode 100644 index 00000000..b338e148 --- /dev/null +++ b/templates/parts/info/_merge_modal.html.twig @@ -0,0 +1,65 @@ +{# Merge modal #} + +{% if is_granted('edit', part) %} +
+ +{% endif %} + + + diff --git a/templates/parts/info/_tools.html.twig b/templates/parts/info/_tools.html.twig index 0662a2e4..1a2c2a8f 100644 --- a/templates/parts/info/_tools.html.twig +++ b/templates/parts/info/_tools.html.twig @@ -7,7 +7,7 @@ {% endif %} - +{# Create new button #} {% if is_granted('create', part) %}
@@ -27,6 +27,19 @@
{% endif %} +{# Merge modal #} +{% include "parts/info/_merge_modal.html.twig" %} + +{# Update part from info provider button #} +{% if is_granted('edit', part) and is_granted('@info_providers.create_parts') %} +
+ + + {% trans %}part.update_part_from_info_provider.btn{% endtrans %} + +{% endif %} + +
. + */ + +namespace App\Tests\Services\EntityMergers\Mergers; + +use App\Entity\Parts\Part; +use App\Services\EntityMergers\Mergers\EntityMergerHelperTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +class EntityMergerHelperTraitTest extends KernelTestCase +{ + use EntityMergerHelperTrait; + + public function setUp(): void + { + self::bootKernel(); + $this->property_accessor = self::getContainer()->get(PropertyAccessorInterface::class); + } + + public function testUseCallback(): void + { + $obj1 = new MergeTestClass(); + $obj1->non_nullable_string = 'obj1'; + $obj2 = new MergeTestClass(); + $obj2->non_nullable_string = 'obj2'; + + $tmp = $this->useCallback(function ($target_value, $other_value, $target, $other, $field) use ($obj1, $obj2) { + $this->assertSame($obj1, $target); + $this->assertSame($obj2, $other); + $this->assertSame('non_nullable_string', $field); + $this->assertSame('obj1', $target_value); + $this->assertSame('obj2', $other_value); + + return 'callback'; + + }, $obj1, $obj2, 'non_nullable_string'); + + //merge should return the target object + $this->assertSame($obj1, $tmp); + //And it should have the value from the callback set + $this->assertSame('callback', $obj1->non_nullable_string); + } + + public function testOtherFunctionIfNotNull(): void + { + $obj1 = new MergeTestClass(); + $obj1->string_property = null; + $obj2 = new MergeTestClass(); + $obj2->string_property = 'obj2'; + + $tmp = $this->useOtherValueIfNotNull($obj1, $obj2, 'string_property'); + $this->assertSame($obj1, $tmp); + $this->assertSame('obj2', $obj1->string_property); + + $obj1->string_property = 'obj1'; + $tmp = $this->useOtherValueIfNotNull($obj1, $obj2, 'string_property'); + $this->assertSame($obj1, $tmp); + $this->assertSame('obj1', $tmp->string_property); + + $obj1->string_property = null; + $obj2->string_property = null; + $this->assertSame($obj1, $this->useOtherValueIfNotNull($obj1, $obj2, 'string_property')); + $this->assertNull($obj1->string_property); + } + + public function testOtherFunctionIfNotEmpty(): void + { + $obj1 = new MergeTestClass(); + $obj1->string_property = null; + $obj2 = new MergeTestClass(); + $obj2->string_property = 'obj2'; + + $tmp = $this->useOtherValueIfNotEmtpy($obj1, $obj2, 'string_property'); + $this->assertSame($obj1, $tmp); + $this->assertSame('obj2', $obj1->string_property); + + $obj1->string_property = 'obj1'; + $tmp = $this->useOtherValueIfNotEmtpy($obj1, $obj2, 'string_property'); + $this->assertSame($obj1, $tmp); + $this->assertSame('obj1', $tmp->string_property); + + $obj1->string_property = null; + $obj2->string_property = null; + $this->assertSame($obj1, $this->useOtherValueIfNotEmtpy($obj1, $obj2, 'string_property')); + $this->assertNull($obj1->string_property); + + $obj1->string_property = ''; + $obj2->string_property = 'test'; + $this->assertSame($obj1, $this->useOtherValueIfNotEmtpy($obj1, $obj2, 'string_property')); + $this->assertSame('test', $obj1->string_property); + } + + public function testUseLargerValue(): void + { + $obj1 = new MergeTestClass(); + $obj1->int_property = 1; + $obj2 = new MergeTestClass(); + $obj2->int_property = 2; + + $tmp = $this->useLargerValue($obj1, $obj2, 'int_property'); + $this->assertSame($obj1, $tmp); + $this->assertSame(2, $obj1->int_property); + + $obj1->int_property = 3; + $obj2->int_property = 2; + + $tmp = $this->useLargerValue($obj1, $obj2, 'int_property'); + $this->assertSame($obj1, $tmp); + $this->assertSame(3, $obj1->int_property); + } + + public function testUseSmallerValue(): void + { + $obj1 = new MergeTestClass(); + $obj1->int_property = 1; + $obj2 = new MergeTestClass(); + $obj2->int_property = 2; + + $tmp = $this->useSmallerValue($obj1, $obj2, 'int_property'); + $this->assertSame($obj1, $tmp); + $this->assertSame(1, $obj1->int_property); + + $obj1->int_property = 3; + $obj2->int_property = 2; + + $tmp = $this->useSmallerValue($obj1, $obj2, 'int_property'); + $this->assertSame($obj1, $tmp); + $this->assertSame(2, $obj1->int_property); + } + + public function testUseTrueValue(): void + { + $obj1 = new MergeTestClass(); + $obj1->bool_property = false; + $obj2 = new MergeTestClass(); + $obj2->bool_property = true; + + $tmp = $this->useTrueValue($obj1, $obj2, 'bool_property'); + $this->assertSame($obj1, $tmp); + $this->assertTrue($obj1->bool_property); + + $obj1->bool_property = true; + $obj2->bool_property = false; + $this->assertTrue($this->useTrueValue($obj1, $obj2, 'bool_property')->bool_property); + + $obj1->bool_property = false; + $obj2->bool_property = false; + $this->assertFalse($this->useTrueValue($obj1, $obj2, 'bool_property')->bool_property); + } + + public function testMergeTags(): void + { + $obj1 = new MergeTestClass(); + $obj1->string_property = 'tag1,tag2,tag3'; + $obj2 = new MergeTestClass(); + $obj2->string_property = 'tag2,tag3,tag4'; + + $tmp = $this->mergeTags($obj1, $obj2, 'string_property'); + $this->assertSame($obj1, $tmp); + $this->assertSame('tag1,tag2,tag3,tag4', $obj1->string_property); + } + + public function testAreStringsEqual(): void + { + $this->assertTrue($this->areStringsEqual('test', 'test')); + $this->assertTrue($this->areStringsEqual('test', 'TEST')); + $this->assertTrue($this->areStringsEqual('test', 'Test')); + $this->assertTrue($this->areStringsEqual('test', ' Test ')); + $this->assertTrue($this->areStringsEqual('Test ', 'test')); + + $this->assertFalse($this->areStringsEqual('test', 'test2')); + $this->assertFalse($this->areStringsEqual('test', 'test 1')); + } + + public function testMergeTextWithSeparator(): void + { + $obj1 = new MergeTestClass(); + $obj1->string_property = 'Test1'; + $obj2 = new MergeTestClass(); + $obj2->string_property = 'Test2'; + + $tmp = $this->mergeTextWithSeparator($obj1, $obj2, 'string_property', ' # '); + $this->assertSame($obj1, $tmp); + $this->assertSame('Test1 # Test2', $obj1->string_property); + + //If thee text is the same, it should not be duplicated + $obj1->string_property = 'Test1'; + $obj2->string_property = 'Test1'; + $this->assertSame($obj1, $this->mergeTextWithSeparator($obj1, $obj2, 'string_property', ' # ')); + $this->assertSame('Test1', $obj1->string_property); + + //Test what happens if the second text is empty + $obj1->string_property = 'Test1'; + $obj2->string_property = ''; + $this->assertSame($obj1, $this->mergeTextWithSeparator($obj1, $obj2, 'string_property', ' # ')); + $this->assertSame('Test1', $obj1->string_property); + + } + + public function testMergeComment(): void + { + $obj1 = new Part(); + $obj1->setName('Test1'); + $obj1->setComment('Comment1'); + $obj2 = new Part(); + $obj2->setName('Test2'); + $obj2->setComment('Comment2'); + + $tmp = $this->mergeComment($obj1, $obj2); + $this->assertSame($obj1, $tmp); + $this->assertSame("Comment1\n\nTest2:\nComment2", $obj1->getComment()); + + //If the comment is the same, it should not be duplicated + $obj1->setComment('Comment1'); + $obj2->setComment('Comment1'); + $this->assertSame($obj1, $this->mergeComment($obj1, $obj2)); + $this->assertSame('Comment1', $obj1->getComment()); + + //Test what happens if the second comment is empty + $obj1->setComment('Comment1'); + $obj2->setComment(''); + $this->assertSame($obj1, $this->mergeComment($obj1, $obj2)); + $this->assertSame('Comment1', $obj1->getComment()); + } +} diff --git a/tests/Services/EntityMergers/Mergers/MergeTestClass.php b/tests/Services/EntityMergers/Mergers/MergeTestClass.php new file mode 100644 index 00000000..da7ad67c --- /dev/null +++ b/tests/Services/EntityMergers/Mergers/MergeTestClass.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Tests\Services\EntityMergers\Mergers; + +use App\Entity\Parts\Category; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; + +class MergeTestClass +{ + public int $int_property = 0; + public ?float $float_property = null; + public ?string $string_property = null; + public string $non_nullable_string = ''; + public bool $bool_property = false; + + public Collection $collection; + + public ?Category $category; + + public function __construct() + { + $this->collection = new ArrayCollection(); + } +} \ No newline at end of file diff --git a/tests/Services/EntityMergers/Mergers/PartMergerTest.php b/tests/Services/EntityMergers/Mergers/PartMergerTest.php new file mode 100644 index 00000000..d607ee72 --- /dev/null +++ b/tests/Services/EntityMergers/Mergers/PartMergerTest.php @@ -0,0 +1,190 @@ +. + */ + +namespace App\Tests\Services\EntityMergers\Mergers; + +use App\Entity\Parts\AssociationType; +use App\Entity\Parts\Category; +use App\Entity\Parts\Footprint; +use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\Part; +use App\Entity\Parts\PartAssociation; +use App\Entity\Parts\PartLot; +use App\Entity\PriceInformations\Orderdetail; +use App\Services\EntityMergers\Mergers\PartMerger; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class PartMergerTest extends KernelTestCase +{ + + /** @var PartMerger|null */ + protected ?PartMerger $merger = null; + + protected function setUp(): void + { + self::bootKernel(); + $this->merger = self::getContainer()->get(PartMerger::class); + } + + public function testMergeOfEntityRelations(): void + { + $category = new Category(); + $footprint = new Footprint(); + $manufacturer1 = new Manufacturer(); + $manufacturer2 = new Manufacturer(); + $unit = new MeasurementUnit(); + + $part1 = (new Part()) + ->setCategory($category) + ->setManufacturer($manufacturer1); + + $part2 = (new Part()) + ->setFootprint($footprint) + ->setManufacturer($manufacturer2) + ->setPartUnit($unit); + + $merged = $this->merger->merge($part1, $part2); + $this->assertSame($merged, $part1); + $this->assertSame($category, $merged->getCategory()); + $this->assertSame($footprint, $merged->getFootprint()); + $this->assertSame($manufacturer1, $merged->getManufacturer()); + $this->assertSame($unit, $merged->getPartUnit()); + } + + public function testMergeOfTags(): void + { + $part1 = (new Part()) + ->setTags('tag1,tag2,tag3'); + + $part2 = (new Part()) + ->setTags('tag2,tag3,tag4'); + + $merged = $this->merger->merge($part1, $part2); + $this->assertSame($merged, $part1); + $this->assertSame('tag1,tag2,tag3,tag4', $merged->getTags()); + } + + public function testMergeOfBoolFields(): void + { + $part1 = (new Part()) + ->setFavorite(false) + ->setNeedsReview(true); + + $part2 = (new Part()) + ->setFavorite(true) + ->setNeedsReview(false); + + $merged = $this->merger->merge($part1, $part2); + //Favorite and needs review should be true, as it is true in one of the parts + $this->assertTrue($merged->isFavorite()); + $this->assertTrue($merged->isNeedsReview()); + } + + public function testMergeOfAssociatedPartsAsOther(): void + { + //Part1 is associated with part2 and part3: + $part1 = (new Part()) + ->setName('part1'); + $part2 = (new Part()) + ->setName('part2'); + $part3 = (new Part()) + ->setName('part3'); + + $association1 = (new PartAssociation()) + ->setOther($part2) + ->setType(AssociationType::COMPATIBLE); + + $association2 = (new PartAssociation()) + ->setOther($part2) + ->setType(AssociationType::SUPERSEDES); + + $association3 = (new PartAssociation()) + ->setOther($part3) + ->setType(AssociationType::SUPERSEDES); + + $part1->addAssociatedPartsAsOwner($association1); + $part1->addAssociatedPartsAsOwner($association2); + $part1->addAssociatedPartsAsOwner($association3); + //Fill the other side of the association manually, as we have no entity manager + $part2->getAssociatedPartsAsOther()->add($association1); + $part2->getAssociatedPartsAsOther()->add($association2); + $part3->getAssociatedPartsAsOther()->add($association3); + + //Now we merge part2 into part3: + $merged = $this->merger->merge($part3, $part2); + $this->assertSame($merged, $part3); + + //Now part1 should have 4 associations, 2 with part2 and 2 with part3 + $this->assertCount(4, $part1->getAssociatedPartsAsOwner()); + $this->assertCount(2, $part1->getAssociatedPartsAsOwner()->filter(fn(PartAssociation $a) => $a->getOther() === $part2)); + $this->assertCount(2, $part1->getAssociatedPartsAsOwner()->filter(fn(PartAssociation $a) => $a->getOther() === $part3)); + } + + /** + * This test also functions as test for EntityMergerHelperTrait::mergeCollections() so its pretty long. + * @return void + */ + public function testMergeOfPartLots(): void + { + $lot1 = (new PartLot())->setAmount(2)->setNeedsRefill(true); + $lot2 = (new PartLot())->setInstockUnknown(true)->setVendorBarcode('test'); + $lot3 = (new PartLot())->setDescription('lot3')->setAmount(3); + $lot4 = (new PartLot())->setDescription('lot4')->setComment('comment'); + + $part1 = (new Part()) + ->setName('Part 1') + ->addPartLot($lot1) + ->addPartLot($lot2); + + $part2 = (new Part()) + ->setName('Part 2') + ->addPartLot($lot3) + ->addPartLot($lot4); + + $merged = $this->merger->merge($part1, $part2); + + $this->assertInstanceOf(Part::class, $merged); + //We should now have all 4 lots + $this->assertCount(4, $merged->getPartLots()); + + //The existing lots should be the same instance as before + $this->assertSame($lot1, $merged->getPartLots()->get(0)); + $this->assertSame($lot2, $merged->getPartLots()->get(1)); + //While the new lots should be new instances + $this->assertNotSame($lot3, $merged->getPartLots()->get(2)); + $this->assertNotSame($lot4, $merged->getPartLots()->get(3)); + + //But the new lots, should be assigned to the target part and contain the same info + $clone3 = $merged->getPartLots()->get(2); + $clone4 = $merged->getPartLots()->get(3); + $this->assertSame($merged, $clone3->getPart()); + $this->assertSame($merged, $clone4->getPart()); + + } + + public function testSupports() + { + $this->assertFalse($this->merger->supports(new \stdClass(), new \stdClass())); + $this->assertFalse($this->merger->supports(new \stdClass(), new Part())); + $this->assertTrue($this->merger->supports(new Part(), new Part())); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 845c0df1..ff5aa895 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -11957,5 +11957,89 @@ Please note, that you can not impersonate a disabled user. If you try you will g Stocked amount + + + collection_type.new_element + collection_type.new_element + + + + + collection_type.new_element.tooltip + This element was newly created and was not persisted to the database yet. + + + + + part.merge.title + Merge part + + + + + part.merge.title.into + into + + + + + part.merge.confirm.title + %other% into %target%?]]> + + + + + part.merge.confirm.message + %other% will be deleted, and the part will be saved with the shown information.]]> + + + + + part.info.merge_modal.title + Merge parts + + + + + part.info.merge_modal.other_part + Other part + + + + + part.info.merge_modal.other_into_this + Merge other part into this one (delete other part, keep this one) + + + + + part.info.merge_modal.this_into_other + Merge this part into other one (delete this part, keep other one) + + + + + part.info.merge_btn + Merge part + + + + + part.update_part_from_info_provider.btn + Update part from info providers + + + + + info_providers.update_part.title + Update existing part from info provider + + + + + part.merge.flash.please_review + Data not saved yet. Review the changes and click save to persist the new data. + +