Bläddra i källkod

add unit tests for dolibarr sync utils methods

Olivier Massot 3 år sedan
förälder
incheckning
8be47c54f3

+ 6 - 0
src/Enum/Access/FunctionEnum.php

@@ -10,6 +10,12 @@ use MyCLabs\Enum\Enum;
  * @method static STUDENT()
  * @method static ADHERENT()
  * @method static OTHER()
+ * @method static PRESIDENT()
+ * @method static TEACHER()
+ * @method static ADMINISTRATIVE_STAFF()
+ * @method static TREASURER()
+ * @method static ARCHIVIST()
+ * @method static DIRECTOR()
  */
 class FunctionEnum extends Enum
 {

+ 1 - 1
src/Repository/Access/FunctionTypeRepository.php

@@ -13,7 +13,7 @@ use Doctrine\Persistence\ManagerRegistry;
  * @method FunctionType[]    findAll()
  * @method FunctionType[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
  */
-final class FunctionTypeRepository extends ServiceEntityRepository
+class FunctionTypeRepository extends ServiceEntityRepository
 {
     public function __construct(ManagerRegistry $registry)
     {

+ 45 - 28
src/Service/Dolibarr/DolibarrSyncService.php

@@ -3,6 +3,8 @@ declare(strict_types=1);
 
 namespace App\Service\Dolibarr;
 
+use App\Entity\Core\AddressPostal;
+use App\Entity\Core\ContactPoint;
 use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
 use App\Enum\Access\FunctionEnum;
@@ -59,7 +61,7 @@ class DolibarrSyncService
      * @return array<BaseRestOperation>
      * @throws Exception
      */
-    public function scan($progressionCallback = null): array {
+    public function scan(?callable $progressionCallback = null): array {
         $this->logger->info("-- Scan started --");
 
         // Index the dolibarr clients by organization ids
@@ -276,12 +278,12 @@ class DolibarrSyncService
      * Returns an array of DolibarrSyncOperations
      *
      * @param array<BaseRestOperation> $operations
-     * @var callable | null $progressionCallback A callback method for indicating the current progression of the process;
-     *                                           Shall accept two integer arguments: current progression, and total.
      * @return array<BaseRestOperation>
      * @throws Exception
+     *@var callable | null $progressionCallback A callback method for indicating the current progression of the process;
+     *                                           Shall accept two integer arguments: current progression, and total.
      */
-    public function execute(array $operations, $progressionCallback = null): array
+    public function execute(array $operations, ?callable $progressionCallback = null): array
     {
         $this->logger->info('-- Execution started --');
         $this->logger->info(count($operations) . ' operations pending...');
@@ -345,7 +347,7 @@ class DolibarrSyncService
      *
      * @return array An index of the form [$organizationId => $dolibarrData]
      */
-    private function getDolibarrSocietiesIndex(): array
+    protected function getDolibarrSocietiesIndex(): array
     {
         $index = [];
         foreach ($this->dolibarrApiService->getAllClients() as $clientData) {
@@ -368,7 +370,7 @@ class DolibarrSyncService
      *
      * @return array An index of the form [$personId => $dolibarrData]
      */
-    private function getDolibarrContactsIndex(int $socId): array {
+    protected function getDolibarrContactsIndex(int $socId): array {
         $index = [];
         $contacts = $this->dolibarrApiService->getOpentalentContacts($socId);
         foreach ($contacts as $contactData) {
@@ -385,7 +387,7 @@ class DolibarrSyncService
      *
      * @return array
      */
-    private function getActiveMembersIndex(): array {
+    protected function getActiveMembersIndex(): array {
         $index = [];
         $results = $this->accessRepository->getAllActiveMembersAndMissions();
         foreach ($results as $row) {
@@ -414,13 +416,13 @@ class DolibarrSyncService
      * @param array|null $data
      * @return array|null
      */
-    private function sanitizeDolibarrData(?array $data): ?array {
+    protected static function sanitizeDolibarrData(?array $data): ?array {
         if ($data === null)
             return null;
 
         foreach ($data as $field => $value) {
             if (is_array($value)) {
-                $data[$field] = $this->sanitizeDolibarrData($value);
+                $data[$field] = self::sanitizeDolibarrData($value);
             } else {
                 if ($value === '') {
                     $data[$field] = null;
@@ -434,9 +436,9 @@ class DolibarrSyncService
      * Retrieve the postal address of the organization
      *
      * @param Organization $organization
-     * @return null
+     * @return AddressPostal|null
      */
-    private function getOrganizationPostalAddress(Organization $organization) {
+    protected function getOrganizationPostalAddress(Organization $organization): ?AddressPostal {
         $addressPriorities = [
             AddressPostalOrganizationTypeEnum::ADDRESS_BILL(),
             AddressPostalOrganizationTypeEnum::ADDRESS_CONTACT(),
@@ -445,8 +447,10 @@ class DolibarrSyncService
             AddressPostalOrganizationTypeEnum::ADDRESS_OTHER()
         ];
 
+        $organizationAddressPostal = $organization->getOrganizationAddressPostals();
+
         foreach ($addressPriorities as $addressType) {
-            foreach ($organization->getOrganizationAddressPostals() as $postalAddress) {
+            foreach ($organizationAddressPostal as $postalAddress) {
                 if ($postalAddress->getType() == $addressType) {
                     return $postalAddress->getAddressPostal();
                 }
@@ -461,7 +465,7 @@ class DolibarrSyncService
      * @param Organization $organization
      * @return string|null
      */
-    private function getOrganizationPhone(Organization $organization): ?string
+    protected function getOrganizationPhone(Organization $organization): ?string
     {
         $contactPriorities = [
             ContactPointTypeEnum::BILL(),
@@ -470,8 +474,10 @@ class DolibarrSyncService
             ContactPointTypeEnum::OTHER()
         ];
 
+        $contactPoints = $organization->getContactPoints();
+
         foreach ($contactPriorities as $contactType) {
-            foreach ($organization->getContactPoints() as $contactPoint) {
+            foreach ($contactPoints as $contactPoint) {
                 if ($contactPoint->getContactType() === $contactType) {
                     if ($contactPoint->getTelphone() !== null) {
                         return $this->formatPhoneNumber($contactPoint->getTelphone());
@@ -489,9 +495,9 @@ class DolibarrSyncService
      * Retrieve the email for the organization
      *
      * @param Organization $organization
-     * @return null
+     * @return string|null
      */
-    private function getOrganizationEmail(Organization $organization) {
+    protected function getOrganizationEmail(Organization $organization): ?string {
         $contactPriorities = [
             ContactPointTypeEnum::BILL(),
             ContactPointTypeEnum::CONTACT(),
@@ -510,31 +516,38 @@ class DolibarrSyncService
     }
 
     /**
-     * Returns the number of accesses posseding at least one of the missions
+     * Returns the number of accesses possessing at least one of the missions
      *
      * @param array $missions A list of missions
      * @param array $members An organization members as returned by getActiveMembersIndex: [$accessID => [$missions...]]
      * @return int
      */
-    private function countWithMission(array $missions, array $members): int {
+    protected static function countWithMission(array $missions, array $members): int {
         return count(array_filter(
             $members,
             function ($actualMissions) use ($missions) { return !empty(array_intersect($actualMissions, $missions)); }
         ));
     }
 
-    private function getPersonContact(Person $person) {
+    /**
+     * Return the best contact point for the given Person, or null if none
+     *
+     * @param Person $person
+     * @return ContactPoint|null
+     */
+    protected function getPersonContact(Person $person): ?ContactPoint {
         $contactPriorities = [
             ContactPointTypeEnum::PRINCIPAL(),
             ContactPointTypeEnum::OTHER()
         ];
 
+        $contactPoints = $person->getContactPoints();
+
         foreach ($contactPriorities as $contactTypeEnum) {
-            $result = $this->contactPointRepository->getByTypeAndPerson(
-                $contactTypeEnum->getValue(), $person
-            );
-            if (!empty($result)) {
-                return $result[0];
+            foreach ($contactPoints as $contactPoint) {
+                if ($contactPoint->getContactType() === $contactTypeEnum->getValue()) {
+                    return $contactPoint;
+                }
             }
         }
         return null;
@@ -547,7 +560,7 @@ class DolibarrSyncService
      * @param string|null $gender
      * @return string
      */
-    private function formatContactPosition(array $missions, ?string $gender = 'X'): string {
+    protected function formatContactPosition(array $missions, ?string $gender = 'X'): string {
         $to_exclude = [FunctionEnum::ADHERENT(), FunctionEnum::STUDENT(), FunctionEnum::OTHER()];
         $poste = implode(
             ', ',
@@ -582,7 +595,7 @@ class DolibarrSyncService
      * @param PhoneNumber $phoneNumber
      * @return mixed
      */
-    private function formatPhoneNumber(PhoneNumber $phoneNumber): string {
+    protected static function formatPhoneNumber(PhoneNumber $phoneNumber): string {
         $phoneUtil = PhoneNumberUtil::getInstance();
         return $phoneUtil->format($phoneNumber, PhoneNumberFormat::INTERNATIONAL);
     }
@@ -598,11 +611,15 @@ class DolibarrSyncService
      * @param array $newData
      * @return array
      */
-    private function filterDiff(array $initialData, array $newData) {
+    protected static function filterDiff(array $initialData, array $newData): array
+    {
         $result = [];
         foreach ($newData as $field => $value) {
             if (is_array($value)) {
-                $data[$field] = $this->filterDiff($initialData[$field] ?? [], $value);
+                $filteredValue = self::filterDiff($initialData[$field] ?? [], $value);
+                if (!empty($filteredValue)) {
+                    $result[$field] = $filteredValue;
+                }
             } else {
                 if (($value ?? '') !== ($initialData[$field] ?? '') || !array_key_exists($field, $initialData)) {
                     $result[$field] = $value;

+ 469 - 0
tests/Service/Dolibarr/DolibarrSyncServiceTest.php

@@ -0,0 +1,469 @@
+<?php
+
+namespace App\Tests\Service\Dolibarr;
+
+use App\Entity\Core\AddressPostal;
+use App\Entity\Core\ContactPoint;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\OrganizationAddressPostal;
+use App\Entity\Person\Person;
+use App\Enum\Access\FunctionEnum;
+use App\Enum\Core\ContactPointTypeEnum;
+use App\Enum\Organization\AddressPostalOrganizationTypeEnum;
+use App\Repository\Access\AccessRepository;
+use App\Repository\Access\FunctionTypeRepository;
+use App\Repository\Core\ContactPointRepository;
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\Dolibarr\DolibarrApiService;
+use App\Service\Dolibarr\DolibarrSyncService;
+use JetBrains\PhpStorm\Pure;
+use libphonenumber\PhoneNumber;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+class TestableDolibarrSyncService extends DolibarrSyncService {
+    public function getDolibarrSocietiesIndex(): array { return parent::getDolibarrSocietiesIndex(); }
+    public function getDolibarrContactsIndex(int $socId): array { return parent::getDolibarrContactsIndex($socId); }
+    public function getActiveMembersIndex(): array { return parent::getActiveMembersIndex(); }
+    public static function sanitizeDolibarrData(?array $data): ?array { return parent::sanitizeDolibarrData($data); }
+    public function getOrganizationPostalAddress(Organization $organization): AddressPostal { return parent::getOrganizationPostalAddress($organization); }
+    public function getOrganizationPhone(Organization $organization): ?string { return parent::getOrganizationPhone($organization); }
+    public function getOrganizationEmail(Organization $organization): ?string { return parent::getOrganizationEmail($organization); }
+    public static function countWithMission(array $missions, array $members): int { return parent::countWithMission($missions, $members); }
+    public function getPersonContact(Person $person): ?ContactPoint { return parent::getPersonContact($person); }
+    public function formatContactPosition(array $missions, ?string $gender = 'X'): string { return parent::formatContactPosition($missions, $gender); }
+    public static function formatPhoneNumber(PhoneNumber $phoneNumber): string { return parent::formatPhoneNumber($phoneNumber); }
+    public static function filterDiff(array $initialData, array $newData): array { return parent::filterDiff($initialData, $newData); }
+}
+
+class DolibarrSyncServiceTest extends TestCase
+{
+    private OrganizationRepository $organizationRepository;
+    private AccessRepository $accessRepository;
+    private ContactPointRepository $contactPointRepository;
+    private FunctionTypeRepository $functionTypeRepository;
+    private DolibarrApiService $dolibarrApiService;
+    private TranslatorInterface $translator;
+    private LoggerInterface $logger;
+
+    public function setUp(): void {
+        $this->organizationRepository = $this->getMockBuilder(OrganizationRepository::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $this->accessRepository = $this->getMockBuilder(AccessRepository::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $this->contactPointRepository = $this->getMockBuilder(ContactPointRepository::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $this->functionTypeRepository = $this->getMockBuilder(FunctionTypeRepository::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $this->dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $this->translator = $this->getMockBuilder(TranslatorInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    #[Pure]
+    private function newDolibarrSyncService(): TestableDolibarrSyncService
+    {
+        return new TestableDolibarrSyncService(
+             $this->organizationRepository,
+             $this->accessRepository,
+             $this->contactPointRepository,
+             $this->functionTypeRepository,
+             $this->dolibarrApiService,
+             $this->translator,
+             $this->logger
+        );
+    }
+
+    private function getJsonContentFromFixture(string $filename): array {
+        $filepath = dirname(__FILE__) . '/fixtures/' . $filename;
+        return json_decode(file_get_contents($filepath), true);
+    }
+
+    public function testGetDolibarrSocietiesIndex() {
+        $this->dolibarrApiService
+            ->expects($this->once())
+            ->method('getAllClients')
+            ->willReturn(
+                $this->getJsonContentFromFixture('thirdparties.json')
+            );
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $index = $syncService->getDolibarrSocietiesIndex();
+
+        $this->assertEquals("13930", $index[13930]['array_options']['options_2iopen_organization_id']);
+    }
+
+    public function testDolibarrContactsIndex() {
+        $this->dolibarrApiService
+            ->expects($this->once())
+            ->method('getOpentalentContacts')
+            ->with(9)
+            ->willReturn(
+                $this->getJsonContentFromFixture('contacts.json')
+            );
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $index = $syncService->getDolibarrContactsIndex(9);
+
+        $this->assertEquals("302117", $index[302117]['array_options']['options_2iopen_person_id']);
+    }
+
+    public function testActiveMembersIndex() {
+        $this->accessRepository
+            ->expects($this->once())
+            ->method('getAllActiveMembersAndMissions')
+            ->willReturn(
+                [
+                    ['id' => 123, 'organization_id' => 1, 'mission' => FunctionEnum::PRESIDENT()->getValue()],
+                    ['id' => 123, 'organization_id' => 1, 'mission' => FunctionEnum::TEACHER()->getValue()],
+                    ['id' => 124, 'organization_id' => 1, 'mission' => FunctionEnum::ADMINISTRATIVE_STAFF()->getValue()],
+                    ['id' => 125, 'organization_id' => 2, 'mission' => FunctionEnum::ADHERENT()->getValue()],
+                ]
+            );
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            [
+                1 => [
+                    123 => [FunctionEnum::PRESIDENT()->getValue(), FunctionEnum::TEACHER()->getValue()],
+                    124 => [FunctionEnum::ADMINISTRATIVE_STAFF()->getValue()]
+                ],
+                2 => [
+                    125 => [FunctionEnum::ADHERENT()->getValue()]
+                ]
+            ],
+            $syncService->getActiveMembersIndex()
+        );
+    }
+
+    public function testSanitizeDolibarrData() {
+
+        $this->assertEquals(null, TestableDolibarrSyncService::sanitizeDolibarrData(null));
+
+        $this->assertEquals(
+            ['a' => 'A', 'b' => null, 'c' => ['d' => 'D', 'e' => null]],
+            TestableDolibarrSyncService::sanitizeDolibarrData(
+                ['a' => 'A', 'b' => '', 'c' => ['d' => 'D', 'e' => '']]
+            )
+        );
+    }
+
+    public function testGetOrganizationPostalAddress() {
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organizationAddressPostal1 = $this->getMockBuilder(OrganizationAddressPostal::class)->getMock();
+        $organizationAddressPostal2 = $this->getMockBuilder(OrganizationAddressPostal::class)->getMock();
+        $organizationAddressPostal3 = $this->getMockBuilder(OrganizationAddressPostal::class)->getMock();
+        $addressPostal = $this->getMockBuilder(AddressPostal::class)->getMock();
+
+        $organizationAddressPostal1->method('getType')->willReturn(AddressPostalOrganizationTypeEnum::ADDRESS_PRACTICE()->getValue());
+        $organizationAddressPostal2->method('getType')->willReturn(AddressPostalOrganizationTypeEnum::ADDRESS_BILL()->getValue());
+        $organizationAddressPostal3->method('getType')->willReturn(AddressPostalOrganizationTypeEnum::ADDRESS_OTHER()->getValue());
+
+        $organizationAddressPostal2->method('getAddressPostal')->willReturn($addressPostal);
+
+        $organization->expects($this->once())
+            ->method('getOrganizationAddressPostals')
+            ->willReturn(
+                [$organizationAddressPostal1, $organizationAddressPostal2, $organizationAddressPostal3]
+            );
+
+        $syncService = $this->newDolibarrSyncService($organization);
+
+        $this->assertEquals(
+            $addressPostal,
+            $syncService->getOrganizationPostalAddress($organization)
+        );
+
+        $organization2 = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->expects($this->once())
+            ->method('getOrganizationAddressPostals')
+            ->willReturn([]);
+
+        $this->assertEquals(
+            null,
+            $syncService->getOrganizationPostalAddress($organization2)
+        );
+    }
+    public function testGetOrganizationPhoneWithExistingPhone()
+    {
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $contactPoint1 = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $contactPoint2 = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $contactPoint3 = $this->getMockBuilder(ContactPoint::class)->getMock();
+
+        $contactPoint1->method('getContactType')->willReturn(ContactPointTypeEnum::OTHER()->getValue());
+        $contactPoint2->method('getContactType')->willReturn(ContactPointTypeEnum::BILL()->getValue());
+        $contactPoint3->method('getContactType')->willReturn(ContactPointTypeEnum::PRINCIPAL()->getValue());
+
+        $contactPoint2->expects($this->once())->method('getTelphone')->willReturn('0161626365');
+
+        $organization
+            ->expects($this->once())
+            ->method('getContactPoints')
+            ->willReturn(
+                [$contactPoint1, $contactPoint2, $contactPoint3]
+            );
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            '+331 61 62 63 65',
+            $syncService->getOrganizationPhone($organization)
+        );
+    }
+
+    public function testGetOrganizationPhoneWithMobilePhone() {
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $contactPoint1 = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $contactPoint2 = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $contactPoint3 = $this->getMockBuilder(ContactPoint::class)->getMock();
+
+        $contactPoint1->method('getContactType')->willReturn(ContactPointTypeEnum::OTHER()->getValue());
+        $contactPoint2->method('getContactType')->willReturn(ContactPointTypeEnum::BILL()->getValue());
+        $contactPoint3->method('getContactType')->willReturn(ContactPointTypeEnum::PRINCIPAL()->getValue());
+
+        $contactPoint2->expects($this->once())->method('getTelphone')->willReturn(null);
+        $contactPoint2->expects($this->once())->method('getMobilPhone')->willReturn('0661626365');
+
+        $organization
+            ->expects($this->once())
+            ->method('getContactPoints')
+            ->willReturn(
+                [$contactPoint1, $contactPoint2, $contactPoint3]
+            );
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            '+336 61 62 63 65',
+            $syncService->getOrganizationPhone($organization)
+        );
+    }
+
+    public function testGetOrganizationPhoneWithNoPhone() {
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization
+            ->expects($this->once())
+            ->method('getContactPoints')
+            ->willReturn([]);
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            null,
+            $syncService->getOrganizationPhone($organization)
+        );
+    }
+
+    public function testGetOrganizationEmailWithExistingEmail() {
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $contactPoint1 = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $contactPoint2 = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $contactPoint3 = $this->getMockBuilder(ContactPoint::class)->getMock();
+
+        $contactPoint1->method('getContactType')->willReturn(ContactPointTypeEnum::OTHER()->getValue());
+        $contactPoint2->method('getContactType')->willReturn(ContactPointTypeEnum::BILL()->getValue());
+        $contactPoint3->method('getContactType')->willReturn(ContactPointTypeEnum::PRINCIPAL()->getValue());
+
+        $contactPoint2->method('getEmail')->willReturn('email@email.com');
+
+        $organization
+            ->expects($this->once())
+            ->method('getContactPoints')
+            ->willReturn(
+                [$contactPoint1, $contactPoint2, $contactPoint3]
+            );
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            'email@email.com',
+            $syncService->getOrganizationEmail($organization)
+        );
+    }
+
+    public function testGetOrganizationEmailWithNoEmail() {
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $organization
+            ->expects($this->once())
+            ->method('getContactPoints')
+            ->willReturn([]);
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            null,
+            $syncService->getOrganizationEmail($organization)
+        );
+    }
+
+    public function testCountWithMission() {
+        $members = [
+            123 => [FunctionEnum::PRESIDENT()->getValue(), FunctionEnum::TEACHER()->getValue()],
+            124 => [FunctionEnum::TEACHER()->getValue()],
+            125 => [FunctionEnum::STUDENT()->getValue()],
+            126 => [FunctionEnum::TREASURER()->getValue()],
+        ];
+
+        $this->assertEquals(
+            2,
+            TestableDolibarrSyncService::countWithMission([FunctionEnum::TEACHER()->getValue()], $members)
+        );
+
+        $this->assertEquals(
+            3,
+            TestableDolibarrSyncService::countWithMission(
+                [FunctionEnum::TEACHER()->getValue(), FunctionEnum::TREASURER()->getValue()],
+                $members
+            )
+        );
+
+        $this->assertEquals(
+            1,
+            TestableDolibarrSyncService::countWithMission([FunctionEnum::STUDENT()->getValue()], $members)
+        );
+
+        $this->assertEquals(
+            0,
+            TestableDolibarrSyncService::countWithMission([FunctionEnum::ARCHIVIST()->getValue()], $members)
+        );
+    }
+
+    public function testGetPersonContact() {
+        $person = $this->getMockBuilder(Person::class)->getMock();
+
+        $contactPoint1 = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $contactPoint2 = $this->getMockBuilder(ContactPoint::class)->getMock();
+
+        $contactPoint1->method('getContactType')->willReturn(ContactPointTypeEnum::OTHER()->getValue());
+        $contactPoint2->method('getContactType')->willReturn(ContactPointTypeEnum::PRINCIPAL()->getValue());
+
+        $person->expects($this->once())->method('getContactPoints')->willReturn([$contactPoint1, $contactPoint2]);
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            $contactPoint2,
+            $syncService->getPersonContact($person)
+        );
+
+        $person2 = $this->getMockBuilder(Person::class)->getMock();
+        $person2->expects($this->once())->method('getContactPoints')->willReturn([]);
+        $this->assertEquals(null, $syncService->getPersonContact($person2));
+    }
+
+    public function testFormatContactPosition() {
+        $this->translator->method('trans')->will(
+            $this->returnCallback(function($mission, $params) {
+                if ($mission == FunctionEnum::PRESIDENT()) {
+                    if ($params === ['gender' => 'X']) { return 'Président(e)'; }
+                    elseif ($params === ['gender' => 'M']) { return 'Président'; }
+                    elseif ($params === ['gender' => 'F']) { return 'Présidente'; }
+                } elseif ($mission == FunctionEnum::DIRECTOR()) {
+                    if ($params === ['gender' => 'X']) { return 'Directeur(ice)'; }
+                    elseif ($params === ['gender' => 'M']) { return 'Directeur'; }
+                    elseif ($params === ['gender' => 'F']) { return 'Directrice'; }
+                }
+                throw new \AssertionError('translator->trans stub has no matching call for arguments ' . json_encode([$mission, $params]));
+              })
+        );
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            'Président(e)',
+            $syncService->formatContactPosition([FunctionEnum::PRESIDENT()->getValue()])
+        );
+
+        $this->assertEquals(
+            'Président',
+            $syncService->formatContactPosition([FunctionEnum::PRESIDENT()->getValue()], 'MISTER')
+        );
+
+        $this->assertEquals(
+            'Présidente',
+            $syncService->formatContactPosition([FunctionEnum::PRESIDENT()->getValue()], 'MISS')
+        );
+
+        $this->assertEquals(
+            'Présidente, Directrice',
+            $syncService->formatContactPosition(
+                [FunctionEnum::PRESIDENT()->getValue(), FunctionEnum::DIRECTOR()->getValue()],
+                'MISS'
+            )
+        );
+
+        $this->assertEquals(
+            'Président, Directeur',
+            $syncService->formatContactPosition(
+                [FunctionEnum::PRESIDENT()->getValue(), FunctionEnum::DIRECTOR()->getValue(), FunctionEnum::ADHERENT()->getValue()],
+                'MISTER'
+            )
+        );
+    }
+
+    public function testFormatPhoneNumber() {
+        $phoneNumber = new PhoneNumber();
+        $phoneNumber->setCountryCode(33);
+        $phoneNumber->setNationalNumber('1 02 03 04 05');
+
+        $this->assertEquals(
+            '+33 1 02 03 04 05',
+            TestableDolibarrSyncService::formatPhoneNumber($phoneNumber)
+        );
+    }
+
+    public function testFilterDiff() {
+        $this->assertEquals(
+            ['b' => -2, 'c' => ['e' => ['f' => -5]], 'g' => 7],
+            TestableDolibarrSyncService::filterDiff(
+                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
+                ['a' => 1, 'b' => -2, 'c' => ['d' => 4, 'e' => ['f' => -5]], 'g' => 7],
+            )
+        );
+
+        $this->assertEquals(
+            [],
+            TestableDolibarrSyncService::filterDiff(
+                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
+                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
+            )
+        );
+
+        $this->assertEquals(
+            [],
+            TestableDolibarrSyncService::filterDiff(
+                [],
+                [],
+            )
+        );
+
+        $this->assertEquals(
+            ['a' => 1],
+            TestableDolibarrSyncService::filterDiff(
+                [],
+                ['a' => 1],
+            )
+        );
+    }
+
+}

+ 442 - 0
tests/Service/Dolibarr/fixtures/contacts.json

@@ -0,0 +1,442 @@
+[
+  {
+    "civility_id": null,
+    "civility_code": "MME",
+    "civility": "Madame",
+    "address": null,
+    "zip": null,
+    "town": null,
+    "state_id": null,
+    "state_code": null,
+    "state": null,
+    "poste": "Secrétaire",
+    "socid": "8",
+    "statut": "1",
+    "code": null,
+    "email": "abcd@hotmail.com",
+    "no_email": null,
+    "skype": null,
+    "photo": null,
+    "jabberid": null,
+    "phone_pro": "+33478570000",
+    "phone_perso": "",
+    "phone_mobile": "+33682980000",
+    "fax": "",
+    "priv": "0",
+    "birthday": "",
+    "default_lang": null,
+    "ref_facturation": null,
+    "ref_contrat": null,
+    "ref_commande": null,
+    "ref_propal": null,
+    "user_id": null,
+    "user_login": null,
+    "roles": null,
+    "cacheprospectstatus": [],
+    "fk_prospectlevel": null,
+    "stcomm_id": "0",
+    "statut_commercial": "Jamais contacté",
+    "stcomm_picto": null,
+    "id": "5868",
+    "import_key": "crm",
+    "array_options": {
+      "options_rfltr_model_id": null,
+      "options_2iopen_person_id": "108939"
+    },
+    "linkedObjectsIds": null,
+    "canvas": null,
+    "fk_project": null,
+    "contact": null,
+    "contact_id": null,
+    "user": null,
+    "origin": null,
+    "origin_id": null,
+    "ref": "5868",
+    "ref_ext": null,
+    "country": "",
+    "country_id": "0",
+    "country_code": "",
+    "barcode_type": null,
+    "barcode_type_code": null,
+    "barcode_type_label": null,
+    "barcode_type_coder": null,
+    "mode_reglement_id": null,
+    "cond_reglement_id": null,
+    "transport_mode_id": null,
+    "cond_reglement": null,
+    "shipping_method_id": null,
+    "modelpdf": null,
+    "last_main_doc": null,
+    "fk_account": null,
+    "note_public": null,
+    "note_private": null,
+    "fk_incoterms": null,
+    "libelle_incoterms": null,
+    "location_incoterms": null,
+    "name": null,
+    "lastname": "DUPONT",
+    "firstname": "Valerie",
+    "date_creation": "",
+    "date_validation": null,
+    "date_modification": 1612541370,
+    "entity": "1",
+    "socname": "Société Musicale l'Abeille",
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "mail": "abcd@hotmail.com",
+    "gender": "woman"
+  },
+  {
+    "civility_id": null,
+    "civility_code": "MME",
+    "civility": "Madame",
+    "address": null,
+    "zip": null,
+    "town": null,
+    "state_id": null,
+    "state_code": null,
+    "state": null,
+    "poste": "Présidente",
+    "socid": "8",
+    "statut": "1",
+    "code": null,
+    "email": "xyz@sfr.fr",
+    "no_email": null,
+    "skype": null,
+    "photo": null,
+    "jabberid": null,
+    "phone_pro": "",
+    "phone_perso": "",
+    "phone_mobile": "+33600009399",
+    "fax": "",
+    "priv": "0",
+    "birthday": "",
+    "default_lang": null,
+    "ref_facturation": null,
+    "ref_contrat": null,
+    "ref_commande": null,
+    "ref_propal": null,
+    "user_id": null,
+    "user_login": null,
+    "roles": null,
+    "cacheprospectstatus": [],
+    "fk_prospectlevel": null,
+    "stcomm_id": "0",
+    "statut_commercial": "Jamais contact&eacute;",
+    "stcomm_picto": null,
+    "id": "5869",
+    "import_key": "crm",
+    "array_options": {
+      "options_rfltr_model_id": null,
+      "options_2iopen_person_id": "156252"
+    },
+    "linkedObjectsIds": null,
+    "canvas": null,
+    "fk_project": null,
+    "contact": null,
+    "contact_id": null,
+    "user": null,
+    "origin": null,
+    "origin_id": null,
+    "ref": "5869",
+    "ref_ext": null,
+    "country": "",
+    "country_id": "0",
+    "country_code": "",
+    "barcode_type": null,
+    "barcode_type_code": null,
+    "barcode_type_label": null,
+    "barcode_type_coder": null,
+    "mode_reglement_id": null,
+    "cond_reglement_id": null,
+    "transport_mode_id": null,
+    "cond_reglement": null,
+    "shipping_method_id": null,
+    "modelpdf": null,
+    "last_main_doc": null,
+    "fk_account": null,
+    "note_public": null,
+    "note_private": null,
+    "fk_incoterms": null,
+    "libelle_incoterms": null,
+    "location_incoterms": null,
+    "name": null,
+    "lastname": "DURAND",
+    "firstname": "Elise",
+    "date_creation": "",
+    "date_validation": null,
+    "date_modification": 1612541370,
+    "entity": "1",
+    "socname": "Société Musicale l'Abeille",
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "mail": "xyz@sfr.fr",
+    "gender": "woman"
+  },
+  {
+    "civility_id": null,
+    "civility_code": "MME",
+    "civility": "Madame",
+    "address": null,
+    "zip": null,
+    "town": null,
+    "state_id": null,
+    "state_code": null,
+    "state": null,
+    "poste": "Trésorière",
+    "socid": "9",
+    "statut": "1",
+    "code": null,
+    "email": null,
+    "no_email": null,
+    "skype": null,
+    "photo": null,
+    "jabberid": null,
+    "phone_pro": "",
+    "phone_perso": "",
+    "phone_mobile": "",
+    "fax": "",
+    "priv": "0",
+    "birthday": "",
+    "default_lang": null,
+    "ref_facturation": null,
+    "ref_contrat": null,
+    "ref_commande": null,
+    "ref_propal": null,
+    "user_id": null,
+    "user_login": null,
+    "roles": null,
+    "cacheprospectstatus": [],
+    "fk_prospectlevel": null,
+    "stcomm_id": "0",
+    "statut_commercial": "Jamais contact&eacute;",
+    "stcomm_picto": null,
+    "id": "5870",
+    "import_key": "crm",
+    "array_options": {
+      "options_rfltr_model_id": null,
+      "options_2iopen_person_id": "112775"
+    },
+    "linkedObjectsIds": null,
+    "canvas": null,
+    "fk_project": null,
+    "contact": null,
+    "contact_id": null,
+    "user": null,
+    "origin": null,
+    "origin_id": null,
+    "ref": "5870",
+    "ref_ext": null,
+    "country": "",
+    "country_id": "0",
+    "country_code": "",
+    "barcode_type": null,
+    "barcode_type_code": null,
+    "barcode_type_label": null,
+    "barcode_type_coder": null,
+    "mode_reglement_id": null,
+    "cond_reglement_id": null,
+    "transport_mode_id": null,
+    "cond_reglement": null,
+    "shipping_method_id": null,
+    "modelpdf": null,
+    "last_main_doc": null,
+    "fk_account": null,
+    "note_public": null,
+    "note_private": null,
+    "fk_incoterms": null,
+    "libelle_incoterms": null,
+    "location_incoterms": null,
+    "name": null,
+    "lastname": "LEGRAND",
+    "firstname": "Anaïs",
+    "date_creation": "",
+    "date_validation": null,
+    "date_modification": 1612541370,
+    "entity": "1",
+    "socname": "Ensemble à vents Opus 92",
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "mail": null,
+    "gender": "woman"
+  },
+  {
+    "civility_id": null,
+    "civility_code": "MME",
+    "civility": "Madame",
+    "address": null,
+    "zip": null,
+    "town": null,
+    "state_id": null,
+    "state_code": null,
+    "state": null,
+    "poste": "Secrétaire",
+    "socid": "9",
+    "statut": "1",
+    "code": null,
+    "email": null,
+    "no_email": null,
+    "skype": null,
+    "photo": null,
+    "jabberid": null,
+    "phone_pro": "",
+    "phone_perso": "",
+    "phone_mobile": "",
+    "fax": "",
+    "priv": "0",
+    "birthday": "",
+    "default_lang": null,
+    "ref_facturation": null,
+    "ref_contrat": null,
+    "ref_commande": null,
+    "ref_propal": null,
+    "user_id": null,
+    "user_login": null,
+    "roles": null,
+    "cacheprospectstatus": [],
+    "fk_prospectlevel": null,
+    "stcomm_id": "0",
+    "statut_commercial": "Jamais contact&eacute;",
+    "stcomm_picto": null,
+    "id": "5871",
+    "import_key": "crm",
+    "array_options": {
+      "options_rfltr_model_id": null,
+      "options_2iopen_person_id": "302117"
+    },
+    "linkedObjectsIds": null,
+    "canvas": null,
+    "fk_project": null,
+    "contact": null,
+    "contact_id": null,
+    "user": null,
+    "origin": null,
+    "origin_id": null,
+    "ref": "5871",
+    "ref_ext": null,
+    "country": "",
+    "country_id": "0",
+    "country_code": "",
+    "barcode_type": null,
+    "barcode_type_code": null,
+    "barcode_type_label": null,
+    "barcode_type_coder": null,
+    "mode_reglement_id": null,
+    "cond_reglement_id": null,
+    "transport_mode_id": null,
+    "cond_reglement": null,
+    "shipping_method_id": null,
+    "modelpdf": null,
+    "last_main_doc": null,
+    "fk_account": null,
+    "note_public": null,
+    "note_private": null,
+    "fk_incoterms": null,
+    "libelle_incoterms": null,
+    "location_incoterms": null,
+    "name": null,
+    "lastname": "LEJEUNE",
+    "firstname": "Cassandra",
+    "date_creation": "",
+    "date_validation": null,
+    "date_modification": 1612541370,
+    "entity": "1",
+    "socname": "Ensemble à vents Opus 92",
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "mail": null,
+    "gender": "woman"
+  },
+  {
+    "civility_id": null,
+    "civility_code": "MR",
+    "civility": "Monsieur",
+    "address": null,
+    "zip": null,
+    "town": null,
+    "state_id": null,
+    "state_code": null,
+    "state": null,
+    "poste": "Président",
+    "socid": "9",
+    "statut": "1",
+    "code": null,
+    "email": "email@gmail.com",
+    "no_email": null,
+    "skype": null,
+    "photo": null,
+    "jabberid": null,
+    "phone_pro": "",
+    "phone_perso": "",
+    "phone_mobile": "",
+    "fax": "",
+    "priv": "0",
+    "birthday": "",
+    "default_lang": null,
+    "ref_facturation": null,
+    "ref_contrat": null,
+    "ref_commande": null,
+    "ref_propal": null,
+    "user_id": null,
+    "user_login": null,
+    "roles": null,
+    "cacheprospectstatus": [],
+    "fk_prospectlevel": null,
+    "stcomm_id": "0",
+    "statut_commercial": "Jamais contact&eacute;",
+    "stcomm_picto": null,
+    "id": "5872",
+    "import_key": "crm",
+    "array_options": {
+      "options_rfltr_model_id": null,
+      "options_2iopen_person_id": "112792"
+    },
+    "linkedObjectsIds": null,
+    "canvas": null,
+    "fk_project": null,
+    "contact": null,
+    "contact_id": null,
+    "user": null,
+    "origin": null,
+    "origin_id": null,
+    "ref": "5872",
+    "ref_ext": null,
+    "country": "",
+    "country_id": "0",
+    "country_code": "",
+    "barcode_type": null,
+    "barcode_type_code": null,
+    "barcode_type_label": null,
+    "barcode_type_coder": null,
+    "mode_reglement_id": null,
+    "cond_reglement_id": null,
+    "transport_mode_id": null,
+    "cond_reglement": null,
+    "shipping_method_id": null,
+    "modelpdf": null,
+    "last_main_doc": null,
+    "fk_account": null,
+    "note_public": null,
+    "note_private": null,
+    "fk_incoterms": null,
+    "libelle_incoterms": null,
+    "location_incoterms": null,
+    "name": null,
+    "lastname": "ZORRO",
+    "firstname": "Fabrice",
+    "date_creation": "",
+    "date_validation": null,
+    "date_modification": 1612541370,
+    "entity": "1",
+    "socname": "Ensemble à vents Opus 92",
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "mail": "email@gmail.com",
+    "gender": "man"
+  }
+]

+ 667 - 0
tests/Service/Dolibarr/fixtures/thirdparties.json

@@ -0,0 +1,667 @@
+[
+  {
+    "entity": "1",
+    "name": "Société Musicale l'Abeille",
+    "name_alias": null,
+    "address": "\n4 rue Jean Moulin\n\n",
+    "zip": "69310",
+    "town": "PIERRE BENITE",
+    "status": "1",
+    "state_id": "76",
+    "state_code": "69",
+    "state": "Rhône",
+    "phone": "623936009",
+    "fax": null,
+    "email": "alexis.manasser@yahoo.fr",
+    "skype": null,
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "url": "http://abeille.wifeo.com/",
+    "barcode": null,
+    "idprof1": null,
+    "idprof2": null,
+    "idprof3": null,
+    "idprof4": null,
+    "idprof5": null,
+    "idprof6": null,
+    "tva_assuj": "1",
+    "tva_intra": null,
+    "localtax1_assuj": "0",
+    "localtax1_value": null,
+    "localtax2_assuj": "0",
+    "localtax2_value": null,
+    "managers": null,
+    "capital": null,
+    "typent_id": "0",
+    "typent_code": "TE_UNKNOWN",
+    "effectif": "",
+    "effectif_id": "0",
+    "forme_juridique_code": "92",
+    "forme_juridique": "Association loi 1901 ou assimilé",
+    "remise_percent": "",
+    "remise_supplier_percent": "0",
+    "mode_reglement_supplier_id": null,
+    "cond_reglement_supplier_id": null,
+    "transport_mode_supplier_id": null,
+    "fk_prospectlevel": null,
+    "date_modification": 1632728888,
+    "user_modification": null,
+    "date_creation": "",
+    "user_creation": null,
+    "specimen": null,
+    "client": "2",
+    "prospect": 0,
+    "fournisseur": "0",
+    "code_client": "000002",
+    "code_fournisseur": null,
+    "code_compta": "0000002",
+    "code_compta_client": null,
+    "code_compta_fournisseur": null,
+    "note_private": "Autres email(s): ",
+    "note_public": null,
+    "stcomm_id": "0",
+    "statut_commercial": "Never contacted",
+    "stcomm_picto": null,
+    "price_level": null,
+    "outstanding_limit": null,
+    "order_min_amount": null,
+    "supplier_order_min_amount": null,
+    "parent": "711",
+    "default_lang": null,
+    "ref": "8",
+    "ref_ext": "3c839cab-f46d-eddc-f2ef-543fb02978a2",
+    "import_key": "crm",
+    "webservices_url": null,
+    "webservices_key": null,
+    "logo": null,
+    "logo_small": null,
+    "logo_mini": null,
+    "logo_squarred": null,
+    "logo_squarred_small": null,
+    "logo_squarred_mini": null,
+    "accountancy_code_sell": null,
+    "accountancy_code_buy": null,
+    "array_options": {
+      "options_rfltr_model_id": null,
+      "options_2iopeninfoopentalent": null,
+      "options_sirene_status": null,
+      "options_b4d_spe_name": null,
+      "options_sirene_update_date": "",
+      "options_2iopen_domain": "7",
+      "options_2iopen_structure_type": "21",
+      "options_2iopen_nombre_eleves": null,
+      "options_2iopen_software_opentalent": null,
+      "options_2iopen_software_used": "1",
+      "options_2iopen_num_portable": null,
+      "options_2iopen_structure_type_cmf": null,
+      "options_2iopen_organization_id": "13878"
+    },
+    "fk_incoterms": null,
+    "location_incoterms": null,
+    "libelle_incoterms": null,
+    "fk_multicurrency": null,
+    "multicurrency_code": null,
+    "id": "8",
+    "linkedObjectsIds": null,
+    "canvas": null,
+    "fk_project": null,
+    "contact": null,
+    "contact_id": null,
+    "user": null,
+    "origin": null,
+    "origin_id": null,
+    "statut": null,
+    "country": "France",
+    "country_id": "1",
+    "country_code": "FR",
+    "barcode_type": null,
+    "barcode_type_code": null,
+    "barcode_type_label": null,
+    "barcode_type_coder": null,
+    "mode_reglement_id": "2",
+    "cond_reglement_id": "1",
+    "transport_mode_id": null,
+    "cond_reglement": null,
+    "shipping_method_id": null,
+    "modelpdf": null,
+    "last_main_doc": null,
+    "fk_account": "0",
+    "lastname": null,
+    "firstname": null,
+    "civility_id": null,
+    "date_validation": null
+  },
+  {
+    "entity": "1",
+    "name": "Ensemble à vents Opus 92",
+    "name_alias": null,
+    "address": "\n96 rue de la Sous Préfecture\n\n",
+    "zip": "69400",
+    "town": "VILLEFRANCHE-SUR-SAÔNE",
+    "status": "1",
+    "state_id": "76",
+    "state_code": "69",
+    "state": "Rhône",
+    "phone": "683283834",
+    "fax": null,
+    "email": "bureau@opus92.org",
+    "skype": null,
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "url": "http://www.opus92.org",
+    "barcode": null,
+    "idprof1": null,
+    "idprof2": null,
+    "idprof3": null,
+    "idprof4": null,
+    "idprof5": null,
+    "idprof6": null,
+    "tva_assuj": "1",
+    "tva_intra": null,
+    "localtax1_assuj": "0",
+    "localtax1_value": null,
+    "localtax2_assuj": "0",
+    "localtax2_value": null,
+    "managers": null,
+    "capital": null,
+    "typent_id": "0",
+    "typent_code": "TE_UNKNOWN",
+    "effectif": "",
+    "effectif_id": "0",
+    "forme_juridique_code": "92",
+    "forme_juridique": "Association loi 1901 ou assimilé",
+    "remise_percent": "",
+    "remise_supplier_percent": "0",
+    "mode_reglement_supplier_id": null,
+    "cond_reglement_supplier_id": null,
+    "transport_mode_supplier_id": null,
+    "fk_prospectlevel": null,
+    "date_modification": 1632728888,
+    "user_modification": null,
+    "date_creation": "",
+    "user_creation": null,
+    "specimen": null,
+    "client": "2",
+    "prospect": 0,
+    "fournisseur": "0",
+    "code_client": "000003",
+    "code_fournisseur": null,
+    "code_compta": "0000003",
+    "code_compta_client": null,
+    "code_compta_fournisseur": null,
+    "note_private": "Autres email(s): contact@opus92.org",
+    "note_public": null,
+    "stcomm_id": "0",
+    "statut_commercial": "Never contacted",
+    "stcomm_picto": null,
+    "price_level": null,
+    "outstanding_limit": null,
+    "order_min_amount": null,
+    "supplier_order_min_amount": null,
+    "parent": "711",
+    "default_lang": null,
+    "ref": "9",
+    "ref_ext": "7191614d-2d4c-8c25-17b9-543fb25fa130",
+    "import_key": "crm",
+    "webservices_url": null,
+    "webservices_key": null,
+    "logo": null,
+    "logo_small": null,
+    "logo_mini": null,
+    "logo_squarred": null,
+    "logo_squarred_small": null,
+    "logo_squarred_mini": null,
+    "accountancy_code_sell": null,
+    "accountancy_code_buy": null,
+    "array_options": {
+      "options_rfltr_model_id": null,
+      "options_2iopeninfoopentalent": null,
+      "options_sirene_status": null,
+      "options_b4d_spe_name": null,
+      "options_sirene_update_date": "",
+      "options_2iopen_domain": "7",
+      "options_2iopen_structure_type": "19",
+      "options_2iopen_nombre_eleves": null,
+      "options_2iopen_software_opentalent": null,
+      "options_2iopen_software_used": "1",
+      "options_2iopen_num_portable": null,
+      "options_2iopen_structure_type_cmf": null,
+      "options_2iopen_organization_id": "13891"
+    },
+    "fk_incoterms": null,
+    "location_incoterms": null,
+    "libelle_incoterms": null,
+    "fk_multicurrency": null,
+    "multicurrency_code": null,
+    "id": "9",
+    "linkedObjectsIds": null,
+    "canvas": null,
+    "fk_project": null,
+    "contact": null,
+    "contact_id": null,
+    "user": null,
+    "origin": null,
+    "origin_id": null,
+    "statut": null,
+    "country": "France",
+    "country_id": "1",
+    "country_code": "FR",
+    "barcode_type": null,
+    "barcode_type_code": null,
+    "barcode_type_label": null,
+    "barcode_type_coder": null,
+    "mode_reglement_id": "2",
+    "cond_reglement_id": "1",
+    "transport_mode_id": null,
+    "cond_reglement": null,
+    "shipping_method_id": null,
+    "modelpdf": null,
+    "last_main_doc": null,
+    "fk_account": "0",
+    "lastname": null,
+    "firstname": null,
+    "civility_id": null,
+    "date_validation": null
+  },
+  {
+    "entity": "1",
+    "name": "Association Musicale de Montanay",
+    "name_alias": null,
+    "address": "\n142 rue Centrale\n\n",
+    "zip": "69250",
+    "town": "MONTANAY",
+    "status": "1",
+    "state_id": "76",
+    "state_code": "69",
+    "state": "Rhône",
+    "phone": "04 78 00 73 64",
+    "fax": null,
+    "email": "gonnet-family@wanadoo.fr",
+    "skype": null,
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "url": "https://assocmusicalemontanay.wordpress.com/",
+    "barcode": null,
+    "idprof1": null,
+    "idprof2": null,
+    "idprof3": null,
+    "idprof4": null,
+    "idprof5": null,
+    "idprof6": null,
+    "tva_assuj": "1",
+    "tva_intra": null,
+    "localtax1_assuj": "0",
+    "localtax1_value": null,
+    "localtax2_assuj": "0",
+    "localtax2_value": null,
+    "managers": null,
+    "capital": null,
+    "typent_id": "0",
+    "typent_code": "TE_UNKNOWN",
+    "effectif": "",
+    "effectif_id": "0",
+    "forme_juridique_code": "92",
+    "forme_juridique": "Association loi 1901 ou assimilé",
+    "remise_percent": "",
+    "remise_supplier_percent": "0",
+    "mode_reglement_supplier_id": null,
+    "cond_reglement_supplier_id": null,
+    "transport_mode_supplier_id": null,
+    "fk_prospectlevel": null,
+    "date_modification": 1632728888,
+    "user_modification": null,
+    "date_creation": "",
+    "user_creation": null,
+    "specimen": null,
+    "client": "2",
+    "prospect": 0,
+    "fournisseur": "0",
+    "code_client": "000004",
+    "code_fournisseur": null,
+    "code_compta": "0000004",
+    "code_compta_client": null,
+    "code_compta_fournisseur": null,
+    "note_private": "Autres email(s): franckbrosse@aol.com, direction.amm@gmail.com",
+    "note_public": null,
+    "stcomm_id": "3",
+    "statut_commercial": "Contacted",
+    "stcomm_picto": null,
+    "price_level": null,
+    "outstanding_limit": null,
+    "order_min_amount": null,
+    "supplier_order_min_amount": null,
+    "parent": "711",
+    "default_lang": null,
+    "ref": "10",
+    "ref_ext": "7f92c92b-ffea-2753-c2bd-4d3a116f7d4e",
+    "import_key": "crm",
+    "webservices_url": null,
+    "webservices_key": null,
+    "logo": null,
+    "logo_small": null,
+    "logo_mini": null,
+    "logo_squarred": null,
+    "logo_squarred_small": null,
+    "logo_squarred_mini": null,
+    "accountancy_code_sell": null,
+    "accountancy_code_buy": null,
+    "array_options": {
+      "options_rfltr_model_id": null,
+      "options_2iopeninfoopentalent": null,
+      "options_sirene_status": null,
+      "options_b4d_spe_name": null,
+      "options_sirene_update_date": "",
+      "options_2iopen_domain": "7",
+      "options_2iopen_structure_type": "19",
+      "options_2iopen_nombre_eleves": "2",
+      "options_2iopen_software_opentalent": null,
+      "options_2iopen_software_used": "1",
+      "options_2iopen_num_portable": null,
+      "options_2iopen_structure_type_cmf": null,
+      "options_2iopen_organization_id": "13904"
+    },
+    "fk_incoterms": null,
+    "location_incoterms": null,
+    "libelle_incoterms": null,
+    "fk_multicurrency": null,
+    "multicurrency_code": null,
+    "id": "10",
+    "linkedObjectsIds": null,
+    "canvas": null,
+    "fk_project": null,
+    "contact": null,
+    "contact_id": null,
+    "user": null,
+    "origin": null,
+    "origin_id": null,
+    "statut": null,
+    "country": "France",
+    "country_id": "1",
+    "country_code": "FR",
+    "barcode_type": null,
+    "barcode_type_code": null,
+    "barcode_type_label": null,
+    "barcode_type_coder": null,
+    "mode_reglement_id": "2",
+    "cond_reglement_id": "1",
+    "transport_mode_id": null,
+    "cond_reglement": null,
+    "shipping_method_id": null,
+    "modelpdf": null,
+    "last_main_doc": null,
+    "fk_account": "0",
+    "lastname": null,
+    "firstname": null,
+    "civility_id": null,
+    "date_validation": null
+  },
+  {
+    "entity": "1",
+    "name": "Le Chant du Marmont",
+    "name_alias": null,
+    "address": "\nen Mairie\nRue des Gagères\n",
+    "zip": "01480",
+    "town": "FRANS",
+    "status": "1",
+    "state_id": "7",
+    "state_code": "01",
+    "state": "Ain",
+    "phone": "474607172",
+    "fax": null,
+    "email": "pat.mouchon@orange.fr",
+    "skype": null,
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "url": "http:/bffrans.freeheberg.com",
+    "barcode": null,
+    "idprof1": null,
+    "idprof2": null,
+    "idprof3": null,
+    "idprof4": null,
+    "idprof5": null,
+    "idprof6": null,
+    "tva_assuj": "1",
+    "tva_intra": null,
+    "localtax1_assuj": "0",
+    "localtax1_value": null,
+    "localtax2_assuj": "0",
+    "localtax2_value": null,
+    "managers": null,
+    "capital": null,
+    "typent_id": "0",
+    "typent_code": "TE_UNKNOWN",
+    "effectif": "",
+    "effectif_id": "0",
+    "forme_juridique_code": "92",
+    "forme_juridique": "Association loi 1901 ou assimilé",
+    "remise_percent": "",
+    "remise_supplier_percent": "0",
+    "mode_reglement_supplier_id": null,
+    "cond_reglement_supplier_id": null,
+    "transport_mode_supplier_id": null,
+    "fk_prospectlevel": null,
+    "date_modification": 1632728888,
+    "user_modification": null,
+    "date_creation": "",
+    "user_creation": null,
+    "specimen": null,
+    "client": "2",
+    "prospect": 0,
+    "fournisseur": "0",
+    "code_client": "000005",
+    "code_fournisseur": null,
+    "code_compta": "0000005",
+    "code_compta_client": null,
+    "code_compta_fournisseur": null,
+    "note_private": "Autres email(s): nuguet.roger@orange.fr",
+    "note_public": null,
+    "stcomm_id": "0",
+    "statut_commercial": "Never contacted",
+    "stcomm_picto": null,
+    "price_level": null,
+    "outstanding_limit": null,
+    "order_min_amount": null,
+    "supplier_order_min_amount": null,
+    "parent": "711",
+    "default_lang": null,
+    "ref": "11",
+    "ref_ext": "5001328e-ce5e-f1b2-ae9e-543fb29aea42",
+    "import_key": "crm",
+    "webservices_url": null,
+    "webservices_key": null,
+    "logo": null,
+    "logo_small": null,
+    "logo_mini": null,
+    "logo_squarred": null,
+    "logo_squarred_small": null,
+    "logo_squarred_mini": null,
+    "accountancy_code_sell": null,
+    "accountancy_code_buy": null,
+    "array_options": {
+      "options_rfltr_model_id": null,
+      "options_2iopeninfoopentalent": null,
+      "options_sirene_status": null,
+      "options_b4d_spe_name": null,
+      "options_sirene_update_date": "",
+      "options_2iopen_domain": "7",
+      "options_2iopen_structure_type": "19",
+      "options_2iopen_nombre_eleves": null,
+      "options_2iopen_software_opentalent": null,
+      "options_2iopen_software_used": "1",
+      "options_2iopen_num_portable": null,
+      "options_2iopen_structure_type_cmf": null,
+      "options_2iopen_organization_id": "13917"
+    },
+    "fk_incoterms": null,
+    "location_incoterms": null,
+    "libelle_incoterms": null,
+    "fk_multicurrency": null,
+    "multicurrency_code": null,
+    "id": "11",
+    "linkedObjectsIds": null,
+    "canvas": null,
+    "fk_project": null,
+    "contact": null,
+    "contact_id": null,
+    "user": null,
+    "origin": null,
+    "origin_id": null,
+    "statut": null,
+    "country": "France",
+    "country_id": "1",
+    "country_code": "FR",
+    "barcode_type": null,
+    "barcode_type_code": null,
+    "barcode_type_label": null,
+    "barcode_type_coder": null,
+    "mode_reglement_id": "2",
+    "cond_reglement_id": "1",
+    "transport_mode_id": null,
+    "cond_reglement": null,
+    "shipping_method_id": null,
+    "modelpdf": null,
+    "last_main_doc": null,
+    "fk_account": "0",
+    "lastname": null,
+    "firstname": null,
+    "civility_id": null,
+    "date_validation": null
+  },
+  {
+    "entity": "1",
+    "name": "Orchestre Symphonique de Villefranche",
+    "name_alias": null,
+    "address": "\n96 rue de la Sous Préfecture\n\n",
+    "zip": "69400",
+    "town": "VILLEFRANCHE-SUR-SAÔNE",
+    "status": "1",
+    "state_id": "76",
+    "state_code": "69",
+    "state": "Rhône",
+    "phone": "06.86.12.85.22",
+    "fax": null,
+    "email": "orchestre.osv@orange.fr",
+    "skype": null,
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "url": " ",
+    "barcode": null,
+    "idprof1": null,
+    "idprof2": null,
+    "idprof3": null,
+    "idprof4": null,
+    "idprof5": null,
+    "idprof6": null,
+    "tva_assuj": "1",
+    "tva_intra": null,
+    "localtax1_assuj": "0",
+    "localtax1_value": null,
+    "localtax2_assuj": "0",
+    "localtax2_value": null,
+    "managers": null,
+    "capital": null,
+    "typent_id": "0",
+    "typent_code": "TE_UNKNOWN",
+    "effectif": "",
+    "effectif_id": "0",
+    "forme_juridique_code": "92",
+    "forme_juridique": "Association loi 1901 ou assimilé",
+    "remise_percent": "",
+    "remise_supplier_percent": "0",
+    "mode_reglement_supplier_id": null,
+    "cond_reglement_supplier_id": null,
+    "transport_mode_supplier_id": null,
+    "fk_prospectlevel": null,
+    "date_modification": 1632728888,
+    "user_modification": null,
+    "date_creation": "",
+    "user_creation": null,
+    "specimen": null,
+    "client": "2",
+    "prospect": 0,
+    "fournisseur": "0",
+    "code_client": "000006",
+    "code_fournisseur": null,
+    "code_compta": "0000006",
+    "code_compta_client": null,
+    "code_compta_fournisseur": null,
+    "note_private": "Autres email(s): sebremont@orange.fr",
+    "note_public": null,
+    "stcomm_id": "0",
+    "statut_commercial": "Never contacted",
+    "stcomm_picto": null,
+    "price_level": null,
+    "outstanding_limit": null,
+    "order_min_amount": null,
+    "supplier_order_min_amount": null,
+    "parent": "711",
+    "default_lang": null,
+    "ref": "12",
+    "ref_ext": "8f83c66f-384c-98c8-94e5-543fb25fb8b6",
+    "import_key": "crm",
+    "webservices_url": null,
+    "webservices_key": null,
+    "logo": null,
+    "logo_small": null,
+    "logo_mini": null,
+    "logo_squarred": null,
+    "logo_squarred_small": null,
+    "logo_squarred_mini": null,
+    "accountancy_code_sell": null,
+    "accountancy_code_buy": null,
+    "array_options": {
+      "options_rfltr_model_id": null,
+      "options_2iopeninfoopentalent": null,
+      "options_sirene_status": null,
+      "options_b4d_spe_name": null,
+      "options_sirene_update_date": "",
+      "options_2iopen_domain": "7",
+      "options_2iopen_structure_type": "21",
+      "options_2iopen_nombre_eleves": null,
+      "options_2iopen_software_opentalent": null,
+      "options_2iopen_software_used": "1",
+      "options_2iopen_num_portable": null,
+      "options_2iopen_structure_type_cmf": null,
+      "options_2iopen_organization_id": "13930"
+    },
+    "fk_incoterms": null,
+    "location_incoterms": null,
+    "libelle_incoterms": null,
+    "fk_multicurrency": null,
+    "multicurrency_code": null,
+    "id": "12",
+    "linkedObjectsIds": null,
+    "canvas": null,
+    "fk_project": null,
+    "contact": null,
+    "contact_id": null,
+    "user": null,
+    "origin": null,
+    "origin_id": null,
+    "statut": null,
+    "country": "France",
+    "country_id": "1",
+    "country_code": "FR",
+    "barcode_type": null,
+    "barcode_type_code": null,
+    "barcode_type_label": null,
+    "barcode_type_coder": null,
+    "mode_reglement_id": "2",
+    "cond_reglement_id": "1",
+    "transport_mode_id": null,
+    "cond_reglement": null,
+    "shipping_method_id": null,
+    "modelpdf": null,
+    "last_main_doc": null,
+    "fk_account": "0",
+    "lastname": null,
+    "firstname": null,
+    "civility_id": null,
+    "date_validation": null
+  }
+]