فهرست منبع

Merge branch 'release/0.2.2'

Vincent GUFFON 3 سال پیش
والد
کامیت
f43d179f8c
100فایلهای تغییر یافته به همراه7195 افزوده شده و 412 حذف شده
  1. 28 3
      .env
  2. 4 0
      .env.ci
  3. 4 0
      .env.preprod
  4. 4 0
      .env.test
  5. 34 21
      composer.json
  6. 516 162
      composer.lock
  7. 2 0
      config/bundles.php
  8. 75 7
      config/packages/dev/monolog.yaml
  9. 1 1
      config/packages/doctrine.yaml
  10. 11 0
      config/packages/knp_gaufrette.yaml
  11. 9 0
      config/packages/knp_snappy.yaml
  12. 2 0
      config/packages/lock.yaml
  13. 3 0
      config/packages/mailer.yaml
  14. 14 0
      config/packages/messenger.yaml
  15. 3 3
      config/packages/prod/monolog.yaml
  16. 13 0
      config/packages/translation.yaml
  17. 2 1
      config/packages/twig.yaml
  18. 8 1
      config/routes.yaml
  19. 26 3
      config/services.yaml
  20. 32 0
      doc/exports.md
  21. 84 0
      doc/logging.md
  22. 27 35
      phpunit.xml.dist
  23. BIN
      public/static/ciseaux.png
  24. BIN
      public/static/cmf-reseau.png
  25. BIN
      public/static/cmf_licence.png
  26. BIN
      public/static/footer_report_activity.jpg
  27. BIN
      public/static/header_report_activity.jpg
  28. BIN
      public/static/logo_welcome.png
  29. BIN
      public/static/picto_face.png
  30. 100 0
      src/ApiResources/Export/ExportRequest.php
  31. 8 0
      src/ApiResources/Export/ExportRequestInterface.php
  32. 49 0
      src/ApiResources/Export/LicenceCmf/LicenceCmfOrganizationER.php
  33. 94 0
      src/Commands/DolibarrSyncCommand.php
  34. 65 0
      src/DataPersister/Export/LicenceCmf/ExportRequestDataPersister.php
  35. 4 3
      src/Doctrine/Access/CurrentAccessExtension.php
  36. 298 2
      src/Entity/Core/File.php
  37. 16 11
      src/Entity/Organization/Organization.php
  38. 20 0
      src/Entity/Organization/Parameters.php
  39. 34 0
      src/Enum/Access/FunctionEnum.php
  40. 1 0
      src/Enum/Access/RoleEnum.php
  41. 4 0
      src/Enum/Core/ContactPointTypeEnum.php
  42. 39 0
      src/Enum/Export/ExportFormatEnum.php
  43. 6 0
      src/Enum/Organization/AddressPostalOrganizationTypeEnum.php
  44. 3 0
      src/Enum/Organization/OrganizationIdsEnum.php
  45. 13 0
      src/Enum/Organization/SettingsProductEnum.php
  46. 17 0
      src/Enum/Person/GenderEnum.php
  47. 33 0
      src/Message/Command/Export.php
  48. 22 0
      src/Message/Handler/ExportHandler.php
  49. 57 3
      src/Repository/Access/AccessRepository.php
  50. 7 7
      src/Repository/Access/FunctionTypeRepository.php
  51. 1 0
      src/Repository/Access/OrganizationFunctionRepository.php
  52. 1 4
      src/Repository/Organization/OrganizationRepository.php
  53. 4 3
      src/Service/Access/Utils.php
  54. 24 0
      src/Service/Core/AddressPostalUtils.php
  55. 4 4
      src/Service/Dolibarr/DolibarrAccountCreator.php
  56. 131 0
      src/Service/Dolibarr/DolibarrApiService.php
  57. 0 84
      src/Service/Dolibarr/DolibarrService.php
  58. 723 0
      src/Service/Dolibarr/DolibarrSyncService.php
  59. 210 0
      src/Service/Export/BaseExporter.php
  60. 11 0
      src/Service/Export/Encoder/EncoderInterface.php
  61. 59 0
      src/Service/Export/Encoder/PdfEncoder.php
  62. 28 0
      src/Service/Export/ExporterInterface.php
  63. 116 0
      src/Service/Export/LicenceCmfExporter.php
  64. 6 0
      src/Service/Export/Model/ExportModelInterface.php
  65. 315 0
      src/Service/Export/Model/LicenceCmf.php
  66. 38 0
      src/Service/Export/Model/LicenceCmfCollection.php
  67. 1 1
      src/Service/Mobyt/MobytService.php
  68. 97 0
      src/Service/Rest/ApiRequestInterface.php
  69. 57 10
      src/Service/Rest/ApiRequestService.php
  70. 173 0
      src/Service/Rest/Operation/BaseRestOperation.php
  71. 71 0
      src/Service/Rest/Operation/CreateOperation.php
  72. 60 0
      src/Service/Rest/Operation/DeleteOperation.php
  73. 75 0
      src/Service/Rest/Operation/UpdateOperation.php
  74. 6 3
      src/Service/ServiceIterator/CurrentAccessExtensionIterator.php
  75. 40 0
      src/Service/ServiceIterator/EncoderIterator.php
  76. 40 0
      src/Service/ServiceIterator/ExporterIterator.php
  77. 4 3
      src/Service/ServiceIterator/OptionalsRolesIterator.php
  78. 22 0
      src/Service/Storage/FileStorage.php
  79. 43 0
      src/Service/Storage/TemporaryFileStorage.php
  80. 31 0
      src/Service/Storage/UploadStorage.php
  81. 45 0
      src/Service/Utils/ArrayUtils.php
  82. 46 0
      src/Service/Utils/Path.php
  83. 19 1
      src/Service/Utils/StringsUtils.php
  84. 94 0
      symfony.lock
  85. 348 0
      templates/export/licence_cmf.html.twig
  86. 2 2
      tests/Service/Access/AccessProfileCreatorTest.php
  87. 5 5
      tests/Service/Access/UtilsTest.php
  88. 7 7
      tests/Service/Dolibarr/DolibarrAccountCreatorTest.php
  89. 12 12
      tests/Service/Dolibarr/DolibarrApiServiceTest.php
  90. 725 0
      tests/Service/Dolibarr/DolibarrSyncServiceTest.php
  91. 442 0
      tests/Service/Dolibarr/fixtures/contacts.json
  92. 798 0
      tests/Service/Dolibarr/fixtures/thirdparties.json
  93. 3 1
      tests/Service/Dolibarr/fixtures/thirdparty.json
  94. 39 0
      tests/Service/Export/Encoder/PdfEncoderTest.php
  95. 167 0
      tests/Service/Export/LicenceCmfExporterTest.php
  96. 8 9
      tests/Service/Rest/ApiRequestServiceTest.php
  97. 126 0
      tests/Service/Rest/Operation/BaseRestOperationTest.php
  98. 42 0
      tests/Service/Rest/Operation/CreateOperationTest.php
  99. 36 0
      tests/Service/Rest/Operation/DeleteOperationTest.php
  100. 48 0
      tests/Service/Rest/Operation/UpdateOperationTest.php

+ 28 - 3
.env

@@ -20,6 +20,9 @@ APP_SECRET=6a76497c8658bb23e2236f97a2627df3
 #TRUSTED_HOSTS='^(localhost|example\.com)$'
 ###< symfony/framework-bundle ###
 
+###> files management ###
+INTERNAL_FILES_DOWNLOAD_URI=https://api.opentalent.fr/_internal/secure/files
+###< files management ###
 
 ###> doctrine/doctrine-bundle ###
 # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
@@ -44,14 +47,36 @@ 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 ###
+WKHTMLTOPDF_PATH=/usr/local/bin/wkhtmltopdf
+WKHTMLTOIMAGE_PATH=/usr/local/bin/wkhtmltoimage
+###< knplabs/knp-snappy-bundle ###
+
+###> symfony/messenger ###
+# Choose one of the transports below
+MESSENGER_TRANSPORT_DSN=doctrine://default
+# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
+# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
+###< symfony/messenger ###
+
 ###> 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 ###

+ 34 - 21
composer.json

@@ -14,49 +14,62 @@
         "api-platform/core": "^2.6",
         "blackfire/php-sdk": "^1.23",
         "composer/package-versions-deprecated": "^1.11",
+        "doctrine/dbal": "^2.6",
         "doctrine/doctrine-bundle": "^2.1",
         "doctrine/doctrine-migrations-bundle": "^3.0",
         "doctrine/orm": "^2.9",
         "egulias/email-validator": "^3.0",
         "jbouzekri/phumbor-bundle": "^2.1",
+        "knplabs/knp-gaufrette-bundle": "^0.7.1",
+        "knplabs/knp-snappy-bundle": "^1.9",
         "lexik/jwt-authentication-bundle": "^2.8",
         "myclabs/php-enum": "^1.7",
         "nelmio/cors-bundle": "^2.1",
         "odolbeau/phone-number-bundle": "^3.1",
         "phpdocumentor/reflection-docblock": "^5.2",
-        "symfony/asset": "5.3.*",
-        "symfony/console": "5.3.*",
-        "symfony/dotenv": "5.3.*",
-        "symfony/expression-language": "5.3.*",
+        "ramsey/uuid": "^4.2",
+        "symfony/asset": "5.4.*",
+        "symfony/console": "5.4.*",
+        "symfony/doctrine-messenger": "5.4.*",
+        "symfony/dotenv": "5.4.*",
+        "symfony/expression-language": "5.4.*",
         "symfony/flex": "^1.3.1",
-        "symfony/framework-bundle": "5.3.*",
-        "symfony/http-client": "5.3.*",
-        "symfony/intl": "5.3.*",
-        "symfony/monolog-bundle": "^3.0",
-        "symfony/property-access": "5.3.*",
-        "symfony/property-info": "5.3.*",
-        "symfony/security-bundle": "5.3.*",
-        "symfony/serializer": "5.3.*",
-        "symfony/twig-bundle": "^5.3",
-        "symfony/validator": "5.3.*",
-        "symfony/yaml": "5.3.*",
+        "symfony/framework-bundle": "5.4.*",
+        "symfony/http-client": "5.4.*",
+        "symfony/intl": "5.4.*",
+        "symfony/monolog-bundle": "^3.7",
+        "symfony/property-access": "5.4.*",
+        "symfony/property-info": "5.4.*",
+        "symfony/security-bundle": "5.4.*",
+        "symfony/serializer": "5.4.*",
+        "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"
     },
     "require-dev": {
         "cyclonedx/cyclonedx-php-composer": "^3.4",
-        "symfony/debug-bundle": "5.3.*",
+        "symfony/debug-bundle": "5.4.*",
         "symfony/maker-bundle": "^1.21",
-        "symfony/phpunit-bridge": "^5.3",
-        "symfony/stopwatch": "^5.3",
-        "symfony/web-profiler-bundle": "^5.3"
+        "symfony/phpunit-bridge": "^5.4",
+        "symfony/stopwatch": "^5.4",
+        "symfony/web-profiler-bundle": "^5.4"
     },
     "config": {
         "optimize-autoloader": true,
         "preferred-install": {
             "*": "dist"
         },
-        "sort-packages": true
+        "sort-packages": true,
+        "allow-plugins": {
+            "cyclonedx/cyclonedx-php-composer": true,
+            "symfony/flex": true
+        }
     },
     "autoload": {
         "psr-4": {
@@ -95,7 +108,7 @@
     "extra": {
         "symfony": {
             "allow-contrib": false,
-            "require": "5.3.*"
+            "require": "5.4.*"
         }
     }
 }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 516 - 162
composer.lock


+ 2 - 0
config/bundles.php

@@ -14,6 +14,8 @@ return [
     Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
     Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
     FOS\ElasticaBundle\FOSElasticaBundle::class => ['all' => true],
+    Knp\Bundle\SnappyBundle\KnpSnappyBundle::class => ['all' => true],
+    Knp\Bundle\GaufretteBundle\KnpGaufretteBundle::class => ['all' => true],
     Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
     Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
 ];

+ 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"]

+ 1 - 1
config/packages/doctrine.yaml

@@ -35,4 +35,4 @@ doctrine:
                         prefix: 'App\Entity'
                         alias: App
             adminassos:
-                connection: adminassos
+                connection: adminassos

+ 11 - 0
config/packages/knp_gaufrette.yaml

@@ -0,0 +1,11 @@
+# @see https://github.com/KnpLabs/KnpGaufretteBundle
+knp_gaufrette:
+  adapters:
+    temp:
+      local:
+        directory: '%kernel.project_dir%/var/files/temp'
+        create: true
+  filesystems:
+    temp:
+      adapter: temp
+      alias: temp

+ 9 - 0
config/packages/knp_snappy.yaml

@@ -0,0 +1,9 @@
+knp_snappy:
+    pdf:
+        enabled:    true
+        binary:     '%env(WKHTMLTOPDF_PATH)%'
+        options:    []
+    image:
+        enabled:    true
+        binary:     '%env(WKHTMLTOIMAGE_PATH)%'
+        options:    []

+ 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)%'

