chore: initial import for test contour

This commit is contained in:
sova-bootstrap
2026-05-27 19:36:32 +03:00
commit 166cdb148e
282 changed files with 84872 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
<?php
namespace App\Service\Bitrix;
use Doctrine\DBAL\Connection;
class BitrixService
{
public function __construct(
#[Autowire(service: 'doctrine.dbal.mysql_connection')]
private Connection $connection
) { }
public function setCharset($name = 'UTF8')
{
$this->connection->executeQuery("SET NAMES $name");
return $this;
}
public function setRegionId(int $id): self
{
$this->regionId = $id;
return $this;
}
public function getPropertyPrice(string $param = 'value'): mixed
{
$props = [
'91' => [
'value' => 2980,
'name' => 'Саратов'
],
'comfort' => [
'value' => 2980,
'name' => 'Саратов Comfort'
],
'93' => [
'value' => 2985,
'name' => 'Воронеж'
],
'92' => [
'value' => 2990,
'name' => 'Волгоград'
],
'94' => [
'value' => 4477,
'name' => 'Краснодар'
],
];
return $props[$this->regionId][$param] ?? null;
}
public function getBlockId(string $param = 'value'): mixed
{
$iblockIds = [
'sovenok' => [
'value' => 174,
'uslugi' => 175,
'name' => 'Саратов Совенок'
],
'comfort' => [
'value' => 145,
'uslugi' => 146,
'name' => 'Саратов Comfort'
],
'91' => [
'value' => 91,
'uslugi' => 165,
'name' => 'Саратов'
],
'92' => [
'value' => 92,
'uslugi' => 167,
'name' => 'Волгоград'
],
'93' => [
'value' => 93,
'uslugi' => 166,
'name' => 'Воронеж'
],
'94' => [
'value' => 219,
'uslugi' => 229,
'name' => 'Краснодар'
],
];
return $iblockIds[$this->regionId][$param] ?? null;
}
public function getReviews(int $doctorId): array
{
$specialist = $this->getSpecialist($doctorId);
if (!$specialist) {
return [];
}
$items = $this->connection->createQueryBuilder()
->select('biep.IBLOCK_ELEMENT_ID as REVIEW_ID')
->from('b_iblock_element_property', 'biep')
->leftJoin('biep', 'b_iblock_property', 'bip', 'biep.IBLOCK_PROPERTY_ID = bip.ID')
->where('biep.VALUE = :VALUE')
->andWhere('bip.CODE REGEXP :CODE')
->setParameter('VALUE', $specialist['ID'])
->setParameter('CODE', 'MEDIC')
->executeQuery()
->fetchAllAssociative();
foreach ($this->getElementProperties($specialist['ID'], 'LINK_REVIEWS') as $item) {
$items[]['REVIEW_ID'] = $item['VALUE'];
}
foreach ($items as $key => $item) {
$items[$key]['DATA'] = $this->getElementProperties($item['REVIEW_ID']);
foreach ($items[$key]['DATA'] as $i => $props) {
if ($props['CODE'] == 'MESSAGE') {
$data = preg_replace_callback('!s:(\d+):"(.*?)";!s', function($m) {
$len = strlen($m[2]);
return "s:$len:\"{$m[2]}\";";
}, $props['VALUE']);
if (@unserialize($data) !== false) {
$items[$key]['DATA'][$i]['VALUE'] = unserialize($data)['TEXT'];
}
}
}
}
return $items;
}
public function getServiceCode(int $doctorId): ?array
{
$kodoper = null;
// Получаем ID элементов цен
$listPriceLink = $this->getElementProperties($doctorId, 'LINK_PRICE_1', true);
if (!empty($listPriceLink)) {
foreach ($listPriceLink as $key => $priceLink) {
$item = $this->getElementProperties($priceLink['VALUE'], 'KOD', false);
if (!empty($item)) {
$kodoper[$key] = $item['VALUE'];
}
}
}
if (!$kodoper) {
return null;
}
return $kodoper;
}
public function getElementProperties(int $elementId, ?string $code = null, bool $all = true): ?array
{
$qb = $this->connection->createQueryBuilder()
->select('bie.NAME as BIE_NAME, biep.ID, bie.ACTIVE, bie.IBLOCK_SECTION_ID, bie.DATE_CREATE, biep.VALUE, bip.NAME, bip.CODE')
->from('b_iblock_element', 'bie')
->innerJoin('bie', 'b_iblock_element_property', 'biep', 'biep.IBLOCK_ELEMENT_ID = bie.ID')
->leftJoin('biep', 'b_iblock_property', 'bip', 'biep.IBLOCK_PROPERTY_ID = bip.ID')
->where('bie.ID = :ID')
->setParameter('ID', $elementId);
if ($code) {
$qb->andWhere('bip.CODE = :CODE')
->setParameter('CODE', $code);
}
$response = $all
? $qb->executeQuery()->fetchAllAssociative()
: $qb->executeQuery()->fetchAssociative();
return $response === false ? null : $response;
}
public function getSpecialist(int $id, bool $fromInfoclinica = false): ?array
{
$qb = $this->connection->createQueryBuilder()
->select('*')
->from('b_iblock_element', 'el');
if ($fromInfoclinica) {
$qb->where('el.XML_ID = :XML_ID')
->setParameter('XML_ID', $id);
} else {
$qb->where('el.ID = :ID')
->setParameter('ID', $id);
}
$specialist = $qb->executeQuery()->fetchAssociative();
if ($specialist) {
$specialist['NAME'] = explode(' ', trim($specialist['NAME']));
}
return $specialist ?: null;
}
}
@@ -0,0 +1,163 @@
<?php
namespace App\Service\Client;
use App\Service\Client\Interfaces\AbstractHttpClientServiceInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
abstract class AbstractHttpClientService implements AbstractHttpClientServiceInterface
{
protected HttpClientInterface $client;
protected string $baseUrl;
protected string $userAgent;
protected array $defaultOptions;
protected array $cookies = [];
public function __construct(string $userAgent, string $baseUrl) {
$this->userAgent = $userAgent;
$this->baseUrl = $baseUrl;
$this->defaultOptions = [
'base_uri' => $this->baseUrl,
'verify_peer' => false,
'verify_host' => false,
'headers' => [
'Content-Type' => 'application/json; charset=UTF-8',
'User-Agent' => $this->userAgent,
'Accept' => 'application/json, text/javascript, */*; q=0.01',
'Accept-Language' => 'ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3',
'Content-Type' => 'application/json; charset=UTF-8',
'X-Requested-With' => 'XMLHttpRequest',
'X-Integration-Type' => 'WEBSDK'
]
];
$this->client = HttpClient::create($this->defaultOptions);
}
/**
* Установка куки
*/
public function setCookie(string $name, string $value): void
{
$this->cookies[$name] = $value;
$this->updateCookiesHeader();
}
/**
* Установка нескольких куки
*/
public function setCookies(array $cookies): void
{
$this->cookies = array_merge($this->cookies, $cookies);
$this->updateCookiesHeader();
}
/**
* Получение куки по имени
*/
public function getCookie(string $name): ?string
{
return $this->cookies[$name] ?? null;
}
/**
* Получение всех куки
*/
public function getCookies(): array
{
return $this->cookies;
}
/**
* Удаление куки
*/
public function removeCookie(string $name): void
{
unset($this->cookies[$name]);
$this->updateCookiesHeader();
}
/**
* Очистка всех куки
*/
public function clearCookies(): void
{
$this->cookies = [];
$this->updateCookiesHeader();
}
/**
* Обновление заголовка Cookie в defaultOptions
*/
protected function updateCookiesHeader(): void
{
if (!empty($this->cookies)) {
$cookieString = '';
foreach ($this->cookies as $name => $value) {
$cookieString .= "{$name}={$value}; ";
}
$this->defaultOptions['headers']['Cookie'] = rtrim($cookieString, '; ');
} else {
unset($this->defaultOptions['headers']['Cookie']);
}
}
/**
* Извлечение куки из ответа и сохранение их
*/
protected function extractCookiesFromResponse(ResponseInterface $response): void
{
$headers = $response->getHeaders();
if (isset($headers['set-cookie'])) {
foreach ($headers['set-cookie'] as $cookieHeader) {
$this->parseAndSetCookie($cookieHeader);
}
}
}
/**
* Парсинг строки Set-Cookie и установка куки
*/
protected function parseAndSetCookie(string $cookieHeader): void
{
$parts = explode(';', $cookieHeader);
$cookiePart = trim($parts[0]);
if (strpos($cookiePart, '=') !== false) {
list($name, $value) = explode('=', $cookiePart, 2);
$this->setCookie(trim($name), trim($value));
}
}
public function request(string $method, string $path, array $options = []): ResponseInterface
{
$validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
if (!in_array(strtoupper($method), $validMethods)) {
throw new \InvalidArgumentException('Invalid HTTP method');
}
// Обновляем заголовки с учетом текущих куки
if (isset($options['headers']) && isset($this->defaultOptions['headers'])) {
$options['headers'] = array_merge($this->defaultOptions['headers'], $options['headers']);
unset($this->defaultOptions['headers']);
}
$options = array_merge($this->defaultOptions, $options);
try {
$response = $this->client->request($method, $path, $options);
// Автоматически извлекаем куки из ответа
$this->extractCookiesFromResponse($response);
return $response;
} catch (TransportExceptionInterface $e) {
throw new \RuntimeException('Failed to send request: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Service\Client;
use App\Service\Client\AbstractHttpClientService;
use App\Service\Client\Interfaces\BitrixClientServiceInterface;
class BitrixClientService extends AbstractHttpClientService implements BitrixClientServiceInterface
{
public function __construct(string $userAgent, string $baseUrl)
{
parent::__construct($userAgent, $baseUrl);
}
public function getSpecialistImage(string $path): string
{
$httpResponse = $this->request('GET', $path);
return $httpResponse->getContent();
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Service\Client;
use App\Dto\CalltouchCreateRequestDto;
use App\Service\Client\Interfaces\CalltouchClientServiceInterface;
use App\Service\Client\AbstractHttpClientService;
final class CalltouchClientService extends AbstractHttpClientService implements CalltouchClientServiceInterface
{
private string $token;
private string $siteId;
private array $params;
protected string $userAgent;
protected string $baseUrl;
public function __construct(string $userAgent, string $baseUrl, string $params)
{
parent::__construct($userAgent, $baseUrl);
$this->baseUrl = $userAgent;
$this->baseUrl = $baseUrl;
$this->toArrayParams($params);
}
private function toArrayParams($params) : void
{
foreach (explode(',' , $params) as $val) {
$params = explode(':', $val);
$this->params[$params[0]]['siteId'] = $params[1];
$this->params[$params[0]]['token'] = $params[2];
}
}
private function configureHeaders(int $regionId): void
{
if (empty($this->params[$regionId]['siteId']) || empty($this->params[$regionId]['token'])) {
throw new \InvalidArgumentException('Missing configuration for region');
}
$this->token = $this->params[$regionId]['token'];
$this->siteId = $this->params[$regionId]['siteId'];
}
public function requestCreate(CalltouchCreateRequestDto $dto) : array
{
$this->configureHeaders($dto->regionId);
$option = [
'headers' => [
'Access-Token' => $this->token,
'SiteId' => $this->siteId,
],
'body' => json_encode(['requests' => $dto->toArray()])
];
$httpResponse = $this->request('POST', '/lead-service/v1/api/request/create', $option);
return $httpResponse->toArray()['data'] ?: [];
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Service\Client;
use App\Service\Client\Interfaces\InfoclinicaClientServiceInterface;
use App\Service\Client\AbstractHttpClientService;
use Symfony\Contracts\HttpClient\ResponseInterface;
use App\Dto\RegistrationDto;
use App\Dto\AnonymousReserveRequestDto;
final class InfoclinicaClientService extends AbstractHttpClientService implements InfoclinicaClientServiceInterface
{
public function __construct(
string $userAgent,
string $baseUrl
) {
parent::__construct($userAgent, $baseUrl);
}
private function normalizeSchedule(array $schedules): array
{
$nearestDate = [];
$schedule = [];
foreach ($schedules as $item) {
foreach ($item['workdates'] as $workdate) {
$dateKey = key($workdate);
foreach ($workdate[$dateKey] as $scheduleItem) {
$isFree = false;
foreach ($scheduleItem['intervals'] as $interval) {
if ($interval['isFree'] === true) {
if (empty($nearestDate[$scheduleItem['depnum']])) {
$nearestDate[$scheduleItem['depnum']] = $dateKey;
}
$isFree = true;
break;
}
}
$schedule[$scheduleItem['depnum']][$dateKey] = $scheduleItem;
$schedule[$scheduleItem['depnum']][$dateKey]['isFree'] = $isFree;
}
}
}
return [
'schedule' => $schedule,
'nearestDate' => $nearestDate
];
}
public function getSchedule(string $queryString): array
{
$httpResponse = $this->request('GET', '/api/reservation/intervals?' . $queryString);
$responseArray = $httpResponse->toArray();
if ($responseArray['data']) {
return $this->normalizeSchedule($responseArray['data']);
}
return [];
}
public function getFilialsList(): array
{
$httpResponse = $this->request('GET', '/filials/list');
return $httpResponse->toArray();
}
public function registration(RegistrationDto $dto): array
{
$httpResponse = $this->request('GET', '/api/reservation/intervals?' . $queryString);
$responseArray = $httpResponse->toArray();
if ($responseArray['data']) {
return $this->normalizeSchedule($responseArray['data']);
}
return [];
}
public function anonymousReserve(AnonymousReserveRequestDto $dto): array
{
$httpResponse = $this->request('POST', '/api/reservation/anonymous-reserve', [
'body' => json_encode($dto->toArray(), JSON_UNESCAPED_UNICODE)
]);
return $httpResponse->toArray();
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Service\Client\Interfaces;
use Symfony\Contracts\HttpClient\ResponseInterface;
interface AbstractHttpClientServiceInterface
{
public function request(string $method, string $path) : ResponseInterface;
}
@@ -0,0 +1,8 @@
<?php
namespace App\Service\Client\Interfaces;
interface BitrixClientServiceInterface
{
public function getSpecialistImage(string $path): string;
}
@@ -0,0 +1,10 @@
<?php
namespace App\Service\Client\Interfaces;
use App\Dto\CalltouchCreateRequestDto;
interface CalltouchClientServiceInterface
{
public function requestCreate(CalltouchCreateRequestDto $requests): array;
}
@@ -0,0 +1,12 @@
<?php
namespace App\Service\Client\Interfaces;
use Symfony\Contracts\HttpClient\ResponseInterface;
use App\Dto\RegistrationDto;
interface InfoclinicaClientServiceInterface
{
public function getSchedule(string $queryString): array;
// public function registration(RegistrationDto $registrationDto): array;
}
@@ -0,0 +1,8 @@
<?php
namespace App\Service\Client\Interfaces;
interface SmartCaptchaClientServiceInterface
{
public function validate(string $token, string $clientIp): array;
}
@@ -0,0 +1,10 @@
<?php
namespace App\Service\Client\Interfaces;
interface SmsClientServiceInterface
{
public function send(string $to, string $msg): array;
public function senders(): array;
public function balance(): array;
}
@@ -0,0 +1,33 @@
<?php
namespace App\Service\Client;
use App\Service\Client\AbstractHttpClientService;
use App\Service\Client\Interfaces\SmartCaptchaClientServiceInterface;
final class SmartCaptchaClientService extends AbstractHttpClientService implements SmartCaptchaClientServiceInterface
{
private string $secret;
public function __construct(string $userAgent, string $baseUrl, string $secret)
{
parent::__construct($userAgent, $baseUrl);
$this->secret = $secret;
}
public function validate(string $token, string $clientIp): array
{
$options = [
'query' => [
"secret" => $this->secret,
"token" => $token,
"ip" => $clientIp,
]
];
$httpResponse = $this->request('POST', '/validate', $options);
return $httpResponse->toArray();
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Service\Client;
use App\Service\Client\AbstractHttpClientService;
use App\Service\Client\Interfaces\SmsClientServiceInterface;
final class Sms4bClientService extends AbstractHttpClientService implements SmsClientServiceInterface
{
private string $token;
private string $sender;
public function __construct(string $userAgent, string $baseUrl, string $token, string $sender)
{
parent::__construct($userAgent, $baseUrl);
$this->token = $token;
$this->sender = $sender;
}
public function send(string $to, string $msg): array
{
$options = [
'headers' => [
'Authorization' => $this->token,
'accept' => 'application/json',
'Content-Type' => 'application/json',
],
'body' => json_encode([
'sender' => $this->sender,
'messages' => [
[
'number' => $to,
'text' => $msg
]
]
])
];
$httpResponse = $this->request('POST', '/v1/sms', $options);
return $httpResponse->toArray();
}
public function senders(): array
{
$options = [
'headers' => [
'Authorization' => $this->token,
'Content-Type' => 'application/json',
]
];
$httpResponse = $this->request('GET', '/v1/sms/senders', $options);
return $httpResponse->toArray();
}
public function balance(): array
{
$options = [
'headers' => [
'Authorization' => $this->token,
'Content-Type' => 'application/json',
]
];
$httpResponse = $this->request('GET', '/v1/balance', $options);
return $httpResponse->toArray();
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Service\Client;
use App\Service\Client\AbstractHttpClientService;
use App\Service\Client\Interfaces\SmsClientServiceInterface;
final class SmsruClientService extends AbstractHttpClientService implements SmsClientServiceInterface
{
private string $token;
private string $sender;
public function __construct(string $userAgent, string $baseUrl, string $token, string $sender)
{
parent::__construct($userAgent, $baseUrl);
$this->token = $token;
$this->sender = $sender;
}
public function send(string $to, string $msg): array
{
$options = [
'query' => [
'to' => $to,
'msg' => $msg,
'from' => $this->sender,
'json' => 1,
'api_id' => $this->token,
'test' => 0
]
];
$httpResponse = $this->request('GET', '/sms/send', $options);
return $httpResponse->toArray();
}
public function senders(): array
{
$options = [
'query' => [
'json' => 1,
'api_id' => $this->token
]
];
$httpResponse = $this->request('GET', '/my/senders', $options);
return $httpResponse->toArray();
}
public function balance(): array
{
$options = [
'query' => [
'json' => 1,
'api_id' => $this->token
]
];
$httpResponse = $this->request('GET', '/my/balance', $options);
return $httpResponse->toArray();
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Service\Client\Stub;
use App\Service\Client\Interfaces\SmartCaptchaClientServiceInterface;
use Psr\Log\LoggerInterface;
final class AlwaysValidSmartCaptchaClientService implements SmartCaptchaClientServiceInterface
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function validate(string $token, string $clientIp): array
{
$this->logger->info('SmartCaptcha suppressed (noop stub)', [
'ip' => $clientIp,
]);
return ['status' => 'ok', 'message' => '', 'stub' => true];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Service\Client\Stub;
use App\Dto\CalltouchCreateRequestDto;
use App\Service\Client\Interfaces\CalltouchClientServiceInterface;
use Psr\Log\LoggerInterface;
final class NoopCalltouchClientService implements CalltouchClientServiceInterface
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function requestCreate(CalltouchCreateRequestDto $requests): array
{
$this->logger->info('Calltouch lead suppressed (noop stub)', [
'regionId' => $requests->regionId ?? null,
]);
return ['leadId' => 'test-stub', 'stub' => true];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Service\Client\Stub;
use App\Service\Client\Interfaces\SmsClientServiceInterface;
use Psr\Log\LoggerInterface;
final class NoopSmsClientService implements SmsClientServiceInterface
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function send(string $to, string $msg): array
{
$this->logger->info('SMS suppressed (noop stub)', ['to' => $to]);
return ['status' => 'ok', 'stub' => true];
}
public function senders(): array
{
$this->logger->info('SMS senders suppressed (noop stub)');
return ['status' => 'ok', 'stub' => true, 'senders' => []];
}
public function balance(): array
{
$this->logger->info('SMS balance suppressed (noop stub)');
return ['status' => 'ok', 'stub' => true, 'balance' => 0];
}
}
+195
View File
@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Service\Crud;
use Doctrine\DBAL\Exception as DbalException;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Универсальный CRUD-ответчик для тонких контент-контроллеров.
*
* Контракт ответов специально сохранён близким к старым *CrudService/контроллерам,
* чтобы не ломать существующих клиентов (фронтенд/мобильное):
* - валидация: HTTP 400 + сериализованный ConstraintViolationList
* (формат Symfony Serializer по умолчанию, т.е. RFC 7807 с ключом violations);
* - удаление с ошибкой БД (например, FK constraint): HTTP 500 + {error, message};
* - JSON-ключи запросов/ответов используют camelCase (см. свойства сущностей и группы *:write).
* Name converter в config/packages/serializer.yaml не задан намеренно — клиенту
* нужен консистентный camelCase, иначе незнакомые ключи будут проигнорированы.
*/
final class CrudResponder
{
public function __construct(
private EntityManagerInterface $em,
private SerializerInterface $serializer,
private DenormalizerInterface $denormalizer,
private ValidatorInterface $validator,
) {
}
/**
* @param list<string> $readGroups
*/
public function read(object $entity, array $readGroups): JsonResponse
{
return $this->json($entity, Response::HTTP_OK, $readGroups);
}
/**
* @template T of object
*
* @param class-string<T> $entityClass
* @param list<string> $writeGroups
* @param list<string> $readGroups
*/
public function create(
Request $request,
string $entityClass,
array $writeGroups,
array $readGroups,
): JsonResponse {
$payload = $this->decodePayload($request);
if ($payload === null) {
return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
}
unset($payload['id']);
try {
/** @var T $entity */
$entity = $this->denormalizer->denormalize(
$payload,
$entityClass,
null,
[
AbstractNormalizer::GROUPS => $writeGroups,
],
);
} catch (SerializerExceptionInterface $e) {
return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
}
if (($validationResponse = $this->validate($entity)) !== null) {
return $validationResponse;
}
$this->em->persist($entity);
$this->em->flush();
return $this->json($entity, Response::HTTP_CREATED, $readGroups);
}
/**
* @param list<string> $writeGroups
* @param list<string> $readGroups
*/
public function update(
Request $request,
object $entity,
array $writeGroups,
array $readGroups,
): JsonResponse {
$payload = $this->decodePayload($request);
if ($payload === null) {
return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
}
unset($payload['id']);
try {
$this->denormalizer->denormalize(
$payload,
$entity::class,
null,
[
AbstractNormalizer::GROUPS => $writeGroups,
AbstractNormalizer::OBJECT_TO_POPULATE => $entity,
],
);
} catch (SerializerExceptionInterface $e) {
return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
}
if (($validationResponse = $this->validate($entity)) !== null) {
return $validationResponse;
}
$this->em->flush();
return $this->json($entity, Response::HTTP_OK, $readGroups);
}
public function delete(object $entity): JsonResponse
{
try {
$this->em->remove($entity);
$this->em->flush();
} catch (DbalException $e) {
// Сохраняем легаси-контракт: при FK / NOT NULL / unique ошибках БД
// отдаём 500 + {error, message}. См. старый ArticleController::delete.
return new JsonResponse(
['error' => 'Ошибка при удалении записи', 'message' => $e->getMessage()],
Response::HTTP_INTERNAL_SERVER_ERROR,
);
}
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
/**
* @return array<string, mixed>|null null если тело не является JSON-объектом
*
* Ловим как нативный \JsonException, так и Symfony\...\HttpFoundation\Exception\JsonException
* (последний наследует UnexpectedValueException, а не \JsonException, и без
* широкого перехвата Symfony ErrorListener перехватит ошибку до нашего try/catch).
*/
private function decodePayload(Request $request): ?array
{
try {
return $request->toArray();
} catch (JsonException|\UnexpectedValueException) {
return null;
}
}
private function validate(object $entity): ?JsonResponse
{
$errors = $this->validator->validate($entity);
if (count($errors) === 0) {
return null;
}
// BC: легаси-контроллеры возвращали именно сериализованный ConstraintViolationList
// с кодом 400. Этот же формат продолжаем отдавать здесь, чтобы фронтенду
// не пришлось переписывать парсинг ошибок.
$json = $this->serializer->serialize($errors, 'json');
return new JsonResponse($json, Response::HTTP_BAD_REQUEST, [], true);
}
/**
* @param list<string> $groups
*/
private function json(mixed $data, int $status, array $groups): JsonResponse
{
$json = $this->serializer->serialize($data, 'json', [
AbstractNormalizer::GROUPS => $groups,
]);
return new JsonResponse($json, $status, [], true);
}
private function jsonError(string $message, int $status): JsonResponse
{
return new JsonResponse(['error' => $message], $status);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Service\Crypt;
use App\Service\Crypt\Interfaces\AESCryptServiceInterface;
final class AESCryptService implements AESCryptServiceInterface
{
private string $cipher;
private string $secretKey;
public function __construct(string $secretKey, string $cipher) {
$this->cipher = $cipher;
$this->secretKey = $secretKey;
if (!in_array($this->cipher, openssl_get_cipher_methods())) {
throw new \RuntimeException(sprintf('Cipher method "%s" is not available', $this->cipher));
}
}
public function encrypt(string $plaintext): string
{
$ivlen = openssl_cipher_iv_length($this->cipher);
$iv = openssl_random_pseudo_bytes($ivlen);
$ciphertext_raw = openssl_encrypt(
$plaintext,
$this->cipher,
$this->secretKey,
OPENSSL_RAW_DATA,
$iv
);
$hmac = hash_hmac('sha256', $ciphertext_raw, $this->secretKey, true);
return base64_encode($iv.$hmac.$ciphertext_raw);
}
public function decrypt(string $ciphertext): ?string
{
$c = base64_decode($ciphertext);
if ($c === false) {
return null;
}
$ivlen = openssl_cipher_iv_length($this->cipher);
$iv = substr($c, 0, $ivlen);
$hmac = substr($c, $ivlen, 32);
$ciphertext_raw = substr($c, $ivlen + 32);
$plaintext = openssl_decrypt(
$ciphertext_raw,
$this->cipher,
$this->secretKey,
OPENSSL_RAW_DATA,
$iv
);
if ($plaintext === false) {
return null;
}
$calcmac = hash_hmac('sha256', $ciphertext_raw, $this->secretKey, true);
return hash_equals($hmac, $calcmac) ? $plaintext : null;
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Service\Crypt\Interfaces;
interface AESCryptServiceInterface
{
public function encrypt(string $plaintext): string;
public function decrypt(string $ciphertext): ?string;
}
@@ -0,0 +1,10 @@
<?php
namespace App\Service\DecoderJWT\Interfaces;
use App\Entity\User;
interface JWTDecoderServiceInterface
{
public function getUser(): ?User;
}
@@ -0,0 +1,37 @@
<?php
namespace App\Service\DecoderJWT;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Service\DecoderJWT\Interfaces\JWTDecoderServiceInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class JWTDecoderService implements JWTDecoderServiceInterface
{
private JWTTokenManagerInterface $jwtManager;
private TokenStorageInterface $tokenStorage;
private UserRepository $userRepository;
public function __construct(
TokenStorageInterface $tokenStorage,
JWTTokenManagerInterface $jwtManager,
UserRepository $userRepository
) {
$this->userRepository = $userRepository;
$this->jwtManager = $jwtManager;
$this->tokenStorage = $tokenStorage;
}
public function getUser(): ?User
{
$decodedJwtToken = $this->jwtManager->decode($this->tokenStorage->getToken());
if ($decodedJwtToken) {
return $this->userRepository->findOneBy(['email' => $decodedJwtToken['username']]);
}
return NULL;
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Service\Department;
use App\Entity\Department;
use App\Repository\DepartmentRepository;
class DepartmentService
{
public function __construct(
private DepartmentRepository $departmentRepository
) {}
public function getList(array $params = []): ?array
{
return $this
->departmentRepository
->createFilteredQueryBuilder($params)
->getQuery()
->getResult()
;
}
public function getShow(array $params = []): ?PriceList
{
return $this
->departmentRepository
->createFilteredQueryBuilder($params)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
/**
* Импорт заболеваний из материализованного представления (Bitrix view).
*
* См. DiseaseController + CrudResponder для CRUD; этот сервис — только syncFromView*.
*/
final class DiseaseCrudService
{
public function __construct(
private EntityManagerInterface $em,
) {
}
public function syncFromViewDisease(string $viewName = 'public.view_disease'): int
{
if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
throw new \InvalidArgumentException('Invalid view name');
}
$sql = sprintf(
'INSERT INTO disease (
id,
name,
preview_picture,
active,
region_id,
alias,
anons,
update_at,
hide_picture,
read_time,
diseases_name,
tags_important,
tags,
diseases_other_name,
symptom,
staff,
link_services,
staff_list,
staff_post,
staff_post_exclude,
link_faq,
bibliography,
staff_check,
content
)
SELECT
id,
name,
preview_picture,
active,
region_id,
alias,
anons,
update_at,
hide_picture,
read_time,
diseases_name,
tags_important,
tags,
diseases_other_name,
symptom,
staff,
link_services,
staff_list,
staff_post,
staff_post_exclude,
link_faq,
bibliography,
staff_check,
content
FROM %s
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
preview_picture = EXCLUDED.preview_picture,
active = EXCLUDED.active,
region_id = EXCLUDED.region_id,
alias = EXCLUDED.alias,
anons = EXCLUDED.anons,
update_at = EXCLUDED.update_at,
hide_picture = EXCLUDED.hide_picture,
read_time = EXCLUDED.read_time,
diseases_name = EXCLUDED.diseases_name,
tags_important = EXCLUDED.tags_important,
tags = EXCLUDED.tags,
diseases_other_name = EXCLUDED.diseases_other_name,
symptom = EXCLUDED.symptom,
staff = EXCLUDED.staff,
link_services = EXCLUDED.link_services,
staff_list = EXCLUDED.staff_list,
staff_post = EXCLUDED.staff_post,
staff_post_exclude = EXCLUDED.staff_post_exclude,
link_faq = EXCLUDED.link_faq,
bibliography = EXCLUDED.bibliography,
staff_check = EXCLUDED.staff_check,
content = EXCLUDED.content',
$viewName
);
return (int) $this->em->getConnection()->executeStatement($sql);
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Service\ErrorHandler;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
class ScheduleErrorHandlerService
{
public function __construct(
private LoggerInterface $logger
) {
$this->logger = $logger->withName('infoclinica-error');
}
public function handleHttpException(
HttpExceptionInterface $e,
string $queryString,
?int $duration = null,
?bool $isOnlineMode = null
): array {
$errorData = [
'query' => $queryString,
'online_mode' => $isOnlineMode,
'status_code' => $e->getResponse()->getStatusCode(),
'duration_ms' => $duration,
'error' => $e->getMessage(),
'response' => $e->getResponse()->getContent(false)
];
$this->logger->error('API request failed', $errorData);
return $errorData;
}
public function handleGeneralException(
\Exception $e,
string $queryString,
?int $duration = null,
?bool $isOnlineMode = null
): array {
$errorData = [
'query' => $queryString,
'online_mode' => $isOnlineMode,
'duration_ms' => $duration,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
];
$this->logger->critical('Unexpected error', $errorData);
return $errorData;
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Service\FileUploader;
use App\Service\FileUploader\Interfaces\FileUploaderServiceInterface;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;
class FileUploaderService implements FileUploaderServiceInterface
{
public function __construct(
private string $targetDirectory,
private SluggerInterface $slugger
) { }
public function upload(UploadedFile $file): string
{
$originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$safeFilename = $this->slugger->slug($originalFilename);
$fileName = $safeFilename.'-'.uniqid().'.'.$file->guessExtension();
try {
$file->move($this->targetDirectory, $fileName);
} catch (FileException $e) {
throw new \RuntimeException('Ошибка при загрузке файла: ' . $e->getMessage());
}
return $fileName;
}
public function remove($file): bool
{
try {
if ($file !== null && $file !== '') {
if (is_file($file) && file_exists($file)) {
@unlink($file);
return true;
}
}
return false;
} catch (Exception $e) {
throw new \RuntimeException('Ошибка при удалении файла: ' . $e->getMessage());
}
}
public function getTargetDirectory(): string
{
return $this->targetDirectory;
}
public function setTargetDirectory(?string $dir): self
{
$this->targetDirectory = $this->targetDirectory . '/' . $dir;
return $this;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Service\FileUploader\Interfaces;
use Symfony\Component\HttpFoundation\File\UploadedFile;
interface FileUploaderServiceInterface
{
public function upload(UploadedFile $file): string;
public function remove(string $file): bool;
public function getTargetDirectory(): string;
public function setTargetDirectory(?string $dir): self;
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Service\Filial;
use App\Entity\Filial;
use App\Repository\FilialRepository;
class FilialService
{
public function __construct(
private FilialRepository $filialRepository
) {}
public function getList(array $params = []): ?array
{
return $this
->filialRepository
->createFilteredQueryBuilder($params)
->getQuery()
->getResult()
;
}
public function getShow(array $params = []): ?Filial
{
return $this
->filialRepository
->createFilteredQueryBuilder($params)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Service\Helper;
class HelperService
{
/**
* Возвращает правильную форму слова "год" в зависимости от числа
*
* @param int $year Год (может быть отрицательным)
* @param bool $exp Полная форма (true - год/года/лет, false - года/лет)
* @return string Правильная форма слова
*/
public function textYear(int $year, bool $exp = false)
{
$absoluteYear = abs($year);
$lastDigit = $absoluteYear % 10;
$lastTwoDigits = $absoluteYear % 100;
if (! $exp) {
// Упрощенная форма: только "года" и "лет"
$response = $lastDigit === 1 ? "года" : "лет";
} else {
$response = match(true) {
$lastDigit === 1 && $lastTwoDigits !== 11 => "год",
$lastDigit >= 2 && $lastDigit <= 4 && ($lastTwoDigits < 10 || $lastTwoDigits >= 20) => "года",
default => "лет"
};
}
return $year . ' ' . $response;
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Service\Image;
use App\Service\Image\Interfaces\ImageServiceInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
class ImageService implements ImageServiceInterface
{
private const DEFAULT_QUALITY = 100;
private const DEFAULT_MIME_TYPE = 'image/png';
public function getPicture(string $filePath, int $width = 200, int $height = 200): Response
{
try {
if (!file_exists($filePath)) {
throw new FileNotFoundException('Image file not found');
}
$imageInfo = getimagesize($filePath);
if ($imageInfo === false) {
throw new FileException('Invalid image file');
}
[$originalWidth, $originalHeight, $imageType] = $imageInfo;
$src = $this->createImageResource($filePath, $imageType);
if ($src === false) {
throw new FileException('Unsupported image type');
}
[$newWidth, $newHeight] = $this->calculateDimensions(
$originalWidth,
$originalHeight,
$width,
$height
);
$processedImage = $this->processImage(
$src,
$originalWidth,
$originalHeight,
$newWidth,
$newHeight,
$imageType
);
return $this->createResponse($processedImage, $imageType);
} catch (\Exception $e) {
return new Response(
$e->getMessage(),
Response::HTTP_INTERNAL_SERVER_ERROR
);
}
}
private function createImageResource(string $filePath, int $imageType)
{
return match ($imageType) {
IMAGETYPE_GIF => @imagecreatefromgif($filePath),
IMAGETYPE_JPEG => @imagecreatefromjpeg($filePath),
IMAGETYPE_PNG => @imagecreatefrompng($filePath),
IMAGETYPE_WEBP => @imagecreatefromwebp($filePath),
default => false,
};
}
private function calculateDimensions(
int $originalWidth,
int $originalHeight,
int $targetWidth,
int $targetHeight
): array {
if ($originalWidth <= $targetWidth && $originalHeight <= $targetHeight) {
return [$originalWidth, $originalHeight];
}
$widthRatio = $targetWidth / $originalWidth;
$heightRatio = $targetHeight / $originalHeight;
$ratio = min($widthRatio, $heightRatio);
return [
(int) round($originalWidth * $ratio),
(int) round($originalHeight * $ratio)
];
}
private function processImage(
$src,
int $originalWidth,
int $originalHeight,
int $newWidth,
int $newHeight,
int $imageType
) {
$tmp = imagecreatetruecolor($newWidth, $newHeight);
// Handle transparency for GIF and PNG
if ($imageType === IMAGETYPE_GIF || $imageType === IMAGETYPE_PNG) {
$this->preserveTransparency($tmp);
}
imagecopyresampled(
$tmp,
$src,
0, 0, 0, 0,
$newWidth,
$newHeight,
$originalWidth,
$originalHeight
);
imagedestroy($src);
return $tmp;
}
private function preserveTransparency($imageResource): void
{
imagealphablending($imageResource, false);
imagesavealpha($imageResource, true);
$transparent = imagecolorallocatealpha($imageResource, 255, 255, 255, 127);
imagefill($imageResource, 0, 0, $transparent);
}
private function createResponse($imageResource, int $imageType): Response
{
ob_start();
switch ($imageType) {
case IMAGETYPE_GIF:
imagegif($imageResource);
$mimeType = 'image/gif';
break;
case IMAGETYPE_JPEG:
imagejpeg($imageResource, null, self::DEFAULT_QUALITY);
$mimeType = 'image/jpeg';
break;
case IMAGETYPE_PNG:
imagepng($imageResource, null, 0);
$mimeType = 'image/png';
break;
case IMAGETYPE_WEBP:
imagewebp($imageResource, null, self::DEFAULT_QUALITY);
$mimeType = 'image/webp';
break;
default:
$mimeType = self::DEFAULT_MIME_TYPE;
imagejpeg($imageResource, null, self::DEFAULT_QUALITY);
}
$imageContent = ob_get_clean();
imagedestroy($imageResource);
return new Response(
$imageContent,
Response::HTTP_OK,
['Content-Type' => $mimeType]
);
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Service\Image\Interfaces;
use Symfony\Component\HttpFoundation\Response;
interface ImageServiceInterface
{
public function getPicture(string $filePath, int $width, int $height): Response;
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Service\Location;
use App\Entity\Location;
use App\Entity\Specialist;
use App\Repository\LocationRepository;
use App\Repository\SpecialistRepository;
use Doctrine\ORM\QueryBuilder;
class LocationService
{
public function __construct(
private LocationRepository $locationRepository
) {}
public function getList(array $params): array
{
return $this
->locationRepository
->createFilteredQueryBuilder($params)
->getQuery()
->getResult();
}
public function getShow(array $params): ?Location
{
return $this
->locationRepository
->createFilteredQueryBuilder($params)
->getQuery()
->getOneOrNullResult();
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
final class SendMailConfig
{
public function __construct(
private readonly string $accessToken
) {
}
public function getAccessToken(): string
{
return $this->accessToken;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
final class SendMailService
{
public function __construct(
private MailerInterface $mailer,
private string $fromEmail = 'noreply@sova.clinic',
private string $fromName = 'Sova Clinic'
) {
}
public function send(string $mailto, string $subject, string $message): void
{
$email = (new Email())
->from(sprintf('%s <%s>', $this->fromName, $this->fromEmail))
->to($mailto)
->subject($subject)
->text($message);
$this->mailer->send($email);
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
/**
* Импорт центров из материализованного представления (Bitrix view).
*
* См. MedicalCenterController + CrudResponder для CRUD; этот сервис — только syncFromView*.
*/
final class MedicalCenterCrudService
{
public function __construct(
private EntityManagerInterface $em,
) {
}
public function syncFromViewCenters(string $viewName = 'public.view_centers'): int
{
if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
throw new \InvalidArgumentException('Invalid view name');
}
$sql = sprintf(
'INSERT INTO medical_center (
id,
name,
active,
region_id,
alias,
anons,
content,
update_at,
kod_uslug,
doctors,
services,
articles,
txt_up,
main_link_staff,
contraindications,
hide_picture,
indications,
link_sale,
plus_list,
plus_text,
plus_title,
process_text,
process_title,
services_list,
services_photos,
services_title,
sort_staff,
training_text,
training_text_title,
why_text,
why_title
)
SELECT
id,
name,
active,
region_id,
alias,
anons,
content,
update_at,
kod_uslug,
doctors,
services,
articles,
txt_up,
main_link_staff,
contraindications,
hide_picture,
indications,
link_sale,
plus_list,
plus_text,
plus_title,
process_text,
process_title,
services_list,
services_photos,
services_title,
sort_staff,
training_text,
training_text_title,
why_text,
why_title
FROM %s
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
active = EXCLUDED.active,
region_id = EXCLUDED.region_id,
alias = EXCLUDED.alias,
anons = EXCLUDED.anons,
content = EXCLUDED.content,
update_at = EXCLUDED.update_at,
kod_uslug = EXCLUDED.kod_uslug,
doctors = EXCLUDED.doctors,
services = EXCLUDED.services,
articles = EXCLUDED.articles,
txt_up = EXCLUDED.txt_up,
main_link_staff = EXCLUDED.main_link_staff,
contraindications = EXCLUDED.contraindications,
hide_picture = EXCLUDED.hide_picture,
indications = EXCLUDED.indications,
link_sale = EXCLUDED.link_sale,
plus_list = EXCLUDED.plus_list,
plus_text = EXCLUDED.plus_text,
plus_title = EXCLUDED.plus_title,
process_text = EXCLUDED.process_text,
process_title = EXCLUDED.process_title,
services_list = EXCLUDED.services_list,
services_photos = EXCLUDED.services_photos,
services_title = EXCLUDED.services_title,
sort_staff = EXCLUDED.sort_staff,
training_text = EXCLUDED.training_text,
training_text_title = EXCLUDED.training_text_title,
why_text = EXCLUDED.why_text,
why_title = EXCLUDED.why_title',
$viewName
);
return (int) $this->em->getConnection()->executeStatement($sql);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
/**
* Импорт новостей из материализованного представления (Bitrix view).
*
* CRUD (create/update/delete/list) живёт теперь в NewsController через
* общие App\Service\Crud\CrudResponder и App\Service\Pagination\Paginator —
* этот сервис отвечает только за синхронизацию (см. App\Command\UploadNewsCommand).
*/
final class NewsCrudService
{
public function __construct(
private EntityManagerInterface $em,
) {
}
public function syncFromViewNews(string $viewName = 'public.view_news'): int
{
if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
throw new \InvalidArgumentException('Invalid view name');
}
$sql = sprintf(
'INSERT INTO news (
id,
name,
active,
region_id,
alias,
anons,
content,
update_at,
link_el_price,
short_name,
timer,
timer_bg,
form_order,
link_services,
link_staff,
photos
)
SELECT
id,
name,
active,
region_id,
alias,
anons,
content,
update_at,
link_el_price,
short_name,
timer,
timer_bg,
form_order,
link_services,
link_staff,
photos
FROM %s
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
active = EXCLUDED.active,
region_id = EXCLUDED.region_id,
alias = EXCLUDED.alias,
anons = EXCLUDED.anons,
content = EXCLUDED.content,
update_at = EXCLUDED.update_at,
link_el_price = EXCLUDED.link_el_price,
short_name = EXCLUDED.short_name,
timer = EXCLUDED.timer,
timer_bg = EXCLUDED.timer_bg,
form_order = EXCLUDED.form_order,
link_services = EXCLUDED.link_services,
link_staff = EXCLUDED.link_staff,
photos = EXCLUDED.photos',
$viewName
);
return (int) $this->em->getConnection()->executeStatement($sql);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Service\Pagination;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Doctrine\ORM\QueryAdapter;
use Pagerfanta\Exception\NotValidCurrentPageException;
use Pagerfanta\Pagerfanta;
use Symfony\Component\HttpFoundation\Request;
/**
* Унифицированная обёртка над Pagerfanta + QueryAdapter.
*
* Соответствует существующему стилю проекта (см. PriceListController/SpecialistController):
* читает page/perPage из Request, ограничивает perPage и возвращает массив
* ['data' => [...], 'pagination' => [...]] в едином формате для новых list-контрактов.
*/
final class Paginator
{
public const DEFAULT_PER_PAGE = 50;
public const MAX_PER_PAGE = 500;
/**
* @return array{data: list<mixed>, pagination: array<string, int|bool>}
*/
public function paginate(
QueryBuilder $qb,
Request $request,
int $defaultPerPage = self::DEFAULT_PER_PAGE,
int $maxPerPage = self::MAX_PER_PAGE,
): array {
$page = max(1, $request->query->getInt('page', 1));
$perPage = min(
max(1, $request->query->getInt('perPage', $defaultPerPage)),
$maxPerPage,
);
$pagerfanta = (new Pagerfanta(new QueryAdapter($qb)))
->setMaxPerPage($perPage);
try {
$pagerfanta->setCurrentPage($page);
} catch (NotValidCurrentPageException) {
// выходим за пределы — возвращаем пустую страницу с корректным total
$pagerfanta->setCurrentPage(max(1, $pagerfanta->getNbPages()));
}
$data = iterator_to_array($pagerfanta->getCurrentPageResults(), false);
return [
'data' => $data,
'pagination' => [
'total' => $pagerfanta->getNbResults(),
'count' => count($data),
'per_page' => $pagerfanta->getMaxPerPage(),
'current_page' => $pagerfanta->getCurrentPage(),
'total_pages' => $pagerfanta->getNbPages(),
'has_previous_page' => $pagerfanta->hasPreviousPage(),
'has_next_page' => $pagerfanta->hasNextPage(),
],
];
}
/**
* Legacy-формат для ArticleController.
*
* Старый контракт /article/list уже использовался клиентами:
* - размер страницы приходит в query-параметре limit;
* - метаданные лежат в ключе meta;
* - поля называются total/page/limit/totalPages.
*
* @return array{data: list<mixed>, meta: array{total: int, page: int, limit: int, totalPages: int}}
*/
public function paginateWithLegacyMeta(
QueryBuilder $qb,
Request $request,
int $defaultLimit = 20,
int $maxLimit = 100,
): array {
$page = max(1, $request->query->getInt('page', 1));
$limit = min(
max(1, $request->query->getInt('limit', $defaultLimit)),
$maxLimit,
);
$pagerfanta = (new Pagerfanta(new QueryAdapter($qb)))
->setMaxPerPage($limit);
try {
$pagerfanta->setCurrentPage($page);
} catch (NotValidCurrentPageException) {
$pagerfanta->setCurrentPage(max(1, $pagerfanta->getNbPages()));
}
return [
'data' => iterator_to_array($pagerfanta->getCurrentPageResults(), false),
'meta' => [
'total' => $pagerfanta->getNbResults(),
'page' => $pagerfanta->getCurrentPage(),
'limit' => $pagerfanta->getMaxPerPage(),
'totalPages' => $pagerfanta->getNbPages(),
],
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Service\Performance;
class PerformanceTrackerService
{
private ?float $startTime = null;
private ?float $endTime = null;
public function start(): void
{
$this->startTime = microtime(true);
$this->endTime = null;
}
public function stop(): void
{
$this->endTime = microtime(true);
}
public function getDurationMs(): int
{
if (!$this->startTime) {
return 0;
}
$endTime = $this->endTime ?? microtime(true);
return (int)round(($endTime - $this->startTime) * 1000);
}
public function reset(): void
{
$this->startTime = null;
$this->endTime = null;
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Service\PriceList;
use App\Entity\PriceList;
use App\Repository\PriceListRepository;
class PriceListService
{
public function __construct(
private PriceListRepository $priceListRepository
) {}
public function getList($params): ?array
{
return $this
->priceListRepository
->createFilteredQueryBuilder($params)
->getQuery()
->getResult()
;
}
public function getShow($params): ?PriceList
{
return $this
->priceListRepository
->createFilteredQueryBuilder($params)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
/**
* Импорт акций из материализованного представления (Bitrix view).
*
* См. PromoController + CrudResponder для CRUD; этот сервис — только syncFromView*.
*/
final class PromoCrudService
{
public function __construct(
private EntityManagerInterface $em,
) {
}
public function syncFromViewPromo(string $viewName = 'public.view_promo'): int
{
if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
throw new \InvalidArgumentException('Invalid view name');
}
$sql = sprintf(
'INSERT INTO promo (
id,
name,
active,
region_id,
alias,
anons,
content,
update_at,
clinics,
timer,
timer_bg,
short_name,
link_services,
link_staff,
period,
photos
)
SELECT
id,
name,
active,
region_id,
alias,
anons,
content,
update_at,
clinics,
timer,
timer_bg,
short_name,
link_services,
link_staff,
period,
photos
FROM %s
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
active = EXCLUDED.active,
region_id = EXCLUDED.region_id,
alias = EXCLUDED.alias,
anons = EXCLUDED.anons,
content = EXCLUDED.content,
update_at = EXCLUDED.update_at,
clinics = EXCLUDED.clinics,
timer = EXCLUDED.timer,
timer_bg = EXCLUDED.timer_bg,
short_name = EXCLUDED.short_name,
link_services = EXCLUDED.link_services,
link_staff = EXCLUDED.link_staff,
period = EXCLUDED.period,
photos = EXCLUDED.photos',
$viewName
);
return (int) $this->em->getConnection()->executeStatement($sql);
}
}
@@ -0,0 +1,208 @@
<?php
namespace App\Service\ScheduleCache;
use App\Entity\Schedule;
use App\Repository\ScheduleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
class ScheduleCacheService
{
private const CACHE_TTL_MINUTES = 5;
public function __construct(
private ScheduleRepository $scheduleRepository,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger
) {
$this->logger = $logger->withName('infoclinica-cache');
}
public function getCachedSchedule(string $queryString, bool $isOnlineMode): ?array
{
try {
$cacheTime = new \DateTime(sprintf('-%d minutes', self::CACHE_TTL_MINUTES));
$recentSchedules = $this->scheduleRepository->findByQueryModeAndTime(
$queryString,
$isOnlineMode,
$cacheTime
);
if (empty($recentSchedules)) {
return null;
}
return $this->reconstructFromDatabase($recentSchedules, $isOnlineMode);
} catch (\Exception $e) {
$this->logger->error('Error reading from cache', [
'error' => $e->getMessage(),
'query' => $queryString,
'onlineMode' => $isOnlineMode
]);
return null;
}
}
public function saveSchedule(array $scheduleData, string $queryString, bool $isOnlineMode): int
{
try {
// Удаляем старые записи для этого запроса и типа расписания
$this->scheduleRepository->removeByQueryStringAndMode($queryString, $isOnlineMode);
$savedCount = 0;
$now = new \DateTime();
foreach ($scheduleData['schedule'] ?? [] as $department => $dates) {
foreach ($dates as $dateString => $daySchedule) {
$savedCount += $this->saveDaySchedule(
$department,
$dateString,
$daySchedule,
$queryString,
$isOnlineMode,
$now
);
}
}
$this->entityManager->flush();
$this->logger->info('Successfully saved schedule to cache', [
'query' => $queryString,
'onlineMode' => $isOnlineMode,
'saved_records' => $savedCount
]);
return $savedCount;
} catch (\Exception $e) {
$this->logger->error('Error saving to cache', [
'error' => $e->getMessage(),
'query' => $queryString,
'onlineMode' => $isOnlineMode
]);
throw $e;
}
}
private function saveDaySchedule(
string $department,
string $dateString,
array $daySchedule,
string $queryString,
bool $isOnlineMode,
\DateTime $createdAt
): int {
$count = 0;
foreach ($daySchedule['intervals'] ?? [] as $interval) {
$schedule = new Schedule();
$workDate = \DateTime::createFromFormat('Ymd', $dateString);
if ($workDate === false) {
$workDate = new \DateTime();
}
// Базовые поля
$schedule
->setDcode((string)($daySchedule['dcode'] ?? ''))
->setDepartment((string)$department)
->setFilial((int)($daySchedule['filial'] ?? 0))
->setSchedident((string)($daySchedule['schedident'] ?? ''))
->setWorkdate($workDate)
->setRnum((string)($daySchedule['rnum'] ?? ''))
->setTime((string)($interval['time'] ?? ''))
->setIsFree((bool)($daySchedule['isFree'] ?? false))
->setOnlineMode($isOnlineMode)
->setIntervalIsFree((bool)($interval['isFree'] ?? false))
->setQueryString($queryString)
->setCreatedAt($createdAt);
// Поля только для офлайн расписания
if (!$isOnlineMode) {
$schedule
->setRfloor($daySchedule['rfloor'] ?? null)
->setRbuilding($daySchedule['rbuilding'] ?? null);
}
// Поле priceInfo только для онлайн расписания
if ($isOnlineMode && isset($daySchedule['priceInfo'])) {
$schedule->setPriceInfo($daySchedule['priceInfo']);
}
$this->entityManager->persist($schedule);
$count++;
}
return $count;
}
private function reconstructFromDatabase(array $schedules, bool $isOnlineMode): array
{
$result = [
'schedule' => [],
'nearestDate' => []
];
foreach ($schedules as $schedule) {
$department = $schedule->getDepartment();
$workDate = $schedule->getWorkdate()->format('Ymd');
if (!isset($result['schedule'][$department][$workDate])) {
$dayData = [
'schedident' => $schedule->getSchedident(),
'rnum' => $schedule->getRnum(),
'dcode' => $schedule->getDcode(),
'filial' => $schedule->getFilial(),
'intervals' => [],
'depnum' => $department,
'isFree' => $schedule->isFree()
];
// Добавляем поля в зависимости от типа расписания
if (!$isOnlineMode) {
$dayData['rfloor'] = $schedule->getRfloor();
$dayData['rbuilding'] = $schedule->getRbuilding();
}
if ($isOnlineMode && $schedule->getPriceInfo()) {
$dayData['priceInfo'] = $schedule->getPriceInfo();
}
$result['schedule'][$department][$workDate] = $dayData;
// Сохраняем nearestDate
if (!isset($result['nearestDate'][$department])) {
$result['nearestDate'][$department] = (int)$workDate;
}
}
$result['schedule'][$department][$workDate]['intervals'][] = [
'time' => $schedule->getTime(),
'isFree' => $schedule->isIntervalIsFree()
];
}
return $result;
}
public function clearOldCache(\DateTimeInterface $olderThan): int
{
try {
return $this->scheduleRepository->removeOlderThan($olderThan);
} catch (\Exception $e) {
$this->logger->error('Error clearing old cache', [
'error' => $e->getMessage()
]);
throw $e;
}
}
public function getCacheStats(): array
{
return $this->scheduleRepository->getCacheStatistics();
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Service\Sequence;
use Doctrine\ORM\EntityManagerInterface;
class SequenceService
{
public function __construct(
private EntityManagerInterface $em
) {}
public function syncSequence(string $entityClass): bool
{
$connection = $this->em->getConnection();
$metadata = $this->em->getClassMetadata($entityClass);
$tableName = $metadata->getTableName();
$sequenceName = $tableName . '_id_seq';
// Проверяем существует ли последовательность
$sequenceExists = $connection->executeQuery("
SELECT 1 FROM pg_class WHERE relname = '$sequenceName'
")->fetchOne();
if (!$sequenceExists) {
throw new \RuntimeException("Sequence $sequenceName does not exist");
}
// Получаем текущие значения
$maxId = (int) $connection->executeQuery("SELECT COALESCE(MAX(id), 0) FROM $tableName")->fetchOne();
$currentSeq = (int) $connection->executeQuery("SELECT last_value FROM $sequenceName")->fetchOne();
// Если последовательность отстает, обновляем ее
if ($maxId >= $currentSeq) {
$nextVal = $maxId + 1;
$connection->executeStatement("SELECT setval('$sequenceName', $nextVal)");
// Проверяем результат
$newSeq = (int) $connection->executeQuery("SELECT last_value FROM $sequenceName")->fetchOne();
return $newSeq === $nextVal;
}
return true;
}
public function debugSequence(string $entityClass): array
{
$connection = $this->em->getConnection();
$metadata = $this->em->getClassMetadata($entityClass);
$tableName = $metadata->getTableName();
$sequenceName = $tableName . '_id_seq';
return [
'table_name' => $tableName,
'sequence_name' => $sequenceName,
'max_id' => $connection->executeQuery("SELECT COALESCE(MAX(id), 0) FROM $tableName")->fetchOne(),
'current_sequence' => $connection->executeQuery("SELECT last_value FROM $sequenceName")->fetchOne(),
'sequence_exists' => (bool) $connection->executeQuery("SELECT 1 FROM pg_class WHERE relname = '$sequenceName'")->fetchOne(),
];
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
/**
* Импорт услуг из материализованного представления (Bitrix view).
*
* См. SiteServiceController + CrudResponder для CRUD; этот сервис — только syncFromView*.
*/
final class SiteServiceCrudService
{
public function __construct(
private EntityManagerInterface $em,
) {
}
public function syncFromViewServices(string $viewName = 'public.view_services'): int
{
if (!preg_match('/^[A-Za-z0-9_.]+$/', $viewName)) {
throw new \InvalidArgumentException('Invalid view name');
}
$sql = sprintf(
'INSERT INTO site_services (
id,
name,
active,
region_id,
alias,
anons,
content,
update_at,
link_videoreviews,
preview_img,
faq,
part_price,
pokazaniya,
preparation,
protivopokazaniya,
hide_sign_btn,
quiz,
tags,
tags_important,
banner_img,
banner_img_m,
banner_img_url,
clinics,
download_file,
full_width_banner,
staff_up,
advantages,
hide_picture,
kod_uslug,
link_price,
photos_title,
sale_id,
sort_staff,
contraindications_list,
custom_block_text,
custom_block_text2,
custom_block_title,
custom_block_title2,
indications_list,
link_articles_services,
plus_list,
plus_text,
plus_title,
prepare_title,
process_text,
process_title,
services_list,
services_photos,
services_title,
text_up,
training_text,
why_text,
why_title,
link_faq,
link_services,
link_staff,
photos
)
SELECT
id,
name,
active,
region_id,
alias,
anons,
content,
update_at,
link_videoreviews,
preview_img,
faq,
part_price,
pokazaniya,
preparation,
protivopokazaniya,
hide_sign_btn,
quiz,
tags,
tags_important,
banner_img,
banner_img_m,
banner_img_url,
clinics,
download_file,
full_width_banner,
staff_up,
advantages,
hide_picture,
kod_uslug,
link_price,
photos_title,
sale_id,
sort_staff,
contraindications_list,
custom_block_text,
custom_block_text2,
custom_block_title,
custom_block_title2,
indications_list,
link_articles_services,
plus_list,
plus_text,
plus_title,
prepare_title,
process_text,
process_title,
services_list,
services_photos,
services_title,
text_up,
training_text,
why_text,
why_title,
link_faq,
link_services,
link_staff,
photos
FROM %s
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
active = EXCLUDED.active,
region_id = EXCLUDED.region_id,
alias = EXCLUDED.alias,
anons = EXCLUDED.anons,
content = EXCLUDED.content,
update_at = EXCLUDED.update_at,
link_videoreviews = EXCLUDED.link_videoreviews,
preview_img = EXCLUDED.preview_img,
faq = EXCLUDED.faq,
part_price = EXCLUDED.part_price,
pokazaniya = EXCLUDED.pokazaniya,
preparation = EXCLUDED.preparation,
protivopokazaniya = EXCLUDED.protivopokazaniya,
hide_sign_btn = EXCLUDED.hide_sign_btn,
quiz = EXCLUDED.quiz,
tags = EXCLUDED.tags,
tags_important = EXCLUDED.tags_important,
banner_img = EXCLUDED.banner_img,
banner_img_m = EXCLUDED.banner_img_m,
banner_img_url = EXCLUDED.banner_img_url,
clinics = EXCLUDED.clinics,
download_file = EXCLUDED.download_file,
full_width_banner = EXCLUDED.full_width_banner,
staff_up = EXCLUDED.staff_up,
advantages = EXCLUDED.advantages,
hide_picture = EXCLUDED.hide_picture,
kod_uslug = EXCLUDED.kod_uslug,
link_price = EXCLUDED.link_price,
photos_title = EXCLUDED.photos_title,
sale_id = EXCLUDED.sale_id,
sort_staff = EXCLUDED.sort_staff,
contraindications_list = EXCLUDED.contraindications_list,
custom_block_text = EXCLUDED.custom_block_text,
custom_block_text2 = EXCLUDED.custom_block_text2,
custom_block_title = EXCLUDED.custom_block_title,
custom_block_title2 = EXCLUDED.custom_block_title2,
indications_list = EXCLUDED.indications_list,
link_articles_services = EXCLUDED.link_articles_services,
plus_list = EXCLUDED.plus_list,
plus_text = EXCLUDED.plus_text,
plus_title = EXCLUDED.plus_title,
prepare_title = EXCLUDED.prepare_title,
process_text = EXCLUDED.process_text,
process_title = EXCLUDED.process_title,
services_list = EXCLUDED.services_list,
services_photos = EXCLUDED.services_photos,
services_title = EXCLUDED.services_title,
text_up = EXCLUDED.text_up,
training_text = EXCLUDED.training_text,
why_text = EXCLUDED.why_text,
why_title = EXCLUDED.why_title,
link_faq = EXCLUDED.link_faq,
link_services = EXCLUDED.link_services,
link_staff = EXCLUDED.link_staff,
photos = EXCLUDED.photos',
$viewName
);
return (int) $this->em->getConnection()->executeStatement($sql);
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Service\Specialist\Interfaces;
use App\Entity\Specialist;
use App\Dto\SpecialistFilterDto;
use App\Dto\ScheduleDto;
use App\Dto\AnonymousReserveRequestDto;
interface SpecialistServiceInterface
{
public function getLoadPicture(int $specialistId): string;
public function getSchedule(ScheduleDto $dto): array;
public function createAnonymousReserve(AnonymousReserveRequestDto $dto): array;
public function getSpecialist(string $identifier, int $regionId = null): ?Specialist;
public function getList(SpecialistFilterDto $filter): array;
}
@@ -0,0 +1,100 @@
<?php
namespace App\Service\Specialist;
use App\Entity\Specialist;
use App\Dto\SpecialistFilterDto;
use App\Service\Location\LocationService;
use App\Repository\SpecialistRepository;
use App\Dto\ScheduleDto;
use App\Dto\AnonymousReserveRequestDto;
use App\Message\GetScheduleMessage;
use App\Message\GetAnonymousReserveRequestMessage;
use App\Message\GetSpecialistPictureMessage;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
use App\Service\Specialist\Interfaces\SpecialistServiceInterface;
class SpecialistService implements SpecialistServiceInterface
{
public function __construct(
private MessageBusInterface $messageBus,
private SpecialistRepository $specialistRepository,
private LocationService $locationService
) { }
/**
* Получить картинку специалиста
*/
public function getLoadPicture(int $specialistId): string
{
$message = new GetSpecialistPictureMessage($specialistId);
$envelope = $this->messageBus->dispatch($message);
$handledStamp = $envelope->last(HandledStamp::class);
return $handledStamp->getResult();
}
/**
* Получить расписание специалиста
*/
public function getSchedule(ScheduleDto $dto): array
{
$message = new GetScheduleMessage($dto->toQueryString(), $dto->onlineMode);
$envelope = $this->messageBus->dispatch($message);
$handledStamp = $envelope->last(HandledStamp::class);
return $handledStamp->getResult();
}
/**
* Создать анонимную запись
*/
public function createAnonymousReserve(AnonymousReserveRequestDto $dto): array
{
$message = new GetAnonymousReserveRequestMessage($dto);
$envelope = $this->messageBus->dispatch($message);
$handledStamp = $envelope->last(HandledStamp::class);
return $handledStamp->getResult();
}
/**
* Получить специалиста по ID или alias
*/
public function getSpecialist(string $identifier, int $regionId = null): ?Specialist
{
if (is_numeric($identifier)) {
return $this->specialistRepository
->createFilteredQueryBuilder([
'id' => $identifier
])
->getQuery()
->getOneOrNullResult();
}
return $this->specialistRepository
->createFilteredQueryBuilder([
'alias' => $identifier,
'regionId' => $regionId
])
->getQuery()
->getOneOrNullResult();
}
public function getList(SpecialistFilterDto $filter): array
{
$params = $filter->toArray();
return $this
->specialistRepository
->createFilteredQueryBuilder($params)
->getQuery()
->getResult();
}
public function getFilteredCount(SpecialistFilterDto $filter): int
{
$params = $filter->toArray();
return $this->specialistRepository->countFiltered($params);
}
}
@@ -0,0 +1,8 @@
<?php
namespace App\Service\Translite\Interfaces;
interface TransliteServiceInterface
{
public function translit(string $string, string $replacement): string;
}
@@ -0,0 +1,35 @@
<?php
namespace App\Service\Translite;
use App\Service\Translite\Interfaces\TransliteServiceInterface;
final class TransliteService implements TransliteServiceInterface
{
public function translit(string $string, string $replacement = '-'): string
{
if ($string === '') {
return $string;
}
static $converter = [
'а' => 'a', 'б' => 'b', 'в' => 'v',
'г' => 'g', 'д' => 'd', 'е' => 'ye',
'ё' => 'yo', 'ж' => 'zh', 'з' => 'z',
'и' => 'i', 'й' => 'y', 'к' => 'k',
'л' => 'l', 'м' => 'm', 'н' => 'n',
'о' => 'o', 'п' => 'p', 'р' => 'r',
'с' => 's', 'т' => 't', 'у' => 'u',
'ф' => 'f', 'х' => 'kh', 'ц' => 'ts',
'ч' => 'ch', 'ш' => 'sh', 'щ' => 'shch',
'ь' => '', 'ы' => 'y', 'ъ' => '',
'э' => 'e', 'ю' => 'yu', 'я' => 'ya'
];
$string = mb_strtolower($string, 'UTF-8');
$string = strtr($string, $converter);
$string = preg_replace('~[^-a-z0-9_]+~u', $replacement, $string);
$string = preg_replace('~' . preg_quote($replacement, '~') . '{2,}~', $replacement, $string);
return trim($string, $replacement);
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Service\User;
use App\Dto\UserAuthDto;
use App\Dto\UserLoginDto;
use App\Repository\UserRepository;
use App\Service\User\Interfaces\AuthenticationServiceInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
final class AuthenticationService implements AuthenticationServiceInterface
{
public function __construct(
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
private UserPasswordHasherInterface $passwordHasher,
) { }
public function jwtAuth(UserAuthDto $dto): array
{
$user = $this->userRepository->findOneBy(['uid' => $dto->uid]);
if($user === null)
return [
'user' => NULL,
'isPasswordValid' => NULL
];
$isPasswordValid = $this->passwordHasher->isPasswordValid($user, $dto->password);
return [
'user' => $user,
'isPasswordValid' => $isPasswordValid
];
}
public function jsonAuth(UserLoginDto $dto): array
{
$user = $this->userRepository->findOneBy(['email' => md5($dto->email)]);
if($user === null)
return [
'user' => NULL,
'isPasswordValid' => NULL
];
$isPasswordValid = $this->passwordHasher->isPasswordValid($user, $dto->password);
return [
'user' => $user,
'isPasswordValid' => $isPasswordValid
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Service\User\Interfaces;
use App\Dto\UserAuthDto;
use App\Dto\UserLoginDto;
interface AuthenticationServiceInterface
{
public function jwtAuth(UserAuthDto $dto): array;
public function jsonAuth(UserLoginDto $dto): array;
}
@@ -0,0 +1,11 @@
<?php
namespace App\Service\User\Interfaces;
use App\Entity\User;
use App\Dto\UserAuthDto;
interface RegistrationServiceInterface
{
public function create(UserAuthDto $dto): User;
}
@@ -0,0 +1,11 @@
<?php
namespace App\Service\User\Interfaces;
use App\Entity\User;
use App\Dto\RegionDto;
interface UserProfileServiceInterface
{
public function updateRegion(RegionDto $dto): User;
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Service\User;
use App\Entity\User;
use App\Dto\UserAuthDto;
use App\Service\User\Interfaces\RegistrationServiceInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use App\Dto\UserUidAuthDto;
final class RegistrationService implements RegistrationServiceInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserPasswordHasherInterface $passwordHasher
) { }
public function create(UserAuthDto $dto): User
{
$user = new User();
$user->setEmail(md5($dto->email));
$user->setRegionId($dto->regionId);
$user->setRoles(['ROLE_USER']);
$user->setUid($dto->uid);
$birthDate = \DateTime::createFromFormat('Ymd', $dto->birthDate);
$user->setBirthDate($birthDate);
$user->setPassword(
$this->passwordHasher->hashPassword($user, $dto->password)
);
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
public function createByUidAndBirthDate(UserUidAuthDto $dto, int $defaultRegionId = 1): User
{
$user = new User();
// Генерируем email на основе uid
$user->setEmail(md5((string)$dto->uid));
// Устанавливаем дефолтный регион
$user->setRegionId($defaultRegionId);
// Устанавливаем роль
$user->setRoles(['ROLE_USER']);
// Устанавливаем uid
$user->setUid($dto->uid);
// Парсим дату рождения
$birthDate = \DateTime::createFromFormat('Ymd', $dto->birthDate);
if (!$birthDate) {
// Пробуем другие форматы
$birthDate = \DateTime::createFromFormat('Y-m-d', $dto->birthDate);
}
if (!$birthDate) {
throw new \InvalidArgumentException('Неверный формат даты рождения');
}
$user->setBirthDate($birthDate);
// Генерируем дефолтный пароль на основе uid и birthDate
$defaultPassword = md5($dto->uid . $dto->birthDate);
$user->setPassword(
$this->passwordHasher->hashPassword($user, $defaultPassword)
);
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Service\User;
use App\Entity\User;
use App\Dto\RegionDto;
use App\Service\User\Interfaces\UserProfileServiceInterface;
use App\Service\DecoderJWT\Interfaces\JWTDecoderServiceInterface;
use Doctrine\ORM\EntityManagerInterface;
final class UserProfileService implements UserProfileServiceInterface
{
public function __construct(
private JWTDecoderServiceInterface $jwtDecoderService,
private EntityManagerInterface $entityManager,
) {}
public function updateRegion(RegionDto $dto): User
{
$user = $this->jwtDecoderService->getUser();
$user->setRegionId($dto->regionId);
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
}
@@ -0,0 +1,623 @@
<?php
namespace App\Service\XmlFeedGenerator;
use DOMDocument;
use DOMElement;
use App\Entity\Filial;
use App\Entity\Specialist;
use App\Entity\Location;
use App\Repository\SpecialistDcodeDescriptionRepository;
use App\Service\PriceList\PriceListService;
use App\Service\Department\DepartmentService;
use App\Service\Location\LocationService;
use App\Service\Specialist\SpecialistService;
use App\Service\Filial\FilialService;
use App\Dto\SpecialistFilterDto;
class XmlFeedGeneratorService
{
private DOMDocument $dom;
private Filial $filial;
private array $utmParams = [];
private array $offerDescriptionCache = [];
public function __construct(
private PriceListService $priceListService,
private DepartmentService $departmentService,
private SpecialistService $specialistService,
private LocationService $locationService,
private FilialService $filialService,
private SpecialistDcodeDescriptionRepository $specialistDcodeDescriptionRepository,
private string $apiPublicUrl,
) {
$this->dom = new DOMDocument('1.0', 'UTF-8');
$this->dom->formatOutput = true;
}
public function generateFeed(Filial $currentFilial, array $utmParams = []): string
{
$this->filial = $currentFilial;
$this->utmParams = $utmParams;
$shop = $this->dom->createElement('shop');
$shop->setAttribute('date', date('Y-m-d\TH:i:s\Z'));
$shop->setAttribute('version', '2.0');
$this->dom->appendChild($shop);
$this->addShopInfo($shop);
$this->addDoctors($shop);
$this->addClinics($shop);
$this->addServices($shop);
$this->addOffers($shop);
return $this->dom->saveXML();
}
private function addServices(DOMElement $shop): void
{
$filter = new SpecialistFilterDto();
$filter->active = true;
$filter->filial = $this->filial->getFid();
$doctors = $this->specialistService->getList($filter);
$kodoperArray = array_map(function($doctor) {
return $doctor->getKodoper();
}, $doctors);
$services = $this->priceListService->getList([
'filial' => $this->filial->getFid(),
'kodoper' => $kodoperArray
]);
$servicesElement = $this->dom->createElement('services');
foreach ($services as $service) {
$serviceElement = $this->dom->createElement('service');
$serviceElement->setAttribute('id', $service->getId());
$this->addTextElement($serviceElement, 'name', $service->getSchname());
$this->addTextElement($serviceElement, 'gov_id', $service->getKodoper());
$servicesElement->appendChild($serviceElement);
}
$shop->appendChild($servicesElement);
}
private function addShopInfo(DOMElement $shop): void
{
$shop->appendChild($this->dom->createElement('picture', $this->getShopPicture()));
$shop->appendChild($this->dom->createElement('name', $this->getShopName()));
$shop->appendChild($this->dom->createElement('company', $this->filial->getCompany()));
$shop->appendChild($this->dom->createElement('url', $this->filial->getOrigin()));
$shop->appendChild($this->dom->createElement('email', $this->filial->getEmail()));
}
private function getShopName(): string
{
return match ($this->filial->getRegionId()) {
94 => 'WMT',
default => match ($this->filial->getFid()) {
9 => 'Совёнок',
default => 'Сова',
}
};
}
private function getShopPicture(): string
{
$picture = match ($this->filial->getRegionId()) {
94 => 'wmtmed.png',
default => match ($this->filial->getFid()) {
9 => 'sovenok.png',
10 => 'comfort.jpg',
default => 'sovamed.png',
}
};
return rtrim($this->apiPublicUrl, '/') . "/images/logo/{$picture}";
}
private function getSpecialistLink(Specialist $specialist): string
{
$url = $this->filial->getOrigin();
$url .= match ($this->filial->getId()) {
8 => '/specialisty',
default => '/vrachi'
};
$url .= match ($specialist->getStype()) {
0 => '/vzroslyj-vrach/',
1 => '/detskij-vrach/',
2 => '/administraciya/',
default => '/vzroslyj-vrach/',
};
$url .= $specialist->getAlias();
$url .= '/';
// Формируем query string из UTM-параметров, если они есть
if (!empty($this->utmParams)) {
$queryParams = array_filter($this->utmParams, fn($value) => $value !== null && $value !== '');
if (!empty($queryParams)) {
// Формируем query string вручную, гарантируя использование &
$parts = [];
foreach ($queryParams as $key => $value) {
if ($value !== null && $value !== '') {
$parts[] = rawurlencode($key) . '=' . rawurlencode($value);
}
}
if (!empty($parts)) {
$url .= '?' . implode('&amp;', $parts);
}
}
}
return $url;
}
private function addSpecialist(Location $location): DOMElement
{
$doctor = $location->getSpecialist();
$doctorElement = $this->dom->createElement('doctor');
$doctorElement->setAttribute('id', $doctor->getId() . '_' . $location->getId());
$url = $this->getSpecialistLink($doctor);
$this->addTextElement($doctorElement, 'url', $url);
$id = $doctor->getId();
$doctorDescription = $this->getOfferDoctorDescription(
$id,
$location->getDcode(),
$location->getDepartment()
);
$this->addTextElement($doctorElement, 'description', $doctorDescription ?? '');
$picture = rtrim($this->apiPublicUrl, '/') . "/specialist/picture/{$id}";
$this->addTextElement($doctorElement, 'picture', $picture);
$this->addTextElement($doctorElement, 'name', $doctor->getName() ?? '');
$this->addTextElement($doctorElement, 'first_name', $doctor->getFullName()['firstName'] ?? '');
$this->addTextElement($doctorElement, 'surname', $doctor->getFullName()['lastName'] ?? '');
$this->addTextElement($doctorElement, 'patronymic', $doctor->getFullName()['middleName'] ?? '');
$experience = $doctor->getExperience();
if ($experience && is_numeric($experience) && $experience > 1900 && $experience <= date('Y')) {
$this->addTextElement($doctorElement, 'career_start_date', $experience . '-01-01');
$this->addTextElement($doctorElement, 'experience_years', $experience ? date('Y') - $experience : '');
}
$this->addTextElement($doctorElement, 'degree', $doctor->getDegree() ?? '');
$this->addTextElement($doctorElement, 'category', $doctor->getCategory() ?? '');
$this->addTextElement($doctorElement, 'internal_id', 'doctor_' . $doctor->getId());
$jobElement = $this->dom->createElement('job');
$this->addTextElement($jobElement, 'organization', $this->filial->getCompany() ?? '');
$this->addTextElement($jobElement, 'position', $doctor->getPost() ?? '');
$doctorElement->appendChild($jobElement);
return $doctorElement;
}
private function addDoctors(DOMElement $shop): void
{
$locations = $this->locationService->getList([
'filial' => $this->filial->getFid(),
'active' => true,
]);
$doctorsElement = $this->dom->createElement('doctors');
$seenSpecialistIds = [];
foreach ($locations as $location) {
$specialist = $location->getSpecialist();
if ($specialist === null) {
continue;
}
$specialistId = $specialist->getId();
if (isset($seenSpecialistIds[$specialistId])) {
continue;
}
$seenSpecialistIds[$specialistId] = true;
$doctorElement = $this->addSpecialist($location);
$doctorsElement->appendChild($doctorElement);
}
$shop->appendChild($doctorsElement);
}
private function prepareXmlContent($content): string
{
$content = str_replace('&', '&amp;', $content);
$validEntities = [
'&amp;nbsp;' => '&#160;',
'&amp;amp;' => '&amp;',
'&amp;lt;' => '&lt;',
'&amp;gt;' => '&gt;',
'&amp;quot;' => '&quot;',
'&amp;apos;' => '&apos;'
];
$content = str_replace(array_keys($validEntities), array_values($validEntities), $content);
return htmlspecialchars($content, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
private function getClinicInteralId(): int
{
return match ($this->filial->getFid()) {
1 => 233054332033, // симбирская
3 => 25612492147, // ленина
4 => 1766329112, // разина
5 => 199291222081, // сакко
7 => 103319931090, // никитинская
8 => 1281270894, // академическая
9 => 227335089844, // чапаева совенок
10 => 196496853150, // б.казачия комфорт
11 => 97526446099, // мая
12 => 134757619374, // постовая
13 => 82702166632, // московский
14 => 168673809491, // петра метальникова
default => (int) $this->filial->getFid(),
};
}
private function addClinics(DOMElement $shop): void
{
$clinicsElement = $this->dom->createElement('clinics');
$clinicElement = $this->dom->createElement('clinic');
$clinicElement->setAttribute('id', $this->filial->getFid());
$this->addTextElement($clinicElement, 'picture', $this->getShopPicture());
$this->addTextElement($clinicElement, 'url', $this->filial->getOrigin());
$this->addTextElement($clinicElement, 'name', $this->getShopName());
$city = match ((int) $this->filial->getRegionId()) {
91 => 'г. Саратов',
92 => 'г. Волгоград',
93 => 'г. Воронеж',
94 => 'г. Краснодар',
default => '',
};
$this->addTextElement($clinicElement, 'city', $city);
$this->addTextElement($clinicElement, 'address', $this->filial->getShortName());
$this->addTextElement($clinicElement, 'phone', $this->filial->getPhone());
$this->addTextElement($clinicElement, 'email', $this->filial->getEmail());
$this->addTextElement($clinicElement, 'internal_id', $this->getClinicInteralId());
$clinicsElement->appendChild($clinicElement);
$shop->appendChild($clinicsElement);
}
private function specialitet(): array
{
return [
'абдоминальный хирург',
'акушер',
'акушер-гинеколог',
'аллерголог',
'аллерголог-иммунолог',
'андролог',
'анестезиолог',
'анестезиолог-реаниматолог',
'аритмолог',
'артролог',
'бариатрический хирург',
'вегетолог',
'венеролог',
'вертебролог',
'вирусолог',
'врач лабораторной диагностики',
'врач лфк',
'врач общей практики',
'врач по медико-социальной экспертизе',
'врач по паллиативной медицинской помощи',
'врач по спортивной медицине',
'врач по рентгенэндоваскулярным диагностике и лечению',
'врач скорой помощи',
'врач УЗИ',
'врач функциональной диагностики',
'врач эфферентной терапии',
'гастроэнтеролог',
'гематолог',
'гемостазиолог',
'генетик',
'гепатолог',
'гериатр (геронтолог)',
'гинеколог',
'гинеколог-эндокринолог',
'гипнолог',
'гирудотерапевт',
'гнатолог',
'гнойный хирург',
'дезинфектолог',
'дерматолог',
'дерматовенеролог',
'дефектолог',
'диабетолог',
'диетолог',
'иммунолог',
'инструктор лфк',
'инфекционист',
'кардиолог',
'кардиохирург',
'кинезиолог',
'кистевой хирург',
'клинический фармаколог',
'колопроктолог (проктолог)',
'косметолог',
'лазерный хирург',
'лимфолог',
'логопед',
'лор (отоларинголог)',
'малоинвазивный хирург',
'маммолог',
'мануальный терапевт',
'массажист',
'миколог',
'нарколог',
'невролог',
'нейропсихолог',
'нейрофизиолог',
'нейрохирург',
'неонатолог',
'нефролог',
'нутрициолог',
'ожоговый хирург (комбустиолог)',
'онкогинеколог',
'онкодерматолог',
'онколог',
'онколог-гематолог',
'онкопроктолог',
'онкоуролог',
'оптометрист',
'ортопед',
'остеопат',
'отоневролог',
'офтальмолог (окулист)',
'офтальмолог-протезист',
'офтальмохирург',
'паразитолог',
'патологоанатом',
'педиатр',
'перинатолог',
'пластический хирург',
'подиатр',
'подолог',
'профпатолог',
'психиатр',
'психолог',
'психотерапевт',
'пульмонолог',
'радиолог',
'радиотерапевт',
'реабилитолог',
'реаниматолог',
'ревматолог',
'рентгенолог',
'репродуктолог',
'рефлексотерапевт',
'сексолог',
'семейный врач',
'сердечно-сосудистый хирург',
'сомнолог',
'сосудистый хирург',
'специалист по грудному вскармливанию',
'спортивный врач',
'стоматолог',
'стоматолог-гигиенист',
'стоматолог-имплантолог',
'стоматолог-ортодонт',
'стоматолог-ортопед',
'стоматолог-пародонтолог',
'стоматолог-терапевт',
'стоматолог-хирург',
'стоматолог-эндодонт',
'судебно-медицинский эксперт',
'сурдолог',
'сурдолог-протезист',
'терапевт',
'токсиколог',
'торакальный онколог',
'торакальный хирург',
'травматолог',
'трансплантолог',
'трансфузиолог',
'трихолог',
'уролог',
'физиотерапевт',
'фитотерапевт',
'флеболог',
'фониатр',
'фтизиатр',
'химиотерапевт',
'хирург',
'хирург-эндокринолог',
'цитолог',
'челюстно-лицевой хирург',
'эмбриолог',
'эндоваскулярный хирург',
'эндокринолог',
'эндоскопист',
'эпидемиолог',
'эпилептолог'
];
}
private function getSpeciality(?string $spesiality): string
{
if (!$spesiality) return '';
// 1. Нормализуем строку: заменяем типы дефисов и удаляем "врач"
$normalized = str_replace(['—', 'Врач'], ['-', ' '], $spesiality);
$normalized = trim($normalized);
$specialities = $this->specialitet();
// 2. Ищем точное совпадение с нормализованной строкой
if (in_array($normalized, $specialities, true)) {
return $normalized;
}
// 3. Разбиваем на части и ищем каждую часть
$parts = preg_split('/[\s,]+/', $normalized);
// Ищем каждую отдельную часть
foreach ($parts as $part) {
$part = trim($part);
if (in_array($part, $specialities, true)) {
return $part;
}
}
// 4. Ищем комбинации из 2 слов
for ($i = 0; $i < count($parts) - 1; $i++) {
$combined = $parts[$i] . ' ' . $parts[$i + 1];
if (in_array($combined, $specialities, true)) {
return $combined;
}
// Также пробуем комбинацию с дефисом (на случай если в оригинале был пробел)
$combinedWithHyphen = $parts[$i] . '-' . $parts[$i + 1];
if (in_array($combinedWithHyphen, $specialities, true)) {
return $combinedWithHyphen;
}
}
// 5. Если не нашли, удаляем все дефисы и ищем снова
$withoutHyphens = str_replace('-', ' ', $normalized);
$withoutHyphens = trim($withoutHyphens);
if ($withoutHyphens !== $normalized) {
$partsWithoutHyphens = preg_split('/[\s,]+/', $withoutHyphens);
// Ищем каждую отдельную часть без дефисов
foreach ($partsWithoutHyphens as $part) {
$part = trim($part);
if (in_array($part, $specialities, true)) {
return $part;
}
}
// Ищем комбинации из 2 слов без дефисов
for ($i = 0; $i < count($partsWithoutHyphens) - 1; $i++) {
$combined = $partsWithoutHyphens[$i] . ' ' . $partsWithoutHyphens[$i + 1];
if (in_array($combined, $specialities, true)) {
return $combined;
}
}
}
return '';
}
private function addOffers(DOMElement $shop): void
{
$offers = $this->locationService->getList([
'filial' => $this->filial->getFid(),
'active' => true
]);
$offersElement = $this->dom->createElement('offers');
foreach ($offers as $offer) {
$specialist = $offer->getSpecialist();
if (empty($specialist)) {
continue;
}
if (!$specialist->getKodoper()) {
continue;
}
$priceList = $this->priceListService->getShow([
'kodoper' => $specialist->getKodoper(),
'filial' => $offer->getFilial()
]);
if ($priceList) {
$priceInfo = $priceList->getPriceInfo();
if (is_array($priceInfo)) {
$offerElement = $this->dom->createElement('offer');
$offerElement->setAttribute('id', $offer->getId());
$url = $this->getSpecialistLink($specialist);
$this->addTextElement($offerElement, 'url', $url);
$this->addTextElement($offerElement, 'oms', 'false');
$this->addTextElement($offerElement, 'appointment', 'true');
$this->addTextElement($offerElement, 'online_schedule', 'true');
$serviceElement = $this->dom->createElement('service');
$serviceElement->setAttribute('id', $priceList->getId());
$offerElement->appendChild($serviceElement);
$clinicElement = $this->dom->createElement('clinic');
$clinicElement->setAttribute('id', $offer->getFilial());
$doctorElement = $this->dom->createElement('doctor');
$doctorElement->setAttribute('id', $specialist->getId() . '_' . $offer->getId());
$speciality = $this->getSpeciality($specialist->getPost());
$this->addTextElement($doctorElement, 'speciality', $speciality);
$this->addTextElement($doctorElement, 'is_base_service', 'true');
$this->addTextElement($doctorElement, 'children_appointment', ($specialist->getStype() == 1)? 'true': 'false');
$this->addTextElement($doctorElement, 'adult_appointment', in_array($specialist->getStype(), [0,2])? 'true': 'false');
$offerElement->appendChild($clinicElement);
$clinicElement->appendChild($doctorElement);
$priceElement = $this->dom->createElement('price');
$this->addTextElement($priceElement, 'base_price', $priceInfo['price']);
$this->addTextElement($priceElement, 'currency', 'RUB');
$offerElement->appendChild($priceElement);
$offersElement->appendChild($offerElement);
}
}
}
$shop->appendChild($offersElement);
}
private function getOfferDoctorDescription(int $specialistId, ?int $dcode, ?int $department): ?string
{
if ($dcode === null) {
return null;
}
$cacheKey = sprintf('%d:%d:%s', $specialistId, $dcode, $department === null ? 'null' : (string) $department);
if (array_key_exists($cacheKey, $this->offerDescriptionCache)) {
return $this->offerDescriptionCache[$cacheKey];
}
$description = $this->specialistDcodeDescriptionRepository->findOneBySpecialistAndDcode(
$specialistId,
$dcode,
$department
);
if ($description === null) {
$this->offerDescriptionCache[$cacheKey] = null;
return null;
}
$content = $description->getContent();
$this->offerDescriptionCache[$cacheKey] = $content;
return $content;
}
private function addTextElement(DOMElement $parent, string $name, $value): void
{
if ($value !== null && $value !== '') {
$element = $this->dom->createElement($name);
$text = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$element->appendChild($this->dom->createTextNode($text));
$parent->appendChild($element);
}
}
}
@@ -0,0 +1,264 @@
<?php
namespace App\Service\XmlFeedGenerator;
use DOMDocument;
use DOMElement;
use App\Dto\SpecialistFilterDto;
use App\Entity\Filial;
use App\Entity\Specialist;
use App\Service\PriceList\PriceListService;
use App\Service\Specialist\SpecialistService;
use App\Service\Helper\HelperService;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
class XmlFeedGeneratorV1Service
{
private DOMDocument $dom;
private Filial $filial;
/** @var array<int, Filial> */
private array $filialsByFid = [];
public function __construct(
private PriceListService $priceListService,
private SpecialistService $specialistService,
private HelperService $helperService,
private Connection $connection,
private string $apiPublicUrl,
private ?LoggerInterface $logger = null,
) {
$this->dom = new DOMDocument('1.0', 'UTF-8');
$this->dom->formatOutput = true;
}
/**
* @param Filial|Filial[] $filials один филиал или массив филиалов (например, по региону)
*/
public function generateFeed(Filial|array $filials): string
{
$filialList = is_array($filials) ? $filials : [$filials];
if ($filialList === []) {
$this->dom = new DOMDocument('1.0', 'UTF-8');
$this->dom->formatOutput = true;
return $this->dom->saveXML();
}
$this->dom = new DOMDocument('1.0', 'UTF-8');
$this->dom->formatOutput = true;
$this->filial = $filialList[0];
$this->filialsByFid = [];
foreach ($filialList as $f) {
$this->filialsByFid[$f->getFid()] = $f;
}
$ymlCatalog = $this->dom->createElement('yml_catalog');
$ymlCatalog->setAttribute('date', date('Y-m-d H:i'));
$this->dom->appendChild($ymlCatalog);
$shop = $this->dom->createElement('shop');
$ymlCatalog->appendChild($shop);
$this->addShopInfo($shop);
$this->addCurrencies($shop);
$this->addCategories($shop);
$this->addSets($shop);
$this->addOffers($shop);
return $this->dom->saveXML();
}
private function addShopInfo(DOMElement $shop): void
{
$this->addTextElement($shop, 'name', $this->getShopName());
$this->addTextElement($shop, 'company', $this->filial->getCompany());
$this->addTextElement($shop, 'url', $this->filial->getOrigin());
$this->addTextElement($shop, 'email', $this->filial->getEmail());
}
private function addCurrencies(DOMElement $shop): void
{
$currencies = $this->dom->createElement('currencies');
$currency = $this->dom->createElement('currency');
$currency->setAttribute('id', 'RUR');
$currency->setAttribute('rate', '1');
$currencies->appendChild($currency);
$shop->appendChild($currencies);
}
private function addCategories(DOMElement $shop): void
{
$categories = $this->dom->createElement('categories');
$category = $this->dom->createElement('category', 'Врач');
$category->setAttribute('id', '1');
$categories->appendChild($category);
$shop->appendChild($categories);
}
private function addSets(DOMElement $shop): void
{
$sets = $this->dom->createElement('sets');
$departments = $this->connection->fetchAllAssociative(
'SELECT did, name FROM departments WHERE active = :active ORDER BY id ASC',
['active' => true]
);
foreach ($departments as $department) {
$set = $this->dom->createElement('set');
$set->setAttribute('id', (string) $department['did']);
$name = $this->dom->createElement('name', (string) $department['name']);
$set->appendChild($name);
$sets->appendChild($set);
}
$shop->appendChild($sets);
}
private function getShopName(): string
{
return match ($this->filial->getRegionId()) {
94 => 'Клиника ВМТ Сова',
default => match ($this->filial->getFid()) {
9 => 'Совёнок',
default => 'Сова',
}
};
}
private function getShopPicture(): string
{
$picture = match ($this->filial->getRegionId()) {
94 => 'wmtmed.png',
default => match ($this->filial->getFid()) {
9 => 'sovenok.png',
10 => 'comfort.jpg',
default => 'sovamed.png',
}
};
return rtrim($this->apiPublicUrl, '/') . "/images/logo/{$picture}";
}
private function getSpecialistLink(Specialist $specialist): string
{
$url = $this->filial->getOrigin();
$url .= match ($this->filial->getId()) {
9 => '/doctors/',
14 => '/doctors/',
10 => '/doctors/',
8 => '/specialisty',
default => '/vrachi'
};
if (!in_array($this->filial->getId(), [9, 14, 10])) {
$sType = $specialist->getSType();
if ($sType !== null) {
$url .= match ($sType) {
0 => '/vzroslyj-vrach/',
1 => '/detskij-vrach/',
2 => '/administraciya/',
default => '/vzroslyj-vrach/',
};
} else {
$this->logger?->info('Specialist type is not set', ['specialist' => $specialist->getId()]);
}
}
$url .= $specialist->getAlias();
$url .= '/';
return $url;
}
private function getCity(): string
{
return match ((int) $this->filial->getRegionId()) {
91 => 'Саратов',
92 => 'Волгоград',
93 => 'Воронеж',
94 => 'Краснодар',
default => ''
};
}
private function addOffers(DOMElement $shop): void
{
$offersElement = $this->dom->createElement('offers');
$fids = array_keys($this->filialsByFid);
if ($fids === []) {
$shop->appendChild($offersElement);
return;
}
$filter = new SpecialistFilterDto();
$filter->kiosk = true;
$filter->sFilial = array_map('strval', $fids);
$specialistList = $this->specialistService->getList($filter);
foreach ($specialistList as $specialist) {
$specialistDescription = $specialist->getPost();
$offerElement = $this->dom->createElement('offer');
$offerElement->setAttribute('id', (string) $specialist->getId());
$url = $this->getSpecialistLink($specialist);
$experience = $specialist->getExperience();
$experienceYears = $experience && is_numeric($experience) && $experience > 1900 && $experience <= date('Y')
? date('Y') - (int) $experience
: null;
if ($experienceYears !== null) {
$specialistDescription .= ' с опытом работы ' . $this->helperService->textYear($experienceYears, true);
}
$specialistDescription .= '. Запись к специалисту на сайте';
$this->addTextElement($offerElement, 'name', $specialist->getName() ?? '');
$this->addTextElement($offerElement, 'description', $specialistDescription ?? '');
$this->addTextElement($offerElement, 'url', $url);
$this->addTextElement($offerElement, 'set-ids', $specialist->getDcodes());
$picture = rtrim($this->apiPublicUrl, '/') . "/specialist/picture/{$specialist->getId()}";
$this->addTextElement($offerElement, 'picture', $picture);
$this->addTextElement($offerElement, 'categoryId', '1');
$this->addTextElement($offerElement, 'currencyId', 'RUR');
$offersElement->appendChild($offerElement);
}
$shop->appendChild($offersElement);
}
private function addTextElement(DOMElement $parent, string $name, $value): void
{
if ($value !== null && $value !== '') {
$parent->appendChild($this->dom->createElement($name, $this->prepareXmlContent((string)$value)));
}
}
private function addParamElement(DOMElement $parent, string $name, $value): void
{
if ($value !== null && $value !== '') {
$param = $this->dom->createElement('param', $this->prepareXmlContent((string)$value));
$param->setAttribute('name', $name);
$parent->appendChild($param);
}
}
private function prepareXmlContent($content): string
{
$content = str_replace('&', '&amp;', $content);
$validEntities = [
'&amp;nbsp;' => '&#160;',
'&amp;amp;' => '&amp;',
'&amp;lt;' => '&lt;',
'&amp;gt;' => '&gt;',
'&amp;quot;' => '&quot;',
'&amp;apos;' => '&apos;'
];
$content = str_replace(array_keys($validEntities), array_values($validEntities), $content);
return htmlspecialchars($content, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
}