chore: initial import for test contour
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('&', $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('&', '&', $content);
|
||||
$validEntities = [
|
||||
'&nbsp;' => ' ',
|
||||
'&amp;' => '&',
|
||||
'&lt;' => '<',
|
||||
'&gt;' => '>',
|
||||
'&quot;' => '"',
|
||||
'&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('&', '&', $content);
|
||||
$validEntities = [
|
||||
'&nbsp;' => ' ',
|
||||
'&amp;' => '&',
|
||||
'&lt;' => '<',
|
||||
'&gt;' => '>',
|
||||
'&quot;' => '"',
|
||||
'&apos;' => '''
|
||||
];
|
||||
$content = str_replace(array_keys($validEntities), array_values($validEntities), $content);
|
||||
|
||||
return htmlspecialchars($content, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user