+ 14 - 0
config/packages/messenger.yaml

@@ -0,0 +1,14 @@
+framework:
+    messenger:
+        # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
+        failure_transport: failed
+
+        transports:
+            # https://symfony.com/doc/current/messenger.html#transport-configuration
+            async: '%env(MESSENGER_TRANSPORT_DSN)%'
+            failed: '%env(MESSENGER_TRANSPORT_DSN)%?queue_name=failed'
+            sync: 'sync://'
+
+        routing:
+            # Route your messages to the transports
+            'App\Message\Command\Export': async

+ 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)%'

+ 2 - 1
config/packages/twig.yaml

@@ -1,2 +1,3 @@
 twig:
-    default_path: '%kernel.project_dir%/templates'
+    paths:
+        '%kernel.project_dir%/templates': templates

+ 8 - 1
config/routes.yaml

@@ -4,4 +4,11 @@ login_check:
 
 swagger_ui:
   path: /docs
-  controller: api_platform.swagger.action.ui
+  controller: api_platform.swagger.action.ui
+
+ot_internal_secure_file_download:
+  path: /_internal/secure/files/{id}
+  controller: App\Controller\FileController::downloadFile
+  methods: GET
+  requirements:
+    id: '\d+'

+ 26 - 3
config/services.yaml

@@ -9,6 +9,14 @@ services:
         autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
         bind:
             $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
@@ -29,6 +37,8 @@ services:
     App\Service\Organization\Utils:
         public: true
 
+    Gaufrette\Filesystem: '@knp_gaufrette.filesystem_map'
+
     #########################################
     ##  TAG Services ##
     _instanceof:
@@ -36,11 +46,24 @@ services:
             tags: ['app.extensions.access']
         App\Service\Access\OptionalsRolesInterface:
             tags: ['app.optionalsroles']
+        App\Service\Export\ExporterInterface:
+            tags: ['app.exporter']
+        App\Service\Export\Encoder\EncoderInterface:
+            tags: ['app.encoder']
 
-    App\Doctrine\Access\HandleCurrentAccessExtension:
+    App\Service\ServiceIterator\CurrentAccessExtensionIterator:
         - !tagged_iterator app.extensions.access
-    App\Service\Access\HandleOptionalsRoles:
+    App\Service\ServiceIterator\OptionalsRolesIterator:
         - !tagged_iterator app.optionalsroles
+    App\Service\ServiceIterator\ExporterIterator:
+        - !tagged_iterator app.exporter
+    App\Service\ServiceIterator\EncoderIterator:
+        - !tagged_iterator app.encoder
+
+
+    App\Service\Dolibarr\DolibarrSyncService:
+        tags:
+            - { name: monolog.logger, channel: dolibarrsync }
 
     #########################################
     ##  SERIALIZER Decorates ##
@@ -61,4 +84,4 @@ services:
     ##  LISTENER ##
     App\EventListener\DoctrineFilter\DoctrineFilterListener:
         tags:
-            - { name: kernel.event_listener, event: kernel.request }
+            - { name: kernel.event_listener, event: kernel.request }

+ 32 - 0
doc/exports.md

@@ -0,0 +1,32 @@
+# Fonctionnement des exports
+
+## Requête HTTP
+
+Pour déclencher un export, on effectue une requete POST visant une ApiResource de type ExportRequest
+Toutes les ApiResource implémentant l'interface ExportRequestInterface sont traitées par le DataPersister 
+`App\DataPersister\Export\LicenceCmf\ExportRequestDataPersister`.
+
+## ExportRequestDataPersister
+
+Le ExportRequestDataPersister utilise un pattern iterable, c'est-à-dire qu'il balaie (via la classe `ExporterHandler`)
+tous les services implémentant `ExporterInterface` jusqu'à trouver un service qui supporte 
+l'ExportRequest passée en paramètre.
+
+Une fois le bon service d'export trouvé, il appelle la méthode `export($exportRequest)` de ce service.
+
+## Le service d'export
+
+Les services d'export recoivent en paramètre une ExportRequest.
+
+Ils vont ensuite effectuer les opérations suivantes:
+
+1. Construire un modèle : le service génère un objet `ExportModel` (qui peut être selon les cas objet ou une collection d'objets), qui
+contiendra toutes les données nécessaires au rendu du template.
+2. Générer le HTML : le service passe ensuite le modèle au template Twig associé, et récupère le résultat au format HTML. 
+3. Encoder : l'encodeur adapté est trouvé via la classe `EncoderHandler` qui itère sur les encoders disponibles
+jusqu'à trouver le premier qui supporte le format demandé dans l'ExportRequest, puis appelle la méthode `encode($html)`
+de cet encoder, et enfin récupère le contenu final du fichier
+4. Création du fichier : le service `TemporaryFileStorage` est utilisé pour créer le fichier d'export dans 
+un répertoire temporaire. 
+5. Mise à jour de la DB : Un objet `File` est enfin enregistré en base.
+

+ 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.
+
+
+
+
+
+
+
+

+ 27 - 35
phpunit.xml.dist

@@ -1,38 +1,30 @@
 <?xml version="1.0" encoding="UTF-8"?>
-
 <!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
-<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:noNamespaceSchemaLocation="bin/.phpunit/phpunit.xsd"
-         backupGlobals="false"
-         colors="true"
-         bootstrap="tests/bootstrap.php"
->
-    <php>
-        <ini name="error_reporting" value="-1" />
-        <server name="APP_ENV" value="test" force="true" />
-        <server name="SHELL_VERBOSITY" value="-1" />
-        <server name="SYMFONY_PHPUNIT_REMOVE" value="" />
-        <server name="SYMFONY_PHPUNIT_VERSION" value="8.5" />
-    </php>
-
-    <testsuites>
-        <testsuite name="Project Test Suite">
-            <directory>tests</directory>
-        </testsuite>
-    </testsuites>
-
-    <filter>
-        <whitelist processUncoveredFilesFromWhitelist="true">
-            <directory suffix=".php">src</directory>
-        </whitelist>
-    </filter>
-
-    <listeners>
-        <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
-    </listeners>
-
-    <logging>
-        <log type="coverage-html" target="./coverage" lowUpperBound="35" highLowerBound="70"/>
-        <log type="junit" target="./coverage/junit-report.xml"/>
-    </logging>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php">
+  <coverage processUncoveredFiles="true">
+    <include>
+      <directory suffix=".php">src</directory>
+    </include>
+    <report>
+      <html outputDirectory="./coverage" lowUpperBound="35" highLowerBound="70"/>
+    </report>
+  </coverage>
+  <php>
+    <ini name="error_reporting" value="-1"/>
+    <server name="APP_ENV" value="test" force="true"/>
+    <server name="SHELL_VERBOSITY" value="-1"/>
+    <server name="SYMFONY_PHPUNIT_REMOVE" value=""/>
+    <server name="SYMFONY_PHPUNIT_VERSION" value="9.4"/>
+  </php>
+  <testsuites>
+    <testsuite name="Project Test Suite">
+      <directory>tests</directory>
+    </testsuite>
+  </testsuites>
+  <listeners>
+    <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
+  </listeners>
+  <logging>
+    <junit outputFile="./coverage/junit-report.xml"/>
+  </logging>
 </phpunit>

BIN
public/static/ciseaux.png


BIN
public/static/cmf-reseau.png


BIN
public/static/cmf_licence.png


BIN
public/static/footer_report_activity.jpg


BIN
public/static/header_report_activity.jpg


BIN
public/static/logo_welcome.png


BIN
public/static/picto_face.png


+ 100 - 0
src/ApiResources/Export/ExportRequest.php

@@ -0,0 +1,100 @@
+<?php
+declare(strict_types=1);
+
+namespace App\ApiResources\Export;
+
+use ApiPlatform\Core\Annotation\ApiProperty;
+use ApiPlatform\Core\Annotation\ApiResource;
+use App\Entity\Access\Access;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * Demande d'export d'un fichier
+ * -- C'est la classe de base des ressources de type ExportRequest --
+ */
+abstract class ExportRequest
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform
+     *
+     * @var int
+     */
+    #[ApiProperty(identifier: true)]
+    protected int $id = 0;
+
+    /**
+     * Format de sortie attendu (pdf, txt...)
+     * @var string
+     */
+    #[Assert\Choice(callback: ['\App\Enum\Export\ExportFormatEnum', 'toArray'], message: 'invalid-output-format')]
+    protected string $format;
+
+    /**
+     * The id of the access requesting this export
+     * @var int|null
+     */
+    protected ?int $requesterId = null;
+
+    /**
+     * Should the export be asynchrone
+     * @var bool
+     */
+    protected bool $async = true;
+
+    /**
+     * @return int
+     */
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    /**
+     * @return string
+     */
+    public function getFormat(): string
+    {
+        return $this->format;
+    }
+
+    /**
+     * @param string $format
+     */
+    public function setFormat(string $format): void
+    {
+        $this->format = $format;
+    }
+
+    /**
+     * @return int|null
+     */
+    public function getRequesterId(): ?int
+    {
+        return $this->requesterId;
+    }
+
+    /**
+     * @param int|null $requesterId
+     */
+    public function setRequesterId(?int $requesterId): void
+    {
+        $this->requesterId = $requesterId;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isAsync(): bool
+    {
+        return $this->async;
+    }
+
+    /**
+     * @param bool $async
+     */
+    public function setAsync(bool $async): void
+    {
+        $this->async = $async;
+    }
+}

+ 8 - 0
src/ApiResources/Export/ExportRequestInterface.php

@@ -0,0 +1,8 @@
+<?php
+
+namespace App\ApiResources\Export;
+
+class ExportRequestInterface
+{
+
+}

+ 49 - 0
src/ApiResources/Export/LicenceCmf/LicenceCmfOrganizationER.php

@@ -0,0 +1,49 @@
+<?php
+declare(strict_types=1);
+
+namespace App\ApiResources\Export\LicenceCmf;
+
+use ApiPlatform\Core\Annotation\ApiResource;
+use App\ApiResources\Export\ExportRequest;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * Requête d'export d'une licence CMF pour l'organization ciblée
+ *
+ * Appeler avec une requête POST à /export/licence-cmf/organization,
+ * avec un body comme :
+ *
+ *   {"organizationId" : 1}
+ */
+#[ApiResource(
+    collectionOperations: [
+        'post' => [
+            'security' => '(is_granted("ROLE_ADMIN_CORE") or 
+                            is_granted("ROLE_ADMINISTRATIF_MANAGER_CORE") or 
+                           ) and object.getOrganizationId() == user.getOrganization().getId()',
+            'method' => 'POST',
+            'path' => '/licence-cmf/organization',
+        ],
+    ],
+    routePrefix: '/export'
+)]
+class LicenceCmfOrganizationER extends ExportRequest
+{
+    /**
+     * Format de sortie attendu (pdf seulement ici)
+     * @var string
+     */
+    #[Assert\EqualTo('pdf')]
+    protected string $format = 'pdf';
+
+    /**
+     * Retourne l'année de la licence
+     * (toujours l'année courante, sauf dans le cas des tests où on pourra mocker cette méthode)
+     *
+     * @return int
+     */
+    public function getYear(): int
+    {
+        return (int)date('Y');
+    }
+}

