瀏覽代碼

dolibarr sync: v8-3056 - various improvements

Olivier Massot 3 年之前
父節點
當前提交
32f29a7656

+ 3 - 4
.env

@@ -20,7 +20,6 @@ APP_SECRET=6a76497c8658bb23e2236f97a2627df3
 #TRUSTED_HOSTS='^(localhost|example\.com)$'
 ###< symfony/framework-bundle ###
 
-
 ###> doctrine/doctrine-bundle ###
 # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
 # For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db"
@@ -44,12 +43,12 @@ OPENTALENT_CONFIG=/config/opentalent
 ###< opentalent config folder ###
 
 ###> dolibarr client ###
-DOLIBARR_API_BASE_URI='https://prod-erp.2iopenservice.com/api/index.php/'
-DOLIBARR_API_TOKEN='Bocc4zC0J186v8J6QCqu7DnoIw4I7mCJ'
+DOLIBARR_API_BASE_URI=https://prod-erp.2iopenservice.com/api/index.php/
+DOLIBARR_API_TOKEN=Bocc4zC0J186v8J6QCqu7DnoIw4I7mCJ
 ###< dolibarr client ###
 
 ###> mobyt client ###
-MOBYT_API_BASE_URI='https://app.mobyt.fr/API/v1.0/REST/'
+MOBYT_API_BASE_URI=https://app.mobyt.fr/API/v1.0/REST/
 ###< mobyt client ###
 
 ###> AdminAssos configuration ###

+ 1 - 1
.env.preprod

@@ -28,5 +28,5 @@ DATABASE_ADMINASSOS_URL=mysql://root:mysql660@preprod:3306/adminassos?serverVers
 ###< AdminAssos configuration ###
 
 ###> dolibarr client ###
-DOLIBARR_API_BASE_URI='https://dev-erp.2iopenservice.com/api/index.php/'
+DOLIBARR_API_BASE_URI=https://dev-erp.2iopenservice.com/api/index.php/
 ###< dolibarr client ###

+ 0 - 45
messages.fr.yaml.off

@@ -1,45 +0,0 @@
-#@see https://symfony.com/doc/5.4/translation.html
-
-# Activities functions
-STUDENT: Elève
-TEACHER: Professeur
-DIRECTOR: Directeur(ice) pédagogique
-DIRECTOR_ASSISTANT: Directeur(ice) pédagogique adjoint(e)
-INITIATOR: Initiateur(ice)
-MONITOR: Moniteur(ice)
-MUSIC_DIRECTOR_AND_HEAD: Directeur(ice) musical(e) ou Chef
-MUSIC_DIRECTOR_AND_HEAD_ASSISTANT: Directeur(ice) musical(e) ou Chef adjoint(e)
-DESK_OFFICER: Chef(fe) de pupitre
-ADMINISTRATIVE_OFFICER: Responsable administratif(ve)
-ADMINISTRATIVE_SECRETARY: Secrétaire administratif(ve)
-ADMINISTRATIVE_DIRECTOR: Directeur(ice) administratif(ve)
-ADMINISTRATIVE_DIRECTOR_ASSISTANT: Directeur(ice) administratif(ve) adjoint(e)
-ARCHIVIST: Archiviste
-PRESENTER: Présentateur(ice)
-ADMINISTRATIVE_STAFF: Personnel administratif
-NETWORK_ANIMATOR: Animateur(rice) réseau
-CORRESPONDING: Correspondant(e)
-COORDINATOR: Coordinateur(ice)
-TECHNICAL_STAFF: Personnel technique
-ACCOUNTANT: Comptable
-ACTIVE_MEMBER_OF_THE_CA: Membre actif du CA
-HONORARY_PRESIDENT: Président(e) d'honneur
-PRESIDENT: Président(e)
-YOUTH_REPRESENTATIVE: Représentant(e) des jeunes
-SECRETARY: Secrétaire
-ASSISTANT_SECRETARY: Secrétaire adjoint(e)
-TREASURER: Trésorier(e)
-TREASURER_ASSISTANT: Trésorier(e) adjoint(e)
-VICE_PRESIDENT: Vice président(e)
-ADHERENT: Adhérent(e)
-NO_MEMBER: Non membre
-VICE_PRESIDENT_OF_HONOR: Vice-président(e) d'honneur
-HOUR_PRESIDENT: Président(e) honoraire
-PRESIDENT_ASSISTANT: Président(e) adjoint(e)
-ACTIVE_COOPTED_MEMBER_OF_THE_CA: Membre actif du CA coopté(e)
-ACTIVE_SUBSTITUTE_MEMBER_OF_THE_CA: Membre actif du CA suppléant(e)
-MEMBER_OF_THE_BOARD: Membre du bureau
-MEMBER_OF_BOARD_OF_HONOR: Membre d'Honneur du CA
-HONORARY_MEMBER: Membre d'honneur
-BENEFACTOR_MEMBER: Membre bienfaiteur
-HOUR_MEMBER: Membre honoraire

