Merge remote-tracking branch 'origin/355-fusion-thirdparty' into testing-202505

This commit is contained in:
Julien Fastré 2025-04-25 18:31:36 +02:00
commit 4d1032c115
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
14 changed files with 571 additions and 9 deletions

View File

@ -57,6 +57,7 @@ import {
THIRDPARTY_A_COMPANY,
PERSON,
THIRDPARTY,
THIRDPARTY_CONTACT,
} from "translator";
const props = defineProps({

View File

@ -44,6 +44,17 @@
{% endif %}
{% endif %}
{% endblock content_view_actions_duplicate_link %}
{% block content_view_actions_merge %}
<li>
<a href="{{ chill_path_add_return_path('chill_thirdparty_find_duplicate',
{ 'thirdparty_id': entity.id }) }}"
title="{{ 'Merge'|trans }}"
class="btn btn-misc">
<i class="bi bi-chevron-contract"></i>
{{ 'Merge'|trans }}
</a>
</li>
{% 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) %}

View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ThirdPartyBundle\Controller;
use Chill\PersonBundle\Form\PersonConfimDuplicateType;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\ThirdPartyBundle\Form\ThirdpartyFindDuplicateType;
use Chill\ThirdPartyBundle\Service\ThirdpartyMergeService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Routing\Annotation\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatorInterface;
class ThirdpartyDuplicateController extends AbstractController
{
public function __construct(private readonly ThirdpartyMergeService $thirdPartyMergeService, private readonly TranslatorInterface $translator) {}
/**
* @ParamConverter("thirdparty", options={"id": "thirdparty_id"})
*/
#[Route(path: '/{_locale}/3party/{thirdparty_id}/find-manually', name: 'chill_thirdparty_find_duplicate')]
public function findManuallyDuplicateAction(ThirdParty $thirdparty, Request $request)
{
$suggested = [];
if ('child' === $thirdparty->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);
}
}
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ThirdPartyBundle\Form;
use Chill\ThirdPartyBundle\Form\Type\PickThirdpartyDynamicType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ThirdpartyFindDuplicateType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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' => [],
]);
}
}

View File

@ -171,7 +171,13 @@
<a class="btn btn-sm btn-show" target="_blank" title="{{ 'Show thirdparty'|trans }}"
href="{{ path('chill_crud_3party_3party_view', { id: thirdparty.isChild ? thirdparty.parent.id : thirdparty.id }) }}"></a>
</li>
{% else %}
{% elseif is_granted('CHILL_3PARTY_3PARTY_UPDATE', thirdparty) %}
<li>
<a href="{{ chill_path_add_return_path('chill_thirdparty_find_duplicate',
{ 'thirdparty_id': thirdparty.id }) }}"
title="{{ 'Merge'|trans }}"
class="btn btn-misc"><i class="bi bi-chevron-contract"></i></a>
</li>
{% endif %}
{% if options['customButtons']['after'] is defined %}

View File

@ -128,7 +128,7 @@
<div class="flex-table">
{% for tp in thirdParty.activeChildren %}
<div class="item-bloc">
{{ 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 }) }}
</div>
{% endfor %}
</div>

View File

@ -0,0 +1,34 @@
{%- macro details(thirdparty, options) -%}
<ul>
<li><b>{{ 'name'|trans }}</b>:
{{ thirdparty.name }}</li>
<li><b>{{ 'First name'|trans }}</b>:
{% if thirdparty.firstname %}{{ thirdparty.firstname }}{% endif %}</li>
<li><b>{{ 'thirdparty.Civility'|trans }}</b>:
{% if thirdparty.getCivility %}{{ thirdparty.getCivility.name|localize_translatable_string }}{% endif %}</li>
<li><b>{{ 'thirdparty.NameCompany'|trans }}</b>:
{% if thirdparty.nameCompany is not empty %}{{ thirdparty.nameCompany }}{% endif %}</li>
<li><b>{{ 'thirdparty.Acronym'|trans }}</b>:
{% if thirdparty.acronym %}{{ thirdparty.acronym }}{% endif %}</li>
<li><b>{{ 'thirdparty.Profession'|trans }}</b>:
{% if thirdparty.profession %}{{ thirdparty.profession }}{% endif %}</li>
<li><b>{{ 'telephone'|trans }}</b>:
{% if thirdparty.telephone %}{{ thirdparty.telephone }}{% endif %}</li>
<li><b>{{ 'email'|trans }}</b>:
{% if thirdparty.email is not null %}{{ thirdparty.email }}{% endif %}</li>
<li><b>{{ 'Address'|trans }}</b>:
{%- if thirdparty.getAddress is not empty -%}
{{ thirdparty.getAddress|chill_entity_render_box }}
{% endif %}</li>
<li><b>{{ 'thirdparty.Contact data are confidential'|trans }}</b>:
{{ thirdparty.contactDataAnonymous }}</li>
<li><b>{{ 'Contacts'|trans }}</b>:
<ul>
{% for c in thirdparty.getChildren %}
<li>{{ c.name }} {{ c.firstName }}</li>
{% endfor %}
</ul>
</li>
</ul>
{% endmacro %}