+ 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;
+    }
+}

+ 65 - 0
src/DataPersister/Export/LicenceCmf/ExportRequestDataPersister.php

@@ -0,0 +1,65 @@
+<?php
+declare(strict_types=1);
+
+namespace App\DataPersister\Export\LicenceCmf;
+
+use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
+use App\ApiResources\Export\ExportRequest;
+use App\Entity\Access\Access;
+use App\Message\Command\Export;
+use App\Service\ServiceIterator\ExporterIterator;
+use Exception;
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Security\Core\Security;
+use Symfony\Component\HttpFoundation\Response;
+
+class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
+{
+    public function __construct(
+        private Security $security,
+        private MessageBusInterface $messageBus,
+        private ExporterIterator $handler
+    ) {}
+
+    public function supports($data, array $context = []): bool
+    {
+        return $data instanceof ExportRequest;
+    }
+
+    /**
+     * @param $exportRequest ExportRequest Une requête d'export
+     * @param array $context
+     * @return Response
+     * @throws Exception
+     */
+    public function persist($exportRequest, array $context = []): Response
+    {
+        /** @var Access $access */
+        $access = $this->security->getUser();
+        $exportRequest->setRequesterId($access->getId());
+
+        if ($exportRequest->isAsync()) {
+
+            // Send the export request to Messenger (@see App\Message\Handler\ExportHandler)
+            $this->messageBus->dispatch(
+                new Export($exportRequest)
+            );
+            return new Response(null, 204);
+
+        } else {
+
+            $exportService = $this->handler->getExporterFor($exportRequest);
+            $file = $exportService->export($exportRequest);
+
+            return new Response('File generated: ' . $file->getId(), 200);
+        }
+    }
+
+    /**
+     * @throws Exception
+     */
+    public function remove($data, array $context = [])
+    {
+        throw new Exception('not supported', 500);
+    }
+}

+ 4 - 3
src/Doctrine/Access/CurrentAccessExtension.php

@@ -7,6 +7,7 @@ use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInter
 use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
 use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
 use App\Entity\Access\Access;
+use App\Service\ServiceIterator\CurrentAccessExtensionIterator;
 use Doctrine\ORM\QueryBuilder;
 use Symfony\Component\Security\Core\Security;
 
@@ -18,7 +19,7 @@ final class CurrentAccessExtension implements QueryCollectionExtensionInterface,
 {
     public function __construct(
         private Security $security,
-        private HandleCurrentAccessExtension $handleCurrentAccessExtension
+        private CurrentAccessExtensionIterator $currentAccessExtensionIterator
     )
     { }
 
@@ -45,6 +46,6 @@ final class CurrentAccessExtension implements QueryCollectionExtensionInterface,
             ->setParameter('current_organization', $currentUser->getOrganization())
         ;
 
-        $this->handleCurrentAccessExtension->addWhere($queryBuilder, $operationName);
+        $this->currentAccessExtensionIterator->addWhere($queryBuilder, $operationName);
     }
-}
+}

+ 298 - 2
src/Entity/Core/File.php

@@ -7,6 +7,7 @@ use ApiPlatform\Core\Annotation\ApiResource;
 use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
 use App\Repository\Core\FileRepository;
+use DateTime;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
@@ -27,20 +28,139 @@ class File
     #[ORM\GeneratedValue]
     private ?int $id = null;
 
+    /**
+     * Propriétaire du fichier
+     *
+     * @var Person
+     */
+    #[ORM\ManyToOne]
+    private Person $person;
+
+    /**
+     * Organisation propriétaire du fichier
+     * @var Organization
+     */
+    #[ORM\ManyToOne]
+    private Organization $organization;
+
+    /**
+     * Slug du fichier (i.e. le chemin d'accès relatif)
+     * @var string
+     */
     #[ORM\Column(length: 255)]
     private string $slug;
 
+    /**
+     * Chemin d'accès du fichier
+     * @var string
+     */
     #[ORM\Column(length: 255)]
     private string $path;
 
+    /**
+     * Nom du fichier
+     * @var string
+     */
     #[ORM\Column(length: 255)]
     private string $name;
 
+    /**
+     * Mimetype du fichier
+     * @var string|null
+     */
     #[ORM\Column(length: 255, nullable: true)]
     private ?string $mimeType = null;
 
-    #[ORM\Column(length: 255)]
-    private string $config;
+    /**
+     * Visibilité du fichier (tout le monde, personne, l'organisation seulement...)
+     * @var string
+     */
+    #[ORM\Column(length: 24, options: ['default' => 'NOBODY'])]
+    private string $visibility = 'NOBODY';
+
+    /**
+     * Configuration particulière associée au fichier (exemple: image recadrée)
+     * @var string|null
+     */
+    #[ORM\Column(type: 'text', length: 255, nullable: true)]
+    private ?string $config;
+
+    /**
+     * Dossier contenant le fichier
+     * @var string
+     */
+    #[ORM\Column(length: 24)]
+    private string $folder;
+
+    /**
+     * Type de document (uploaded, mail, bill...etc)
+     * @var string
+     */
+    #[ORM\Column(length: 50, options: ['default' => 'NONE'])]
+    private string $type = "NONE";
+
+    /**
+     * Taille du document en octets
+     * @var int|null
+     */
+    #[ORM\Column]
+    private ?int $size;
+
+    /**
+     * Un fichier est temporaire par exemple s'il a été généré et est stocké pour être téléchargé dans la foulée
+     * Les fichiers temporaires peuvent être supprimés sans risque, à l'inverse des fichiers uploadés par les
+     * utilisateurs par exemple.
+     *
+     * @var boolean
+     */
+    #[ORM\Column(options: ['default' => false])]
+    private bool $isTemporaryFile = false;
+
+    /**
+     * Date de création du fichier
+     * @var DateTime
+     */
+    #[ORM\Column]
+    private DateTime $createDate;
+
+    /**
+     * Id de l'access ayant créé ce fichier
+     * @var int|null
+     */
+    #[ORM\Column]
+    private ?int $createdBy;
+
+    /**
+     * Date de dernière mise à jour du fichier
+     * @var DateTime
+     */
+    #[ORM\Column]
+    private DateTime $updateDate;
+
+    /**
+     * Id de l'access ayant mis à jour ce fichier le dernier
+     * @var int|null
+     */
+    #[ORM\Column]
+    private ?int $updatedBy;
+
+//    #[ORM\Column]
+//    private ?int $eventReport_id;
+//
+//    #[ORM\Column]
+//    private ?\DateTime $availabilityDate;
+//
+//    #[ORM\Column]
+//    private ?int $documentWish_id;
+//
+//    #[ORM\Column]
+//    private ?int $onlineRegistrationSetting_id;
+//
+//    #[ORM\Column]
+//    private ?int $templateSystem_id;
+//
+//    #[ORM\Column]
+//    private ?int $work_id;
 
     #[ORM\OneToMany(mappedBy: 'image', targetEntity: Person::class, orphanRemoval: true)]
     private Collection $personImages;
@@ -63,6 +183,38 @@ class File
         return $this->id;
     }
 
+    /**
+     * @return Person
+     */
+    public function getPerson(): Person
+    {
+        return $this->person;
+    }
+
+    /**
+     * @param Person $person
+     */
+    public function setPerson(Person $person): void
+    {
+        $this->person = $person;
+    }
+
+    /**
+     * @return Organization
+     */
+    public function getOrganization(): Organization
+    {
+        return $this->organization;
+    }
+
+    /**
+     * @param Organization $organization
+     */
+    public function setOrganization(Organization $organization): void
+    {
+        $this->organization = $organization;
+    }
+
     public function getSlug(): string
     {
         return $this->slug;
@@ -146,6 +298,150 @@ class File
         return $this;
     }
 
+    /**
+     * @return string
+     */
+    public function getVisibility(): string
+    {
+        return $this->visibility;
+    }
+
+    /**
+     * @param string $visibility
+     */
+    public function setVisibility(string $visibility): void
+    {
+        $this->visibility = $visibility;
+    }
+
+    /**
+     * @return string
+     */
+    public function getFolder(): string
+    {
+        return $this->folder;
+    }
+
+    /**
+     * @param string $folder
+     */
+    public function setFolder(string $folder): void
+    {
+        $this->folder = $folder;
+    }
+
+    /**
+     * @return string
+     */
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    /**
+     * @param string $type
+     */
+    public function setType(string $type): void
+    {
+        $this->type = $type;
+    }
+
+    /**
+     * @return int|null
+     */
+    public function getSize(): ?int
+    {
+        return $this->size;
+    }
+
+    /**
+     * @param int|null $size
+     */
+    public function setSize(?int $size): void
+    {
+        $this->size = $size;
+    }
+
+    /**
+     * @return bool
+     */
+    public function getIsTemporaryFile(): bool
+    {
+        return $this->isTemporaryFile;
+    }
+
+    /**
+     * @param bool $isTemporaryFile
+     */
+    public function setIsTemporaryFile(bool $isTemporaryFile): void
+    {
+        $this->isTemporaryFile = $isTemporaryFile;
+    }
+
+    /**
+     * @return DateTime
+     */
+    public function getCreateDate(): DateTime
+    {
+        return $this->createDate;
+    }
+
+    /**
+     * @param DateTime $createDate
+     */
+    public function setCreateDate(DateTime $createDate): void
+    {
+        $this->createDate = $createDate;
+    }
+
+    /**
+     * @return int|null
+     */
+    public function getCreatedBy(): ?int
+    {
+        return $this->createdBy;
+    }
+
+    /**
+     * @param int|null $createdBy
+     */
+    public function setCreatedBy(?int $createdBy): void
+    {
+        $this->createdBy = $createdBy;
+    }
+
+    /**
+     * @return DateTime
+     */
+    public function getUpdateDate(): DateTime
+    {
+        return $this->updateDate;
+    }
+
+    /**
+     * @param DateTime $updateDate
+     */
+    public function setUpdateDate(DateTime $updateDate): void
+    {
+        $this->updateDate = $updateDate;
+    }
+
+    /**
+     * @return int|null
+     */
+    public function getUpdatedBy(): ?int
+    {
+        return $this->updatedBy;
+    }
+
+    /**
+     * @param int|null $updatedBy
+     */
+    public function setUpdatedBy(?int $updatedBy): void
+    {
+        $this->updatedBy = $updatedBy;
+    }
+
     public function getOrganizationLogos(): Collection
     {
         return $this->organizationLogos;

+ 16 - 11
src/Entity/Organization/Organization.php

@@ -355,6 +355,22 @@ class Organization
         return $this;
     }
 
+    /**
+     * @return File
+     */
+    public function getLogo(): File
+    {
+        return $this->logo;
+    }
+
+    /**
+     * @param File $logo
+     */
+    public function setLogo(File $logo): void
+    {
+        $this->logo = $logo;
+    }
+
     public function getBillingSetting(): BillingSetting
     {
         return $this->billingSetting;
@@ -763,17 +779,6 @@ class Organization
         return $this;
     }
 
