first commit

This commit is contained in:
2025-08-02 16:30:27 +02:00
commit 23646bfcee
14851 changed files with 1750626 additions and 0 deletions

View File

@@ -0,0 +1,779 @@
<?php
declare(strict_types=1);
namespace External\ZNZBundle\Synchronizers;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use External\ZNZBundle\Exception\ZNZException;
use External\ZNZBundle\Util\ZNZApi;
use External\ZNZBundle\Util\ZNZUtil;
use KupShop\AdminBundle\Util\ActivityLog;
use KupShop\KupShopBundle\Context\ContextManager;
use KupShop\KupShopBundle\Context\CountryContext;
use KupShop\KupShopBundle\Context\LanguageContext;
use KupShop\KupShopBundle\Email\PasswordResetAdminEmail;
use KupShop\KupShopBundle\Util\Contexts;
use KupShop\KupShopBundle\Util\Database\QueryHint;
use KupShop\KupShopBundle\Util\System\TokenGenerator;
use KupShop\SynchronizationBundle\Exception\RabbitRetryMessageException;
use Query\Operator;
use Query\QueryBuilder;
use Symfony\Contracts\Service\Attribute\Required;
class UserSynchronizer extends BaseSynchronizer implements SynchronizerOutInterface
{
protected static string $type = 'user';
public static string $typeGroups = 'users_group';
#[Required]
public ZNZApi $znzApi;
#[Required]
public TokenGenerator $tokenGenerator;
#[Required]
public ContextManager $contextManager;
#[Required]
public PasswordResetAdminEmail $passwordResetAdminEmail;
public static function getPriority(): int
{
return 99;
}
public static function getHandledTables(): array
{
return [
'ZakaznickaSkupina' => 'processUserGroup',
'Zakaznici' => 'processUser',
'Adresy' => 'processUserAddress',
'CenoveUrovneVazby' => 'processUserGroupPriceListRelation',
];
}
public function useTestConnection(): bool
{
return !$this->configuration->useProductionHelios();
}
public static function getIdField(): ?string
{
return 'IdZakaznik';
}
public function processToHelios(): void
{
if (isLocalDevelopment()) {
return;
}
$lastSyncTime = $this->getLastSyncTime();
$orSpecs = ['zu.id_znz IS NULL'];
if ($lastSyncTime) {
$orSpecs[] = 'u.date_updated IS NOT NULL AND u.date_updated > :filterDate';
}
$qb = sqlQueryBuilder()
->select('u.*, zu.id_znz, zu.id_znz_invoice, zu.id_znz_delivery')
->from('users', 'u')
->leftJoin('u', 'znz_users', 'zu', 'zu.id_user = u.id')
->andWhere(
Operator::andX(
$this->getUsersToHeliosSpec(),
// podminky pro zapis uzivatele
Operator::orX($orSpecs)
)
)
// order by date_updated with newly registered at the beginning
->orderBySql('zu.id_znz IS NULL DESC, u.date_updated ASC')
->groupBy('u.id');
if ($lastSyncTime) {
$qb->setParameter('filterDate', $lastSyncTime->format('Y-m-d H:i:s'));
}
// max 250 per one sync run
$qb->setMaxResults(250);
$updateLastSyncTime = null;
foreach ($qb->execute() as $item) {
$user = new \User();
$user->loadData($item);
$userData = $this->getUserData($user, !empty($item['id_znz']) ? (int) $item['id_znz'] : null);
// save date_updated of last synchronized item
if (!empty($item['id_znz']) && !empty($item['date_updated'])) {
$updateLastSyncTime = (new \DateTime($item['date_updated']))->getTimestamp();
}
try {
$znzId = ZNZUtil::withRetryStrategy(fn () => $this->getZNZApi()->updateUser($userData), false);
} catch (\Exception $e) {
$this->logger->log($e);
continue;
}
if (empty($item['id_znz'])) {
$this->znzUtil->createMapping(self::$type, $znzId, (int) $item['id']);
}
$addresses = $this->getUserAddresses(
$user,
!empty($item['id_znz_invoice']) ? (int) $item['id_znz_invoice'] : null,
!empty($item['id_znz_delivery']) ? (int) $item['id_znz_delivery'] : null
);
$invoiceAddress = $addresses['invoice'];
$deliveryAddress = $addresses['delivery'];
// pokud uz je fakturacni adresa v Heliosu zalozena, nebo je vyplnena adresa na e-shopu
if ($item['id_znz_invoice'] || !empty($invoiceAddress['firstname'])) {
try {
$znzInvoiceId = ZNZUtil::withRetryStrategy(fn () => $this->getZNZApi()->updateUserAddress($invoiceAddress), false);
} catch (\Exception $e) {
$this->logger->log($e);
}
if (empty($item['id_znz_invoice']) && isset($znzInvoiceId)) {
sqlQueryBuilder()
->update('znz_users')
->directValues(['id_znz_invoice' => $znzInvoiceId])
->where(Operator::equals(['id_user' => $user->id]))
->execute();
}
}
// pokud uz je dorucovaci adresa v Heliosu zalozena, nebo je vyplnena adresa na e-shopu
if ($item['id_znz_delivery'] || !empty($deliveryAddress['firstname'])) {
try {
$znzDeliveryId = ZNZUtil::withRetryStrategy(fn () => $this->getZNZApi()->updateUserAddress($deliveryAddress), false);
} catch (\Exception $e) {
$this->logger->log($e);
}
if (empty($item['id_znz_delivery']) && isset($znzDeliveryId)) {
sqlQueryBuilder()
->update('znz_users')
->directValues(['id_znz_delivery' => $znzDeliveryId])
->where(Operator::equals(['id_user' => $user->id]))
->execute();
}
}
}
if ($updateLastSyncTime) {
$this->updateLastSyncTime($updateLastSyncTime);
}
}
public function getUserData(\User $user, ?int $znzId = null): array
{
$user->fetchAddresses();
$data = [
'email' => $user->email,
'firstname' => $user->invoice['name'],
'middlename' => '',
'lastname' => $user->invoice['surname'],
'group_id' => $this->getUserGroupId($user),
'_website' => $this->getUserWebsite($user),
'helios_customer_id' => $znzId,
'helios_activity_type' => null,
'helios_legal_form' => 2, // 0=Právnická osoba 1=Fyzická osoba 2=Soukromá osoba 3=Neurčeno
];
return $data;
}
protected function processUser(array $item): void
{
$userId = $this->znzUtil->getMapping(self::$type, $item['IdZakaznik']);
if ($item['meta']['delete']) {
if ($userId) {
sqlQueryBuilder()
->delete('users')
->where(Operator::equals(['id' => $userId]))
->execute();
$this->logger->activity(sprintf('Proběhlo smazání uživatele: %s (%s)', $userId, $item['email'] ?? ''), [
'item' => $item,
]);
}
return;
}
if (empty($item['email'])) {
return;
}
if (!$userId) {
$registrationData = [
'email' => $item['email'],
'passw' => !empty($item['HashHeslo']) ? $item['HashHeslo'] : '',
'date_reg' => (new \DateTime())->format('Y-m-d H:i:s'),
];
try {
$userId = sqlGetConnection()->transactional(function () use ($item, $registrationData) {
sqlQueryBuilder()
->insert('users')
->directValues($registrationData)
->execute();
$userId = (int) sqlInsertId();
$this->znzUtil->createMapping(self::$type, $item['IdZakaznik'], $userId);
return $userId;
});
} catch (UniqueConstraintViolationException $e) {
if ($this->tryMergeWithNewsletterUser($userId, $registrationData)) {
$this->znzUtil->createMapping(self::$type, $item['IdZakaznik'], $userId);
} else {
throw new ZNZException(
sprintf('Unable to create user because of duplicated email: %s:%s', $item['IdZakaznik'], $item['email']),
[
'user' => $item,
]
);
}
}
if ($this->configuration->isB2BShop()) {
$this->activateUserB2BFeeds($userId);
}
}
$hasEmptyPassword = sqlQueryBuilder()
->select('1')
->from('users')
->where(Operator::equals(['id' => $userId, 'passw' => '']))
->execute()->fetchOne();
$update = [
'id_language' => $this->getUserLanguageByWebsite($item['IdWebsite']),
'email' => $item['email'],
];
// pokud se nejedna o B2B shop, tak aktualizuju i figure zakaznika
if (!$this->configuration->isB2BShop()) {
$update['figure'] = $item['Aktivni'] ? 'Y' : 'N';
}
if ($hasEmptyPassword && !empty($item['HashHeslo'])) {
$update['passw'] = $item['HashHeslo'];
}
try {
sqlQueryBuilder()
->update('users')
->directValues($update)
->where(Operator::equals(['id' => $userId]))
->execute();
} catch (UniqueConstraintViolationException $e) {
if (!$this->tryMergeWithNewsletterUser($userId, $update)) {
throw new ZNZException(
sprintf('Unable to update user "%s" because of duplicated email: %s:%s', $userId, $item['IdZakaznik'], $item['email']),
[
'item' => $item,
'userId' => $userId,
]
);
}
}
$this->updateUserGroup($userId, $item['IdZakaznickaSkupina']);
$this->updateUserData($userId, $item);
if ($this->configuration->isB2BShop()) {
$this->activateB2BUser($userId, $item);
}
}
protected function processUserAddress(array $item): void
{
if (!($mapping = $this->znzUtil->getUserMapping($item['IdZakaznik']))) {
return;
}
if ($item['meta']['delete']) {
return;
}
$prefix = '';
$mappingField = 'id_znz_invoice';
if (!$item['Fakturacni']) {
$prefix = 'delivery_';
$mappingField = 'id_znz_delivery';
}
$userName = $this->znzUtil->getUserNameParts($item['Nazev']);
$update = [
$prefix.'name' => $userName['name'],
$prefix.'surname' => $userName['surname'],
$prefix.'firm' => $item['DruhyNazev'],
$prefix.'city' => $item['Misto'],
$prefix.'street' => $item['Ulice'],
$prefix.'zip' => $item['PSC'],
$prefix.'country' => $this->znzUtil->getCountryCodeForHelios($item['IdZeme'] ?? Contexts::get(CountryContext::class)->getDefaultId()),
$prefix.'phone' => $item['Telefon'],
];
if ($item['Fakturacni']) {
if (!empty($item['DIC'])) {
$update[$prefix.'dic'] = trim($item['DIC']);
}
// fakturacni email
if (!empty($item['Email'])) {
$update['copy_email'] = $item['Email'];
}
}
sqlQueryBuilder()
->update('users')
->directValues($update)
->where(Operator::equals(['id' => $mapping['id_user']]))
->execute();
if (empty($mapping['id_znz_invoice'])) {
sqlQueryBuilder()
->update('znz_users')
->directValues(
[
$mappingField => $item['IdAdresa'],
]
)
->where(Operator::equals(['id_user' => $mapping['id_user']]))
->execute();
}
}
protected function processUserGroup(array $item): void
{
if ($this->isDeleteMessage($item)) {
if ($userGroupId = $this->znzUtil->getMapping(self::$typeGroups, $item['meta']['unique_id'])) {
sqlQueryBuilder()
->delete('users_groups')
->where(Operator::equals(['id' => $userGroupId]))
->execute();
}
return;
}
if (!($userGroupId = $this->znzUtil->getMapping(self::$typeGroups, $item['IdZakaznickaSkupina']))) {
sqlGetConnection()->transactional(function () use ($item) {
sqlQueryBuilder()
->insert('users_groups')
->directValues(
[
'name' => $item['Nazev'],
]
)->execute();
$userGroupId = (int) sqlInsertId();
$this->znzUtil->createMapping(self::$typeGroups, $item['IdZakaznickaSkupina'], $userGroupId);
return $userGroupId;
});
}
try {
sqlQueryBuilder()
->update('users_groups')
->directValues(
[
'name' => $item['Nazev'],
'descr' => $item['Cislo'],
]
)
->where(Operator::equals(['id' => $userGroupId]))
->execute();
} catch (UniqueConstraintViolationException $e) {
sqlQueryBuilder()
->update('users_groups')
->directValues(
[
'name' => "{$item['Nazev']} (2)",
'descr' => $item['Cislo'],
]
)
->where(Operator::equals(['id' => $userGroupId]))
->execute();
}
}
public function processUserGroupPriceListRelation(array $item): void
{
// pokud se nejedna o B2B rezim a poradi je vetsi jak 1, tak to ignoruju
if (!$this->configuration->isB2BMode() && ($item['Poradi'] ?? null) > 1) {
return;
}
if (!($userGroupId = $this->znzUtil->getMapping(self::$typeGroups, $item['IdZakaznickaSkupina']))) {
throw new RabbitRetryMessageException('User group not found');
}
if (!($priceListId = $this->znzUtil->getMapping(PriceListSynchronizer::getType(), $item['CenovaUroven']))) {
throw new RabbitRetryMessageException('Price list not found');
}
$update = ['id_pricelist' => $priceListId];
if ($this->configuration->isB2BShop()) {
$data = $this->getUserGroupData($userGroupId);
$data['znz']['stores'][$item['Poradi'] ?? 0] = null;
if (!empty($item['IdSklad'])) {
if ($storeId = $this->znzUtil->getMapping('store', $item['IdSklad'])) {
$data['znz']['stores'][$item['Poradi'] ?? 0] = $storeId;
}
}
$update['data'] = json_encode($data);
}
sqlQueryBuilder()
->update('users_groups')
->directValues($update)
->where(Operator::equals(['id' => $userGroupId]))
->execute();
}
public function getUserAddresses(\User $user, ?int $znzInvoiceId = null, ?int $znzDeliveryId = null): array
{
// nacitam z masteru, protoze se stalo, ze se uzivatel registrovat v 14:40:58 a v 14:41:00 se spustila sync, ktera
// ho zacala odesilat, ale fetchAddresses sel na slave, kde ten uzivatel jeste chybel a timpadem se udaje nenacetly
QueryHint::withRouteToMaster(fn () => $user->fetchAddresses());
return [
'invoice' => $this->getUserAddress($user, $znzInvoiceId),
'delivery' => $this->getUserAddress($user, $znzDeliveryId, 'delivery'),
];
}
private function getUserGroupId(\User $user): int
{
$groupIds = $this->configuration->getSettings()['user']['groupId'] ?? [];
$groupId = $groupIds[$user->id_language] ?? null;
if (empty($groupId)) {
$groupId = reset($groupIds);
if (!empty($groupId)) {
return (int) $groupId;
}
return 80;
}
return (int) $groupId;
}
private function getUserAddress(\User $user, ?int $znzId, string $type = 'invoice'): array
{
return [
'helios_addr_id' => $znzId,
'firstname' => $user->{$type}['name'],
'middlename' => '',
'lastname' => $user->{$type}['surname'],
'company' => $user->{$type}['firm'],
'street' => $user->{$type}['street'],
'city' => $user->{$type}['city'],
'postcode' => $user->{$type}['zip'],
'telephone' => $user->{$type}['phone'],
'vat_id' => $user->invoice['dic'],
'helios_company_registration_number' => $user->invoice['ico'],
'region_id' => '',
'country_id' => $this->getUserCountry($user, $type),
'_email' => $user->invoice['email'],
'_website' => $this->getUserWebsite($user),
'_address_default_billing_' => $type === 'invoice' ? 1 : 0,
'_address_default_shipping_' => $type === 'delivery' ? 1 : 0,
];
}
private function getUserWebsite(\User $user): string
{
return $this->znzUtil->getCurrentWebsite(
$this->getUserLanguage($user)
);
}
private function getUserLanguage(\User $user): string
{
$language = $user->id_language ?: Contexts::get(LanguageContext::class)->getDefaultId();
if (empty($user->id_language) && !empty($user->invoice['country'])) {
$language = match ($user->invoice['country']) {
'CZ' => 'cs',
'SK' => 'sk',
'IT' => 'it',
'FR' => 'fr',
'DE' => 'de',
default => Contexts::get(LanguageContext::class)->getDefaultId(),
};
}
return $language;
}
private function getUserCountry(\User $user, string $type): string
{
$country = $user->{$type}['country'];
if (empty($country)) {
$country = match ($this->getUserLanguage($user)) {
'cs' => 'CZ',
'sk' => 'SK',
'it' => 'IT',
'fr' => 'FR',
'de' => 'DE',
default => Contexts::get(CountryContext::class)->getDefaultId(),
};
}
return $this->znzUtil->getCountryCodeForHelios($country);
}
private function getUserLanguageByWebsite(string $website): string
{
$websites = $this->configuration->getSupportedWebsites();
$language = reset($websites)['language'] ?? Contexts::get(LanguageContext::class)->getDefaultId();
if ($websites[$website] ?? false) {
$language = $websites[$website]['language'] ?? $language;
}
if (is_array($language)) {
$language = reset($language);
}
return $language;
}
private function updateUserData(int $userId, array $item): void
{
$turnoverData = [];
if (!empty($item['SlevoveMeze'])) {
$turnoverData = [
'slevaProcentem' => $item['slevaProcentem'],
'MenaObratu' => $item['MenaObratu'],
'Obrat' => $item['Obrat'],
'SledovatObratDnu' => $item['SledovatObratDnu'],
'SlevoveMeze' => json_decode($item['SlevoveMeze'] ?: '', true) ?: [],
];
}
$data = [
'feeds' => [
'ObrazkyPovoleny' => $item['ObrazkyPovoleny'] ?? false,
'PopiskyPovoleny' => $item['PopiskyPovoleny'] ?? false,
],
'turnoverData' => $turnoverData,
];
$user = new \User();
$user->id = $userId;
$user->setCustomData('znz', $data);
}
private function updateUserGroup(int $userId, $znzUserGroupId): void
{
$znzGroups = array_map(fn ($x) => $x['id_users_group'], sqlQueryBuilder()
->select('id_users_group')
->from('znz_users_groups')
->execute()->fetchAllAssociative());
if (!empty($znzGroups)) {
sqlQueryBuilder()
->delete('users_groups_relations')
->andWhere(Operator::equals(['id_user' => $userId]))
->andWhere(Operator::inIntArray($znzGroups, 'id_group'))
->execute();
}
$userGroupId = $this->znzUtil->getMapping(self::$typeGroups, $znzUserGroupId);
// pokud mam ZNZ Group ID, ale nepodarilo se najit skupinu na shopu, tak zaloguju chybu do activity logu
if (!empty($znzUserGroupId) && !$userGroupId) {
$this->logger->activity(
sprintf('Uživatele "%s" se nepodařilo zařadit do skupiny "%s", protože skupina neexistuje!', $userId, $znzUserGroupId),
[
'userId' => $userId,
'znzGroupId' => $znzUserGroupId,
]
);
}
if ($userGroupId) {
$this->setUserGroup($userId, $userGroupId);
}
}
private function setUserGroup(int $userId, int $userGroupId): void
{
try {
sqlQueryBuilder()
->insert('users_groups_relations')
->directValues(
[
'id_user' => $userId,
'id_group' => $userGroupId,
]
)->execute();
} catch (UniqueConstraintViolationException) {
}
}
private function tryMergeWithNewsletterUser(?int &$userId, array $data): bool
{
$duplicatedUser = sqlQueryBuilder()
->select('id, figure, get_news, date_subscribe, date_unsubscribe')
->from('users')
->where(Operator::equals(['email' => $data['email']]))
->execute()->fetchAssociative();
if ($duplicatedUser['id'] === $userId) {
return false;
}
$isNewsletterUser = false;
// je to newsletter user
if ($duplicatedUser['figure'] === 'N' && $duplicatedUser['get_news'] === 'Y') {
$isNewsletterUser = true;
}
if (!$isNewsletterUser) {
return false;
}
$data['get_news'] = $duplicatedUser['get_news'];
$data['date_subscribe'] = $duplicatedUser['date_subscribe'];
$data['date_unsubscribe'] = $duplicatedUser['date_unsubscribe'];
$deleteDuplicatedUser = true;
if ($userId === null && $duplicatedUser['id']) {
$userId = $duplicatedUser['id'];
$deleteDuplicatedUser = false;
}
sqlGetConnection()->transactional(function () use ($duplicatedUser, $userId, $deleteDuplicatedUser, $data) {
if ($deleteDuplicatedUser) {
sqlQueryBuilder()
->delete('users')
->where(Operator::equals(['id' => $duplicatedUser['id']]))
->execute();
}
sqlQueryBuilder()
->update('users')
->directValues($data)
->where(Operator::equals(['id' => $userId]))
->execute();
});
return true;
}
private function getUserGroupData(int $userGroupId): array
{
$data = sqlQueryBuilder()
->select('data')
->from('users_groups')
->where(Operator::equals(['id' => $userGroupId]))
->execute()->fetchOne();
return json_decode($data ?: '', true) ?? [];
}
private function activateB2BUser(int $userId, array $item): void
{
// activate inactive user if they need to be assigned to a group
if (!$this->isUserActive($userId) && !empty($item['IdZakaznickaSkupina'])) {
sqlQueryBuilder()
->update('users')
->directValues(['figure' => 'Y'])
->where(Operator::equals(['id' => $userId]))
->execute();
$this->sendPasswordEmail($userId);
$this->logger->activity(
message: "Provedena aktivace B2B účtu `{$item['email']}` a odeslán mail pro nastavení hesla",
data: ['userId' => $userId],
severity: ActivityLog::SEVERITY_NOTICE
);
}
// activate B2B feeds
$this->activateUserB2BFeeds($userId);
// assign user to B2B group
$this->setUserGroup($userId, $this->configuration->getB2BGroup());
}
private function sendPasswordEmail(int $userId): void
{
if (!($user = \User::createFromId($userId))) {
return;
}
$emailService = clone $this->passwordResetAdminEmail;
$this->contextManager->activateContexts(
[LanguageContext::class => $user->id_language ?: Contexts::get(LanguageContext::class)->getDefaultId()],
function () use ($emailService, $user) {
$message = $emailService->getEmail();
$message['to'] = $user->email;
$emailService->sendEmail($message);
}
);
}
private function activateUserB2BFeeds(int $userId): void
{
if (!findModule(\Modules::XML_FEEDS_B2B)) {
return;
}
// prvotni aktivace feedu pro B2B uzivatele (vygenerovat token pokud jeste neexistuje)
sqlQueryBuilder()
->update('users')
->directValues(['feed_token' => $this->tokenGenerator->generate(15)])
->andWhere(Operator::equals(['id' => $userId]))
->andWhere('feed_token IS NULL')
->execute();
}
private function isUserActive(int $userId): bool
{
return sqlQueryBuilder()
->select('figure')
->from('users')
->where(Operator::equals(['id' => $userId]))
->sendToMaster()
->execute()->fetchOne() === 'Y';
}
private function getUsersToHeliosSpec(): callable
{
if ($this->configuration->isB2BShop()) {
// v pripade B2B uzivatele neresime viditelnost uzivatele
// protoze chceme zapsat i neaktivniho uzivatele, kterej vytvoril pozadavek na registraci
return function (QueryBuilder $qb) {
$qb->leftJoin('u', 'users_groups_relations', 'ugr', 'ugr.id_user = u.id');
return Operator::inIntArray(
array_filter([$this->configuration->getB2BGroup(), $this->configuration->getB2BRegistrationGroup()]),
'ugr.id_group'
);
};
}
// v pripade B2C zapisujeme pouze viditelne uzivatele
return Operator::equals(['u.figure' => 'Y']);
}
}