From bc5468e5a078bc853d4f926d8465d44663f2d709 Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Thu, 14 May 2026 16:16:07 +0300 Subject: [PATCH 01/11] issues/27: update crud from admin api --- config/packages/nelmio_api_doc.yaml | 10 +- src/Controller/ArticleController.php | 129 ++----- src/Controller/DiseaseController.php | 80 ++-- src/Controller/MedicalCenterController.php | 62 ++- src/Controller/NewsController.php | 62 ++- src/Controller/PromoController.php | 62 ++- src/Controller/SiteServiceController.php | 81 ++-- src/Repository/ArticleRepository.php | 59 +-- src/Repository/ContentRepositoryFilter.php | 123 ++++++ src/Repository/DiseaseRepository.php | 13 + src/Repository/MedicalCenterRepository.php | 13 + src/Repository/NewsRepository.php | 17 + src/Repository/PromoRepository.php | 13 + src/Repository/SiteServiceRepository.php | 13 + src/Service/Crud/CrudResponder.php | 184 +++++++++ src/Service/DiseaseCrudService.php | 192 +--------- src/Service/MedicalCenterCrudService.php | 417 ++++++--------------- src/Service/NewsCrudService.php | 136 +------ src/Service/Pagination/Paginator.php | 65 ++++ src/Service/PromoCrudService.php | 134 +------ src/Service/SiteServiceCrudService.php | 346 +---------------- 21 files changed, 755 insertions(+), 1456 deletions(-) create mode 100644 src/Repository/ContentRepositoryFilter.php create mode 100644 src/Service/Crud/CrudResponder.php create mode 100644 src/Service/Pagination/Paginator.php diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index 26c60a8..af132f8 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -16,5 +16,11 @@ nelmio_api_doc: '^/specialist/list$', '^/specialist/schedule$', '^/pricelist/list$', - '^/pricelist/department$' - ] \ No newline at end of file + '^/pricelist/department$', + '^/news($|/)', + '^/promo($|/)', + '^/disease($|/)', + '^/medical-center($|/)', + '^/article($|/)', + '^/site-services($|/)' + ] diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index ef454fe..6b701bf 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -4,52 +4,42 @@ namespace App\Controller; use App\Entity\Article; use App\Repository\ArticleRepository; -use Doctrine\ORM\EntityManagerInterface; +use App\Service\Crud\CrudResponder; +use App\Service\Pagination\Paginator; +use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; -use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Validator\Validator\ValidatorInterface; -use Exception; #[Route('/article')] final class ArticleController extends AbstractController { - public function __construct( - private EntityManagerInterface $em, - private ValidatorInterface $validator, - private SerializerInterface $serializer - ) { } + private const READ_GROUPS = ['article:read']; + private const WRITE_GROUPS = ['article:write']; + public function __construct( + private readonly CrudResponder $crud, + private readonly Paginator $paginator, + ) { + } + + #[OA\Tag(name: 'Статьи')] + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] + #[OA\Parameter(name: 'alias', in: 'query', schema: new OA\Schema(type: 'string'))] + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] #[Route('/list', name: 'article_list', methods: ['GET'])] public function list(Request $request, ArticleRepository $repository): JsonResponse { - $page = max(1, (int) $request->query->get('page', 1)); - $limit = min(100, max(1, (int) $request->query->get('limit', 20))); + $qb = $repository->createFilteredQueryBuilder($request->query->all()); - $filters = [ - 'alias' => $request->query->get('alias', ''), - 'active' => $request->query->get('active', ''), - 'regionId' => $request->query->get('regionId', ''), - ]; - - $articles = $repository->findByFilters($filters, $page, $limit); - $total = $repository->countByFilters($filters); - $totalPages = (int) ceil($total / $limit); - - return $this->json([ - 'data' => $articles, - 'meta' => [ - 'total' => $total, - 'page' => $page, - 'limit' => $limit, - 'totalPages' => $totalPages, - ], - ], Response::HTTP_OK, [], [ - 'groups' => ['article:read'] + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ + 'groups' => self::READ_GROUPS, ]); } @@ -60,99 +50,34 @@ final class ArticleController extends AbstractController if (!$article) { throw $this->createNotFoundException('Статья не найдена'); } - return $this->json($article, Response::HTTP_OK, [], [ - 'groups' => ['article:read'] - ]); + + return $this->crud->read($article, self::READ_GROUPS); } #[Route('/{id}', name: 'article_show', methods: ['GET'], requirements: ['id' => '\d+'])] public function show(Article $article): JsonResponse { - return $this->json($article, Response::HTTP_OK, [], [ - 'groups' => ['article:read'] - ]); + return $this->crud->read($article, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/create', name: 'article_create', methods: ['POST'])] public function create(Request $request): JsonResponse { - try { - $article = $this->serializer->deserialize( - $request->getContent(), - Article::class, - 'json', - ['groups' => ['article:write']] - ); - - $errors = $this->validator->validate($article); - - if (count($errors) > 0) { - return $this->json($errors, Response::HTTP_BAD_REQUEST); - } - - $this->em->persist($article); - $this->em->flush(); - - return $this->json($article, Response::HTTP_CREATED, [], [ - 'groups' => ['article:read'] - ]); - } catch (Exception $e) { - return new JsonResponse([ - 'error' => 'Ошибка при создании статьи', - 'message' => $e->getMessage() - ], Response::HTTP_INTERNAL_SERVER_ERROR); - } + return $this->crud->create($request, Article::class, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'article_update', methods: ['PUT'], requirements: ['id' => '\d+'])] public function update(Request $request, Article $article): JsonResponse { - try { - $this->serializer->deserialize( - $request->getContent(), - Article::class, - 'json', - [ - 'groups' => ['article:write'], - 'object_to_populate' => $article - ] - ); - - $errors = $this->validator->validate($article); - - if (count($errors) > 0) { - return $this->json($errors, Response::HTTP_BAD_REQUEST); - } - - $this->em->flush(); - - return $this->json($article, Response::HTTP_OK, [], [ - 'groups' => ['article:read'] - ]); - } catch (Exception $e) { - return new JsonResponse([ - 'error' => 'Ошибка при обновлении статьи', - 'message' => $e->getMessage() - ], Response::HTTP_INTERNAL_SERVER_ERROR); - } + return $this->crud->update($request, $article, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'article_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] public function delete(Article $article): JsonResponse { - try { - $this->em->remove($article); - $this->em->flush(); - - return new JsonResponse(null, Response::HTTP_NO_CONTENT); - } catch (Exception $e) { - return new JsonResponse([ - 'error' => 'Ошибка при удалении статьи', - 'message' => $e->getMessage() - ], Response::HTTP_INTERNAL_SERVER_ERROR); - } + return $this->crud->delete($article); } } diff --git a/src/Controller/DiseaseController.php b/src/Controller/DiseaseController.php index 04b4ce0..43e10cb 100644 --- a/src/Controller/DiseaseController.php +++ b/src/Controller/DiseaseController.php @@ -3,7 +3,10 @@ namespace App\Controller; use App\Entity\Disease; -use App\Service\DiseaseCrudService; +use App\Repository\DiseaseRepository; +use App\Service\Crud\CrudResponder; +use App\Service\Pagination\Paginator; +use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -14,90 +17,55 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/disease')] final class DiseaseController extends AbstractController { + private const READ_GROUPS = ['disease:read']; + private const WRITE_GROUPS = ['disease:write']; + public function __construct( - private DiseaseCrudService $diseaseCrud, + private readonly CrudResponder $crud, + private readonly Paginator $paginator, ) { } + #[OA\Tag(name: 'Заболевания')] + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] #[Route('/list', name: 'disease_list', methods: ['GET'])] - public function list(Request $request): JsonResponse + public function list(Request $request, DiseaseRepository $repository): JsonResponse { - $page = $request->query->getInt('page', 1); - $perPage = min($request->query->getInt('perPage', 100), 500); - $regionId = $request->query->getInt('regionId', 0) ?: null; + $qb = $repository->createFilteredQueryBuilder($request->query->all()); - $result = $this->diseaseCrud->getPaginatedList($page, $perPage, $regionId); - $data = $result['data']; - $total = $result['total']; - $perPage = $result['per_page']; - $totalPages = (int) ceil($total / $perPage); - - return $this->json([ - 'data' => $data, - 'pagination' => [ - 'total' => $total, - 'count' => count($data), - 'per_page' => $perPage, - 'current_page' => $result['page'], - 'total_pages' => $totalPages, - 'has_previous_page' => $result['page'] > 1, - 'has_next_page' => $result['page'] < $totalPages, - ], - ], Response::HTTP_OK, [], [ - 'groups' => ['disease:read'], + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ + 'groups' => self::READ_GROUPS, ]); } #[Route('/{id}', name: 'disease_show', methods: ['GET'], requirements: ['id' => '\d+'])] public function show(Disease $disease): JsonResponse { - return $this->json($disease, Response::HTTP_OK, [], [ - 'groups' => ['disease:read'], - ]); + return $this->crud->read($disease, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/create', name: 'disease_create', methods: ['POST'])] public function create(Request $request): JsonResponse { - $data = json_decode($request->getContent(), true); - if (!is_array($data)) { - return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); - } - - try { - $disease = $this->diseaseCrud->create($data); - } catch (\InvalidArgumentException $e) { - return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); - } - - return $this->json($disease, Response::HTTP_CREATED, [], [ - 'groups' => ['disease:read'], - ]); + return $this->crud->create($request, Disease::class, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'disease_update', methods: ['PUT'], requirements: ['id' => '\d+'])] - public function update(Disease $disease, Request $request): JsonResponse + public function update(Request $request, Disease $disease): JsonResponse { - $data = json_decode($request->getContent(), true); - if (!is_array($data)) { - return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); - } - - $disease = $this->diseaseCrud->update($disease, $data); - - return $this->json($disease, Response::HTTP_OK, [], [ - 'groups' => ['disease:read'], - ]); + return $this->crud->update($request, $disease, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'disease_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] public function delete(Disease $disease): JsonResponse { - $this->diseaseCrud->delete($disease); - - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->crud->delete($disease); } } diff --git a/src/Controller/MedicalCenterController.php b/src/Controller/MedicalCenterController.php index 0492347..ad53c4e 100644 --- a/src/Controller/MedicalCenterController.php +++ b/src/Controller/MedicalCenterController.php @@ -3,7 +3,10 @@ namespace App\Controller; use App\Entity\MedicalCenter; -use App\Service\MedicalCenterCrudService; +use App\Repository\MedicalCenterRepository; +use App\Service\Crud\CrudResponder; +use App\Service\Pagination\Paginator; +use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -14,72 +17,55 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/medical-center')] final class MedicalCenterController extends AbstractController { + private const READ_GROUPS = ['medical_center:read']; + private const WRITE_GROUPS = ['medical_center:write']; + public function __construct( - private MedicalCenterCrudService $medicalCenterCrud, + private readonly CrudResponder $crud, + private readonly Paginator $paginator, ) { } + #[OA\Tag(name: 'Центры')] + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] #[Route('/list', name: 'medical_center_list', methods: ['GET'])] - public function list(Request $request): JsonResponse + public function list(Request $request, MedicalCenterRepository $repository): JsonResponse { - $regionId = $request->query->getInt('regionId', 0); - $activeParam = $request->query->get('active'); - $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - if ($activeParam !== null && $active === null) { - return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); - } + $qb = $repository->createFilteredQueryBuilder($request->query->all()); - return $this->json(['data' => $this->medicalCenterCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [ - 'groups' => ['medical_center:read'], + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ + 'groups' => self::READ_GROUPS, ]); } #[Route('/{id}', name: 'medical_center_show', methods: ['GET'], requirements: ['id' => '\d+'])] public function show(MedicalCenter $medicalCenter): JsonResponse { - return $this->json($medicalCenter, Response::HTTP_OK, [], [ - 'groups' => ['medical_center:read'], - ]); + return $this->crud->read($medicalCenter, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/create', name: 'medical_center_create', methods: ['POST'])] public function create(Request $request): JsonResponse { - $data = json_decode($request->getContent(), true); - if (!is_array($data)) { - return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); - } - - $medicalCenter = $this->medicalCenterCrud->create($data); - - return $this->json($medicalCenter, Response::HTTP_CREATED, [], [ - 'groups' => ['medical_center:read'], - ]); + return $this->crud->create($request, MedicalCenter::class, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'medical_center_update', methods: ['PUT'], requirements: ['id' => '\d+'])] - public function update(MedicalCenter $medicalCenter, Request $request): JsonResponse + public function update(Request $request, MedicalCenter $medicalCenter): JsonResponse { - $data = json_decode($request->getContent(), true); - if (!is_array($data)) { - return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); - } - - $medicalCenter = $this->medicalCenterCrud->update($medicalCenter, $data); - - return $this->json($medicalCenter, Response::HTTP_OK, [], [ - 'groups' => ['medical_center:read'], - ]); + return $this->crud->update($request, $medicalCenter, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'medical_center_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] public function delete(MedicalCenter $medicalCenter): JsonResponse { - $this->medicalCenterCrud->delete($medicalCenter); - - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->crud->delete($medicalCenter); } } diff --git a/src/Controller/NewsController.php b/src/Controller/NewsController.php index c0c8c4e..96f4534 100644 --- a/src/Controller/NewsController.php +++ b/src/Controller/NewsController.php @@ -3,7 +3,10 @@ namespace App\Controller; use App\Entity\News; -use App\Service\NewsCrudService; +use App\Repository\NewsRepository; +use App\Service\Crud\CrudResponder; +use App\Service\Pagination\Paginator; +use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -14,72 +17,55 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/news')] final class NewsController extends AbstractController { + private const READ_GROUPS = ['news:read']; + private const WRITE_GROUPS = ['news:write']; + public function __construct( - private NewsCrudService $newsCrud, + private readonly CrudResponder $crud, + private readonly Paginator $paginator, ) { } + #[OA\Tag(name: 'Новости')] + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] #[Route('/list', name: 'news_list', methods: ['GET'])] - public function list(Request $request): JsonResponse + public function list(Request $request, NewsRepository $repository): JsonResponse { - $regionId = $request->query->getInt('regionId', 0); - $activeParam = $request->query->get('active'); - $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - if ($activeParam !== null && $active === null) { - return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); - } + $qb = $repository->createFilteredQueryBuilder($request->query->all()); - return $this->json(['data' => $this->newsCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [ - 'groups' => ['news:read'], + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ + 'groups' => self::READ_GROUPS, ]); } #[Route('/{id}', name: 'news_show', methods: ['GET'], requirements: ['id' => '\d+'])] public function show(News $news): JsonResponse { - return $this->json($news, Response::HTTP_OK, [], [ - 'groups' => ['news:read'], - ]); + return $this->crud->read($news, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/create', name: 'news_create', methods: ['POST'])] public function create(Request $request): JsonResponse { - $data = json_decode($request->getContent(), true); - if (!is_array($data)) { - return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); - } - - $news = $this->newsCrud->create($data); - - return $this->json($news, Response::HTTP_CREATED, [], [ - 'groups' => ['news:read'], - ]); + return $this->crud->create($request, News::class, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\d+'])] - public function update(News $news, Request $request): JsonResponse + public function update(Request $request, News $news): JsonResponse { - $data = json_decode($request->getContent(), true); - if (!is_array($data)) { - return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); - } - - $news = $this->newsCrud->update($news, $data); - - return $this->json($news, Response::HTTP_OK, [], [ - 'groups' => ['news:read'], - ]); + return $this->crud->update($request, $news, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'news_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] public function delete(News $news): JsonResponse { - $this->newsCrud->delete($news); - - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->crud->delete($news); } } diff --git a/src/Controller/PromoController.php b/src/Controller/PromoController.php index dee5970..d2984e3 100644 --- a/src/Controller/PromoController.php +++ b/src/Controller/PromoController.php @@ -3,7 +3,10 @@ namespace App\Controller; use App\Entity\Promo; -use App\Service\PromoCrudService; +use App\Repository\PromoRepository; +use App\Service\Crud\CrudResponder; +use App\Service\Pagination\Paginator; +use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -14,72 +17,55 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/promo')] final class PromoController extends AbstractController { + private const READ_GROUPS = ['promo:read']; + private const WRITE_GROUPS = ['promo:write']; + public function __construct( - private PromoCrudService $promoCrud, + private readonly CrudResponder $crud, + private readonly Paginator $paginator, ) { } + #[OA\Tag(name: 'Акции')] + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] #[Route('/list', name: 'promo_list', methods: ['GET'])] - public function list(Request $request): JsonResponse + public function list(Request $request, PromoRepository $repository): JsonResponse { - $regionId = $request->query->getInt('regionId', 0); - $activeParam = $request->query->get('active'); - $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - if ($activeParam !== null && $active === null) { - return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); - } + $qb = $repository->createFilteredQueryBuilder($request->query->all()); - return $this->json(['data' => $this->promoCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [ - 'groups' => ['promo:read'], + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ + 'groups' => self::READ_GROUPS, ]); } #[Route('/{id}', name: 'promo_show', methods: ['GET'], requirements: ['id' => '\d+'])] public function show(Promo $promo): JsonResponse { - return $this->json($promo, Response::HTTP_OK, [], [ - 'groups' => ['promo:read'], - ]); + return $this->crud->read($promo, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/create', name: 'promo_create', methods: ['POST'])] public function create(Request $request): JsonResponse { - $data = json_decode($request->getContent(), true); - if (!is_array($data)) { - return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); - } - - $promo = $this->promoCrud->create($data); - - return $this->json($promo, Response::HTTP_CREATED, [], [ - 'groups' => ['promo:read'], - ]); + return $this->crud->create($request, Promo::class, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'promo_update', methods: ['PUT'], requirements: ['id' => '\d+'])] - public function update(Promo $promo, Request $request): JsonResponse + public function update(Request $request, Promo $promo): JsonResponse { - $data = json_decode($request->getContent(), true); - if (!is_array($data)) { - return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); - } - - $promo = $this->promoCrud->update($promo, $data); - - return $this->json($promo, Response::HTTP_OK, [], [ - 'groups' => ['promo:read'], - ]); + return $this->crud->update($request, $promo, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'promo_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] public function delete(Promo $promo): JsonResponse { - $this->promoCrud->delete($promo); - - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->crud->delete($promo); } } diff --git a/src/Controller/SiteServiceController.php b/src/Controller/SiteServiceController.php index f078bad..92c517d 100644 --- a/src/Controller/SiteServiceController.php +++ b/src/Controller/SiteServiceController.php @@ -3,7 +3,10 @@ namespace App\Controller; use App\Entity\SiteService; -use App\Service\SiteServiceCrudService; +use App\Repository\SiteServiceRepository; +use App\Service\Crud\CrudResponder; +use App\Service\Pagination\Paginator; +use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -14,91 +17,55 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/site-services')] final class SiteServiceController extends AbstractController { + private const READ_GROUPS = ['site_service:read']; + private const WRITE_GROUPS = ['site_service:write']; + public function __construct( - private SiteServiceCrudService $siteServiceCrud, + private readonly CrudResponder $crud, + private readonly Paginator $paginator, ) { } + #[OA\Tag(name: 'Услуги')] + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] #[Route('/list', name: 'site_service_list', methods: ['GET'])] - public function list(Request $request): JsonResponse + public function list(Request $request, SiteServiceRepository $repository): JsonResponse { - $page = $request->query->getInt('page', 1); - $perPage = min($request->query->getInt('perPage', 50), 500); - $regionId = $request->query->getInt('regionId', 0) ?: null; - $activeParam = $request->query->get('active'); - $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - if ($activeParam !== null && $active === null) { - return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); - } + $qb = $repository->createFilteredQueryBuilder($request->query->all()); - $result = $this->siteServiceCrud->getPaginatedList($page, $perPage, $regionId, $active); - $data = $result['data']; - $total = $result['total']; - $perPage = $result['per_page']; - $totalPages = (int) ceil($total / $perPage); - - return $this->json([ - 'data' => $data, - 'pagination' => [ - 'total' => $total, - 'count' => count($data), - 'per_page' => $perPage, - 'current_page' => $result['page'], - 'total_pages' => $totalPages, - 'has_previous_page' => $result['page'] > 1, - 'has_next_page' => $result['page'] < $totalPages, - ], - ], Response::HTTP_OK, [], [ - 'groups' => ['site_service:read'], + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ + 'groups' => self::READ_GROUPS, ]); } #[Route('/{id}', name: 'site_service_show', methods: ['GET'], requirements: ['id' => '\d+'])] public function show(SiteService $siteService): JsonResponse { - return $this->json($siteService, Response::HTTP_OK, [], [ - 'groups' => ['site_service:read'], - ]); + return $this->crud->read($siteService, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/create', name: 'site_service_create', methods: ['POST'])] public function create(Request $request): JsonResponse { - $data = json_decode($request->getContent(), true); - if (!is_array($data)) { - return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); - } - - $siteService = $this->siteServiceCrud->create($data); - - return $this->json($siteService, Response::HTTP_CREATED, [], [ - 'groups' => ['site_service:read'], - ]); + return $this->crud->create($request, SiteService::class, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'site_service_update', methods: ['PUT'], requirements: ['id' => '\d+'])] - public function update(SiteService $siteService, Request $request): JsonResponse + public function update(Request $request, SiteService $siteService): JsonResponse { - $data = json_decode($request->getContent(), true); - if (!is_array($data)) { - return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); - } - - $siteService = $this->siteServiceCrud->update($siteService, $data); - - return $this->json($siteService, Response::HTTP_OK, [], [ - 'groups' => ['site_service:read'], - ]); + return $this->crud->update($request, $siteService, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'site_service_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] public function delete(SiteService $siteService): JsonResponse { - $this->siteServiceCrud->delete($siteService); - - return new JsonResponse(null, Response::HTTP_NO_CONTENT); + return $this->crud->delete($siteService); } } diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index 697d852..7a63323 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; use App\Entity\Article; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -16,58 +17,28 @@ class ArticleRepository extends ServiceEntityRepository parent::__construct($registry, Article::class); } - public function findByFilters(array $filters, int $page = 1, int $limit = 20): array + /** + * @param array $filters + */ + public function createFilteredQueryBuilder(array $filters): QueryBuilder { - $qb = $this->createQueryBuilder('a'); + $qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC'); - if (isset($filters['alias']) && $filters['alias'] !== '') { - $qb->andWhere('a.alias = :alias') - ->setParameter('alias', $filters['alias']); - } - if (isset($filters['active']) && $filters['active'] !== '') { - $qb->andWhere('a.active = :active') - ->setParameter('active', filter_var($filters['active'], FILTER_VALIDATE_BOOLEAN)); - } - if (isset($filters['regionId']) && $filters['regionId'] !== '') { - $qb->andWhere('a.regionId = :regionId') - ->setParameter('regionId', (int) $filters['regionId']); - } + ContentRepositoryFilter::applyCommon($qb, 'a', $filters); - $qb->orderBy('a.id', 'DESC'); - - $qb->setFirstResult(($page - 1) * $limit) - ->setMaxResults($limit); - - return $qb->getQuery()->getResult(); - } - - public function countByFilters(array $filters): int - { - $qb = $this->createQueryBuilder('a') - ->select('COUNT(a.id)'); - - if (isset($filters['alias']) && $filters['alias'] !== '') { - $qb->andWhere('a.alias = :alias') - ->setParameter('alias', $filters['alias']); - } - if (isset($filters['active']) && $filters['active'] !== '') { - $qb->andWhere('a.active = :active') - ->setParameter('active', filter_var($filters['active'], FILTER_VALIDATE_BOOLEAN)); - } - if (isset($filters['regionId']) && $filters['regionId'] !== '') { - $qb->andWhere('a.regionId = :regionId') - ->setParameter('regionId', (int) $filters['regionId']); - } - - return (int) $qb->getQuery()->getSingleScalarResult(); + return $qb; } + /** + * Поиск статьи по alias с учётом возможных вариантов написания (исторический функционал). + */ public function findOneByAlias(string $alias): ?Article { $alias = trim($alias); if ($alias === '') { return null; } + $variants = [ $alias, $alias . '-', @@ -79,16 +50,18 @@ class ArticleRepository extends ServiceEntityRepository return $article; } } - // Поиск по TRIM(alias) в БД (нативный SQL для совместимости с PostgreSQL) + + // Фолбэк по TRIM(alias) в БД для совместимости со старыми данными. $conn = $this->getEntityManager()->getConnection(); $id = $conn->fetchOne( 'SELECT id FROM article WHERE TRIM(alias) = :alias LIMIT 1', ['alias' => $alias], - ['alias' => \PDO::PARAM_STR] + ['alias' => \PDO::PARAM_STR], ); if ($id !== false) { return $this->find($id); } + return null; } } diff --git a/src/Repository/ContentRepositoryFilter.php b/src/Repository/ContentRepositoryFilter.php new file mode 100644 index 0000000..d4235d8 --- /dev/null +++ b/src/Repository/ContentRepositoryFilter.php @@ -0,0 +1,123 @@ + 0 + * - active bool (по умолчанию true, если не передан) + * - alias строка (строгое равенство) + * - search LIKE по name (case-insensitive) + * + * Параметры пагинации (page/perPage) обрабатываются Paginator'ом и здесь игнорируются. + */ +final class ContentRepositoryFilter +{ + /** + * @param array $filters + */ + public static function applyCommon(QueryBuilder $qb, string $alias, array $filters): void + { + $regionId = self::extractInt($filters, ['regionId', 'region_id']); + if ($regionId !== null && $regionId > 0) { + $qb->andWhere("$alias.regionId = :regionId") + ->setParameter('regionId', $regionId); + } + + $active = self::extractBool($filters, ['active']); + if ($active !== null) { + $qb->andWhere("$alias.active = :active") + ->setParameter('active', $active); + } + + $aliasFilter = self::extractNonEmptyString($filters, ['alias']); + if ($aliasFilter !== null) { + $qb->andWhere("$alias.alias = :aliasValue") + ->setParameter('aliasValue', $aliasFilter); + } + + $search = self::extractNonEmptyString($filters, ['search', 'q']); + if ($search !== null) { + $qb->andWhere("LOWER($alias.name) LIKE :search") + ->setParameter('search', '%' . mb_strtolower($search) . '%'); + } + } + + /** + * @param array $filters + * @param list $keys + */ + private static function extractInt(array $filters, array $keys): ?int + { + foreach ($keys as $key) { + if (!array_key_exists($key, $filters)) { + continue; + } + $value = $filters[$key]; + if ($value === null || $value === '') { + continue; + } + if (is_numeric($value)) { + return (int) $value; + } + } + + return null; + } + + /** + * @param array $filters + * @param list $keys + */ + private static function extractBool(array $filters, array $keys): ?bool + { + foreach ($keys as $key) { + if (!array_key_exists($key, $filters)) { + continue; + } + $value = $filters[$key]; + if ($value === null || $value === '') { + continue; + } + if (is_bool($value)) { + return $value; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + + return null; + } + + /** + * @param array $filters + * @param list $keys + */ + private static function extractNonEmptyString(array $filters, array $keys): ?string + { + foreach ($keys as $key) { + if (!array_key_exists($key, $filters)) { + continue; + } + $value = $filters[$key]; + if (!is_string($value)) { + continue; + } + $trimmed = trim($value); + if ($trimmed !== '') { + return $trimmed; + } + } + + return null; + } +} diff --git a/src/Repository/DiseaseRepository.php b/src/Repository/DiseaseRepository.php index 24f4994..5e9eca7 100644 --- a/src/Repository/DiseaseRepository.php +++ b/src/Repository/DiseaseRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; use App\Entity\Disease; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -18,4 +19,16 @@ class DiseaseRepository extends ServiceEntityRepository { parent::__construct($registry, Disease::class); } + + /** + * @param array $filters + */ + public function createFilteredQueryBuilder(array $filters): QueryBuilder + { + $qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC'); + + ContentRepositoryFilter::applyCommon($qb, 'd', $filters); + + return $qb; + } } diff --git a/src/Repository/MedicalCenterRepository.php b/src/Repository/MedicalCenterRepository.php index 7088a7c..2b14f2a 100644 --- a/src/Repository/MedicalCenterRepository.php +++ b/src/Repository/MedicalCenterRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; use App\Entity\MedicalCenter; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -18,4 +19,16 @@ class MedicalCenterRepository extends ServiceEntityRepository { parent::__construct($registry, MedicalCenter::class); } + + /** + * @param array $filters + */ + public function createFilteredQueryBuilder(array $filters): QueryBuilder + { + $qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC'); + + ContentRepositoryFilter::applyCommon($qb, 'm', $filters); + + return $qb; + } } diff --git a/src/Repository/NewsRepository.php b/src/Repository/NewsRepository.php index 9607b31..7703f0d 100644 --- a/src/Repository/NewsRepository.php +++ b/src/Repository/NewsRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; use App\Entity\News; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -18,4 +19,20 @@ class NewsRepository extends ServiceEntityRepository { parent::__construct($registry, News::class); } + + /** + * Готовит QueryBuilder под пагинацию (Pagerfanta\QueryAdapter). + * + * Поддерживаемые фильтры: regionId, active (по умолчанию true), alias, search. + * + * @param array $filters + */ + public function createFilteredQueryBuilder(array $filters): QueryBuilder + { + $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC'); + + ContentRepositoryFilter::applyCommon($qb, 'n', $filters); + + return $qb; + } } diff --git a/src/Repository/PromoRepository.php b/src/Repository/PromoRepository.php index 5a5c4c6..35e0b1e 100644 --- a/src/Repository/PromoRepository.php +++ b/src/Repository/PromoRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; use App\Entity\Promo; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -18,4 +19,16 @@ class PromoRepository extends ServiceEntityRepository { parent::__construct($registry, Promo::class); } + + /** + * @param array $filters + */ + public function createFilteredQueryBuilder(array $filters): QueryBuilder + { + $qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC'); + + ContentRepositoryFilter::applyCommon($qb, 'p', $filters); + + return $qb; + } } diff --git a/src/Repository/SiteServiceRepository.php b/src/Repository/SiteServiceRepository.php index 1a07399..c44abbd 100644 --- a/src/Repository/SiteServiceRepository.php +++ b/src/Repository/SiteServiceRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; use App\Entity\SiteService; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -18,4 +19,16 @@ class SiteServiceRepository extends ServiceEntityRepository { parent::__construct($registry, SiteService::class); } + + /** + * @param array $filters + */ + public function createFilteredQueryBuilder(array $filters): QueryBuilder + { + $qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC'); + + ContentRepositoryFilter::applyCommon($qb, 's', $filters); + + return $qb; + } } diff --git a/src/Service/Crud/CrudResponder.php b/src/Service/Crud/CrudResponder.php new file mode 100644 index 0000000..4e40251 --- /dev/null +++ b/src/Service/Crud/CrudResponder.php @@ -0,0 +1,184 @@ +crud->create($request, News::class, ['news:write'], ['news:read']); + */ +final class CrudResponder +{ + public function __construct( + private EntityManagerInterface $em, + private SerializerInterface $serializer, + private ValidatorInterface $validator, + ) { + } + + /** + * @param list $readGroups + */ + public function read(object $entity, array $readGroups): JsonResponse + { + return $this->json($entity, Response::HTTP_OK, $readGroups); + } + + /** + * @template T of object + * + * @param class-string $entityClass + * @param list $writeGroups + * @param list $readGroups + */ + public function create( + Request $request, + string $entityClass, + array $writeGroups, + array $readGroups, + bool $allowIdFromPayload = true, + ): JsonResponse { + $payload = $this->decodePayload($request); + if ($payload === null) { + return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST); + } + + try { + /** @var T $entity */ + $entity = $this->serializer->deserialize( + $request->getContent(), + $entityClass, + 'json', + [ + AbstractNormalizer::GROUPS => $writeGroups, + ], + ); + } catch (SerializerExceptionInterface $e) { + return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); + } + + // Id в группе :write не присутствует (чтобы запретить инъекцию при update). + // На create поддерживаем явный id из payload, потому что у контент-сущностей + // нет GeneratedValue (id приходит из Bitrix-view). + if ($allowIdFromPayload && isset($payload['id']) && method_exists($entity, 'setId')) { + $id = (int) $payload['id']; + if ($id > 0) { + $entity->setId($id); + } + } + + 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 $writeGroups + * @param list $readGroups + */ + public function update( + Request $request, + object $entity, + array $writeGroups, + array $readGroups, + ): JsonResponse { + if ($this->decodePayload($request) === null) { + return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST); + } + + try { + $this->serializer->deserialize( + $request->getContent(), + $entity::class, + 'json', + [ + 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 + { + $this->em->remove($entity); + $this->em->flush(); + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } + + /** + * @return array|null null если тело не является JSON-объектом + */ + private function decodePayload(Request $request): ?array + { + $data = json_decode($request->getContent(), true); + + return is_array($data) ? $data : null; + } + + private function validate(object $entity): ?JsonResponse + { + $errors = $this->validator->validate($entity); + if (count($errors) === 0) { + return null; + } + + $formatted = []; + foreach ($errors as $error) { + $formatted[] = [ + 'property' => $error->getPropertyPath(), + 'message' => $error->getMessage(), + ]; + } + + return new JsonResponse(['errors' => $formatted], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + /** + * @param list $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); + } +} diff --git a/src/Service/DiseaseCrudService.php b/src/Service/DiseaseCrudService.php index 01a0de1..ff42797 100644 --- a/src/Service/DiseaseCrudService.php +++ b/src/Service/DiseaseCrudService.php @@ -2,206 +2,26 @@ namespace App\Service; -use App\Entity\Disease; -use App\Repository\DiseaseRepository; use Doctrine\ORM\EntityManagerInterface; +/** + * Импорт заболеваний из материализованного представления (Bitrix view). + * + * См. DiseaseController + CrudResponder для CRUD; этот сервис — только syncFromView*. + */ final class DiseaseCrudService { public function __construct( private EntityManagerInterface $em, - private DiseaseRepository $diseaseRepository, ) { } - /** - * @return array{data: Disease[], total: int, page: int, per_page: int} - */ - public function getPaginatedList(int $page, int $perPage, ?int $regionId = null): array - { - $page = max(1, $page); - $perPage = min(max(1, $perPage), 500); - - $qb = $this->diseaseRepository->createQueryBuilder('d') - ->orderBy('d.id', 'ASC'); - - if ($regionId !== null) { - $qb->andWhere('d.regionId = :regionId') - ->setParameter('regionId', $regionId); - } - - $countQb = $this->diseaseRepository->createQueryBuilder('d') - ->select('COUNT(d.id)'); - if ($regionId !== null) { - $countQb->andWhere('d.regionId = :regionId') - ->setParameter('regionId', $regionId); - } - $total = (int) $countQb->getQuery()->getSingleScalarResult(); - - $qb->setFirstResult(($page - 1) * $perPage) - ->setMaxResults($perPage); - - $data = $qb->getQuery()->getResult(); - - return [ - 'data' => $data, - 'total' => $total, - 'page' => $page, - 'per_page' => $perPage, - ]; - } - - public function getShow(int $id): ?Disease - { - return $this->diseaseRepository->find($id); - } - - public function create(array $data): Disease - { - if (!array_key_exists('id', $data) || $data['id'] === null || $data['id'] === '') { - throw new \InvalidArgumentException('Поле id обязательно.'); - } - - $disease = new Disease(); - $this->updateEntity($disease, $data); - - $this->em->persist($disease); - $this->em->flush(); - - return $disease; - } - - public function update(Disease $disease, array $data): Disease - { - unset($data['id']); - $this->updateEntity($disease, $data); - - $this->em->flush(); - - return $disease; - } - - public function delete(Disease $disease): void - { - $this->em->remove($disease); - $this->em->flush(); - } - - private function updateEntity(Disease $disease, array $data): void - { - if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') { - $disease->setId((int) $data['id']); - } - - if (array_key_exists('name', $data)) { - $disease->setName($data['name']); - } - - if (array_key_exists('previewPicture', $data) || array_key_exists('preview_picture', $data)) { - $disease->setPreviewPicture($data['previewPicture'] ?? $data['preview_picture']); - } - - if (array_key_exists('active', $data)) { - $disease->setActive($data['active']); - } - - if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { - $v = $data['regionId'] ?? $data['region_id']; - $disease->setRegionId($v === null || $v === '' ? null : (int) $v); - } - - if (array_key_exists('alias', $data)) { - $disease->setAlias($data['alias']); - } - - if (array_key_exists('anons', $data)) { - $disease->setAnons($data['anons']); - } - - if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { - $raw = $data['updateAt'] ?? $data['update_at']; - if ($raw === null || $raw === '') { - $disease->setUpdateAt(null); - } elseif ($raw instanceof \DateTimeInterface) { - $disease->setUpdateAt($raw); - } elseif (is_string($raw)) { - $disease->setUpdateAt(new \DateTimeImmutable($raw)); - } - } - - if (array_key_exists('hidePicture', $data) || array_key_exists('hide_picture', $data)) { - $disease->setHidePicture($data['hidePicture'] ?? $data['hide_picture']); - } - - if (array_key_exists('readTime', $data) || array_key_exists('read_time', $data)) { - $disease->setReadTime($data['readTime'] ?? $data['read_time']); - } - - if (array_key_exists('diseasesName', $data) || array_key_exists('diseases_name', $data)) { - $disease->setDiseasesName($data['diseasesName'] ?? $data['diseases_name']); - } - - if (array_key_exists('tagsImportant', $data) || array_key_exists('tags_important', $data)) { - $disease->setTagsImportant($data['tagsImportant'] ?? $data['tags_important']); - } - - if (array_key_exists('tags', $data)) { - $disease->setTags($data['tags']); - } - - if (array_key_exists('diseasesOtherName', $data) || array_key_exists('diseases_other_name', $data)) { - $disease->setDiseasesOtherName($data['diseasesOtherName'] ?? $data['diseases_other_name']); - } - - if (array_key_exists('symptom', $data)) { - $disease->setSymptom($data['symptom']); - } - - if (array_key_exists('staff', $data)) { - $disease->setStaff($data['staff']); - } - - if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) { - $disease->setLinkServices($data['linkServices'] ?? $data['link_services']); - } - - if (array_key_exists('staffList', $data) || array_key_exists('staff_list', $data)) { - $disease->setStaffList($data['staffList'] ?? $data['staff_list']); - } - - if (array_key_exists('staffPost', $data) || array_key_exists('staff_post', $data)) { - $disease->setStaffPost($data['staffPost'] ?? $data['staff_post']); - } - - if (array_key_exists('staffPostExclude', $data) || array_key_exists('staff_post_exclude', $data)) { - $disease->setStaffPostExclude($data['staffPostExclude'] ?? $data['staff_post_exclude']); - } - - if (array_key_exists('linkFaq', $data) || array_key_exists('link_faq', $data)) { - $disease->setLinkFaq($data['linkFaq'] ?? $data['link_faq']); - } - - if (array_key_exists('bibliography', $data)) { - $disease->setBibliography($data['bibliography']); - } - - if (array_key_exists('staffCheck', $data) || array_key_exists('staff_check', $data)) { - $disease->setStaffCheck($data['staffCheck'] ?? $data['staff_check']); - } - - if (array_key_exists('content', $data)) { - $disease->setContent($data['content']); - } - } - public function syncFromViewDisease(string $viewName = 'public.view_disease'): int { if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { throw new \InvalidArgumentException('Invalid view name'); } - $connection = $this->em->getConnection(); - $sql = sprintf( 'INSERT INTO disease ( id, @@ -282,6 +102,6 @@ final class DiseaseCrudService $viewName ); - return (int) $connection->executeStatement($sql); + return (int) $this->em->getConnection()->executeStatement($sql); } } diff --git a/src/Service/MedicalCenterCrudService.php b/src/Service/MedicalCenterCrudService.php index f5d6ed9..1dc1be7 100644 --- a/src/Service/MedicalCenterCrudService.php +++ b/src/Service/MedicalCenterCrudService.php @@ -2,312 +2,127 @@ namespace App\Service; -use App\Entity\MedicalCenter; -use App\Repository\MedicalCenterRepository; use Doctrine\ORM\EntityManagerInterface; +/** + * Импорт центров из материализованного представления (Bitrix view). + * + * См. MedicalCenterController + CrudResponder для CRUD; этот сервис — только syncFromView*. + */ final class MedicalCenterCrudService { - public function __construct( - private EntityManagerInterface $em, - private MedicalCenterRepository $medicalCenterRepository - ) { - } + public function __construct( + private EntityManagerInterface $em, + ) { + } - /** - * @return MedicalCenter[] - */ - public function getList(?int $regionId = null, ?bool $active = true): array - { - $criteria = []; - if ($regionId !== null) { - $criteria['regionId'] = $regionId; - } - if ($active !== null) { - $criteria['active'] = $active; - } + public function syncFromViewCenters(string $viewName = 'public.view_centers'): int + { + if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { + throw new \InvalidArgumentException('Invalid view name'); + } - return $this->medicalCenterRepository->findBy($criteria, ['id' => 'ASC']); - } + $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 + ); - public function getShow(int $id): ?MedicalCenter - { - return $this->medicalCenterRepository->find($id); - } - - public function create(array $data): MedicalCenter - { - $medicalCenter = new MedicalCenter(); - $this->updateEntity($medicalCenter, $data); - - $this->em->persist($medicalCenter); - $this->em->flush(); - - return $medicalCenter; - } - - public function update(MedicalCenter $medicalCenter, array $data): MedicalCenter - { - unset($data['id']); - $this->updateEntity($medicalCenter, $data); - - $this->em->flush(); - return $medicalCenter; - } - - public function delete(MedicalCenter $medicalCenter): void - { - $this->em->remove($medicalCenter); - $this->em->flush(); - } - - private function updateEntity(MedicalCenter $medicalCenter, array $data): void - { - if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') { - $medicalCenter->setId((int) $data['id']); - } - - if (array_key_exists('name', $data)) { - $medicalCenter->setName($data['name']); - } - - if (array_key_exists('active', $data)) { - $medicalCenter->setActive($data['active']); - } - - if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { - $v = $data['regionId'] ?? $data['region_id']; - $medicalCenter->setRegionId($v === null || $v === '' ? null : (int) $v); - } - - if (array_key_exists('alias', $data)) { - $medicalCenter->setAlias($data['alias']); - } - - if (array_key_exists('anons', $data)) { - $medicalCenter->setAnons($data['anons']); - } - - if (array_key_exists('content', $data)) { - $medicalCenter->setContent($data['content']); - } - - if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { - $raw = $data['updateAt'] ?? $data['update_at']; - if ($raw === null || $raw === '') { - $medicalCenter->setUpdateAt(null); - } elseif ($raw instanceof \DateTimeInterface) { - $medicalCenter->setUpdateAt($raw); - } elseif (is_string($raw)) { - $medicalCenter->setUpdateAt(new \DateTimeImmutable($raw)); - } - } - - if (array_key_exists('kodUslug', $data) || array_key_exists('kod_uslug', $data)) { - $medicalCenter->setKodUslug($data['kodUslug'] ?? $data['kod_uslug']); - } - - if (array_key_exists('doctors', $data)) { - $medicalCenter->setDoctors($data['doctors']); - } - - if (array_key_exists('services', $data)) { - $medicalCenter->setServices($data['services']); - } - - if (array_key_exists('articles', $data)) { - $medicalCenter->setArticles($data['articles']); - } - - if (array_key_exists('txtUp', $data) || array_key_exists('txt_up', $data)) { - $medicalCenter->setTxtUp($data['txtUp'] ?? $data['txt_up']); - } - - if (array_key_exists('mainLinkStaff', $data) || array_key_exists('main_link_staff', $data)) { - $medicalCenter->setMainLinkStaff($data['mainLinkStaff'] ?? $data['main_link_staff']); - } - - if (array_key_exists('contraindications', $data)) { - $medicalCenter->setContraindications($data['contraindications']); - } - - if (array_key_exists('hidePicture', $data) || array_key_exists('hide_picture', $data)) { - $v = $data['hidePicture'] ?? $data['hide_picture']; - $medicalCenter->setHidePicture($v === null || $v === '' ? null : (int) $v); - } - - if (array_key_exists('indications', $data)) { - $medicalCenter->setIndications($data['indications']); - } - - if (array_key_exists('linkSale', $data) || array_key_exists('link_sale', $data)) { - $medicalCenter->setLinkSale($data['linkSale'] ?? $data['link_sale']); - } - - if (array_key_exists('plusList', $data) || array_key_exists('plus_list', $data)) { - $medicalCenter->setPlusList($data['plusList'] ?? $data['plus_list']); - } - - if (array_key_exists('plusText', $data) || array_key_exists('plus_text', $data)) { - $medicalCenter->setPlusText($data['plusText'] ?? $data['plus_text']); - } - - if (array_key_exists('plusTitle', $data) || array_key_exists('plus_title', $data)) { - $medicalCenter->setPlusTitle($data['plusTitle'] ?? $data['plus_title']); - } - - if (array_key_exists('processText', $data) || array_key_exists('process_text', $data)) { - $medicalCenter->setProcessText($data['processText'] ?? $data['process_text']); - } - - if (array_key_exists('processTitle', $data) || array_key_exists('process_title', $data)) { - $medicalCenter->setProcessTitle($data['processTitle'] ?? $data['process_title']); - } - - if (array_key_exists('servicesList', $data) || array_key_exists('services_list', $data)) { - $medicalCenter->setServicesList($data['servicesList'] ?? $data['services_list']); - } - - if (array_key_exists('servicesPhotos', $data) || array_key_exists('services_photos', $data)) { - $medicalCenter->setServicesPhotos($data['servicesPhotos'] ?? $data['services_photos']); - } - - if (array_key_exists('servicesTitle', $data) || array_key_exists('services_title', $data)) { - $medicalCenter->setServicesTitle($data['servicesTitle'] ?? $data['services_title']); - } - - if (array_key_exists('sortStaff', $data) || array_key_exists('sort_staff', $data)) { - $medicalCenter->setSortStaff($data['sortStaff'] ?? $data['sort_staff']); - } - - if (array_key_exists('trainingText', $data) || array_key_exists('training_text', $data)) { - $medicalCenter->setTrainingText($data['trainingText'] ?? $data['training_text']); - } - - if (array_key_exists('trainingTextTitle', $data) || array_key_exists('training_text_title', $data)) { - $medicalCenter->setTrainingTextTitle($data['trainingTextTitle'] ?? $data['training_text_title']); - } - - if (array_key_exists('whyText', $data) || array_key_exists('why_text', $data)) { - $medicalCenter->setWhyText($data['whyText'] ?? $data['why_text']); - } - - if (array_key_exists('whyTitle', $data) || array_key_exists('why_title', $data)) { - $medicalCenter->setWhyTitle($data['whyTitle'] ?? $data['why_title']); - } - } - - public function syncFromViewCenters(string $viewName = 'public.view_centers'): int - { - // В опции разрешаем только идентификаторы (буквы/цифры/underscore) и точку для схемы. - if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { - throw new \InvalidArgumentException('Invalid view name'); - } - - $connection = $this->em->getConnection(); - - $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) $connection->executeStatement($sql); - } + return (int) $this->em->getConnection()->executeStatement($sql); + } } - diff --git a/src/Service/NewsCrudService.php b/src/Service/NewsCrudService.php index 988f1ed..c5a7fd0 100644 --- a/src/Service/NewsCrudService.php +++ b/src/Service/NewsCrudService.php @@ -2,148 +2,28 @@ namespace App\Service; -use App\Entity\News; -use App\Repository\NewsRepository; 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, - private NewsRepository $newsRepository ) { } - /** - * @return News[] - */ - public function getList(?int $regionId = null, ?bool $active = true): array - { - $criteria = []; - if ($regionId !== null) { - $criteria['regionId'] = $regionId; - } - if ($active !== null) { - $criteria['active'] = $active; - } - - return $this->newsRepository->findBy($criteria, ['id' => 'ASC']); - } - - public function getShow(int $id): ?News - { - return $this->newsRepository->find($id); - } - - public function create(array $data): News - { - $news = new News(); - $this->updateEntity($news, $data); - - $this->em->persist($news); - $this->em->flush(); - - return $news; - } - - public function update(News $news, array $data): News - { - unset($data['id']); - $this->updateEntity($news, $data); - - $this->em->flush(); - return $news; - } - - public function delete(News $news): void - { - $this->em->remove($news); - $this->em->flush(); - } - - private function updateEntity(News $news, array $data): void - { - if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') { - $news->setId((int) $data['id']); - } - - if (array_key_exists('name', $data)) { - $news->setName($data['name']); - } - - if (array_key_exists('active', $data)) { - $news->setActive($data['active']); - } - - if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { - $v = $data['regionId'] ?? $data['region_id']; - $news->setRegionId($v === null || $v === '' ? null : (int) $v); - } - - if (array_key_exists('alias', $data)) { - $news->setAlias($data['alias']); - } - - if (array_key_exists('anons', $data)) { - $news->setAnons($data['anons']); - } - - if (array_key_exists('content', $data)) { - $news->setContent($data['content']); - } - - if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { - $raw = $data['updateAt'] ?? $data['update_at']; - if ($raw === null || $raw === '') { - $news->setUpdateAt(null); - } elseif ($raw instanceof \DateTimeInterface) { - $news->setUpdateAt($raw); - } elseif (is_string($raw)) { - $news->setUpdateAt(new \DateTimeImmutable($raw)); - } - } - - if (array_key_exists('linkElPrice', $data) || array_key_exists('link_el_price', $data)) { - $news->setLinkElPrice($data['linkElPrice'] ?? $data['link_el_price']); - } - - if (array_key_exists('shortName', $data) || array_key_exists('short_name', $data)) { - $news->setShortName($data['shortName'] ?? $data['short_name']); - } - - if (array_key_exists('timer', $data)) { - $news->setTimer($data['timer']); - } - - if (array_key_exists('timerBg', $data) || array_key_exists('timer_bg', $data)) { - $news->setTimerBg($data['timerBg'] ?? $data['timer_bg']); - } - - if (array_key_exists('formOrder', $data) || array_key_exists('form_order', $data)) { - $news->setFormOrder($data['formOrder'] ?? $data['form_order']); - } - - if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) { - $news->setLinkServices($data['linkServices'] ?? $data['link_services']); - } - - if (array_key_exists('linkStaff', $data) || array_key_exists('link_staff', $data)) { - $news->setLinkStaff($data['linkStaff'] ?? $data['link_staff']); - } - - if (array_key_exists('photos', $data)) { - $news->setPhotos($data['photos']); - } - } - public function syncFromViewNews(string $viewName = 'public.view_news'): int { if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { throw new \InvalidArgumentException('Invalid view name'); } - $connection = $this->em->getConnection(); - $sql = sprintf( 'INSERT INTO news ( id, @@ -200,6 +80,6 @@ final class NewsCrudService $viewName ); - return (int) $connection->executeStatement($sql); + return (int) $this->em->getConnection()->executeStatement($sql); } } diff --git a/src/Service/Pagination/Paginator.php b/src/Service/Pagination/Paginator.php new file mode 100644 index 0000000..527908a --- /dev/null +++ b/src/Service/Pagination/Paginator.php @@ -0,0 +1,65 @@ + [...], 'pagination' => [...]] в едином формате для всех контроллеров. + */ +final class Paginator +{ + public const DEFAULT_PER_PAGE = 50; + public const MAX_PER_PAGE = 500; + + /** + * @return array{data: list, pagination: array} + */ + 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(), + ], + ]; + } +} diff --git a/src/Service/PromoCrudService.php b/src/Service/PromoCrudService.php index e0f2fab..c6b21b6 100644 --- a/src/Service/PromoCrudService.php +++ b/src/Service/PromoCrudService.php @@ -2,148 +2,26 @@ namespace App\Service; -use App\Entity\Promo; -use App\Repository\PromoRepository; use Doctrine\ORM\EntityManagerInterface; +/** + * Импорт акций из материализованного представления (Bitrix view). + * + * См. PromoController + CrudResponder для CRUD; этот сервис — только syncFromView*. + */ final class PromoCrudService { public function __construct( private EntityManagerInterface $em, - private PromoRepository $promoRepository ) { } - /** - * @return Promo[] - */ - public function getList(?int $regionId = null, ?bool $active = true): array - { - $criteria = []; - if ($regionId !== null) { - $criteria['regionId'] = $regionId; - } - if ($active !== null) { - $criteria['active'] = $active; - } - - return $this->promoRepository->findBy($criteria, ['id' => 'ASC']); - } - - public function getShow(int $id): ?Promo - { - return $this->promoRepository->find($id); - } - - public function create(array $data): Promo - { - $promo = new Promo(); - $this->updateEntity($promo, $data); - - $this->em->persist($promo); - $this->em->flush(); - - return $promo; - } - - public function update(Promo $promo, array $data): Promo - { - unset($data['id']); - $this->updateEntity($promo, $data); - - $this->em->flush(); - return $promo; - } - - public function delete(Promo $promo): void - { - $this->em->remove($promo); - $this->em->flush(); - } - - private function updateEntity(Promo $promo, array $data): void - { - if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') { - $promo->setId((int) $data['id']); - } - - if (array_key_exists('name', $data)) { - $promo->setName($data['name']); - } - - if (array_key_exists('active', $data)) { - $promo->setActive($data['active']); - } - - if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { - $v = $data['regionId'] ?? $data['region_id']; - $promo->setRegionId($v === null || $v === '' ? null : (int) $v); - } - - if (array_key_exists('alias', $data)) { - $promo->setAlias($data['alias']); - } - - if (array_key_exists('anons', $data)) { - $promo->setAnons($data['anons']); - } - - if (array_key_exists('content', $data)) { - $promo->setContent($data['content']); - } - - if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { - $raw = $data['updateAt'] ?? $data['update_at']; - if ($raw === null || $raw === '') { - $promo->setUpdateAt(null); - } elseif ($raw instanceof \DateTimeInterface) { - $promo->setUpdateAt($raw); - } elseif (is_string($raw)) { - $promo->setUpdateAt(new \DateTimeImmutable($raw)); - } - } - - if (array_key_exists('clinics', $data)) { - $promo->setClinics($data['clinics']); - } - - if (array_key_exists('timer', $data)) { - $promo->setTimer($data['timer']); - } - - if (array_key_exists('timerBg', $data) || array_key_exists('timer_bg', $data)) { - $promo->setTimerBg($data['timerBg'] ?? $data['timer_bg']); - } - - if (array_key_exists('shortName', $data) || array_key_exists('short_name', $data)) { - $promo->setShortName($data['shortName'] ?? $data['short_name']); - } - - if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) { - $promo->setLinkServices($data['linkServices'] ?? $data['link_services']); - } - - if (array_key_exists('linkStaff', $data) || array_key_exists('link_staff', $data)) { - $promo->setLinkStaff($data['linkStaff'] ?? $data['link_staff']); - } - - if (array_key_exists('period', $data)) { - $promo->setPeriod($data['period']); - } - - if (array_key_exists('photos', $data)) { - $promo->setPhotos($data['photos']); - } - } - public function syncFromViewPromo(string $viewName = 'public.view_promo'): int { if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { throw new \InvalidArgumentException('Invalid view name'); } - $connection = $this->em->getConnection(); - $sql = sprintf( 'INSERT INTO promo ( id, @@ -200,6 +78,6 @@ final class PromoCrudService $viewName ); - return (int) $connection->executeStatement($sql); + return (int) $this->em->getConnection()->executeStatement($sql); } } diff --git a/src/Service/SiteServiceCrudService.php b/src/Service/SiteServiceCrudService.php index fc7b607..befae42 100644 --- a/src/Service/SiteServiceCrudService.php +++ b/src/Service/SiteServiceCrudService.php @@ -2,358 +2,26 @@ namespace App\Service; -use App\Entity\SiteService; -use App\Repository\SiteServiceRepository; use Doctrine\ORM\EntityManagerInterface; +/** + * Импорт услуг из материализованного представления (Bitrix view). + * + * См. SiteServiceController + CrudResponder для CRUD; этот сервис — только syncFromView*. + */ final class SiteServiceCrudService { public function __construct( private EntityManagerInterface $em, - private SiteServiceRepository $siteServiceRepository, ) { } - /** - * @return SiteService[] - */ - public function getList(?int $regionId = null, ?bool $active = true): array - { - $criteria = []; - if ($regionId !== null) { - $criteria['regionId'] = $regionId; - } - if ($active !== null) { - $criteria['active'] = $active; - } - - return $this->siteServiceRepository->findBy($criteria, ['id' => 'ASC']); - } - - /** - * @return array{data: SiteService[], total: int, page: int, per_page: int} - */ - public function getPaginatedList(int $page, int $perPage, ?int $regionId = null, ?bool $active = true): array - { - $page = max(1, $page); - $perPage = min(max(1, $perPage), 500); - - $countQb = $this->siteServiceRepository->createQueryBuilder('s') - ->select('COUNT(s.id)'); - if ($regionId !== null) { - $countQb->andWhere('s.regionId = :regionId') - ->setParameter('regionId', $regionId); - } - if ($active !== null) { - $countQb->andWhere('s.active = :active') - ->setParameter('active', $active); - } - $total = (int) $countQb->getQuery()->getSingleScalarResult(); - - $qb = $this->siteServiceRepository->createQueryBuilder('s') - ->orderBy('s.id', 'ASC'); - if ($regionId !== null) { - $qb->andWhere('s.regionId = :regionId') - ->setParameter('regionId', $regionId); - } - if ($active !== null) { - $qb->andWhere('s.active = :active') - ->setParameter('active', $active); - } - $qb->setFirstResult(($page - 1) * $perPage) - ->setMaxResults($perPage); - - $data = $qb->getQuery()->getResult(); - - return [ - 'data' => $data, - 'total' => $total, - 'page' => $page, - 'per_page' => $perPage, - ]; - } - - public function getShow(int $id): ?SiteService - { - return $this->siteServiceRepository->find($id); - } - - public function create(array $data): SiteService - { - $siteService = new SiteService(); - $this->updateEntity($siteService, $data); - - $this->em->persist($siteService); - $this->em->flush(); - - return $siteService; - } - - public function update(SiteService $siteService, array $data): SiteService - { - unset($data['id']); - $this->updateEntity($siteService, $data); - - $this->em->flush(); - - return $siteService; - } - - public function delete(SiteService $siteService): void - { - $this->em->remove($siteService); - $this->em->flush(); - } - - private function updateEntity(SiteService $siteService, array $data): void - { - if (array_key_exists('id', $data)) { - $v = $data['id']; - $siteService->setId($v === null || $v === '' ? null : (int) $v); - } - - if (array_key_exists('name', $data)) { - $siteService->setName($data['name']); - } - - if (array_key_exists('active', $data)) { - $siteService->setActive($data['active']); - } - - if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { - $v = $data['regionId'] ?? $data['region_id']; - $siteService->setRegionId($v === null || $v === '' ? null : (int) $v); - } - - if (array_key_exists('alias', $data)) { - $siteService->setAlias($data['alias']); - } - - if (array_key_exists('anons', $data)) { - $siteService->setAnons($data['anons']); - } - - if (array_key_exists('content', $data)) { - $siteService->setContent($data['content']); - } - - if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { - $raw = $data['updateAt'] ?? $data['update_at']; - if ($raw === null || $raw === '') { - $siteService->setUpdateAt(null); - } elseif ($raw instanceof \DateTimeInterface) { - $siteService->setUpdateAt($raw); - } elseif (is_string($raw)) { - $siteService->setUpdateAt(new \DateTimeImmutable($raw)); - } - } - - if (array_key_exists('linkVideoreviews', $data) || array_key_exists('link_videoreviews', $data)) { - $siteService->setLinkVideoreviews($data['linkVideoreviews'] ?? $data['link_videoreviews']); - } - - if (array_key_exists('previewImg', $data) || array_key_exists('preview_img', $data)) { - $siteService->setPreviewImg($data['previewImg'] ?? $data['preview_img']); - } - - if (array_key_exists('faq', $data)) { - $siteService->setFaq($data['faq']); - } - - if (array_key_exists('partPrice', $data) || array_key_exists('part_price', $data)) { - $siteService->setPartPrice($data['partPrice'] ?? $data['part_price']); - } - - if (array_key_exists('pokazaniya', $data)) { - $siteService->setPokazaniya($data['pokazaniya']); - } - - if (array_key_exists('preparation', $data)) { - $siteService->setPreparation($data['preparation']); - } - - if (array_key_exists('protivopokazaniya', $data)) { - $siteService->setProtivopokazaniya($data['protivopokazaniya']); - } - - if (array_key_exists('hideSignBtn', $data) || array_key_exists('hide_sign_btn', $data)) { - $siteService->setHideSignBtn($data['hideSignBtn'] ?? $data['hide_sign_btn']); - } - - if (array_key_exists('quiz', $data)) { - $siteService->setQuiz($data['quiz']); - } - - if (array_key_exists('tags', $data)) { - $siteService->setTags($data['tags']); - } - - if (array_key_exists('tagsImportant', $data) || array_key_exists('tags_important', $data)) { - $siteService->setTagsImportant($data['tagsImportant'] ?? $data['tags_important']); - } - - if (array_key_exists('bannerImg', $data) || array_key_exists('banner_img', $data)) { - $siteService->setBannerImg($data['bannerImg'] ?? $data['banner_img']); - } - - if (array_key_exists('bannerImgM', $data) || array_key_exists('banner_img_m', $data)) { - $siteService->setBannerImgM($data['bannerImgM'] ?? $data['banner_img_m']); - } - - if (array_key_exists('bannerImgUrl', $data) || array_key_exists('banner_img_url', $data)) { - $siteService->setBannerImgUrl($data['bannerImgUrl'] ?? $data['banner_img_url']); - } - - if (array_key_exists('clinics', $data)) { - $siteService->setClinics($data['clinics']); - } - - if (array_key_exists('downloadFile', $data) || array_key_exists('download_file', $data)) { - $siteService->setDownloadFile($data['downloadFile'] ?? $data['download_file']); - } - - if (array_key_exists('fullWidthBanner', $data) || array_key_exists('full_width_banner', $data)) { - $siteService->setFullWidthBanner($data['fullWidthBanner'] ?? $data['full_width_banner']); - } - - if (array_key_exists('staffUp', $data) || array_key_exists('staff_up', $data)) { - $siteService->setStaffUp($data['staffUp'] ?? $data['staff_up']); - } - - if (array_key_exists('advantages', $data)) { - $siteService->setAdvantages($data['advantages']); - } - - if (array_key_exists('hidePicture', $data) || array_key_exists('hide_picture', $data)) { - $v = $data['hidePicture'] ?? $data['hide_picture']; - $siteService->setHidePicture($v === null || $v === '' ? null : (int) $v); - } - - if (array_key_exists('kodUslug', $data) || array_key_exists('kod_uslug', $data)) { - $siteService->setKodUslug($data['kodUslug'] ?? $data['kod_uslug']); - } - - if (array_key_exists('linkPrice', $data) || array_key_exists('link_price', $data)) { - $siteService->setLinkPrice($data['linkPrice'] ?? $data['link_price']); - } - - if (array_key_exists('photosTitle', $data) || array_key_exists('photos_title', $data)) { - $siteService->setPhotosTitle($data['photosTitle'] ?? $data['photos_title']); - } - - if (array_key_exists('saleId', $data) || array_key_exists('sale_id', $data)) { - $siteService->setSaleId($data['saleId'] ?? $data['sale_id']); - } - - if (array_key_exists('sortStaff', $data) || array_key_exists('sort_staff', $data)) { - $siteService->setSortStaff($data['sortStaff'] ?? $data['sort_staff']); - } - - if (array_key_exists('contraindicationsList', $data) || array_key_exists('contraindications_list', $data)) { - $siteService->setContraindicationsList($data['contraindicationsList'] ?? $data['contraindications_list']); - } - - if (array_key_exists('customBlockText', $data) || array_key_exists('custom_block_text', $data)) { - $siteService->setCustomBlockText($data['customBlockText'] ?? $data['custom_block_text']); - } - - if (array_key_exists('customBlockText2', $data) || array_key_exists('custom_block_text2', $data)) { - $siteService->setCustomBlockText2($data['customBlockText2'] ?? $data['custom_block_text2']); - } - - if (array_key_exists('customBlockTitle', $data) || array_key_exists('custom_block_title', $data)) { - $siteService->setCustomBlockTitle($data['customBlockTitle'] ?? $data['custom_block_title']); - } - - if (array_key_exists('customBlockTitle2', $data) || array_key_exists('custom_block_title2', $data)) { - $siteService->setCustomBlockTitle2($data['customBlockTitle2'] ?? $data['custom_block_title2']); - } - - if (array_key_exists('indicationsList', $data) || array_key_exists('indications_list', $data)) { - $siteService->setIndicationsList($data['indicationsList'] ?? $data['indications_list']); - } - - if (array_key_exists('linkArticlesServices', $data) || array_key_exists('link_articles_services', $data)) { - $siteService->setLinkArticlesServices($data['linkArticlesServices'] ?? $data['link_articles_services']); - } - - if (array_key_exists('plusList', $data) || array_key_exists('plus_list', $data)) { - $siteService->setPlusList($data['plusList'] ?? $data['plus_list']); - } - - if (array_key_exists('plusText', $data) || array_key_exists('plus_text', $data)) { - $siteService->setPlusText($data['plusText'] ?? $data['plus_text']); - } - - if (array_key_exists('plusTitle', $data) || array_key_exists('plus_title', $data)) { - $siteService->setPlusTitle($data['plusTitle'] ?? $data['plus_title']); - } - - if (array_key_exists('prepareTitle', $data) || array_key_exists('prepare_title', $data)) { - $siteService->setPrepareTitle($data['prepareTitle'] ?? $data['prepare_title']); - } - - if (array_key_exists('processText', $data) || array_key_exists('process_text', $data)) { - $siteService->setProcessText($data['processText'] ?? $data['process_text']); - } - - if (array_key_exists('processTitle', $data) || array_key_exists('process_title', $data)) { - $siteService->setProcessTitle($data['processTitle'] ?? $data['process_title']); - } - - if (array_key_exists('servicesList', $data) || array_key_exists('services_list', $data)) { - $siteService->setServicesList($data['servicesList'] ?? $data['services_list']); - } - - if (array_key_exists('servicesPhotos', $data) || array_key_exists('services_photos', $data)) { - $siteService->setServicesPhotos($data['servicesPhotos'] ?? $data['services_photos']); - } - - if (array_key_exists('servicesTitle', $data) || array_key_exists('services_title', $data)) { - $siteService->setServicesTitle($data['servicesTitle'] ?? $data['services_title']); - } - - if (array_key_exists('textUp', $data) || array_key_exists('text_up', $data)) { - $siteService->setTextUp($data['textUp'] ?? $data['text_up']); - } - - if (array_key_exists('trainingText', $data) || array_key_exists('training_text', $data)) { - $siteService->setTrainingText($data['trainingText'] ?? $data['training_text']); - } - - if (array_key_exists('whyText', $data) || array_key_exists('why_text', $data)) { - $siteService->setWhyText($data['whyText'] ?? $data['why_text']); - } - - if (array_key_exists('whyTitle', $data) || array_key_exists('why_title', $data)) { - $siteService->setWhyTitle($data['whyTitle'] ?? $data['why_title']); - } - - if (array_key_exists('linkFaq', $data) || array_key_exists('link_faq', $data)) { - $siteService->setLinkFaq($data['linkFaq'] ?? $data['link_faq']); - } - - if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) { - $siteService->setLinkServices($data['linkServices'] ?? $data['link_services']); - } - - if (array_key_exists('linkStaff', $data) || array_key_exists('link_staff', $data)) { - $siteService->setLinkStaff($data['linkStaff'] ?? $data['link_staff']); - } - - if (array_key_exists('photos', $data)) { - $siteService->setPhotos($data['photos']); - } - - } - public function syncFromViewServices(string $viewName = 'public.view_services'): int { - if (! preg_match('/^[A-Za-z0-9_.]+$/', $viewName)) { + if (!preg_match('/^[A-Za-z0-9_.]+$/', $viewName)) { throw new \InvalidArgumentException('Invalid view name'); } - $connection = $this->em->getConnection(); $sql = sprintf( 'INSERT INTO site_services ( id, @@ -533,6 +201,6 @@ final class SiteServiceCrudService $viewName ); - return (int) $connection->executeStatement($sql); + return (int) $this->em->getConnection()->executeStatement($sql); } } From 656f79ff4e981465b98e54c77a6a4f0ac4959a92 Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Fri, 15 May 2026 14:08:47 +0300 Subject: [PATCH 02/11] issues/27: old meta with pagination and trait and more --- src/Controller/ArticleController.php | 4 +- src/Repository/ArticleRepository.php | 4 +- ...itoryFilter.php => ContentFilterTrait.php} | 38 +++++++++------- src/Repository/DiseaseRepository.php | 4 +- src/Repository/MedicalCenterRepository.php | 4 +- src/Repository/NewsRepository.php | 4 +- src/Repository/PromoRepository.php | 4 +- src/Repository/SiteServiceRepository.php | 4 +- src/Service/Crud/CrudResponder.php | 9 ++-- src/Service/Pagination/Paginator.php | 44 ++++++++++++++++++- 10 files changed, 90 insertions(+), 29 deletions(-) rename src/Repository/{ContentRepositoryFilter.php => ContentFilterTrait.php} (66%) diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 6b701bf..f33b043 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -28,7 +28,7 @@ final class ArticleController extends AbstractController #[OA\Tag(name: 'Статьи')] #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] - #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] + #[OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] #[OA\Parameter(name: 'alias', in: 'query', schema: new OA\Schema(type: 'string'))] @@ -38,7 +38,7 @@ final class ArticleController extends AbstractController { $qb = $repository->createFilteredQueryBuilder($request->query->all()); - return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ + return $this->json($this->paginator->paginateWithLegacyMeta($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, ]); } diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index 7a63323..7b70263 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -12,6 +12,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class ArticleRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Article::class); @@ -24,7 +26,7 @@ class ArticleRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC'); - ContentRepositoryFilter::applyCommon($qb, 'a', $filters); + $this->applyCommonFilters($qb, 'a', $filters); return $qb; } diff --git a/src/Repository/ContentRepositoryFilter.php b/src/Repository/ContentFilterTrait.php similarity index 66% rename from src/Repository/ContentRepositoryFilter.php rename to src/Repository/ContentFilterTrait.php index d4235d8..eddfebd 100644 --- a/src/Repository/ContentRepositoryFilter.php +++ b/src/Repository/ContentFilterTrait.php @@ -9,43 +9,41 @@ use Doctrine\ORM\QueryBuilder; /** * Общие фильтры для контентных репозиториев (News/Promo/Disease/MedicalCenter/Article/SiteService). * - * Назначение — не плодить одинаковые if-блоки в каждом репозитории и сохранить - * единый контракт query-параметров для list-эндпоинтов. + * Trait подключается в Doctrine-репозитории, чтобы не держать бизнес-фильтры + * в статическом helper-классе и при этом не копировать одинаковые if-блоки. * * Поддерживается: - * - regionId целое > 0 - * - active bool (по умолчанию true, если не передан) - * - alias строка (строгое равенство) - * - search LIKE по name (case-insensitive) - * - * Параметры пагинации (page/perPage) обрабатываются Paginator'ом и здесь игнорируются. + * - regionId / region_id: целое > 0; + * - active: bool; + * - alias: точное совпадение; + * - search / q: LIKE по name в lower-case. */ -final class ContentRepositoryFilter +trait ContentFilterTrait { /** * @param array $filters */ - public static function applyCommon(QueryBuilder $qb, string $alias, array $filters): void + private function applyCommonFilters(QueryBuilder $qb, string $alias, array $filters): void { - $regionId = self::extractInt($filters, ['regionId', 'region_id']); + $regionId = $this->extractIntFilter($filters, ['regionId', 'region_id']); if ($regionId !== null && $regionId > 0) { $qb->andWhere("$alias.regionId = :regionId") ->setParameter('regionId', $regionId); } - $active = self::extractBool($filters, ['active']); + $active = $this->extractBoolFilter($filters, ['active']); if ($active !== null) { $qb->andWhere("$alias.active = :active") ->setParameter('active', $active); } - $aliasFilter = self::extractNonEmptyString($filters, ['alias']); + $aliasFilter = $this->extractNonEmptyStringFilter($filters, ['alias']); if ($aliasFilter !== null) { $qb->andWhere("$alias.alias = :aliasValue") ->setParameter('aliasValue', $aliasFilter); } - $search = self::extractNonEmptyString($filters, ['search', 'q']); + $search = $this->extractNonEmptyStringFilter($filters, ['search', 'q']); if ($search !== null) { $qb->andWhere("LOWER($alias.name) LIKE :search") ->setParameter('search', '%' . mb_strtolower($search) . '%'); @@ -56,16 +54,18 @@ final class ContentRepositoryFilter * @param array $filters * @param list $keys */ - private static function extractInt(array $filters, array $keys): ?int + private function extractIntFilter(array $filters, array $keys): ?int { foreach ($keys as $key) { if (!array_key_exists($key, $filters)) { continue; } + $value = $filters[$key]; if ($value === null || $value === '') { continue; } + if (is_numeric($value)) { return (int) $value; } @@ -78,16 +78,18 @@ final class ContentRepositoryFilter * @param array $filters * @param list $keys */ - private static function extractBool(array $filters, array $keys): ?bool + private function extractBoolFilter(array $filters, array $keys): ?bool { foreach ($keys as $key) { if (!array_key_exists($key, $filters)) { continue; } + $value = $filters[$key]; if ($value === null || $value === '') { continue; } + if (is_bool($value)) { return $value; } @@ -102,16 +104,18 @@ final class ContentRepositoryFilter * @param array $filters * @param list $keys */ - private static function extractNonEmptyString(array $filters, array $keys): ?string + private function extractNonEmptyStringFilter(array $filters, array $keys): ?string { foreach ($keys as $key) { if (!array_key_exists($key, $filters)) { continue; } + $value = $filters[$key]; if (!is_string($value)) { continue; } + $trimmed = trim($value); if ($trimmed !== '') { return $trimmed; diff --git a/src/Repository/DiseaseRepository.php b/src/Repository/DiseaseRepository.php index 5e9eca7..77faff4 100644 --- a/src/Repository/DiseaseRepository.php +++ b/src/Repository/DiseaseRepository.php @@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class DiseaseRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Disease::class); @@ -27,7 +29,7 @@ class DiseaseRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC'); - ContentRepositoryFilter::applyCommon($qb, 'd', $filters); + $this->applyCommonFilters($qb, 'd', $filters); return $qb; } diff --git a/src/Repository/MedicalCenterRepository.php b/src/Repository/MedicalCenterRepository.php index 2b14f2a..9c00e8e 100644 --- a/src/Repository/MedicalCenterRepository.php +++ b/src/Repository/MedicalCenterRepository.php @@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class MedicalCenterRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, MedicalCenter::class); @@ -27,7 +29,7 @@ class MedicalCenterRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC'); - ContentRepositoryFilter::applyCommon($qb, 'm', $filters); + $this->applyCommonFilters($qb, 'm', $filters); return $qb; } diff --git a/src/Repository/NewsRepository.php b/src/Repository/NewsRepository.php index 7703f0d..c2d3c41 100644 --- a/src/Repository/NewsRepository.php +++ b/src/Repository/NewsRepository.php @@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class NewsRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, News::class); @@ -31,7 +33,7 @@ class NewsRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC'); - ContentRepositoryFilter::applyCommon($qb, 'n', $filters); + $this->applyCommonFilters($qb, 'n', $filters); return $qb; } diff --git a/src/Repository/PromoRepository.php b/src/Repository/PromoRepository.php index 35e0b1e..0f89df0 100644 --- a/src/Repository/PromoRepository.php +++ b/src/Repository/PromoRepository.php @@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class PromoRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Promo::class); @@ -27,7 +29,7 @@ class PromoRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC'); - ContentRepositoryFilter::applyCommon($qb, 'p', $filters); + $this->applyCommonFilters($qb, 'p', $filters); return $qb; } diff --git a/src/Repository/SiteServiceRepository.php b/src/Repository/SiteServiceRepository.php index c44abbd..7964a99 100644 --- a/src/Repository/SiteServiceRepository.php +++ b/src/Repository/SiteServiceRepository.php @@ -15,6 +15,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class SiteServiceRepository extends ServiceEntityRepository { + use ContentFilterTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, SiteService::class); @@ -27,7 +29,7 @@ class SiteServiceRepository extends ServiceEntityRepository { $qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC'); - ContentRepositoryFilter::applyCommon($qb, 's', $filters); + $this->applyCommonFilters($qb, 's', $filters); return $qb; } diff --git a/src/Service/Crud/CrudResponder.php b/src/Service/Crud/CrudResponder.php index 4e40251..522c693 100644 --- a/src/Service/Crud/CrudResponder.php +++ b/src/Service/Crud/CrudResponder.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service\Crud; use Doctrine\ORM\EntityManagerInterface; +use JsonException; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -142,9 +143,11 @@ final class CrudResponder */ private function decodePayload(Request $request): ?array { - $data = json_decode($request->getContent(), true); - - return is_array($data) ? $data : null; + try { + return $request->toArray(); + } catch (JsonException) { + return null; + } } private function validate(object $entity): ?JsonResponse diff --git a/src/Service/Pagination/Paginator.php b/src/Service/Pagination/Paginator.php index 527908a..bb53ee6 100644 --- a/src/Service/Pagination/Paginator.php +++ b/src/Service/Pagination/Paginator.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request; * * Соответствует существующему стилю проекта (см. PriceListController/SpecialistController): * читает page/perPage из Request, ограничивает perPage и возвращает массив - * ['data' => [...], 'pagination' => [...]] в едином формате для всех контроллеров. + * ['data' => [...], 'pagination' => [...]] в едином формате для новых list-контрактов. */ final class Paginator { @@ -62,4 +62,46 @@ final class Paginator ], ]; } + + /** + * Legacy-формат для ArticleController. + * + * Старый контракт /article/list уже использовался клиентами: + * - размер страницы приходит в query-параметре limit; + * - метаданные лежат в ключе meta; + * - поля называются total/page/limit/totalPages. + * + * @return array{data: list, 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(), + ], + ]; + } } From da5f7bb24245cb9d21ba3961d8e8358755794d5f Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Fri, 15 May 2026 14:31:24 +0300 Subject: [PATCH 03/11] issues/27: sequence/default migration and #[OA\RequestBody(... Model(... groups: *:write))] --- migrations/Version20260515142000.php | 53 ++++++++++++++++++++++ src/Controller/ArticleController.php | 3 ++ src/Controller/DiseaseController.php | 3 ++ src/Controller/MedicalCenterController.php | 3 ++ src/Controller/NewsController.php | 3 ++ src/Controller/PromoController.php | 3 ++ src/Controller/SiteServiceController.php | 3 ++ src/Entity/Disease.php | 1 + src/Entity/MedicalCenter.php | 1 + src/Entity/News.php | 1 + src/Entity/Promo.php | 1 + src/Entity/SiteService.php | 1 + src/Repository/ContentFilterTrait.php | 3 ++ src/Service/Crud/CrudResponder.php | 34 ++++++++++---- 14 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 migrations/Version20260515142000.php diff --git a/migrations/Version20260515142000.php b/migrations/Version20260515142000.php new file mode 100644 index 0000000..e3fb5e2 --- /dev/null +++ b/migrations/Version20260515142000.php @@ -0,0 +1,53 @@ +addSql(sprintf('CREATE SEQUENCE IF NOT EXISTS %s OWNED BY %s.id', $sequence, $table)); + $this->addSql(sprintf( + 'SELECT setval(\'%s\', COALESCE((SELECT MAX(id) FROM %s), 0) + 1, false)', + $sequence, + $table, + )); + $this->addSql(sprintf( + 'ALTER TABLE %s ALTER COLUMN id SET DEFAULT nextval(\'%s\')', + $table, + $sequence, + )); + } + } + + public function down(Schema $schema): void + { + foreach (array_reverse(self::TABLES) as $table) { + $sequence = $table . '_id_seq'; + + $this->addSql(sprintf('ALTER TABLE %s ALTER COLUMN id DROP DEFAULT', $table)); + $this->addSql(sprintf('DROP SEQUENCE IF EXISTS %s', $sequence)); + } + } +} diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index f33b043..798b0d4 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -6,6 +6,7 @@ use App\Entity\Article; use App\Repository\ArticleRepository; use App\Service\Crud\CrudResponder; use App\Service\Pagination\Paginator; +use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -61,6 +62,7 @@ final class ArticleController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Article::class, groups: self::WRITE_GROUPS)))] #[Route('/create', name: 'article_create', methods: ['POST'])] public function create(Request $request): JsonResponse { @@ -68,6 +70,7 @@ final class ArticleController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Article::class, groups: self::WRITE_GROUPS)))] #[Route('/{id}', name: 'article_update', methods: ['PUT'], requirements: ['id' => '\d+'])] public function update(Request $request, Article $article): JsonResponse { diff --git a/src/Controller/DiseaseController.php b/src/Controller/DiseaseController.php index 43e10cb..1c18794 100644 --- a/src/Controller/DiseaseController.php +++ b/src/Controller/DiseaseController.php @@ -6,6 +6,7 @@ use App\Entity\Disease; use App\Repository\DiseaseRepository; use App\Service\Crud\CrudResponder; use App\Service\Pagination\Paginator; +use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -49,6 +50,7 @@ final class DiseaseController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Disease::class, groups: self::WRITE_GROUPS)))] #[Route('/create', name: 'disease_create', methods: ['POST'])] public function create(Request $request): JsonResponse { @@ -56,6 +58,7 @@ final class DiseaseController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Disease::class, groups: self::WRITE_GROUPS)))] #[Route('/{id}', name: 'disease_update', methods: ['PUT'], requirements: ['id' => '\d+'])] public function update(Request $request, Disease $disease): JsonResponse { diff --git a/src/Controller/MedicalCenterController.php b/src/Controller/MedicalCenterController.php index ad53c4e..8125326 100644 --- a/src/Controller/MedicalCenterController.php +++ b/src/Controller/MedicalCenterController.php @@ -6,6 +6,7 @@ use App\Entity\MedicalCenter; use App\Repository\MedicalCenterRepository; use App\Service\Crud\CrudResponder; use App\Service\Pagination\Paginator; +use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -49,6 +50,7 @@ final class MedicalCenterController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: MedicalCenter::class, groups: self::WRITE_GROUPS)))] #[Route('/create', name: 'medical_center_create', methods: ['POST'])] public function create(Request $request): JsonResponse { @@ -56,6 +58,7 @@ final class MedicalCenterController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: MedicalCenter::class, groups: self::WRITE_GROUPS)))] #[Route('/{id}', name: 'medical_center_update', methods: ['PUT'], requirements: ['id' => '\d+'])] public function update(Request $request, MedicalCenter $medicalCenter): JsonResponse { diff --git a/src/Controller/NewsController.php b/src/Controller/NewsController.php index 96f4534..dd57dda 100644 --- a/src/Controller/NewsController.php +++ b/src/Controller/NewsController.php @@ -6,6 +6,7 @@ use App\Entity\News; use App\Repository\NewsRepository; use App\Service\Crud\CrudResponder; use App\Service\Pagination\Paginator; +use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -49,6 +50,7 @@ final class NewsController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: News::class, groups: self::WRITE_GROUPS)))] #[Route('/create', name: 'news_create', methods: ['POST'])] public function create(Request $request): JsonResponse { @@ -56,6 +58,7 @@ final class NewsController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: News::class, groups: self::WRITE_GROUPS)))] #[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\d+'])] public function update(Request $request, News $news): JsonResponse { diff --git a/src/Controller/PromoController.php b/src/Controller/PromoController.php index d2984e3..dff9926 100644 --- a/src/Controller/PromoController.php +++ b/src/Controller/PromoController.php @@ -6,6 +6,7 @@ use App\Entity\Promo; use App\Repository\PromoRepository; use App\Service\Crud\CrudResponder; use App\Service\Pagination\Paginator; +use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -49,6 +50,7 @@ final class PromoController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Promo::class, groups: self::WRITE_GROUPS)))] #[Route('/create', name: 'promo_create', methods: ['POST'])] public function create(Request $request): JsonResponse { @@ -56,6 +58,7 @@ final class PromoController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Promo::class, groups: self::WRITE_GROUPS)))] #[Route('/{id}', name: 'promo_update', methods: ['PUT'], requirements: ['id' => '\d+'])] public function update(Request $request, Promo $promo): JsonResponse { diff --git a/src/Controller/SiteServiceController.php b/src/Controller/SiteServiceController.php index 92c517d..50893a7 100644 --- a/src/Controller/SiteServiceController.php +++ b/src/Controller/SiteServiceController.php @@ -6,6 +6,7 @@ use App\Entity\SiteService; use App\Repository\SiteServiceRepository; use App\Service\Crud\CrudResponder; use App\Service\Pagination\Paginator; +use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -49,6 +50,7 @@ final class SiteServiceController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: SiteService::class, groups: self::WRITE_GROUPS)))] #[Route('/create', name: 'site_service_create', methods: ['POST'])] public function create(Request $request): JsonResponse { @@ -56,6 +58,7 @@ final class SiteServiceController extends AbstractController } #[IsGranted('ROLE_ADMIN')] + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: SiteService::class, groups: self::WRITE_GROUPS)))] #[Route('/{id}', name: 'site_service_update', methods: ['PUT'], requirements: ['id' => '\d+'])] public function update(Request $request, SiteService $siteService): JsonResponse { diff --git a/src/Entity/Disease.php b/src/Entity/Disease.php index 26fc0bd..9bbd65c 100644 --- a/src/Entity/Disease.php +++ b/src/Entity/Disease.php @@ -15,6 +15,7 @@ class Disease { #[Groups(['disease:read'])] #[ORM\Id] + #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] private ?int $id = null; diff --git a/src/Entity/MedicalCenter.php b/src/Entity/MedicalCenter.php index b116c78..028cee6 100644 --- a/src/Entity/MedicalCenter.php +++ b/src/Entity/MedicalCenter.php @@ -12,6 +12,7 @@ use Symfony\Component\Serializer\Annotation\Groups; class MedicalCenter { #[ORM\Id] + #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] #[Groups(['medical_center:read'])] private ?int $id = null; diff --git a/src/Entity/News.php b/src/Entity/News.php index 94dd5e9..552af9f 100644 --- a/src/Entity/News.php +++ b/src/Entity/News.php @@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Annotation\Groups; class News { #[ORM\Id] + #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] #[Groups(['news:read'])] private ?int $id = null; diff --git a/src/Entity/Promo.php b/src/Entity/Promo.php index 94bb004..4f1e983 100644 --- a/src/Entity/Promo.php +++ b/src/Entity/Promo.php @@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Annotation\Groups; class Promo { #[ORM\Id] + #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] #[Groups(['promo:read'])] private ?int $id = null; diff --git a/src/Entity/SiteService.php b/src/Entity/SiteService.php index cf48e0f..ac29328 100644 --- a/src/Entity/SiteService.php +++ b/src/Entity/SiteService.php @@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Annotation\Groups; class SiteService { #[ORM\Id] + #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] #[Groups(['site_service:read'])] private ?int $id = null; diff --git a/src/Repository/ContentFilterTrait.php b/src/Repository/ContentFilterTrait.php index eddfebd..c95453b 100644 --- a/src/Repository/ContentFilterTrait.php +++ b/src/Repository/ContentFilterTrait.php @@ -17,6 +17,9 @@ use Doctrine\ORM\QueryBuilder; * - active: bool; * - alias: точное совпадение; * - search / q: LIKE по name в lower-case. + * + * Важно: search использует LOWER(name), поэтому для больших таблиц нужен + * функциональный индекс в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)). */ trait ContentFilterTrait { diff --git a/src/Service/Crud/CrudResponder.php b/src/Service/Crud/CrudResponder.php index 522c693..138511c 100644 --- a/src/Service/Crud/CrudResponder.php +++ b/src/Service/Crud/CrudResponder.php @@ -52,30 +52,34 @@ final class CrudResponder string $entityClass, array $writeGroups, array $readGroups, - bool $allowIdFromPayload = true, + bool $allowIdFromPayload = false, ): JsonResponse { $payload = $this->decodePayload($request); if ($payload === null) { return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST); } + $deserializationPayload = $payload; + if (!$allowIdFromPayload) { + unset($deserializationPayload['id']); + } + try { /** @var T $entity */ $entity = $this->serializer->deserialize( - $request->getContent(), + $this->encodePayload($deserializationPayload), $entityClass, 'json', [ AbstractNormalizer::GROUPS => $writeGroups, ], ); - } catch (SerializerExceptionInterface $e) { + } catch (JsonException|SerializerExceptionInterface $e) { return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); } - // Id в группе :write не присутствует (чтобы запретить инъекцию при update). - // На create поддерживаем явный id из payload, потому что у контент-сущностей - // нет GeneratedValue (id приходит из Bitrix-view). + // По умолчанию публичный CRUD не принимает id от клиента. Если системной + // интеграции понадобится внешний id, конкретный вызов должен явно передать true. if ($allowIdFromPayload && isset($payload['id']) && method_exists($entity, 'setId')) { $id = (int) $payload['id']; if ($id > 0) { @@ -103,13 +107,15 @@ final class CrudResponder array $writeGroups, array $readGroups, ): JsonResponse { - if ($this->decodePayload($request) === null) { + $payload = $this->decodePayload($request); + if ($payload === null) { return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST); } + unset($payload['id']); try { $this->serializer->deserialize( - $request->getContent(), + $this->encodePayload($payload), $entity::class, 'json', [ @@ -117,7 +123,7 @@ final class CrudResponder AbstractNormalizer::OBJECT_TO_POPULATE => $entity, ], ); - } catch (SerializerExceptionInterface $e) { + } catch (JsonException|SerializerExceptionInterface $e) { return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); } @@ -150,6 +156,16 @@ final class CrudResponder } } + /** + * @param array $payload + * + * @throws JsonException + */ + private function encodePayload(array $payload): string + { + return json_encode($payload, JSON_THROW_ON_ERROR); + } + private function validate(object $entity): ?JsonResponse { $errors = $this->validator->validate($entity); From 76044381fdb67502b42dc53f2faf51950e496078 Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Fri, 15 May 2026 15:35:50 +0300 Subject: [PATCH 04/11] issues/27: filter DTO, strip id from payloads, lifecycle updateAt --- src/Controller/ArticleController.php | 3 +- src/Controller/DiseaseController.php | 3 +- src/Controller/MedicalCenterController.php | 3 +- src/Controller/NewsController.php | 3 +- src/Controller/PromoController.php | 3 +- src/Controller/SiteServiceController.php | 3 +- src/Dto/Content/ContentFilterDto.php | 63 +++++++++++++ src/Entity/Article.php | 6 +- src/Entity/Behavior/UpdateTimestampTrait.php | 24 +++++ src/Entity/Disease.php | 6 +- src/Entity/MedicalCenter.php | 6 +- src/Entity/News.php | 6 +- src/Entity/Promo.php | 6 +- src/Entity/SiteService.php | 6 +- src/Repository/ArticleRepository.php | 4 +- src/Repository/ContentFilterTrait.php | 99 ++------------------ src/Repository/DiseaseRepository.php | 4 +- src/Repository/MedicalCenterRepository.php | 4 +- src/Repository/NewsRepository.php | 4 +- src/Repository/PromoRepository.php | 4 +- src/Repository/SiteServiceRepository.php | 4 +- src/Service/Crud/CrudResponder.php | 18 +--- 22 files changed, 153 insertions(+), 129 deletions(-) create mode 100644 src/Dto/Content/ContentFilterDto.php create mode 100644 src/Entity/Behavior/UpdateTimestampTrait.php diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 798b0d4..5ab7989 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Dto\Content\ContentFilterDto; use App\Entity\Article; use App\Repository\ArticleRepository; use App\Service\Crud\CrudResponder; @@ -37,7 +38,7 @@ final class ArticleController extends AbstractController #[Route('/list', name: 'article_list', methods: ['GET'])] public function list(Request $request, ArticleRepository $repository): JsonResponse { - $qb = $repository->createFilteredQueryBuilder($request->query->all()); + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); return $this->json($this->paginator->paginateWithLegacyMeta($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, diff --git a/src/Controller/DiseaseController.php b/src/Controller/DiseaseController.php index 1c18794..34ccc34 100644 --- a/src/Controller/DiseaseController.php +++ b/src/Controller/DiseaseController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Dto\Content\ContentFilterDto; use App\Entity\Disease; use App\Repository\DiseaseRepository; use App\Service\Crud\CrudResponder; @@ -36,7 +37,7 @@ final class DiseaseController extends AbstractController #[Route('/list', name: 'disease_list', methods: ['GET'])] public function list(Request $request, DiseaseRepository $repository): JsonResponse { - $qb = $repository->createFilteredQueryBuilder($request->query->all()); + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, diff --git a/src/Controller/MedicalCenterController.php b/src/Controller/MedicalCenterController.php index 8125326..474a765 100644 --- a/src/Controller/MedicalCenterController.php +++ b/src/Controller/MedicalCenterController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Dto\Content\ContentFilterDto; use App\Entity\MedicalCenter; use App\Repository\MedicalCenterRepository; use App\Service\Crud\CrudResponder; @@ -36,7 +37,7 @@ final class MedicalCenterController extends AbstractController #[Route('/list', name: 'medical_center_list', methods: ['GET'])] public function list(Request $request, MedicalCenterRepository $repository): JsonResponse { - $qb = $repository->createFilteredQueryBuilder($request->query->all()); + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, diff --git a/src/Controller/NewsController.php b/src/Controller/NewsController.php index dd57dda..08b29ac 100644 --- a/src/Controller/NewsController.php +++ b/src/Controller/NewsController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Dto\Content\ContentFilterDto; use App\Entity\News; use App\Repository\NewsRepository; use App\Service\Crud\CrudResponder; @@ -36,7 +37,7 @@ final class NewsController extends AbstractController #[Route('/list', name: 'news_list', methods: ['GET'])] public function list(Request $request, NewsRepository $repository): JsonResponse { - $qb = $repository->createFilteredQueryBuilder($request->query->all()); + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, diff --git a/src/Controller/PromoController.php b/src/Controller/PromoController.php index dff9926..ba95aad 100644 --- a/src/Controller/PromoController.php +++ b/src/Controller/PromoController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Dto\Content\ContentFilterDto; use App\Entity\Promo; use App\Repository\PromoRepository; use App\Service\Crud\CrudResponder; @@ -36,7 +37,7 @@ final class PromoController extends AbstractController #[Route('/list', name: 'promo_list', methods: ['GET'])] public function list(Request $request, PromoRepository $repository): JsonResponse { - $qb = $repository->createFilteredQueryBuilder($request->query->all()); + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, diff --git a/src/Controller/SiteServiceController.php b/src/Controller/SiteServiceController.php index 50893a7..32a232f 100644 --- a/src/Controller/SiteServiceController.php +++ b/src/Controller/SiteServiceController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Dto\Content\ContentFilterDto; use App\Entity\SiteService; use App\Repository\SiteServiceRepository; use App\Service\Crud\CrudResponder; @@ -36,7 +37,7 @@ final class SiteServiceController extends AbstractController #[Route('/list', name: 'site_service_list', methods: ['GET'])] public function list(Request $request, SiteServiceRepository $repository): JsonResponse { - $qb = $repository->createFilteredQueryBuilder($request->query->all()); + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, diff --git a/src/Dto/Content/ContentFilterDto.php b/src/Dto/Content/ContentFilterDto.php new file mode 100644 index 0000000..8cb53cb --- /dev/null +++ b/src/Dto/Content/ContentFilterDto.php @@ -0,0 +1,63 @@ +query->get('regionId', $request->query->get('region_id'))), + active: self::nullableBool($request->query->get('active')), + alias: self::nonEmptyString($request->query->get('alias')), + search: self::nonEmptyString($request->query->get('search', $request->query->get('q'))), + ); + } + + private static function positiveInt(mixed $value): ?int + { + if ($value === null || $value === '' || !is_numeric($value)) { + return null; + } + + $value = (int) $value; + + return $value > 0 ? $value : null; + } + + private static function nullableBool(mixed $value): ?bool + { + if ($value === null || $value === '') { + return null; + } + + if (is_bool($value)) { + return $value; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + + private static function nonEmptyString(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + $value = trim($value); + + return $value !== '' ? $value : null; + } +} diff --git a/src/Entity/Article.php b/src/Entity/Article.php index dffa469..675d15f 100644 --- a/src/Entity/Article.php +++ b/src/Entity/Article.php @@ -2,6 +2,7 @@ namespace App\Entity; +use App\Entity\Behavior\UpdateTimestampTrait; use App\Repository\ArticleRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -12,8 +13,11 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Table(name: 'article')] #[ORM\Index(name: 'idx_article_region_id', columns: ['region_id'])] #[ORM\Index(name: 'idx_article_active', columns: ['active'])] +#[ORM\HasLifecycleCallbacks] class Article { + use UpdateTimestampTrait; + #[Groups(['article:read'])] #[ORM\Id] #[ORM\GeneratedValue(strategy: "IDENTITY")] @@ -56,7 +60,7 @@ class Article #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $content = null; - #[Groups(['article:read', 'article:write'])] + #[Groups(['article:read'])] #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] private ?\DateTimeInterface $updateAt = null; diff --git a/src/Entity/Behavior/UpdateTimestampTrait.php b/src/Entity/Behavior/UpdateTimestampTrait.php new file mode 100644 index 0000000..7d851d3 --- /dev/null +++ b/src/Entity/Behavior/UpdateTimestampTrait.php @@ -0,0 +1,24 @@ +updateAt === null) { + $this->updateAt = new \DateTime(); + } + } + + #[ORM\PreUpdate] + public function refreshUpdateAt(): void + { + $this->updateAt = new \DateTime(); + } +} diff --git a/src/Entity/Disease.php b/src/Entity/Disease.php index 9bbd65c..1851e00 100644 --- a/src/Entity/Disease.php +++ b/src/Entity/Disease.php @@ -2,6 +2,7 @@ namespace App\Entity; +use App\Entity\Behavior\UpdateTimestampTrait; use App\Repository\DiseaseRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -11,8 +12,11 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ORM\Table(name: 'disease')] #[ORM\Index(name: 'idx_disease_region_id', columns: ['region_id'])] #[ORM\Index(name: 'idx_disease_active', columns: ['active'])] +#[ORM\HasLifecycleCallbacks] class Disease { + use UpdateTimestampTrait; + #[Groups(['disease:read'])] #[ORM\Id] #[ORM\GeneratedValue(strategy: "IDENTITY")] @@ -43,7 +47,7 @@ class Disease #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $anons = null; - #[Groups(['disease:read', 'disease:write'])] + #[Groups(['disease:read'])] #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] private ?\DateTimeInterface $updateAt = null; diff --git a/src/Entity/MedicalCenter.php b/src/Entity/MedicalCenter.php index 028cee6..6e01ced 100644 --- a/src/Entity/MedicalCenter.php +++ b/src/Entity/MedicalCenter.php @@ -2,6 +2,7 @@ namespace App\Entity; +use App\Entity\Behavior\UpdateTimestampTrait; use App\Repository\MedicalCenterRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -9,8 +10,11 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ORM\Entity(repositoryClass: MedicalCenterRepository::class)] #[ORM\Table(name: 'medical_center')] +#[ORM\HasLifecycleCallbacks] class MedicalCenter { + use UpdateTimestampTrait; + #[ORM\Id] #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] @@ -42,7 +46,7 @@ class MedicalCenter private ?string $content = null; #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] - #[Groups(['medical_center:read', 'medical_center:write'])] + #[Groups(['medical_center:read'])] private ?\DateTimeInterface $updateAt = null; #[ORM\Column(name: 'kod_uslug', type: 'jsonb', nullable: true)] diff --git a/src/Entity/News.php b/src/Entity/News.php index 552af9f..f91922a 100644 --- a/src/Entity/News.php +++ b/src/Entity/News.php @@ -2,6 +2,7 @@ namespace App\Entity; +use App\Entity\Behavior\UpdateTimestampTrait; use App\Repository\NewsRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -11,8 +12,11 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ORM\Table(name: 'news')] #[ORM\Index(name: 'idx_news_region_id', columns: ['region_id'])] #[ORM\Index(name: 'idx_news_active', columns: ['active'])] +#[ORM\HasLifecycleCallbacks] class News { + use UpdateTimestampTrait; + #[ORM\Id] #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] @@ -44,7 +48,7 @@ class News private ?string $content = null; #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] - #[Groups(['news:read', 'news:write'])] + #[Groups(['news:read'])] private ?\DateTimeInterface $updateAt = null; #[ORM\Column(name: 'link_el_price', type: Types::TEXT, nullable: true)] diff --git a/src/Entity/Promo.php b/src/Entity/Promo.php index 4f1e983..63076f0 100644 --- a/src/Entity/Promo.php +++ b/src/Entity/Promo.php @@ -2,6 +2,7 @@ namespace App\Entity; +use App\Entity\Behavior\UpdateTimestampTrait; use App\Repository\PromoRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -11,8 +12,11 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ORM\Table(name: 'promo')] #[ORM\Index(name: 'idx_promo_region_id', columns: ['region_id'])] #[ORM\Index(name: 'idx_promo_active', columns: ['active'])] +#[ORM\HasLifecycleCallbacks] class Promo { + use UpdateTimestampTrait; + #[ORM\Id] #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] @@ -44,7 +48,7 @@ class Promo private ?string $content = null; #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] - #[Groups(['promo:read', 'promo:write'])] + #[Groups(['promo:read'])] private ?\DateTimeInterface $updateAt = null; #[ORM\Column(type: 'jsonb', nullable: true)] diff --git a/src/Entity/SiteService.php b/src/Entity/SiteService.php index ac29328..9691651 100644 --- a/src/Entity/SiteService.php +++ b/src/Entity/SiteService.php @@ -2,6 +2,7 @@ namespace App\Entity; +use App\Entity\Behavior\UpdateTimestampTrait; use App\Repository\SiteServiceRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -11,8 +12,11 @@ use Symfony\Component\Serializer\Annotation\Groups; #[ORM\Table(name: 'site_services')] #[ORM\Index(name: 'idx_site_services_region_id', columns: ['region_id'])] #[ORM\Index(name: 'idx_site_services_active', columns: ['active'])] +#[ORM\HasLifecycleCallbacks] class SiteService { + use UpdateTimestampTrait; + #[ORM\Id] #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] @@ -44,7 +48,7 @@ class SiteService private ?string $content = null; #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] - #[Groups(['site_service:read', 'site_service:write'])] + #[Groups(['site_service:read'])] private ?\DateTimeInterface $updateAt = null; #[ORM\Column(name: 'link_videoreviews', type: 'jsonb', nullable: true)] diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index 7b70263..e194953 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -2,6 +2,7 @@ namespace App\Repository; +use App\Dto\Content\ContentFilterDto; use App\Entity\Article; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; @@ -20,9 +21,8 @@ class ArticleRepository extends ServiceEntityRepository } /** - * @param array $filters */ - public function createFilteredQueryBuilder(array $filters): QueryBuilder + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder { $qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC'); diff --git a/src/Repository/ContentFilterTrait.php b/src/Repository/ContentFilterTrait.php index c95453b..6a47ec2 100644 --- a/src/Repository/ContentFilterTrait.php +++ b/src/Repository/ContentFilterTrait.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Repository; +use App\Dto\Content\ContentFilterDto; use Doctrine\ORM\QueryBuilder; /** @@ -24,107 +25,27 @@ use Doctrine\ORM\QueryBuilder; trait ContentFilterTrait { /** - * @param array $filters */ - private function applyCommonFilters(QueryBuilder $qb, string $alias, array $filters): void + private function applyCommonFilters(QueryBuilder $qb, string $alias, ContentFilterDto $filters): void { - $regionId = $this->extractIntFilter($filters, ['regionId', 'region_id']); - if ($regionId !== null && $regionId > 0) { + if ($filters->regionId !== null) { $qb->andWhere("$alias.regionId = :regionId") - ->setParameter('regionId', $regionId); + ->setParameter('regionId', $filters->regionId); } - $active = $this->extractBoolFilter($filters, ['active']); - if ($active !== null) { + if ($filters->active !== null) { $qb->andWhere("$alias.active = :active") - ->setParameter('active', $active); + ->setParameter('active', $filters->active); } - $aliasFilter = $this->extractNonEmptyStringFilter($filters, ['alias']); - if ($aliasFilter !== null) { + if ($filters->alias !== null) { $qb->andWhere("$alias.alias = :aliasValue") - ->setParameter('aliasValue', $aliasFilter); + ->setParameter('aliasValue', $filters->alias); } - $search = $this->extractNonEmptyStringFilter($filters, ['search', 'q']); - if ($search !== null) { + if ($filters->search !== null) { $qb->andWhere("LOWER($alias.name) LIKE :search") - ->setParameter('search', '%' . mb_strtolower($search) . '%'); + ->setParameter('search', '%' . mb_strtolower($filters->search) . '%'); } } - - /** - * @param array $filters - * @param list $keys - */ - private function extractIntFilter(array $filters, array $keys): ?int - { - foreach ($keys as $key) { - if (!array_key_exists($key, $filters)) { - continue; - } - - $value = $filters[$key]; - if ($value === null || $value === '') { - continue; - } - - if (is_numeric($value)) { - return (int) $value; - } - } - - return null; - } - - /** - * @param array $filters - * @param list $keys - */ - private function extractBoolFilter(array $filters, array $keys): ?bool - { - foreach ($keys as $key) { - if (!array_key_exists($key, $filters)) { - continue; - } - - $value = $filters[$key]; - if ($value === null || $value === '') { - continue; - } - - if (is_bool($value)) { - return $value; - } - - return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - } - - return null; - } - - /** - * @param array $filters - * @param list $keys - */ - private function extractNonEmptyStringFilter(array $filters, array $keys): ?string - { - foreach ($keys as $key) { - if (!array_key_exists($key, $filters)) { - continue; - } - - $value = $filters[$key]; - if (!is_string($value)) { - continue; - } - - $trimmed = trim($value); - if ($trimmed !== '') { - return $trimmed; - } - } - - return null; - } } diff --git a/src/Repository/DiseaseRepository.php b/src/Repository/DiseaseRepository.php index 77faff4..33dd6b1 100644 --- a/src/Repository/DiseaseRepository.php +++ b/src/Repository/DiseaseRepository.php @@ -2,6 +2,7 @@ namespace App\Repository; +use App\Dto\Content\ContentFilterDto; use App\Entity\Disease; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; @@ -23,9 +24,8 @@ class DiseaseRepository extends ServiceEntityRepository } /** - * @param array $filters */ - public function createFilteredQueryBuilder(array $filters): QueryBuilder + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder { $qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC'); diff --git a/src/Repository/MedicalCenterRepository.php b/src/Repository/MedicalCenterRepository.php index 9c00e8e..021af74 100644 --- a/src/Repository/MedicalCenterRepository.php +++ b/src/Repository/MedicalCenterRepository.php @@ -2,6 +2,7 @@ namespace App\Repository; +use App\Dto\Content\ContentFilterDto; use App\Entity\MedicalCenter; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; @@ -23,9 +24,8 @@ class MedicalCenterRepository extends ServiceEntityRepository } /** - * @param array $filters */ - public function createFilteredQueryBuilder(array $filters): QueryBuilder + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder { $qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC'); diff --git a/src/Repository/NewsRepository.php b/src/Repository/NewsRepository.php index c2d3c41..4520283 100644 --- a/src/Repository/NewsRepository.php +++ b/src/Repository/NewsRepository.php @@ -2,6 +2,7 @@ namespace App\Repository; +use App\Dto\Content\ContentFilterDto; use App\Entity\News; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; @@ -27,9 +28,8 @@ class NewsRepository extends ServiceEntityRepository * * Поддерживаемые фильтры: regionId, active (по умолчанию true), alias, search. * - * @param array $filters */ - public function createFilteredQueryBuilder(array $filters): QueryBuilder + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder { $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC'); diff --git a/src/Repository/PromoRepository.php b/src/Repository/PromoRepository.php index 0f89df0..3d73d2b 100644 --- a/src/Repository/PromoRepository.php +++ b/src/Repository/PromoRepository.php @@ -2,6 +2,7 @@ namespace App\Repository; +use App\Dto\Content\ContentFilterDto; use App\Entity\Promo; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; @@ -23,9 +24,8 @@ class PromoRepository extends ServiceEntityRepository } /** - * @param array $filters */ - public function createFilteredQueryBuilder(array $filters): QueryBuilder + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder { $qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC'); diff --git a/src/Repository/SiteServiceRepository.php b/src/Repository/SiteServiceRepository.php index 7964a99..73d834a 100644 --- a/src/Repository/SiteServiceRepository.php +++ b/src/Repository/SiteServiceRepository.php @@ -2,6 +2,7 @@ namespace App\Repository; +use App\Dto\Content\ContentFilterDto; use App\Entity\SiteService; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; @@ -23,9 +24,8 @@ class SiteServiceRepository extends ServiceEntityRepository } /** - * @param array $filters */ - public function createFilteredQueryBuilder(array $filters): QueryBuilder + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder { $qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC'); diff --git a/src/Service/Crud/CrudResponder.php b/src/Service/Crud/CrudResponder.php index 138511c..e8dd60a 100644 --- a/src/Service/Crud/CrudResponder.php +++ b/src/Service/Crud/CrudResponder.php @@ -52,22 +52,17 @@ final class CrudResponder string $entityClass, array $writeGroups, array $readGroups, - bool $allowIdFromPayload = false, ): JsonResponse { $payload = $this->decodePayload($request); if ($payload === null) { return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST); } - - $deserializationPayload = $payload; - if (!$allowIdFromPayload) { - unset($deserializationPayload['id']); - } + unset($payload['id']); try { /** @var T $entity */ $entity = $this->serializer->deserialize( - $this->encodePayload($deserializationPayload), + $this->encodePayload($payload), $entityClass, 'json', [ @@ -78,15 +73,6 @@ final class CrudResponder return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); } - // По умолчанию публичный CRUD не принимает id от клиента. Если системной - // интеграции понадобится внешний id, конкретный вызов должен явно передать true. - if ($allowIdFromPayload && isset($payload['id']) && method_exists($entity, 'setId')) { - $id = (int) $payload['id']; - if ($id > 0) { - $entity->setId($id); - } - } - if (($validationResponse = $this->validate($entity)) !== null) { return $validationResponse; } From 02897a1fdba054b2b5964d9af16bdd931a7913ce Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Fri, 15 May 2026 16:11:53 +0300 Subject: [PATCH 05/11] issues/27: denormalize CRUD payloads and align error responses with legacy clients --- src/Repository/ContentFilterTrait.php | 23 ++++++--- src/Service/Crud/CrudResponder.php | 74 +++++++++++++++------------ 2 files changed, 55 insertions(+), 42 deletions(-) diff --git a/src/Repository/ContentFilterTrait.php b/src/Repository/ContentFilterTrait.php index 6a47ec2..87366e9 100644 --- a/src/Repository/ContentFilterTrait.php +++ b/src/Repository/ContentFilterTrait.php @@ -17,17 +17,24 @@ use Doctrine\ORM\QueryBuilder; * - regionId / region_id: целое > 0; * - active: bool; * - alias: точное совпадение; - * - search / q: LIKE по name в lower-case. + * - search / q: LIKE по lower-case значению заданного поля (по умолчанию `name`). * - * Важно: search использует LOWER(name), поэтому для больших таблиц нужен - * функциональный индекс в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)). + * Поле поиска параметризовано через $searchField на случай сущностей, + * где основное текстовое поле называется иначе (например, `title`). + * Если у сущности нет такого свойства, Doctrine упадёт с QueryException — это + * лучше ловится тестами на этапе разработки, чем 500 в проде. + * + * Важно: LOWER($alias.$searchField) при больших таблицах требует функционального + * индекса в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)). */ trait ContentFilterTrait { - /** - */ - private function applyCommonFilters(QueryBuilder $qb, string $alias, ContentFilterDto $filters): void - { + private function applyCommonFilters( + QueryBuilder $qb, + string $alias, + ContentFilterDto $filters, + string $searchField = 'name', + ): void { if ($filters->regionId !== null) { $qb->andWhere("$alias.regionId = :regionId") ->setParameter('regionId', $filters->regionId); @@ -44,7 +51,7 @@ trait ContentFilterTrait } if ($filters->search !== null) { - $qb->andWhere("LOWER($alias.name) LIKE :search") + $qb->andWhere("LOWER($alias.$searchField) LIKE :search") ->setParameter('search', '%' . mb_strtolower($filters->search) . '%'); } } diff --git a/src/Service/Crud/CrudResponder.php b/src/Service/Crud/CrudResponder.php index e8dd60a..a775767 100644 --- a/src/Service/Crud/CrudResponder.php +++ b/src/Service/Crud/CrudResponder.php @@ -4,6 +4,7 @@ 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; @@ -11,23 +12,28 @@ 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-ответчик для тонких контент-контроллеров. * - * Инкапсулирует общую логику десериализации тела запроса по группе :write, - * валидации сущности, persist/flush и сериализации ответа по группе :read. - * - * Контроллер становится декларативным: - * return $this->crud->create($request, News::class, ['news:write'], ['news:read']); + * Контракт ответов специально сохранён близким к старым *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, ) { } @@ -61,15 +67,15 @@ final class CrudResponder try { /** @var T $entity */ - $entity = $this->serializer->deserialize( - $this->encodePayload($payload), + $entity = $this->denormalizer->denormalize( + $payload, $entityClass, - 'json', + null, [ AbstractNormalizer::GROUPS => $writeGroups, ], ); - } catch (JsonException|SerializerExceptionInterface $e) { + } catch (SerializerExceptionInterface $e) { return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); } @@ -100,16 +106,16 @@ final class CrudResponder unset($payload['id']); try { - $this->serializer->deserialize( - $this->encodePayload($payload), + $this->denormalizer->denormalize( + $payload, $entity::class, - 'json', + null, [ AbstractNormalizer::GROUPS => $writeGroups, AbstractNormalizer::OBJECT_TO_POPULATE => $entity, ], ); - } catch (JsonException|SerializerExceptionInterface $e) { + } catch (SerializerExceptionInterface $e) { return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); } @@ -124,34 +130,37 @@ final class CrudResponder public function delete(object $entity): JsonResponse { - $this->em->remove($entity); - $this->em->flush(); + 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|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) { + } catch (JsonException|\UnexpectedValueException) { return null; } } - /** - * @param array $payload - * - * @throws JsonException - */ - private function encodePayload(array $payload): string - { - return json_encode($payload, JSON_THROW_ON_ERROR); - } - private function validate(object $entity): ?JsonResponse { $errors = $this->validator->validate($entity); @@ -159,15 +168,12 @@ final class CrudResponder return null; } - $formatted = []; - foreach ($errors as $error) { - $formatted[] = [ - 'property' => $error->getPropertyPath(), - 'message' => $error->getMessage(), - ]; - } + // BC: легаси-контроллеры возвращали именно сериализованный ConstraintViolationList + // с кодом 400. Этот же формат продолжаем отдавать здесь, чтобы фронтенду + // не пришлось переписывать парсинг ошибок. + $json = $this->serializer->serialize($errors, 'json'); - return new JsonResponse(['errors' => $formatted], Response::HTTP_UNPROCESSABLE_ENTITY); + return new JsonResponse($json, Response::HTTP_BAD_REQUEST, [], true); } /** From 7a4fda31a4bc8724f6cb62fe681031c23c52e9f0 Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Fri, 15 May 2026 16:20:45 +0300 Subject: [PATCH 06/11] issues/27: Content list filters: restore default active=true; CrudResponder denormalize and legacy... --- src/Controller/MedicalCenterController.php | 4 ++-- src/Controller/NewsController.php | 4 ++-- src/Controller/PromoController.php | 4 ++-- src/Controller/SiteServiceController.php | 4 ++-- src/Dto/Content/ContentFilterDto.php | 12 ++++++++++-- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/Controller/MedicalCenterController.php b/src/Controller/MedicalCenterController.php index 474a765..111bd43 100644 --- a/src/Controller/MedicalCenterController.php +++ b/src/Controller/MedicalCenterController.php @@ -32,12 +32,12 @@ final class MedicalCenterController extends AbstractController #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] - #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] + #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] #[Route('/list', name: 'medical_center_list', methods: ['GET'])] public function list(Request $request, MedicalCenterRepository $repository): JsonResponse { - $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, diff --git a/src/Controller/NewsController.php b/src/Controller/NewsController.php index 08b29ac..fd9fb10 100644 --- a/src/Controller/NewsController.php +++ b/src/Controller/NewsController.php @@ -32,12 +32,12 @@ final class NewsController extends AbstractController #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] - #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] + #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] #[Route('/list', name: 'news_list', methods: ['GET'])] public function list(Request $request, NewsRepository $repository): JsonResponse { - $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, diff --git a/src/Controller/PromoController.php b/src/Controller/PromoController.php index ba95aad..48aafdd 100644 --- a/src/Controller/PromoController.php +++ b/src/Controller/PromoController.php @@ -32,12 +32,12 @@ final class PromoController extends AbstractController #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] - #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] + #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] #[Route('/list', name: 'promo_list', methods: ['GET'])] public function list(Request $request, PromoRepository $repository): JsonResponse { - $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, diff --git a/src/Controller/SiteServiceController.php b/src/Controller/SiteServiceController.php index 32a232f..5443153 100644 --- a/src/Controller/SiteServiceController.php +++ b/src/Controller/SiteServiceController.php @@ -32,12 +32,12 @@ final class SiteServiceController extends AbstractController #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] - #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] + #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] #[Route('/list', name: 'site_service_list', methods: ['GET'])] public function list(Request $request, SiteServiceRepository $repository): JsonResponse { - $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, diff --git a/src/Dto/Content/ContentFilterDto.php b/src/Dto/Content/ContentFilterDto.php index 8cb53cb..f63c6cf 100644 --- a/src/Dto/Content/ContentFilterDto.php +++ b/src/Dto/Content/ContentFilterDto.php @@ -16,11 +16,19 @@ final readonly class ContentFilterDto ) { } - public static function fromRequest(Request $request): self + /** + * @param ?bool $defaultActive если задан (например, true), подставляется, + * когда query-параметр `active` отсутствует или пустой. + * Легаси: в старых list-эндпоинтах News/Promo/MedicalCenter/SiteService + * при отсутствии `active` подразумевалось active = true. + */ + public static function fromRequest(Request $request, ?bool $defaultActive = null): self { + $active = self::nullableBool($request->query->get('active')); + return new self( regionId: self::positiveInt($request->query->get('regionId', $request->query->get('region_id'))), - active: self::nullableBool($request->query->get('active')), + active: $active ?? $defaultActive, alias: self::nonEmptyString($request->query->get('alias')), search: self::nonEmptyString($request->query->get('search', $request->query->get('q'))), ); From 4f76693fe106f486b1622daebd8d30ed21be50b1 Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Fri, 15 May 2026 16:29:30 +0300 Subject: [PATCH 07/11] issues/27: Harden ContentFilterDto query parsing; use DateTimeImmutable in UpdateTimestampTrait --- src/Dto/Content/ContentFilterDto.php | 12 +++++++++++- src/Entity/Behavior/UpdateTimestampTrait.php | 9 +++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Dto/Content/ContentFilterDto.php b/src/Dto/Content/ContentFilterDto.php index f63c6cf..1902b5f 100644 --- a/src/Dto/Content/ContentFilterDto.php +++ b/src/Dto/Content/ContentFilterDto.php @@ -34,9 +34,12 @@ final readonly class ContentFilterDto ); } + /** + * Symfony QueryBag может отдать массив при ?regionId[]=… — не передаём его в is_numeric (TypeError в PHP 8). + */ private static function positiveInt(mixed $value): ?int { - if ($value === null || $value === '' || !is_numeric($value)) { + if ($value === null || $value === '' || !is_scalar($value) || !is_numeric($value)) { return null; } @@ -45,12 +48,19 @@ final readonly class ContentFilterDto return $value > 0 ? $value : null; } + /** + * При ?active[]=… query->get вернёт массив — отбрасываем без вызова filter_var по нему. + */ private static function nullableBool(mixed $value): ?bool { if ($value === null || $value === '') { return null; } + if (!is_scalar($value)) { + return null; + } + if (is_bool($value)) { return $value; } diff --git a/src/Entity/Behavior/UpdateTimestampTrait.php b/src/Entity/Behavior/UpdateTimestampTrait.php index 7d851d3..f68363f 100644 --- a/src/Entity/Behavior/UpdateTimestampTrait.php +++ b/src/Entity/Behavior/UpdateTimestampTrait.php @@ -6,19 +6,24 @@ namespace App\Entity\Behavior; use Doctrine\ORM\Mapping as ORM; +/** + * Требует у класса-сущности свойство `$updateAt` (mapped column). + * + * @property \DateTimeInterface|null $updateAt + */ trait UpdateTimestampTrait { #[ORM\PrePersist] public function setInitialUpdateAt(): void { if ($this->updateAt === null) { - $this->updateAt = new \DateTime(); + $this->updateAt = new \DateTimeImmutable(); } } #[ORM\PreUpdate] public function refreshUpdateAt(): void { - $this->updateAt = new \DateTime(); + $this->updateAt = new \DateTimeImmutable(); } } From 7bfa94bbd68ab5affc7fc81c83147f194572f629 Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Sat, 16 May 2026 13:39:20 +0300 Subject: [PATCH 08/11] issues/27: fix date immutable --- .gitignore | 5 ++++- src/Entity/Article.php | 2 +- src/Entity/Disease.php | 2 +- src/Entity/MedicalCenter.php | 2 +- src/Entity/News.php | 2 +- src/Entity/Promo.php | 2 +- src/Entity/SiteService.php | 2 +- 7 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 844c325..21c50bf 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ yarn.lock /assets/vendor/ ###< symfony/asset-mapper ### -/php: \ No newline at end of file +/php: + +.cursorignore +.env \ No newline at end of file diff --git a/src/Entity/Article.php b/src/Entity/Article.php index 675d15f..3f37913 100644 --- a/src/Entity/Article.php +++ b/src/Entity/Article.php @@ -61,7 +61,7 @@ class Article private ?string $content = null; #[Groups(['article:read'])] - #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] + #[ORM\Column(name: 'update_at', type: Types::DATETIME_IMMUTABLE, nullable: true)] private ?\DateTimeInterface $updateAt = null; public function getId(): ?int diff --git a/src/Entity/Disease.php b/src/Entity/Disease.php index 1851e00..3a33ac3 100644 --- a/src/Entity/Disease.php +++ b/src/Entity/Disease.php @@ -48,7 +48,7 @@ class Disease private ?string $anons = null; #[Groups(['disease:read'])] - #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] + #[ORM\Column(name: 'update_at', type: Types::DATETIME_IMMUTABLE, nullable: true)] private ?\DateTimeInterface $updateAt = null; #[Groups(['disease:read', 'disease:write'])] diff --git a/src/Entity/MedicalCenter.php b/src/Entity/MedicalCenter.php index 6e01ced..c3411cf 100644 --- a/src/Entity/MedicalCenter.php +++ b/src/Entity/MedicalCenter.php @@ -45,7 +45,7 @@ class MedicalCenter #[Groups(['medical_center:read', 'medical_center:write'])] private ?string $content = null; - #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] + #[ORM\Column(name: 'update_at', type: Types::DATETIME_IMMUTABLE, nullable: true)] #[Groups(['medical_center:read'])] private ?\DateTimeInterface $updateAt = null; diff --git a/src/Entity/News.php b/src/Entity/News.php index f91922a..afd8194 100644 --- a/src/Entity/News.php +++ b/src/Entity/News.php @@ -47,7 +47,7 @@ class News #[Groups(['news:read', 'news:write'])] private ?string $content = null; - #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] + #[ORM\Column(name: 'update_at', type: Types::DATETIME_IMMUTABLE, nullable: true)] #[Groups(['news:read'])] private ?\DateTimeInterface $updateAt = null; diff --git a/src/Entity/Promo.php b/src/Entity/Promo.php index 63076f0..e612da9 100644 --- a/src/Entity/Promo.php +++ b/src/Entity/Promo.php @@ -47,7 +47,7 @@ class Promo #[Groups(['promo:read', 'promo:write'])] private ?string $content = null; - #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] + #[ORM\Column(name: 'update_at', type: Types::DATETIME_IMMUTABLE, nullable: true)] #[Groups(['promo:read'])] private ?\DateTimeInterface $updateAt = null; diff --git a/src/Entity/SiteService.php b/src/Entity/SiteService.php index 9691651..b67737f 100644 --- a/src/Entity/SiteService.php +++ b/src/Entity/SiteService.php @@ -47,7 +47,7 @@ class SiteService #[Groups(['site_service:read', 'site_service:write'])] private ?string $content = null; - #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] + #[ORM\Column(name: 'update_at', type: Types::DATETIME_IMMUTABLE, nullable: true)] #[Groups(['site_service:read'])] private ?\DateTimeInterface $updateAt = null; From fe24537e9fe2c84f5cac0c0c472bc6dc39fdfa10 Mon Sep 17 00:00:00 2001 From: sova-ci Date: Wed, 3 Jun 2026 17:06:17 +0300 Subject: [PATCH 09/11] chore(#27): sync issues/27 from monorepo (bfeabe5 issues/27: fix date immutable) --- config/services.yaml | 22 ------------ src/Command/UploadFilialsCommand.php | 5 ++- src/Command/UploadPriceCommand.php | 3 +- src/Command/UploadPriceDepCommand.php | 3 +- .../AlwaysValidSmartCaptchaClientService.php | 23 ------------ .../Stub/NoopCalltouchClientService.php | 24 ------------- .../Client/Stub/NoopSmsClientService.php | 35 ------------------- .../XmlFeedGeneratorService.php | 7 ++-- .../XmlFeedGeneratorV1Service.php | 5 ++- 9 files changed, 9 insertions(+), 118 deletions(-) delete mode 100644 src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php delete mode 100644 src/Service/Client/Stub/NoopCalltouchClientService.php delete mode 100644 src/Service/Client/Stub/NoopSmsClientService.php diff --git a/config/services.yaml b/config/services.yaml index 81c413c..810547c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,11 +7,6 @@ parameters: app.timezone: 'Europe/Moscow' upload_directory: '%kernel.project_dir%/public/uploads' api.baseurl: '%env(string:API_BASE_URL)%' - api.public_url: '%env(default:api_base_url_default:API_PUBLIC_URL)%' - api_base_url_default: '%env(API_BASE_URL)%' - env(WIDGET_API_URL): '' - widget_api_url: '%env(default:mis_url_default:WIDGET_API_URL)%' - mis_url_default: '%env(MIS_URL)%' mailer_from_email: 'noreply@sova.clinic' mailer_from_name: 'Sova Clinic' mailer_access_token: '' @@ -66,8 +61,6 @@ services: alias: App\Service\Translite\TransliteService App\Command\UploadFilialsCommand: - arguments: - $widgetApiUrl: '%widget_api_url%' tags: ['console.command'] App\Command\UploadDoctorsCommand: @@ -77,13 +70,9 @@ services: tags: ['console.command'] App\Command\UploadPriceDepCommand: - arguments: - $widgetApiUrl: '%widget_api_url%' tags: ['console.command'] App\Command\UploadPriceCommand: - arguments: - $widgetApiUrl: '%widget_api_url%' tags: ['console.command'] App\Command\BitrixUpdateDoctorsCommand: @@ -154,15 +143,6 @@ services: $token: '%env(string:SMSRU_TOKEN)%' $sender: '%env(string:SMSRU_SENDER)%' - App\Service\Client\Interfaces\CalltouchClientServiceInterface: - alias: App\Service\Client\CalltouchClientService - - App\Service\Client\Interfaces\SmartCaptchaClientServiceInterface: - alias: App\Service\Client\SmartCaptchaClientService - - App\Service\Client\Interfaces\SmsClientServiceInterface: - alias: App\Service\Client\SmsruClientService - App\Service\Bitrix\BitrixService: public: true arguments: @@ -192,7 +172,6 @@ services: $specialistService: '@App\Service\Specialist\SpecialistService' $locationService: '@App\Service\Location\LocationService' $filialService: '@App\Service\Filial\FilialService' - $apiPublicUrl: '%api.public_url%' App\Service\XmlFeedGenerator\XmlFeedGeneratorV1Service: arguments: @@ -201,7 +180,6 @@ services: $helperService: '@App\Service\Helper\HelperService' $connection: '@doctrine.dbal.default_connection' $logger: '@logger' - $apiPublicUrl: '%api.public_url%' App\Service\ScheduleCache\ScheduleCacheService: arguments: diff --git a/src/Command/UploadFilialsCommand.php b/src/Command/UploadFilialsCommand.php index d1d1e65..664ff9f 100644 --- a/src/Command/UploadFilialsCommand.php +++ b/src/Command/UploadFilialsCommand.php @@ -23,8 +23,7 @@ class UploadFilialsCommand extends Command private LoggerInterface $logger, private EntityManagerInterface $entityManager, private HttpClientInterface $client, - private TransliteServiceInterface $transliteService, - private string $widgetApiUrl, + private TransliteServiceInterface $transliteService ) { parent::__construct(); @@ -41,7 +40,7 @@ class UploadFilialsCommand extends Command $response = $this->client->request('GET', '/filials/list', [ 'verify_peer' => false, 'verify_host' => false, - 'base_uri' => $this->widgetApiUrl, + 'base_uri' => 'https://widget.sovamed.ru', 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'sovamed_bot' diff --git a/src/Command/UploadPriceCommand.php b/src/Command/UploadPriceCommand.php index 2435fd0..9b1555a 100644 --- a/src/Command/UploadPriceCommand.php +++ b/src/Command/UploadPriceCommand.php @@ -25,7 +25,6 @@ class UploadPriceCommand extends Command public function __construct( private EntityManagerInterface $entityManager, private HttpClientInterface $client, - private string $widgetApiUrl, ) { parent::__construct(); @@ -145,7 +144,7 @@ class UploadPriceCommand extends Command 'verify_peer' => false, 'verify_host' => false, 'timeout' => 60, - 'base_uri' => $this->widgetApiUrl, + 'base_uri' => 'https://widget.sovamed.ru', 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'sovamed_bot' diff --git a/src/Command/UploadPriceDepCommand.php b/src/Command/UploadPriceDepCommand.php index acd09a7..defb90e 100644 --- a/src/Command/UploadPriceDepCommand.php +++ b/src/Command/UploadPriceDepCommand.php @@ -20,7 +20,6 @@ class UploadPriceDepCommand extends Command public function __construct( private EntityManagerInterface $entityManager, private HttpClientInterface $client, - private string $widgetApiUrl, ) { parent::__construct(); @@ -35,7 +34,7 @@ class UploadPriceDepCommand extends Command $response = $this->client->request('GET', '/pricelist/departments', [ 'verify_peer' => false, 'verify_host' => false, - 'base_uri' => $this->widgetApiUrl, + 'base_uri' => 'https://widget.sovamed.ru', 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'sovamed_bot' diff --git a/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php b/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php deleted file mode 100644 index 9d3d9cd..0000000 --- a/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php +++ /dev/null @@ -1,23 +0,0 @@ -logger->info('SmartCaptcha suppressed (noop stub)', [ - 'ip' => $clientIp, - ]); - - return ['status' => 'ok', 'message' => '', 'stub' => true]; - } -} diff --git a/src/Service/Client/Stub/NoopCalltouchClientService.php b/src/Service/Client/Stub/NoopCalltouchClientService.php deleted file mode 100644 index 809df62..0000000 --- a/src/Service/Client/Stub/NoopCalltouchClientService.php +++ /dev/null @@ -1,24 +0,0 @@ -logger->info('Calltouch lead suppressed (noop stub)', [ - 'regionId' => $requests->regionId ?? null, - ]); - - return ['leadId' => 'test-stub', 'stub' => true]; - } -} diff --git a/src/Service/Client/Stub/NoopSmsClientService.php b/src/Service/Client/Stub/NoopSmsClientService.php deleted file mode 100644 index 7956f42..0000000 --- a/src/Service/Client/Stub/NoopSmsClientService.php +++ /dev/null @@ -1,35 +0,0 @@ -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]; - } -} diff --git a/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php b/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php index 896db13..dfa7a1d 100644 --- a/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php +++ b/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php @@ -28,8 +28,7 @@ class XmlFeedGeneratorService private SpecialistService $specialistService, private LocationService $locationService, private FilialService $filialService, - private SpecialistDcodeDescriptionRepository $specialistDcodeDescriptionRepository, - private string $apiPublicUrl, + private SpecialistDcodeDescriptionRepository $specialistDcodeDescriptionRepository ) { $this->dom = new DOMDocument('1.0', 'UTF-8'); $this->dom->formatOutput = true; @@ -115,7 +114,7 @@ class XmlFeedGeneratorService } }; - return rtrim($this->apiPublicUrl, '/') . "/images/logo/{$picture}"; + return "https://api.sovamed.ru/images/logo/{$picture}"; } private function getSpecialistLink(Specialist $specialist): string @@ -172,7 +171,7 @@ class XmlFeedGeneratorService $location->getDepartment() ); $this->addTextElement($doctorElement, 'description', $doctorDescription ?? ''); - $picture = rtrim($this->apiPublicUrl, '/') . "/specialist/picture/{$id}"; + $picture = "https://api.sovamed.ru/specialist/picture/{$id}"; $this->addTextElement($doctorElement, 'picture', $picture); $this->addTextElement($doctorElement, 'name', $doctor->getName() ?? ''); $this->addTextElement($doctorElement, 'first_name', $doctor->getFullName()['firstName'] ?? ''); diff --git a/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php b/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php index d4502f2..dc60a3f 100644 --- a/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php +++ b/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php @@ -26,7 +26,6 @@ class XmlFeedGeneratorV1Service private SpecialistService $specialistService, private HelperService $helperService, private Connection $connection, - private string $apiPublicUrl, private ?LoggerInterface $logger = null, ) { $this->dom = new DOMDocument('1.0', 'UTF-8'); @@ -140,7 +139,7 @@ class XmlFeedGeneratorV1Service } }; - return rtrim($this->apiPublicUrl, '/') . "/images/logo/{$picture}"; + return "https://api.sovamed.ru/images/logo/{$picture}"; } private function getSpecialistLink(Specialist $specialist): string @@ -220,7 +219,7 @@ class XmlFeedGeneratorV1Service $this->addTextElement($offerElement, 'url', $url); $this->addTextElement($offerElement, 'set-ids', $specialist->getDcodes()); - $picture = rtrim($this->apiPublicUrl, '/') . "/specialist/picture/{$specialist->getId()}"; + $picture = "https://api.sovamed.ru/specialist/picture/{$specialist->getId()}"; $this->addTextElement($offerElement, 'picture', $picture); $this->addTextElement($offerElement, 'categoryId', '1'); $this->addTextElement($offerElement, 'currencyId', 'RUR'); From 65934cff3ccac78af221dd56145358997aad4220 Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Wed, 3 Jun 2026 18:37:51 +0300 Subject: [PATCH 10/11] issues/27: sync branch from k3s-test --- config/services.yaml | 22 ++++++++++++ src/Command/UploadFilialsCommand.php | 5 +-- src/Command/UploadPriceCommand.php | 3 +- src/Command/UploadPriceDepCommand.php | 3 +- .../AlwaysValidSmartCaptchaClientService.php | 23 ++++++++++++ .../Stub/NoopCalltouchClientService.php | 24 +++++++++++++ .../Client/Stub/NoopSmsClientService.php | 35 +++++++++++++++++++ .../XmlFeedGeneratorService.php | 7 ++-- .../XmlFeedGeneratorV1Service.php | 5 +-- 9 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php create mode 100644 src/Service/Client/Stub/NoopCalltouchClientService.php create mode 100644 src/Service/Client/Stub/NoopSmsClientService.php diff --git a/config/services.yaml b/config/services.yaml index 810547c..81c413c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,6 +7,11 @@ parameters: app.timezone: 'Europe/Moscow' upload_directory: '%kernel.project_dir%/public/uploads' api.baseurl: '%env(string:API_BASE_URL)%' + api.public_url: '%env(default:api_base_url_default:API_PUBLIC_URL)%' + api_base_url_default: '%env(API_BASE_URL)%' + env(WIDGET_API_URL): '' + widget_api_url: '%env(default:mis_url_default:WIDGET_API_URL)%' + mis_url_default: '%env(MIS_URL)%' mailer_from_email: 'noreply@sova.clinic' mailer_from_name: 'Sova Clinic' mailer_access_token: '' @@ -61,6 +66,8 @@ services: alias: App\Service\Translite\TransliteService App\Command\UploadFilialsCommand: + arguments: + $widgetApiUrl: '%widget_api_url%' tags: ['console.command'] App\Command\UploadDoctorsCommand: @@ -70,9 +77,13 @@ services: tags: ['console.command'] App\Command\UploadPriceDepCommand: + arguments: + $widgetApiUrl: '%widget_api_url%' tags: ['console.command'] App\Command\UploadPriceCommand: + arguments: + $widgetApiUrl: '%widget_api_url%' tags: ['console.command'] App\Command\BitrixUpdateDoctorsCommand: @@ -143,6 +154,15 @@ services: $token: '%env(string:SMSRU_TOKEN)%' $sender: '%env(string:SMSRU_SENDER)%' + App\Service\Client\Interfaces\CalltouchClientServiceInterface: + alias: App\Service\Client\CalltouchClientService + + App\Service\Client\Interfaces\SmartCaptchaClientServiceInterface: + alias: App\Service\Client\SmartCaptchaClientService + + App\Service\Client\Interfaces\SmsClientServiceInterface: + alias: App\Service\Client\SmsruClientService + App\Service\Bitrix\BitrixService: public: true arguments: @@ -172,6 +192,7 @@ services: $specialistService: '@App\Service\Specialist\SpecialistService' $locationService: '@App\Service\Location\LocationService' $filialService: '@App\Service\Filial\FilialService' + $apiPublicUrl: '%api.public_url%' App\Service\XmlFeedGenerator\XmlFeedGeneratorV1Service: arguments: @@ -180,6 +201,7 @@ services: $helperService: '@App\Service\Helper\HelperService' $connection: '@doctrine.dbal.default_connection' $logger: '@logger' + $apiPublicUrl: '%api.public_url%' App\Service\ScheduleCache\ScheduleCacheService: arguments: diff --git a/src/Command/UploadFilialsCommand.php b/src/Command/UploadFilialsCommand.php index 664ff9f..d1d1e65 100644 --- a/src/Command/UploadFilialsCommand.php +++ b/src/Command/UploadFilialsCommand.php @@ -23,7 +23,8 @@ class UploadFilialsCommand extends Command private LoggerInterface $logger, private EntityManagerInterface $entityManager, private HttpClientInterface $client, - private TransliteServiceInterface $transliteService + private TransliteServiceInterface $transliteService, + private string $widgetApiUrl, ) { parent::__construct(); @@ -40,7 +41,7 @@ class UploadFilialsCommand extends Command $response = $this->client->request('GET', '/filials/list', [ 'verify_peer' => false, 'verify_host' => false, - 'base_uri' => 'https://widget.sovamed.ru', + 'base_uri' => $this->widgetApiUrl, 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'sovamed_bot' diff --git a/src/Command/UploadPriceCommand.php b/src/Command/UploadPriceCommand.php index 9b1555a..2435fd0 100644 --- a/src/Command/UploadPriceCommand.php +++ b/src/Command/UploadPriceCommand.php @@ -25,6 +25,7 @@ class UploadPriceCommand extends Command public function __construct( private EntityManagerInterface $entityManager, private HttpClientInterface $client, + private string $widgetApiUrl, ) { parent::__construct(); @@ -144,7 +145,7 @@ class UploadPriceCommand extends Command 'verify_peer' => false, 'verify_host' => false, 'timeout' => 60, - 'base_uri' => 'https://widget.sovamed.ru', + 'base_uri' => $this->widgetApiUrl, 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'sovamed_bot' diff --git a/src/Command/UploadPriceDepCommand.php b/src/Command/UploadPriceDepCommand.php index defb90e..acd09a7 100644 --- a/src/Command/UploadPriceDepCommand.php +++ b/src/Command/UploadPriceDepCommand.php @@ -20,6 +20,7 @@ class UploadPriceDepCommand extends Command public function __construct( private EntityManagerInterface $entityManager, private HttpClientInterface $client, + private string $widgetApiUrl, ) { parent::__construct(); @@ -34,7 +35,7 @@ class UploadPriceDepCommand extends Command $response = $this->client->request('GET', '/pricelist/departments', [ 'verify_peer' => false, 'verify_host' => false, - 'base_uri' => 'https://widget.sovamed.ru', + 'base_uri' => $this->widgetApiUrl, 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'sovamed_bot' diff --git a/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php b/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php new file mode 100644 index 0000000..9d3d9cd --- /dev/null +++ b/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php @@ -0,0 +1,23 @@ +logger->info('SmartCaptcha suppressed (noop stub)', [ + 'ip' => $clientIp, + ]); + + return ['status' => 'ok', 'message' => '', 'stub' => true]; + } +} diff --git a/src/Service/Client/Stub/NoopCalltouchClientService.php b/src/Service/Client/Stub/NoopCalltouchClientService.php new file mode 100644 index 0000000..809df62 --- /dev/null +++ b/src/Service/Client/Stub/NoopCalltouchClientService.php @@ -0,0 +1,24 @@ +logger->info('Calltouch lead suppressed (noop stub)', [ + 'regionId' => $requests->regionId ?? null, + ]); + + return ['leadId' => 'test-stub', 'stub' => true]; + } +} diff --git a/src/Service/Client/Stub/NoopSmsClientService.php b/src/Service/Client/Stub/NoopSmsClientService.php new file mode 100644 index 0000000..7956f42 --- /dev/null +++ b/src/Service/Client/Stub/NoopSmsClientService.php @@ -0,0 +1,35 @@ +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]; + } +} diff --git a/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php b/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php index dfa7a1d..896db13 100644 --- a/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php +++ b/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php @@ -28,7 +28,8 @@ class XmlFeedGeneratorService private SpecialistService $specialistService, private LocationService $locationService, private FilialService $filialService, - private SpecialistDcodeDescriptionRepository $specialistDcodeDescriptionRepository + private SpecialistDcodeDescriptionRepository $specialistDcodeDescriptionRepository, + private string $apiPublicUrl, ) { $this->dom = new DOMDocument('1.0', 'UTF-8'); $this->dom->formatOutput = true; @@ -114,7 +115,7 @@ class XmlFeedGeneratorService } }; - return "https://api.sovamed.ru/images/logo/{$picture}"; + return rtrim($this->apiPublicUrl, '/') . "/images/logo/{$picture}"; } private function getSpecialistLink(Specialist $specialist): string @@ -171,7 +172,7 @@ class XmlFeedGeneratorService $location->getDepartment() ); $this->addTextElement($doctorElement, 'description', $doctorDescription ?? ''); - $picture = "https://api.sovamed.ru/specialist/picture/{$id}"; + $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'] ?? ''); diff --git a/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php b/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php index dc60a3f..d4502f2 100644 --- a/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php +++ b/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php @@ -26,6 +26,7 @@ class XmlFeedGeneratorV1Service private SpecialistService $specialistService, private HelperService $helperService, private Connection $connection, + private string $apiPublicUrl, private ?LoggerInterface $logger = null, ) { $this->dom = new DOMDocument('1.0', 'UTF-8'); @@ -139,7 +140,7 @@ class XmlFeedGeneratorV1Service } }; - return "https://api.sovamed.ru/images/logo/{$picture}"; + return rtrim($this->apiPublicUrl, '/') . "/images/logo/{$picture}"; } private function getSpecialistLink(Specialist $specialist): string @@ -219,7 +220,7 @@ class XmlFeedGeneratorV1Service $this->addTextElement($offerElement, 'url', $url); $this->addTextElement($offerElement, 'set-ids', $specialist->getDcodes()); - $picture = "https://api.sovamed.ru/specialist/picture/{$specialist->getId()}"; + $picture = rtrim($this->apiPublicUrl, '/') . "/specialist/picture/{$specialist->getId()}"; $this->addTextElement($offerElement, 'picture', $picture); $this->addTextElement($offerElement, 'categoryId', '1'); $this->addTextElement($offerElement, 'currencyId', 'RUR'); From 9d452cefa6adf6a2e082dbb0b8113c8b43a118f9 Mon Sep 17 00:00:00 2001 From: sova-ci Date: Tue, 9 Jun 2026 15:42:04 +0300 Subject: [PATCH 11/11] chore(#27): sync issues/27 from monorepo (bfeabe5 issues/27: fix date immutable) --- config/services.yaml | 22 ------------ src/Command/UploadFilialsCommand.php | 5 ++- src/Command/UploadPriceCommand.php | 3 +- src/Command/UploadPriceDepCommand.php | 3 +- .../AlwaysValidSmartCaptchaClientService.php | 23 ------------ .../Stub/NoopCalltouchClientService.php | 24 ------------- .../Client/Stub/NoopSmsClientService.php | 35 ------------------- .../XmlFeedGeneratorService.php | 7 ++-- .../XmlFeedGeneratorV1Service.php | 5 ++- 9 files changed, 9 insertions(+), 118 deletions(-) delete mode 100644 src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php delete mode 100644 src/Service/Client/Stub/NoopCalltouchClientService.php delete mode 100644 src/Service/Client/Stub/NoopSmsClientService.php diff --git a/config/services.yaml b/config/services.yaml index 81c413c..810547c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,11 +7,6 @@ parameters: app.timezone: 'Europe/Moscow' upload_directory: '%kernel.project_dir%/public/uploads' api.baseurl: '%env(string:API_BASE_URL)%' - api.public_url: '%env(default:api_base_url_default:API_PUBLIC_URL)%' - api_base_url_default: '%env(API_BASE_URL)%' - env(WIDGET_API_URL): '' - widget_api_url: '%env(default:mis_url_default:WIDGET_API_URL)%' - mis_url_default: '%env(MIS_URL)%' mailer_from_email: 'noreply@sova.clinic' mailer_from_name: 'Sova Clinic' mailer_access_token: '' @@ -66,8 +61,6 @@ services: alias: App\Service\Translite\TransliteService App\Command\UploadFilialsCommand: - arguments: - $widgetApiUrl: '%widget_api_url%' tags: ['console.command'] App\Command\UploadDoctorsCommand: @@ -77,13 +70,9 @@ services: tags: ['console.command'] App\Command\UploadPriceDepCommand: - arguments: - $widgetApiUrl: '%widget_api_url%' tags: ['console.command'] App\Command\UploadPriceCommand: - arguments: - $widgetApiUrl: '%widget_api_url%' tags: ['console.command'] App\Command\BitrixUpdateDoctorsCommand: @@ -154,15 +143,6 @@ services: $token: '%env(string:SMSRU_TOKEN)%' $sender: '%env(string:SMSRU_SENDER)%' - App\Service\Client\Interfaces\CalltouchClientServiceInterface: - alias: App\Service\Client\CalltouchClientService - - App\Service\Client\Interfaces\SmartCaptchaClientServiceInterface: - alias: App\Service\Client\SmartCaptchaClientService - - App\Service\Client\Interfaces\SmsClientServiceInterface: - alias: App\Service\Client\SmsruClientService - App\Service\Bitrix\BitrixService: public: true arguments: @@ -192,7 +172,6 @@ services: $specialistService: '@App\Service\Specialist\SpecialistService' $locationService: '@App\Service\Location\LocationService' $filialService: '@App\Service\Filial\FilialService' - $apiPublicUrl: '%api.public_url%' App\Service\XmlFeedGenerator\XmlFeedGeneratorV1Service: arguments: @@ -201,7 +180,6 @@ services: $helperService: '@App\Service\Helper\HelperService' $connection: '@doctrine.dbal.default_connection' $logger: '@logger' - $apiPublicUrl: '%api.public_url%' App\Service\ScheduleCache\ScheduleCacheService: arguments: diff --git a/src/Command/UploadFilialsCommand.php b/src/Command/UploadFilialsCommand.php index d1d1e65..664ff9f 100644 --- a/src/Command/UploadFilialsCommand.php +++ b/src/Command/UploadFilialsCommand.php @@ -23,8 +23,7 @@ class UploadFilialsCommand extends Command private LoggerInterface $logger, private EntityManagerInterface $entityManager, private HttpClientInterface $client, - private TransliteServiceInterface $transliteService, - private string $widgetApiUrl, + private TransliteServiceInterface $transliteService ) { parent::__construct(); @@ -41,7 +40,7 @@ class UploadFilialsCommand extends Command $response = $this->client->request('GET', '/filials/list', [ 'verify_peer' => false, 'verify_host' => false, - 'base_uri' => $this->widgetApiUrl, + 'base_uri' => 'https://widget.sovamed.ru', 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'sovamed_bot' diff --git a/src/Command/UploadPriceCommand.php b/src/Command/UploadPriceCommand.php index 2435fd0..9b1555a 100644 --- a/src/Command/UploadPriceCommand.php +++ b/src/Command/UploadPriceCommand.php @@ -25,7 +25,6 @@ class UploadPriceCommand extends Command public function __construct( private EntityManagerInterface $entityManager, private HttpClientInterface $client, - private string $widgetApiUrl, ) { parent::__construct(); @@ -145,7 +144,7 @@ class UploadPriceCommand extends Command 'verify_peer' => false, 'verify_host' => false, 'timeout' => 60, - 'base_uri' => $this->widgetApiUrl, + 'base_uri' => 'https://widget.sovamed.ru', 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'sovamed_bot' diff --git a/src/Command/UploadPriceDepCommand.php b/src/Command/UploadPriceDepCommand.php index acd09a7..defb90e 100644 --- a/src/Command/UploadPriceDepCommand.php +++ b/src/Command/UploadPriceDepCommand.php @@ -20,7 +20,6 @@ class UploadPriceDepCommand extends Command public function __construct( private EntityManagerInterface $entityManager, private HttpClientInterface $client, - private string $widgetApiUrl, ) { parent::__construct(); @@ -35,7 +34,7 @@ class UploadPriceDepCommand extends Command $response = $this->client->request('GET', '/pricelist/departments', [ 'verify_peer' => false, 'verify_host' => false, - 'base_uri' => $this->widgetApiUrl, + 'base_uri' => 'https://widget.sovamed.ru', 'headers' => [ 'Content-Type' => 'application/json', 'User-Agent' => 'sovamed_bot' diff --git a/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php b/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php deleted file mode 100644 index 9d3d9cd..0000000 --- a/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php +++ /dev/null @@ -1,23 +0,0 @@ -logger->info('SmartCaptcha suppressed (noop stub)', [ - 'ip' => $clientIp, - ]); - - return ['status' => 'ok', 'message' => '', 'stub' => true]; - } -} diff --git a/src/Service/Client/Stub/NoopCalltouchClientService.php b/src/Service/Client/Stub/NoopCalltouchClientService.php deleted file mode 100644 index 809df62..0000000 --- a/src/Service/Client/Stub/NoopCalltouchClientService.php +++ /dev/null @@ -1,24 +0,0 @@ -logger->info('Calltouch lead suppressed (noop stub)', [ - 'regionId' => $requests->regionId ?? null, - ]); - - return ['leadId' => 'test-stub', 'stub' => true]; - } -} diff --git a/src/Service/Client/Stub/NoopSmsClientService.php b/src/Service/Client/Stub/NoopSmsClientService.php deleted file mode 100644 index 7956f42..0000000 --- a/src/Service/Client/Stub/NoopSmsClientService.php +++ /dev/null @@ -1,35 +0,0 @@ -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]; - } -} diff --git a/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php b/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php index 896db13..dfa7a1d 100644 --- a/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php +++ b/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php @@ -28,8 +28,7 @@ class XmlFeedGeneratorService private SpecialistService $specialistService, private LocationService $locationService, private FilialService $filialService, - private SpecialistDcodeDescriptionRepository $specialistDcodeDescriptionRepository, - private string $apiPublicUrl, + private SpecialistDcodeDescriptionRepository $specialistDcodeDescriptionRepository ) { $this->dom = new DOMDocument('1.0', 'UTF-8'); $this->dom->formatOutput = true; @@ -115,7 +114,7 @@ class XmlFeedGeneratorService } }; - return rtrim($this->apiPublicUrl, '/') . "/images/logo/{$picture}"; + return "https://api.sovamed.ru/images/logo/{$picture}"; } private function getSpecialistLink(Specialist $specialist): string @@ -172,7 +171,7 @@ class XmlFeedGeneratorService $location->getDepartment() ); $this->addTextElement($doctorElement, 'description', $doctorDescription ?? ''); - $picture = rtrim($this->apiPublicUrl, '/') . "/specialist/picture/{$id}"; + $picture = "https://api.sovamed.ru/specialist/picture/{$id}"; $this->addTextElement($doctorElement, 'picture', $picture); $this->addTextElement($doctorElement, 'name', $doctor->getName() ?? ''); $this->addTextElement($doctorElement, 'first_name', $doctor->getFullName()['firstName'] ?? ''); diff --git a/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php b/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php index d4502f2..dc60a3f 100644 --- a/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php +++ b/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php @@ -26,7 +26,6 @@ class XmlFeedGeneratorV1Service private SpecialistService $specialistService, private HelperService $helperService, private Connection $connection, - private string $apiPublicUrl, private ?LoggerInterface $logger = null, ) { $this->dom = new DOMDocument('1.0', 'UTF-8'); @@ -140,7 +139,7 @@ class XmlFeedGeneratorV1Service } }; - return rtrim($this->apiPublicUrl, '/') . "/images/logo/{$picture}"; + return "https://api.sovamed.ru/images/logo/{$picture}"; } private function getSpecialistLink(Specialist $specialist): string @@ -220,7 +219,7 @@ class XmlFeedGeneratorV1Service $this->addTextElement($offerElement, 'url', $url); $this->addTextElement($offerElement, 'set-ids', $specialist->getDcodes()); - $picture = rtrim($this->apiPublicUrl, '/') . "/specialist/picture/{$specialist->getId()}"; + $picture = "https://api.sovamed.ru/specialist/picture/{$specialist->getId()}"; $this->addTextElement($offerElement, 'picture', $picture); $this->addTextElement($offerElement, 'categoryId', '1'); $this->addTextElement($offerElement, 'currencyId', 'RUR');