Dolibarr DB ** */ class DolibarrSyncService { private LoggerInterface $logger; public function __construct( private OrganizationRepository $organizationRepository, private AccessRepository $accessRepository, private FunctionTypeRepository $functionTypeRepository, private DolibarrApiService $dolibarrApiService, private AddressPostalUtils $addressPostalUtils, private ArrayUtils $arrayUtils, private TranslatorInterface $translator, private Utils $organizationUtils ) {} #[Required] /** @see https://symfony.com/doc/current/logging/channels_handlers.html#how-to-autowire-logger-channels */ public function setLoggerInterface(LoggerInterface $cronLogger): void { $this->logger = $cronLogger; } /** * 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 * * @param 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 * @throws Exception * * @noinspection NullPointerExceptionInspection */ 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]) as $functionType) { $adminMissions[] = $functionType->getMission()->value; } // Store networks ids id dolibarr $cmfDolibarrId = (int)($this->dolibarrApiService->getSociety(OrganizationIdsEnum::CMF->value)['id']); $ffecDolibarrId = (int)($this->dolibarrApiService->getSociety(OrganizationIdsEnum::FFEC->value)['id']); // Loop over the Opentalent organizations, and fill up the operations list $operations = []; $i = 0; $total = count($dolibarrClientsIndex); foreach ($dolibarrClientsIndex as $organizationId => $dolibarrSociety) { $dolibarrSociety = $this->sanitizeDolibarrData($dolibarrSociety); $organization = $this->organizationRepository->find($organizationId); if ($organization === null) { $this->logger->error("Organization " . $organizationId . " not found in the Opentalent DB"); $i++; if ($progressionCallback !== null) { $progressionCallback($i, $total); } 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 = $this->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 if (!in_array($organization->getId(), [NetworkEnum::CMF, NetworkEnum::FFEC], true)) { $newSocietyData['parent'] = '' . match ( $this->getOrganizationNetworkId($organization) ) { NetworkEnum::CMF->value => $cmfDolibarrId, NetworkEnum::FFEC->value => $ffecDolibarrId, default => null }; } // More infos $infos = []; $product = $organization->getSettings()->getProduct(); if ($this->organizationUtils->isSchool($organization)) { $infos[] = $this->translator->trans('STUDENTS_COUNT') . " : " . $this->countWithMission([FunctionEnum::STUDENT->value], $organizationMembers); } if ($this->organizationUtils->isSchool($organization) || $this->organizationUtils->isArtist($organization)) { $infos[] = $this->translator->trans('ADHERENTS_COUNT') . " : " . $this->countWithMission([FunctionEnum::ADHERENT->value], $organizationMembers); } $infos[] = $this->translator->trans('ADMIN_ACCESS_COUNT') . " : " . $this->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); if (!empty($product)) { $newSocietyData['array_options']['options_2iopen_software_opentalent'] = $this->translator->trans($product->value); } // 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 = $this->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(); // Keep track of the contacts seen $contactsProcessed[] = $person->getId(); // special: if the contact hasn't a firstname and a lastname, ignore it if (empty($person->getName()) || empty($person->getGivenName())) { $this->logger->error("Person " . $person->getId() . " miss a lastname and/or a firstname, ignored."); continue; } // Get the matching dolibarr contact $dolibarrContact = $this->findDolibarrContactFor($dolibarrSocietyContacts, $person); $dolibarrContact = $this->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()->value) : null, 'lastname' => trim($person->getName()), 'firstname' => trim($person->getGivenName()), 'email' => $contact?->getEmail(), 'phone_pro' => $contact?->getTelphone() ? $this->formatPhoneNumber($contact->getTelphone()) : null, 'phone_mobile' => $contact?->getMobilPhone() ? $this->formatPhoneNumber($contact->getMobilPhone()): null, 'poste' => $this->formatContactPosition($missions, $person->getGender()?->value), '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 = $this->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 $operations * @param 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 * @throws Exception */ 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); $i++; if ($progressionCallback !== null) { $progressionCallback($i, $total); } 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->getMessage()); } } $done++; } catch (RuntimeException $e) { $this->logger->error('Error while executing operation : ' . $operation); $this->logger->error(implode("\n", $operation->getChangeLog())); $this->logger->error($e->getMessage()); $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 * @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->value; } 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 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 function sanitizeDolibarrData(?array $data): ?array { if ($data === null) { return null; } foreach ($data as $field => $value) { if (is_array($value)) { $data[$field] = $this->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, AddressPostalOrganizationTypeEnum::ADDRESS_CONTACT, AddressPostalOrganizationTypeEnum::ADDRESS_HEAD_OFFICE, AddressPostalOrganizationTypeEnum::ADDRESS_PRACTICE, AddressPostalOrganizationTypeEnum::ADDRESS_OTHER ]; $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, ContactPointTypeEnum::CONTACT, ContactPointTypeEnum::PRINCIPAL, ContactPointTypeEnum::OTHER ]; $contactPoints = $organization->getContactPoints(); foreach ($contactPriorities as $contactType) { foreach ($contactPoints as $contactPoint) { if ($contactPoint->getContactType() === $contactType) { if ($contactPoint->getTelphone() !== null) { return $this->formatPhoneNumber($contactPoint->getTelphone()); } if ($contactPoint->getMobilPhone() !== null) { return $this->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, ContactPointTypeEnum::CONTACT, ContactPointTypeEnum::PRINCIPAL, ContactPointTypeEnum::OTHER ]; $contactPoints = $organization->getContactPoints(); foreach ($contactPriorities as $contactType) { foreach ($contactPoints as $contactPoint) { if ($contactPoint->getContactType() === $contactType && $contactPoint->getEmail() !== null) { return $contactPoint->getEmail(); } } } return null; } /** * Return the id of the first active network found for the given organization * * @param Organization $organization * @return int|null */ protected function getOrganizationNetworkId(Organization $organization): ?int { foreach ($organization->getNetworkOrganizations() as $networkOrganization) { if ($networkOrganization->getEndDate() !== null && $networkOrganization->getEndDate() < new \DateTime()) { continue; } return $networkOrganization->getNetwork()?->getId(); } 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 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, ContactPointTypeEnum::OTHER ]; $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 list $missions * @param string|null $gender * @return string */ protected function formatContactPosition(array $missions, ?string $gender = 'X'): string { $to_exclude = [ FunctionEnum::ADHERENT->value, FunctionEnum::STUDENT->value, FunctionEnum::OTHER->value ]; $poste = implode( ', ', array_map( function($m) use ($gender) { return $this->translator->trans( $m, ['gender' => [ GenderEnum::MISS->value=> 'F', GenderEnum::MISTER->value => '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 function formatPhoneNumber(PhoneNumber $phoneNumber): mixed { $phoneUtil = PhoneNumberUtil::getInstance(); return str_replace( ' ', '', $phoneUtil->format($phoneNumber, PhoneNumberFormat::INTERNATIONAL) ); } /** * Post-validation of the execution of the operation. * Compare the actual result to the expected one to ensure that the data was correctly updated. * * In the case of a validation error, throw an HttpException * * @param ResponseInterface $response * @param UpdateOperation | CreateOperation $operation * @throws RuntimeException */ protected function validateResponse(ResponseInterface $response, UpdateOperation | CreateOperation $operation): void { $updated = $operation->getData(); 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 = $this->sanitizeDolibarrData($updated); $responseData = $this->sanitizeDolibarrData($responseData); $diffs = $this->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) ); } } }