Dolibarr DB ** */ class DolibarrSyncService { private const ASSOCIATION_SCHOOL_TAG_ID = 67; private const ASSOCIATION_PREMIUM_TAG_ID = 69; private const LOCAL_AUTH_SCHOOL_TAG_ID = 68; private const LOCAL_AUTH_PREMIUM_TAG_ID = 70; private const SYNCHRONIZED_TAGS = [ self::ASSOCIATION_SCHOOL_TAG_ID => 'Association School', self::ASSOCIATION_PREMIUM_TAG_ID => 'Association School Premium', self::LOCAL_AUTH_SCHOOL_TAG_ID => 'CT School', self::LOCAL_AUTH_PREMIUM_TAG_ID => 'CT School Premium', ]; 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; } $dolibarrSocietyId = (int) $dolibarrSociety['id']; // 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 $organizationEmail = $this->getOrganizationEmail($organization); $newSocietyData['email'] = !empty($organizationEmail) ? $organizationEmail : $dolibarrSociety['email']; $organizationPhone = $this->getOrganizationPhone($organization); $newSocietyData['phone'] = !empty($organizationPhone) ? $organizationPhone : $dolibarrSociety['phone']; // 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)) { $newSocietyData['array_options']['options_2iopen_last_sync_date'] = DatesUtils::new()->format('c'); $operations[] = new UpdateOperation( 'Update society : '.$organization->getName().' ('.$organization->getId().')', 'thirdparties', $dolibarrSocietyId, $newSocietyData, $dolibarrSociety ); } // ===== Update Contacts ===== $dolibarrSocietyContacts = $this->dolibarrApiService->getContacts($dolibarrSocietyId); $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() ?? $dolibarrContact['email'] ?? null, 'phone_pro' => !empty($contact?->getTelphone()) ? $this->formatPhoneNumber($contact->getTelphone()) : $dolibarrContact['phone_pro'] ?? null, 'phone_mobile' => !empty($contact?->getMobilPhone()) ? $this->formatPhoneNumber($contact->getMobilPhone()) : $dolibarrContact['phone_mobile'] ?? null, 'poste' => !empty($missions) ? $this->formatContactPosition($missions, $person->getGender()?->value) : $dolibarrContact['poste'] ?? null, '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'] = $dolibarrSocietyId; $newContactData['array_options']['options_2iopen_last_sync_date'] = DatesUtils::new()->format('c'); $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)) { $newContactData['array_options']['options_2iopen_last_sync_date'] = DatesUtils::new()->format('c'); $operations[] = new UpdateOperation( 'Update contact: '.$person->getName().' '.$person->getGivenName().' ('.$person->getId().')'. ' in '.$organization->getName().' ('.$organization->getId().')', 'contacts', (int) $dolibarrContact['id'], $newContactData, $dolibarrContact ); } } } // Disable non existing contacts 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 $newContactData = [ 'statut' => '0', 'array_options' => ['options_2iopen_last_sync_date' => DatesUtils::new()->format('c')], ]; $operations[] = new UpdateOperation( 'Disable contact: '.$contactData['lastname'].' '.$contactData['firstname'].' ('.$personId.')'. ' from '.$organization->getName().' ('.$organization->getId().')', 'contacts', (int) $contactData['id'], $newContactData, $contactData ); } } // Update Billing service contact (if existing) foreach ($dolibarrSocietyContacts as $contactData) { if ( strtolower($contactData['lastname']) === 'service facturation' && empty($contactData['name']) && empty($contactData['array_options']['options_2iopen_person_id']) ) { $billingAddress = $this->getOrganizationBillingPostalAddress($organization); $newContactData = $contactData; if ($billingAddress) { $newContactData['address'] = $this->addressPostalUtils->getFullStreetAddress($billingAddress); $newContactData['zip'] = $mainAddress->getPostalCode(); $newContactData['town'] = $mainAddress->getAddressCity(); } else { $newContactData['address'] = null; $newContactData['zip'] = null; $newContactData['town'] = null; } // 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( $contactData, $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: Service facturation'. ' in '.$organization->getName().' ('.$organization->getId().')', 'contacts', (int) $contactData['id'], $newContactData, $contactData ); } break; } } // ===== Update Tags ===== $currentTags = $this->dolibarrApiService->getSocietyTagsIds($dolibarrSocietyId); $expectedTags = $this->getExpectedTagsFor($organization); // Remove unexpected tags foreach ($currentTags as $tagId) { if (!array_key_exists($tagId, self::SYNCHRONIZED_TAGS)) { continue; } if (!in_array($tagId, $expectedTags)) { $operations[] = new DeleteOperation( 'Delete tag: `'.self::SYNCHRONIZED_TAGS[$tagId]. '` from '.$organization->getName().' ('.$organization->getId().')', "/thirdparties/$dolibarrSocietyId/categories", $tagId ); } } // Add missing tags foreach ($expectedTags as $tagId) { if (!in_array($tagId, $currentTags)) { $operations[] = new CreateOperation( 'Add tag: `'.self::SYNCHRONIZED_TAGS[$tagId]. '` to '.$organization->getName().' ('.$organization->getId().')', "/thirdparties/$dolibarrSocietyId/categories/$tagId", [] ); } } // 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()); } ++$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 * * @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); } elseif ($value === '') { $data[$field] = null; } } return $data; } /** * Retrieve the postal address of the organization. */ protected function getOrganizationPostalAddress(Organization $organization): ?AddressPostal { $addressPriorities = [ AddressPostalOrganizationTypeEnum::ADDRESS_HEAD_OFFICE, AddressPostalOrganizationTypeEnum::ADDRESS_BILL, AddressPostalOrganizationTypeEnum::ADDRESS_CONTACT, 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 postal address of the organization. */ protected function getOrganizationBillingPostalAddress(Organization $organization): ?AddressPostal { $addressPriorities = [ AddressPostalOrganizationTypeEnum::ADDRESS_BILL, AddressPostalOrganizationTypeEnum::ADDRESS_CONTACT, AddressPostalOrganizationTypeEnum::ADDRESS_HEAD_OFFICE, 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. */ 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. */ 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. */ 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...]] */ 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. */ 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 */ 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. */ protected function formatPhoneNumber(PhoneNumber $phoneNumber): mixed { $phoneUtil = PhoneNumberUtil::getInstance(); return str_replace( ' ', '', $phoneUtil->format($phoneNumber, PhoneNumberFormat::INTERNATIONAL) ); } /** * @return array */ protected function getExpectedTagsFor(Organization $organization): array { $expectedTags = []; $product = $organization->getSettings()->getProduct(); // Association, school or school premium if ($organization->getLegalStatus() === LegalEnum::ASSOCIATION_LAW_1901) { if ($product === SettingsProductEnum::SCHOOL) { $expectedTags[] = self::ASSOCIATION_SCHOOL_TAG_ID; } if ($product === SettingsProductEnum::SCHOOL_PREMIUM) { $expectedTags[] = self::ASSOCIATION_PREMIUM_TAG_ID; } } // Local authorities, school or school premium if ($organization->getLegalStatus() === LegalEnum::LOCAL_AUTHORITY) { if ($product === SettingsProductEnum::SCHOOL) { $expectedTags[] = self::LOCAL_AUTH_SCHOOL_TAG_ID; } if ($product === SettingsProductEnum::SCHOOL_PREMIUM) { $expectedTags[] = self::LOCAL_AUTH_PREMIUM_TAG_ID; } } return $expectedTags; } }