-    public function setLogo(?File $image):self
-    {
-        $this->logo = $image;
-        return $this;
-    }
-
-    public function getLogo(): ?File
-    {
-        return $this->logo;
-    }
-
     public function setImage(?File $image):self
     {
         $this->image = $image;

+ 20 - 0
src/Entity/Organization/Parameters.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 namespace App\Entity\Organization;
 
 use ApiPlatform\Core\Annotation\ApiResource;
+use App\Entity\Core\File;
 use App\Repository\Organization\ParametersRepository;
 use Doctrine\ORM\Mapping as ORM;
 use Symfony\Component\Validator\Constraints as Assert;
@@ -115,6 +116,9 @@ class Parameters
     #[Assert\Choice(callback: ['\App\Enum\Organization\PeriodicityEnum', 'toArray'], message: 'invalid-periodicity')]
     private ?string $educationPeriodicity = null;
 
+    #[ORM\OneToOne(cascade: ['persist', 'remove'])]
+    private File $qrCode;
+
     public function getId(): ?int
     {
         return $this->id;
@@ -479,4 +483,20 @@ class Parameters
 
         return $this;
     }
+
+    /**
+     * @return File
+     */
+    public function getQrCode(): File
+    {
+        return $this->qrCode;
+    }
+
+    /**
+     * @param File $qrCode
+     */
+    public function setQrCode(File $qrCode): void
+    {
+        $this->qrCode = $qrCode;
+    }
 }

+ 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
 {

+ 39 - 0
src/Enum/Export/ExportFormatEnum.php

@@ -0,0 +1,39 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Enum\Export;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * Formats de sortie des fichiers exportés
+ */
+class ExportFormatEnum extends Enum
+{
+    private const PDF ='pdf';
+    private const CSV ='csv';
+    private const TXT = 'txt';
+    private const XLSX = 'xlsx';
+    private const XML = 'xml';
+
+    /** @var array */
+    protected static array $mimeType = [
+        self::PDF => 'application/pdf',
+        self::CSV => 'text/csv',
+        self::TXT => 'text/plain',
+        self::XLSX => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+        self::XML => 'application/xml'
+    ];
+
+    /**
+     * @param  string $formatShortName
+     * @return string
+     */
+    public static function getMimeType($formatShortName)
+    {
+        if (!isset(static::$mimeType[$formatShortName])) {
+            return "Unknown format ($formatShortName)";
+        }
+        return static::$mimeType[$formatShortName];
+    }
+}

+ 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';
+}

+ 33 - 0
src/Message/Command/Export.php

@@ -0,0 +1,33 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Message\Command;
+
+use App\ApiResources\Export\ExportRequest;
+
+/**
+ * Transmission d'une ExportRequest au service d'export associé
+ */
+class Export
+{
+    public function __construct(
+        private ExportRequest $exportRequest
+    )
+    {}
+
+    /**
+     * @return ExportRequest
+     */
+    public function getExportRequest(): ExportRequest
+    {
+        return $this->exportRequest;
+    }
+
+    /**
+     * @param ExportRequest $exportRequest
+     */
+    public function setExportRequest(ExportRequest $exportRequest): void
+    {
+        $this->exportRequest = $exportRequest;
+    }
+}

+ 22 - 0
src/Message/Handler/ExportHandler.php

@@ -0,0 +1,22 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Message\Handler;
+
+use App\Message\Command\Export;
+use App\Service\ServiceIterator\ExporterIterator;
+use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
+
+class ExportHandler implements MessageHandlerInterface
+{
+    public function __construct(
+        private ExporterIterator $handler
+    ) {}
+
+    public function __invoke(Export $export)
+    {
+        $exportRequest = $export->getExportRequest();
+        $exportService = $this->handler->getExporterFor($exportRequest);
+        $exportService->export($exportRequest);
+    }
+}

+ 57 - 3
src/Repository/Access/AccessRepository.php

@@ -5,6 +5,8 @@ namespace App\Repository\Access;
 
 use App\DQL\DateConditions;
 use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use DateTime;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 use Doctrine\Persistence\ManagerRegistry;
 use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
@@ -72,11 +74,11 @@ class AccessRepository extends ServiceEntityRepository implements UserLoaderInte
     }
 
     /**
-     * @param Access $acces
+     * @param Access $access
      * @return mixed
      * @throws \Exception
      */
-    public function findAllValidAccesses(Access $acces): array
+    public function findAllValidAccesses(Access $access): array
     {
         $datetime = new \DateTime();
         $today = $datetime->format('Y-m-d');
@@ -86,7 +88,7 @@ class AccessRepository extends ServiceEntityRepository implements UserLoaderInte
             ->innerJoin('organization.networkOrganizations', 'networkOrganizations')
             ->where('access.person = :person')
             ->andWhere('networkOrganizations.startDate <= :today')
-            ->setParameter('person', $acces->getPerson())
+            ->setParameter('person', $access->getPerson())
             ->setParameter('today', $today)
             ->getQuery()
             ->getResult()
@@ -114,4 +116,56 @@ class AccessRepository extends ServiceEntityRepository implements UserLoaderInte
 
         return $result;
     }
+
+    /**
+     * Retourne tous les accesses de l'organization ayant la fonction donnée à la date donnée
+     *
+     * @param Organization $organization
+     * @param $function
+     * @param DateTime|null $date
+     * @return array
+     */
+    public function findByOrganizationAndMission(Organization $organization, $function, \DateTime $date = null): array
+    {
+        if ($date === null)
+            $date = new DateTime();
+
+        $this->_em->getFilters()->disable('date_time_filter');
+
+        $qb = $this->createQueryBuilder('access');
+        $qb
+            ->innerJoin('access.organizationFunction', 'organization_function')
+            ->innerJoin('organization_function.functionType', 'function_type')
+            ->where('function_type.mission = :mission')
+            ->andWhere('access.organization = :id')
+            ->setParameter('id', $organization->getId())
+            ->setParameter('mission', $function)
+        ;
+        DateConditions::addDateInPeriodCondition($qb, 'organization_function', $date->format('Y-m-d'));
+        $this->_em->getFilters()->enable('date_time_filter');
+
+        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;
 
 /**

+ 4 - 3
src/Service/Access/Utils.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 namespace App\Service\Access;
 
 use App\Entity\Access\Access;
+use App\Service\ServiceIterator\OptionalsRolesIterator;
 use App\Test\Service\Access\UtilsTest;
 use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
 
@@ -15,7 +16,7 @@ class Utils
 {
     public function __construct(
         private RoleHierarchyInterface $roleHierarchy,
-        private HandleOptionalsRoles $handleOptionalsRoles
+        private OptionalsRolesIterator $optionalsRolesIterator
     )
     {}
 
@@ -51,7 +52,7 @@ class Utils
      * @see UtilsTest::testGetAllRoles()
      */
     public function getAllRoles(Access $access): array {
-        $roles = $this->handleOptionalsRoles->getOptionalsRoles($access);
+        $roles = $this->optionalsRolesIterator->getOptionalsRoles($access);
         return $this->roleHierarchy->getReachableRoleNames(array_merge($access->getRoles(), $roles));
     }
-}
+}

+ 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)
+            );
+        }
+    }
+}

+ 210 - 0
src/Service/Export/BaseExporter.php

@@ -0,0 +1,210 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export;
+
+use App\ApiResources\Export\ExportRequest;
+use App\Entity\Core\File;
+use App\Enum\Export\ExportFormatEnum;
+use App\Repository\Access\AccessRepository;
+use App\Service\Export\Model\ExportModelInterface;
+use App\Service\ServiceIterator\EncoderIterator;
+use App\Service\Storage\TemporaryFileStorage;
+use App\Service\Utils\StringsUtils;
+use Doctrine\ORM\EntityManagerInterface;
+use Exception;
+use Psr\Log\LoggerInterface;
+use Symfony\Contracts\Service\Attribute\Required;
+use Twig\Environment;
+
+/**
+ * Classe de base des services d'export
+ */
+abstract class BaseExporter implements ExporterInterface
+{
+    // dependencies injections
+    protected AccessRepository $accessRepository;
+    protected Environment $twig;
+    protected EncoderIterator $encoderIterator;
+    protected EntityManagerInterface $entityManager;
+    protected TemporaryFileStorage $storage;
+    protected LoggerInterface $logger;
+
+    #[Required]
+    public function setAccessRepository(AccessRepository $accessRepository) { $this->accessRepository = $accessRepository; }
+    #[Required]
+    public function setTwig(Environment $twig) { $this->twig = $twig; }
+    #[Required]
+    public function setEncoderIterator(EncoderIterator $encoderIterator) { $this->encoderIterator = $encoderIterator; }
+    #[Required]
+    public function setEntityManager(EntityManagerInterface $entityManager) { $this->entityManager = $entityManager; }
+    #[Required]
+    public function setStorage(TemporaryFileStorage $storage) { $this->storage = $storage; }
+    #[Required]
+    public function setLogger(LoggerInterface $logger) { $this->logger = $logger; }
+
+    public function support(ExportRequest $exportRequest): bool
+    {
+        return false;
+    }
+
+    /**
+     * Exécute l'opération d'export correspondant à la requête passée
+     * en paramètre
+     *
+     * @param ExportRequest $exportRequest
+     * @return File
+     * @throws Exception
+     */
+    public function export(ExportRequest $exportRequest): File
+    {
+        // Génère le modèle à partir de l'exportRequest
+        $model = $this->buildModel($exportRequest);
+
+        // Génère le html à partir du template et du service
+        $html = $this->render($model);
+
+        // Encode le html au format voulu
+        $content = $this->encode($html, $exportRequest->getFormat());
+
+        // Créé le fichier dans le storage adapté
+        $filename = $this->getFileBasename($model);
+        if (!preg_match('/^.+\.' . $exportRequest->getFormat() . '$/i', $filename)) {
+            $filename .= '.' . $exportRequest->getFormat();
+        }
+
+        $path = $this->store($filename, $content);
+
+        // Met à jour l'enregistrement du fichier en base
+        // <-- [refactoring] cette partie pourrait être faite en amont du service
+        $file = new File();
+
+        $requesterId = $exportRequest->getRequesterId();
+        $organization = $this->accessRepository->find($requesterId)->getOrganization();
+
+        $file->setOrganization($organization);
+        $file->setVisibility('NOBODY');
+        $file->setFolder('DOCUMENTS');
+        $file->setCreateDate(new \DateTime());
+        $file->setCreatedBy($requesterId);
+        // -->
+        // <-- [refactoring] cette partie doit être faite après la création du fichier (storage ? service ?)
+        $file->setType($this->getFileType());
+        $file->setMimeType(ExportFormatEnum::getMimeType($exportRequest->getFormat()));
+        $file->setName($filename);
+        $file->setPath($path);
+        $file->setSlug($path);
+        // -->
+
+        $this->entityManager->persist($file);
+        $this->entityManager->flush();
+
+        // Retourne l'objet File ainsi créé
+        return $file;
+    }
+
+    /**
+     * Construit le modèle de données qui servira au render du template
+     *
+     * @param ExportRequest $exportRequest
+     * @return ExportModelInterface
+     * @throws Exception
+     */
+    protected function buildModel(ExportRequest $exportRequest): ExportModelInterface
+    {
+        throw new Exception('not implemented error');
+    }
+
+    /**
+     * Retourne le nom par défaut de cet export,
+     * utilisé pour trouver le template twig ou encore pour nommer
+     * le fichier exporté.
+     *
+     * @return string
+     */
+    protected function getBasename(): string
+    {
+        $arr = explode('\\', static::class);
+        $classname = end($arr);
+        return StringsUtils::camelToSnake(
+            preg_replace(
+                '/^([\w\d]+)Exporter$/',
+                '$1',
+                $classname,
+                1)
+        );
+    }
+
+    /**
+     * Return the path of the twig template for this export
+     * @return string
+     */
+    protected function getTemplatePath(): string {
+        return '@templates/export/' . $this->getBasename() . '.html.twig';
+    }
+
+    /**
+     * Fait le render du template twig à partir du modèle de données
+     *
+     * @param ExportModelInterface $model
+     * @return string Rendu HTML
+     * @throws Exception
+     */
+    protected function render(ExportModelInterface $model): string
+    {
+        try {
+            return $this->twig->render(
+                $this->getTemplatePath(),
+                ['model' => $model]
+            );
+        }
+        catch (\Twig\Error\LoaderError | \Twig\Error\RuntimeError | \Twig\Error\SyntaxError $e) {
+            throw new \Exception('error during template rendering : ' . $e);
+        }
+    }
+
+    /**
+     * Encode le html au format demandé
+     *
+     * @param string $html
+     * @param string $format @see ExportFormatEnum
+     * @return string
+     * @throws Exception
+     */
+    protected function encode(string $html, string $format): string
+    {
+        $encoder = $this->encoderIterator->getEncoderFor($format);
+        return $encoder->encode($html);
+    }
+
+    /**
+     * Retourne le nom du fichier exporté
+     *
+     * @param ExportModelInterface $model
+     * @return string
+     */
+    protected function getFileBasename(ExportModelInterface $model): string
+    {
+        return $this->getBasename();
+    }
+
+    /**
+     * Retourne le type de fichier tel qu'il apparait au niveau du champ File.type
+     *
+     * @return string
+     */
+    protected function getFileType(): string {
+        return 'UNKNOWN';
+    }
+
+    /**
+     * Créé le fichier
+     *
+     * @return mixed
+     * @throws Exception
+     */
+    protected function store(string $name, string $content): string
+    {
+        return $this->storage->write($name, $content);
+    }
+}

+ 11 - 0
src/Service/Export/Encoder/EncoderInterface.php

@@ -0,0 +1,11 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export\Encoder;
+
+interface EncoderInterface
+{
+    public function support(string $format): bool;
+
+    public function encode(string $html, array $options = []);
+}

+ 59 - 0
src/Service/Export/Encoder/PdfEncoder.php

@@ -0,0 +1,59 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export\Encoder;
+
+use App\Enum\Export\ExportFormatEnum;
+use \Knp\Snappy\Pdf;
+
+/**
+ * Encode HTML to PDF
+ */
+class PdfEncoder implements EncoderInterface
+{
+    /**
+     * Default encoding options
+     * @see https://wkhtmltopdf.org/libwkhtmltox/
+     *
+     * @var array
+     */
+    private array $defaultOptions = [
+        'margin-top'    => 35,
+        'margin-right'  => 10,
+        'margin-bottom' => 15,
+        'margin-left'   => 15,
+        'header-spacing'   => 5,
+        'enable-local-file-access' => true
+    ];
+
+    public function __construct(
+        private Pdf $knpSnappy
+    ) {}
+
+    public function support(string $format): bool {
+        return $format === ExportFormatEnum::PDF()->getValue();
+    }
+
+    /**
+     * Default encoding options
+     * @return array
+     */
+    public function getDefaultOptions() {
+        return $this->defaultOptions;
+    }
+
+    /**
+     * Encode the given HTML content into PDF, and
+     * return the encoded content
+     *
+     * @param string $html
+     * @param array $options
+     * @return string
+     */
+    public function encode(string $html, array $options = []): string
+    {
+        $options = array_merge($this->defaultOptions, $options);
+
+        return $this->knpSnappy->getOutputFromHtml($html, $options);
+    }
+}

+ 28 - 0
src/Service/Export/ExporterInterface.php

@@ -0,0 +1,28 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export;
+
+use App\ApiResources\Export\ExportRequest;
+
+/**
+ * Classe de base des services d'export
+ */
+interface ExporterInterface
+{
+    /**
+     * Le service supporte-t-il ce type d'ExportRequest
+     *
+     * @param ExportRequest $exportRequest
+     * @return boolean
+     */
+    public function support(ExportRequest $exportRequest): bool;
+
+    /**
+     * Exécute l'opération d'export correspondant à la requête passée
+     * en paramètre
+     *
+     * @param ExportRequest $exportRequest
+     */
+    public function export(ExportRequest $exportRequest);
+}

+ 116 - 0
src/Service/Export/LicenceCmfExporter.php

@@ -0,0 +1,116 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export;
+
+use App\ApiResources\Export\ExportRequest;
+use App\ApiResources\Export\LicenceCmf\LicenceCmfOrganizationER;
+use App\Service\Export\Model\ExportModelInterface;
+use App\Service\Export\Model\LicenceCmf;
+use App\Enum\Access\FunctionEnum;
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\Export\Model\LicenceCmfCollection;
+use App\Service\Storage\UploadStorage;
+
+/**
+ * Exporte la licence CMF de la structure ou du ou des access, au format demandé
+ */
+class LicenceCmfExporter extends BaseExporter implements ExporterInterface
+{
+    const CMF_ID = 12097;
+
+    /**
+     * La couleur de la carte de licence change chaque année, de manière cyclique
+     */
+    const LICENCE_CMF_COLOR_START_YEAR = "2020";
+    const LICENCE_CMF_COLOR = [0 => '931572', 1 => 'C2981A', 2 =>  '003882', 3 =>  '27AAE1', 4 =>  '2BB673'];
+
+    public function __construct(
+        private OrganizationRepository $organizationRepository,
+        private UploadStorage $uploadStorage,
+    )
+    {}
+
+    public function support($exportRequest): bool
+    {
+        return $exportRequest instanceof LicenceCmfOrganizationER;
+    }
+
+    protected function buildModel(ExportRequest $exportRequest): LicenceCmfCollection
+    {
+        $organization = $this->accessRepository->find($exportRequest->getRequesterId())->getOrganization();
+
+        $licenceCmf = new LicenceCmf();
+        $licenceCmf->setId($organization->getId());
+        $licenceCmf->setYear($exportRequest->getYear());
+        $licenceCmf->setIsOrganizationLicence( $exportRequest instanceof LicenceCmfOrganizationER);
+        $licenceCmf->setOrganizationName($organization->getName());
+        $licenceCmf->setOrganizationIdentifier($organization->getIdentifier());
+
+        $parentFederation = $organization->getNetworkOrganizations()->get(0)->getParent();
+        $licenceCmf->setFederationName($parentFederation->getName());
+
+        $licenceCmf->setColor(
+            $this->getLicenceColor($exportRequest->getYear())
+        );
+
+        $logoId = $organization->getLogo()?->getId();
+        if ($logoId) {
+            $licenceCmf->setLogoUri(
+                $this->uploadStorage->getUri($logoId)
+            );
+        }
+
+        $presidents = $this->accessRepository->findByOrganizationAndMission($organization, FunctionEnum::PRESIDENT()->getValue());
+        if (count($presidents) > 0) {
+            $president = $presidents[0]->getPerson();
+            $licenceCmf->setPersonId($president->getId());
+            $licenceCmf->setPersonGender($president->getGender());
+            $licenceCmf->setPersonFirstName($president->getGivenName());
+            $licenceCmf->setPersonLastName($president->getName());
+        }
+
+        $cmf = $this->organizationRepository->find(self::CMF_ID);
+        $qrCodeId = $cmf->getParameters()?->getQrCode()?->getId();
+        if ($qrCodeId) {
+            $licenceCmf->setQrCodeUri(
+                $this->uploadStorage->getUri($qrCodeId)
+            );
+        }
+
+        $model = new LicenceCmfCollection();
+        $model->setLicences([$licenceCmf]);
+        return $model;
+    }
+
+    /**
+     * @param LicenceCmfCollection $model
+     * @return string
+     */
+    protected function getFileBasename(ExportModelInterface $model): string
+    {
+        return 'licence_cmf_' . $model->getLicences()[0]->getYear() . '.pdf';
+    }
+
+    /**
+     * Retourne le type de fichier tel qu'il apparait au niveau du champ File.type
+     *
+     * @return string
+     */
+    protected function getFileType(): string {
+        return 'LICENCE_CMF';
+    }
+
+    /**
+     * Retourne la couleur de licence pour l'année donnée
+     *
+     * @param int $year
+     * @return string
+     */
+    protected function getLicenceColor(int $year): string {
+        if (!self::LICENCE_CMF_COLOR_START_YEAR > $year) {
+            return self::LICENCE_CMF_COLOR[0];
+        }
+        return self::LICENCE_CMF_COLOR[($year - self::LICENCE_CMF_COLOR_START_YEAR) % count(self::LICENCE_CMF_COLOR)];
+    }
+}

+ 6 - 0
src/Service/Export/Model/ExportModelInterface.php

@@ -0,0 +1,6 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export\Model;
+
+interface ExportModelInterface {}

+ 315 - 0
src/Service/Export/Model/LicenceCmf.php

