mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-06-21 01:25:55 +02:00
Merge branch 'merge_system'
This commit is contained in:
commit
5b09cbf1ac
22 changed files with 1573 additions and 17 deletions
|
@ -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;
|
||||
|
||||
|
|
68
assets/controllers/pages/part_merge_modal_controller.js
Normal file
68
assets/controllers/pages/part_merge_modal_controller.js
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
76
src/Services/EntityMergers/EntityMerger.php
Normal file
76
src/Services/EntityMergers/EntityMerger.php
Normal file
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\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);
|
||||
}
|
||||
}
|
374
src/Services/EntityMergers/Mergers/EntityMergerHelperTrait.php
Normal file
374
src/Services/EntityMergers/Mergers/EntityMergerHelperTrait.php
Normal file
|
@ -0,0 +1,374 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\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<b>%s:</b>\n%s",
|
||||
trim($t),
|
||||
$other->getName(),
|
||||
trim($o)
|
||||
);
|
||||
},
|
||||
$target,
|
||||
$other,
|
||||
$field
|
||||
);
|
||||
}
|
||||
}
|
58
src/Services/EntityMergers/Mergers/EntityMergerInterface.php
Normal file
58
src/Services/EntityMergers/Mergers/EntityMergerInterface.php
Normal file
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\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;
|
||||
}
|
187
src/Services/EntityMergers/Mergers/PartMerger.php
Normal file
187
src/Services/EntityMergers/Mergers/PartMerger.php
Normal file
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\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<Part>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 %}
|
||||
<span class="position-absolute top-0 start-100 translate-middle p-2 bg-primary border border-light rounded-circle"
|
||||
title="{% trans %}collection_type.new_element.tooltip{% endtrans %}">
|
||||
<span class="visually-hidden">New alerts</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
|
@ -50,8 +50,9 @@
|
|||
{{ form_errors(form.name) }}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger lot_btn_delete" {{ collection.delete_btn() }}>
|
||||
<button type="button" class="btn btn-danger lot_btn_delete position-relative" {{ collection.delete_btn() }}>
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
{{ collection.new_element_indicator(value) }}
|
||||
</button>
|
||||
{{ form_errors(form) }}
|
||||
</td>
|
||||
|
|
|
@ -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 %}
|
||||
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.search.title{% endtrans %}
|
||||
{% if update_target %} {# If update_target is set, we update an existing part #}
|
||||
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.update_part.title{% endtrans %}:
|
||||
<b><a href="{{ entity_url(update_target) }}" target="_blank" class="text-bg-primary">{{ update_target.name }}</a></b>
|
||||
{% else %} {# Create a fresh part #}
|
||||
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.search.title{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
|
||||
|
||||
|
||||
{{ form_start(form) }}
|
||||
|
||||
{{ form_row(form.keyword) }}
|
||||
|
@ -86,7 +95,15 @@
|
|||
<br>
|
||||
<small class="text-muted">{{ result.provider_id }}</small>
|
||||
<td>
|
||||
<a class="btn btn-primary" href="{{ path('info_providers_create_part', {'providerKey': result.provider_key, 'providerId': result.provider_id}) }}"
|
||||
{% if update_target %} {# We update an existing part #}
|
||||
{% set href = path('info_providers_update_part',
|
||||
{'providerKey': result.provider_key, 'providerId': result.provider_id, 'id': update_target.iD}) %}
|
||||
{% else %} {# Create a fresh part #}
|
||||
{% set href = path('info_providers_create_part',
|
||||
{'providerKey': result.provider_key, 'providerId': result.provider_id}) %}
|
||||
{% endif %}
|
||||
|
||||
<a class="btn btn-primary" href="{{ href }}"
|
||||
target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
|
||||
<i class="fa-solid fa-plus-square"></i>
|
||||
</a>
|
||||
|
|
|
@ -14,9 +14,10 @@
|
|||
</td>
|
||||
<td>{{ form_widget(form.price_related_quantity, {'attr': {'class': 'form-control-sm'}}) }} {{ form_errors(form.price_related_quantity) }}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger order_btn_delete btn-sm" title="{% trans %}orderdetail.delete{% endtrans %}"
|
||||
<button type="button" class="btn btn-danger order_btn_delete btn-sm position-relative" title="{% trans %}orderdetail.delete{% endtrans %}"
|
||||
{{ collection.delete_btn() }}>
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
{{ collection.new_element_indicator(value) }}
|
||||
</button>
|
||||
{{ form_errors(form) }}
|
||||
</td>
|
||||
|
@ -57,8 +58,9 @@
|
|||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger order_btn_delete" {{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}">
|
||||
<button type="button" class="btn btn-danger order_btn_delete position-relative" {{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
{{ collection.new_element_indicator(value) }}
|
||||
</button>
|
||||
{{ form_errors(form) }}
|
||||
</td>
|
||||
|
@ -77,8 +79,10 @@
|
|||
<td>{{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }}</td>
|
||||
<td>{{ form_widget(form.group) }}{{ form_errors(form.group) }}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger btn-sm order_btn_delete {% if form.parent.vars.allow_delete is defined and not form.parent.vars.allow_delete %}disabled{% endif %}" {{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}">
|
||||
<button type="button" class="btn btn-danger btn-sm order_btn_delete position-relative {% if form.parent.vars.allow_delete is defined and not form.parent.vars.allow_delete %}disabled{% endif %}"
|
||||
{{ collection.delete_btn() }} title="{% trans %}orderdetail.delete{% endtrans %}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
{{ collection.new_element_indicator(value) }}
|
||||
</button>
|
||||
{{ form_errors(form) }}
|
||||
</td>
|
||||
|
@ -108,9 +112,10 @@
|
|||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger lot_btn_delete" {{ collection.delete_btn() }}>
|
||||
<button type="button" class="btn btn-danger lot_btn_delete position-relative" {{ collection.delete_btn() }}>
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
{% trans %}part_lot.delete{% endtrans %}
|
||||
{{ collection.new_element_indicator(value) }}
|
||||
</button>
|
||||
{{ form_errors(form) }}
|
||||
</td>
|
||||
|
@ -140,9 +145,10 @@
|
|||
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger lot_btn_delete" {{ collection.delete_btn() }}>
|
||||
<button type="button" class="btn btn-danger lot_btn_delete position-relative" {{ collection.delete_btn() }}>
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
{% trans %}attachment.delete{% endtrans %}
|
||||
{{ collection.new_element_indicator(value) }}
|
||||
</button>
|
||||
|
||||
{% set attach = form.vars.value %}
|
||||
|
@ -210,9 +216,10 @@
|
|||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger lot_btn_delete" {{ collection.delete_btn() }}>
|
||||
<button type="button" class="btn btn-danger lot_btn_delete position-relative" {{ collection.delete_btn() }}>
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
{% trans %}part_lot.delete{% endtrans %}
|
||||
{{ collection.new_element_indicator(value) }}
|
||||
</button>
|
||||
{{ form_errors(form) }}
|
||||
</td>
|
||||
|
|
27
templates/parts/edit/merge_parts.html.twig
Normal file
27
templates/parts/edit/merge_parts.html.twig
Normal file
|
@ -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 %}
|
||||
<i class="fas fa-code-merge" aria-hidden="true"></i>
|
||||
{% trans %}part.merge.title{% endtrans %}
|
||||
<b><a class="text-bg-info" href="{{ entity_url(merge_other, 'info') }}">{{ merge_other.name }} (ID: {{ merge_other.iD }})</a></b>
|
||||
{% trans %}part.merge.title.into{% endtrans %}
|
||||
<b><a class="text-bg-info" href="{{ entity_url(part, 'info') }}">{{ merge_old_name }} (ID: {{ part.id }})</a></b>
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
<div {{ stimulus_controller('elements/delete_btn') }} {{ stimulus_action('elements/delete_btn', "submit", "submit") }}
|
||||
data-delete-title="{% trans with {'%target%': merge_old_name|escape, '%other%': merge_other.name }%}part.merge.confirm.title{% endtrans %}"
|
||||
data-delete-message="{% trans with {'%other%': merge_other.name } %}part.merge.confirm.message{% endtrans %}">
|
||||
{{ parent() }}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -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 %}
|
||||
<i class="fas fa-edit fa-plus-square" aria-hidden="true"></i>
|
||||
{% trans %}part.new.card_title{% endtrans %}
|
||||
|
|
16
templates/parts/edit/update_from_ip.html.twig
Normal file
16
templates/parts/edit/update_from_ip.html.twig
Normal file
|
@ -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 %}
|
||||
<i class="fas fa-cloud-arrow-down" aria-hidden="true"></i>
|
||||
{% trans %}info_providers.update_part.title{% endtrans %}:
|
||||
<b><a class="text-bg-info" href="{{ entity_url(part) }}">{{ merge_old_name }}</a></b>
|
||||
{% endblock %}
|
65
templates/parts/info/_merge_modal.html.twig
Normal file
65
templates/parts/info/_merge_modal.html.twig
Normal file
|
@ -0,0 +1,65 @@
|
|||
{# Merge modal #}
|
||||
|
||||
{% if is_granted('edit', part) %}
|
||||
<br>
|
||||
<button type="button" class="btn btn-info mt-2" data-bs-toggle="modal" data-bs-target="#merge-modal">
|
||||
<i class="fas fa-code-merge" aria-hidden="true"></i> {% trans %}part.info.merge_btn{% endtrans %}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<div class="modal fade" id="merge-modal" tabindex="-1" aria-labelledby="merge-modal-title" tabindex="-1" aria-hidden="true" {{ stimulus_controller('pages/part_withdraw_modal') }}>
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" {{ stimulus_controller('pages/part_merge_modal', {'targetId': part.iD }) }}>
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="merge-modal-title">{% trans %}part.info.merge_modal.title{% endtrans %}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{# non visible form elements #}
|
||||
<input type="hidden" name="lot_id" value="">
|
||||
<input type="hidden" name="_redirect" value="{{ app.request.baseUrl ~ app.request.requestUri }}">
|
||||
|
||||
<div class="row mb-2">
|
||||
<label class="form-label">
|
||||
{% trans %}part.info.merge_modal.other_part{% endtrans %}:
|
||||
</label>
|
||||
<select class="form-select" {{ stimulus_controller('elements/part_select') }}
|
||||
data-autocomplete="{{ path('typeahead_parts', {'query': '__QUERY__'}) }}"
|
||||
{{ stimulus_target('pages/part_merge_modal', 'otherSelect') }}
|
||||
{{ stimulus_action('pages/part_merge_modal', 'update', 'change') }}
|
||||
>
|
||||
{# Filled by stimulus controller #}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="mergeModalMode" id="mergeModalMode_1"
|
||||
{{ stimulus_target('pages/part_merge_modal', 'mode') }}
|
||||
{{ stimulus_action('pages/part_merge_modal', 'update', 'change') }}
|
||||
value="1">
|
||||
<label class="form-check-label" for="mergeModalMode_1">
|
||||
{% trans %}part.info.merge_modal.other_into_this{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="mergeModalMode" id="mergeModalMode_2"
|
||||
{{ stimulus_target('pages/part_merge_modal', 'mode') }}
|
||||
{{ stimulus_action('pages/part_merge_modal', 'update', 'change') }}
|
||||
value="2" checked>
|
||||
<label class="form-check-label" for="mergeModalMode_2">
|
||||
{% trans %}part.info.merge_modal.this_into_other{% endtrans %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans %}modal.close{% endtrans %}</button>
|
||||
<a class="btn btn-primary disabled" {{ stimulus_target('pages/part_merge_modal', 'link') }}
|
||||
data-href-template="{{ path('part_merge', {'target': '__target__', 'other': '__other__'}) }}"
|
||||
>{% trans %}modal.submit{% endtrans %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{# Create new button #}
|
||||
{% if is_granted('create', part) %}
|
||||
<br>
|
||||
<div class="btn-group mt-2">
|
||||
|
@ -27,6 +27,19 @@
|
|||
</div>
|
||||
{% 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') %}
|
||||
<br>
|
||||
<a class="btn btn-info mt-2" href="{{ path('info_providers_update_part_search', {'target': part.iD}) }}">
|
||||
<i class="fas fa-cloud-arrow-down"></i>
|
||||
{% trans %}part.update_part_from_info_provider.btn{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<form method="post" class="mt-2" action="{{ entity_url(part, 'delete') }}"
|
||||
{{ stimulus_controller('elements/delete_btn') }} {{ stimulus_action('elements/delete_btn', "submit", "submit") }}
|
||||
data-delete-title="{% trans with {'%name%': part.name|escape }%}part.delete.confirm_title{% endtrans %}"
|
||||
|
|
|
@ -91,6 +91,8 @@ class ApplicationAvailabilityFunctionalTest extends WebTestCase
|
|||
|
||||
yield ['/part/3/clone'];
|
||||
|
||||
yield ['/part/1/merge/2'];
|
||||
|
||||
yield ['/part/new'];
|
||||
yield ['/part/new?category=1&footprint=1&manufacturer=1&storelocation=1&supplier=1'];
|
||||
|
||||
|
@ -144,6 +146,8 @@ class ApplicationAvailabilityFunctionalTest extends WebTestCase
|
|||
//Test info provider system
|
||||
yield ['/tools/info_providers/providers']; //List all providers
|
||||
yield ['/tools/info_providers/search']; //Search page
|
||||
yield['/tools/info_providers/update/1']; //Update search for part from info provider
|
||||
yield ['/part/from_info_provider/test/element1/create']; //Create part from info provider
|
||||
yield ['/part/1/from_info_provider/test/element1/update']; //Update part from info provider
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,244 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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\n<b>Test2:</b>\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());
|
||||
}
|
||||
}
|
46
tests/Services/EntityMergers/Mergers/MergeTestClass.php
Normal file
46
tests/Services/EntityMergers/Mergers/MergeTestClass.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\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();
|
||||
}
|
||||
}
|
190
tests/Services/EntityMergers/Mergers/PartMergerTest.php
Normal file
190
tests/Services/EntityMergers/Mergers/PartMergerTest.php
Normal file
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
|
@ -11957,5 +11957,89 @@ Please note, that you can not impersonate a disabled user. If you try you will g
|
|||
<target>Stocked amount</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="hfwUcM0" name="collection_type.new_element">
|
||||
<segment>
|
||||
<source>collection_type.new_element</source>
|
||||
<target>collection_type.new_element</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="2NZFail" name="collection_type.new_element.tooltip">
|
||||
<segment>
|
||||
<source>collection_type.new_element.tooltip</source>
|
||||
<target>This element was newly created and was not persisted to the database yet.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="USUaBZ0" name="part.merge.title">
|
||||
<segment>
|
||||
<source>part.merge.title</source>
|
||||
<target>Merge part</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="sCLZTwA" name="part.merge.title.into">
|
||||
<segment>
|
||||
<source>part.merge.title.into</source>
|
||||
<target>into</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="nISUoQl" name="part.merge.confirm.title">
|
||||
<segment>
|
||||
<source>part.merge.confirm.title</source>
|
||||
<target><![CDATA[Do you really want to merge <b>%other%</b> into <b>%target%</b>?]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="qxHNYfX" name="part.merge.confirm.message">
|
||||
<segment>
|
||||
<source>part.merge.confirm.message</source>
|
||||
<target><![CDATA[<b>%other%</b> will be deleted, and the part will be saved with the shown information.]]></target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="BY9.T4F" name="part.info.merge_modal.title">
|
||||
<segment>
|
||||
<source>part.info.merge_modal.title</source>
|
||||
<target>Merge parts</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="CasQeyD" name="part.info.merge_modal.other_part">
|
||||
<segment>
|
||||
<source>part.info.merge_modal.other_part</source>
|
||||
<target>Other part</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="96Qzc6H" name="part.info.merge_modal.other_into_this">
|
||||
<segment>
|
||||
<source>part.info.merge_modal.other_into_this</source>
|
||||
<target>Merge other part into this one (delete other part, keep this one)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Oe4cpRH" name="part.info.merge_modal.this_into_other">
|
||||
<segment>
|
||||
<source>part.info.merge_modal.this_into_other</source>
|
||||
<target>Merge this part into other one (delete this part, keep other one)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="pO3q5CN" name="part.info.merge_btn">
|
||||
<segment>
|
||||
<source>part.info.merge_btn</source>
|
||||
<target>Merge part</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="efgWRwB" name="part.update_part_from_info_provider.btn">
|
||||
<segment>
|
||||
<source>part.update_part_from_info_provider.btn</source>
|
||||
<target>Update part from info providers</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="n6_Ec0h" name="info_providers.update_part.title">
|
||||
<segment>
|
||||
<source>info_providers.update_part.title</source>
|
||||
<target>Update existing part from info provider</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="lkIVIWr" name="part.merge.flash.please_review">
|
||||
<segment>
|
||||
<source>part.merge.flash.please_review</source>
|
||||
<target>Data not saved yet. Review the changes and click save to persist the new data.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue