chore: initial import for test contour with k3s CI

This commit is contained in:
sova-bootstrap
2026-05-28 12:09:28 +03:00
commit d77d0a872f
423 changed files with 35401 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Service;
use App\Entity\PriceList;
use App\Repository\PriceListRepository;
use App\Repository\FilialRepository;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\Request;
use App\Bundle\Infoclinica\Region;
class PriceListService
{
private $priceListRepository;
private $filialRepository;
public function __construct(PriceListRepository $priceListRepository, FilialRepository $filialRepository)
{
$this->priceListRepository = $priceListRepository;
$this->filialRepository = $filialRepository;
}
public function getFilteredPriceListQuery(array $filters): QueryBuilder
{
$filters['actual'] = true;
if (empty($filters['filial'])) {
$filters['filial'] = $this->getCurrentFilialIds();
}
$priceListQuery = $this->priceListRepository->createFilteredQueryBuilder($filters);
return $priceListQuery;
}
public function getPriceListQuery(array $filters): QueryBuilder
{
$priceListQuery = $this->priceListRepository->createFilteredQueryBuilder($filters);
return $priceListQuery;
}
private function getCurrentFilialIds(): array
{
$filials = $this->filialRepository->findByRegion(Region::getCurrentName());
return array_map(function ($filial) {
return $filial->getFid();
}, $filials);
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
namespace App\Service;
use App\Entity\LocationView;
use App\Entity\PriceList;
use App\Entity\Review;
use App\Repository\LocationViewRepository;
use App\Repository\PriceListRepository;
use App\Repository\ReviewRepository;
use App\Bundle\Utils\Logger;
class SpecialistMoreService
{
public array $prices = [];
private array $locations = [];
public int $specialist;
public ?array $kodopers = null;
public ?array $reviews = null;
public bool $onlineMode;
public function __construct(
private LocationViewRepository $locationViewRepository,
private ReviewRepository $reviewRepository,
private PriceListRepository $priceListRepository,
bool $onlineMode = false
) {
$this->onlineMode = $onlineMode;
}
public function setSpecialist(int $specialistId, ?array $kodopers): static
{
$this->specialistId = $specialistId;
$this->kodopers = $kodopers;
return $this;
}
public function getLocations(): array
{
if (empty($this->locations)) {
$this->locations = $this
->locationViewRepository
->findBySpecialistId($this->specialistId, $this->onlineMode);
}
return $this->locations;
}
public function locationsCount(): int
{
return count($this->getLocations());
}
public function defaultLocation(): ?array
{
$this->getLocations();
usort($this->locations, function($a, $b) {
return $a['nearestDate'] <=> $b['nearestDate'];
});
return !empty($this->locations) ? $this->locations[0] : null;
}
public function hasPrice(): bool
{
return !empty($this->getPrices());
}
public function getPrices(): ?array
{
if (empty($this->prices)) {
if ($this->kodopers) {
// Логируем входные данные
Logger::send([
'method' => 'SpecialistMoreService::getPrices()',
'action' => 'Input kodopers',
'kodopers' => $this->kodopers
], 'dev.log');
// Фильтр по актуальным записям (обновленным за последние 2 дня)
$filters['actual'] = true;
$filters['kodoper'] = $this->kodopers;
$filters['filial'] = [];
// Убеждаемся, что locations загружены перед использованием
$locations = $this->getLocations();
foreach ($locations as $location) {
if (isset($location['filial']) && $location['filial'] !== null) {
$filters['filial'][] = $location['filial'];
}
}
// Убираем дубликаты филиалов
$filters['filial'] = array_values(array_unique($filters['filial']));
// Если филиалы не найдены, не применяем фильтр по филиалам
// чтобы показать все цены для данного специалиста
if (empty($filters['filial'])) {
unset($filters['filial']);
}
$queryBuilder = $this->priceListRepository->createFilteredQueryBuilder($filters);
// Логируем SQL запрос и параметры для отладки
$query = $queryBuilder->getQuery();
$sql = $query->getSQL();
$params = [];
foreach ($query->getParameters() as $param) {
$params[$param->getName()] = $param->getValue();
}
Logger::send([
'method' => 'SpecialistMoreService::getPrices()',
'action' => 'PriceList SQL params',
'params' => $params
], 'dev.log');
// Выполняем запрос
$allPrices = $queryBuilder->getQuery()->getResult();
// Убираем дубликаты по комбинации kodoper + filial + schname
$uniquePrices = [];
$seenKeys = [];
foreach ($allPrices as $price) {
$kodoper = $price->getKodoper();
$filial = $price->getFilial();
$schname = $price->getSchname();
// Создаем уникальный ключ
$key = md5($kodoper . '|' . $filial . '|' . $schname);
if (!isset($seenKeys[$key])) {
$seenKeys[$key] = true;
$uniquePrices[] = $price;
}
}
$this->prices = $uniquePrices;
// Логируем детальную информацию о найденных записях
$foundKodopers = [];
$foundRecords = [];
foreach ($this->prices as $price) {
$kodoper = $price->getKodoper();
$foundKodopers[] = $kodoper;
$foundRecords[] = [
'id' => $price->getId(),
'kodoper' => $kodoper,
'schname' => $price->getSchname(),
'filial' => $price->getFilial(),
'price' => $price->getPriceInfo()['price'] ?? null,
];
}
Logger::send([
'method' => 'SpecialistMoreService::getPrices()',
'action' => 'PriceList query result',
'kodoper' => $filters['kodoper'],
'filial' => $filters['filial'] ?? 'not set',
'found_records' => count($this->prices),
'before_dedup' => count($allPrices),
'found_kodopers' => $foundKodopers,
'records_details' => $foundRecords
], 'dev.log');
}
}
return $this->prices;
}
public function minPrice(): ?PriceList
{
$minPriceRecord = null;
$minPrice = PHP_INT_MAX;
foreach ($this->getPrices() as $record) {
$currentPrice = $record->getPriceInfo()['price'];
if ($currentPrice < $minPrice) {
$minPrice = $currentPrice;
$minPriceRecord = $record;
}
}
return $minPriceRecord;
}
public function getReviews(): ?array
{
if (empty($this->reviews)) {
$this->reviews = $this
->reviewRepository
->findBy(['specialistId' => $this->specialistId]);
}
return $this->reviews;
}
public function hasReviews(): bool
{
return !empty($this->getReviews());
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Service;
use App\Entity\SpecialistView;
use App\Repository\SpecialistViewRepository;
use App\Repository\LocationViewRepository;
Use App\Repository\ReviewRepository;
use App\Service\SpecialistMoreService;
use Knp\Component\Pager\PaginatorInterface;
use Knp\Bundle\PaginatorBundle\Pagination\SlidingPagination;
use App\Repository\PriceListRepository;
class SpecialistService
{
public function __construct(
private LocationViewRepository $locationViewRepository,
private PriceListRepository $priceListRepository,
private SpecialistViewRepository $specialistViewRepository,
private ReviewRepository $reviewRepository,
private PaginatorInterface $paginator
) { }
public function listPaginated(
array $filters = [],
int $page = 1,
int $limit = 10
): SlidingPagination {
$query = $this->specialistViewRepository
->createFilteredQueryBuilder($filters)
->getQuery()
;
$paginatedSpecialists = $this->paginator->paginate($query, $page, $limit);
foreach ($paginatedSpecialists as $key => $specialist) {
$specialist->addSpecialistMoreService(
new SpecialistMoreService(
$this->locationViewRepository,
$this->reviewRepository,
$this->priceListRepository,
$filters['onlineMode'] ?? false
)
);
}
return $paginatedSpecialists;
}
public function list(array $filters = []): array
{
$specialists = $this->specialistViewRepository
->createFilteredQueryBuilder($filters)
->getQuery()
->getResult()
;
foreach ($specialists as $key => $specialist) {
$specialist->addSpecialistMoreService(
new SpecialistMoreService(
$this->locationViewRepository,
$this->reviewRepository,
$this->priceListRepository,
$filters['onlineMode'] ?? false
)
);
}
return $specialists;
}
public function show(array $filters = []): ?SpecialistView
{
$specialist = $this->specialistViewRepository
->createFilteredQueryBuilder($filters)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
;
if ($specialist) {
$specialist->addSpecialistMoreService(
new SpecialistMoreService(
$this->locationViewRepository,
$this->reviewRepository,
$this->priceListRepository,
$filters['onlineMode'] ?? false
)
);
}
return $specialist;
}
}
+318
View File
@@ -0,0 +1,318 @@
<?php
namespace App\Service;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler;
class UserCleanupService
{
private $userRepository;
private $entityManager;
private $logger;
private $cookieLifetime;
private $sessionHandler;
public function __construct(
UserRepository $userRepository,
EntityManagerInterface $entityManager,
LoggerInterface $logger,
array $sessionStorageOptions = [],
$sessionHandler = null
) {
$this->userRepository = $userRepository;
$this->entityManager = $entityManager;
$this->logger = $logger;
$this->cookieLifetime = $sessionStorageOptions['cookie_lifetime'] ?? 0;
$this->sessionHandler = $sessionHandler;
}
/**
* Удаляет неактивных пользователей с только ROLE_USER после истечения времени жизни кукисов
*
* @return int Количество удаленных пользователей
*/
public function cleanupExpiredUsers(): int
{
// Если cookie_lifetime = 0, используем время жизни сессии по умолчанию (1 час)
// Иначе используем указанное время жизни кукисов
$sessionLifetime = $this->cookieLifetime > 0
? $this->cookieLifetime
: 3600; // 1 час по умолчанию для session cookies
// Вычисляем дату, до которой должна быть последняя активность пользователя
$beforeDate = new \DateTime();
$beforeDate->modify("-{$sessionLifetime} seconds");
$usersToDelete = $this->userRepository->findUsersToCleanup($beforeDate);
$deletedCount = 0;
$this->logger->info(sprintf(
'Найдено пользователей для проверки: %d (время жизни сессии: %d секунд, дата отсечки: %s)',
count($usersToDelete),
$sessionLifetime,
$beforeDate->format('Y-m-d H:i:s')
));
foreach ($usersToDelete as $user) {
// Проверяем, что у пользователя только ROLE_USER
// Если массив ролей пуст, значит у пользователя только ROLE_USER (который добавляется автоматически)
$actualRoles = $user->getActualRoles();
$hasOnlyUserRole = empty($actualRoles);
$this->logger->debug(sprintf(
'Проверка пользователя ID %d: роли=%s, только ROLE_USER=%s',
$user->getId(),
json_encode($actualRoles),
$hasOnlyUserRole ? 'да' : 'нет'
));
if ($hasOnlyUserRole) {
// Определяем дату для логирования
$activityDate = $user->getLastActivityAt() ?? $user->getCreatedAt();
$activityDateStr = $activityDate ? $activityDate->format('Y-m-d H:i:s') : 'N/A';
try {
// Удаляем сессии пользователя из Redis перед удалением пользователя
$this->cleanupUserSessions($user);
$this->deleteUserAndRelatedData($user);
$deletedCount++;
$this->logger->info(sprintf(
'Удален неактивный пользователь с ID %d (uid: %d, email: %s, последняя активность: %s)',
$user->getId(),
$user->getUid(),
$user->getEmail() ?? 'N/A',
$activityDateStr
));
} catch (\Exception $e) {
$this->logger->error(sprintf(
'Ошибка при удалении пользователя с ID %d: %s',
$user->getId(),
$e->getMessage()
));
}
}
}
// Очищаем старые сессии из Redis
$this->cleanupOldRedisSessions($sessionLifetime);
return $deletedCount;
}
/**
* Удаляет пользователя и все связанные данные
*
* @param User $user
*/
private function deleteUserAndRelatedData(User $user): void
{
// Удаляем связанные данные Calltouch (если есть)
$this->deleteCalltouchData($user);
// Удаляем сам пользователя
$this->entityManager->remove($user);
$this->entityManager->flush();
}
/**
* Очищает сессии пользователя из Redis
*
* @param User $user
*/
private function cleanupUserSessions(User $user): void
{
if (!$this->sessionHandler) {
return;
}
try {
$redis = $this->getRedisClient();
if (!$redis) {
return;
}
// Ищем все сессии, которые могут принадлежать пользователю
// В Symfony сессии обычно хранятся с префиксом PHPSESSID или sess_
$patterns = ['PHPSESSID_*', 'sess_*', '*_symfony_*'];
$deletedSessions = 0;
foreach ($patterns as $pattern) {
$keys = $redis->keys($pattern);
if (!$keys) {
continue;
}
foreach ($keys as $key) {
try {
$sessionData = $redis->get($key);
if ($sessionData) {
// Пытаемся найти user_id в данных сессии
// Сессии Symfony обычно содержат сериализованные данные
if (strpos($sessionData, '_security_main') !== false ||
strpos($sessionData, (string)$user->getId()) !== false ||
strpos($sessionData, (string)$user->getUid()) !== false) {
$redis->del($key);
$deletedSessions++;
}
}
} catch (\Exception $e) {
// Игнорируем ошибки при проверке отдельных ключей
}
}
}
if ($deletedSessions > 0) {
$this->logger->info(sprintf(
'Удалено %d сессий из Redis для пользователя %d',
$deletedSessions,
$user->getId()
));
}
} catch (\Exception $e) {
$this->logger->warning(sprintf(
'Ошибка при очистке сессий пользователя %d из Redis: %s',
$user->getId(),
$e->getMessage()
));
}
}
/**
* Очищает старые сессии из Redis
*
* @param int $sessionLifetime Время жизни сессии в секундах
*/
private function cleanupOldRedisSessions(int $sessionLifetime): void
{
if (!$this->sessionHandler) {
return;
}
try {
$redis = $this->getRedisClient();
if (!$redis) {
return;
}
$patterns = ['PHPSESSID_*', 'sess_*', '*_symfony_*'];
$deletedCount = 0;
$now = time();
foreach ($patterns as $pattern) {
$keys = $redis->keys($pattern);
if (!$keys) {
continue;
}
foreach ($keys as $key) {
try {
$ttl = $redis->ttl($key);
// Если TTL отрицательный (ключ не имеет TTL) или больше времени жизни сессии, пропускаем
if ($ttl < 0 || $ttl > $sessionLifetime) {
continue;
}
// Проверяем, не истекла ли сессия
$lastAccess = $now - $ttl;
if ($lastAccess > $sessionLifetime) {
$redis->del($key);
$deletedCount++;
}
} catch (\Exception $e) {
// Игнорируем ошибки при проверке отдельных ключей
}
}
}
if ($deletedCount > 0) {
$this->logger->info(sprintf(
'Удалено %d старых сессий из Redis',
$deletedCount
));
}
} catch (\Exception $e) {
$this->logger->warning(sprintf(
'Ошибка при очистке старых сессий из Redis: %s',
$e->getMessage()
));
}
}
/**
* Получает Redis клиент из session handler
*
* @return \Redis|\Predis\Client|null
*/
private function getRedisClient()
{
if (!$this->sessionHandler) {
return null;
}
try {
// Пытаемся получить Redis клиент из различных типов session handlers
if ($this->sessionHandler instanceof \Redis) {
return $this->sessionHandler;
}
if ($this->sessionHandler instanceof \Predis\Client) {
return $this->sessionHandler;
}
// Для RedisSessionHandler из Symfony
if ($this->sessionHandler instanceof RedisSessionHandler) {
$reflection = new \ReflectionClass($this->sessionHandler);
if ($reflection->hasProperty('redis')) {
$property = $reflection->getProperty('redis');
$property->setAccessible(true);
return $property->getValue($this->sessionHandler);
}
}
// Пытаемся вызвать метод getRedis, если он существует
if (method_exists($this->sessionHandler, 'getRedis')) {
return $this->sessionHandler->getRedis();
}
return null;
} catch (\Exception $e) {
$this->logger->warning(sprintf(
'Не удалось получить Redis клиент: %s',
$e->getMessage()
));
return null;
}
}
/**
* Удаляет данные Calltouch, связанные с пользователем
*
* @param User $user
*/
private function deleteCalltouchData(User $user): void
{
try {
// Проверяем наличие связи с Calltouch через миграции
// Если есть таблица calltouch с внешним ключом на users.id
$connection = $this->entityManager->getConnection();
// Удаляем записи calltouch, связанные с пользователем
$connection->executeStatement(
'DELETE FROM calltouch WHERE user_id = :userId',
['userId' => $user->getId()]
);
} catch (\Exception $e) {
// Игнорируем ошибки, если таблица не существует или нет связи
$this->logger->warning(sprintf(
'Не удалось удалить данные Calltouch для пользователя %d: %s',
$user->getId(),
$e->getMessage()
));
}
}
}