+ 3 - 3
src/Commands/DolibarrSyncCommand.php

@@ -68,9 +68,9 @@ class DolibarrSyncCommand extends Command
 
         if ($input->getOption('preview')) {
             $output->writeln("-- Preview --");
-            for ($i = 0; $i < count($operations); $i++) {
-                $output->writeln($i . '. ' . $operations[$i]->getLabel());
-                foreach ($operations[$i]->getChangeLog() as $message) {
+            foreach ($operations as $i => $iValue) {
+                $output->writeln($i . '. ' . $iValue->getLabel());
+                foreach ($iValue->getChangeLog() as $message) {
                     $output->writeln('   ' . $message);
                 }
             }

+ 32 - 9
src/Service/Dolibarr/DolibarrApiService.php

@@ -15,8 +15,9 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
  */
 class DolibarrApiService extends ApiRequestService
 {
+    /** @noinspection SenselessProxyMethodInspection Method shall be kept to allow dependency injections, even if empty */
     #[Pure]
-    function __construct(HttpClientInterface $dolibarr_client)
+    public function __construct(HttpClientInterface $dolibarr_client)
     {
         parent::__construct($dolibarr_client);
     }
@@ -45,7 +46,7 @@ class DolibarrApiService extends ApiRequestService
                 ["limit" => "1", "sqlfilters" => "statut=1", "thirdparty_ids" => $socId]
             )[0];
         } catch (HttpException $e) {
-            if ($e->getStatusCode() == 404) {
+            if ($e->getStatusCode() === 404) {
                 // /!\ The dolibarr API will return a 404 error if no results are found...
                 return [];
             }
@@ -65,7 +66,7 @@ class DolibarrApiService extends ApiRequestService
                 "invoices",
                 ["sortfield" => "datef", "sortorder" => "DESC", "limit" => 5, "sqlfilters" => "fk_soc=" . $socId]);
         } catch (HttpException $e) {
-            if ($e->getStatusCode() == 404) {
+            if ($e->getStatusCode() === 404) {
                 // /!\ The dolibarr API will return a 404 error if no results are found...
                 return [];
             }
@@ -74,14 +75,36 @@ class DolibarrApiService extends ApiRequestService
     }
 
     /**
-     * Get all the societies which are Opentalent active client
+     * Get all the societies which are Opentalent client
      * @throws HttpException
      */
-    public function getAllClients(): array
+    public function getAllClients(bool $withContract = false): array
     {
         return $this->getJsonContent(
             "thirdparties",
-            ["sqlfilters" => "client=1", 'limit' => '1000000']);
+            ["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;
+        }
     }
 
     /**
@@ -89,16 +112,16 @@ class DolibarrApiService extends ApiRequestService
      *
      * @throws HttpException
      */
-    public function getOpentalentContacts(int $socId): array
+    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&thirdparty_ids=" . $socId . "&sqlfilters=(te.2iopen_person_id%3A%3E%3A0)"
+                "contacts?limit=1000&t.statut=1&thirdparty_ids=" . $socId . "&sqlfilters=(te.2iopen_person_id%3A%3E%3A0)"
             );
         } catch (HttpException $e) {
-            if ($e->getStatusCode() == 404) {
+            if ($e->getStatusCode() === 404) {
                 // /!\ The dolibarr API will return a 404 error if no results are found...
                 return [];
             }

+ 208 - 131
src/Service/Dolibarr/DolibarrSyncService.php

@@ -21,12 +21,20 @@ 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 HttpException;
 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;
 
 /**
@@ -91,7 +99,7 @@ class DolibarrSyncService
                 continue;
             }
 
-            // Populate the expectedContacts array
+            // Populate the expected contacts array
             $organizationMembers = $membersIndex[$organization->getId()] ?? [];
 
             // ===== Update Society =====
@@ -148,99 +156,119 @@ class DolibarrSyncService
             $newSocietyData['array_options'] = $dolibarrSociety["array_options"];
             $newSocietyData['array_options']['options_2iopeninfoopentalent'] = implode("\n", $infos);
 
-            // Only update the fields that are different
-            $newSocietyData = self::filterDiff($dolibarrSociety, $newSocietyData);
+            // 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',
-                    $dolibarrSociety,
-                    $newSocietyData
+                    (int)$dolibarrSociety['id'],
+                    $newSocietyData,
+                    $dolibarrSociety
                 );
             }
 
             // ===== Update Contacts =====
-            $dolibarrContactsIndex = $this->getDolibarrContactsIndex((int)$dolibarrSociety['id']);
+            $dolibarrSocietyContacts = $this->dolibarrApiService->getContacts((int)$dolibarrSociety['id']);
             $contactsProcessed = [];
 
             foreach ($organizationMembers as $accessId => $missions) {
-                foreach ($missions as $mission) {
-                    if (in_array($mission, FunctionEnum::getOfficeMissions(), true)) {
-                        $access = $this->accessRepository->find($accessId);
-                        if ($access === null) {
-                            continue;
-                        }
-
-                        $person = $access->getPerson();
-                        if ($person === null) {
-                            continue;
-                        }
-
-                        // 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()) {
-                            continue;
-                        }
-
-                        // Build parameters for the query (if a query is needed
-                        $dolibarrContact = $dolibarrContactsIndex[$person->getId()] ?? null;
-                        $dolibarrContact = self::sanitizeDolibarrData($dolibarrContact);
-
-                        $contact = $this->getPersonContact($person);
-
-                        $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())
-                        ];
-
-                        if ($dolibarrContact === null) {
-                            // New contact
-                            $newContactData['socid'] = (int)$dolibarrSociety['id'];
-                            $newContactData['array_options'] = [
-                                'options_2iopen_person_id' => $person->getId()
-                            ];
-
-                            $operations[] = new CreateOperation(
-                                'New contact: ' . $person->getName() . ' ' . $person->getGivenName() . ' (' . $person->getId() . ')',
-                                'contacts',
-                                $newContactData
-                            );
-                        } else {
-                            // Only update the fields that are different
-                            $newContactData = self::filterDiff($dolibarrContact, $newContactData);
-
-                            // 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',
-                                    $dolibarrContact,
-                                    $newContactData,
-                                );
-                            }
-                        }
-
-                        // No need to test the other missions of this access
-                        break;
+                // 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 ($dolibarrContactsIndex as $personId => $contactData) {
+            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;
@@ -251,8 +279,9 @@ class DolibarrSyncService
                         'Disable contact: ' . $contactData['lastname'] . ' ' . $contactData['firstname'] . ' (' . $personId . ')' .
                         ' from  ' . $organization->getName() . ' (' . $organization->getId() . ')',
                         'contacts',
-                        $contactData,
-                        ['statut' => 0]
+                        (int)$contactData['id'],
+                        ['statut' => '0'],
+                        $contactData
                     );
                 }
             }
@@ -265,14 +294,6 @@ class DolibarrSyncService
         }
 
         $this->logger->info('Scan done, ' . count($operations) . ' required operations listed');
-        foreach ($operations as $operation) {
-            $this->logger->debug($operation->getLabel());
-            foreach ($operation->getChangeLog() as $message) {
-                $this->logger->debug('   ' . $message);
-            }
-        }
-
-        $this->logger->info('Scan ended');
         return $operations;
     }
 
@@ -295,23 +316,42 @@ class DolibarrSyncService
 
         $i = 0; $total = count($operations);
         foreach ($operations as $operation) {
-            if ($operation->getStatus() !== BaseRestOperation::STATUS_READY) {
+            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;
             }
 
-            $operation->execute($this->dolibarrApiService);
+            $this->logger->debug($operation->getLabel());
+            foreach ($operation->getChangeLog() as $message) {
+                $this->logger->debug('   ' . $message);
+            }
+
+            try {
+                // Execute the request
+                $response = $operation->execute($this->dolibarrApiService);
 
-            if ($operation->getStatus() === BaseRestOperation::STATUS_ERROR) {
+                // 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($operation->getErrorMessage());
+                $this->logger->error($e);
                 $errors++;
-            } elseif ($operation->getStatus() === BaseRestOperation::STATUS_DONE) {
-                $done++;
-            } else {
-                $unknown++;
             }
 
             $i++;
@@ -357,6 +397,13 @@ class DolibarrSyncService
         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'] . ')'
@@ -369,21 +416,6 @@ class DolibarrSyncService
         return $index;
     }
 
-    /**
-     * Get the dolibarr contacts of the society and index them by person_id
-     *
-     * @return array An index of the form [$personId => $dolibarrData]
-     */
-    protected function getDolibarrContactsIndex(int $socId): array {
-        $index = [];
-        $contacts = $this->dolibarrApiService->getOpentalentContacts($socId);
-        foreach ($contacts as $contactData) {
-            $personId = (int)$contactData["array_options"]["options_2iopen_person_id"];
-            $index[$personId] = $contactData;
-        }
-        return $index;
-    }
-
     /**
      * Returns an index of all the active members with their current mission(s)
      *
@@ -410,6 +442,38 @@ class DolibarrSyncService
         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() === $contactData["lastname"] &&
+                $person->getGivenName() === $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
@@ -615,30 +679,43 @@ class DolibarrSyncService
         );
     }
 
+
     /**
-     * Returns an array containing the keys/values from the newData array
-     * which are absent or different from $initialData
-     *
-     * /!\ Sub-arrays shall stay complete and must not be filtered
-     *
-     * Because for some fields the dolibarr api returns empty strings even when field is null in DB,
-     * we have to consider null and empty-string as equals. As far as we know, this causes no loss of information.
+     * Post-validation of the execution of the operation.
+     * In the case of a validation error, throw an HttpException
      *
-     * @param array $initialData
-     * @param array $newData
-     * @return array
+     * @param ResponseInterface $response
+     * @param BaseRestOperation $operation
+     * @throws RuntimeException
      */
-    protected static function filterDiff(array $initialData, array $newData): array
+    protected function validateResponse(ResponseInterface $response, BaseRestOperation $operation): void
     {
-        $result = [];
-        foreach ($newData as $field => $value) {
-            if (
-                ($value ?? '') !== ($initialData[$field] ?? '') ||
-                !array_key_exists($field, $initialData)
-            ) {
-                $result[$field] = $value;
-            }
+        $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)
+            );
         }
-        return $result;
     }
 }

