Duplicate module

Signed-off-by: Mathieu Jaumotte <mathieu.jaumotte@champs-libres.coop>
This commit is contained in:
Mathieu Jaumotte 2021-03-21 14:06:37 +01:00
parent 728ea73bdf
commit 0149457fba
12 changed files with 292 additions and 111 deletions

View File

@ -94,7 +94,6 @@ class PersonMove
foreach ($metadata->getAssociationMappings() as $field => $mapping) { foreach ($metadata->getAssociationMappings() as $field => $mapping) {
if ($mapping['targetEntity'] === Person::class) { if ($mapping['targetEntity'] === Person::class) {
if (\in_array($metadata->getName(), $toDelete)) { if (\in_array($metadata->getName(), $toDelete)) {
$sql = $this->createDeleteSQL($metadata, $from, $field); $sql = $this->createDeleteSQL($metadata, $from, $field);
$event = new ActionEvent($from->getId(), $metadata->getName(), $sql, $event = new ActionEvent($from->getId(), $metadata->getName(), $sql,

View File

@ -2,13 +2,17 @@
namespace Chill\PersonBundle\Controller; namespace Chill\PersonBundle\Controller;
use Chill\PersonBundle\Actions\Remove\PersonMove;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonNotDuplicate; use Chill\PersonBundle\Entity\PersonNotDuplicate;
use Chill\PersonBundle\Form\PersonConfimDuplicateType; use Chill\PersonBundle\Form\PersonConfimDuplicateType;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\PersonBundle\Repository\PersonRepository; use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Search\SimilarPersonMatcher; use Chill\PersonBundle\Search\SimilarPersonMatcher;
use http\Exception\InvalidArgumentException; use http\Exception\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Translation\TranslatorInterface; use Symfony\Component\Translation\TranslatorInterface;
@ -29,14 +33,28 @@ class PersonDuplicateController extends Controller
*/ */
private $personRepository; private $personRepository;
/**
* @var \Chill\PersonBundle\Actions\Remove\PersonMove
*/
private $personMove;
/**
* @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
*/
private $eventDispatcher;
public function __construct( public function __construct(
SimilarPersonMatcher $similarPersonMatcher, SimilarPersonMatcher $similarPersonMatcher,
TranslatorInterface $translator, TranslatorInterface $translator,
PersonRepository $personRepository PersonRepository $personRepository,
PersonMove $personMove,
EventDispatcherInterface $eventDispatcher
) { ) {
$this->similarPersonMatcher = $similarPersonMatcher; $this->similarPersonMatcher = $similarPersonMatcher;
$this->translator = $translator; $this->translator = $translator;
$this->personRepository = $personRepository; $this->personRepository = $personRepository;
$this->personMove = $personMove;
$this->eventDispatcher = $eventDispatcher;
} }
public function viewAction($person_id) public function viewAction($person_id)
@ -47,88 +65,88 @@ class PersonDuplicateController extends Controller
. " found on this server"); . " found on this server");
} }
$duplicatePersons = $this->similarPersonMatcher->matchPerson($person, 0.5); $duplicatePersons = $this->similarPersonMatcher->
matchPerson($person, 0.5, SimilarPersonMatcher::SIMILAR_SEARCH_ORDER_BY_ALPHABETICAL);
$notDuplicatePersons = $this->getDoctrine()->getRepository(PersonNotDuplicate::class)
->findNotDuplicatePerson($person);
return $this->render('ChillPersonBundle:PersonDuplicate:view.html.twig', [ return $this->render('ChillPersonBundle:PersonDuplicate:view.html.twig', [
"person" => $person, 'person' => $person,
'duplicatePersons' => $duplicatePersons 'duplicatePersons' => $duplicatePersons,
'notDuplicatePersons' => $notDuplicatePersons,
]); ]);
} }
public function confirmAction($person_id, $person2_id, Request $request) public function confirmAction($person1_id, $person2_id, Request $request)
{ {
if ($person_id === $person2_id) { [$person1, $person2] = $this->_getPersonsByPriority($person1_id, $person2_id);
throw new InvalidArgumentException('Can not merge same person');
}
if ($person_id > $person2_id) {
$tmpId = $person2_id;
$person2_id = $person_id;
$person_id = $tmpId;
unset($tmpId);
}
$person = $this->_getPerson($person_id);
if ($person === null) {
throw $this->createNotFoundException("Person with id $person_id not"
. " found on this server");
}
$person2 = $this->_getPerson($person2_id);
if ($person2 === null) {
throw $this->createNotFoundException("Person with id $person2_id not"
. " found on this server");
}
$form = $this->createForm(PersonConfimDuplicateType::class); $form = $this->createForm(PersonConfimDuplicateType::class);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
dd('todo'); $event = new PrivacyEvent($person1, array(
'element_class' => Person::class,
'action' => 'move'
));
$event->addPerson($person2);
$this->eventDispatcher->dispatch(PrivacyEvent::PERSON_PRIVACY_EVENT, $event);
$sqls = $this->personMove->getSQL($person2, $person1);
$connection = $this->getDoctrine()->getConnection();
$connection->beginTransaction();
foreach($sqls as $sql) {
$connection->executeQuery($sql);
}
$connection->commit();
return $this->redirectToRoute('chill_person_duplicate_view', ['person_id' => $person1->getId()]);
} }
return $this->render('ChillPersonBundle:PersonDuplicate:confirm.html.twig', [ return $this->render('ChillPersonBundle:PersonDuplicate:confirm.html.twig', [
'person' => $person, 'person' => $person1,
'person2' => $person2, 'person2' => $person2,
'form' => $form->createView(), 'form' => $form->createView(),
]); ]);
} }
public function notDuplicateAction($person_id, $person2_id) public function notDuplicateAction($person1_id, $person2_id)
{ {
if ($person_id === $person2_id) { [$person1, $person2] = $this->_getPersonsByPriority($person1_id, $person2_id);
throw new InvalidArgumentException('Can not merge same person');
}
if ($person_id > $person2_id) { $personNotDuplicate = $this->getDoctrine()->getRepository(PersonNotDuplicate::class)
$tmpId = $person2_id; ->findOneBy(['person1' => $person1, 'person2' => $person2]);
$person2_id = $person_id;
$person_id = $tmpId;
unset($tmpId);
}
$person = $this->_getPerson($person_id);
if ($person === null) {
throw $this->createNotFoundException("Person with id $person_id not"
. " found on this server");
}
$person2 = $this->_getPerson($person2_id);
if ($person2 === null) {
throw $this->createNotFoundException("Person with id $person2_id not"
. " found on this server");
}
if (!$personNotDuplicate instanceof PersonNotDuplicate) {
$personNotDuplicate = new PersonNotDuplicate(); $personNotDuplicate = new PersonNotDuplicate();
$personNotDuplicate->setPerson1($person); $personNotDuplicate->setPerson1($person1);
$personNotDuplicate->setPerson2($person2); $personNotDuplicate->setPerson2($person2);
$personNotDuplicate->setUser($this->getUser()); $personNotDuplicate->setUser($this->getUser());
$this->getDoctrine()->getManager()->persist($personNotDuplicate); $this->getDoctrine()->getManager()->persist($personNotDuplicate);
$this->getDoctrine()->getManager()->flush(); $this->getDoctrine()->getManager()->flush();
}
return $this->redirectToRoute('chill_person_duplicate_view', ['person_id' => $person->getId()]); return $this->redirectToRoute('chill_person_duplicate_view', ['person_id' => $person1->getId()]);
}
public function removeNotDuplicateAction($person1_id, $person2_id)
{
[$person1, $person2] = $this->_getPersonsByPriority($person1_id, $person2_id);
$personNotDuplicate = $this->getDoctrine()->getRepository(PersonNotDuplicate::class)
->findOneBy(['person1' => $person1, 'person2' => $person2]);
if ($personNotDuplicate instanceof PersonNotDuplicate) {
$this->getDoctrine()->getManager()->remove($personNotDuplicate);
$this->getDoctrine()->getManager()->flush();
}
return $this->redirectToRoute('chill_person_duplicate_view', ['person_id' => $person1->getId()]);
} }
/** /**
@ -138,4 +156,31 @@ class PersonDuplicateController extends Controller
{ {
return $this->personRepository->find($id); return $this->personRepository->find($id);
} }
private function _getPersonsByPriority($person1_id, $person2_id)
{
if ($person1_id === $person2_id) {
throw new InvalidArgumentException('Can not merge same person');
}
if ($person1_id > $person2_id) {
$person1 = $this->_getPerson($person2_id);
$person2 = $this->_getPerson($person1_id);
} else {
$person1 = $this->_getPerson($person1_id);
$person2 = $this->_getPerson($person2_id);
}
if ($person1 === null) {
throw $this->createNotFoundException("Person with id $person1_id not"
. " found on this server");
}
if ($person2 === null) {
throw $this->createNotFoundException("Person with id $person2_id not"
. " found on this server");
}
return [$person1, $person2];
}
} }

View File

@ -9,7 +9,7 @@ use Chill\MainBundle\Entity\User;
* PersonNotDuplicate * PersonNotDuplicate
* *
* @ORM\Table(name="chill_person_not_duplicate") * @ORM\Table(name="chill_person_not_duplicate")
* @ORM\Entity() * @ORM\Entity(repositoryClass="Chill\PersonBundle\Repository\PersonNotDuplicateRepository")
*/ */
class PersonNotDuplicate class PersonNotDuplicate
{ {

View File

@ -16,7 +16,7 @@ class PersonConfimDuplicateType extends AbstractType
{ {
$builder $builder
->add('confirm', CheckboxType::class, [ ->add('confirm', CheckboxType::class, [
'label' => 'Je confirme la fusion de ces 2 personnes', 'label' => 'I confirm the merger of these 2 people',
'mapped' => false, 'mapped' => false,
]); ]);
} }

View File

@ -0,0 +1,41 @@
<?php
namespace Chill\PersonBundle\Repository;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonNotDuplicate;
use Doctrine\ORM\EntityRepository;
/**
* Class PersonNotDuplicateRepository
*
* @package Chill\PersonBundle\Repository
*/
class PersonNotDuplicateRepository extends EntityRepository
{
/**
* @param \Chill\PersonBundle\Entity\Person $person
*
* @return array
*/
public function findNotDuplicatePerson(Person $person)
{
$qb = $this->createQueryBuilder('pnd');
$qb->select('pnd')
->where('pnd.person1 = :person OR pnd.person2 = :person')
;
$qb->setParameter('person', $person);
$result = $qb->getQuery()->getResult();
$persons = [];
foreach ($result as $row) {
if ($row->getPerson1() === $person) {
$persons[] = $row->getPerson2();
} elseif ($row->getPerson2() === $person) {
$persons[] = $row->getPerson1();
}
}
return $persons;
}
}

View File

@ -6,12 +6,12 @@
' ' ~ person.lastName }}{% endblock %} ' ' ~ person.lastName }}{% endblock %}
{% block personcontent %} {% block personcontent %}
<h2>Ancien dossier</h2> <h2>{{ 'Old person'|trans }}</h2>
{{ person2 }} {{ person2 }}
<a class="sc-button bt-show" target="_blank" href="{{ path('chill_person_view', { person_id : person2.id }) }}"></a> <a class="sc-button bt-show" target="_blank" href="{{ path('chill_person_view', { person_id : person2.id }) }}"></a>
<h2>Nouveau Dossier</h2> <h2>{{ 'New person'|trans }}</h2>
{{ person }} {{ person }}
<a class="sc-button bt-show" target="_blank" href="{{ path('chill_person_view', { person_id : person.id }) }}"></a> <a class="sc-button bt-show" target="_blank" href="{{ path('chill_person_view', { person_id : person.id }) }}"></a>