@@ -0,0 +1,315 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export\Model;
+
+class LicenceCmf implements ExportModelInterface
+{
+    /**
+     * An id for the licence
+     * @var int
+     */
+    private int $id;
+
+    /**
+     * Year of the licence
+     * @var int
+     */
+    private int $year;
+
+    /**
+     * Is this the licence of an organization and not of a person?
+     * @var bool
+     */
+    private bool $isOrganizationLicence;
+
+    /**
+     * Name of the organization
+     * @var string
+     */
+    private string $organizationName;
+
+    /**
+     * Identifier of the organization
+     * @var string
+     */
+    private string $organizationIdentifier;
+
+    /**
+     * Name of the federation
+     * @var string|null
+     */
+    private string $federationName;
+
+    /**
+     * Color of the licence card for the given year
+     * @var string
+     */
+    private string $color;
+
+    /**
+     * URI of the organization's logo
+     * @var string | null
+     */
+    private ?string $logoUri;
+
+    /**
+     * URI of the CMF QrCode
+     * @var string | null
+     */
+    private ?string $qrCodeUri;
+
+    /**
+     * Gender of the licence owner
+     * @var int|null
+     */
+    private ?int $personId = null;
+
+    /**
+     * Gender of the licence owner
+     * @var string
+     */
+    private string $personGender = '';
+
+    /**
+     * First name of the licence owner
+     * @var string
+     */
+    private string $personFirstName = '';
+
+    /**
+     * Name of the licence owner
+     * @var string
+     */
+    private string $personLastName = '';
+
+    /**
+     * Avatar of the person
+     * @var string|null
+     */
+    private ?string $personAvatarUri = '';
+
+    /**
+     * @return int
+     */
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    /**
+     * @param int $id
+     */
+    public function setId(int $id): void
+    {
+        $this->id = $id;
+    }
+
+    /**
+     * @return int
+     */
+    public function getYear(): int
+    {
+        return $this->year;
+    }
+
+    /**
+     * @param int $year
+     */
+    public function setYear(int $year): void
+    {
+        $this->year = $year;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isOrganizationLicence(): bool
+    {
+        return $this->isOrganizationLicence;
+    }
+
+    /**
+     * @param bool $isOrganizationLicence
+     */
+    public function setIsOrganizationLicence(bool $isOrganizationLicence): void
+    {
+        $this->isOrganizationLicence = $isOrganizationLicence;
+    }
+
+    /**
+     * @return string
+     */
+    public function getOrganizationName(): string
+    {
+        return $this->organizationName;
+    }
+
+    /**
+     * @param string $organizationName
+     */
+    public function setOrganizationName(string $organizationName): void
+    {
+        $this->organizationName = $organizationName;
+    }
+
+    /**
+     * @return string
+     */
+    public function getOrganizationIdentifier(): string
+    {
+        return $this->organizationIdentifier;
+    }
+
+    /**
+     * @param string|null $organizationIdentifier
+     */
+    public function setOrganizationIdentifier(?string $organizationIdentifier): void
+    {
+        $this->organizationIdentifier = $organizationIdentifier;
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getFederationName(): ?string
+    {
+        return $this->federationName;
+    }
+
+    /**
+     * @param string $federationName
+     */
+    public function setFederationName(string $federationName): void
+    {
+        $this->federationName = $federationName;
+    }
+
+    /**
+     * @return string
+     */
+    public function getColor(): string
+    {
+        return $this->color;
+    }
+
+    /**
+     * @param string $color
+     */
+    public function setColor(string $color): void
+    {
+        $this->color = $color;
+    }
+
+    /**
+     * @return string
+     */
+    public function getLogoUri(): string
+    {
+        return $this->logoUri;
+    }
+
+    /**
+     * @param string $logoUri
+     */
+    public function setLogoUri(string $logoUri): void
+    {
+        $this->logoUri = $logoUri;
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getQrCodeUri(): ?string
+    {
+        return $this->qrCodeUri;
+    }
+
+    /**
+     * @param string|null $qrCodeUri
+     */
+    public function setQrCodeUri(?string $qrCodeUri): void
+    {
+        $this->qrCodeUri = $qrCodeUri;
+    }
+
+    /**
+     * @return int
+     */
+    public function getPersonId(): int
+    {
+        return $this->personId;
+    }
+
+    /**
+     * @param int|null $personId
+     */
+    public function setPersonId(?int $personId): void
+    {
+        $this->personId = $personId;
+    }
+
+    /**
+     * @return string
+     */
+    public function getPersonGender(): string
+    {
+        return $this->personGender;
+    }
+
+    /**
+     * @param string $personGender
+     */
+    public function setPersonGender(string $personGender): void
+    {
+        $this->personGender = $personGender;
+    }
+
+    /**
+     * @return string
+     */
+    public function getPersonFirstName(): string
+    {
+        return $this->personFirstName;
+    }
+
+    /**
+     * @param string $personFirstName
+     */
+    public function setPersonFirstName(string $personFirstName): void
+    {
+        $this->personFirstName = $personFirstName;
+    }
+
+    /**
+     * @return string
+     */
+    public function getPersonLastName(): string
+    {
+        return $this->personLastName;
+    }
+
+    /**
+     * @param string $personLastName
+     */
+    public function setPersonLastName(string $personLastName): void
+    {
+        $this->personLastName = $personLastName;
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getPersonAvatarUri(): ?string
+    {
+        return $this->personAvatarUri;
+    }
+
+    /**
+     * @param string|null $personAvatarUri
+     */
+    public function setPersonAvatarUri(?string $personAvatarUri): void
+    {
+        $this->personAvatarUri = $personAvatarUri;
+    }
+}

+ 38 - 0
src/Service/Export/Model/LicenceCmfCollection.php

@@ -0,0 +1,38 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Export\Model;
+
+class LicenceCmfCollection implements ExportModelInterface
+{
+    /**
+     * Les différentes licences contenues dans la collection
+     *
+     * @var array
+     */
+    private array $licences;
+
+    /**
+     * @return array
+     */
+    public function getLicences(): array
+    {
+        return $this->licences;
+    }
+
+    /**
+     * @param array $licences
+     */
+    public function setLicences(array $licences): void
+    {
+        $this->licences = $licences;
+    }
+
+    /**
+     * @param LicenceCmf $licence
+     */
+    public function addLicence(LicenceCmf $licence): void
+    {
+        $this->licences[] = $licence;
+    }
+}

+ 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;
+    }
+}

+ 6 - 3
src/Doctrine/Access/HandleCurrentAccessExtension.php → src/Service/ServiceIterator/CurrentAccessExtensionIterator.php

@@ -1,11 +1,13 @@
 <?php
 declare(strict_types=1);
 
-namespace App\Doctrine\Access;
+namespace App\Service\ServiceIterator;
 
+use App\Doctrine\Access\AccessExtensionInterface;
 use Doctrine\ORM\QueryBuilder;
+use Exception;
 
-class HandleCurrentAccessExtension{
+class CurrentAccessExtensionIterator{
     public function __construct(private iterable $extensions)
     { }
 
@@ -15,5 +17,6 @@ class HandleCurrentAccessExtension{
             if($extension->support($operationName))
                 return $extension->addWhere($queryBuilder);
         }
+        throw new Exception('no extension found for this operation');
     }
-}
+}

+ 40 - 0
src/Service/ServiceIterator/EncoderIterator.php

@@ -0,0 +1,40 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\ServiceIterator;
+
+use App\ApiResources\Export\ExportRequest;
+use App\Service\Export\Encoder\EncoderInterface;
+use Exception;
+
+/**
+ * Permet d'itérer sur les services d'encodage
+ */
+class EncoderIterator
+{
+    /**
+     * Pour l'injection des services, voir config/services.yaml, section 'TAG Services'
+     * @param iterable $encoders
+     */
+    public function __construct(
+        private iterable $encoders,
+    ) {}
+
+    /**
+     * Itère sur les services d'encodage disponibles et
+     * retourne le premier qui supporte ce type de requête.
+     *
+     * @param string $format
+     * @return EncoderInterface
+     * @throws Exception
+     */
+    public function getEncoderFor(string $format): EncoderInterface
+    {
+        /** @var EncoderInterface $encoder */
+        foreach ($this->encoders as $encoder){
+            if($encoder->support($format))
+                return $encoder;
+        }
+        throw new Exception('no encoder found for this export request');
+    }
+}

+ 40 - 0
src/Service/ServiceIterator/ExporterIterator.php

@@ -0,0 +1,40 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\ServiceIterator;
+
+use App\ApiResources\Export\ExportRequest;
+use App\Service\Export\ExporterInterface;
+use Exception;
+
+/**
+ * Permet d'itérer sur les services d'export
+ */
+class ExporterIterator
+{
+    /**
+     * Pour l'injection des services, voir config/services.yaml, section 'TAG Services'
+     * @param iterable $exportServices
+     */
+    public function __construct(
+        private iterable $exportServices,
+    ) {}
+
+    /**
+     * Itère sur les services d'export disponibles et
+     * retourne le premier qui supporte ce type de requête.
+     *
+     * @param ExportRequest $exportRequest
+     * @return ExporterInterface
+     * @throws Exception
+     */
+    public function getExporterFor(ExportRequest $exportRequest): ExporterInterface
+    {
+        /** @var ExporterInterface $exportService */
+        foreach ($this->exportServices as $exportService){
+            if($exportService->support($exportRequest))
+                return $exportService;
+        }
+        throw new Exception('no export service found for this export request');
+    }
+}

+ 4 - 3
src/Service/Access/HandleOptionalsRoles.php → src/Service/ServiceIterator/OptionalsRolesIterator.php

@@ -1,11 +1,12 @@
 <?php
 declare(strict_types=1);
 
-namespace App\Service\Access;
+namespace App\Service\ServiceIterator;
 
 use App\Entity\Access\Access;
+use App\Service\Access\OptionalsRolesInterface;
 
-class HandleOptionalsRoles{
+class OptionalsRolesIterator {
 
     public function __construct(private iterable $optionalsRoles)
     { }
@@ -19,4 +20,4 @@ class HandleOptionalsRoles{
         }
         return $roles;
     }
-}
+}

+ 22 - 0
src/Service/Storage/FileStorage.php

@@ -0,0 +1,22 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Storage;
+
+use App\Service\Utils\Path;
+use Knp\Bundle\GaufretteBundle\FilesystemMap;
+
+/**
+ * Base class for file storage
+ */
+abstract class FileStorage
+{
+    public function __construct(
+        protected FilesystemMap $filesystem
+    )
+    {}
+
+    protected function getStorageBaseDir(): string {
+        return Path::join(Path::getProjectDir(), 'var', 'files');
+    }
+}

+ 43 - 0
src/Service/Storage/TemporaryFileStorage.php

@@ -0,0 +1,43 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Storage;
+
+use App\Service\Utils\Path;
+use Exception;
+use Ramsey\Uuid\Uuid;
+
+/**
+ * Gère le stockage des fichiers temporaires, comme les documents générés par les utilisateurs
+ * comme des fichiers d'export
+ */
+class TemporaryFileStorage extends FileStorage
+{
+    protected function getRelativeStorageBaseDir(): string {
+        return 'temp';
+    }
+
+    protected function getStorageBaseDir(): string {
+        // TODO: remplacer par une reference à config/packages/knp_gaufrette.yaml
+        return Path::join(parent::getStorageBaseDir(), $this->getRelativeStorageBaseDir());
+    }
+
+    /**
+     * Write the given content to a temporary file
+     *
+     * @param string $filename
+     * @param string $content
+     * @return string
+     * @throws Exception
+     */
+    public function write(string $filename, string $content): string
+    {
+        // Temp dir name is a concatenation of current time (for convenience and sorting) and a short uuid4
+        $tempDirName = date('Ymd_His') . '_' . substr(Uuid::uuid4()->toString(), 0, 8);
+
+        $filePath = Path::join($tempDirName, $filename);
+        $this->filesystem->get('temp')->getAdapter()->write($filePath, $content);
+
+        return Path::join('temp', $filePath);
+    }
+}

+ 31 - 0
src/Service/Storage/UploadStorage.php

@@ -0,0 +1,31 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Storage;
+
+/**
+ * Gère l'upload et le téléchargement de fichiers par les utilisateurs
+ *
+ * Pour la durée de la migration vers Symfony 5, la gestion des fichiers est déléguée à l'ancienne API
+ *
+ */
+// TODO: revoir le fonctionnement de ce storage pour le mettre sur le même format que les autres
+class UploadStorage
+{
+    public function __construct(private string $internalFilesUploadUri)
+    {}
+
+    private function getBaseDownloadUri(): string {
+        return $this->internalFilesUploadUri;
+    }
+
+    private static function getUploadUri(): string
+    {
+        return '';
+    }
+
+    public function getUri(int $fileId): string {
+        return rtrim(self::getBaseDownloadUri(), '/') . '/' . $fileId;
+    }
+
+}

+ 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;
+    }
+
+}

+ 46 - 0
src/Service/Utils/Path.php

@@ -0,0 +1,46 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Utils;
+
+/**
+ * Various methods to manipulate file paths
+ */
+class Path
+{
+    /**
+     * Returns the application directory
+     *
+     * @return string
+     */
+    public static function getProjectDir(): string
+    {
+        return dirname(__file__, 4);
+    }
+
+    /**
+     * Properly join the $path and $tail as a new valid file's path
+     * @see https://stackoverflow.com/a/15575293/4279120
+     *
+     * Ex:
+     *   Path.join('/var/www/', '/html/') > '/var/www/html'
+     *
+     * Input                   Result
+     * ['','']              >  ''
+     * ['','/']             >  '/'
+     * ['/','a']            >  '/a'
+     * ['/','/a']           >  '/a'
+     * ['abc','def']        >  'abc/def'
+     * ['abc','/def']       >  'abc/def'
+     * ['/abc','def']       >  '/abc/def'
+     * ['','foo.jpg']       >  'foo.jpg'
+     * ['dir','0','a.jpg']  >  'dir/0/a.jpg'
+     *
+     * @return string
+     */
+    public static function join(): string
+    {
+        $paths = array_filter(func_get_args(), function ($s) { return $s !== ''; });
+        return preg_replace('#/+#','/',join('/', $paths));
+    }
+}

+ 19 - 1
src/Service/Utils/StringsUtils.php

@@ -20,4 +20,22 @@ class StringsUtils
     public static function unquote(string $str): string {
         return str_replace("'", "", $str);
     }
-}
+
+    /**
+     * Convert CamelCase formatted string into snake_case
+     * @see https://stackoverflow.com/a/40514305/4279120
+     *
+     * @param string $string
+     * @param string $sep
+     * @return string
+     */
+    public static function camelToSnake(string $string, string $sep = "_"): string {
+        return strtolower(
+            preg_replace(
+            '/(?<=\d)(?=[A-Za-z])|(?<=[A-Za-z])(?=\d)|(?<=[a-z])(?=[A-Z])/',
+            $sep,
+            $string
+            )
+        );
+    }
+}

+ 94 - 0
symfony.lock

@@ -19,6 +19,9 @@
     "blackfire/php-sdk": {
         "version": "v1.23.0"
     },
+    "brick/math": {
+        "version": "0.9.3"
+    },
     "composer/ca-bundle": {
         "version": "1.2.8"
     },
@@ -128,6 +131,24 @@
     "jbouzekri/phumbor-bundle": {
         "version": "2.1.0"
     },
+    "knplabs/gaufrette": {
+        "version": "v0.9.0"
+    },
+    "knplabs/knp-gaufrette-bundle": {
+        "version": "v0.7.1"
+    },
+    "knplabs/knp-snappy": {
+        "version": "v1.4.1"
+    },
+    "knplabs/knp-snappy-bundle": {
+        "version": "1.9",
+        "recipe": {
+            "repo": "github.com/symfony/recipes-contrib",
+            "branch": "master",
+            "version": "1.5",
+            "ref": "c81bdcf4a9d4e7b1959071457f9608631865d381"
+        }
+    },
     "laminas/laminas-code": {
         "version": "4.4.2"
     },