+ 26 - 18
src/Service/Rest/Operation/BaseRestOperation.php

@@ -4,26 +4,26 @@ declare(strict_types=1);
 namespace App\Service\Rest\Operation;
 
 use App\Service\Rest\ApiRequestInterface;
-use App\Service\Rest\ApiRequestService;
 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\Service\Attribute\Required;
+use Symfony\Contracts\HttpClient\ResponseInterface;
 
 /**
  * A single operation, corresponding to a single request
- * to a REST API
+ * to a REST API (Json only)
  */
 abstract class BaseRestOperation
 {
-    const STATUS_READY = 0;
-    const STATUS_PENDING = 1;
-    const STATUS_DONE = 2;
-    const STATUS_ERROR = 3;
+    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 $label;
@@ -31,39 +31,46 @@ abstract class BaseRestOperation
     protected string $path;
     protected array $parameters;
     protected array $options;
-    protected array $currentData;
+    protected array $initialData;
     protected string $errorMessage = "";
 
     public function __construct(
         string $label,
         string $method,
         string $path,
-        array $currentData = [],
-        array $parameters = [],
-        array $options = []
+        array  $initialData = [],
+        array  $parameters = [],
+        array  $options = []
     ) {
         $this->label = $label;
         $this->method = $method;
         $this->path = $path;
-        $this->currentData = $currentData;
+        $this->initialData = $initialData;
         $this->parameters = $parameters;
         $this->options = $options;
     }
 
     /**
      * Execute the operation and update its status according to the result
+     *
+     * @param ApiRequestInterface $apiService
+     * @return ResponseInterface
      */
-    public function execute(ApiRequestInterface $apiService) {
+    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) {
+            if ($response->getStatusCode() === 200) {
+                $this->status = self::STATUS_DONE;
+            } else {
                 $this->status = self::STATUS_ERROR;
                 $this->errorMessage = 'Error ' . $response->getStatusCode() . ' : ' . $response->getContent();
-                return;
+                throw new HttpException($response->getStatusCode(), $response->getContent());
             }
-            $this->status = self::STATUS_DONE;
+
+            return $response;
         } catch (
             HttpException |
             ClientExceptionInterface |
@@ -74,6 +81,7 @@ abstract class BaseRestOperation
         $e) {
             $this->status = self::STATUS_ERROR;
             $this->errorMessage = '' . $e;
+            throw new RuntimeException($e->getMessage());
         }
     }
 
