import duplication from 2.0 branch

This commit is contained in:
Julien Fastré 2021-08-27 15:24:51 +02:00
parent fe54c76317
commit c32ba2bee4
22 changed files with 1012 additions and 54 deletions

View File

@ -49,7 +49,8 @@
"sensio/distribution-bundle": "^5.0",
"knplabs/knp-menu-bundle": "^2.2",
"league/csv": "^9.0",
"champs-libres/async-uploader-bundle": "~1.0"
"champs-libres/async-uploader-bundle": "~1.0",
"laminas/laminas-zendframework-bridge": "~1.3"
},
"require-dev": {
"symfony/dom-crawler": "~3.4",

View File

@ -169,7 +169,6 @@ class PhonenumberHelper
} catch (ClientException $e) {
$this->logger->error("[phonenumber helper] Could not format number "
. "due to client error", [
"message" => $e->getResponseBodySummary($e->getResponse()),
"status_code" => $e->getResponse()->getStatusCode(),
"phonenumber" => $phonenumber
]);
@ -178,7 +177,6 @@ class PhonenumberHelper
} catch (ServerException $e) {
$this->logger->error("[phonenumber helper] Could not format number "
. "due to server error", [
"message" => $e->getResponseBodySummary($e->getResponse()),
"status_code" => $e->getResponse()->getStatusCode(),
"phonenumber" => $phonenumber
]);

View File

@ -0,0 +1,256 @@
<?php
namespace Chill\PersonBundle\Controller;
use Chill\PersonBundle\Actions\Remove\PersonMove;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonNotDuplicate;
use Chill\PersonBundle\Form\PersonConfimDuplicateType;
use Chill\PersonBundle\Form\PersonFindManuallyDuplicateType;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Search\SimilarPersonMatcher;
use http\Exception\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
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;
/**
* @var \Chill\PersonBundle\Actions\Remove\PersonMove
*/
private $personMove;
/**
* @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
*/
private $eventDispatcher;
public function __construct(
SimilarPersonMatcher $similarPersonMatcher,
TranslatorInterface $translator,
PersonRepository $personRepository,
PersonMove $personMove,
EventDispatcherInterface $eventDispatcher
) {
$this->similarPersonMatcher = $similarPersonMatcher;
$this->translator = $translator;
$this->personRepository = $personRepository;
$this->personMove = $personMove;
$this->eventDispatcher = $eventDispatcher;
}
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");
}
$this->denyAccessUnlessGranted('CHILL_PERSON_DUPLICATE', $person,
"You are not allowed to see this person.");
$duplicatePersons = $this->similarPersonMatcher->
matchPerson($person, 0.5, SimilarPersonMatcher::SIMILAR_SEARCH_ORDER_BY_ALPHABETICAL);
$notDuplicatePersons = $this->getDoctrine()->getRepository(PersonNotDuplicate::class)
->findByNotDuplicatePerson($person);
return $this->render('ChillPersonBundle:PersonDuplicate:view.html.twig', [
'person' => $person,
'duplicatePersons' => $duplicatePersons,
'notDuplicatePersons' => $notDuplicatePersons,
]);
}
public function confirmAction($person1_id, $person2_id, Request $request)
{
[$person1, $person2] = $this->_getPersonsByPriority($person1_id, $person2_id);
$this->denyAccessUnlessGranted('CHILL_PERSON_DUPLICATE', $person1,
"You are not allowed to see this person.");
$form = $this->createForm(PersonConfimDuplicateType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$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();
$this->addFlash('success', $this->translator->trans('The de-duplicate operation success'));
return $this->redirectToRoute('chill_person_duplicate_view', ['person_id' => $person1->getId()]);
}
return $this->render('ChillPersonBundle:PersonDuplicate:confirm.html.twig', [
'person' => $person1,
'person2' => $person2,
'form' => $form->createView(),
]);
}
public function notDuplicateAction($person1_id, $person2_id)
{
[$person1, $person2] = $this->_getPersonsByPriority($person1_id, $person2_id);
$this->denyAccessUnlessGranted('CHILL_PERSON_DUPLICATE', $person1,
"You are not allowed to see this person.");
$personNotDuplicate = $this->getDoctrine()->getRepository(PersonNotDuplicate::class)
->findOneBy(['person1' => $person1, 'person2' => $person2]);
if (!$personNotDuplicate instanceof PersonNotDuplicate) {
$personNotDuplicate = new PersonNotDuplicate();
$personNotDuplicate->setPerson1($person1);
$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' => $person1->getId()]);
}
public function removeNotDuplicateAction($person1_id, $person2_id)
{
[$person1, $person2] = $this->_getPersonsByPriority($person1_id, $person2_id);
$this->denyAccessUnlessGranted('CHILL_PERSON_DUPLICATE', $person1,
"You are not allowed to see this person.");
$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()]);
}
public function findManuallyDuplicateAction($person_id, Request $request)
{
$person = $this->_getPerson($person_id);
if ($person === null) {
throw $this->createNotFoundException("Person with id $person_id not"
. " found on this server");
}
$this->denyAccessUnlessGranted('CHILL_PERSON_DUPLICATE', $person,
"You are not allowed to see this person.");
$form = $this->createForm(PersonFindManuallyDuplicateType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$person2 = $form->get('person')->getData();
if ($person2 === null) {
throw $this->createNotFoundException("Person with id $person2->getId() not"
. " found on this server");
}
if ($person === $person2) {
$this->addFlash('error', $this->translator->trans('You cannot add duplicate with same person'));
} elseif ($person->getCenter() !== $person2->getCenter()) {
$this->addFlash('error', $this->translator->trans('You cannot duplicate two persons in two different centers'));
} else {
$direction = $form->get('direction')->getData();
if ($direction === 'starting') {
$params = [
'person1_id' => $person->getId(),
'person2_id' => $person2->getId(),
];
} else {
$params = [
'person1_id' => $person2->getId(),
'person2_id' => $person->getId(),
];
}
return $this->redirectToRoute('chill_person_duplicate_confirm', $params);
}
}
return $this->render('ChillPersonBundle:PersonDuplicate:find_manually.html.twig', [
'person' => $person,
'form' => $form->createView(),
]);
}
/**
* easy getting a person by his id
*/
private function _getPerson($id): ?Person
{
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

@ -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' => 'I confirm the merger of these 2 people',
'mapped' => false,
]);
}
/**
* @return string
*/
public function getBlockPrefix()
{
return 'chill_personbundle_person_confirm_duplicate';
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace Chill\PersonBundle\Form;
use Chill\PersonBundle\Form\Type\PickPersonType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotEqualTo;
class PersonFindManuallyDuplicateType extends AbstractType
{
/**
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('person', PickPersonType::class, [
'label' => 'Find duplicate',
'mapped' => false,
])
->add('direction', HiddenType::class, [
'data' => 'starting',
])
;
}
/**
* @return string
*/
public function getBlockPrefix()
{
return 'chill_personbundle_person_find_manually_duplicate';
}
}

View File

@ -18,7 +18,12 @@
namespace Chill\PersonBundle\Menu;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Translation\TranslatorInterface;
/**
@ -44,12 +49,16 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface
*/
protected $translator;
protected AuthorizationCheckerInterface $security;
public function __construct(
$showAccompanyingPeriod,
TranslatorInterface $translator
TranslatorInterface $translator,
AuthorizationCheckerInterface $security
) {
$this->showAccompanyingPeriod = $showAccompanyingPeriod;
$this->translator = $translator;
$this->security = $security;
}
public function buildMenu($menuId, MenuItem $menu, array $parameters)
@ -75,6 +84,18 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface
'order' => 100
]);
}
if ($this->security->isGranted(PersonVoter::DUPLICATE, $parameters['person'])) {
$menu->addChild($this->translator->trans('Person duplicate'), [
'route' => 'chill_person_duplicate_view',
'routeParameters' => [
'person_id' => $parameters['person']->getId()
]
])
->setExtras([
'order' => 99999
]);
}
}
public static function getMenuIds(): array

