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 * @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 = $this->sanitizeDolibarrData($dolibarrSociety); $organization = $this->organizationRepository->find($organizationId); if ($organization === null) { $this->logger->error("Organization " . $organizationId . " not found in the Opentalent DB"); continue; } // Populate the expectedContacts 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') . " : " . $this->countWithMission([FunctionEnum::STUDENT()->getValue()], $organizationMembers); } if (SettingsProductEnum::isSchool($product) || SettingsProductEnum::isArtist($product)) { $infos[] = $this->translator->trans('ADHERENTS_COUNT') . " : " . $this->countWithMission([FunctionEnum::ADHERENT()->getValue()], $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); // Only update the fields that are different $newSocietyData = $this->filterDiff($dolibarrSociety, $newSocietyData); // 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 ); } // ===== Update Contacts ===== $dolibarrContactsIndex = $this->getDolibarrContactsIndex((int)$dolibarrSociety['id']); $contactsProcessed = []; foreach ($organizationMembers as $accessId => $missions) { foreach ($missions as $mission) { if (in_array($mission, FunctionEnum::getOfficeMissions())) { $access = $this->accessRepository->find($accessId); $person = $access->getPerson(); // Keep track of the contacts seen if (in_array($person->getId(), $contactsProcessed)) { // 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 = $this->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() ? $this->formatPhoneNumber($contact?->getTelphone()) : null, 'phone_mobile' => $contact?->getMobilPhone() ? $this->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 = $this->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; } } } foreach ($dolibarrContactsIndex as $personId => $contactData) { if ((int)$contactData['statut'] === 0) { // contact is already disabled continue; } if (!in_array($personId, $contactsProcessed)) { // 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', $contactData, ['statut' => 0] ); } } // Next society $i++; if ($progressionCallback !== null) { $progressionCallback($i, $total); } } $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; } /** * Execute the operations listed with the DolibarrSyncService::scan method * * Returns an array of DolibarrSyncOperations * * @param array $operations * @return array * @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() !== BaseRestOperation::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); if ($operation->getStatus() === BaseRestOperation::STATUS_ERROR) { $this->logger->error('Error while executing operation : ' . $operation); $this->logger->error(implode("\n", $operation->getChangeLog())); $this->logger->error($operation->getErrorMessage()); $errors++; } elseif ($operation->getStatus() === BaseRestOperation::STATUS_DONE) { $done++; } else { $unknown++; } $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) { $this->logger->warning( 'Dolibarr client has no organization id: ' . $clientData['name'] . ' (' . $clientData['id'] . ')' ); continue; } $index[$organizationId] = $clientData; } 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 = intval($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) * * 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; } /** * 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 $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()->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, 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, function ($m) use ($to_exclude) { return !in_array($m, $to_exclude); } ) ) ); 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) ); } /** * 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. * * @param array $initialData * @param array $newData * @return array */ protected static function filterDiff(array $initialData, array $newData): array { $result = []; foreach ($newData as $field => $value) { if ( ($value ?? '') !== ($initialData[$field] ?? '') || !array_key_exists($field, $initialData) ) { $result[$field] = $value; } } return $result; } }