@@ -120,9 +128,9 @@ abstract class BaseRestOperation
     /**
      * @return array
      */
-    public function getCurrentData(): array
+    public function getInitialData(): array
     {
-        return $this->currentData;
+        return $this->initialData;
     }
 
     /**

+ 3 - 3
src/Service/Rest/Operation/CreateOperation.php

@@ -14,18 +14,18 @@ class CreateOperation extends BaseRestOperation
     protected array $data;
 
     #[Pure]
-    public function __construct(string $label, string $entity, array $data, array $parameters = [], array $options = []) {
+    public function __construct(string $label, string $entityName, array $data, array $parameters = [], array $options = []) {
         $this->data = $data;
         $options['json'] = $this->data;
         parent::__construct(
             $label,
             'POST',
-            $entity,
+            $entityName,
             [],
             $parameters,
             $options
         );
-        $this->entity = $entity;
+        $this->entity = $entityName;
     }
 
     /**

+ 8 - 4
src/Service/Rest/Operation/DeleteOperation.php

@@ -14,19 +14,19 @@ class DeleteOperation extends BaseRestOperation
     protected int $id;
 
     #[Pure]
-    public function __construct(string $label, string $entity, array $current, array $options = []) {
-        $id = (int)$current['id'];
+    public function __construct(string $label, string $entityName, array $initialData, array $options = []) {
+        $id = (int)$initialData['id'];
 
         parent::__construct(
             $label,
             'DELETE',
-            $entity . '/' . $id,
+            $entityName . '/' . $id,
             [],
             [],
             $options
         );
 
-        $this->entity = $entity;
+        $this->entity = $entityName;
         $this->id = $id;
     }
 
@@ -38,6 +38,10 @@ class DeleteOperation extends BaseRestOperation
         return $this->entity;
     }
 
+    protected function getExpectedResult(): ?array {
+        return null;
+    }
+
     /**
      * Return an array of messages describing the change that this operation will bring
      *

+ 29 - 18
src/Service/Rest/Operation/UpdateOperation.php

@@ -4,6 +4,13 @@ declare(strict_types=1);
 namespace App\Service\Rest\Operation;
 
 use JetBrains\PhpStorm\Pure;
+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;
 
 /**
  * A single update operation (a PUT request)
@@ -14,22 +21,30 @@ class UpdateOperation extends BaseRestOperation
     protected int $id;
     protected array $data;
 
+    /**
+     * @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(string $label, string $entity, array $current, array $data, array $parameters = [], array $options = []) {
-        $id = (int)$current['id'];
+    public function __construct(string $label, string $entityName, int $id, array $data, array $initialData = [], array $parameters = [], array $options = []) {
         $this->data = $data;
         $options['json'] = $this->data;
 
         parent::__construct(
             $label,
             'PUT',
-            $entity . '/' . $id,
-            $current,
+            $entityName . '/' . $id,
+            $initialData,
             $parameters,
             $options
         );
 
-        $this->entity = $entity;
+        $this->entity = $entityName;
         $this->id = $id;
     }
 
@@ -53,29 +68,25 @@ class UpdateOperation extends BaseRestOperation
      * Return an array of messages describing the change that this operation will bring
      *
      * @return array
-     * @throws \Exception
      */
     public function getChangeLog(): array {
         $messages = [
             '[PUT ' . $this->entity . '/' . $this->id . ']'
         ];
         foreach ($this->data as $field => $newValue) {
-            if (!array_key_exists($field, $this->currentData)) {
-                throw new \Exception('Field does not exists in the current object data : ' . $field);
-            }
-            if (is_array($newValue)) {
+            if (!array_key_exists($field, $this->initialData)) {
+                $messages[] = $field . '.' . $field . ' : ? => `' . $newValue . '`';
+            } else if (is_array($newValue)) {
                 foreach ($newValue as $subField => $newSubValue) {
-                    if (!array_key_exists($subField, $this->currentData[$field])) {
-                        throw new \Exception('Field does not exists in the current object data : ' . $field . '.' . $subField);
+                    if (!array_key_exists($subField, $this->initialData[$field])) {
+                        $messages[] = $field . '.' . $subField . ' : (new sub-key) `' . $newSubValue . '`';
                     }
-                    if ($newSubValue !== $this->currentData[$field][$subField]) {
-                        $messages[] = $field . '.' . $subField . ' : `'. $this->currentData[$field][$subField] . '` => `' . $newSubValue . '`';
+                    else if ($newSubValue !== $this->initialData[$field][$subField]) {
+                        $messages[] = $field . '.' . $subField . ' : `'. $this->initialData[$field][$subField] . '` => `' . $newSubValue . '`';
                     }
                 }
-            } else {
-                if ($newValue !== $this->currentData[$field]) {
-                    $messages[] = $field . ' : `' . $this->currentData[$field] . '` => `' . $newValue . '`';
-                }
+            } else if ($newValue !== $this->initialData[$field]) {
+                $messages[] = $field . ' : `' . $this->initialData[$field] . '` => `' . $newValue . '`';
             }
         }
         return $messages;

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

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

+ 75 - 60
tests/Service/Dolibarr/DolibarrSyncServiceTest.php

@@ -22,8 +22,8 @@ use App\Repository\Access\FunctionTypeRepository;
 use App\Repository\Organization\OrganizationRepository;
 use App\Service\Dolibarr\DolibarrApiService;
 use App\Service\Dolibarr\DolibarrSyncService;
-use App\Service\Rest\ApiRequestService;
 use App\Service\Rest\Operation\CreateOperation;
+use App\Service\Rest\Operation\UpdateOperation;
 use Doctrine\Common\Collections\ArrayCollection;
 use JetBrains\PhpStorm\Pure;
 use libphonenumber\PhoneNumber;
@@ -35,7 +35,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
 
 class TestableDolibarrSyncService extends DolibarrSyncService {
     public function getDolibarrSocietiesIndex(): array { return parent::getDolibarrSocietiesIndex(); }
-    public function getDolibarrContactsIndex(int $socId): array { return parent::getDolibarrContactsIndex($socId); }
+    public 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); }
@@ -45,7 +45,7 @@ class TestableDolibarrSyncService extends DolibarrSyncService {
     public function getPersonContact(Person $person): ?ContactPoint { return parent::getPersonContact($person); }
     public function formatContactPosition(array $missions, ?string $gender = 'X'): string { return parent::formatContactPosition($missions, $gender); }
     public static function formatPhoneNumber(PhoneNumber $phoneNumber): string { return parent::formatPhoneNumber($phoneNumber); }
-    public static function filterDiff(array $initialData, array $newData): array { return parent::filterDiff($initialData, $newData); }
+    public static function getChanges(array $initialData, array $newData): array { return parent::getChanges($initialData, $newData); }
 }
 
 class DolibarrSyncServiceTest extends TestCase
@@ -188,12 +188,12 @@ class DolibarrSyncServiceTest extends TestCase
 
         // Get dolibarr contacts
         $this->dolibarrApiService
-        ->method('getOpentalentContacts')
+        ->method('getContacts')
         ->with(1726)
         ->willReturn(
             array_filter(
                 $this->getJsonContentFromFixture('contacts.json'),
-                function ($c) {
+                static function ($c) {
                     return in_array(
                         (int)$c["array_options"]["options_2iopen_person_id"],
                         [
@@ -306,8 +306,9 @@ class DolibarrSyncServiceTest extends TestCase
                 'phone_pro : ``',
                 'phone_mobile : ``',
                 'poste : ``',
-                'socid : `1726`',
-                'array_options.options_2iopen_person_id : `1000`'
+                'statut : `1`',
+                'array_options.options_2iopen_person_id : `1000`',
+                'socid : `1726`'
             ],
             $operations[2]->getChangeLog()
         );
@@ -317,19 +318,47 @@ class DolibarrSyncServiceTest extends TestCase
         );
     }
 
-    public function testExecuteOk() {
-
+    public function testExecuteError()
+    {
         $operation = new CreateOperation('operation 1', 'thirdparty', ['data' => 1]);
         $this->assertEquals($operation->getStatus(), $operation::STATUS_READY);
 
-        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
-        $response->method('getStatusCode')->willReturn(200);
-        $this->dolibarrApiService->method('request')->willReturn($response);
+        $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();
-        $operations = $syncService->execute([$operation]);
+        $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);
 
-        $this->assertEquals($operation::STATUS_DONE, $operations[0]->getStatus());
+        // 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() {
@@ -347,20 +376,41 @@ class DolibarrSyncServiceTest extends TestCase
         $this->assertEquals("13930", $index[13930]['array_options']['options_2iopen_organization_id']);
     }
 
-    public function testDolibarrContactsIndex() {
-        $this->dolibarrApiService
-            ->expects($this->once())
-            ->method('getOpentalentContacts')
-            ->with(9)
-            ->willReturn(
-                $this->getJsonContentFromFixture('contacts.json')
-            );
+    public function testFindDolibarrContactFor() {
 
-        $syncService = $this->newDolibarrSyncService();
+        $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']);
 
-        $index = $syncService->getDolibarrContactsIndex(9);
+        // 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');
 
-        $this->assertEquals("302117", $index[302117]['array_options']['options_2iopen_person_id']);
+        $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() {
@@ -672,39 +722,4 @@ class DolibarrSyncServiceTest extends TestCase
             TestableDolibarrSyncService::formatPhoneNumber($phoneNumber)
         );
     }
-
-    public function testFilterDiff() {
-        $this->assertEquals(
-            ['b' => -2, 'c' => ['d' => 4, 'e' => ['f' => -5]], 'g' => 7],
-            TestableDolibarrSyncService::filterDiff(
-                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
-                ['a' => 1, 'b' => -2, 'c' => ['d' => 4, 'e' => ['f' => -5]], 'g' => 7],
-            )
-        );
-
-        $this->assertEquals(
-            [],
-            TestableDolibarrSyncService::filterDiff(
-                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
-                ['a' => 1, 'b' => 2, 'c' => ['d' => 4, 'e' => ['f' => 5]]],
-            )
-        );
-
-        $this->assertEquals(
-            [],
-            TestableDolibarrSyncService::filterDiff(
-                [],
-                [],
-            )
-        );
-
-        $this->assertEquals(
-            ['a' => 1],
-            TestableDolibarrSyncService::filterDiff(
-                [],
-                ['a' => 1],
-            )
-        );
-    }
-
 }

+ 1 - 1
tests/Service/Dolibarr/fixtures/contacts.json

@@ -393,7 +393,7 @@
     "import_key": "crm",
     "array_options": {
       "options_rfltr_model_id": null,
-      "options_2iopen_person_id": "112792"
+      "options_2iopen_person_id": null
     },
     "linkedObjectsIds": null,
     "canvas": null,

+ 10 - 4
tests/Service/Rest/Operation/BaseRestOperationTest.php

@@ -33,7 +33,7 @@ class BaseRestOperationTest extends TestCase
         $this->assertEquals('a label', $operation->getLabel());
         $this->assertEquals('GET', $operation->getMethod());
         $this->assertEquals('/a/path', $operation->getPath());
-        $this->assertEquals(['data' => 1], $operation->getCurrentData());
+        $this->assertEquals(['data' => 1], $operation->getInitialData());
         $this->assertEquals(['param' => 2], $operation->getParameters());
         $this->assertEquals(['option' => 3], $operation->getOptions());
 
@@ -82,9 +82,12 @@ class BaseRestOperationTest extends TestCase
             ->with('PUT', 'entity/2')
             ->willReturn($responseError);
 
-        $operation->execute($this->apiRequestService);
+        try {
+            $operation->execute($this->apiRequestService);
+        } catch (RuntimeException) {
+        }
         $this->assertEquals(BaseRestOperation::STATUS_ERROR, $operation->getStatus());
-        $this->assertEquals('Error 404 : Not found', $operation->getErrorMessage());
+        $this->assertMatchesRegularExpression('/.*Not found.*/', $operation->getErrorMessage());
     }
 
     /**
@@ -110,7 +113,10 @@ class BaseRestOperationTest extends TestCase
             ->with('PUT', 'entity/3')
             ->willThrowException(new ClientException($responseException));
 
-        $operation->execute($this->apiRequestService);
+        try {
+            $operation->execute($this->apiRequestService);
+        } catch (RuntimeException) {
+        }
         $this->assertEquals(BaseRestOperation::STATUS_ERROR, $operation->getStatus());
         $this->assertMatchesRegularExpression(
             '/.*ClientException: HTTP 500 returned for "entity\/3".*/',

+ 7 - 5
tests/Service/Rest/Operation/UpdateOperationTest.php

@@ -10,14 +10,15 @@ class UpdateOperationTest extends TestCase
         $operation = new UpdateOperation(
             'Update a dinosaur',
             'dinosaur',
-            ['id' => 1, 'weight' => 1600],
-            ['weight' => 1800]
+            1,
+            ['weight' => 1800],
+            ['weight' => 1600]
         );
 
         $this->assertEquals('PUT', $operation->getMethod());
         $this->assertEquals('dinosaur', $operation->getEntity());
         $this->assertEquals('dinosaur/1', $operation->getPath());
-        $this->assertEquals(['id' => 1, 'weight' => 1600], $operation->getCurrentData());
+        $this->assertEquals(['weight' => 1600], $operation->getInitialData());
         $this->assertEquals(['weight' => 1800], $operation->getData());
 
         $this->assertEquals('PUT dinosaur/1', (string)$operation);
@@ -27,8 +28,9 @@ class UpdateOperationTest extends TestCase
         $operation = new UpdateOperation(
             'Update a dinosaur',
             'dinosaur',
-            ['id' => 1, 'weight' => 1600, 'attrs' => ['vision' => 'movement-based', 'teeth' => '100']],
-            ['weight' => 1800, 'attrs' => ['vision' => 'movement-based', 'teeth' => '99']]
+            1,
+            ['weight' => 1800, 'attrs' => ['vision' => 'movement-based', 'teeth' => '99']],
+            ['weight' => 1600, 'attrs' => ['vision' => 'movement-based', 'teeth' => '100']]
         );
 
         $this->assertEquals(

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

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