Browse Source

Merge branch 'feature/sync_dolibarr' into develop

# Conflicts:
#	.env
#	composer.json
#	composer.lock
#	src/Repository/Access/AccessRepository.php
#	symfony.lock
Vincent GUFFON 3 years ago
parent
commit
6eb15be64b
55 changed files with 4875 additions and 153 deletions
  1. 13 3
      .env
  2. 4 0
      .env.ci
  3. 4 0
      .env.preprod
  4. 4 0
      .env.test
  5. 9 1
      composer.json
  6. 418 1
      composer.lock
  7. 75 7
      config/packages/dev/monolog.yaml
  8. 2 0
      config/packages/lock.yaml
  9. 3 0
      config/packages/mailer.yaml
  10. 3 3
      config/packages/prod/monolog.yaml
  11. 13 0
      config/packages/translation.yaml
  12. 12 0
      config/services.yaml
  13. 84 0
      doc/logging.md
  14. 94 0
      src/Commands/DolibarrSyncCommand.php
  15. 34 0
      src/Enum/Access/FunctionEnum.php
  16. 1 0
      src/Enum/Access/RoleEnum.php
  17. 4 0
      src/Enum/Core/ContactPointTypeEnum.php
  18. 6 0
      src/Enum/Organization/AddressPostalOrganizationTypeEnum.php
  19. 3 0
      src/Enum/Organization/OrganizationIdsEnum.php
  20. 13 0
      src/Enum/Organization/SettingsProductEnum.php
  21. 17 0
      src/Enum/Person/GenderEnum.php
  22. 22 0
      src/Repository/Access/AccessRepository.php
  23. 7 7
      src/Repository/Access/FunctionTypeRepository.php
  24. 1 0
      src/Repository/Access/OrganizationFunctionRepository.php
  25. 1 4
      src/Repository/Organization/OrganizationRepository.php
  26. 24 0
      src/Service/Core/AddressPostalUtils.php
  27. 4 4
      src/Service/Dolibarr/DolibarrAccountCreator.php
  28. 131 0
      src/Service/Dolibarr/DolibarrApiService.php
  29. 0 84
      src/Service/Dolibarr/DolibarrService.php
  30. 723 0
      src/Service/Dolibarr/DolibarrSyncService.php
  31. 1 1
      src/Service/Mobyt/MobytService.php
  32. 97 0
      src/Service/Rest/ApiRequestInterface.php
  33. 57 10
      src/Service/Rest/ApiRequestService.php
  34. 173 0
      src/Service/Rest/Operation/BaseRestOperation.php
  35. 71 0
      src/Service/Rest/Operation/CreateOperation.php
  36. 60 0
      src/Service/Rest/Operation/DeleteOperation.php
  37. 75 0
      src/Service/Rest/Operation/UpdateOperation.php
  38. 45 0
      src/Service/Utils/ArrayUtils.php
  39. 43 0
      symfony.lock
  40. 7 7
      tests/Service/Dolibarr/DolibarrAccountCreatorTest.php
  41. 11 11
      tests/Service/Dolibarr/DolibarrApiServiceTest.php
  42. 725 0
      tests/Service/Dolibarr/DolibarrSyncServiceTest.php
  43. 442 0
      tests/Service/Dolibarr/fixtures/contacts.json
  44. 798 0
      tests/Service/Dolibarr/fixtures/thirdparties.json
  45. 3 1
      tests/Service/Dolibarr/fixtures/thirdparty.json
  46. 8 9
      tests/Service/Rest/ApiRequestServiceTest.php
  47. 126 0
      tests/Service/Rest/Operation/BaseRestOperationTest.php
  48. 42 0
      tests/Service/Rest/Operation/CreateOperationTest.php
  49. 36 0
      tests/Service/Rest/Operation/DeleteOperationTest.php
  50. 48 0
      tests/Service/Rest/Operation/UpdateOperationTest.php
  51. 79 0
      tests/Service/Utils/ArrayUtilsTest.php
  52. 192 0
      translations/enum/missions/messages+intl-icu.fr.yaml
  53. 2 0
      translations/enum/person/gender/messages+intl-icu.fr.yaml
  54. 2 0
      translations/messages+intl-icu.fr.yaml
  55. 3 0
      translations/services/dolibarr-sync/messages+intl-icu.fr.yaml

+ 13 - 3
.env

@@ -47,12 +47,12 @@ OPENTALENT_CONFIG=/config/opentalent
 ###< opentalent config folder ###
 
 ###> dolibarr client ###
-DOLIBARR_API_BASE_URI='https://prod-erp.2iopenservice.com/api/index.php/'
-DOLIBARR_API_TOKEN='Bocc4zC0J186v8J6QCqu7DnoIw4I7mCJ'
+DOLIBARR_API_BASE_URI=https://prod-erp.2iopenservice.com/api/index.php/
+DOLIBARR_API_TOKEN=Bocc4zC0J186v8J6QCqu7DnoIw4I7mCJ
 ###< dolibarr client ###
 
 ###> mobyt client ###
-MOBYT_API_BASE_URI='https://app.mobyt.fr/API/v1.0/REST/'
+MOBYT_API_BASE_URI=https://app.mobyt.fr/API/v1.0/REST/
 ###< mobyt client ###
 
 ###> knplabs/knp-snappy-bundle ###
@@ -70,3 +70,13 @@ MESSENGER_TRANSPORT_DSN=doctrine://default
 ###> AdminAssos configuration ###
 #DATABASE_ADMINASSOS_URL=mysql://root:mysql660@db:3306/adminassos?serverVersion=5.7
 ###< AdminAssos configuration ###
+
+###> symfony/lock ###
+# Choose one of the stores below
+# postgresql+advisory://db_user:db_password@localhost/db_name
+LOCK_DSN=semaphore
+###< symfony/lock ###
+
+###> symfony/mailer ###
+MAILER_DSN=smtp://localhost
+###< symfony/mailer ###

+ 4 - 0
.env.ci

@@ -26,3 +26,7 @@ BLACKFIRE_SERVER_TOKEN=dbd1cfbea015fe83cccfc189a36ca3c16f3a1b43b94f50032a15e41e5
 # >>> No database shall be needed by unit tests
 DATABASE_ADMINASSOS_URL=mysql://root:xxx@preprod:3306/none?serverVersion=5.7
 ###< AdminAssos configuration ###
+
+###> dolibarr client ###
+DOLIBARR_API_BASE_URI='https://dev-erp.2iopenservice.com/api/index.php/'
+###< dolibarr client ###

+ 4 - 0
.env.preprod

@@ -26,3 +26,7 @@ BLACKFIRE_SERVER_TOKEN=dbd1cfbea015fe83cccfc189a36ca3c16f3a1b43b94f50032a15e41e5
 ###> AdminAssos configuration ###
 DATABASE_ADMINASSOS_URL=mysql://root:mysql2iopenservice369566@preprod:3306/adminassos?serverVersion=5.7
 ###< AdminAssos configuration ###
+
+###> dolibarr client ###
+DOLIBARR_API_BASE_URI=https://dev-erp.2iopenservice.com/api/index.php/
+###< dolibarr client ###

+ 4 - 0
.env.test

@@ -3,3 +3,7 @@ KERNEL_CLASS='App\Kernel'
 APP_SECRET='$ecretf0rt3st'
 SYMFONY_DEPRECATIONS_HELPER=999999
 PANTHER_APP_ENV=panther
+
+###> dolibarr client ###
+DOLIBARR_API_BASE_URI='https://dev-erp.2iopenservice.com/api/index.php/'
+###< dolibarr client ###

+ 9 - 1
composer.json

@@ -45,6 +45,10 @@
         "symfony/twig-bundle": "^5.4",
         "symfony/validator": "5.4.*",
         "symfony/yaml": "5.4.*",
+        "symfony/lock": "5.4.*",
+        "symfony/mailer": "^5.4",
+        "symfony/polyfill-intl-messageformatter": "^1.24",
+        "symfony/translation": "5.4.*",
         "vincent/foselastica": "1.2",
         "webonyx/graphql-php": "^14.3"
     },
@@ -61,7 +65,11 @@
         "preferred-install": {
             "*": "dist"
         },
-        "sort-packages": true
+        "sort-packages": true,
+        "allow-plugins": {
+            "cyclonedx/cyclonedx-php-composer": true,
+            "symfony/flex": true
+        }
     },
     "autoload": {
         "psr-4": {

+ 418 - 1
composer.lock

@@ -5911,6 +5911,244 @@
             ],
             "time": "2022-01-12T18:55:10+00:00"
         },
+        {
+            "name": "symfony/lock",
+            "version": "v5.3.14",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/lock.git",
+                "reference": "0f498d4b90b2a52a063243c461cd0dc5b3bab758"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/lock/zipball/0f498d4b90b2a52a063243c461cd0dc5b3bab758",
+                "reference": "0f498d4b90b2a52a063243c461cd0dc5b3bab758",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "psr/log": "^1|^2|^3",
+                "symfony/deprecation-contracts": "^2.1",
+                "symfony/polyfill-php80": "^1.16"
+            },
+            "conflict": {
+                "doctrine/dbal": "<2.10"
+            },
+            "require-dev": {
+                "doctrine/dbal": "^2.10|^3.0",
+                "predis/predis": "~1.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Lock\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jérémy Derussé",
+                    "email": "jeremy@derusse.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Creates and manages locks, a mechanism to provide exclusive access to a shared resource",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "cas",
+                "flock",
+                "locking",
+                "mutex",
+                "redlock",
+                "semaphore"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/lock/tree/v5.3.14"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-01-02T09:51:59+00:00"
+        },
+        {
+            "name": "symfony/mailer",
+            "version": "v5.4.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/mailer.git",
+                "reference": "f6e927ec95c957131e6b2c78790e1a6d4c576447"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/mailer/zipball/f6e927ec95c957131e6b2c78790e1a6d4c576447",
+                "reference": "f6e927ec95c957131e6b2c78790e1a6d4c576447",
+                "shasum": ""
+            },
+            "require": {
+                "egulias/email-validator": "^2.1.10|^3",
+                "php": ">=7.2.5",
+                "psr/event-dispatcher": "^1",
+                "psr/log": "^1|^2|^3",
+                "symfony/deprecation-contracts": "^2.1|^3",
+                "symfony/event-dispatcher": "^4.4|^5.0|^6.0",
+                "symfony/mime": "^5.2.6|^6.0",
+                "symfony/polyfill-php80": "^1.16",
+                "symfony/service-contracts": "^1.1|^2|^3"
+            },
+            "conflict": {
+                "symfony/http-kernel": "<4.4"
+            },
+            "require-dev": {
+                "symfony/http-client-contracts": "^1.1|^2|^3",
+                "symfony/messenger": "^4.4|^5.0|^6.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Mailer\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Helps sending emails",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/mailer/tree/v5.4.5"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-02-25T10:48:33+00:00"
+        },
+        {
+            "name": "symfony/mime",
+            "version": "v5.3.14",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/mime.git",
+                "reference": "2769b338f999a7c53a88e3c124a3d69d7d3feb49"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/mime/zipball/2769b338f999a7c53a88e3c124a3d69d7d3feb49",
+                "reference": "2769b338f999a7c53a88e3c124a3d69d7d3feb49",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1",
+                "symfony/polyfill-intl-idn": "^1.10",
+                "symfony/polyfill-mbstring": "^1.0",
+                "symfony/polyfill-php80": "^1.16"
+            },
+            "conflict": {
+                "egulias/email-validator": "~3.0.0",
+                "phpdocumentor/reflection-docblock": "<3.2.2",
+                "phpdocumentor/type-resolver": "<1.4.0",
+                "symfony/mailer": "<4.4"
+            },
+            "require-dev": {
+                "egulias/email-validator": "^2.1.10|^3.1",
+                "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
+                "symfony/dependency-injection": "^4.4|^5.0",
+                "symfony/property-access": "^4.4|^5.1",
+                "symfony/property-info": "^4.4|^5.1",
+                "symfony/serializer": "^5.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Mime\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Allows manipulating MIME messages",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "mime",
+                "mime-type"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/mime/tree/v5.3.14"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-01-02T09:51:59+00:00"
+        },
         {
             "name": "symfony/monolog-bridge",
             "version": "v5.4.3",
@@ -6317,6 +6555,90 @@
             ],
             "time": "2021-09-14T14:02:44+00:00"
         },
+        {
+            "name": "symfony/polyfill-intl-messageformatter",
+            "version": "v1.24.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-messageformatter.git",
+                "reference": "22c4bba53bfadde90a4c1b32088e720638a42a83"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-messageformatter/zipball/22c4bba53bfadde90a4c1b32088e720638a42a83",
+                "reference": "22c4bba53bfadde90a4c1b32088e720638a42a83",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\MessageFormatter\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's MessageFormatter class and related functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "intl",
+                "messageformatter",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-messageformatter/tree/v1.24.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-02-19T12:13:01+00:00"
+        },
         {
             "name": "symfony/polyfill-intl-normalizer",
             "version": "v1.24.0",
@@ -7868,6 +8190,101 @@
             ],
             "time": "2022-01-02T09:53:40+00:00"
         },
+        {
+            "name": "symfony/translation",
+            "version": "v5.3.14",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/translation.git",
+                "reference": "945066809dc18f6e26123098e1b6e1d7a948660b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/translation/zipball/945066809dc18f6e26123098e1b6e1d7a948660b",
+                "reference": "945066809dc18f6e26123098e1b6e1d7a948660b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "symfony/deprecation-contracts": "^2.1",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/polyfill-php80": "^1.16",
+                "symfony/translation-contracts": "^2.3"
+            },
+            "conflict": {
+                "symfony/config": "<4.4",
+                "symfony/dependency-injection": "<5.0",
+                "symfony/http-kernel": "<5.0",
+                "symfony/twig-bundle": "<5.0",
+                "symfony/yaml": "<4.4"
+            },
+            "provide": {
+                "symfony/translation-implementation": "2.3"
+            },
+            "require-dev": {
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^4.4|^5.0",
+                "symfony/console": "^4.4|^5.0",
+                "symfony/dependency-injection": "^5.0",
+                "symfony/finder": "^4.4|^5.0",
+                "symfony/http-kernel": "^5.0",
+                "symfony/intl": "^4.4|^5.0",
+                "symfony/polyfill-intl-icu": "^1.21",
+                "symfony/service-contracts": "^1.1.2|^2",
+                "symfony/yaml": "^4.4|^5.0"
+            },
+            "suggest": {
+                "psr/log-implementation": "To use logging capability in translator",
+                "symfony/config": "",
+                "symfony/yaml": ""
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "Resources/functions.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Component\\Translation\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides tools to internationalize your application",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/translation/tree/v5.3.14"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-01-03T19:49:08+00:00"
+        },
         {
             "name": "symfony/translation-contracts",
             "version": "v2.5.0",
@@ -9692,5 +10109,5 @@
         "ext-iconv": "*"
     },
     "platform-dev": [],
-    "plugin-api-version": "2.1.0"
+    "plugin-api-version": "2.2.0"
 }

+ 75 - 7
config/packages/dev/monolog.yaml

@@ -1,10 +1,82 @@
+# Voir doc/logging.md
 monolog:
     handlers:
-        main:
+        # sorties standards (stdout, stderr, console)
+        stderr:
             type: stream
-            path: "%kernel.logs_dir%/%kernel.environment%.log"
+            path: php://stderr
+            level: error
+            channels: ["!event", "!doctrine"]
+        console:
+            type: console
+            process_psr_3_messages: false
+            level: debug
+            channels: ["!event", "!doctrine", "!console"]
+
+        # email en cas d'erreurs critiques, sauf erreurs 404 / 405
+#        critical:
+#            type: fingers_crossed
+#            action_level: critical
+#            excluded_http_codes: [ 404, 405 ]
+#            handler: deduplicated
+#        deduplicated:
+#            type: deduplication
+#            handler: mailer
+#        mailer:
+#            type: symfony_mailer
+#            from_email: "process@opentalent.fr"
+#            to_email: "exploitation@opentalent.fr"
+#            subject: AP2I - Critical Error Occurred
+#            level: critical
+#            formatter: monolog.formatter.html
+#            content_type: text/html
+
+        # logging fichier
+        file_main:
+            type: rotating_file
+            path: "%kernel.logs_dir%/%kernel.environment%.main.log"
+            level: debug
+            max_files: 3
+            channels: [php, doctrine, http_client, elastica]
+        file_auth:
+            type: rotating_file
+            path: "%kernel.logs_dir%/%kernel.environment%.auth.log"
             level: debug