View File

@ -0,0 +1,33 @@
<?php
namespace Chill\PersonBundle\Repository;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonNotDuplicate;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
final class PersonNotDuplicateRepository extends EntityRepository
{
public function findByNotDuplicatePerson(Person $person): array
{
$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

@ -0,0 +1,20 @@
Chill\PersonBundle\Entity\PersonNotDuplicate:
type: entity
table: chill_person_not_duplicate
repositoryClass: Chill\PersonBundle\Repository\PersonNotDuplicateRepository
id:
id:
type: integer
id: true
generator:
strategy: AUTO
fields:
date:
type: datetime
manyToOne:
person1:
targetEntity: Chill\PersonBundle\Entity\Person
person2:
targetEntity: Chill\PersonBundle\Entity\Person
user:
targetEntity: Chill\MainBundle\Entity\User

View File

@ -88,3 +88,23 @@ chill_person_timeline:
chill_person_admin:
path: "/{_locale}/admin/person"
defaults: { _controller: ChillPersonBundle:Admin:index }
chill_person_duplicate_view:
path: /{_locale}/person/{person_id}/duplicate/view
controller: Chill\PersonBundle\Controller\PersonDuplicateController::viewAction
chill_person_duplicate_confirm:
path: /{_locale}/person/{person1_id}/duplicate/{person2_id}/confirm
controller: Chill\PersonBundle\Controller\PersonDuplicateController::confirmAction
chill_person_duplicate_not_duplicate:
path: /{_locale}/person/{person1_id}/duplicate/{person2_id}/not-duplicate
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
chill_person_find_manually_duplicate:
path: /{_locale}/person/{person_id}/find-manually
controller: Chill\PersonBundle\Controller\PersonDuplicateController::findManuallyDuplicateAction

View File

@ -17,3 +17,7 @@ services:
$eventDispatcher: '@Symfony\Component\EventDispatcher\EventDispatcherInterface'
Chill\PersonBundle\Controller\AdminController: ~
Chill\PersonBundle\Controller\PersonDuplicateController:
autowire: true
autoconfigure: true

View File

@ -12,8 +12,10 @@ services:
- { name: 'chill.menu_builder' }
Chill\PersonBundle\Menu\PersonMenuBuilder:
autowire: true
arguments:
$showAccompanyingPeriod: '%chill_person.accompanying_period%'
$translator: '@Symfony\Component\Translation\TranslatorInterface'
# $translator: '@Symfony\Component\Translation\TranslatorInterface'
# $security: '@Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface'
tags:
- { name: 'chill.menu_builder' }

View File

@ -12,6 +12,12 @@ services:
arguments:
- 'Chill\PersonBundle\Entity\Person'
Chill\PersonBundle\Repository\PersonNotDuplicateRepository:
class: Chill\PersonBundle\Person\PersonNotDuplicateRepository
factory: [ '@doctrine.orm.entity_manager', getRepository ]
arguments:
- 'Chill\PersonBundle\Entity\PersonNotDuplicate'
Chill\PersonBundle\Repository\ClosingMotiveRepository:
class: Chill\PersonBundle\Repository\ClosingMotiveRepository
factory: ['@doctrine.orm.entity_manager', getRepository]

View File

@ -24,7 +24,4 @@ services:
- { name: chill.search, alias: 'person_similarity' }
Chill\PersonBundle\Search\SimilarPersonMatcher:
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'
$tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'
$authorizationHelper: '@Chill\MainBundle\Security\Authorization\AuthorizationHelper'
autowire: true

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

@ -149,6 +149,37 @@ Add an address: Ajouter une adresse
Back to the person details: Retour aux détails de la personne
Move to another address: Nouvelle adresse
# dédoublonnage
Old person: Doublon
Old person explain: sera supprimé lors de la fusion
New person: Dossier cible
New person explain: sera conservé lors de la fusion
I confirm the merger of these 2 people : Je confime la fusion de ces 2 dossiers
Person duplicate explained: Chill a détecté des doublons potentiels ! Vous pouvez confirmer, infirmer, ou encore désigner manuellement un autre doublon.
Person flaged as duplicate: Dossiers marqués comme faux-positif
Person flaged as duplicate explained: Les dossiers suivants sont marqués comme faux-positifs. Ce ne sont pas des doublons !
Associate manually a duplicate person: Désigner manuellement un doublon
Invert: Inverser le sens de la fusion
Find duplicate: Trouver un doublon
Person duplicate: Traiter les doublons
Open in another window: Ouvrir dans une nouvelle fenêtre
Deleted datas: Données supprimées
Keeped datas: Données conservées
Moved links: Relations déplacées
Keeped links: Relations conservées
Merge duplicate persons folders: Fusion de dossiers
Merge: Fusionner
duplicate: Doublon
not-duplicate: Faux-positif
Switch to truefalse: Marquer comme faux-positif
Switch to duplicate: Marquer comme doublon potentiel
No duplicate candidates: Il n'y a pas de doublons détectés, ni de faux-positifs
You cannot add duplicate with same person: Indiquez une autre personne. Il n'est pas possible de fusionner un dossier de personne avec elle-même.
You cannot duplicate two persons in two different centers: Il n'est pas possible de fusionner les dossiers dans deux centres différents
CHILL_PERSON_DUPLICATE: Gestion des doublons
The de-duplicate operation success: L'opération de dé-doublonnage s'est terminée avec succès
#timeline
Timeline: Historique
Closing the accompanying period: Fermeture de la période d'accompagnement

View File

@ -0,0 +1,38 @@
{%- macro details(person, options) -%}
<ul>
<li><b>Identifiant</b>: {{ person.id }}</li>
<li><b>{{ 'gender'|trans }}</b>:
{{ person.gender|trans }}</li>
<li><b>{{ 'Marital status'|trans }}</b>:
{% if person.maritalStatus is not null %}{{ person.maritalStatus.name|localize_translatable_string }}{% endif %}</li>
<li><b>{{ 'birthdate'|trans }}</b>:
{% if person.birthdate is not null %}{{ person.birthdate|localizeddate('short', 'none') }}{% endif %}</li>
<li><b>{{ 'placeOfBirth'|trans }}</b>:
{% if person.placeOfBirth is not empty %}{{ person.placeOfBirth }}{% endif %}</li>
<li><b>{{ 'countryOfBirth'|trans }}</b>:
{% if person.countryOfBirth is not null %}{{ person.countryOfBirth.name|localize_translatable_string }}{% endif %}</li>
<li><b>{{ 'nationality'|trans }}</b>:
{% if person.nationality is not null %}{{ person.nationality.name|localize_translatable_string }}{% endif %}</li>
<li><b>{{ 'phonenumber'|trans }}</b>:
{{ person.phonenumber|chill_format_phonenumber }}</li>
<li><b>{{ 'mobilenumber'|trans }}</b>:
{{ person.mobilenumber|chill_format_phonenumber }}</li>
<li><b>{{ 'email'|trans }}</b>:
{{ person.email }}</li>
<li><b>{{ 'memo'|trans }}</b>:
{{ person.memo }}</li>
<li><b>{{ 'Address'|trans }}</b>:
{%- if person.lastAddress is not empty -%}
{{ person.lastAddress|chill_entity_render_box({'with_valid_from': false}) }}
{% else %}
<span class="chill-no-data-statement">{{ 'Any address'|trans }}</span>
{% endif %}
</li>
<li><b>{{ 'Spoken languages'|trans }}</b>:
{% for lang in person.spokenLanguages %}{{ lang.name|localize_translatable_string }}{% if not loop.last %},{% endif %}{% endfor %}</li>
<li><b>{{ 'Contact information'|trans }}</b>:
{% if person.contactInfo is not empty %}{{ person.contactInfo|nl2br }}{% endif %}</li>
</ul>
{% endmacro %}

View File

@ -0,0 +1,93 @@
{% extends "@ChillMain/layout.html.twig" %}
{% set activeRouteKey = 'chill_person_duplicate' %}
{% import '@ChillPerson/PersonDuplicate/_sidepane.html.twig' as sidepane %}
{% block title %}{{ 'Person duplicate'|trans|capitalize ~ ' ' ~ person.firstName|capitalize ~
' ' ~ person.lastName }}{% 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>{{ 'Merge duplicate persons folders'|trans }}</h1>
<div class="grid-6 grid-tablet-12 gid-mobile-12">
<p><b>{{ 'Old person'|trans }}</b>:
{{ 'Old person explain'|trans }}
</p>
<div class="col">
<h1><span><a class="btn btn-show" target="_blank" title="{{ 'Open in another window'|trans }}" href="{{ path('chill_person_view', { person_id : person2.id }) }}"></a></span>
{{ person2 }}
</h1>
<h4>{{ 'Deleted datas'|trans ~ ':' }}</h4>
{{ sidepane.details(person2) }}
</div>
</div>
<div class="grid-6 grid-tablet-12 gid-mobile-12">
<p><b>{{ 'New person'|trans }}</b>:
{{ 'New person explain'|trans }}
</p>
<div class="col border">
<h1><span><a class="btn btn-show" target="_blank" title="{{ 'Open in another window'|trans }}" href="{{ path('chill_person_view', { person_id : person.id }) }}"></a></span>
{{ person }}
</h1>
<h4>{{ 'Keeped datas'|trans ~ ':' }}</h4>
{{ sidepane.details(person) }}
</div>
</div>
{{ form_start(form) }}
<div class="grid-4 grid-tablet-12 gid-mobile-12 centered">
<div class="container-fluid" style="padding-top: 1em;">
<div class="col-1 clear" style="padding-top: 10px;">
{{ form_widget(form.confirm) }}
{{ form_label(form.confirm) }}
</div>
</div>
</div>
<ul class="sticky-form-buttons record_actions">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_person_duplicate_view', {'person_id': person.id }) }}" class="sc-button bt-cancel btn btn-chill-gray center margin-5">
{{ 'Return'|trans }}
</a>
</li>
<li class="">
<a href="{{ path('chill_person_duplicate_confirm', { person1_id : person2.id, person2_id : person.id }) }}"
class="sc-button bt-orange btn btn-action">
<i class="fa fa-exchange"></i>
{{ 'Invert'|trans }}
</a>
</li>
<li>
<button class="sc-button bt-green 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,31 @@
{% extends "@ChillPerson/layout.html.twig" %}
{% set activeRouteKey = 'chill_person_duplicate' %}
{% block title %}{{ 'Find duplicate'|trans|capitalize ~ ' ' ~ person.firstName|capitalize ~
' ' ~ person.lastName }}{% endblock %}
{% block personcontent %}
<div class="person-duplicate">
<h1>{{ 'Désigner un dossier doublon'|trans }}</h1>
{{ form_start(form) }}
{{ form_rest(form) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_person_duplicate_view', {'person_id' : person.id}) }}" class="sc-button bt-cancel btn btn-cancel">
{{ 'Return'|trans }}
</a>
</li>
<li>
<button class="sc-button bt-green btn btn-save" type="submit">{{ 'Next'|trans }}</button>
</li>
</ul>
{{ form_end(form) }}
</div>
{% endblock %}

View File

@ -0,0 +1,159 @@
{% extends "@ChillPerson/layout.html.twig" %}
{% set activeRouteKey = 'chill_person_duplicate' %}
{% block title %}{{ 'Person duplicate'|trans|capitalize ~ ' ' ~ person.firstName|capitalize ~
' ' ~ person.lastName }}{% endblock %}
{% block personcontent %}
<div class="person-duplicate">
<h1>{{ title|default('Person duplicate')|trans }}</h1>
{% if duplicatePersons|length > 0 %}
<p>{{ title|default('Person duplicate explained')|trans }}</p>
<table class="table table-bordered border-dark">
<thead>
<tr>
<th class="chill-green">{% trans %}Name{% endtrans %}</th>
<th class="chill-green">{% trans %}Date of birth{% endtrans %}</th>
<th class="chill-green">{% 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|localizeddate('long', 'none') }}
{% 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 btn btn-show" target="_blank" href="{{ path('chill_person_view', { person_id : duplicatePerson.id }) }}"></a>
</li>
<li>
<a class="sc-button bt-orange btn btn-action" href="{{ path('chill_person_duplicate_confirm', { person1_id : person.id, person2_id : duplicatePerson.id }) }}">
<i class="fa fa-cog fa-fw"></i>{{ 'Merge'|trans }}</a>
</li>
<li>
<a class="sc-button btn btn-misc" title="{{ 'Switch to truefalse'|trans }}"
href="{{ path('chill_person_duplicate_not_duplicate', {person1_id : person.id, person2_id : duplicatePerson.id}) }}">
{{ 'Switch to truefalse'|trans }}
</a>
</li>
</ul>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% if notDuplicatePersons|length > 0 %}
<h2>{{ 'Person flaged as duplicate' | trans }}</h2>
<p>{{ 'Person flaged as duplicate explained' | trans }}</p>
<table class="table table-bordered border-dark">
<thead>
<tr>
<th class="chill-orange">{% trans %}Name{% endtrans %}</th>
<th class="chill-orange">{% 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|localizeddate('long', 'none') }}
{% 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 btn btn-show" target="_blank" href="{{ path('chill_person_view', { person_id : notDuplicatePerson.id }) }}"></a>
</li>
<li>
<a class="sc-button btn btn-misc" title="{{ 'Switch to duplicate'|trans }}"
href="{{ path('chill_person_remove_duplicate_not_duplicate', {person1_id : person.id, person2_id : notDuplicatePerson.id}) }}">
{{ 'Switch to duplicate'|trans }}
</a>
</li>
</ul>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% if notDuplicatePersons|length == 0 and duplicatePersons|length == 0 %}
<span class="chill-no-data-statement">{{ 'No duplicate candidates'|trans }}</span>
{% endif %}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_person_view', {person_id: person.id }) }}" class="sc-button bt-cancel btn btn-cancel">
{{ 'Return'|trans }}</a>
</li>
<li>
<a href="{{ path('chill_person_find_manually_duplicate', {person_id: person.id}) }}" class="sc-button bt-create btn btn-action">
{{ 'Associate manually a duplicate person' | trans }}
</a>
</li>
</ul>
</div>
{% endblock %}

View File

@ -18,9 +18,12 @@
*/
namespace Chill\PersonBundle\Search;
use Chill\PersonBundle\Entity\PersonNotDuplicate;
use Chill\PersonBundle\Templating\Entity\PersonRender;
use Doctrine\ORM\EntityManagerInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Chill\PersonBundle\Repository\PersonNotDuplicateRepository;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Role\Role;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
@ -32,62 +35,89 @@ use Chill\PersonBundle\Security\Authorization\PersonVoter;
*/
class SimilarPersonMatcher
{
CONST SIMILAR_SEARCH_ORDER_BY_ALPHABETICAL = 'alphabetical';
CONST SIMILAR_SEARCH_ORDER_BY_SIMILARITY = 'similarity';
/**
*
* @var EntityManagerInterface
*/
protected $em;
/**
*
* @var AuthorizationHelper
*/
protected $authorizationHelper;
/**
*
* @var TokenStorageInterface
*/
protected $tokenStorage;
protected PersonNotDuplicateRepository $personNotDuplicateRepository;
protected PersonRender $personRender;
public function __construct(
EntityManagerInterface $em,
AuthorizationHelper $authorizationHelper,
TokenStorageInterface $tokenStorage
TokenStorageInterface $tokenStorage,
PersonNotDuplicateRepository $personNotDuplicateRepository,
PersonRender $personRender
) {
$this->em = $em;
$this->authorizationHelper = $authorizationHelper;
$this->tokenStorage = $tokenStorage;
$this->personNotDuplicateRepository = $personNotDuplicateRepository;
$this->personRender = $personRender;
}
public function matchPerson(Person $person)
{
$centers = $this->authorizationHelper
->getReachableCenters(
public function matchPerson(
Person $person,
float $precision = 0.15,
string $orderBy = self::SIMILAR_SEARCH_ORDER_BY_SIMILARITY,
bool $addYearComparison = false
) {
$centers = $this->authorizationHelper->getReachableCenters(
$this->tokenStorage->getToken()->getUser(),
new Role(PersonVoter::SEE)
);
$query = $this->em->createQuery();
$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 '
. ' ) '
. ' AND p.center IN (:centers)'
. ' ORDER BY SIMILARITY(p.fullnameCanonical, UNACCENT(LOWER(:fullName))) DESC '
;
$query =
$this->em
->createQuery($dql)
->setParameter('firstName', $person->getFirstName())
->setParameter('lastName', $person->getLastName())
->setParameter('fullName', $person->getFirstName() . ' ' . $person->getLastName())
if ($person->getId() !== NULL) {
$dql .= ' AND p.id != :personId ';
$notDuplicatePersons = $this->personNotDuplicateRepository->findByNotDuplicatePerson($person);
$query->setParameter('personId', $person->getId());
if (count($notDuplicatePersons)) {
$dql .= ' AND p.id not in (:notDuplicatePersons)';
$query->setParameter('notDuplicatePersons', $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 = $query
->setDQL($dql)
->setParameter('fullName', $this->personRender->renderString($person, []))
->setParameter('centers', $centers)
->setParameter('precision', $precision)
;
return $query->getResult();

View File

@ -40,6 +40,7 @@ class PersonVoter extends AbstractChillVoter implements ProvideRoleHierarchyInte
const SEE = 'CHILL_PERSON_SEE';
const STATS = 'CHILL_PERSON_STATS';
const LISTS = 'CHILL_PERSON_LISTS';
const DUPLICATE = 'CHILL_PERSON_DUPLICATE';
/**
*
@ -56,11 +57,11 @@ class PersonVoter extends AbstractChillVoter implements ProvideRoleHierarchyInte
{
if ($subject instanceof Person) {
return \in_array($attribute, [
self::CREATE, self::UPDATE, self::SEE
self::CREATE, self::UPDATE, self::SEE, self::DUPLICATE
]);
} elseif ($subject instanceof Center) {
return \in_array($attribute, [
self::STATS, self::LISTS
self::STATS, self::LISTS, self::DUPLICATE
]);
} elseif ($subject === null) {
return $attribute === self::CREATE;
@ -87,7 +88,7 @@ class PersonVoter extends AbstractChillVoter implements ProvideRoleHierarchyInte
private function getAttributes()
{
return array(self::CREATE, self::UPDATE, self::SEE, self::STATS, self::LISTS);
return array(self::CREATE, self::UPDATE, self::SEE, self::STATS, self::LISTS, self::DUPLICATE);
}
public function getRoles()