diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/BadgeEntity.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/BadgeEntity.vue index 1c5d2b6da..622703387 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/BadgeEntity.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/BadgeEntity.vue @@ -57,6 +57,7 @@ import { THIRDPARTY_A_COMPANY, PERSON, THIRDPARTY, + THIRDPARTY_CONTACT, } from "translator"; const props = defineProps({ diff --git a/src/Bundle/ChillMainBundle/Resources/views/CRUD/_view_content.html.twig b/src/Bundle/ChillMainBundle/Resources/views/CRUD/_view_content.html.twig index 203a24926..71755cc11 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/CRUD/_view_content.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/CRUD/_view_content.html.twig @@ -4,7 +4,7 @@ {% endblock crud_content_header %} {% block crud_content_view %} - + {% block crud_content_view_details %}
id
@@ -20,7 +20,7 @@ {{ 'Cancel'|trans }} - {% endblock %} + {% endblock %} {% block content_view_actions_before %}{% endblock %} {% block content_form_actions_delete %} {% if chill_crud_action_exists(crud_name, 'delete') %} @@ -32,7 +32,7 @@ {% endif %} {% endif %} - {% endblock content_form_actions_delete %} + {% endblock content_form_actions_delete %} {% block content_view_actions_duplicate_link %} {% if chill_crud_action_exists(crud_name, 'new') %} {% if is_granted(chill_crud_config('role', crud_name, 'new'), entity) %} @@ -44,6 +44,17 @@ {% endif %} {% endif %} {% endblock content_view_actions_duplicate_link %} + {% block content_view_actions_merge %} +
  • + + + {{ 'Merge'|trans }} + +
  • + {% endblock %} {% block content_view_actions_edit_link %} {% if chill_crud_action_exists(crud_name, 'edit') %} {% if is_granted(chill_crud_config('role', crud_name, 'edit'), entity) %} diff --git a/src/Bundle/ChillThirdPartyBundle/Controller/ThirdpartyDuplicateController.php b/src/Bundle/ChillThirdPartyBundle/Controller/ThirdpartyDuplicateController.php new file mode 100644 index 000000000..ab42b5656 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Controller/ThirdpartyDuplicateController.php @@ -0,0 +1,125 @@ +getKind()) { + $suggested = $thirdparty->getParent()->getChildren(); + } + + $form = $this->createForm(ThirdpartyFindDuplicateType::class, null, ['suggested' => $suggested]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $thirdparty2 = $form->get('thirdparty')->getData(); + + $direction = $form->get('direction')->getData(); + + if ('starting' === $direction) { + $params = [ + 'thirdparty1_id' => $thirdparty->getId(), + 'thirdparty2_id' => $thirdparty2->getId(), + ]; + } else { + $params = [ + 'thirdparty1_id' => $thirdparty2->getId(), + 'thirdparty2_id' => $thirdparty->getId(), + ]; + } + + return $this->redirectToRoute('chill_thirdparty_duplicate_confirm', $params); + } + + return $this->render('@ChillThirdParty/ThirdPartyDuplicate/find_duplicate.html.twig', [ + 'thirdparty' => $thirdparty, + 'form' => $form->createView(), + ]); + } + + /** + * @ParamConverter("thirdparty1", options={"id": "thirdparty1_id"}) + * @ParamConverter("thirdparty2", options={"id": "thirdparty2_id"}) + */ + #[Route(path: '/{_locale}/3party/{thirdparty1_id}/duplicate/{thirdparty2_id}/confirm', name: 'chill_thirdparty_duplicate_confirm')] + public function confirmAction(ThirdParty $thirdparty1, ThirdParty $thirdparty2, Request $request) + { + try { + $this->validateThirdpartyMerge($thirdparty1, $thirdparty2); + $form = $this->createForm(PersonConfimDuplicateType::class); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + + $this->thirdPartyMergeService->merge($thirdparty1, $thirdparty2); + + $session = $request->getSession(); + if ($session instanceof Session) { + $session->getFlashBag()->add('success', new TranslatableMessage('thirdparty_duplicate.Merge successful')); + } + + return $this->redirectToRoute('chill_crud_3party_3party_view', ['id' => $thirdparty1->getId()]); + } + + return $this->render('@ChillThirdParty/ThirdPartyDuplicate/confirm.html.twig', [ + 'thirdparty' => $thirdparty1, + 'thirdparty2' => $thirdparty2, + 'form' => $form->createView(), + ]); + } catch (\InvalidArgumentException $e) { + $this->addFlash('error', $this->translator->trans($e->getMessage())); + + return $this->redirectToRoute('chill_thirdparty_find_duplicate', [ + 'thirdparty_id' => $thirdparty1->getId(), + ]); + } + } + + private function validateThirdpartyMerge(ThirdParty $thirdparty1, ThirdParty $thirdparty2): void + { + $constraints = [ + [$thirdparty1 === $thirdparty2, 'thirdparty_duplicate.You cannot merge a thirdparty with itself. Please choose a different thirdparty'], + [$thirdparty1->getKind() !== $thirdparty2->getKind(), 'thirdparty_duplicate.A thirdparty can only be merged with a thirdparty of the same kind'], + [$thirdparty1->getParent() !== $thirdparty2->getParent(), 'thirdparty_duplicate.Two child thirdparties must have the same parent'], + ]; + + foreach ($constraints as [$condition, $message]) { + if ($condition) { + throw new \InvalidArgumentException($message); + } + } + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/Form/ThirdpartyFindDuplicateType.php b/src/Bundle/ChillThirdPartyBundle/Form/ThirdpartyFindDuplicateType.php new file mode 100644 index 000000000..275b6d21c --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Form/ThirdpartyFindDuplicateType.php @@ -0,0 +1,41 @@ +add('thirdparty', PickThirdpartyDynamicType::class, [ + 'label' => 'Find duplicate', + 'mapped' => false, + 'suggested' => $options['suggested'], + ]) + ->add('direction', HiddenType::class, [ + 'data' => 'starting', + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'suggested' => [], + ]); + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/views/Entity/thirdparty.html.twig b/src/Bundle/ChillThirdPartyBundle/Resources/views/Entity/thirdparty.html.twig index b3327e24d..8c500c6cc 100644 --- a/src/Bundle/ChillThirdPartyBundle/Resources/views/Entity/thirdparty.html.twig +++ b/src/Bundle/ChillThirdPartyBundle/Resources/views/Entity/thirdparty.html.twig @@ -171,7 +171,13 @@ - {% else %} + {% elseif is_granted('CHILL_3PARTY_3PARTY_UPDATE', thirdparty) %} +
  • + +
  • {% endif %} {% if options['customButtons']['after'] is defined %} diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdParty/view.html.twig b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdParty/view.html.twig index bc5fc3325..2bd6f426f 100644 --- a/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdParty/view.html.twig +++ b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdParty/view.html.twig @@ -128,7 +128,7 @@
    {% for tp in thirdParty.activeChildren %}
    - {{ tp|chill_entity_render_box({'render': 'bloc', 'addLink': false, 'isConfidential': tp.contactDataAnonymous ? true : false }) }} + {{ tp|chill_entity_render_box({'render': 'bloc', 'addLink': false, 'isConfidential': tp.contactDataAnonymous ? true : false, 'showFusion': true }) }}
    {% endfor %}
    diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/_details.html.twig b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/_details.html.twig new file mode 100644 index 000000000..7b59c758d --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/_details.html.twig @@ -0,0 +1,34 @@ +{%- macro details(thirdparty, options) -%} + + +{% endmacro %} diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/confirm.html.twig b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/confirm.html.twig new file mode 100644 index 000000000..540b10ca7 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/confirm.html.twig @@ -0,0 +1,97 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% import '@ChillThirdParty/ThirdPartyDuplicate/_details.html.twig' as details %} + +{% block title %}{{ 'thirdparty_duplicate.Thirdparty duplicate title'|trans ~ ' ' ~ thirdparty.name }}{% endblock %} + +{% block content %} + +
    + +

    {{ 'thirdparty_duplicate.title'|trans }}

    + +
    +

    {{ 'thirdparty_duplicate.Thirdparty to delete'|trans }}: + {{ 'thirdparty_duplicate.Thirdparty to delete explanation'|trans }} +

    +
    + +

    + {{ thirdparty2 }} +

    + +

    {{ 'Deleted datas'|trans ~ ':' }}

    + {{ details.details(thirdparty2) }} + +{#

    {{ 'Moved links'|trans ~ ':' }}

    #} +{# {{ details.links(thirdparty2) }}#} +
    +
    + +
    +

    {{ 'thirdparty_duplicate.Thirdparty to keep'|trans }}: + {{ 'thirdparty_duplicate.Thirdparty to keep explanation'|trans }} +

    +
    + +

    + {{ thirdparty }} +

    + +

    {{ 'thirdparty_duplicate.Data to keep'|trans ~ ':' }}

    + {{ details.details(thirdparty) }} + +{#

    {{ 'thirdparty_duplicate.links to keep'|trans ~ ':' }}

    #} +{# {{ sidepane.links(thirdparty) }}#} +
    +
    + + {{ form_start(form) }} + +
    + +
    +
    + {{ form_widget(form.confirm) }} +
    +
    + {{ form_label(form.confirm) }} +
    +
    +
    + + + + {{ form_end(form) }} + +
    +{% endblock %} diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/find_duplicate.html.twig b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/find_duplicate.html.twig new file mode 100644 index 000000000..a5d463a39 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Resources/views/ThirdPartyDuplicate/find_duplicate.html.twig @@ -0,0 +1,38 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% set activeRouteKey = 'chill_thirdparty_duplicate' %} + +{% block title %}{{ 'thirdparty_duplicate.find'|trans ~ ' ' ~ thirdparty.name|capitalize }}{% endblock %} + + +{% block content %} +
    + +

    {{ 'thirdparty_duplicate.find'|trans }}

    + + {{ form_start(form) }} + {{ form_rest(form) }} + + + + {{ form_end(form) }} + +
    +{% endblock %} + +{% block js %} + {{ encore_entry_script_tags('mod_pickentity_type') }} +{% endblock %} + +{% block css %} + {{ encore_entry_link_tags('mod_pickentity_type') }} +{% endblock %} diff --git a/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php b/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php new file mode 100644 index 000000000..6c0014a2d --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php @@ -0,0 +1,107 @@ +em->getConnection(); + $conn->beginTransaction(); + + try { + $queries = [ + ...$this->updateReferences($toKeep, $toDelete), + ...$this->removeThirdparty($toKeep, $toDelete), + ]; + + foreach ($queries as $query) { + $conn->executeStatement($query['sql'], $query['params']); + } + + $conn->commit(); + } catch (\Exception $e) { + $conn->rollBack(); + throw $e; + } + } + + private function updateReferences(ThirdParty $toKeep, ThirdParty $toDelete): array + { + $queries = []; + $allMeta = $this->em->getMetadataFactory()->getAllMetadata(); + + foreach ($allMeta as $meta) { + if ($meta->isMappedSuperclass) { + continue; + } + + $tableName = $meta->getTableName(); + foreach ($meta->getAssociationMappings() as $assoc) { + if (ThirdParty::class !== $assoc['targetEntity']) { + continue; + } + + // phpstan wants boolean for if condition + if (($assoc['type'] & ClassMetadata::TO_ONE) !== 0) { + $joinColumn = $meta->getSingleAssociationJoinColumnName($assoc['fieldName']); + + $suffix = (ThirdParty::class === $assoc['sourceEntity']) ? 'chill_3party.' : ''; + + $queries[] = [ + 'sql' => "UPDATE {$suffix}{$tableName} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete", + 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], + ]; + } elseif (ClassMetadata::MANY_TO_MANY === $assoc['type'] && isset($assoc['joinTable'])) { + $joinTable = $assoc['joinTable']['name']; + $prefix = null !== ($assoc['joinTable']['schema'] ?? null) ? $assoc['joinTable']['schema'].'.' : ''; + $joinColumn = $assoc['joinTable']['inverseJoinColumns'][0]['name']; + $queries[] = [ + 'sql' => "UPDATE {$prefix}{$joinTable} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete AND NOT EXISTS (SELECT 1 FROM {$prefix}{$joinTable} WHERE {$joinColumn} = :toKeep)", + 'params' => ['toDelete' => $toDelete->getId(), 'toKeep' => $toKeep->getId()], + ]; + + $queries[] = [ + 'sql' => "DELETE FROM {$joinTable} WHERE {$joinColumn} = :toDelete", + 'params' => ['toDelete' => $toDelete->getId()], + ]; + } + } + } + + return $queries; + } + + public function removeThirdparty(ThirdParty $toKeep, ThirdParty $toDelete): array + { + return [ + [ + 'sql' => 'UPDATE chill_3party.third_party SET parent_id = :toKeep WHERE parent_id = :toDelete', + 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], + ], + [ + 'sql' => 'UPDATE chill_3party.thirdparty_category SET thirdparty_id = :toKeep WHERE thirdparty_id = :toDelete AND NOT EXISTS (SELECT 1 FROM chill_3party.thirdparty_category WHERE thirdparty_id = :toKeep)', + 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], + ], + [ + 'sql' => 'DELETE FROM chill_3party.third_party WHERE id = :toDelete', + 'params' => ['toDelete' => $toDelete->getId()], + ], + ]; + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/Templating/Entity/ThirdPartyRender.php b/src/Bundle/ChillThirdPartyBundle/Templating/Entity/ThirdPartyRender.php index a36ceda4e..427f2b9e3 100644 --- a/src/Bundle/ChillThirdPartyBundle/Templating/Entity/ThirdPartyRender.php +++ b/src/Bundle/ChillThirdPartyBundle/Templating/Entity/ThirdPartyRender.php @@ -39,6 +39,7 @@ class ThirdPartyRender implements ChillEntityRenderInterface 'showContacts' => $options['showContacts'] ?? false, 'showParent' => $options['showParent'] ?? true, 'isConfidential' => $options['isConfidential'] ?? false, + 'showFusion' => $options['showFusion'] ?? false, ]; return diff --git a/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php b/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php new file mode 100644 index 000000000..4b2819751 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php @@ -0,0 +1,86 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + + $this->service = new ThirdpartyMergeService($this->em); + } + + public function testMergeUpdatesReferencesAndDeletesThirdparty(): void + { + // Create ThirdParty entities + $toKeep = new ThirdParty(); + $toKeep->setName('Thirdparty to keep'); + $this->em->persist($toKeep); + + $toDelete = new ThirdParty(); + $toDelete->setName('Thirdparty to delete'); + $this->em->persist($toDelete); + + // Create a related entity with TO_ONE relation (thirdparty parent) + $relatedToOneEntity = new ThirdParty(); + $relatedToOneEntity->setName('RelatedToOne thirdparty'); + $relatedToOneEntity->setParent($toDelete); + $this->em->persist($relatedToOneEntity); + + // Create a related entity with TO_MANY relation (thirdparty category) + $thirdpartyCategory = new ThirdPartyCategory(); + $thirdpartyCategory->setName(['fr' => 'Thirdparty category']); + $this->em->persist($thirdpartyCategory); + $toDelete->addCategory($thirdpartyCategory); + $this->em->persist($toDelete); + + $activity = new Activity(); + $activity->setDate(new \DateTime()); + $activity->addThirdParty($toDelete); + $this->em->persist($activity); + + $this->em->flush(); + + // Run merge + $this->service->merge($toKeep, $toDelete); + $this->em->refresh($toKeep); + $this->em->refresh($relatedToOneEntity); + + // Check that references were updated + $this->assertEquals($toKeep->getId(), $relatedToOneEntity->getParent()->getId(), 'The parent thirdparty was succesfully merged'); + + $updatedRelatedManyEntity = $this->em->find(ThirdPartyCategory::class, $thirdpartyCategory->getId()); + $this->assertContains($updatedRelatedManyEntity, $toKeep->getCategories(), 'The thirdparty category was found in the toKeep entity'); + + // Check that toDelete was removed + $this->em->clear(); + $deletedThirdParty = $this->em->find(ThirdParty::class, $toDelete->getId()); + $this->assertNull($deletedThirdParty); + } +} diff --git a/src/Bundle/ChillThirdPartyBundle/config/services.yaml b/src/Bundle/ChillThirdPartyBundle/config/services.yaml index 572026771..ffe3207d3 100644 --- a/src/Bundle/ChillThirdPartyBundle/config/services.yaml +++ b/src/Bundle/ChillThirdPartyBundle/config/services.yaml @@ -1,14 +1,14 @@ ---- services: - Chill\ThirdPartyBundle\Serializer\Normalizer\: + _defaults: autowire: true + autoconfigure: true + + Chill\ThirdPartyBundle\Serializer\Normalizer\: resource: '../Serializer/Normalizer/' tags: - { name: 'serializer.normalizer', priority: 64 } Chill\ThirdPartyBundle\Export\: - autowire: true - autoconfigure: true resource: '../Export/' Chill\ThirdPartyBundle\Validator\: @@ -16,3 +16,5 @@ services: autowire: true resource: '../Validator/' + Chill\ThirdPartyBundle\Service\ThirdpartyMergeService: ~ + diff --git a/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml b/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml index c5a5a110f..17771c0c0 100644 --- a/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml @@ -155,3 +155,16 @@ Telephone2: Autre téléphone Contact email: Courrier électronique du contact Contact address: Adresse du contact Contact profession: Profession du contact + +thirdparty_duplicate: + title: Fusionner les tiers doublons + find: Désigner un tiers doublon + Thirdparty to keep: Tiers à conserver + Thirdparty to delete: Tiers à supprimer + Thirdparty to delete explanation: Ce tiers sera supprimé. Seuls les contacts de ce tiers, énumérés ci-dessous, seront transférés. + Thirdparty to keep explanation: Ce tiers sera conservé + Data to keep: Données conservées + You cannot merge a thirdparty with itself. Please choose a different thirdparty: Vous ne pouvez pas fusionner un tiers avec lui-même. Veuillez choisir un autre tiers. + A thirdparty can only be merged with a thirdparty of the same kind: Un tiers ne peut être fusionné qu'avec un tiers de même type. + Two child thirdparties must have the same parent: Deux tiers de type « contact » doivent avoir le même tiers parent. + Merge successful: La fusion a été effectuée avec succès