-            channels: ["!event"]
+            max_files: 3
+            channels: security
+
+        # logs spécifiques à certains process
+        # * synchro dolibarr
+        dolibarrsync:
+            type: group
+            members: [dolibarrsync_file]
+            channels: dolibarrsync
+        dolibarrsync_file:
+            type: rotating_file
+            path: "%kernel.logs_dir%/%kernel.environment%.dolibarrsync.log"
+            level: debug
+            max_files: 7
+            formatter: monolog.formatter.message
+
+#        dolibarrsync_critical:
+#            type:           fingers_crossed
+#            action_level:   critical
+#            handler:        dolibarrsync_deduplicated
+#        dolibarrsync_deduplicated:
+#            type: deduplication
+#            # the time in seconds during which duplicate entries are discarded (default: 60)
+#            time: 10
+#            handler: dolibarrsync_mailer
+#        dolibarrsync_mailer:
+#            type:           symfony_mailer
+#            from_email:     "process@opentalent.fr"
+#            to_email:       "exploitation@opentalent.fr"
+#            subject:        "Dolibarr Sync - Critical Error"
+#            level:          error
+#            formatter:      monolog.formatter.html
+#            content_type:   text/html
+
+
         # uncomment to get logging in your browser
         # you may have to allow bigger header sizes in your Web server configuration
         #firephp:
@@ -13,7 +85,3 @@ monolog:
         #chromephp:
         #    type: chromephp
         #    level: info
-        console:
-            type: console
-            process_psr_3_messages: false
-            channels: ["!event", "!doctrine", "!console"]

+ 2 - 0
config/packages/lock.yaml

@@ -0,0 +1,2 @@
+framework:
+    lock: '%env(LOCK_DSN)%'

+ 3 - 0
config/packages/mailer.yaml

@@ -0,0 +1,3 @@
+framework:
+    mailer:
+        dsn: '%env(MAILER_DSN)%'

+ 3 - 3
config/packages/prod/monolog.yaml

@@ -3,12 +3,12 @@ monolog:
         main:
             type: fingers_crossed
             action_level: error
-            handler: nested
+            handler: nested_stderr
             excluded_http_codes: [404, 405]
             buffer_size: 50 # How many messages should be saved? Prevent memory leaks
-        nested:
+        nested_stderr:
             type: stream
-            path: php://stderr
+            path: php://stderr  # Handled by the apache logging system
             level: debug
             formatter: monolog.formatter.json
         console:

+ 13 - 0
config/packages/translation.yaml

@@ -0,0 +1,13 @@
+framework:
+    default_locale: en
+    translator:
+        default_path: '%kernel.project_dir%/translations'
+        fallbacks:
+            - fr
+#        providers:
+#            crowdin:
+#                dsn: '%env(CROWDIN_DSN)%'
+#            loco:
+#                dsn: '%env(LOCO_DSN)%'
+#            lokalise:
+#                dsn: '%env(LOKALISE_DSN)%'

+ 12 - 0
config/services.yaml

@@ -11,6 +11,13 @@ services:
             $opentalentConfig: '%kernel.project_dir%%env(OPENTALENT_CONFIG)%'
             $internalFilesUploadUri: '%env(INTERNAL_FILES_DOWNLOAD_URI)%'
 
+    # Logging: a shorter version of the default monolog line formatter
+    monolog.formatter.message:
+        class: Monolog\Formatter\LineFormatter
+        arguments:
+            - "[%%datetime%%] %%level_name%% : %%message%%\n"
+            - "Y-m-d H:i:s.v"
+
     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name
     App\:
@@ -53,6 +60,11 @@ services:
     App\Service\ServiceIterator\EncoderIterator:
         - !tagged_iterator app.encoder
 
+
+    App\Service\Dolibarr\DolibarrSyncService:
+        tags:
+            - { name: monolog.logger, channel: dolibarrsync }
+
     #########################################
     ##  SERIALIZER Decorates ##
     App\Serializer\DefaultNormalizer:

+ 84 - 0
doc/logging.md

@@ -0,0 +1,84 @@
+# Logging
+
+Le logging s'effectue avec monolog: <https://symfony.com/doc/5.4/logging.html>
+
+Les logs sont traités par channels (ex : security, doctrine, http...)
+On peut consulter la liste des channels actifs avec la commande :
+
+     ./bin/console debug:container monolog.log
+
+Pour enregistrer un nouveau canal, on configure le service dans `config/services.yaml`:
+
+    App\Service\MyService:
+        tags:
+            - { name: monolog.logger, channel: my_channel }
+
+
+## Canaux et traitements
+
+### Principe général
+
+Certains handlers sont communs à différents canaux : stderr, console, critical
+On se contente alors d'exclure les canaux non pertinents avec la syntaxe suivante:
+
+    channels: ["!event", "!doctrine"]
+
+
+D'autres sont plus spécifiques, comme les différents `rotating_files`. On définit un ou plusieurs canaux
+concernés :
+
+        file_main:
+            type: rotating_file
+            [...]
+            channels: ["logger", "php", "doctrine", "http_client", "elastica"]
+        file_auth:
+            type: rotating_file
+            [...]
+            channels: security
+
+Enfin, d'autres sont vraiment dédiés à des process particuliers, comme des opérations d'export ou de 
+synchronisation.
+
+
+#### stderr
+
+Tous les loggers (exceptés `event` et `event`) transmettent les messages de niveau ERROR ou plus vers la sortie 
+d'erreur stderr de php. Le stream est ensuite géré par le serveur apache, nginx ou autre.
+
+#### console
+
+La console associe automatiquement les niveaux de log avec la verbosité attendue :
+
+| LoggerInterface | Verbosity | Command line |
+| --- | --- | --- |
+| ->error() | OutputInterface::VERBOSITY_QUIET | stderr |
+| ->warning() | OutputInterface::VERBOSITY_NORMAL | stdout |
+| ->notice() | OutputInterface::VERBOSITY_VERBOSE | -v |
+| ->info() | OutputInterface::VERBOSITY_VERY_VERBOSE | -vv |
+| ->debug()  | OutputInterface::VERBOSITY_DEBUG  | -vvv |
+
+
+> Voir: https://symfony.com/doc/current/logging/monolog_console.html
+
+#### rotating_files (main, et particuliers)
+
+Les canaux natifs ou custom sont par défaut gérés par un handler de type rotating_file qui logue les messages 
+dans `var/log/{env}.main.log`
+
+Certains canaux sont gérés par un handler particulier et logués dans des fichiers spécifiques. Par ex, 
+le canal `security` est redirigé vers `var/log/{env}.auth.log`
+
+#### critical
+
+En cas d'erreur de niveau critical ou supérieur, un handler de type finger_crossed passe les logs à un handler 
+de type `mailer` (par l'intermédiaire d'un handler `deduplicated` qui évitera les lignes en doublon).
+
+Le handler `mailer` envoie ensuite un mail à l'exploitation.
+
+
+
+
+
+
+
+

+ 94 - 0
src/Commands/DolibarrSyncCommand.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace App\Commands;
+
+use App\Service\Dolibarr\DolibarrSyncService;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Command\LockableTrait;
+use Symfony\Component\Console\Helper\ProgressBar;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+#[AsCommand(
+    name: 'opentalent:dolibarr-sync',
+    description: 'Push the latest data from the Opentalent DB to dolibarr'
+)]
+class DolibarrSyncCommand extends Command
+{
+    use LockableTrait;
+
+    /**
+     * How many operations are shown each time the preview choice is made
+     */
+    const PREVIEW_CHUNK = 20;
+
+    public function __construct(
+        private DolibarrSyncService $dolibarrSyncService
+    ) {
+        parent::__construct();
+    }
+
+    protected function configure()
+    {
+        $this->addOption(
+            'preview',
+            'p',
+            InputOption::VALUE_NONE,
+            'Only preview the sync operations instead of executing it'
+        );
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        if (!$this->lock()) {
+            $output->writeln('The command is already running in another process.');
+            return Command::SUCCESS;
+        }
+
+        $output->writeln("Start the synchronization");
+        $t0 = microtime(true);
+        $output->writeln("Scanning...");
+
+        $progressBar = new ProgressBar($output, 0);
+        $progressCallback = function($i, $total) use ($progressBar) {
+            if (!$progressBar->getMaxSteps() !== $total) {
+                $progressBar->setMaxSteps($total);
+            }
+            $progressBar->setProgress($i);
+        };
+
+        $operations = $this->dolibarrSyncService->scan($progressCallback);
+
+        $t1 = microtime(true);
+        $output->writeln("Scan lasted " . ($t1 - $t0) . " sec.");
+
+        $output->writeln(count($operations) . " operations to be executed");
+
+        if ($input->getOption('preview')) {
+            $output->writeln("-- Preview --");
+            foreach ($operations as $i => $iValue) {
+                $output->writeln($i . '. ' . $iValue->getLabel());
+                foreach ($iValue->getChangeLog() as $message) {
+                    $output->writeln('   ' . $message);
+                }
+            }
+        } else {
+            $t0 = microtime(true);
+            $output->writeln("Executing...");
+
+            $operations = $this->dolibarrSyncService->execute($operations, $progressCallback);
+
+            $successes = count(array_filter($operations, function ($o) { return $o->getStatus() === $o::STATUS_DONE; } ));
+            $errors = count(array_filter($operations, function ($o) { return $o->getStatus() === $o::STATUS_ERROR; } ));
+            $output->writeln($successes . " operations successfully executed");
+            $output->writeln($errors . " errors");
+
+            $t1 = microtime(true);
+            $output->writeln("Execution lasted " . ($t1 - $t0) . " sec.");
+        }
+
+        return Command::SUCCESS;
+    }
+}

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

@@ -7,6 +7,16 @@ use MyCLabs\Enum\Enum;
 
 /**
  * Activities functions.
+ * @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()
+ * @method static MUSIC_DIRECTOR_AND_HEAD()
  */
 class FunctionEnum extends Enum
 {
@@ -98,6 +108,30 @@ class FunctionEnum extends Enum
             self::MUSIC_DIRECTOR_AND_HEAD
         ];
     }
+
+    /**
+     * Office missions
+     *
+     * Used by the DolibarrSyncService
+     */
+    public static function getOfficeMissions(): array
+    {
+        return [
+            self::PRESIDENT,
+            self::PRESIDENT_ASSISTANT,
+            self::DIRECTOR,
+            self::DIRECTOR_ASSISTANT,
+            self::SECRETARY,
+            self::ASSISTANT_SECRETARY,
+            self::TREASURER,
+            self::TREASURER_ASSISTANT,
+            self::ADMINISTRATIVE_OFFICER,
+            self::ADMINISTRATIVE_SECRETARY,
+            self::ADMINISTRATIVE_DIRECTOR,
+            self::ADMINISTRATIVE_DIRECTOR_ASSISTANT,
+            self::ADMINISTRATIVE_STAFF
+        ];
+    }
 }
 
 

+ 1 - 0
src/Enum/Access/RoleEnum.php

@@ -7,6 +7,7 @@ use MyCLabs\Enum\Enum;
 
 /**
  * Role
+ * @method static ROLE_ADMIN()
  */
 class RoleEnum extends Enum
 {

+ 4 - 0
src/Enum/Core/ContactPointTypeEnum.php

@@ -8,6 +8,10 @@ use MyCLabs\Enum\Enum;
 /**
  * Type de point de contact
  *
+ * @method static BILL()
+ * @method static CONTACT()
+ * @method static PRINCIPAL()
+ * @method static OTHER()
  */
 class ContactPointTypeEnum extends Enum
 {

+ 6 - 0
src/Enum/Organization/AddressPostalOrganizationTypeEnum.php

@@ -7,6 +7,12 @@ use MyCLabs\Enum\Enum;
 
 /**
  * Type d'adresse postale pour une organization
+ * @method static ADDRESS_BILL()
+ * @method static ADDRESS_CONTACT()
+ * @method static ADDRESS_HEAD_OFFICE()
+ * @method static ADDRESS_PRACTICE()
+ * @method static ADDRESS_OTHER()
+ * @method static ADDRESS_PRINCIPAL()
  */
 class AddressPostalOrganizationTypeEnum extends Enum
 {

+ 3 - 0
src/Enum/Organization/OrganizationIdsEnum.php

@@ -7,10 +7,13 @@ use MyCLabs\Enum\Enum;
 
 /**
  * Id de structure spécifiques
+ * @method static CMF()
+ * @method static FFEC()
  */
 class OrganizationIdsEnum extends Enum
 {
     private const CMF     = 12097;
     private const _2IOS   = 32366;
+    private const FFEC    = 91295;
     private const OPENTALENT_BASE   = 13;
 }

+ 13 - 0
src/Enum/Organization/SettingsProductEnum.php

@@ -7,6 +7,7 @@ use MyCLabs\Enum\Enum;
 
 /**
  * Type de produit disponible pour une organisation
+ * @method static SCHOOL()
  */
 class SettingsProductEnum extends Enum
 {
@@ -16,4 +17,16 @@ class SettingsProductEnum extends Enum
     private const SCHOOL_PREMIUM = 'school-premium';
     private const MANAGER = 'manager';
     private const MANAGER_PREMIUM = 'manager-premium';
+
+    public static function isArtist(string $product): bool {
+        return $product === self::ARTIST || $product === self::ARTIST_PREMIUM;
+    }
+
+    public static function isSchool(string $product): bool {
+        return $product === self::SCHOOL || $product === self::SCHOOL_PREMIUM;
+    }
+
+    public static function isManager(string $product): bool {
+        return $product === self::MANAGER || $product === self::MANAGER_PREMIUM;
+    }
 }

+ 17 - 0
src/Enum/Person/GenderEnum.php

@@ -0,0 +1,17 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Enum\Person;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * Genres des personnes
+ * @method static MISS()
+ * @method static MISTER()
+ */
+class GenderEnum extends Enum
+{
+    private const MISTER = 'MISTER';
+    private const MISS = 'MISS';
+}

+ 22 - 0
src/Repository/Access/AccessRepository.php

@@ -146,4 +146,26 @@ class AccessRepository extends ServiceEntityRepository implements UserLoaderInte
 
         return $qb->getQuery()->getResult();
     }
+
+    /**
+     * Get all the currently active accesses and return an array
+     * of the form ['id' => $accessId, 'organization_id' => $organizationId, 'mission' => $mission]
+     *
+     * Used by App\Service\Dolibarr\DolibarrSync\DolibarrSyncService
+     *
+     * @return array
+     */
+    public function getAllActiveMembersAndMissions(): array
+    {
+        $qb = $this->createQueryBuilder('access');
+        $qb
+            ->select('access.id', 'organization.id as organization_id', 'function_type.mission')
+            ->innerJoin('access.organization', 'organization')
+            ->innerJoin('access.organizationFunction', 'organization_function')
+            ->innerJoin('organization_function.functionType', 'function_type')
+        ;
+        DateConditions::addDateInPeriodCondition($qb, 'organization_function', date('Y-m-d'));
+
+        return $qb->getQuery()->getArrayResult();
+    }
 }

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

@@ -3,20 +3,20 @@ declare(strict_types=1);
 
 namespace App\Repository\Access;
 
-use App\Entity\Access\OrganizationFunction;
+use App\Entity\Access\FunctionType;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 use Doctrine\Persistence\ManagerRegistry;
 
 /**
- * @method OrganizationFunction|null find($id, $lockMode = null, $lockVersion = null)
- * @method OrganizationFunction|null findOneBy(array $criteria, array $orderBy = null)
- * @method OrganizationFunction[]    findAll()
- * @method OrganizationFunction[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ * @method FunctionType|null find($id, $lockMode = null, $lockVersion = null)
+ * @method FunctionType|null findOneBy(array $criteria, array $orderBy = null)
+ * @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)
     {
-        parent::__construct($registry, OrganizationFunction::class);
+        parent::__construct($registry, FunctionType::class);
     }
 }

+ 1 - 0
src/Repository/Access/OrganizationFunctionRepository.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 namespace App\Repository\Access;
 
 use App\Entity\Access\OrganizationFunction;
+use App\Entity\Organization\Organization;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 use Doctrine\Persistence\ManagerRegistry;
 

+ 1 - 4
src/Repository/Organization/OrganizationRepository.php

@@ -3,12 +3,9 @@ declare(strict_types=1);
 
 namespace App\Repository\Organization;
 
+use App\DQL\DateConditions;
 use App\Entity\Organization\Organization;
-use App\Enum\Organization\OrganizationIdsEnum;
-use App\Enum\Organization\PrincipalTypeEnum;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
-use Doctrine\ORM\Query\ResultSetMapping;
-use Doctrine\ORM\Query\ResultSetMappingBuilder;
 use Doctrine\Persistence\ManagerRegistry;
 
 /**

+ 24 - 0
src/Service/Core/AddressPostalUtils.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Service\Core;
+
+use App\Entity\Core\AddressPostal;
+
+class AddressPostalUtils
+{
+    /**
+     * Concatenate and return the three streetAddress parts of the given address
+     *
+     * @param AddressPostal $addressPostal
+     * @param string $separator
+     * @return string
+     */
+    public static function getFullStreetAddress(AddressPostal $addressPostal, string $separator = "\n"): string {
+        return implode($separator, array_filter([
+            trim($addressPostal->getStreetAddress()),
+            trim($addressPostal->getStreetAddressSecond()),
+            trim($addressPostal->getStreetAddressThird())
+        ], static function ($addressPart) { return !empty($addressPart); })
+        );
+    }
+}

