Files
backend/mr.diff
2026-05-27 19:36:32 +03:00

3256 lines
118 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/migrations/Version20260515142000.php b/migrations/Version20260515142000.php
new file mode 100644
index 0000000..e3fb5e2
--- /dev/null
+++ b/migrations/Version20260515142000.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DoctrineMigrations;
+
+use Doctrine\DBAL\Schema\Schema;
+use Doctrine\Migrations\AbstractMigration;
+
+final class Version20260515142000 extends AbstractMigration
+{
+ private const TABLES = [
+ 'news',
+ 'promo',
+ 'disease',
+ 'medical_center',
+ 'site_services',
+ ];
+
+ public function getDescription(): string
+ {
+ return 'Add generated id defaults for content CRUD entities';
+ }
+
+ public function up(Schema $schema): void
+ {
+ foreach (self::TABLES as $table) {
+ $sequence = $table . '_id_seq';
+
+ $this->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 ef454fe..5ab7989 100644
--- a/src/Controller/ArticleController.php
+++ b/src/Controller/ArticleController.php
@@ -2,54 +2,46 @@
namespace App\Controller;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\Article;
use App\Repository\ArticleRepository;
-use Doctrine\ORM\EntityManagerInterface;
+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;
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
{
+ private const READ_GROUPS = ['article:read'];
+ private const WRITE_GROUPS = ['article:write'];
+
public function __construct(
- private EntityManagerInterface $em,
- private ValidatorInterface $validator,
- private SerializerInterface $serializer
- ) { }
+ 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: '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'))]
+ #[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)));
-
- $filters = [
- 'alias' => $request->query->get('alias', ''),
- 'active' => $request->query->get('active', ''),
- 'regionId' => $request->query->get('regionId', ''),
- ];
+ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request));
- $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->paginateWithLegacyMeta($qb, $request), Response::HTTP_OK, [], [
+ 'groups' => self::READ_GROUPS,
]);
}
@@ -60,99 +52,36 @@ 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')]
+ #[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
{
- 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')]
+ #[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
{
- 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..34ccc34 100644
--- a/src/Controller/DiseaseController.php
+++ b/src/Controller/DiseaseController.php
@@ -2,8 +2,13 @@
namespace App\Controller;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\Disease;
-use App\Service\DiseaseCrudService;
+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;
use Symfony\Component\HttpFoundation\Request;
@@ -14,90 +19,57 @@ 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;
-
- $result = $this->diseaseCrud->getPaginatedList($page, $perPage, $regionId);
- $data = $result['data'];
- $total = $result['total'];
- $perPage = $result['per_page'];
- $totalPages = (int) ceil($total / $perPage);
+ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request));
- 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')]
+ #[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
{
- $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')]
+ #[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(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..111bd43 100644
--- a/src/Controller/MedicalCenterController.php
+++ b/src/Controller/MedicalCenterController.php
@@ -2,8 +2,13 @@
namespace App\Controller;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\MedicalCenter;
-use App\Service\MedicalCenterCrudService;
+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;
use Symfony\Component\HttpFoundation\Request;
@@ -14,72 +19,57 @@ 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', 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): 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(ContentFilterDto::fromRequest($request, true));
- 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')]
+ #[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
{
- $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')]
+ #[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(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..fd9fb10 100644
--- a/src/Controller/NewsController.php
+++ b/src/Controller/NewsController.php
@@ -2,8 +2,13 @@
namespace App\Controller;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\News;
-use App\Service\NewsCrudService;
+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;
use Symfony\Component\HttpFoundation\Request;
@@ -14,72 +19,57 @@ 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', 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): 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(ContentFilterDto::fromRequest($request, true));
- 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')]
+ #[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
{
- $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')]
+ #[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(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..48aafdd 100644
--- a/src/Controller/PromoController.php
+++ b/src/Controller/PromoController.php
@@ -2,8 +2,13 @@
namespace App\Controller;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\Promo;
-use App\Service\PromoCrudService;
+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;
use Symfony\Component\HttpFoundation\Request;
@@ -14,72 +19,57 @@ 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', 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): 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(ContentFilterDto::fromRequest($request, true));
- 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')]
+ #[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
{
- $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')]
+ #[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(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..5443153 100644
--- a/src/Controller/SiteServiceController.php
+++ b/src/Controller/SiteServiceController.php
@@ -2,8 +2,13 @@
namespace App\Controller;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\SiteService;
-use App\Service\SiteServiceCrudService;
+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;
use Symfony\Component\HttpFoundation\Request;
@@ -14,91 +19,57 @@ 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', 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): 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);
- }
-
- $result = $this->siteServiceCrud->getPaginatedList($page, $perPage, $regionId, $active);
- $data = $result['data'];
- $total = $result['total'];
- $perPage = $result['per_page'];
- $totalPages = (int) ceil($total / $perPage);
+ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true));
- 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')]
+ #[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
{
- $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')]
+ #[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(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/Dto/Content/ContentFilterDto.php b/src/Dto/Content/ContentFilterDto.php
new file mode 100644
index 0000000..1902b5f
--- /dev/null
+++ b/src/Dto/Content/ContentFilterDto.php
@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Dto\Content;
+
+use Symfony\Component\HttpFoundation\Request;
+
+final readonly class ContentFilterDto
+{
+ public function __construct(
+ public ?int $regionId = null,
+ public ?bool $active = null,
+ public ?string $alias = null,
+ public ?string $search = null,
+ ) {
+ }
+
+ /**
+ * @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: $active ?? $defaultActive,
+ alias: self::nonEmptyString($request->query->get('alias')),
+ search: self::nonEmptyString($request->query->get('search', $request->query->get('q'))),
+ );
+ }
+
+ /**
+ * Symfony QueryBag может отдать массив при ?regionId[]=… — не передаём его в is_numeric (TypeError в PHP 8).
+ */
+ private static function positiveInt(mixed $value): ?int
+ {
+ if ($value === null || $value === '' || !is_scalar($value) || !is_numeric($value)) {
+ return null;
+ }
+
+ $value = (int) $value;
+
+ 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;
+ }
+
+ 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..f68363f
--- /dev/null
+++ b/src/Entity/Behavior/UpdateTimestampTrait.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+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 \DateTimeImmutable();
+ }
+ }
+
+ #[ORM\PreUpdate]
+ public function refreshUpdateAt(): void
+ {
+ $this->updateAt = new \DateTimeImmutable();
+ }
+}
diff --git a/src/Entity/Disease.php b/src/Entity/Disease.php
index 26fc0bd..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,10 +12,14 @@ 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")]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id = null;
@@ -42,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 b116c78..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,9 +10,13 @@ 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)]
#[Groups(['medical_center:read'])]
private ?int $id = null;
@@ -41,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 94dd5e9..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,9 +12,13 @@ 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)]
#[Groups(['news:read'])]
private ?int $id = null;
@@ -43,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 94bb004..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,9 +12,13 @@ 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)]
#[Groups(['promo:read'])]
private ?int $id = null;
@@ -43,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 cf48e0f..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,9 +12,13 @@ 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)]
#[Groups(['site_service:read'])]
private ?int $id = null;
@@ -43,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 697d852..e194953 100644
--- a/src/Repository/ArticleRepository.php
+++ b/src/Repository/ArticleRepository.php
@@ -2,8 +2,10 @@
namespace App\Repository;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -11,63 +13,34 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class ArticleRepository extends ServiceEntityRepository
{
+ use ContentFilterTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
- public function findByFilters(array $filters, int $page = 1, int $limit = 20): array
+ /**
+ */
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
{
- $qb = $this->createQueryBuilder('a');
-
- 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']);
- }
+ $qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC');
- $qb->orderBy('a.id', 'DESC');
+ $this->applyCommonFilters($qb, 'a', $filters);
- $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 +52,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/ContentFilterTrait.php b/src/Repository/ContentFilterTrait.php
new file mode 100644
index 0000000..87366e9
--- /dev/null
+++ b/src/Repository/ContentFilterTrait.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repository;
+
+use App\Dto\Content\ContentFilterDto;
+use Doctrine\ORM\QueryBuilder;
+
+/**
+ * Общие фильтры для контентных репозиториев (News/Promo/Disease/MedicalCenter/Article/SiteService).
+ *
+ * Trait подключается в Doctrine-репозитории, чтобы не держать бизнес-фильтры
+ * в статическом helper-классе и при этом не копировать одинаковые if-блоки.
+ *
+ * Поддерживается:
+ * - regionId / region_id: целое > 0;
+ * - active: bool;
+ * - alias: точное совпадение;
+ * - search / q: LIKE по lower-case значению заданного поля (по умолчанию `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,
+ string $searchField = 'name',
+ ): void {
+ if ($filters->regionId !== null) {
+ $qb->andWhere("$alias.regionId = :regionId")
+ ->setParameter('regionId', $filters->regionId);
+ }
+
+ if ($filters->active !== null) {
+ $qb->andWhere("$alias.active = :active")
+ ->setParameter('active', $filters->active);
+ }
+
+ if ($filters->alias !== null) {
+ $qb->andWhere("$alias.alias = :aliasValue")
+ ->setParameter('aliasValue', $filters->alias);
+ }
+
+ if ($filters->search !== null) {
+ $qb->andWhere("LOWER($alias.$searchField) LIKE :search")
+ ->setParameter('search', '%' . mb_strtolower($filters->search) . '%');
+ }
+ }
+}
diff --git a/src/Repository/DiseaseRepository.php b/src/Repository/DiseaseRepository.php
index 24f4994..33dd6b1 100644
--- a/src/Repository/DiseaseRepository.php
+++ b/src/Repository/DiseaseRepository.php
@@ -2,8 +2,10 @@
namespace App\Repository;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\Disease;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class DiseaseRepository extends ServiceEntityRepository
{
+ use ContentFilterTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Disease::class);
}
+
+ /**
+ */
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
+ {
+ $qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC');
+
+ $this->applyCommonFilters($qb, 'd', $filters);
+
+ return $qb;
+ }
}
diff --git a/src/Repository/MedicalCenterRepository.php b/src/Repository/MedicalCenterRepository.php
index 7088a7c..021af74 100644
--- a/src/Repository/MedicalCenterRepository.php
+++ b/src/Repository/MedicalCenterRepository.php
@@ -2,8 +2,10 @@
namespace App\Repository;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\MedicalCenter;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class MedicalCenterRepository extends ServiceEntityRepository
{
+ use ContentFilterTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MedicalCenter::class);
}
+
+ /**
+ */
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
+ {
+ $qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC');
+
+ $this->applyCommonFilters($qb, 'm', $filters);
+
+ return $qb;
+ }
}
diff --git a/src/Repository/NewsRepository.php b/src/Repository/NewsRepository.php
index 9607b31..4520283 100644
--- a/src/Repository/NewsRepository.php
+++ b/src/Repository/NewsRepository.php
@@ -2,8 +2,10 @@
namespace App\Repository;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\News;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -14,8 +16,25 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class NewsRepository extends ServiceEntityRepository
{
+ use ContentFilterTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, News::class);
}
+
+ /**
+ * Готовит QueryBuilder под пагинацию (Pagerfanta\QueryAdapter).
+ *
+ * Поддерживаемые фильтры: regionId, active (по умолчанию true), alias, search.
+ *
+ */
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
+ {
+ $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC');
+
+ $this->applyCommonFilters($qb, 'n', $filters);
+
+ return $qb;
+ }
}
diff --git a/src/Repository/PromoRepository.php b/src/Repository/PromoRepository.php
index 5a5c4c6..3d73d2b 100644
--- a/src/Repository/PromoRepository.php
+++ b/src/Repository/PromoRepository.php
@@ -2,8 +2,10 @@
namespace App\Repository;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\Promo;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class PromoRepository extends ServiceEntityRepository
{
+ use ContentFilterTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Promo::class);
}
+
+ /**
+ */
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
+ {
+ $qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC');
+
+ $this->applyCommonFilters($qb, 'p', $filters);
+
+ return $qb;
+ }
}
diff --git a/src/Repository/SiteServiceRepository.php b/src/Repository/SiteServiceRepository.php
index 1a07399..73d834a 100644
--- a/src/Repository/SiteServiceRepository.php
+++ b/src/Repository/SiteServiceRepository.php
@@ -2,8 +2,10 @@
namespace App\Repository;
+use App\Dto\Content\ContentFilterDto;
use App\Entity\SiteService;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class SiteServiceRepository extends ServiceEntityRepository
{
+ use ContentFilterTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SiteService::class);
}
+
+ /**
+ */
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
+ {
+ $qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC');
+
+ $this->applyCommonFilters($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..a775767
--- /dev/null
+++ b/src/Service/Crud/CrudResponder.php
@@ -0,0 +1,195 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Crud;
+
+use Doctrine\DBAL\Exception as DbalException;
+use Doctrine\ORM\EntityManagerInterface;
+use JsonException;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface;
+use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\Serializer\SerializerInterface;
+use Symfony\Component\Validator\Validator\ValidatorInterface;
+
+/**
+ * Универсальный CRUD-ответчик для тонких контент-контроллеров.
+ *
+ * Контракт ответов специально сохранён близким к старым *CrudService/контроллерам,
+ * чтобы не ломать существующих клиентов (фронтенд/мобильное):
+ * - валидация: HTTP 400 + сериализованный ConstraintViolationList
+ * (формат Symfony Serializer по умолчанию, т.е. RFC 7807 с ключом violations);
+ * - удаление с ошибкой БД (например, FK constraint): HTTP 500 + {error, message};
+ * - JSON-ключи запросов/ответов используют camelCase (см. свойства сущностей и группы *:write).
+ * Name converter в config/packages/serializer.yaml не задан намеренно — клиенту
+ * нужен консистентный camelCase, иначе незнакомые ключи будут проигнорированы.
+ */
+final class CrudResponder
+{
+ public function __construct(
+ private EntityManagerInterface $em,
+ private SerializerInterface $serializer,
+ private DenormalizerInterface $denormalizer,
+ private ValidatorInterface $validator,
+ ) {
+ }
+
+ /**
+ * @param list<string> $readGroups
+ */
+ public function read(object $entity, array $readGroups): JsonResponse
+ {
+ return $this->json($entity, Response::HTTP_OK, $readGroups);
+ }
+
+ /**
+ * @template T of object
+ *
+ * @param class-string<T> $entityClass
+ * @param list<string> $writeGroups
+ * @param list<string> $readGroups
+ */
+ public function create(
+ Request $request,
+ string $entityClass,
+ array $writeGroups,
+ array $readGroups,
+ ): JsonResponse {
+ $payload = $this->decodePayload($request);
+ if ($payload === null) {
+ return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
+ }
+ unset($payload['id']);
+
+ try {
+ /** @var T $entity */
+ $entity = $this->denormalizer->denormalize(
+ $payload,
+ $entityClass,
+ null,
+ [
+ AbstractNormalizer::GROUPS => $writeGroups,
+ ],
+ );
+ } catch (SerializerExceptionInterface $e) {
+ return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
+ }
+
+ if (($validationResponse = $this->validate($entity)) !== null) {
+ return $validationResponse;
+ }
+
+ $this->em->persist($entity);
+ $this->em->flush();
+
+ return $this->json($entity, Response::HTTP_CREATED, $readGroups);
+ }
+
+ /**
+ * @param list<string> $writeGroups
+ * @param list<string> $readGroups
+ */
+ public function update(
+ Request $request,
+ object $entity,
+ array $writeGroups,
+ array $readGroups,
+ ): JsonResponse {
+ $payload = $this->decodePayload($request);
+ if ($payload === null) {
+ return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
+ }
+ unset($payload['id']);
+
+ try {
+ $this->denormalizer->denormalize(
+ $payload,
+ $entity::class,
+ null,
+ [
+ AbstractNormalizer::GROUPS => $writeGroups,
+ AbstractNormalizer::OBJECT_TO_POPULATE => $entity,
+ ],
+ );
+ } catch (SerializerExceptionInterface $e) {
+ return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
+ }
+
+ if (($validationResponse = $this->validate($entity)) !== null) {
+ return $validationResponse;
+ }
+
+ $this->em->flush();
+
+ return $this->json($entity, Response::HTTP_OK, $readGroups);
+ }
+
+ public function delete(object $entity): JsonResponse
+ {
+ try {
+ $this->em->remove($entity);
+ $this->em->flush();
+ } catch (DbalException $e) {
+ // Сохраняем легаси-контракт: при FK / NOT NULL / unique ошибках БД
+ // отдаём 500 + {error, message}. См. старый ArticleController::delete.
+ return new JsonResponse(
+ ['error' => 'Ошибка при удалении записи', 'message' => $e->getMessage()],
+ Response::HTTP_INTERNAL_SERVER_ERROR,
+ );
+ }
+
+ return new JsonResponse(null, Response::HTTP_NO_CONTENT);
+ }
+
+ /**
+ * @return array<string, mixed>|null null если тело не является JSON-объектом
+ *
+ * Ловим как нативный \JsonException, так и Symfony\...\HttpFoundation\Exception\JsonException
+ * (последний наследует UnexpectedValueException, а не \JsonException, и без
+ * широкого перехвата Symfony ErrorListener перехватит ошибку до нашего try/catch).
+ */
+ private function decodePayload(Request $request): ?array
+ {
+ try {
+ return $request->toArray();
+ } catch (JsonException|\UnexpectedValueException) {
+ return null;
+ }
+ }
+
+ private function validate(object $entity): ?JsonResponse
+ {
+ $errors = $this->validator->validate($entity);
+ if (count($errors) === 0) {
+ return null;
+ }
+
+ // BC: легаси-контроллеры возвращали именно сериализованный ConstraintViolationList
+ // с кодом 400. Этот же формат продолжаем отдавать здесь, чтобы фронтенду
+ // не пришлось переписывать парсинг ошибок.
+ $json = $this->serializer->serialize($errors, 'json');
+
+ return new JsonResponse($json, Response::HTTP_BAD_REQUEST, [], true);
+ }
+
+ /**
+ * @param list<string> $groups
+ */
+ private function json(mixed $data, int $status, array $groups): JsonResponse
+ {
+ $json = $this->serializer->serialize($data, 'json', [
+ AbstractNormalizer::GROUPS => $groups,
+ ]);
+
+ return new JsonResponse($json, $status, [], true);
+ }
+
+ private function jsonError(string $message, int $status): JsonResponse
+ {
+ return new JsonResponse(['error' => $message], $status);
+ }
+}
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
- ) {
- }
-
- /**
- * @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;
- }
-
- return $this->medicalCenterRepository->findBy($criteria, ['id' => 'ASC']);
- }
-
- 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);
- }
+ public function __construct(
+ private EntityManagerInterface $em,
+ ) {
+ }
+
+ public function syncFromViewCenters(string $viewName = 'public.view_centers'): int
+ {
+ if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
+ throw new \InvalidArgumentException('Invalid view name');
+ }
+
+ $sql = sprintf(
+ 'INSERT INTO medical_center (
+ id,
+ name,
+ active,
+ region_id,
+ alias,
+ anons,
+ content,
+ update_at,
+ kod_uslug,
+ doctors,
+ services,
+ articles,
+ txt_up,
+ main_link_staff,
+ contraindications,
+ hide_picture,
+ indications,
+ link_sale,
+ plus_list,
+ plus_text,
+ plus_title,
+ process_text,
+ process_title,
+ services_list,
+ services_photos,
+ services_title,
+ sort_staff,
+ training_text,
+ training_text_title,
+ why_text,
+ why_title
+ )
+ SELECT
+ id,
+ name,
+ active,
+ region_id,
+ alias,
+ anons,
+ content,
+ update_at,
+ kod_uslug,
+ doctors,
+ services,
+ articles,
+ txt_up,
+ main_link_staff,
+ contraindications,
+ hide_picture,
+ indications,
+ link_sale,
+ plus_list,
+ plus_text,
+ plus_title,
+ process_text,
+ process_title,
+ services_list,
+ services_photos,
+ services_title,
+ sort_staff,
+ training_text,
+ training_text_title,
+ why_text,
+ why_title
+ FROM %s
+ ON CONFLICT (id) DO UPDATE SET
+ name = EXCLUDED.name,
+ active = EXCLUDED.active,
+ region_id = EXCLUDED.region_id,
+ alias = EXCLUDED.alias,
+ anons = EXCLUDED.anons,
+ content = EXCLUDED.content,
+ update_at = EXCLUDED.update_at,
+ kod_uslug = EXCLUDED.kod_uslug,
+ doctors = EXCLUDED.doctors,
+ services = EXCLUDED.services,
+ articles = EXCLUDED.articles,
+ txt_up = EXCLUDED.txt_up,
+ main_link_staff = EXCLUDED.main_link_staff,
+ contraindications = EXCLUDED.contraindications,
+ hide_picture = EXCLUDED.hide_picture,
+ indications = EXCLUDED.indications,
+ link_sale = EXCLUDED.link_sale,
+ plus_list = EXCLUDED.plus_list,
+ plus_text = EXCLUDED.plus_text,
+ plus_title = EXCLUDED.plus_title,
+ process_text = EXCLUDED.process_text,
+ process_title = EXCLUDED.process_title,
+ services_list = EXCLUDED.services_list,
+ services_photos = EXCLUDED.services_photos,
+ services_title = EXCLUDED.services_title,
+ sort_staff = EXCLUDED.sort_staff,
+ training_text = EXCLUDED.training_text,
+ training_text_title = EXCLUDED.training_text_title,
+ why_text = EXCLUDED.why_text,
+ why_title = EXCLUDED.why_title',
+ $viewName
+ );
+
+ return (int) $this->em->getConnection()->executeStatement($sql);
+ }
}
-
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..bb53ee6
--- /dev/null
+++ b/src/Service/Pagination/Paginator.php
@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Pagination;
+
+use Doctrine\ORM\QueryBuilder;
+use Pagerfanta\Doctrine\ORM\QueryAdapter;
+use Pagerfanta\Exception\NotValidCurrentPageException;
+use Pagerfanta\Pagerfanta;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Унифицированная обёртка над Pagerfanta + QueryAdapter.
+ *
+ * Соответствует существующему стилю проекта (см. PriceListController/SpecialistController):
+ * читает page/perPage из Request, ограничивает perPage и возвращает массив
+ * ['data' => [...], 'pagination' => [...]] в едином формате для новых list-контрактов.
+ */
+final class Paginator
+{
+ public const DEFAULT_PER_PAGE = 50;
+ public const MAX_PER_PAGE = 500;
+
+ /**
+ * @return array{data: list<mixed>, pagination: array<string, int|bool>}
+ */
+ public function paginate(
+ QueryBuilder $qb,
+ Request $request,
+ int $defaultPerPage = self::DEFAULT_PER_PAGE,
+ int $maxPerPage = self::MAX_PER_PAGE,
+ ): array {
+ $page = max(1, $request->query->getInt('page', 1));
+ $perPage = min(
+ max(1, $request->query->getInt('perPage', $defaultPerPage)),
+ $maxPerPage,
+ );
+
+ $pagerfanta = (new Pagerfanta(new QueryAdapter($qb)))
+ ->setMaxPerPage($perPage);
+
+ try {
+ $pagerfanta->setCurrentPage($page);
+ } catch (NotValidCurrentPageException) {
+ // выходим за пределы — возвращаем пустую страницу с корректным total
+ $pagerfanta->setCurrentPage(max(1, $pagerfanta->getNbPages()));
+ }
+
+ $data = iterator_to_array($pagerfanta->getCurrentPageResults(), false);
+
+ return [
+ 'data' => $data,
+ 'pagination' => [
+ 'total' => $pagerfanta->getNbResults(),
+ 'count' => count($data),
+ 'per_page' => $pagerfanta->getMaxPerPage(),
+ 'current_page' => $pagerfanta->getCurrentPage(),
+ 'total_pages' => $pagerfanta->getNbPages(),
+ 'has_previous_page' => $pagerfanta->hasPreviousPage(),
+ 'has_next_page' => $pagerfanta->hasNextPage(),
+ ],
+ ];
+ }
+
+ /**
+ * Legacy-формат для ArticleController.
+ *
+ * Старый контракт /article/list уже использовался клиентами:
+ * - размер страницы приходит в query-параметре limit;
+ * - метаданные лежат в ключе meta;
+ * - поля называются total/page/limit/totalPages.
+ *
+ * @return array{data: list<mixed>, meta: array{total: int, page: int, limit: int, totalPages: int}}
+ */
+ public function paginateWithLegacyMeta(
+ QueryBuilder $qb,
+ Request $request,
+ int $defaultLimit = 20,
+ int $maxLimit = 100,
+ ): array {
+ $page = max(1, $request->query->getInt('page', 1));
+ $limit = min(
+ max(1, $request->query->getInt('limit', $defaultLimit)),
+ $maxLimit,
+ );
+
+ $pagerfanta = (new Pagerfanta(new QueryAdapter($qb)))
+ ->setMaxPerPage($limit);
+
+ try {
+ $pagerfanta->setCurrentPage($page);
+ } catch (NotValidCurrentPageException) {
+ $pagerfanta->setCurrentPage(max(1, $pagerfanta->getNbPages()));
+ }
+
+ return [
+ 'data' => iterator_to_array($pagerfanta->getCurrentPageResults(), false),
+ 'meta' => [
+ 'total' => $pagerfanta->getNbResults(),
+ 'page' => $pagerfanta->getCurrentPage(),
+ 'limit' => $pagerfanta->getMaxPerPage(),
+ 'totalPages' => $pagerfanta->getNbPages(),
+ ],
+ ];
+ }
+}
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);
}
}