List duplicate persons

Signed-off-by: Mathieu Jaumotte <mathieu.jaumotte@champs-libres.coop>

Signed-off-by: Mathieu Jaumotte <mathieu.jaumotte@champs-libres.coop>
This commit is contained in:
Mathieu Jaumotte 2021-03-21 13:58:19 +01:00
parent 0cc951b296
commit 728ea73bdf
12 changed files with 469 additions and 22 deletions

View File

@ -0,0 +1,141 @@
<?php
namespace Chill\PersonBundle\Controller;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonNotDuplicate;
use Chill\PersonBundle\Form\PersonConfimDuplicateType;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Search\SimilarPersonMatcher;
use http\Exception\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Translation\TranslatorInterface;
class PersonDuplicateController extends Controller
{
/**
* @var \Chill\PersonBundle\Search\SimilarPersonMatcher
*/
private $similarPersonMatcher;
/**
* @var \Symfony\Component\Translation\TranslatorInterface
*/
private $translator;
/**
* @var \Chill\PersonBundle\Repository\PersonRepository
*/
private $personRepository;
public function __construct(
SimilarPersonMatcher $similarPersonMatcher,
TranslatorInterface $translator,
PersonRepository $personRepository
) {
$this->similarPersonMatcher = $similarPersonMatcher;
$this->translator = $translator;
$this->personRepository = $personRepository;
}
public function viewAction($person_id)
{
$person = $this->_getPerson($person_id);
if ($person === null) {
throw $this->createNotFoundException("Person with id $person_id not"
. " found on this server");
}
$duplicatePersons = $this->similarPersonMatcher->matchPerson($person, 0.5);
return $this->render('ChillPersonBundle:PersonDuplicate:view.html.twig', [
"person" => $person,
'duplicatePersons' => $duplicatePersons
]);
}
public function confirmAction($person_id, $person2_id, Request $request)
{
if ($person_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->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
dd('todo');
}
return $this->render('ChillPersonBundle:PersonDuplicate:confirm.html.twig', [
'person' => $person,
'person2' => $person2,
'form' => $form->createView(),
]);
}
public function notDuplicateAction($person_id, $person2_id)
{
if ($person_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");
}
$personNotDuplicate = new PersonNotDuplicate();
$personNotDuplicate->setPerson1($person);
$personNotDuplicate->setPerson2($person2);
$personNotDuplicate->setUser($this->getUser());
$this->getDoctrine()->getManager()->persist($personNotDuplicate);
$this->getDoctrine()->getManager()->flush();
return $this->redirectToRoute('chill_person_duplicate_view', ['person_id' => $person->getId()]);
}
/**
* easy getting a person by his id
*/
private function _getPerson($id): ?Person
{
return $this->personRepository->find($id);
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace Chill\PersonBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Chill\MainBundle\Entity\User;
/**
* PersonNotDuplicate
*
* @ORM\Table(name="chill_person_not_duplicate")
* @ORM\Entity()
*/
class PersonNotDuplicate
{
/**
* The person's id
* @var integer
*
* @ORM\Id
* @ORM\Column(name="id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @var Person
*
* @ORM\ManyToOne(targetEntity="Chill\PersonBundle\Entity\Person")
*/
private $person1;
/**
* @var Person
*
* @ORM\ManyToOne(targetEntity="Chill\PersonBundle\Entity\Person")
*/
private $person2;
/**
* @var \DateTime
* @ORM\Column(type="datetime")
*/
private $date;
/**
* @var User
*
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User")
*/
private $user;
public function __construct()
{
$this->date = new \DateTime();
}
public function getId()
{
return $this->id;
}
public function setId($id)
{
$this->id = $id;
}
public function getPerson1()
{
return $this->person1;
}
public function setPerson1(Person $person1)
{
$this->person1 = $person1;
}
public function getPerson2()
{
return $this->person2;
}
public function setPerson2(Person $person2)
{
$this->person2 = $person2;
}
public function getDate()
{
return $this->date;
}
public function setDate(\DateTime $date)
{
$this->date = $date;
}
public function getUser()
{
return $this->user;
}
public function setUser(User $user)
{
$this->user = $user;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Chill\PersonBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
class PersonConfimDuplicateType extends AbstractType
{
/**
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('confirm', CheckboxType::class, [
'label' => 'Je confirme la fusion de ces 2 personnes',
'mapped' => false,
]);
}
/**
* @return string
*/
public function getBlockPrefix()
{
return 'chill_personbundle_person_confirm_duplicate';
}
}

View File

@ -63,6 +63,16 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface
->setExtras([
'order' => 50
]);
$menu->addChild($this->translator->trans('Person duplicate'), [
'route' => 'chill_person_duplicate_view',
'routeParameters' => [
'person_id' => $parameters['person']->getId()
]
])
->setExtras([
'order' => 51
]);
if ($this->showAccompanyingPeriod === 'visible') {
$menu->addChild($this->translator->trans('Accompanying period list'), [

View File

@ -0,0 +1,36 @@
{% extends "@ChillPerson/layout.html.twig" %}
{% set activeRouteKey = 'chill_person_duplicate' %}
{% block title %}{{ 'Person duplicate'|trans|capitalize ~ ' ' ~ person.firstName|capitalize ~
' ' ~ person.lastName }}{% endblock %}
{% block personcontent %}
<h2>Ancien dossier</h2>
{{ person2 }}
<a class="sc-button bt-show" target="_blank" href="{{ path('chill_person_view', { person_id : person2.id }) }}"></a>
<h2>Nouveau Dossier</h2>
{{ person }}
<a class="sc-button bt-show" target="_blank" href="{{ path('chill_person_view', { person_id : person.id }) }}"></a>
{{ form_start(form) }}
{{ form_rest(form) }}
<ul class="grid-12 sticky-form-buttons record_actions ">
<li class="cancel">
<a href="{{ path('chill_person_duplicate_view', {'person_id' : person.id}) }}" class="sc-button grey center margin-5">
<i class="fa fa-arrow-left"></i>
{{ 'Return'|trans }}
</a>
</li>
<li>
<button class="sc-button bt-save" type="submit">{{ 'Confirm'|trans }}</button>
</li>
</ul>
{{ form_end(form) }}
{% endblock %}

View File

@ -0,0 +1,65 @@
{% extends "@ChillPerson/layout.html.twig" %}
{% set activeRouteKey = 'chill_person_duplicate' %}
{% block title %}{{ 'Person duplicate'|trans|capitalize ~ ' ' ~ person.firstName|capitalize ~
' ' ~ person.lastName }}{% endblock %}
{% block personcontent %}
<h2>{{ title|default('Person 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 duplicatePerson in duplicatePersons %}
<tr>
<td>
{% set is_open = duplicatePerson.isOpen() %}
<a href="{{ path('chill_person_view', { person_id : duplicatePerson.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 %}>
{{ duplicatePerson|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 duplicatePerson.birthdate is not null %}
{{ duplicatePerson.birthdate|format_date('long') }}
{% else %}
{{ 'Unknown date of birth'|trans }}
{% endif %}
</td>
<td>
{% if duplicatePerson.nationality is not null %}
{{ duplicatePerson.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 : 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-not-duplicate" href="{{ path('chill_person_duplicate_not_duplicate', {person_id : person.id, person2_id : duplicatePerson.id}) }}"></a></li>
</ul>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@ -61,35 +61,32 @@ class SimilarPersonMatcher
}
public function matchPerson(Person $person)
public function matchPerson(Person $person, $precision = 0.15)
{
$centers = $this->authorizationHelper
->getReachableCenters(
$this->tokenStorage->getToken()->getUser(),
new Role(PersonVoter::SEE)
);
$centers = $this->authorizationHelper->getReachableCenters(
$this->tokenStorage->getToken()->getUser(),
new Role(PersonVoter::SEE)
);
$dql = 'SELECT p from ChillPersonBundle:Person p WHERE'
. ' ('
. ' UNACCENT(LOWER(p.firstName)) LIKE UNACCENT(LOWER(:firstName)) '
. ' OR UNACCENT(LOWER(p.lastName)) LIKE UNACCENT(LOWER(:lastName)) '
. ' OR UNACCENT(LOWER(p.firstName)) LIKE UNACCENT(LOWER(:lastName)) '
. ' OR UNACCENT(LOWER(p.lastName)) LIKE UNACCENT(LOWER(:firstName)) '
. ' OR SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) >= 0.15 '
$dql = 'SELECT p from ChillPersonBundle:Person p '
. ' WHERE ('
. ' SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) >= :precision '
. ' OR SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullNameInverted))) >= :precision '
. ' ) '
. ' AND p.center IN (:centers)'
. ' AND p.id != :personId'
. ' ORDER BY SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) DESC '
;
;
$query =
$this->em
$query = $this->em
->createQuery($dql)
->setParameter('firstName', $person->getFirstName())
->setParameter('lastName', $person->getLastName())
->setParameter('fullName', $person->getFirstName() . ' ' . $person->getLastName())
->setParameter('fullNameInverted', $person->getLastName() . ' ' . $person->getFirstName())
->setParameter('centers', $centers)
;
->setParameter('personId', $person->getId())
->setParameter('precision', $precision)
;
return $query->getResult();
}
}

View File

@ -98,6 +98,10 @@ chill_person_admin_redirect_to_admin_index:
order: 0
label: Main admin menu
chill_person_duplicate_view:
path: /{_locale}/person/{person_id}/duplicate/view
controller: Chill\PersonBundle\Controller\PersonDuplicateController::viewAction
chill_person_closingmotive_admin:
path: /{_locale}/admin/closing-motive
controller: cscrud_closing_motive_controller:index
@ -107,6 +111,10 @@ chill_person_closingmotive_admin:
order: 90
label: 'person_admin.closing motives'
chill_person_duplicate_confirm:
path: /{_locale}/person/{person_id}/duplicate/{person2_id}/confirm
controller: Chill\PersonBundle\Controller\PersonDuplicateController::confirmAction
chill_person_maritalstatus_admin:
path: /{_locale}/admin/marital-status
controller: cscrud_marital_status_controller:index
@ -114,4 +122,8 @@ chill_person_maritalstatus_admin:
menus:
admin_person:
order: 120
label: 'person_admin.marital status'
label: 'person_admin.marital status'
chill_person_duplicate_not_duplicate:
path: /{_locale}/person/{person_id}/duplicate/{person2_id}/not-duplicate
controller: Chill\PersonBundle\Controller\PersonDuplicateController::notDuplicateAction

View File

@ -20,5 +20,12 @@ services:
arguments:
$eventDispatcher: '@Symfony\Component\EventDispatcher\EventDispatcherInterface'
tags: ['controller.service_arguments']
Chill\PersonBundle\Controller\AdminController: ~
Chill\PersonBundle\Controller\PersonDuplicateController:
arguments:
$similarPersonMatcher: '@Chill\PersonBundle\Search\SimilarPersonMatcher'
$translator: '@Symfony\Component\Translation\TranslatorInterface'
$personRepository: '@Chill\PersonBundle\Repository\PersonRepository'
tags: ['controller.service_arguments']

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Application\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20210128152747 extends AbstractMigration
{
public function getDescription() : string
{
return '';
}
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SEQUENCE chill_person_not_duplicate_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_person_not_duplicate (id INT NOT NULL, person1_id INT DEFAULT NULL, person2_id INT DEFAULT NULL, user_id INT DEFAULT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_BD211EE23EF5821B ON chill_person_not_duplicate (person1_id)');
$this->addSql('CREATE INDEX IDX_BD211EE22C402DF5 ON chill_person_not_duplicate (person2_id)');
$this->addSql('CREATE INDEX IDX_BD211EE2A76ED395 ON chill_person_not_duplicate (user_id)');
$this->addSql('ALTER TABLE chill_person_not_duplicate ADD CONSTRAINT FK_BD211EE23EF5821B FOREIGN KEY (person1_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_person_not_duplicate ADD CONSTRAINT FK_BD211EE22C402DF5 FOREIGN KEY (person2_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_person_not_duplicate ADD CONSTRAINT FK_BD211EE2A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP SEQUENCE chill_person_not_duplicate_id_seq CASCADE');
$this->addSql('DROP TABLE chill_person_not_duplicate');
}
}

View File

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

View File

@ -119,6 +119,7 @@ Update accompanying period: Mettre à jour une période d'accompagnement
"Period not opened : form is invalid": "La période n'a pas été ouverte: le formulaire est invalide."
'Closing motive': 'Motif de clôture'
'Person details': 'Détails de la personne'
'Person duplicate': 'Trouver des doublons'
'Update details for %name%': 'Modifier détails de %name%'
Any accompanying periods are open: Aucune période d'accompagnement ouverte
An accompanying period is open: Une période d'accompagnement est ouverte