+ 4 - 4
src/Service/Dolibarr/DolibarrAccountCreator.php

@@ -19,25 +19,25 @@ class DolibarrAccountCreator
     ];
 
     public function __construct(
-        private DolibarrService $dolibarrService,
+        private DolibarrApiService $dolibarrApiService,
     )
     {}
 
     public function getDolibarrAccount(int $id): DolibarrAccount {
 
         // Get dolibarr account (society)
-        $accountData = $this->dolibarrService->getSociety($id);
+        $accountData = $this->dolibarrApiService->getSociety($id);
         $dolibarrAccount = $this->createDolibarrAccount($id, $accountData);
 
         // Get active contract and services
-        $contractData = $this->dolibarrService->getActiveContract($dolibarrAccount->getSocId());
+        $contractData = $this->dolibarrApiService->getActiveContract($dolibarrAccount->getSocId());
         if ($contractData !== null) {
             $contract = $this->createDolibarrContract($contractData);
             $dolibarrAccount->setContract($contract);
         }
 
         // get bills
-        $billsData = $this->dolibarrService->getBills($dolibarrAccount->getSocId());
+        $billsData = $this->dolibarrApiService->getBills($dolibarrAccount->getSocId());
         foreach ($billsData as $billData) {
             $bill = $this->createDolibarrBill($billData);
             $dolibarrAccount->addBill($bill);

+ 131 - 0
src/Service/Dolibarr/DolibarrApiService.php

@@ -0,0 +1,131 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Dolibarr;
+
+use App\Service\Rest\ApiRequestService;
+use JetBrains\PhpStorm\Pure;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+/**
+ * Service d'appel à l'API dolibarr
+ *
+ * @see https://prod-erp.2iopenservice.com/api/index.php/explorer/
+ */
+class DolibarrApiService extends ApiRequestService
+{
+    /** @noinspection SenselessProxyMethodInspection Method shall be kept to allow dependency injections, even if empty */
+    #[Pure]
+    public function __construct(HttpClientInterface $dolibarr_client)
+    {
+        parent::__construct($dolibarr_client);
+    }
+
+    /**
+     * Get a dolibarr society by its opentalent organization id
+     *
+     * @param int $organizationId
+     * @return array
+     * @throws HttpException
+     */
+    public function getSociety(int $organizationId): array {
+        return $this->getJsonContent("thirdparties" , [ "limit" => "1", "sqlfilters" => "ref_int=" . $organizationId])[0];
+    }
+
+    /**
+     * Get the first active contract for the given dolibarr society
+     *
+     * @param int $socId
+     * @return array|null
+     */
+    public function getActiveContract(int $socId): ?array {
+        try {
+            return $this->getJsonContent(
+                "contracts",
+                ["limit" => "1", "sqlfilters" => "statut=1", "thirdparty_ids" => $socId]
+            )[0];
+        } catch (HttpException $e) {
+            if ($e->getStatusCode() === 404) {
+                // /!\ The dolibarr API will return a 404 error if no results are found...
+                return [];
+            }
+            throw $e;
+        }
+    }
+
+    /**
+     * Get a society bills by their society id
+     *
+     * @param int $socId
+     * @return array
+     */
+    public function getBills(int $socId): array {
+        try {
+            return $this->getJsonContent(
+                "invoices",
+                ["sortfield" => "datef", "sortorder" => "DESC", "limit" => 5, "sqlfilters" => "fk_soc=" . $socId]);
+        } catch (HttpException $e) {
+            if ($e->getStatusCode() === 404) {
+                // /!\ The dolibarr API will return a 404 error if no results are found...
+                return [];
+            }
+            throw $e;
+        }
+    }
+
+    /**
+     * Get all the societies which are Opentalent client
+     * @throws HttpException
+     */
+    public function getAllClients(bool $withContract = false): array
+    {
+        return $this->getJsonContent(
+            "thirdparties",
+            ["limit" => "1000000", "sqlfilters" => "client=1"]
+        );
+    }
+
+    /**
+     * Get the society contacts
+     *
+     * @throws HttpException
+     */
+    public function getContacts(int $socId): array
+    {
+        try {
+            return $this->getJsonContent(
+                "contacts",
+                ['limit' => 1000, 'thirdparty_ids' => $socId],
+            );
+        } catch (HttpException $e) {
+            if ($e->getStatusCode() === 404) {
+                // /!\ The dolibarr API will return a 404 error if no results are found...
+                return [];
+            }
+            throw $e;
+        }
+    }
+
+    /**
+     * Get the society contacts that have a non-null personId
+     *
+     * @throws HttpException
+     */
+    public function getActiveOpentalentContacts(int $socId): array
+    {
+        // On est obligé ici de passer la query en dur, sinon les parenthèses sont encodées,
+        // et dolibarr est pas content :(
+        try {
+            return $this->getJsonContent(
+                "contacts?limit=1000&t.statut=1&thirdparty_ids=" . $socId . "&sqlfilters=(te.2iopen_person_id%3A%3E%3A0)"
+            );
+        } catch (HttpException $e) {
+            if ($e->getStatusCode() === 404) {
+                // /!\ The dolibarr API will return a 404 error if no results are found...
+                return [];
+            }
+            throw $e;
+        }
+    }
+}

+ 0 - 84
src/Service/Dolibarr/DolibarrService.php

@@ -1,84 +0,0 @@
-<?php
-declare(strict_types=1);
-
-namespace App\Service\Dolibarr;
-
-use App\Service\ApiRequestService;
-use JetBrains\PhpStorm\Pure;
-use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
-use Symfony\Contracts\HttpClient\HttpClientInterface;
-
-/**
- * Service d'appel à l'API dolibarr
- *
- * @see https://prod-erp.2iopenservice.com/api/index.php/explorer/
- */
-class DolibarrService extends ApiRequestService
-{
-    #[Pure]
-    function __construct(HttpClientInterface $dolibarr_client)
-    {
-        parent::__construct($dolibarr_client);
-    }
-
-    /**
-     * Get a dolibarr society by its opentalent organization id
-     *
-     * @param int $organizationId
-     * @return array
-     */
-    public function getSociety(int $organizationId): array {
-        return $this->getJsonContent("thirdparties" , [ "sqlfilters" => "ref_int=" . $organizationId])[0];
-    }
-
-    /**
-     * Get the first active contract for the given dolibarr society
-     *
-     * @param int $socId
-     * @return array|null
-     */
-    public function getActiveContract(int $socId): ?array {
-        try {
-            return $this->getJsonContent(
-                "contracts",
-                ["limit" => "1", "sqlfilters" => "statut=1", "thirdparty_ids" => $socId]
-            )[0];
-        } catch (NotFoundHttpException) {
-            // /!\ The dolibarr API will return a 404 error if no contract is found...
-            return null;
-        }
-    }
-
-    /**
-     * Get a society bills by their society id
-     *
-     * @param int $socId
-     * @return array
-     */
-    public function getBills(int $socId): array {
-        try {
-            return $this->getJsonContent(
-                "invoices",
-                ["sortfield" => "datef", "sortorder" => "DESC", "limit" => 5, "sqlfilters" => "fk_soc=" . $socId]);
-        } catch (NotFoundHttpException) {
-            // /!\ The dolibarr API will return a 404 error if no invoices are found...
-            return [];
-        }
-    }
-
-    /**
-     *
-     * @param $organization
-     * @return void
-     */
-    public function createSociety($organization): void
-    {
-        $body = sprintf(
-            '{"name":"%s","client":"2","code_client":"-1","ref_int":"%s","import_key":"crm"}',
-            $organization->getName(),
-            $organization->getId()
-        );
-
-        $this->request('POST', "api/index.php/thirdparties", [], ['body' => $body]);
-    }
-}

+ 723 - 0
src/Service/Dolibarr/DolibarrSyncService.php

@@ -0,0 +1,723 @@
+<?php
+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;
+use App\Enum\Access\RoleEnum;
+use App\Enum\Core\ContactPointTypeEnum;
+use App\Enum\Organization\AddressPostalOrganizationTypeEnum;
+use App\Enum\Organization\OrganizationIdsEnum;
+use App\Enum\Organization\SettingsProductEnum;
+use App\Enum\Person\GenderEnum;
+use App\Repository\Access\AccessRepository;
+use App\Repository\Access\FunctionTypeRepository;
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\Core\AddressPostalUtils;
+use App\Service\Rest\Operation\BaseRestOperation;
+use App\Service\Rest\Operation\CreateOperation;
+use App\Service\Rest\Operation\UpdateOperation;
+use App\Service\Utils\ArrayUtils;
+use Exception;
+use libphonenumber\PhoneNumber;
+use libphonenumber\PhoneNumberFormat;
+use libphonenumber\PhoneNumberUtil;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+/**
+ * Push the data from the Opentalent DB into the Dolibarr DB, trough both applications
+ * REST APIs.
+ *
+ * ** /!\ This sync is and must remain one-sided: Opentalent DB => Dolibarr DB **
+ */
+class DolibarrSyncService
+{
+    public function __construct(
+        private OrganizationRepository $organizationRepository,
+        private AccessRepository $accessRepository,
+        private FunctionTypeRepository $functionTypeRepository,
+        private DolibarrApiService $dolibarrApiService,
+        private TranslatorInterface $translator,
+        private LoggerInterface $logger
+    ) {}
+
+    /**
+     * Performs a scan, comparing data from the Opentalent DB and the data returned
+     * by the Dolibarr API
+     *
+     * Errors during the scan are recorded in the $this->scanErrors
+     *
+     * Returns an array of DolibarrSyncOperations
+     *
+     * @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
+     */
+    public function scan(?callable $progressionCallback = null): array {
+        $this->logger->info("-- Scan started --");
+
+        // Index the dolibarr clients by organization ids
+        $dolibarrClientsIndex = $this->getDolibarrSocietiesIndex();
+        $this->logger->info(count($dolibarrClientsIndex) . " clients fetched from dolibarr");
+
+        // Get all active accesses
+        $membersIndex = $this->getActiveMembersIndex();
+
+        // Get all the missions with an admin default role
+        $adminMissions = [];
+        foreach ($this->functionTypeRepository->findBy(['roleByDefault' => RoleEnum::ROLE_ADMIN()->getValue()]) as $functionType) {
+            $adminMissions[] = $functionType->getMission();
+        }
+
+        // Store networks ids id dolibarr
+        $cmfDolibarrId = (int)($this->dolibarrApiService->getSociety(OrganizationIdsEnum::CMF()->getValue())['id']);
+        $ffecDolibarrId = (int)($this->dolibarrApiService->getSociety(OrganizationIdsEnum::FFEC()->getValue())['id']);
+
+        // Loop over the Opentalent organizations, and fill up the operations list
+        $operations = [];
+        $i = 0; $total = count($dolibarrClientsIndex);
+        foreach ($dolibarrClientsIndex as $organizationId => $dolibarrSociety) {
+            $dolibarrSociety = self::sanitizeDolibarrData($dolibarrSociety);
+
+            $organization = $this->organizationRepository->find($organizationId);
+            if ($organization === null) {
+                $this->logger->error("Organization " . $organizationId . " not found in the Opentalent DB");
+                continue;
+            }
+
+            // Populate the expected contacts array
+            $organizationMembers = $membersIndex[$organization->getId()] ?? [];
+
+            // ===== Update Society =====
+            $newSocietyData = [];
+
+            // Sync name
+            $newSocietyData['name'] = trim($organization->getName());
+
+            // Sync contact data of the client
+            $mainAddress = $this->getOrganizationPostalAddress($organization);
+            if ($mainAddress !== null) {
+                $streetAddress = AddressPostalUtils::getFullStreetAddress($mainAddress);
+                if (trim($mainAddress->getAddressOwner() ?? '') !== '') {
+                    $streetAddress = $mainAddress->getAddressOwner() . "\n" . $streetAddress;
+                }
+                $newSocietyData['address'] = $streetAddress;
+                $newSocietyData['zip'] = $mainAddress->getPostalCode();
+                $newSocietyData['town'] = $mainAddress->getAddressCity();
+            } else {
+                $newSocietyData['address'] = null;
+                $newSocietyData['zip'] = null;
+                $newSocietyData['town'] = null;
+            }
+
+            // Sync contact
+            $newSocietyData['email'] = $this->getOrganizationEmail($organization);
+            $newSocietyData['phone'] = $this->getOrganizationPhone($organization);
+
+            // Sync Network
+            $newSocietyData['parent'] = match (
+                $organization->getNetworkOrganizations()?->first()?->getNetwork()?->getId()
+            ) {
+                OrganizationIdsEnum::CMF()->getValue() => $cmfDolibarrId,
+                OrganizationIdsEnum::FFEC()->getValue() => $ffecDolibarrId,
+                default => null
+            };
+
+            // More infos
+            $infos = [];
+            $product = $organization->getSettings()->getProduct();
+            if (SettingsProductEnum::isSchool($product)) {
+                $infos[] = $this->translator->trans('STUDENTS_COUNT') . " : " .
+                    self::countWithMission([FunctionEnum::STUDENT()->getValue()], $organizationMembers);
+            }
+            if (SettingsProductEnum::isSchool($product) || SettingsProductEnum::isArtist($product)) {
+                $infos[] = $this->translator->trans('ADHERENTS_COUNT') . " : " .
+                    self::countWithMission([FunctionEnum::ADHERENT()->getValue()], $organizationMembers);
+            }
+            $infos[] = $this->translator->trans('ADMIN_ACCESS_COUNT') . " : " .
+                self::countWithMission($adminMissions, $organizationMembers);
+
+            // /!\ On est forcé de passer la sub-array entière pour mettre à jour le champ modifié, sinon
+            //     tous les autres champs seront passés à null...
+            $newSocietyData['array_options'] = $dolibarrSociety["array_options"];
+            $newSocietyData['array_options']['options_2iopeninfoopentalent'] = implode("\n", $infos);
+
+            // Set the society as active (warning: use the field 'status' for societies, and not 'statut'!)
+            $newSocietyData['status'] = '1';
+
+            // Only update the fields that are different (it's important to let it non-recursive, the subarray have to be passed entirely)
+            $newSocietyData = ArrayUtils::getChanges(
+                $dolibarrSociety,
+                $newSocietyData,
+                false,
+                static function ($v1, $v2) { return ($v1 ?? '') === ($v2 ?? ''); }
+            );
+
+            // Add an update operation if some data has to be updated
+            if (!empty($newSocietyData)) {
+                $operations[] = new UpdateOperation(
+                    'Update society : ' . $organization->getName() . ' (' . $organization->getId() . ')',
+                    'thirdparties',
+                    (int)$dolibarrSociety['id'],
+                    $newSocietyData,
+                    $dolibarrSociety
+                );
+            }
+
+            // ===== Update Contacts =====
+            $dolibarrSocietyContacts = $this->dolibarrApiService->getContacts((int)$dolibarrSociety['id']);
+            $contactsProcessed = [];
+
+            foreach ($organizationMembers as $accessId => $missions) {
+                // Check if member has office missions, skip if it doesn't
+                if (empty(array_intersect($missions, FunctionEnum::getOfficeMissions()))) {
+                    continue;
+                }
+
+                $access = $this->accessRepository->find($accessId);
+                $person = $access?->getPerson();
+
+                if ($person === null) // this should not happen, but is expected by code inspection...
+                { throw new \Exception('Access or person not found'); }
+
+                // Keep track of the contacts seen
+                if (in_array($person->getId(), $contactsProcessed, true)) {
+                    // already updated from another mission
+                    continue;
+                }
+                $contactsProcessed[] = $person->getId();
+
+                // special: if the contact has no name, ignore it
+                if (!$person->getName() || !$person->getGivenName()) {
+                    continue;
+                }
+
+                // Get the matching dolibarr contact
+                $dolibarrContact = self::findDolibarrContactFor($dolibarrSocietyContacts, $person);
+                $dolibarrContact = self::sanitizeDolibarrData($dolibarrContact);
+
+                $contact = $this->getPersonContact($person);
+
+                // Build parameters for the query (we'll see later if a query is needed)
+                $newContactData = [
+                    'civility_code' => $person->getGender() ? $this->translator->trans($person->getGender()) : null,
+                    'lastname' => trim($person->getName()),
+                    'firstname' => trim($person->getGivenName()),
+                    'email' => $contact?->getEmail(),
+                    'phone_pro' => $contact?->getTelphone() ? self::formatPhoneNumber($contact?->getTelphone()) : null,
+                    'phone_mobile' => $contact?->getMobilPhone() ? self::formatPhoneNumber($contact?->getMobilPhone()): null,
+                    'poste' => $this->formatContactPosition($missions, $person->getGender()),
+                    'statut' => '1'
+                ];
+
+                // The person's id may be missing if the contact is new or if it was found through its name
+                if ($dolibarrContact !== null && !(empty($dolibarrContact["array_options"] ?? []))) {
+                    $newContactData["array_options"] = $dolibarrContact["array_options"];
+                } else {
+                    $newContactData["array_options"] = [];
+                }
+                $newContactData["array_options"]["options_2iopen_person_id"] = (string)$person->getId();
+
+                if ($dolibarrContact === null) {
+                    // New contact
+                    $newContactData['socid'] = (int)$dolibarrSociety['id'];
+
+                    $operations[] = new CreateOperation(
+                        'New contact: ' . $person->getName() . ' ' . $person->getGivenName() . ' (' . $person->getId() . ')',
+                        'contacts',
+                        $newContactData
+                    );
+                } else {
+                    // Only update the fields that are different (it's important to let it non-recursive, the subarray have to be passed entirely)
+                    $newContactData = ArrayUtils::getChanges(
+                        $dolibarrContact,
+                        $newContactData,
+                        false,
+                        static function ($v1, $v2) { return ($v1 ?? '') === ($v2 ?? ''); }
+                    );
+
+                    // add an update operation if some data has to be updated
+                    if (!empty($newContactData)) {
+                        $operations[] = new UpdateOperation(
+                            'Update contact: ' . $person->getName() . ' ' . $person->getGivenName() . ' (' . $person->getId() . ')' .
+                            ' in ' . $organization->getName() . ' (' . $organization->getId() . ')',
+                            'contacts',
+                            (int)$dolibarrContact['id'],
+                            $newContactData,
+                            $dolibarrContact
+                        );
+                    }
+                }
+            }
+
+            foreach ($dolibarrSocietyContacts as $contactData) {
+                if (empty($contactData["array_options"]["options_2iopen_person_id"])) {
+                    continue;
+                }
+                $personId = (int)$contactData["array_options"]["options_2iopen_person_id"];
+                if ((int)$contactData['statut'] === 0) {
+                    // contact is already disabled
+                    continue;
+                }
+                if (!in_array($personId, $contactsProcessed, true)) {
+                    // Ce personId n'existe plus dans les membres Opentalent de cette société, on delete
+                    $operations[] = new UpdateOperation(
+                        'Disable contact: ' . $contactData['lastname'] . ' ' . $contactData['firstname'] . ' (' . $personId . ')' .
+                        ' from  ' . $organization->getName() . ' (' . $organization->getId() . ')',
+                        'contacts',
+                        (int)$contactData['id'],
+                        ['statut' => '0'],
+                        $contactData
+                    );
+                }
+            }
+
+            // Next society
+            $i++;
+            if ($progressionCallback !== null) {
+                $progressionCallback($i, $total);
+            }
+        }
+
+        $this->logger->info('Scan done, ' . count($operations) . ' required operations listed');
+        return $operations;
+    }
+
+    /**
+     * Execute the operations listed with the DolibarrSyncService::scan method
+     *
+     * Returns an array of DolibarrSyncOperations
+     *
+     * @param array<BaseRestOperation> $operations
+     * @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, ?callable $progressionCallback = null): array
+    {
+        $this->logger->info('-- Execution started --');
+        $this->logger->info(count($operations) . ' operations pending...');
+        $done = 0; $errors = 0; $unknown = 0;
+
+        $i = 0; $total = count($operations);
+        foreach ($operations as $operation) {
+            if ($operation->getStatus() !== $operation::STATUS_READY) {
+                // operation has already been treated
+                $this->logger->warning('Tried to execute an operation that was not marked as ready : ' . $operation);
+                continue;
+            }
+
+            $this->logger->debug($operation->getLabel());
+            foreach ($operation->getChangeLog() as $message) {
+                $this->logger->debug('   ' . $message);
+            }
+
+            try {
+                // Execute the request
+                $response = $operation->execute($this->dolibarrApiService);
+
+                // Check the status
+                if ($operation->getStatus() !== $operation::STATUS_DONE) {
+                    $unknown++;
+                    throw new RuntimeException('Operation has an inconsistent status : ' . $operation->getStatus());
+                }
+
+                // If this is an update operation, validate the result
+                if ($operation instanceof UpdateOperation) {
+                    try {
+                        $this->validateResponse($response, $operation);
+                    } catch (RuntimeException $e) {
+                        $this->logger->warning($e);
+                    }
+                }
+
+                $done++;
+            } catch (RuntimeException $e) {
+                $this->logger->error('Error while executing operation : ' . $operation);
+                $this->logger->error(implode("\n", $operation->getChangeLog()));
+                $this->logger->error($e);
+                $errors++;
+            }
+
+            $i++;
+            if ($progressionCallback !== null) {
+                $progressionCallback($i, $total);
+            }
+        }
+
+        $this->logger->info('Execution ended');
+        $this->logger->info('Done : ' . $done);
+        $this->logger->info('Errors : ' . $errors);
+        if ($unknown > 0) {
+            $this->logger->warning('Unknown : ' . $unknown);
+        }
+
+        return $operations;
+    }
+
+    /**
+     * Scan and execute the sync process
+     *
+     * @return array<BaseRestOperation>
+     * @throws HttpException
+     * @throws Exception
+     */
+    public function run(): array
+    {
+        $operations = $this->scan();
+
+        $this->execute($operations);
+
+        return $operations;
+    }
+
+    /**
+     * Get the client societies dolibarr and index them by organization id
+     *
+     * @return array An index of the form [$organizationId => $dolibarrData]
+     */
+    protected function getDolibarrSocietiesIndex(): array
+    {
+        $index = [];
+        foreach ($this->dolibarrApiService->getAllClients() as $clientData) {
+            $organizationId = $clientData["array_options"]["options_2iopen_organization_id"] ?? null;
+            if (!($organizationId > 0)) {
+
+                // Ignoring clients without contract
+                $contract = $this->dolibarrApiService->getActiveContract((int)$clientData['id']);
+                if (empty($contract)) {
+                    continue;
+                }
+
+                $this->logger->warning(
+                    'Dolibarr client has no organization id: ' .
+                    $clientData['name'] . ' (' . $clientData['id'] . ')'
+                );
+                continue;
+            }
+
+            $index[$organizationId] = $clientData;
+        }
+        return $index;
+    }
+
+    /**
+     * Returns an index of all the active members with their current mission(s)
+     *
+     * Index is the form: [$organizationId => [$accessId => [$mission, $mission...], $accessId...], $organizationId2...]
+     *
+     * @return array
+     */
+    protected function getActiveMembersIndex(): array {
+        $index = [];
+        $results = $this->accessRepository->getAllActiveMembersAndMissions();
+        foreach ($results as $row) {
+            $accessId = $row['id'];
+            $organizationId = $row['organization_id'];
+            $mission = $row['mission'];
+
+            if (!array_key_exists($organizationId, $index)) {
+                $index[$organizationId] = [];
+            }
+            if (!array_key_exists($accessId, $index[$organizationId])) {
+                $index[$organizationId][$accessId] = [];
+            }
+            $index[$organizationId][$accessId][] = $mission;
+        }
+        return $index;
+    }
+
+    /**
+     * Get the first contact that has the same person id.
+     *
+     * If none are found with the person id, try to find one with the same full name and no person id
+     *
+     * @param array $dolibarrContacts
+     * @param Person $person
+     * @return array|null
+     */
+    protected static function findDolibarrContactFor(array $dolibarrContacts, Person $person): ?array {
+        foreach ($dolibarrContacts as $contactData) {
+            if (!empty($contactData["array_options"]["options_2iopen_person_id"])) {
+                $id = (int)$contactData["array_options"]["options_2iopen_person_id"];
+
+                if ($id === $person->getId()) {
+                    return $contactData;
+                }
+            }
+        }
+
+        foreach ($dolibarrContacts as $contactData) {
+            if (
+                !($contactData["array_options"]["options_2iopen_person_id"] ?? null) &&
+                $person->getName() !== null &&
+                $person->getGivenName() !== null &&
+                strtolower($person->getName() ?? '') === strtolower($contactData["lastname"] ?? '') &&
+                strtolower($person->getGivenName() ?? '') === strtolower($contactData["firstname"] ?? '')
+            ) {
+                return $contactData;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Because for some fields the dolibarr api returns empty strings even when field is null in DB,
+     * we have to post-process it to avoid unnecessary and endless update operations
+     *
+     * As far as we know, there is no harm here to replace every empty string value by a null value
+     * (no loss of information)
+     *
+     * @param array|null $data
+     * @return array|null
+     */
+    protected static function sanitizeDolibarrData(?array $data): ?array {
+        if ($data === null) {
+            return null;
+        }
+
+        foreach ($data as $field => $value) {
+            if (is_array($value)) {
+                $data[$field] = self::sanitizeDolibarrData($value);
+            } else if ($value === '') {
+                $data[$field] = null;
+            }
+        }
+        return $data;
+    }
+
+    /**
+     * Retrieve the postal address of the organization
+     *
+     * @param Organization $organization
+     * @return AddressPostal|null
+     */
+    protected function getOrganizationPostalAddress(Organization $organization): ?AddressPostal {
+        $addressPriorities = [
+            AddressPostalOrganizationTypeEnum::ADDRESS_BILL()->getValue(),
+            AddressPostalOrganizationTypeEnum::ADDRESS_CONTACT()->getValue(),
+            AddressPostalOrganizationTypeEnum::ADDRESS_HEAD_OFFICE()->getValue(),
+            AddressPostalOrganizationTypeEnum::ADDRESS_PRACTICE()->getValue(),
+            AddressPostalOrganizationTypeEnum::ADDRESS_OTHER()->getValue()
+        ];
+
+        $organizationAddressPostal = $organization->getOrganizationAddressPostals();
+
+        foreach ($addressPriorities as $addressType) {
+            foreach ($organizationAddressPostal as $postalAddress) {
+                if ($postalAddress->getType() === $addressType) {
+                    return $postalAddress->getAddressPostal();
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Retrieve the phone for the organization
+     *
+     * @param Organization $organization
+     * @return string|null
+     */
+    protected function getOrganizationPhone(Organization $organization): ?string
+    {
+        $contactPriorities = [
+            ContactPointTypeEnum::BILL()->getValue(),
+            ContactPointTypeEnum::CONTACT()->getValue(),
+            ContactPointTypeEnum::PRINCIPAL()->getValue(),
+            ContactPointTypeEnum::OTHER()->getValue()
+        ];
+
+        $contactPoints = $organization->getContactPoints();
+
+        foreach ($contactPriorities as $contactType) {
+            foreach ($contactPoints as $contactPoint) {
+                if ($contactPoint->getContactType() === $contactType) {
+                    if ($contactPoint->getTelphone() !== null) {
+
+                        return self::formatPhoneNumber($contactPoint->getTelphone());
+                    }
+                    if ($contactPoint->getMobilPhone() !== null) {
+                        return self::formatPhoneNumber($contactPoint->getMobilPhone());
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Retrieve the email for the organization
+     *
+     * @param Organization $organization
+     * @return string|null
+     */
+    protected function getOrganizationEmail(Organization $organization): ?string {
+        $contactPriorities = [
+            ContactPointTypeEnum::BILL()->getValue(),
+            ContactPointTypeEnum::CONTACT()->getValue(),
+            ContactPointTypeEnum::PRINCIPAL()->getValue(),
+            ContactPointTypeEnum::OTHER()->getValue()
+        ];
+
+        $contactPoints = $organization->getContactPoints();
+
+        foreach ($contactPriorities as $contactType) {
+            foreach ($contactPoints as $contactPoint) {
+                if ($contactPoint->getContactType() === $contactType && $contactPoint->getEmail() !== null) {
+                    return $contactPoint->getEmail();
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 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
+     */
+    protected static function countWithMission(array $missions, array $members): int {
+        return count(array_filter(
+            $members,
+            static function ($actualMissions) use ($missions) { return !empty(array_intersect($actualMissions, $missions)); }
+        ));
+    }
+
+    /**
+     * 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()->getValue(),
+            ContactPointTypeEnum::OTHER()->getValue()
+        ];
+
+        $contactPoints = $person->getContactPoints();
+
+        foreach ($contactPriorities as $contactType) {
+            foreach ($contactPoints as $contactPoint) {
+                if ($contactPoint->getContactType() === $contactType) {
+                    return $contactPoint;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Format the contact position from its gender and missions
+     *
+     * @param array $missions
+     * @param string|null $gender
+     * @return string
+     */
+    protected function formatContactPosition(array $missions, ?string $gender = 'X'): string {
+        $to_exclude = [
+            FunctionEnum::ADHERENT()->getValue(),
+            FunctionEnum::STUDENT()->getValue(),
+            FunctionEnum::OTHER()->getValue()
+        ];
+
+        $poste = implode(
+            ', ',
+            array_map(
+                function($m) use ($gender) {
+                    return $this->translator->trans(
+                        $m,
+                        ['gender' => [
+                                GenderEnum::MISS()->getValue() => 'F',
+                                GenderEnum::MISTER()->getValue() => 'M'
+                            ][$gender] ?? 'X']
+                    );
+                },
+                array_filter(
+                    $missions,
+                    static function ($m) use ($to_exclude) {
+                        return !in_array($m, $to_exclude, true);
+                    }
+                )
+            )
+        );
+
+        if (strlen($poste) > 80) {
+            $poste = mb_substr($poste, 0, 77, "utf-8") . '...';
+        }
+        return $poste;
+    }
+
+    /**
+     * Format a phone number into international format
+     *
+     * @param PhoneNumber $phoneNumber
+     * @return mixed
+     */
+    protected static function formatPhoneNumber(PhoneNumber $phoneNumber): string {
+        $phoneUtil = PhoneNumberUtil::getInstance();
+        return str_replace(
+            ' ',
+            '',
+            $phoneUtil->format($phoneNumber, PhoneNumberFormat::INTERNATIONAL)
+        );
+    }
+
+
+    /**
+     * Post-validation of the execution of the operation.
+     * In the case of a validation error, throw an HttpException
+     *
+     * @param ResponseInterface $response
+     * @param BaseRestOperation $operation
+     * @throws RuntimeException
+     */
+    protected function validateResponse(ResponseInterface $response, BaseRestOperation $operation): void
+    {
+        $updated = $operation->getData();
+        if ($updated === null) {
+            return;
+        }
+
+        try {
+            $responseData = $response->toArray();
+        } catch (ClientExceptionInterface | DecodingExceptionInterface | RedirectionExceptionInterface | ServerExceptionInterface | TransportExceptionInterface $e) {
+            throw new RuntimeException(
+                "Couldn't read the content of the response : " . $e
+            );
+        }
+
+        // Sanitize to get rid of the null / empty strings transformations of the API
+        $updated = self::sanitizeDolibarrData($updated);
+        $responseData = self::sanitizeDolibarrData($responseData);
+
+        $diffs = ArrayUtils::getChanges($responseData, $updated, true);
+
+        if (!empty($diffs)) {
+            /** @noinspection JsonEncodingApiUsageInspection */
+            throw new RuntimeException(
+                "The " . $operation->getMethod() . " request had an unexpected result.\n" .
+                "Expected content: " . json_encode($updated) . "\n" .
+                "Actual content  : " . json_encode($responseData)
+            );
+        }
+    }
+}

+ 1 - 1
src/Service/Mobyt/MobytService.php

@@ -3,7 +3,7 @@ declare(strict_types=1);
 
 namespace App\Service\Mobyt;
 
-use App\Service\ApiRequestService;
+use App\Service\Rest\ApiRequestService;
 use JetBrains\PhpStorm\Pure;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 

+ 97 - 0
src/Service/Rest/ApiRequestInterface.php

@@ -0,0 +1,97 @@
+<?php
+declare(strict_types = 1);
+
+namespace App\Service\Rest;
+
+use App\Service\Rest\Operation\BaseRestOperation;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+/**
+ * Interface for services sending requests to an external REST API
+ */
+interface ApiRequestInterface
+{
+    /**
+     * Sends a GET request and returns the response's body decoded as json
+     * @param string $path
+     * @param array $parameters
+     * @param array $options
+     * @return array
+     * @throws HttpException
+     */
+    public function getJsonContent(string $path, array $parameters = [], array $options = []): array;
+
+    /**
+     * Sends a GET request and returns the response's body
+     *
+     * @param string $path
+     * @param array $parameters
+     * @param array $options
+     * @return string
+     * @throws HttpException
+     */
+    public function getContent(string $path, array $parameters = [], array $options = []): string;
+
+    /**
+     * Sends a GET request and returns the response
+     *
+     * @param string $path
+     * @param array $parameters
+     * @param array $options
+     * @return ResponseInterface
+     * @throws HttpException
+     */
+    public function get(string $path, array $parameters = [], array $options = []): ResponseInterface;
+
+    /**
+     * Sends a POST request and returns the response
+     *
+     * @param string $path
+     * @param array $parameters
+     * @param array $options
+     * @return ResponseInterface
+     * @throws HttpException
+     */
+    public function post(string $path, array $parameters = [], array $options = []): ResponseInterface;
+
+    /**
+     * Sends a PUT request and returns the response
+     *
+     * @param string $path
+     * @param array $parameters
+     * @param array $options
+     * @return ResponseInterface
+     * @throws HttpException
+     */
+    public function put(string $path, array $parameters = [], array $options = []): ResponseInterface;
+
+    /**
+     * Sends a DELETE request and returns the response
+     *
+     * @param string $path
+     * @param array $parameters
+     * @param array $options
+     * @return ResponseInterface
+     * @throws HttpException
+     */
+    public function delete(string $path, array $parameters = [], array $options = []): ResponseInterface;
+
+    /**
+     * Send an HTTP request to a REST API,
+     * and return the decoded content of the response's body
+     *
+     * @param string $method
+     * @param string $url
+     * @param array $parameters
+     * @param array $options
+     * @return ResponseInterface
+     * @throws HttpException
+     */
+    public function request(
+        string $method,
+        string $url,
+        array  $parameters = [],
+        array  $options = []
+    ): ResponseInterface;
+}

+ 57 - 10
src/Service/ApiRequestService.php → src/Service/Rest/ApiRequestService.php

@@ -1,11 +1,11 @@
 <?php
 declare(strict_types=1);
 
-namespace App\Service;
+namespace App\Service\Rest;
 
 use App\Service\Utils\UrlBuilder;
+use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
-use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
 use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
 use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
@@ -16,9 +16,9 @@ use Symfony\Contracts\HttpClient\ResponseInterface;
 /**
  * Base class for services sending requests to an external API
  */
-class ApiRequestService
+class ApiRequestService implements ApiRequestInterface
 {
-    function __construct(
+    public function __construct(
         protected HttpClientInterface $client
     ) {}
 
@@ -28,9 +28,11 @@ class ApiRequestService
      * @param array $parameters
      * @param array $options
      * @return array
+     * @throws HttpException
+     * @throws \JsonException
      */
     public function getJsonContent(string $path, array $parameters = [], array $options = []): array {
-        return json_decode($this->getContent($path, $parameters, $options), true);
+        return json_decode($this->getContent($path, $parameters, $options), true, 512, JSON_THROW_ON_ERROR);
     }
 
     /**
@@ -40,12 +42,13 @@ class ApiRequestService
      * @param array $parameters
      * @param array $options
      * @return string
+     * @throws HttpException
      */
     public function getContent(string $path, array $parameters = [], array $options = []): string {
         try {
             return $this->get($path, $parameters, $options)->getContent();
         } catch (ClientExceptionInterface | TransportExceptionInterface | RedirectionExceptionInterface | ServerExceptionInterface $e) {
-            throw new NotFoundHttpException('data not found', $e, 404);
+            throw new HttpException(404, 'Data not found', $e);
         }
     }
 
@@ -56,13 +59,56 @@ class ApiRequestService
      * @param array $parameters
      * @param array $options
      * @return ResponseInterface
+     * @throws HttpException
      */
-    protected function get(string $path, array $parameters = [], array $options = []): ResponseInterface {
+    public function get(string $path, array $parameters = [], array $options = []): ResponseInterface {
         return $this->request('GET', $path, $parameters, $options);
     }
 
     /**
-     * Send an HTTP request to the Dolibarr API,
+     * Sends a POST request and returns the response
+     *
+     * @param string $path
+     * @param array $parameters
+     * @param array $options
+     * @return ResponseInterface
+     * @throws HttpException
+     */
+    public function post(string $path, array $parameters = [], array $options = []): ResponseInterface
+    {
+        return $this->request('POST', $path, $parameters, $options);
+    }
+
+    /**
+     * Sends a PUT request and returns the response
+     *
+     * @param string $path
+     * @param array $parameters
+     * @param array $options
+     * @return ResponseInterface
+     * @throws HttpException
+     */
+    public function put(string $path, array $parameters = [], array $options = []): ResponseInterface
+    {
+        return $this->request('PUT', $path, $parameters, $options);
+    }
+
+    /**
+     * Sends a DELETE request and returns the response
+     *
+     * @param string $path
+     * @param array $parameters
+     * @param array $options
+     * @return ResponseInterface
+     * @throws HttpException
+     */
+    public function delete(string $path, array $parameters = [], array $options = []): ResponseInterface
+    {
+        return $this->request('DELETE', $path, $parameters, $options);
+    }
+
+    /**
+     * Send an HTTP request to a REST API,
      * and return the decoded content of the response's body
      *
      * @param string $method
@@ -70,8 +116,9 @@ class ApiRequestService
      * @param array $parameters
      * @param array $options
      * @return ResponseInterface
+     * @throws HttpException
      */
-    protected function request(
+    public function request(
         string $method,
         string $url,
         array $parameters = [],
@@ -83,7 +130,7 @@ class ApiRequestService
         try {
             return $this->client->request($method, $url, $options);
         } catch (HttpExceptionInterface | TransportExceptionInterface $e) {
-            throw new NotFoundHttpException('fetch error', $e, 500);
+            throw new HttpException(500, 'Request error : ', $e);
         }
     }
 }

+ 173 - 0
src/Service/Rest/Operation/BaseRestOperation.php

@@ -0,0 +1,173 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Rest\Operation;
+
+use App\Service\Rest\ApiRequestInterface;
+use JetBrains\PhpStorm\Pure;
+use RuntimeException;
+use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+/**
+ * A single operation, corresponding to a single request
+ * to a REST API (Json only)
+ */
+abstract class BaseRestOperation
+{
+    public const STATUS_READY = 0;
+    public const STATUS_PENDING = 1;
+    public const STATUS_DONE = 2;
+    public const STATUS_ERROR = 3;
+
+    protected int $status = self::STATUS_READY;
+    protected string $errorMessage = "";
+
+    public function __construct(
+        protected string $label,
+        protected string $method,
+        protected string $path,
+        protected array  $initialData = [],
+        protected array  $parameters = [],
+        protected array  $options = []
+    ) {}
+
+    /**
+     * Execute the operation and update its status according to the result
+     *
+     * @param ApiRequestInterface $apiService
+     * @return ResponseInterface
+     */
+    public function execute(ApiRequestInterface $apiService): ResponseInterface
+    {
+        $this->status = self::STATUS_PENDING;
+        try {
+            $response = $apiService->request($this->method, $this->path, $this->parameters, $this->options);
+
+            if ($response->getStatusCode() === 200) {
+                $this->status = self::STATUS_DONE;
+            } else {
+                $this->status = self::STATUS_ERROR;
+                $this->errorMessage = 'Error ' . $response->getStatusCode() . ' : ' . $response->getContent();
+                throw new HttpException($response->getStatusCode(), $response->getContent());
+            }
+
+            return $response;
+        } catch (
+            HttpException |
+            ClientExceptionInterface |
+            TransportExceptionInterface |
+            RedirectionExceptionInterface |
+            ServerExceptionInterface |
+            InvalidArgumentException
+        $e) {
+            $this->status = self::STATUS_ERROR;
+            $this->errorMessage = '' . $e;
+            throw new RuntimeException($e->getMessage());
+        }
+    }
+
+    /**
+     * @return string
+     */
+    public function getLabel(): string
+    {
+        return $this->label;
+    }
+
+    /**
+     * @return int
+     */
+    public function getStatus(): int
+    {
+        return $this->status;
+    }
+
+    /**
+     * @return string
+     */
+    public function getErrorMessage(): string
+    {
+        return $this->errorMessage;
+    }
+
+    /**
+     * @return string
+     */
+    public function getMethod(): string
+    {
+        return $this->method;
+    }
+
+    /**
+     * @return string
+     */
+    public function getPath(): string
+    {
+        return $this->path;
+    }
+
+    /**
+     * @return array
+     */
+    public function getInitialData(): array
+    {
+        return $this->initialData;
+    }
+
+    /**
+     * @return array
+     */
+    public function getParameters(): array
+    {
+        return $this->parameters;
+    }
+
+    /**
+     * @return array
+     */
+    public function getOptions(): array
+    {
+        return $this->options;
+    }
+
+    /**
+     * Return an array of messages describing the change that this operation will bring
+     *
+     * @return array
+     * @throws \Exception
+     */
+    abstract public function getChangeLog(): array;
+
+    #[Pure]
+    public function __toString(): string {
+        return $this->getMethod() . " " . $this->getPath();
+    }
+
+    protected static function getRecursiveChangeLog(array $initialData, array $newData, string $prefix = ""): array {
+        $messages = [];
+        foreach ($newData as $field => $newValue) {
+            $fieldLabel = $prefix ? $prefix . '.' . $field : $field;
+
+            if (is_array($newValue)) {
+                array_push(
+                    $messages,
+                    ...self::getRecursiveChangeLog(
+                    $initialData[$field] ?? [],
+                    $newValue,
+                    $fieldLabel)
+                );
+            } else if (!array_key_exists($field, $initialData)) {
+                $messages[] = $fieldLabel . ' : (new) => `' . $newValue . '`';
+            } else if ($newValue !== $initialData[$field]) {
+                $messages[] = $fieldLabel . ' : `' . $initialData[$field] . '` => `' . $newValue . '`';
+            }
+        }
+        return $messages;
+    }
+}

+ 71 - 0
src/Service/Rest/Operation/CreateOperation.php

@@ -0,0 +1,71 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Rest\Operation;
+
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * A single create operation (a POST request)
+ */
+class CreateOperation extends BaseRestOperation
+{
+    /**
+     * @param string $label A label for the operation
+     * @param string $entityName The name of the entity to update. This will be used in the path of the request.
+     * @param array $data The data to update, will be post as Json within the request.
+     * @param array $parameters
+     * @param array $options
+     */
+    #[Pure]
+    public function __construct(
+        protected string $label,
+        protected string $entityName,
+        protected array $data,
+        protected array $parameters = [],
+        protected array $options = []
+    ) {
+        $options['json'] = $this->data;
+
+        parent::__construct(
+            $label,
+            'POST',
+            $entityName,
+            [],
+            $parameters,
+            $options
+        );
+    }
+
+    /**
+     * @return string
+     */
+    public function getEntityName(): string
+    {
+        return $this->entityName;
+    }
+
+    /**
+     * @return array
+     */
+    public function getData(): array
+    {
+        return $this->data;
+    }
+
+    /**
+     * Return an array of messages describing the change that this operation will bring
+     *
+     * @return array
+     */
+    public function getChangeLog(): array {
+        $messages = [
+            '[POST ' . $this->entityName . ']'
+        ];
+        array_push(
+            $messages,
+            ...self::getRecursiveChangeLog([], $this->data)
+        );
+        return $messages;
+    }
+}

+ 60 - 0
src/Service/Rest/Operation/DeleteOperation.php

@@ -0,0 +1,60 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Rest\Operation;
+
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * A single delete operation (a DELETE request)
+ */
+class DeleteOperation extends BaseRestOperation
+{
+    /**
+     * @param string $label A label for the operation
+     * @param string $entityName The name of the entity to update. This will be used in the path of the request.
+     * @param int $id
+     * @param array $initialData The data of the existing object, before the update
+     * @param array $options
+     */
+    #[Pure]
+    public function __construct(
+        protected string $label,
+        protected string $entityName,
+        protected int $id,
+        protected array $initialData = [],
+        protected array $options = []
+    ) {
+        parent::__construct(
+            $label,
+            'DELETE',
+            $entityName . '/' . $id,
+            [],
+            [],
+            $options
+        );
+    }
+
+    /**
+     * @return string
+     */
+    public function getEntityName(): string
+    {
+        return $this->entityName;
+    }
+
+    protected function getExpectedResult(): ?array {
+        return null;
+    }
+
+    /**
+     * Return an array of messages describing the change that this operation will bring
+     *
+     * @return array
+     */
+    public function getChangeLog(): array {
+        return [
+            '[DELETE ' . $this->entityName . '/' . $this->id . ']'
+        ];
+    }
+}

+ 75 - 0
src/Service/Rest/Operation/UpdateOperation.php

@@ -0,0 +1,75 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Rest\Operation;
+
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * A single update operation (a PUT request)
+ */
+class UpdateOperation extends BaseRestOperation
+{
+    /**
+     * @param string $label A label for the operation
+     * @param string $entityName The name of the entity to update. This will be used in the path of the request.
+     * @param int $id
+     * @param array $data The data to update, will be post as Json within the request.
+     * @param array $initialData The data of the existing object, before the update
+     * @param array $parameters
+     * @param array $options
+     */
+    #[Pure]
+    public function __construct(
+        protected string $label,
+        protected string $entityName,
+        protected int $id,
+        protected array $data,
+        protected array $initialData = [],
+        protected array $parameters = [],
+        protected array $options = []
+    ) {
+        $options['json'] = $this->data;
+
+        parent::__construct(
+            $label,
+            'PUT',
+            $entityName . '/' . $id,
+            $initialData,
+            $parameters,
+            $options
+        );
+    }
+
+    /**
+     * @return string
+     */
+    public function getEntityName(): string
+    {
+        return $this->entityName;
+    }
+
+    /**
+     * @return array
+     */
+    public function getData(): array
+    {
+        return $this->data;
+    }
+
+    /**
+     * Return an array of messages describing the change that this operation will bring
+     *
+     * @return array
+     */
+    public function getChangeLog(): array {
+        $messages = [
+            '[PUT ' . $this->entityName . '/' . $this->id . ']'
+        ];
+        array_push(
+            $messages,
+            ...self::getRecursiveChangeLog($this->initialData, $this->data)
+        );
+        return $messages;
+    }
+}

+ 45 - 0
src/Service/Utils/ArrayUtils.php

@@ -0,0 +1,45 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Utils;
+
+class ArrayUtils
+{
+    /**
+     * Returns an array containing changes between the first array the keys/values from an array
+     * which are absent or different from $initialData
+     *
+     * If recursive is true, when values are arrays, they'll get processed too to only keep changes. Else,
+     * the whole value will be added to the changes if there is at least one difference between the former and the latter.
+     *
+     * @param array $initialArray
+     * @param array $newArray
+     * @param bool $recursive
+     * @param callable|null $callback An optional callback method to test the equality between to values. The callback shall
+     *                                accept two parameters (the values) and return true if the values are equals.
+     * @return array
+     */
+    public static function getChanges(array $initialArray, array $newArray, bool $recursive = false, ?callable $callback = null): array
+    {
+        $changes = [];
+        foreach ($newArray as $field => $value) {
+            if (!array_key_exists($field, $initialArray)) {
+                $changes[$field] = $value;
+            }
+            elseif ($recursive && is_array($initialArray[$field]) && is_array($value)) {
+                $newVal = self::getChanges($initialArray[$field], $value, $recursive, $callback);
+                if (!empty($newVal)) {
+                    $changes[$field] = $newVal;
+                }
+            }
+            elseif ($callback === null && $value !== $initialArray[$field]) {
+                $changes[$field] = $value;
+            }
+            elseif ($callback !== null && !$callback($value, $initialArray[$field])) {
+                $changes[$field] = $value;
+            }
+        }
+        return $changes;
+    }
+
+}

+ 43 - 0
symfony.lock

@@ -376,6 +376,30 @@
     "symfony/intl": {
         "version": "v5.2.3"
     },
+    "symfony/lock": {
+        "version": "5.3",
+        "recipe": {
+            "repo": "github.com/symfony/recipes",
+            "branch": "master",
+            "version": "5.2",
+            "ref": "a1c8800e40ae735206bb14586fdd6c4630a51b8d"
+        },
+        "files": [
+            "config/packages/lock.yaml"
+        ]
+    },
+    "symfony/mailer": {
+        "version": "5.4",
+        "recipe": {
+            "repo": "github.com/symfony/recipes",
+            "branch": "master",
+            "version": "4.3",
+            "ref": "bbfc7e27257d3a3f12a6fb0a42540a42d9623a37"
+        },
+        "files": [
+            "config/packages/mailer.yaml"
+        ]
+    },
     "symfony/maker-bundle": {
         "version": "1.0",
         "recipe": {
@@ -385,6 +409,9 @@
             "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
         }
     },
+    "symfony/mime": {
+             "version": "v5.3.14"
+    },
     "symfony/messenger": {
         "version": "5.3",
         "recipe": {
@@ -445,6 +472,9 @@
     "symfony/polyfill-intl-idn": {
         "version": "v1.22.1"
     },
+    "symfony/polyfill-intl-messageformatter": {
+        "version": "v1.24.0"
+    },
     "symfony/polyfill-intl-normalizer": {
         "version": "v1.18.1"
     },
@@ -525,6 +555,19 @@
     "symfony/string": {
         "version": "v5.1.7"
     },
+    "symfony/translation": {
+        "version": "5.3",
+        "recipe": {
+            "repo": "github.com/symfony/recipes",
+            "branch": "master",
+            "version": "5.3",
+            "ref": "da64f5a2b6d96f5dc24914517c0350a5f91dee43"
+        },
+        "files": [
+            "config/packages/translation.yaml",
+            "translations/.gitignore"
+        ]
+    },
     "symfony/translation-contracts": {
         "version": "v2.3.0"
     },

+ 7 - 7
tests/Service/Dolibarr/DolibarrAccountCreatorTest.php

@@ -3,20 +3,20 @@
 namespace App\Tests\Service\Dolibarr;
 
 use App\Service\Dolibarr\DolibarrAccountCreator;
-use App\Service\Dolibarr\DolibarrService;
+use App\Service\Dolibarr\DolibarrApiService;
 use PHPUnit\Framework\TestCase;
 
 class DolibarrAccountCreatorTest extends TestCase
 {
-    private DolibarrService $dolibarrService;
+    private DolibarrApiService $dolibarrApiService;
     private DolibarrAccountCreator $dolibarrAccountCreator;
 
     public function setUp(): void {
-        $this->dolibarrService = $this->getMockBuilder(DolibarrService::class)
+        $this->dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
             ->disableOriginalConstructor()
             ->getMock();
 
-        $this->dolibarrAccountCreator = new DolibarrAccountCreator($this->dolibarrService);
+        $this->dolibarrAccountCreator = new DolibarrAccountCreator($this->dolibarrApiService);
     }
 
     private function getJsonContentFromFixture(string $filename): array {
@@ -25,7 +25,7 @@ class DolibarrAccountCreatorTest extends TestCase
     }
 
     public function testGetDolibarrAccount() {
-        $this->dolibarrService
+        $this->dolibarrApiService
             ->expects($this->once())
             ->method('getSociety')
             ->with(1)
@@ -33,7 +33,7 @@ class DolibarrAccountCreatorTest extends TestCase
                 $this->getJsonContentFromFixture('thirdparty.json')[0]
             );
 
-        $this->dolibarrService
+        $this->dolibarrApiService
             ->expects($this->once())
             ->method('getActiveContract')
             ->with(1726)
@@ -41,7 +41,7 @@ class DolibarrAccountCreatorTest extends TestCase
                 $this->getJsonContentFromFixture('contract.json')[0]
             );
 
-        $this->dolibarrService
+        $this->dolibarrApiService
             ->expects($this->once())
             ->method('getBills')
             ->with(1726)

+ 11 - 11
tests/Service/Dolibarr/DolibarrServiceTest.php → tests/Service/Dolibarr/DolibarrApiServiceTest.php

@@ -2,22 +2,22 @@
 
 namespace App\Tests\Service\Dolibarr;
 
-use App\Service\Dolibarr\DolibarrService;
+use App\Service\Dolibarr\DolibarrApiService;
 use PHPUnit\Framework\TestCase;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\Contracts\HttpClient\ResponseInterface;
 
-class DolibarrServiceTest extends TestCase
+class DolibarrApiServiceTest extends TestCase
 {
     private HttpClientInterface $client;
-    private DolibarrService $dolibarrService;
+    private DolibarrApiService $dolibarrApiService;
 
     public function setUp(): void {
         $this->client = $this->getMockBuilder(HttpClientInterface::class)
             ->disableOriginalConstructor()
             ->getMock();
 
-        $this->dolibarrService = new DolibarrService($this->client);
+        $this->dolibarrApiService = new DolibarrApiService($this->client);
     }
 
     private function getContentFromFixture(string $filename): string {
@@ -26,7 +26,7 @@ class DolibarrServiceTest extends TestCase
     }
 
     /**
-     * @see DolibarrService::getSociety()
+     * @see DolibarrApiService::getSociety()
      */
     public function testGetSociety(): void {
         $responseContent = $this->getContentFromFixture('thirdparty.json');
@@ -39,10 +39,10 @@ class DolibarrServiceTest extends TestCase
         $this->client
             ->expects($this->once())
             ->method('request')
-            ->with("GET", "thirdparties?sqlfilters=ref_int%3D1")
+            ->with("GET", "thirdparties?limit=1&sqlfilters=ref_int%3D1")
             ->willReturn($response);
 
-        $data = $this->dolibarrService->getSociety(1);
+        $data = $this->dolibarrApiService->getSociety(1);
 
         $this->assertEquals(
             $data['id'],
@@ -51,7 +51,7 @@ class DolibarrServiceTest extends TestCase
     }
 
     /**
-     * @see DolibarrService::getActiveContract()
+     * @see DolibarrApiService::getActiveContract()
      */
     public function testGetActiveContract(): void {
         $responseContent = $this->getContentFromFixture('contract.json');
@@ -68,13 +68,13 @@ class DolibarrServiceTest extends TestCase
             ->willReturn($response);
 
         $this->assertEquals(
-            $this->dolibarrService->getActiveContract(1)['socid'],
+            $this->dolibarrApiService->getActiveContract(1)['socid'],
             43
         );
     }
 
     /**
-     * @see DolibarrService::getBills()
+     * @see DolibarrApiService::getBills()
      */
     public function testGetBills(): void {
         $responseContent = $this->getContentFromFixture('bills.json');
@@ -91,7 +91,7 @@ class DolibarrServiceTest extends TestCase
             ->willReturn($response);
 
         $this->assertEquals(
-            $this->dolibarrService->getBills(1)[2]['socid'],
+            $this->dolibarrApiService->getBills(1)[2]['socid'],
             284
         );
     }

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

@@ -0,0 +1,725 @@
+<?php
+
+namespace App\Tests\Service\Dolibarr;
+
+use App\Entity\Access\Access;
+use App\Entity\Access\FunctionType;
+use App\Entity\Core\AddressPostal;
+use App\Entity\Core\ContactPoint;
+use App\Entity\Network\Network;
+use App\Entity\Network\NetworkOrganization;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\OrganizationAddressPostal;
+use App\Entity\Organization\Settings;
+use App\Entity\Person\Person;
+use App\Enum\Access\FunctionEnum;
+use App\Enum\Access\RoleEnum;
+use App\Enum\Core\ContactPointTypeEnum;
+use App\Enum\Organization\AddressPostalOrganizationTypeEnum;
+use App\Enum\Organization\SettingsProductEnum;
+use App\Repository\Access\AccessRepository;
+use App\Repository\Access\FunctionTypeRepository;
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\Dolibarr\DolibarrApiService;
+use App\Service\Dolibarr\DolibarrSyncService;
+use App\Service\Rest\Operation\CreateOperation;
+use App\Service\Rest\Operation\UpdateOperation;
+use Doctrine\Common\Collections\ArrayCollection;
+use JetBrains\PhpStorm\Pure;
+use libphonenumber\PhoneNumber;
+use libphonenumber\PhoneNumberUtil;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+class TestableDolibarrSyncService extends DolibarrSyncService {
+    public function getDolibarrSocietiesIndex(): array { return parent::getDolibarrSocietiesIndex(); }
+    public static function findDolibarrContactFor(array $dolibarrContacts, Person $person): ?array { return parent::findDolibarrContactFor($dolibarrContacts, $person); }
+    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 getChanges(array $initialData, array $newData): array { return parent::getChanges($initialData, $newData); }
+}
+
+class DolibarrSyncServiceTest extends TestCase
+{
+    private OrganizationRepository $organizationRepository;
+    private AccessRepository $accessRepository;
+    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->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();
+
+        $this->logger->method('info')->willReturnSelf();
+        $this->logger->method('debug')->willReturnSelf();
+        $this->logger->method('warning')->willReturnSelf();
+        $this->logger->method('error')->willReturnSelf();
+    }
+
+    #[Pure]
+    private function newDolibarrSyncService(): TestableDolibarrSyncService
+    {
+        return new TestableDolibarrSyncService(
+             $this->organizationRepository,
+             $this->accessRepository,
+             $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 testScan() {
+
+        // mock services and special methods from repos
+        $this->dolibarrApiService
+            ->expects($this->once())
+            ->method('getAllClients')
+            ->willReturn(
+                $this->getJsonContentFromFixture('thirdparty.json')
+            );
+
+        $this->dolibarrApiService->method('getSociety')->willReturnMap(
+            [
+                [12097, ['id' => 711]],
+                [91295, ['id' => 5086]]
+            ]
+        );
+        $this->accessRepository
+            ->expects($this->once())
+            ->method('getAllActiveMembersAndMissions')
+            ->willReturn(
+                [
+                    ['id' => 1, 'organization_id' => 37306, 'mission' => FunctionEnum::PRESIDENT()->getValue()],
+                    ['id' => 1, 'organization_id' => 37306, 'mission' => FunctionEnum::ADHERENT()->getValue()],
+                    ['id' => 2, 'organization_id' => 37306, 'mission' => FunctionEnum::TREASURER()->getValue()],
+                    ['id' => 2, 'organization_id' => 37306, 'mission' => FunctionEnum::ADHERENT()->getValue()],
+                    ['id' => 3, 'organization_id' => 37306, 'mission' => FunctionEnum::STUDENT()->getValue()],
+                    ['id' => 3, 'organization_id' => 37306, 'mission' => FunctionEnum::ADHERENT()->getValue()],
+                ]
+            );
+
+        // Function types
+        $functionType1 = $this->getMockBuilder(FunctionType::class)->getMock();
+        $functionType1->method('getMission')->willReturn(FunctionEnum::DIRECTOR()->getValue());
+        $functionType2 = $this->getMockBuilder(FunctionType::class)->getMock();
+        $functionType2->method('getMission')->willReturn(FunctionEnum::PRESIDENT()->getValue());
+        $this->functionTypeRepository
+            ->expects($this->once())
+            ->method('findBy')
+            ->with(['roleByDefault' => RoleEnum::ROLE_ADMIN()->getValue()])
+            ->willReturn([$functionType1, $functionType2]);
+
+        // Organization's name
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(37306);
+        $organization->method('getName')->willReturn("Etablissement d'Enseignement Artistique");
+
+        // Postal address
+        $organizationAddressPostal = $this->getMockBuilder(OrganizationAddressPostal::class)->getMock();
+        $addressPostal = $this->getMockBuilder(AddressPostal::class)->getMock();
+        $addressPostal->method('getStreetAddress')->willReturn('21b baker street');
+        $addressPostal->method('getStreetAddressSecond')->willReturn('');
+        $addressPostal->method('getStreetAddressThird')->willReturn('');
+        $addressPostal->method('getAddressOwner')->willReturn('');
+        $addressPostal->method('getPostalCode')->willReturn('250 329');
+        $addressPostal->method('getAddressCity')->willReturn('Londres');
+        $organizationAddressPostal->method('getType')->willReturn(AddressPostalOrganizationTypeEnum::ADDRESS_CONTACT()->getValue());
+        $organizationAddressPostal->method('getAddressPostal')->willReturn($addressPostal);
+        $organization->method('getOrganizationAddressPostals')->willReturn(
+            new ArrayCollection([$organizationAddressPostal])
+        );
+
+        // Email and phone
+        $phoneUtil = PhoneNumberUtil::getInstance();
+
+        $contactPoint = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $contactPoint->method('getContactType')->willReturn(ContactPointTypeEnum::CONTACT()->getValue());
+        $contactPoint->method('getEmail')->willReturn('email@email.com');
+        $phoneNumber = $phoneUtil->parse('01 02 03 04 05', 'FR');
+        $contactPoint->method('getTelphone')->willReturn($phoneNumber);
+        $organization->method('getContactPoints')->willReturn(
+            new ArrayCollection([$contactPoint])
+        );
+
+        // Network
+        $network = $this->getMockBuilder(Network::class)->getMock();
+        $network->method('getId')->willReturn(91295);
+        $networkOrganization = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+        $networkOrganization->method('getNetwork')->willReturn($network);
+        $organization->method('getNetworkOrganizations')->willReturn(new ArrayCollection([$networkOrganization]));
+
+        // Product
+        $settings = $this->getMockBuilder(Settings::class)->getMock();
+        $settings->method('getProduct')->willReturn(SettingsProductEnum::SCHOOL()->getValue());
+        $organization->method('getSettings')->willReturn($settings);
+
+        // Get dolibarr contacts
+        $this->dolibarrApiService
+        ->method('getContacts')
+        ->with(1726)
+        ->willReturn(
+            array_filter(
+                $this->getJsonContentFromFixture('contacts.json'),
+                static function ($c) {
+                    return in_array(
+                        (int)$c["array_options"]["options_2iopen_person_id"],
+                        [
+                            108939, // existing person, to be updated
+                            156252  // no matching person, to be deleted
+                                   // a new contact shall be created too
+                        ]
+                    ); }
+            )
+        );
+
+        $this->organizationRepository->method('find')->willReturn($organization);
+
+        $access1 = $this->getMockBuilder(Access::class)->getMock();
+        $access1->method('getId')->willReturn(1);
+
+        $person1 = $this->getMockBuilder(Person::class)->getMock();
+        $person1->method('getId')->willReturn(108939);
+        $person1->method('getName')->willReturn('Holmes');
+        $person1->method('getGender')->willReturn('MISTER');
+        $person1->method('getGivenName')->willReturn('Sherlock');
+        $person1->method('getGivenName')->willReturn('Sherlock');
+
+        $personContactPoint1 = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $personContactPoint1->method('getContactType')->willReturn(ContactPointTypeEnum::CONTACT()->getValue());
+        $personContactPoint1->method('getEmail')->willReturn('sherlock@holmes.com');
+        $phoneNumber1 = $phoneUtil->parse('02 98 76 54 32', 'FR');
+        $personContactPoint1->method('getTelphone')->willReturn($phoneNumber1);
+        $personContactPoint1->method('getMobilPhone')->willReturn(null);
+        $person1->method('getContactPoints')->willReturn(
+            new ArrayCollection([$personContactPoint1])
+        );
+        $access1->method('getPerson')->willReturn($person1);
+
+        $access2 = $this->getMockBuilder(Access::class)->getMock();
+        $access2->method('getId')->willReturn(1);
+
+        $person2 = $this->getMockBuilder(Person::class)->getMock();
+        $person2->method('getId')->willReturn(1000);
+        $person2->method('getName')->willReturn('Watson');
+        $person2->method('getGender')->willReturn('MISTER');
+        $person2->method('getGivenName')->willReturn('John');
+        $person2->method('getGivenName')->willReturn('John');
+
+        $personContactPoint2 = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $personContactPoint2->method('getContactType')->willReturn(ContactPointTypeEnum::CONTACT()->getValue());
+        $personContactPoint2->method('getEmail')->willReturn('docteur@watson.com');
+        $phoneNumber2 = $phoneUtil->parse('02 10 11 12 13', 'FR');
+        $personContactPoint2->method('getTelphone')->willReturn($phoneNumber2);
+        $personContactPoint2->method('getMobilPhone')->willReturn(null);
+        $person2->method('getContactPoints')->willReturn(
+            new ArrayCollection([$personContactPoint2])
+        );
+        $access2->method('getPerson')->willReturn($person2);
+
+        $this->accessRepository->method('find')->willReturnMap(
+            [
+                [1, null, null, $access1],
+                [2, null, null, $access2],
+            ]
+        );
+
+        $this->translator->method('trans')->willReturnMap(
+            [
+                ['STUDENTS_COUNT', [], null, null, "Nombre d'élèves"],
+                ['ADHERENTS_COUNT', [], null, null, "Nombre d'adhérents"],
+                ['ADMIN_ACCESS_COUNT', [], null, null, "Nombre d'accès admin"],
+            ]
+        );
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $operations = $syncService->scan();
+
+        $this->assertCount(4, $operations);
+
+        $this->assertEquals(
+            [
+                '[PUT thirdparties/1726]',
+                "address : `\n217, rue Raoul Follereau\n` => `21b baker street`",
+                'zip : `74300` => `250 329`',
+                'town : `CLUSES` => `Londres`',
+                'email : `` => `email@email.com`',
+                'phone : `+33678403010` => `+33102030405`',
+                'parent : `` => `5086`',
+                "array_options.options_2iopeninfoopentalent : `` => `Nombre d'élèves : 1\nNombre d'adhérents : 3\nNombre d'accès admin : 1`"
+            ],
+            $operations[0]->getChangeLog()
+        );
+        $this->assertEquals(
+            [
+                '[PUT contacts/5868]',
+                'civility_code : `MME` => ``',
+                'lastname : `DUPONT` => `Holmes`',
+                'firstname : `Valerie` => `Sherlock`',
+                'email : `abcd@hotmail.com` => ``',
+                'phone_pro : `+33478570000` => ``',
+                'phone_mobile : `+33682980000` => ``',
+                'poste : `Secrétaire` => ``'
+            ],
+            $operations[1]->getChangeLog()
+        );
+        $this->assertEquals(
+            [
+                '[POST contacts]',
+                'civility_code : (new) => ``',
+                'lastname : (new) => `Watson`',
+                'firstname : (new) => `John`',
+                'email : (new) => ``',
+                'phone_pro : (new) => ``',
+                'phone_mobile : (new) => ``',
+                'poste : (new) => ``',
+                'statut : (new) => `1`',
+                'array_options.options_2iopen_person_id : (new) => `1000`',
+                'socid : (new) => `1726`'
+            ],
+            $operations[2]->getChangeLog()
+        );
+        $this->assertEquals(
+            ['[PUT contacts/5869]', 'statut : `1` => `0`'],
+            $operations[3]->getChangeLog()
+        );
+    }
+
+    public function testExecuteError()
+    {
+        $operation = new CreateOperation('operation 1', 'thirdparty', ['data' => 1]);
+        $this->assertEquals($operation->getStatus(), $operation::STATUS_READY);
+
+        $responseError = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $responseError->method('getStatusCode')->willReturn(500);
+        $this->dolibarrApiService->method('request')->willReturn($responseError);
+
+        // POST operation will returned a server error
+        $syncService = $this->newDolibarrSyncService();
+        $operation = $syncService->execute([$operation])[0];
+        $this->assertEquals($operation::STATUS_ERROR, $operation->getStatus());
+    }
+
+    public function testExecuteInvalid()
+    {
+        $operation = new UpdateOperation('operation 1', 'thirdparty', 1, ['data' => 1]);
+        $responseInvalid = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $responseInvalid->method('getStatusCode')->willReturn(200);
+        $responseInvalid->method('toArray')->willReturn(['data' => 0]);
+        $this->dolibarrApiService->method('request')->willReturn($responseInvalid);
+
+        // POST operation will return a different content that the one which were posted, this should log a warning
+        $this->logger->expects($this->once())->method('warning');
+
+        $syncService = $this->newDolibarrSyncService();
+        $operation = $syncService->execute([$operation])[0];
+        $this->assertEquals($operation::STATUS_DONE, $operation->getStatus());
+    }
+
+    public function testExecuteOk() {
+        $operation = new CreateOperation('operation 1', 'thirdparty', ['data' => 1]);
+        $responseOk = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $responseOk->method('getStatusCode')->willReturn(200);
+        $responseOk->method('toArray')->willReturn(['data' => 1]);
+        $this->dolibarrApiService->method('request')->willReturn($responseOk);
+
+        $syncService = $this->newDolibarrSyncService();
+        $operation = $syncService->execute([$operation])[0];
+        $this->assertEquals($operation::STATUS_DONE, $operation->getStatus());
+    }
+
+    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 testFindDolibarrContactFor() {
+
+        $contacts = $this->getJsonContentFromFixture('contacts.json');
+
+        // Find by id
+        $person1 = $this->getMockBuilder(Person::class)->getMock();
+        $person1->method('getId')->willReturn(108939);
+
+        $contact1 = TestableDolibarrSyncService::findDolibarrContactFor($contacts, $person1);
+        $this->assertEquals("5868", $contact1['id']);
+
+        // Find by full name (contact already has another person id, it should not be returned)
+        $person2 = $this->getMockBuilder(Person::class)->getMock();
+        $person2->method('getId')->willReturn(-1);
+        $person2->method('getName')->willReturn('dupont');
+        $person2->method('getGivenName')->willReturn('Valerie');
+
+        $contact2 = TestableDolibarrSyncService::findDolibarrContactFor($contacts, $person2);
+        $this->assertEquals(null, $contact2);
+
+        // Find by full name (contact has no person id, it should be returned)
+        $person3 = $this->getMockBuilder(Person::class)->getMock();
+        $person3->method('getId')->willReturn(-1);
+        $person3->method('getName')->willReturn('ZORRO');
+        $person3->method('getGivenName')->willReturn('Fabrice');
+
+        $contact3 = TestableDolibarrSyncService::findDolibarrContactFor($contacts, $person3);
+        $this->assertEquals("5872", $contact3['id']);
+
+        // Do not find
+        $person4 = $this->getMockBuilder(Person::class)->getMock();
+        $person4->method('getId')->willReturn(-1);
+
+        $contact4 = TestableDolibarrSyncService::findDolibarrContactFor($contacts, $person4);
+        $this->assertEquals(null, $contact4);
+    }
+
+    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(
+                new ArrayCollection([$organizationAddressPostal1, $organizationAddressPostal2, $organizationAddressPostal3])
+            );
+
+        $syncService = $this->newDolibarrSyncService($organization);
+
+        $this->assertEquals(
+            $addressPostal,
+            $syncService->getOrganizationPostalAddress($organization)
+        );
+
+        $organization2 = $this->getMockBuilder(Organization::class)->getMock();
+        $organization2->expects($this->once())
+            ->method('getOrganizationAddressPostals')
+            ->willReturn(new ArrayCollection([]));
+
+        $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());
+
+        $phoneUtil = PhoneNumberUtil::getInstance();
+        $phoneNumber = $phoneUtil->parse('0161626365', "FR");
+        $contactPoint2->method('getTelphone')->willReturn($phoneNumber);
+
+        $organization
+            ->expects($this->once())
+            ->method('getContactPoints')
+            ->willReturn(
+                new ArrayCollection([$contactPoint1, $contactPoint2, $contactPoint3])
+            );
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            '+33161626365',
+            $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);
+
+        $phoneUtil = PhoneNumberUtil::getInstance();
+        $phoneNumber = $phoneUtil->parse('0661626365', "FR");
+        $contactPoint2->method('getMobilPhone')->willReturn($phoneNumber);
+
+        $organization
+            ->expects($this->once())
+            ->method('getContactPoints')
+            ->willReturn(
+                new ArrayCollection([$contactPoint1, $contactPoint2, $contactPoint3])
+            );
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            '+33661626365',
+            $syncService->getOrganizationPhone($organization)
+        );
+    }
+
+    public function testGetOrganizationPhoneWithNoPhone() {
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization
+            ->expects($this->once())
+            ->method('getContactPoints')
+            ->willReturn(new ArrayCollection([]));
+
+        $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(
+                new ArrayCollection([$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(new ArrayCollection([]));
+
+        $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(new ArrayCollection([$contactPoint1, $contactPoint2]));
+
+        $syncService = $this->newDolibarrSyncService();
+
+        $this->assertEquals(
+            $contactPoint2,
+            $syncService->getPersonContact($person)
+        );
+
+        $person2 = $this->getMockBuilder(Person::class)->getMock();
+        $person2->expects($this->once())->method('getContactPoints')->willReturn(new ArrayCollection([]));
+        $this->assertEquals(null, $syncService->getPersonContact($person2));
+    }
+
+    public function testFormatContactPosition() {
+        $this->translator->method('trans')->willReturnMap(
+            [
+                [FunctionEnum::PRESIDENT()->getValue(), ['gender' => 'X'], null, null, 'Président(e)'],
+                [FunctionEnum::PRESIDENT()->getValue(), ['gender' => 'M'], null, null, 'Président'],
+                [FunctionEnum::PRESIDENT()->getValue(), ['gender' => 'F'], null, null, 'Présidente'],
+                [FunctionEnum::DIRECTOR()->getValue(), ['gender' => 'X'], null, null, 'Directeur(ice)'],
+                [FunctionEnum::DIRECTOR()->getValue(), ['gender' => 'M'], null, null, 'Directeur'],
+                [FunctionEnum::DIRECTOR()->getValue(), ['gender' => 'F'], null, null, 'Directrice'],
+            ]
+        );
+
+        $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() {
+        $phoneUtil = PhoneNumberUtil::getInstance();
+        $phoneNumber = $phoneUtil->parse('01 02 03 04 05', "FR");
+        $this->assertEquals(
+            '+33102030405',
+            TestableDolibarrSyncService::formatPhoneNumber($phoneNumber)
+        );
+    }
+}

+ 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": null
+    },
+    "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"
+  }
+]

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

@@ -0,0 +1,798 @@
+[
+  {
+    "entity": "1",
+    "name": "Etablissement d'Enseignement Artistique",
+    "name_alias": null,
+    "address": "\n217, rue Raoul Follereau\n",
+    "zip": "74300",
+    "town": "CLUSES",
+    "status": "1",
+    "state_id": "81",
+    "state_code": "74",
+    "state": "Haute-Savoie",
+    "phone": "+33678403010",
+    "fax": null,
+    "email": null,
+    "skype": null,
+    "twitter": null,
+    "facebook": null,
+    "linkedin": null,
+    "url": null,
+    "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": "812",
+    "forme_juridique": null,
+    "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": "001855",
+    "code_fournisseur": null,
+    "code_compta": "0001855",
+    "code_compta_client": null,
+    "code_compta_fournisseur": null,
+    "note_private": null,
+    "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": null,
+    "default_lang": null,
+    "ref": "1726",
+    "ref_ext": null,
+    "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_sirene_status": null,
+      "options_b4d_spe_name": null,
+      "options_sirene_update_date": "",
+      "options_2iopen_domain": "7",
+      "options_2iopen_structure_type": "12",
+      "options_2iopen_nombre_eleves": null,
+      "options_2iopen_software_used": "4",
+      "options_2iopen_num_portable": null,
+      "options_2iopen_structure_type_cmf": null,
+      "options_2iopen_organization_id": "37306"
+    },
+    "fk_incoterms": null,
+    "location_incoterms": null,
+    "libelle_incoterms": null,
+    "fk_multicurrency": null,
+    "multicurrency_code": null,
+    "id": "1726",
+    "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": "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
+  }
+]

+ 3 - 1
tests/Service/Dolibarr/fixtures/thirdparty.json

@@ -92,7 +92,9 @@
       "options_2iopen_nombre_eleves": null,
       "options_2iopen_software_used": "4",
       "options_2iopen_num_portable": null,
-      "options_2iopen_structure_type_cmf": null
+      "options_2iopen_structure_type_cmf": null,
+      "options_2iopen_organization_id": "37306",
+      "options_2iopeninfoopentalent": ""
     },
     "fk_incoterms": null,
     "location_incoterms": null,

+ 8 - 9
tests/Service/ApiRequestServiceTest.php → tests/Service/Rest/ApiRequestServiceTest.php

@@ -2,18 +2,17 @@
 
 namespace App\Tests\Service;
 
-use App\Service\ApiRequestService;
+use App\Service\Rest\ApiRequestService;
 use PHPUnit\Framework\TestCase;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\Contracts\HttpClient\ResponseInterface;
 
 class ApiRequestServiceTest extends TestCase
 {
-    private HttpClientInterface $client;
     private ApiRequestService $apiRequestService;
 
     public function setUp(): void {
-        $this->client = $this->getMockBuilder(HttpClientInterface::class)
+        $client = $this->getMockBuilder(HttpClientInterface::class)
             ->disableOriginalConstructor()
             ->getMock();
 
@@ -22,26 +21,26 @@ class ApiRequestServiceTest extends TestCase
             ->getMock();
         $response->method('getContent')->willReturn('{"a": 1}');
 
-        $this->client
+        $client
             ->expects($this->once())
             ->method('request')
             ->with("GET", "my_url.org")
             ->willReturn($response);
 
-        $this->apiRequestService = new ApiRequestService($this->client);
+        $this->apiRequestService = new ApiRequestService($client);
     }
 
     public function testGetJsonContent() {
         $this->assertEquals(
-            $this->apiRequestService->getJsonContent('my_url.org'),
-            ['a' => 1]
+            ['a' => 1],
+            $this->apiRequestService->getJsonContent('my_url.org')
         );
     }
 
     public function testGetContent() {
         $this->assertEquals(
-            $this->apiRequestService->getContent('my_url.org'),
-            '{"a": 1}'
+            '{"a": 1}',
+            $this->apiRequestService->getContent('my_url.org')
         );
     }
 }

+ 126 - 0
tests/Service/Rest/Operation/BaseRestOperationTest.php

@@ -0,0 +1,126 @@
+<?php
+
+use App\Service\Rest\ApiRequestService;
+use App\Service\Rest\Operation\BaseRestOperation;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpClient\Exception\ClientException;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+class TestableBaseRestOperation extends BaseRestOperation {
+    public function getChangeLog(): array { return []; }
+}
+
+class BaseRestOperationTest extends TestCase
+{
+    private ApiRequestService $apiRequestService;
+
+    public function setUp(): void {
+        $this->apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    public function testGetters() {
+        $operation = new TestableBaseRestOperation(
+            'a label',
+            'GET',
+            '/a/path',
+            ['data' => 1],
+            ['param' => 2],
+            ['option' => 3]
+        );
+
+        $this->assertEquals('a label', $operation->getLabel());
+        $this->assertEquals('GET', $operation->getMethod());
+        $this->assertEquals('/a/path', $operation->getPath());
+        $this->assertEquals(['data' => 1], $operation->getInitialData());
+        $this->assertEquals(['param' => 2], $operation->getParameters());
+        $this->assertEquals(['option' => 3], $operation->getOptions());
+
+        $this->assertEquals('GET /a/path', (string)$operation);
+    }
+
+    /**
+     * Test execution with a valid request
+     */
+    public function testExecuteValid()
+    {
+        $operation = new TestableBaseRestOperation(
+            'Update entity 1', 'PUT', 'entity/1', [], [], ['json' => '{"a":1}']
+        );
+
+        $responseOk = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $responseOk->method('getStatusCode')->willReturn(200);
+
+        $this->apiRequestService
+            ->expects($this->once())
+            ->method('request')
+            ->with('PUT', 'entity/1', [], ['json' => '{"a":1}'])
+            ->willReturn($responseOk);
+
+        $operation->execute($this->apiRequestService);
+        $this->assertEquals(BaseRestOperation::STATUS_DONE, $operation->getStatus());
+        $this->assertEquals("", $operation->getErrorMessage());
+    }
+
+    /**
+     * Test execution with an invalid request (api returns an error 404 for example)
+     */
+    public function testExecuteInvalid()
+    {
+        $operation = new TestableBaseRestOperation(
+            'Update entity 1', 'PUT', 'entity/2'
+        );
+
+        $responseError = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $responseError->method('getStatusCode')->willReturn(404);
+        $responseError->method('getContent')->willReturn('Not found');
+
+        $this->apiRequestService
+            ->expects($this->once())
+            ->method('request')
+            ->with('PUT', 'entity/2')
+            ->willReturn($responseError);
+
+        try {
+            $operation->execute($this->apiRequestService);
+        } catch (RuntimeException) {
+        }
+        $this->assertEquals(BaseRestOperation::STATUS_ERROR, $operation->getStatus());
+        $this->assertMatchesRegularExpression('/.*Not found.*/', $operation->getErrorMessage());
+    }
+
+    /**
+     * Test execution if the request throw an HTTP exception
+     */
+    public function testExecutionError() {
+        $operation = new TestableBaseRestOperation(
+            'Update entity 1', 'PUT', 'entity/3'
+        );
+
+        $responseException = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $responseException->method('getStatusCode')->willReturn(500);
+        $responseException->method('getContent')->willReturn('The server said the request is bad');
+        $responseException->method('getInfo')->willReturnMap([
+            ['http_code', 500],
+            ['url', 'entity/3'],
+            ['response_headers', ['content-type: json']]
+        ]);
+
+        $this->apiRequestService
+            ->expects($this->once())
+            ->method('request')
+            ->with('PUT', 'entity/3')
+            ->willThrowException(new ClientException($responseException));
+
+        try {
+            $operation->execute($this->apiRequestService);
+        } catch (RuntimeException) {
+        }
+        $this->assertEquals(BaseRestOperation::STATUS_ERROR, $operation->getStatus());
+        $this->assertMatchesRegularExpression(
+            '/.*ClientException: HTTP 500 returned for "entity\/3".*/',
+            $operation->getErrorMessage()
+        );
+    }
+}

+ 42 - 0
tests/Service/Rest/Operation/CreateOperationTest.php

@@ -0,0 +1,42 @@
+<?php
+
+use App\Service\Rest\Operation\CreateOperation;
+use PHPUnit\Framework\TestCase;
+
+class CreateOperationTest extends TestCase
+{
+    public function testGetters() {
+        $operation = new CreateOperation(
+            'Create a dinosaur',
+            'dinosaur',
+            ['name' => 'denver']
+        );
+
+        $this->assertEquals('POST', $operation->getMethod());
+        $this->assertEquals('dinosaur', $operation->getEntityName());
+        $this->assertEquals('dinosaur', $operation->getPath());
+        $this->assertEquals(['name' => 'denver'], $operation->getData());
+
+        $this->assertEquals('POST dinosaur', (string)$operation);
+    }
+
+    public function testGetChangeLog() {
+        $operation = new CreateOperation(
+            'Create a dinosaur',
+            'dinosaur',
+            ['name' => 'denver', 'color' => 'green', 'objects' => ['glasses' => 'pink', 'guitar' => 'electric']]
+        );
+
+        $this->assertEquals(
+            [
+                '[POST dinosaur]',
+                'name : (new) => `denver`',
+                'color : (new) => `green`',
+                'objects.glasses : (new) => `pink`',
+                'objects.guitar : (new) => `electric`',
+            ],
+            $operation->getChangeLog()
+        );
+    }
+
+}

+ 36 - 0
tests/Service/Rest/Operation/DeleteOperationTest.php

@@ -0,0 +1,36 @@
+<?php
+
+use App\Service\Rest\Operation\CreateOperation;
+use App\Service\Rest\Operation\DeleteOperation;
+use PHPUnit\Framework\TestCase;
+
+class DeleteOperationTest extends TestCase
+{
+    public function testGetters() {
+        $operation = new DeleteOperation(
+            'Delete a dinosaur',
+            'dinosaur',
+            1
+        );
+
+        $this->assertEquals('DELETE', $operation->getMethod());
+        $this->assertEquals('dinosaur', $operation->getEntityName());
+        $this->assertEquals('dinosaur/1', $operation->getPath());
+
+        $this->assertEquals('DELETE dinosaur/1', (string)$operation);
+    }
+
+    public function testGetChangeLog() {
+        $operation = new DeleteOperation(
+            'Delete a dinosaur',
+            'dinosaur',
+            1
+        );
+
+        $this->assertEquals(
+            ['[DELETE dinosaur/1]'],
+            $operation->getChangeLog()
+        );
+    }
+
+}

+ 48 - 0
tests/Service/Rest/Operation/UpdateOperationTest.php

@@ -0,0 +1,48 @@
+<?php
+
+use App\Service\Rest\Operation\CreateOperation;
+use App\Service\Rest\Operation\UpdateOperation;
+use PHPUnit\Framework\TestCase;
+
+class UpdateOperationTest extends TestCase
+{
+    public function testGetters() {
+        $operation = new UpdateOperation(
+            'Update a dinosaur',
+            'dinosaur',
+            1,
+            ['weight' => 1800],
+            ['weight' => 1600]
+        );
+
+        $this->assertEquals('PUT', $operation->getMethod());
+        $this->assertEquals('dinosaur', $operation->getEntityName());
+        $this->assertEquals('dinosaur/1', $operation->getPath());
+        $this->assertEquals(['weight' => 1600], $operation->getInitialData());
+        $this->assertEquals(['weight' => 1800], $operation->getData());
+
+        $this->assertEquals('PUT dinosaur/1', (string)$operation);
+    }
+
+    public function testGetChangeLog() {
+        $operation = new UpdateOperation(
+            'Update a dinosaur',
+            'dinosaur',
+            1,
+            ['weight' => 1800, 'attrs' => ['vision' => 'movement-based', 'teeth' => '99', 'adn' => ['1' => 'b'], 'color' => 'green']],
+            ['weight' => 1600, 'attrs' => ['vision' => 'movement-based', 'teeth' => '100', 'adn' => ['1' => 'a']]]
+        );
+
+        $this->assertEquals(
+            [
+                '[PUT dinosaur/1]',
+                'weight : `1600` => `1800`',
+                'attrs.teeth : `100` => `99`',
+                'attrs.adn.1 : `a` => `b`',
+                'attrs.color : (new) => `green`',
+            ],
+            $operation->getChangeLog()
+        );
+    }
+
+}

+ 79 - 0
tests/Service/Utils/ArrayUtilsTest.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Tests\Service\Utils;
+
+use App\Service\Utils\ArrayUtils;
+use PHPUnit\Framework\TestCase;
+
+class ArrayUtilsTest extends TestCase
+{
+    public function testGetChanges(): void
+    {
+        // Non-recursive (default)
+        $this->assertEquals(
+            ['b' => -2, 'c' => ['d' => 4, 'e' => ['f' => -5]], 'g' => 7],
+            ArrayUtils::getChanges(
+                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
+                ['a' => 1, 'b' => -2, 'c' => ['d' => 4, 'e' => ['f' => -5]], 'g' => 7],
+            )
+        );
+
+        // Recursive
+        $this->assertEquals(
+            ['b' => -2, 'c' => ['e' => ['f' => -5]], 'g' => 7],
+            ArrayUtils::getChanges(
+                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
+                ['a' => 1, 'b' => -2, 'c' => ['d' => 4, 'e' => ['f' => -5]], 'g' => 7],
+                true
+            )
+        );
+
+        // Recursive with unchanged sub array
+        $this->assertEquals(
+            ['b' => -2],
+            ArrayUtils::getChanges(
+                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
+                ['a' => 1, 'b' => -2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
+                true
+            )
+        );
+
+        // No changes
+        $this->assertEquals(
+            [],
+            ArrayUtils::getChanges(
+                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
+                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
+            )
+        );
+
+        // Empty arrays
+        $this->assertEquals(
+            [],
+            ArrayUtils::getChanges(
+                [],
+                [],
+            )
+        );
+
+        // First array is empty
+        $this->assertEquals(
+            ['a' => 1],
+            ArrayUtils::getChanges(
+                [],
+                ['a' => 1],
+            )
+        );
+
+        // With callback
+        $this->assertEquals(
+            ['a' => 2],
+            ArrayUtils::getChanges(
+                ['a' => 1, 'b' => ''],
+                ['a' => 2, 'b' => null],
+                false,
+                static function ($v1, $v2) { return ($v1 ?? '') === ($v2 ?? ''); }
+            )
+        );
+    }
+}

+ 192 - 0
translations/enum/missions/messages+intl-icu.fr.yaml

@@ -0,0 +1,192 @@
+STUDENT: Elève
+TEACHER: >-
+  {gender, select,
+  M      {Professeur}
+  F      {Professeure}
+  other  {Professeur(e)}
+  }
+DIRECTOR: >-
+  {gender, select,
+  M      {Directeur pédagogique}
+  F      {Directrice pédagogique}
+  other  {Directeur(ice) pédagogique}
+  }
+DIRECTOR_ASSISTANT: >-
+  {gender, select,
+  M      {Directeur pédagogique adjoint}
+  F      {Directrice pédagogique adjointe}
+  other  {Directeur(ice) pédagogique adjoint(e)}
+  }
+INITIATOR: >-
+  {gender, select,
+  M      {Initiateur}
+  F      {Initiatrice}
+  other  {Initiateur(ice)}
+  }
+MONITOR: >-
+  {gender, select,
+  M      {Moniteur}
+  F      {Monitrice}
+  other  {Moniteur(ice)}
+  }
+MUSIC_DIRECTOR_AND_HEAD: >-
+  {gender, select,
+  M      {Directeur musical ou Chef}
+  F      {Directrice musicale ou Cheffe}
+  other  {Directeur(ice) musical(e) ou Chef(fe)}
+  }
+MUSIC_DIRECTOR_AND_HEAD_ASSISTANT: >-
+  {gender, select,
+  M      {Directeur musical ou Chef adjoint}
+  F      {Directrice musicale ou Cheffe adjointe}
+  other  {Directeur(ice) musical(e) ou Chef(fe) adjoint(e)}
+  }
+DESK_OFFICER: >-
+  {gender, select,
+  M      {Chef de pupitre}
+  F      {Cheffe de pupitre}
+  other  {Chef(fe) de pupitre}
+  }
+ADMINISTRATIVE_OFFICER:  >-
+  {gender, select,
+  M      {Responsable administratif}
+  F      {Responsable administrative}
+  other  {Responsable administratif(ve)}
+  }
+ADMINISTRATIVE_SECRETARY: >-
+  {gender, select,
+  M      {Secrétaire administratif}
+  F      {Secrétaire administrative}
+  other  {Secrétaire administratif(ve)}
+  }
+ADMINISTRATIVE_DIRECTOR: >-
+  {gender, select,
+  M      {Directeur administratif}
+  F      {Directrice administrative}
+  other  {Directeur(ice) administratif(ve)}
+  }
+ADMINISTRATIVE_DIRECTOR_ASSISTANT: >-
+  {gender, select,
+  M      {Directeur administratif adjoint}
+  F      {Directrice administrative adjointe}
+  other  {Directeur(ice) administratif(ve) adjoint(e)}
+  }
+ARCHIVIST: Archiviste
+PRESENTER:  >-
+  {gender, select,
+  M      {Présentateur}
+  F      {Présentatrice}
+  other  {Présentateur(ice)}
+  }
+ADMINISTRATIVE_STAFF: Personnel administratif
+NETWORK_ANIMATOR: >-
+  {gender, select,
+  M      {Animateur réseau}
+  F      {Animatrice réseau}
+  other  {Animateur(ice) réseau}
+  }
+CORRESPONDING: >-
+  {gender, select,
+  M      {Correspondant}
+  F      {Correspondante}
+  other  {Correspondant(e)}
+  }
+COORDINATOR: >-
+  {gender, select,
+  M      {Coordinateur}
+  F      {Coordinatrice}
+  other  {Coordinateur(ice)}
+  }
+TECHNICAL_STAFF: Personnel technique
+ACCOUNTANT: Comptable
+ACTIVE_MEMBER_OF_THE_CA: >-
+  {gender, select,
+  M      {Membre actif du CA}
+  F      {Membre active du CA}
+  other  {Membre actif du CA}
+  }
+HONORARY_PRESIDENT: >-
+  {gender, select,
+  M      {Président d'honneur}
+  F      {Présidente d'honneur}
+  other  {Président(e) d'honneur}
+  }
+PRESIDENT: >-
+  {gender, select,
+  M {Président}
+  F {Présidente}
+  other {Président(e)}
+  }
+YOUTH_REPRESENTATIVE: >-
+  {gender, select,
+  M      {Représentant des jeunes}
+  F      {Représentante des jeunes}
+  other  {Représentant(e) des jeunes}
+  }
+SECRETARY: Secrétaire
+ASSISTANT_SECRETARY: >-
+  {gender, select,
+  M      {Secrétaire adjoint}
+  F      {Secrétaire adjointe}
+  other  {Secrétaire adjoint(e)}
+  }
+TREASURER: >-
+  {gender, select,
+  M      {Trésorier}
+  F      {Trésorière}
+  other  {Trésorier(e)}
+  }
+TREASURER_ASSISTANT: >-
+  {gender, select,
+  M      {Trésorier adjoint}
+  F      {Trésorière adjointe}
+  other  {Trésorier(e) adjoint(e)}
+  }
+VICE_PRESIDENT: >-
+  {gender, select,
+  M      {Vice-président}
+  F      {Vice-présidente}
+  other  {Vice-président(e)}
+  }
+ADHERENT: >-
+  {gender, select,
+  M      {Adhérent}
+  F      {Adhérente}
+  other  {Adhérent(e)}
+  }
+NO_MEMBER: Non membre
+VICE_PRESIDENT_OF_HONOR: >-
+  {gender, select,
+  M      {Vice-président d'honneur}
+  F      {Vice-présidente d'honneur}
+  other  {Vice-président(e) d'honneur}
+  }
+HOUR_PRESIDENT: >-
+  {gender, select,
+  M      {Président honoraire}
+  F      {Présidente honoraire}
+  other  {Président(e) honoraire}
+  }
+PRESIDENT_ASSISTANT: >-
+  {gender, select,
+  M      {Président adjoint}
+  F      {Présidente adjointe}
+  other  {Président(e) adjoint(e)}
+  }
+ACTIVE_COOPTED_MEMBER_OF_THE_CA: >-
+  {gender, select,
+  M      {Membre actif du CA coopté}
+  F      {Membre actif du CA cooptée}
+  other  {Membre actif du CA coopté(e)}
+  }
+ACTIVE_SUBSTITUTE_MEMBER_OF_THE_CA: >-
+  {gender, select,
+  M      {Membre actif du CA suppléant}
+  F      {Membre actif du CA suppléante}
+  other  {Membre actif du CA suppléant(e)}
+  }
+MEMBER_OF_THE_BOARD: Membre du bureau
+MEMBER_OF_BOARD_OF_HONOR: Membre d'Honneur du CA
+HONORARY_MEMBER: Membre d'honneur
+BENEFACTOR_MEMBER: Membre bienfaiteur
+HOUR_MEMBER: Membre honoraire

+ 2 - 0
translations/enum/person/gender/messages+intl-icu.fr.yaml

@@ -0,0 +1,2 @@
+MISS: MME
+MISTER: MR

+ 2 - 0
translations/messages+intl-icu.fr.yaml

@@ -0,0 +1,2 @@
+# @see https://symfony.com/doc/5.4/translation/message_format.html
+# @see https://symfony.com/doc/5.4/translation.html

+ 3 - 0
translations/services/dolibarr-sync/messages+intl-icu.fr.yaml

@@ -0,0 +1,3 @@
+ADHERENTS_COUNT: Nombre d'adhérents
+STUDENTS_COUNT: Nombre d'élèves
+ADMIN_ACCESS_COUNT: Nombre d'accès admin