View File

@ -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 %}
<style>
div.duplicate-content {
margin: 0 2rem;
}
div.col {
padding: 1em;
border: 3px solid #cccccc;
}
div.border {
border: 4px solid #3c9f8d;
}
</style>
<div class="container-fluid content"><div class="duplicate-content">
<h1>{{ 'thirdparty_duplicate.title'|trans }}</h1>
<div class="col-md-11">
<p><b>{{ 'thirdparty_duplicate.Thirdparty to delete'|trans }}</b>:
{{ 'thirdparty_duplicate.Thirdparty to delete explanation'|trans }}
</p>
<div class="col">
<h1><span><a class="btn btn-show" target="_blank" title="{{ 'Open in another window'|trans }}" href="{{ path('chill_crud_3party_3party_view', { id : thirdparty2.id }) }}"></a></span>
{{ thirdparty2 }}
</h1>
<h4>{{ 'Deleted datas'|trans ~ ':' }}</h4>
{{ details.details(thirdparty2) }}
{# <h4>{{ 'Moved links'|trans ~ ':' }}</h4>#}
{# {{ details.links(thirdparty2) }}#}
</div>
</div>
<div class="col-md-11 mt-3">
<p><b>{{ 'thirdparty_duplicate.Thirdparty to keep'|trans }}</b>:
{{ 'thirdparty_duplicate.Thirdparty to keep explanation'|trans }}
</p>
<div class="col border">
<h1><span><a class="btn btn-show" target="_blank" title="{{ 'Open in another window'|trans }}" href="{{ path('chill_crud_3party_3party_view', { id : thirdparty.id }) }}"></a></span>
{{ thirdparty }}
</h1>
<h4>{{ 'thirdparty_duplicate.Data to keep'|trans ~ ':' }}</h4>
{{ details.details(thirdparty) }}
{# <h4>{{ 'thirdparty_duplicate.links to keep'|trans ~ ':' }}</h4>#}
{# {{ sidepane.links(thirdparty) }}#}
</div>
</div>
{{ form_start(form) }}
<div class="col-md-12 centered">
<div class="container-fluid" style="padding-top: 1em;">
<div class="clear" style="padding-top: 10px;">
{{ form_widget(form.confirm) }}
</div>
<div class="col-11">
{{ form_label(form.confirm) }}
</div>
</div>
</div>
<ul class="col-12 record_actions">
<li class="cancel">
<a href="{{ path('chill_thirdparty_find_duplicate', {thirdparty_id : thirdparty.id}) }}" class="btn btn-chill-gray center margin-5">
{{ 'Return'|trans }}
</a>
</li>
<li class="cancel">
<a href="{{ path('chill_thirdparty_duplicate_confirm', { thirdparty1_id : thirdparty2.id, thirdparty2_id : thirdparty.id }) }}"
class="btn btn-action">
<i class="fa fa-exchange"></i>
{{ 'Invert'|trans }}
</a>
</li>
<li>
<button class="btn btn-submit" type="submit"><i class="fa fa-cog fa-fw"></i>{{ 'Merge'|trans }}</button>
</li>
</ul>
{{ form_end(form) }}
</div></div>
{% endblock %}

View File

@ -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 %}
<div class="person-duplicate">
<h1>{{ 'thirdparty_duplicate.find'|trans }}</h1>
{{ form_start(form) }}
{{ form_rest(form) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_crud_3party_3party_view', {'id' : thirdparty.id}) }}" class="btn btn-cancel">
{{ 'Return'|trans }}
</a>
</li>
<li>
<button class="btn btn-save" type="submit">{{ 'Next'|trans }}</button>
</li>
</ul>
{{ form_end(form) }}
</div>
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block css %}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ThirdPartyBundle\Service;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
class ThirdpartyMergeService
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function merge(ThirdParty $toKeep, ThirdParty $toDelete): void
{
$conn = $this->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()],
],
];
}
}

View File

@ -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

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\ThirdPartyBundle\Tests\Service;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\ThirdPartyBundle\Entity\ThirdPartyCategory;
use Chill\ThirdPartyBundle\Service\ThirdpartyMergeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class ThirdpartyMergeServiceTest extends KernelTestCase
{
private EntityManagerInterface $em;
private ThirdpartyMergeService $service;
protected function setUp(): void
{
self::bootKernel();
$this->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);
}
}

View File

@ -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: ~

View File

@ -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