@@ -221,6 +242,12 @@
     "psr/log": {
         "version": "1.1.3"
     },
+    "ramsey/collection": {
+        "version": "1.2.2"
+    },
+    "ramsey/uuid": {
+        "version": "4.2.3"
+    },
     "ruflin/elastica": {
         "version": "3.2"
     },
@@ -230,6 +257,9 @@
     "swaggest/json-schema": {
         "version": "v0.12.38"
     },
+    "symfony/amqp-messenger": {
+        "version": "v5.3.14"
+    },
     "symfony/asset": {
         "version": "v5.1.7"
     },
@@ -275,6 +305,9 @@
     "symfony/doctrine-bridge": {
         "version": "v5.1.7"
     },
+    "symfony/doctrine-messenger": {
+        "version": "v5.3.14"
+    },
     "symfony/dotenv": {
         "version": "v5.1.7"
     },
@@ -343,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": {
@@ -352,6 +409,21 @@
             "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
         }
     },
+    "symfony/mime": {
+             "version": "v5.3.14"
+    },
+    "symfony/messenger": {
+        "version": "5.3",
+        "recipe": {
+            "repo": "github.com/symfony/recipes",
+            "branch": "master",
+            "version": "4.3",
+            "ref": "25e3c964d3aee480b3acc3114ffb7940c89edfed"
+        },
+        "files": [
+            "config/packages/messenger.yaml"
+        ]
+    },
     "symfony/monolog-bridge": {
         "version": "v5.3.7"
     },
@@ -400,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"
     },
@@ -415,12 +490,18 @@
     "symfony/polyfill-php81": {
         "version": "v1.23.0"
     },
+    "symfony/process": {
+        "version": "v5.3.14"
+    },
     "symfony/property-access": {
         "version": "v5.1.7"
     },
     "symfony/property-info": {
         "version": "v5.1.7"
     },
+    "symfony/redis-messenger": {
+        "version": "v5.3.14"
+    },
     "symfony/routing": {
         "version": "5.1",
         "recipe": {
@@ -474,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"
     },

+ 348 - 0
templates/export/licence_cmf.html.twig

@@ -0,0 +1,348 @@
+<!DOCTYPE html>
+<html lang="fr">
+<head>
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+    <style media="all">
+
+        {% block style %}
+        html {
+            width: 21cm;
+            height: 100%;
+        }
+
+        @page {
+            margin: 180px 50px;
+        }
+
+        body {
+            height: 100%;
+        }
+
+        .Style1 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 12px;
+            font-weight: bold;
+            color: #FFFFFF;
+        }
+
+        .Style2 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 12px;
+            font-weight: bold;
+        }
+
+        .Style3 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 8px;
+            color: #FFFFFF;
+            vertical-align: text-top;
+        }
+
+        .Style4 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 9px;
+        }
+
+        .Style5 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 10px;
+        }
+
+        .Style7 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 16px;
+        }
+
+        .Style8 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 12px;
+        }
+
+        .Style9 {
+            font-family: Verdana, Arial, Helvetica, sans-serif;
+            font-size: 12px;
+            font-weight: bold;
+        }
+
+        .relative {
+            position: relative;
+        }
+
+        #year_head {
+            position: absolute;
+            bottom: 10px;
+            left: 100px;
+            color: #9d1348;
+            font-size: 25px;
+            font-weight: bold;
+        }
+
+        .avatar {
+            max-width: 85px;
+            max-height: 82px;
+        }
+
+        #year_card {
+            position: absolute;
+            bottom: 75px;
+            left: 55px;
+            color: #9d1348;
+            font-size: 14px;
+            font-weight: bold;
+        }
+
+        .Style2 p, .Style4 p {
+            margin: 0;
+            padding: 0;
+        }
+
+        .page_break {
+            page-break-before: always;
+            top: 0 !important;
+            margin-top: 0 !important;
+            position: initial;
+        }
+
+        #card {
+            position: relative;
+            z-index: 1;
+            top: 0;
+            left: 0;
+            margin: 50px auto;
+        }
+
+        .card_dimension {
+            width: 110mm;
+            height: 72mm;
+        }
+
+        #dashed {
+            width: 106mm;
+            height: 66mm;
+            border: 1px dashed #0f0f0f;
+            position: absolute;
+            border-radius: 4px;
+            top: 2mm;
+            left: 2mm;
+            z-index: 2;
+        }
+
+        #scissor{
+            position: absolute;
+            top: 0;
+            left: 15px;
+        }
+
+        .up {
+            padding-top: 100px;
+        }
+
+        .bottom {
+            padding-top: 20px;
+        }
+
+        #qrCode {
+            padding-right: 15px;
+        }
+
+        #avatar{
+            padding-left: 5px;
+        }
+
+        {% endblock style %}
+    </style>
+
+    <link
+            href=
+            "https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"
+            rel="stylesheet"
+            integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN"
+            crossorigin="anonymous"
+    />
+
+    <title>Licence CMF</title>
+</head>
+
+<body>
+
+{% block content %}
+    {% for licence in model.licences %}
+        <page data-iri="{{ licence.id }}">
+            <table width="793" border="0" cellspacing="0" cellpadding="0">
+                <tbody>
+                <tr>
+                    <td width="693">
+                        <table width="680" border="0" align="center" cellpadding="0" cellspacing="0">
+                            <tbody>
+                            <tr>
+                                <td width="340" class="relative">
+                                    <img src="{{ asset('static/cmf_licence.png') }}"
+                                            width="170" height="86"/>
+                                    <span id="year_head">{{ licence.year }}</span>
+                                </td>
+                                <td width="340">
+                                    <div align="right">
+                                        <img src="{{ asset('static/cmf-reseau.png') }}"
+                                                width="200" height="86"/>
+                                    </div>
+                                </td>
+                            </tr>
+                            </tbody>
+                        </table>
+                    </td>
+                </tr>
+                <tr>
+                    <td><p class="Style7"></p>
+                        <p class="Style7"></p>
+                        <p class="Style7">
+                            {{ (licence.personGender ~ '_long')| trans }} {{ licence.personLastName }} {{ licence.personFirstName }}
+                            ,</p>
+
+                        <p class="Style7"></p>
+                        <p class="Style8">Vous trouverez ci-joint votre <strong>Licence CMF pour
+                                l’année {{ licence.year }}</strong>.
+                        </p>
+                        <p class="Style8">Vous pouvez : </p>
+                        <ul class="Style8">
+                            <li> Imprimer ce document tel quel et le garder au format A4.</li>
+                            <li> Imprimer ce document sur du papier cartonné et le découper selon les pointillés.</li>
+                            <li> Enregister le PDF pour garder ce document au format numérique.</li>
+                        </ul>
+                        <p class="Style8">Cette licence et le numéro associé vous sera utile pour bénéficier des
+                            avantages partenaires mis en place par la CMF.
+                            Pour connaître les partenariats existants, rendez-vous sur [
+                            <a href="https://www.cmf-musique.org/services/tarifs-preferentiels/">
+                                www.cmf-musique.org/services/tarifs-preferentiels/
+                            </a>]
+                        </p>
+                        <p class="Style8">
+                            Nous vous rappelons que la Licence CMF, personnelle et unique, <br/>
+                            est <strong>valable jusqu’au 31 décembre de l’année {{ licence.year }}</strong>.
+                        </p>
+                        <p class="Style8"></p>
+                        <hr/>
+                    </td>
+                </tr>
+                </tbody>
+            </table>
+
+            <div id="card" class="card_dimension">
+                <table class="card_dimension" align="center" cellpadding="0" cellspacing="0">
+                    <tbody>
+                    <tr>
+                        <td height="26" colspan="3" align="center" valign="bottom" bgcolor="{{ licence.color }}">
+                            <div align="center"><span class="Style1">Licence CMF</span></div>
+                        </td>
+                    </tr>
+                    <tr class="up">
+                        {% if licence.isOrganizationLicence %}
+                            <td width="80" id="avatar">
+                                <div align="center">
+                                    {% if(licence.logoUri is null) %}
+                                        <img src="{{ asset('public/static/picto_face.png') }}"
+                                             width="85"
+                                             height="82"/>
+                                    {% else %}
+                                        <img src="{{ licence.logoUri }}"
+                                             width="85"
+                                             height="82"/>
+                                    {% endif %}
+                                </div>
+                            </td>
+                            <td colspan="2">
+                                <span class="Style2">
+                                    <p>{{ licence.personLastName }} {{ licence.personFirstName }}</p>
+                                    <p>{{ licence.organizationName }}</p>
+                                    <p>{{ licence.federationName }}</p>
+                                    <p>N° : {{ licence.organizationIdentifier }}</p>
+                                    <p>Licence valable jusqu’au 31/12/{{ licence.year }}</p>
+                                </span>
+                            </td>
+                        {% else %}
+                            <td width="80" id="avatar">
+                                <div align="center">
+                                    {% if(licence.personAvatarUri is null) %}
+                                        <img
+                                                src="{{ asset('public/static/picto_face.png') }}"
+                                                width="85"
+                                                height="82"/>
+                                    {% else %}
+                                        <img class="avatar"
+                                             src="{{ asset(licence.personAvatarUri) }}"/>
+                                    {% endif %}
+                                </div>
+                            </td>
+                            <td colspan="2">
+                                <span class="Style2">
+                                   <p>{{ licence.personLastName }} {{ licence.personFirstName }}</p>
+                                   <p>{{ licence.organizationName }}</p>
+                                   <p>?</p>
+                                  <p>N° : {{ licence.organizationIdentifier }}-{{ licence.personId }}</p>
+                                <p>Licence valable jusqu’au 31/12/{{ licence.year }}</p>
+                                </span>
+                            </td>
+                        {% endif %}
+                    </tr>
+
+                    <tr class="bottom">
+                        <td width="70" valign="middle"
+                            style="vertical-align: top;">
+                            <div align="center">
+                                <img src="{{ asset('public/static/cmf_licence.png') }}"
+                                     height="45"/>
+                                <span id="year_card">{{ licence.year }}</span>
+                            </div>
+                        </td>
+                        <td width="140" align="right" valign="middle">
+                            <div align="right"><span class="Style4">
+                                <p>Consultez vos</p>
+                                <p> avantages sur</p>
+                                <p>www.cmf-musique.org</p>
+                                <p> ou flashez ce code</p></span>
+                            </div>
+                        </td>
+                        <td width="70" align="right" valign="middle" id="qrCode">
+                            {% if(licence.qrCodeUri is not null) %}
+                                <img style="margin-right: 10px;"
+                                     src="{{ asset(licence.qrCodeUri) }}"
+                                     alt=""
+                                     width="65" height="65"/>
+                            {% endif %}
+                        </td>
+                    </tr>
+
+                    <tr>
+                        <td colspan="3" align="center" bgcolor="{{ licence.color }}"><span class="Style3">CMF ● cmf@cmf-musique.org ● 01 55 58 22 82 ● www.cmf-musique.org</span>
+                        </td>
+                    </tr>
+
+                    </tbody>
+                </table>
+
+                <div id="dashed"></div>
+                <i class="fa fa-scissors" id="scissor" aria-hidden="true"></i>
+            </div>
+
+
+            <table width="793" border="0" cellspacing="0" cellpadding="0">
+                <tbody>
+                <tr>
+                    <td></td>
+                    <td>
+                        <hr/>
+                        <p align="center" class="Style9">Vous rencontrez des difficultés pour utiliser cette carte ?</p>
+                        <p align="center" class="Style8">Contactez nous : <br/>
+                            CMF<br/>
+                            cmf@cmf-musique.org<br/>
+                            01 55 58 22 82 </p>
+                    </td>
+                    <td></td>
+                </tr>
+                </tbody>
+            </table>
+            <div class="page_break"></div>
+        </page>
+    {% endfor %}
+{% endblock content %}
+</body>
+</html>