View File

@ -8,6 +8,9 @@
{% block personcontent %} {% block personcontent %}
{% if duplicatePersons|length > 0 %}
<h2>{{ title|default('Person duplicate')|trans }}</h2> <h2>{{ title|default('Person duplicate')|trans }}</h2>
<table> <table>
@ -54,12 +57,70 @@
<td> <td>
<ul class="record_actions"> <ul class="record_actions">
<li><a class="sc-button bt-show" target="_blank" href="{{ path('chill_person_view', { person_id : duplicatePerson.id }) }}"></a></li> <li><a class="sc-button bt-show" target="_blank" href="{{ path('chill_person_view', { person_id : duplicatePerson.id }) }}"></a></li>
<li><a class="sc-button bt-duplicate" href="{{ path('chill_person_duplicate_confirm', { person_id : person.id, person2_id : duplicatePerson.id }) }}"></a></li> <li><a class="sc-button bt-duplicate" href="{{ path('chill_person_duplicate_confirm', { person1_id : person.id, person2_id : duplicatePerson.id }) }}"></a></li>
<li><a class="sc-button bt-not-duplicate" href="{{ path('chill_person_duplicate_not_duplicate', {person_id : person.id, person2_id : duplicatePerson.id}) }}"></a></li> <li><a class="sc-button bt-not-duplicate" href="{{ path('chill_person_duplicate_not_duplicate', {person1_id : person.id, person2_id : duplicatePerson.id}) }}"></a></li>
</ul> </ul>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
{% endif %}
{% if notDuplicatePersons|length > 0 %}
<h2>{{ 'Person flaged as duplicate' | trans }}</h2>
<table>
<thead>
<tr>
<th class="chill-red">{% trans %}Name{% endtrans %}</th>
<th class="chill-green">{% trans %}Date of birth{% endtrans %}</th>
<th class="chill-orange">{% trans %}Nationality{% endtrans %}</th>
<th>&nbsp;</th>
</tr>
</thead>
{% for notDuplicatePerson in notDuplicatePersons %}
<tr>
<td>
{% set is_open = notDuplicatePerson.isOpen() %}
<a href="{{ path('chill_person_view', { person_id : notDuplicatePerson.getId }) }}" {% if chill_person.fields.accompanying_period == 'visible' %}{% if is_open %} alt="{{ 'An accompanying period is open'|trans|e('html_attr') }}"{% else %} alt="{{ 'Any accompanying periods are open'|trans|e('html_attr') }}" {% endif %}{% endif %}>
{{ notDuplicatePerson|chill_entity_render_box }}
{% apply spaceless %}
{% if chill_person.fields.accompanying_period == 'visible' %}
{% if is_open == false %}
<i class="fa fa-lock" ></i>
{% else %}
<i class="fa fa-unlock" ></i>
{% endif %}
{% endif %}
{% endapply %}
</a>
</td>
<td>
{% if notDuplicatePerson.birthdate is not null %}
{{ notDuplicatePerson.birthdate|format_date('long') }}
{% else %}
{{ 'Unknown date of birth'|trans }}
{% endif %}
</td>
<td>
{% if notDuplicatePerson.nationality is not null %}
{{ notDuplicatePerson.nationality.name|localize_translatable_string }}
{% else %}
{{ 'Without nationality'|trans }}
{% endif %}
</td>
<td>
<ul class="record_actions">
<li><a class="sc-button bt-show" target="_blank" href="{{ path('chill_person_view', { person_id : notDuplicatePerson.id }) }}"></a></li>
<li><a class="sc-button bt-not-duplicate" href="{{ path('chill_person_remove_duplicate_not_duplicate', {person1_id : person.id, person2_id : notDuplicatePerson.id}) }}"></a></li>
</ul>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</table> </table>
{% endblock %} {% endblock %}