+ 2 - 2
tests/Service/Access/AccessProfileCreatorTest.php

@@ -10,7 +10,7 @@ use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
 use App\Repository\Access\AccessRepository;
 use App\Service\Access\AccessProfileCreator;
-use App\Service\Access\HandleOptionalsRoles;
+use App\Service\Access\OptionalsRolesIterator;
 use App\Service\Access\Utils;
 use App\Service\Organization\OrganizationProfileCreator;
 use PHPUnit\Framework\TestCase;
@@ -139,4 +139,4 @@ class AccessProfileCreatorTest extends TestCase
         $accessProfile = $this->accessProfileCreator->createCompleteAccessProfile($this->access);
         $this->assertFalse($accessProfile->getIsPayor());
     }
-}
+}

+ 5 - 5
tests/Service/Access/UtilsTest.php

@@ -3,8 +3,8 @@
 namespace App\Test\Service\Access;
 
 use App\Entity\Access\Access;
-use App\Service\Access\HandleOptionalsRoles;
 use App\Service\Access\Utils;
+use App\Service\ServiceIterator\OptionalsRolesIterator;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\Security\Core\Role\RoleHierarchy;
 
@@ -19,12 +19,12 @@ class UtilsTest extends TestCase
             ->method('getReachableRoleNames')
             ->willReturn(["ROLE_A", "ROLE_B"]);
 
-        $handleOptionalsRolesMock = $this->getMockBuilder(HandleOptionalsRoles::class)->disableOriginalConstructor()->getMock();
-        $handleOptionalsRolesMock
+        $optionalsRolesIteratorMock = $this->getMockBuilder(OptionalsRolesIterator::class)->disableOriginalConstructor()->getMock();
+        $optionalsRolesIteratorMock
             ->method('getOptionalsRoles')
             ->willReturn(["ROLE_OPT"]);
 
-        $this->utils = new Utils($roleHierarchyMock, $handleOptionalsRolesMock);
+        $this->utils = new Utils($roleHierarchyMock, $optionalsRolesIteratorMock);
     }
 
     /**
@@ -86,4 +86,4 @@ class UtilsTest extends TestCase
 
         $this->assertEquals(['ROLE_A', 'ROLE_B'], $this->utils->getAllRoles($accessMock1));
     }
-}
+}

+ 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)

+ 12 - 12
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');
@@ -64,17 +64,17 @@ class DolibarrServiceTest extends TestCase
         $this->client
             ->expects($this->once())
             ->method('request')
-            ->with("GET", "contracts?limit=1&sqlfilters=statut%3D1&thirdparty_ids%3D1")
+            ->with("GET", "contracts?limit=1&sqlfilters=statut%3D1&thirdparty_ids=1")
             ->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,

+ 39 - 0
tests/Service/Export/Encoder/PdfEncoderTest.php

@@ -0,0 +1,39 @@
+<?php
+
+use App\Service\Export\Encoder\PdfEncoder;
+use Knp\Snappy\Pdf;
+use PHPUnit\Framework\TestCase;
+
+class PdfEncoderTest extends TestCase
+{
+    public function testSupport() {
+        $mocker = $this->getMockBuilder(Pdf::class);
+        $knpSnappy = $mocker->getMock();
+        $encoder = new PdfEncoder($knpSnappy);
+
+        $this->assertTrue($encoder->support('pdf'));
+        $this->assertFalse($encoder->support('txt'));
+    }
+
+    public function testGetDefaultOptions() {
+        $mocker = $this->getMockBuilder(Pdf::class);
+        $knpSnappy = $mocker->getMock();
+        $encoder = new PdfEncoder($knpSnappy);
+
+        $this->assertIsArray($encoder->getDefaultOptions());
+    }
+
+    public function testEncode() {
+        $mocker = $this->getMockBuilder(Pdf::class);
+        $knpSnappy = $mocker->getMock();
+        $knpSnappy
+            ->expects(self::once())
+            ->method('getOutputFromHtml')
+            ->with('<div>content</div>')
+            ->willReturn('%%encoded%%');
+
+        $encoder = new PdfEncoder($knpSnappy);
+
+        $this->assertEquals('%%encoded%%', $encoder->encode('<div>content</div>'));
+    }
+}

+ 167 - 0
tests/Service/Export/LicenceCmfExporterTest.php

@@ -0,0 +1,167 @@
+<?php
+
+use App\ApiResources\Export\ExportRequest;
+use App\ApiResources\Export\LicenceCmf\LicenceCmfOrganizationER;
+use App\Entity\Access\Access;
+use App\Entity\Core\File;
+use App\Entity\Network\Network;
+use App\Entity\Network\NetworkOrganization;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\Parameters;
+use App\Entity\Person\Person;
+use App\Repository\Access\AccessRepository;
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\Export\Encoder\PdfEncoder;
+use App\Service\Export\LicenceCmfExporter;
+use App\Service\ServiceIterator\EncoderIterator;
+use App\Service\Storage\TemporaryFileStorage;
+use App\Service\Storage\UploadStorage;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\TestCase;
+use Twig\Environment;
+
+class LicenceCmfExporterTest extends TestCase
+{
+    private mixed $exportRequest;
+    private mixed $accessRepo;
+    private mixed $twig;
+    private mixed $encoderIterator;
+    private mixed $em;
+    private mixed $storage;
+    private mixed $organizationRepo;
+    private mixed $uploadStorage;
+    private mixed $access;
+    private mixed $organization;
+    private mixed $cmf;
+    private mixed $networkOrgs;
+    private mixed $collection;
+    private mixed $parent;
+    private mixed $logo;
+    private mixed $presidentAccess;
+    private mixed $president;
+    private mixed $cmfParameters;
+    private mixed $qrCode;
+    private LicenceCmfExporter $exporter;
+    private mixed $encoder;
+
+    public function setUp(): void {
+        $this->exportRequest = $this->getMockBuilder(LicenceCmfOrganizationER::class)->getMock();
+        $this->accessRepo = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $this->twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
+        $this->encoderIterator = $this->getMockBuilder(EncoderIterator::class)->disableOriginalConstructor()->getMock();
+        $this->encoder = $this->getMockBuilder(PdfEncoder::class)->disableOriginalConstructor()->getMock();
+        $this->em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->storage = $this->getMockBuilder(TemporaryFileStorage::class)->disableOriginalConstructor()->getMock();
+        $this->organizationRepo = $this->getMockBuilder(OrganizationRepository::class)->disableOriginalConstructor()->getMock();
+        $this->uploadStorage = $this->getMockBuilder(UploadStorage::class)->disableOriginalConstructor()->getMock();
+        $this->access = $this->getMockBuilder(Access::class)->getMock();
+        $this->organization = $this->getMockBuilder(Organization::class)->getMock();
+        $this->cmf = $this->getMockBuilder(Organization::class)->getMock();
+        $this->networkOrgs = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+        $this->collection = $this->getMockBuilder(Collection::class)->getMock();
+        $this->parent = $this->getMockBuilder(Organization::class)->getMock();
+        $this->logo = $this->getMockBuilder(File::class)->getMock();
+        $this->presidentAccess = $this->getMockBuilder(Access::class)->getMock();
+        $this->president = $this->getMockBuilder(Person::class)->getMock();
+        $this->cmfParameters = $this->getMockBuilder(Parameters::class)->getMock();
+        $this->qrCode = $this->getMockBuilder(File::class)->getMock();
+
+        $this->exporter = new LicenceCmfExporter($this->organizationRepo, $this->uploadStorage);
+        $this->exporter->setAccessRepository($this->accessRepo);
+        $this->exporter->setTwig($this->twig);
+        $this->exporter->setEncoderIterator($this->encoderIterator);
+        $this->exporter->setEntityManager($this->em);
+        $this->exporter->setStorage($this->storage);
+    }
+
+    public function testSupport() {
+        $unsupportedExportRequest = $this->getMockBuilder(ExportRequest::class)->disableOriginalConstructor()->getMock();
+
+        $this->assertTrue($this->exporter->support($this->exportRequest));
+        $this->assertFalse($this->exporter->support($unsupportedExportRequest));
+    }
+
+    private function prepareModelBuilding() {
+        $this->exportRequest->method('getRequesterId')->willReturn(1);
+        $this->exportRequest->method('getYear')->willReturn(2020);
+        $this->exportRequest->method('getFormat')->willReturn('pdf');
+
+        $this->accessRepo->method('find')->with(1)->willReturn($this->access);
+        $this->access->method('getOrganization')->willReturn($this->organization);
+        $this->organization->method('getId')->willReturn(1);
+        $this->organization->method('getName')->willReturn('my_organization');
+        $this->organization->method('getIdentifier')->willReturn('org1');
+        $this->organization->method('getNetworkOrganizations')->willReturn($this->collection);
+        $this->collection->method('get')->willReturn($this->networkOrgs);
+        $this->networkOrgs->expects(self::once())->method('getParent')->willReturn($this->parent);
+        $this->parent->expects(self::once())->method('getName')->willReturn('my_network');
+        $this->organization->method('getLogo')->willReturn($this->logo);
+        $this->logo->method('getId')->willReturn(1);
+        $this->uploadStorage->method('getUri')->willReturn('http:://foo.bar/1');
+        $this->president->method('getId')->willReturn(1);
+        $this->president->method('getGender')->willReturn('M');
+        $this->president->method('getGivenName')->willReturn('Joe');
+        $this->president->method('getName')->willReturn('Dalton');
+        $this->accessRepo
+            ->expects(self::once())
+            ->method('findByOrganizationAndMission')
+            ->with($this->organization, 'PRESIDENT')
+            ->willReturn([$this->presidentAccess]);
+        $this->presidentAccess->method('getPerson')->willReturn($this->president);
+        $this->cmf->method('getParameters')->willReturn($this->cmfParameters);
+        $this->cmfParameters->expects(self::once())->method('getQrCode')->willReturn($this->qrCode);
+        $this->qrCode->method('getId')->willReturn(1);
+        $this->organizationRepo->expects(self::once())->method('find')->with(12097)->willReturn($this->cmf);
+    }
+
+    public function testBuildModel() {
+        $this->prepareModelBuilding();
+
+        $reflection = new ReflectionClass('App\Service\Export\LicenceCmfExporter');
+        $this->buildModelMethod = $reflection->getMethod('buildModel');
+        $this->buildModelMethod->setAccessible(true);
+
+        $modelCollection = $this->buildModelMethod->invokeArgs($this->exporter, [$this->exportRequest]);
+        $licence = $modelCollection->getLicences()[0];
+
+        $this->assertEquals('my_network', $licence->getFederationName());
+        $this->assertEquals(true, $licence->isOrganizationLicence());
+        $this->assertEquals(2020, $licence->getYear());
+        $this->assertEquals('931572', $licence->getColor());
+        $this->assertEquals('http:://foo.bar/1', $licence->getQrCodeUri());
+        $this->assertEquals('http:://foo.bar/1', $licence->getLogoUri());
+    }
+
+    public function testExport() {
+        $this->prepareModelBuilding();
+
+        $this->twig
+            ->expects(self::once())
+            ->method('render')
+            ->with('@templates/export/licence_cmf.html.twig')
+            ->willReturn('<div>rendered html</div>');
+
+        $this->encoderIterator
+            ->expects(self::once())
+            ->method('getEncoderFor')
+            ->with('pdf')
+            ->willReturn($this->encoder);
+
+        $this->encoder
+            ->expects(self::once())
+            ->method('encode')
+            ->with('<div>rendered html</div>')
+            ->willReturn('%%encoded%%');
+
+        $this->storage
+            ->expects(self::once())
+            ->method('write')
+            ->willReturn('/temp/abcd/licence_cmf_2020.pdf');
+
+        $file = $this->exporter->export($this->exportRequest);
+
+        $this->assertMatchesRegularExpression('/licence_cmf_\d{4}.pdf/', $file->getName());
+    }
+
+}

+ 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()
+        );
+    }
+
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است