View File

@ -18,6 +18,7 @@
*/ */
namespace Chill\PersonBundle\Search; namespace Chill\PersonBundle\Search;
use Chill\PersonBundle\Entity\PersonNotDuplicate;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper; use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
@ -32,6 +33,10 @@ use Chill\PersonBundle\Security\Authorization\PersonVoter;
*/ */
class SimilarPersonMatcher class SimilarPersonMatcher
{ {
CONST SIMILAR_SEARCH_ORDER_BY_ALPHABETICAL = 'alphabetical';
CONST SIMILAR_SEARCH_ORDER_BY_SIMILARITY = 'similarity';
/** /**
* *
* @var EntityManagerInterface * @var EntityManagerInterface
@ -61,7 +66,7 @@ class SimilarPersonMatcher
} }
public function matchPerson(Person $person, $precision = 0.15) public function matchPerson(Person $person, $precision = 0.15, $orderBy = self::SIMILAR_SEARCH_ORDER_BY_SIMILARITY)
{ {
$centers = $this->authorizationHelper->getReachableCenters( $centers = $this->authorizationHelper->getReachableCenters(
$this->tokenStorage->getToken()->getUser(), $this->tokenStorage->getToken()->getUser(),
@ -71,22 +76,40 @@ class SimilarPersonMatcher
$dql = 'SELECT p from ChillPersonBundle:Person p ' $dql = 'SELECT p from ChillPersonBundle:Person p '
. ' WHERE (' . ' WHERE ('
. ' SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) >= :precision ' . ' SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) >= :precision '
. ' OR SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullNameInverted))) >= :precision '
. ' ) ' . ' ) '
. ' AND p.center IN (:centers)' . ' AND p.center IN (:centers)'
. ' AND p.id != :personId ' . ' AND p.id != :personId '
. ' ORDER BY SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) DESC '
; ;
$notDuplicatePersons = $this->em->getRepository(PersonNotDuplicate::class)
->findNotDuplicatePerson($person);
if (count($notDuplicatePersons)) {
$dql .= ' AND p.id not in (:notDuplicatePersons)';
}
switch ($orderBy) {
case self::SIMILAR_SEARCH_ORDER_BY_ALPHABETICAL:
$dql .= ' ORDER BY p.fullnameCanonical ASC ';
break;
case self::SIMILAR_SEARCH_ORDER_BY_SIMILARITY:
default :
$dql .= ' ORDER BY SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) DESC ';
}
$query = $this->em $query = $this->em
->createQuery($dql) ->createQuery($dql)
->setParameter('fullName', $person->getFirstName() . ' ' . $person->getLastName()) ->setParameter('fullName', $person->getFirstName() . ' ' . $person->getLastName())
->setParameter('fullNameInverted', $person->getLastName() . ' ' . $person->getFirstName())
->setParameter('centers', $centers) ->setParameter('centers', $centers)
->setParameter('personId', $person->getId()) ->setParameter('personId', $person->getId())
->setParameter('precision', $precision) ->setParameter('precision', $precision)
; ;
if (count($notDuplicatePersons)) {
$query->setParameter('notDuplicatePersons', $notDuplicatePersons);
}
return $query->getResult(); return $query->getResult();
} }
} }

View File

@ -112,7 +112,7 @@ chill_person_closingmotive_admin:
label: 'person_admin.closing motives' label: 'person_admin.closing motives'
chill_person_duplicate_confirm: chill_person_duplicate_confirm:
path: /{_locale}/person/{person_id}/duplicate/{person2_id}/confirm path: /{_locale}/person/{person1_id}/duplicate/{person2_id}/confirm
controller: Chill\PersonBundle\Controller\PersonDuplicateController::confirmAction controller: Chill\PersonBundle\Controller\PersonDuplicateController::confirmAction
chill_person_maritalstatus_admin: chill_person_maritalstatus_admin:
@ -125,5 +125,9 @@ chill_person_maritalstatus_admin:
label: 'person_admin.marital status' label: 'person_admin.marital status'
chill_person_duplicate_not_duplicate: chill_person_duplicate_not_duplicate:
path: /{_locale}/person/{person_id}/duplicate/{person2_id}/not-duplicate path: /{_locale}/person/{person1_id}/duplicate/{person2_id}/not-duplicate
controller: Chill\PersonBundle\Controller\PersonDuplicateController::notDuplicateAction controller: Chill\PersonBundle\Controller\PersonDuplicateController::notDuplicateAction
chill_person_remove_duplicate_not_duplicate:
path: /{_locale}/person/{person1_id}/duplicate/{person2_id}/remove-not-duplicate
controller: Chill\PersonBundle\Controller\PersonDuplicateController::removeNotDuplicateAction

View File

@ -28,4 +28,6 @@ services:
$similarPersonMatcher: '@Chill\PersonBundle\Search\SimilarPersonMatcher' $similarPersonMatcher: '@Chill\PersonBundle\Search\SimilarPersonMatcher'
$translator: '@Symfony\Component\Translation\TranslatorInterface' $translator: '@Symfony\Component\Translation\TranslatorInterface'
$personRepository: '@Chill\PersonBundle\Repository\PersonRepository' $personRepository: '@Chill\PersonBundle\Repository\PersonRepository'
$personMove: '@Chill\PersonBundle\Actions\Remove\PersonMove'
$eventDispatcher: '@Symfony\Component\EventDispatcher\EventDispatcherInterface'
tags: ['controller.service_arguments'] tags: ['controller.service_arguments']

View File

@ -67,6 +67,8 @@ Reset: 'Remise à zéro'
'Closing motive': 'Motif de clôture' 'Closing motive': 'Motif de clôture'
'Person details': 'Détails de la personne' 'Person details': 'Détails de la personne'
'Person duplicate': 'Find duplicate' 'Person duplicate': 'Find duplicate'
Old person: Old person
New person: New person
Create an accompanying period: Create an accompanying period Create an accompanying period: Create an accompanying period
'Create': Create 'Create': Create

View File

@ -66,6 +66,10 @@ Married: Marié(e)
'Contact information': 'Informations de contact' 'Contact information': 'Informations de contact'
'Administrative information': Administratif 'Administrative information': Administratif
File number: Dossier n° File number: Dossier n°
Old person: Ancien dossier
New person: Nouveau dossier
I confirm the merger of these 2 people : Je confime la fusion de ces 2 dossiers
Person flaged as duplicate: Dossiers marqués comme faux-positif
# addresses part # addresses part
address_street_address_1: Adresse ligne 1 address_street_address_1: Adresse ligne 1