Diff to HTML by rtfpessoa

Files changed (30) hide show
  1. config/packages/nelmio_api_doc.yaml +8 -2
  2. migrations/Version20260515142000.php +53 -0
  3. src/Controller/ArticleController.php +30 -101
  4. src/Controller/DiseaseController.php +28 -56
  5. src/Controller/MedicalCenterController.php +28 -38
  6. src/Controller/NewsController.php +28 -38
  7. src/Controller/PromoController.php +28 -38
  8. src/Controller/SiteServiceController.php +28 -57
  9. src/Dto/Content/ContentFilterDto.php +81 -0
  10. src/Entity/Article.php +5 -1
  11. src/Entity/Behavior/UpdateTimestampTrait.php +29 -0
  12. src/Entity/Disease.php +6 -1
  13. src/Entity/MedicalCenter.php +6 -1
  14. src/Entity/News.php +6 -1
  15. src/Entity/Promo.php +6 -1
  16. src/Entity/SiteService.php +6 -1
  17. src/Repository/ArticleRepository.php +18 -43
  18. src/Repository/ContentFilterTrait.php +58 -0
  19. src/Repository/DiseaseRepository.php +15 -0
  20. src/Repository/MedicalCenterRepository.php +15 -0
  21. src/Repository/NewsRepository.php +19 -0
  22. src/Repository/PromoRepository.php +15 -0
  23. src/Repository/SiteServiceRepository.php +15 -0
  24. src/Service/Crud/CrudResponder.php +195 -0
  25. src/Service/DiseaseCrudService.php +6 -186
  26. src/Service/MedicalCenterCrudService.php +119 -304
  27. src/Service/NewsCrudService.php +8 -128
  28. src/Service/Pagination/Paginator.php +107 -0
  29. src/Service/PromoCrudService.php +6 -128
  30. src/Service/SiteServiceCrudService.php +7 -339
config/packages/nelmio_api_doc.yaml CHANGED
@@ -16,5 +16,11 @@ nelmio_api_doc:
16
  '^/specialist/list$',
17
  '^/specialist/schedule$',
18
  '^/pricelist/list$',
19
- '^/pricelist/department$'
20
- ]
 
 
 
 
 
 
 
16
  '^/specialist/list$',
17
  '^/specialist/schedule$',
18
  '^/pricelist/list$',
19
+ '^/pricelist/department$',
20
+ '^/news($|/)',
21
+ '^/promo($|/)',
22
+ '^/disease($|/)',
23
+ '^/medical-center($|/)',
24
+ '^/article($|/)',
25
+ '^/site-services($|/)'
26
+ ]
migrations/Version20260515142000.php ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace DoctrineMigrations;
6
+
7
+ use Doctrine\DBAL\Schema\Schema;
8
+ use Doctrine\Migrations\AbstractMigration;
9
+
10
+ final class Version20260515142000 extends AbstractMigration
11
+ {
12
+ private const TABLES = [
13
+ 'news',
14
+ 'promo',
15
+ 'disease',
16
+ 'medical_center',
17
+ 'site_services',
18
+ ];
19
+
20
+ public function getDescription(): string
21
+ {
22
+ return 'Add generated id defaults for content CRUD entities';
23
+ }
24
+
25
+ public function up(Schema $schema): void
26
+ {
27
+ foreach (self::TABLES as $table) {
28
+ $sequence = $table . '_id_seq';
29
+
30
+ $this->addSql(sprintf('CREATE SEQUENCE IF NOT EXISTS %s OWNED BY %s.id', $sequence, $table));
31
+ $this->addSql(sprintf(
32
+ 'SELECT setval(\'%s\', COALESCE((SELECT MAX(id) FROM %s), 0) + 1, false)',
33
+ $sequence,
34
+ $table,
35
+ ));
36
+ $this->addSql(sprintf(
37
+ 'ALTER TABLE %s ALTER COLUMN id SET DEFAULT nextval(\'%s\')',
38
+ $table,
39
+ $sequence,
40
+ ));
41
+ }
42
+ }
43
+
44
+ public function down(Schema $schema): void
45
+ {
46
+ foreach (array_reverse(self::TABLES) as $table) {
47
+ $sequence = $table . '_id_seq';
48
+
49
+ $this->addSql(sprintf('ALTER TABLE %s ALTER COLUMN id DROP DEFAULT', $table));
50
+ $this->addSql(sprintf('DROP SEQUENCE IF EXISTS %s', $sequence));
51
+ }
52
+ }
53
+ }
src/Controller/ArticleController.php CHANGED
@@ -2,54 +2,46 @@
2
 
3
  namespace App\Controller;
4
 
 
5
  use App\Entity\Article;
6
  use App\Repository\ArticleRepository;
7
- use Doctrine\ORM\EntityManagerInterface;
 
 
 
8
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
9
  use Symfony\Component\HttpFoundation\JsonResponse;
10
  use Symfony\Component\HttpFoundation\Request;
11
  use Symfony\Component\HttpFoundation\Response;
12
  use Symfony\Component\Routing\Annotation\Route;
13
  use Symfony\Component\Security\Http\Attribute\IsGranted;
14
- use Symfony\Component\Serializer\SerializerInterface;
15
- use Symfony\Component\Validator\Validator\ValidatorInterface;
16
- use Exception;
17
 
18
  #[Route('/article')]
19
  final class ArticleController extends AbstractController
20
  {
 
 
 
21
  public function __construct(
22
- private EntityManagerInterface $em,
23
- private ValidatorInterface $validator,
24
- private SerializerInterface $serializer
25
- ) { }
26
 
 
 
 
 
 
 
 
27
  #[Route('/list', name: 'article_list', methods: ['GET'])]
28
  public function list(Request $request, ArticleRepository $repository): JsonResponse
29
  {
30
- $page = max(1, (int) $request->query->get('page', 1));
31
- $limit = min(100, max(1, (int) $request->query->get('limit', 20)));
32
-
33
- $filters = [
34
- 'alias' => $request->query->get('alias', ''),
35
- 'active' => $request->query->get('active', ''),
36
- 'regionId' => $request->query->get('regionId', ''),
37
- ];
38
 
39
- $articles = $repository->findByFilters($filters, $page, $limit);
40
- $total = $repository->countByFilters($filters);
41
- $totalPages = (int) ceil($total / $limit);
42
-
43
- return $this->json([
44
- 'data' => $articles,
45
- 'meta' => [
46
- 'total' => $total,
47
- 'page' => $page,
48
- 'limit' => $limit,
49
- 'totalPages' => $totalPages,
50
- ],
51
- ], Response::HTTP_OK, [], [
52
- 'groups' => ['article:read']
53
  ]);
54
  }
55
 
@@ -60,99 +52,36 @@ final class ArticleController extends AbstractController
60
  if (!$article) {
61
  throw $this->createNotFoundException('Статья не найдена');
62
  }
63
- return $this->json($article, Response::HTTP_OK, [], [
64
- 'groups' => ['article:read']
65
- ]);
66
  }
67
 
68
  #[Route('/{id}', name: 'article_show', methods: ['GET'], requirements: ['id' => '\d+'])]
69
  public function show(Article $article): JsonResponse
70
  {
71
- return $this->json($article, Response::HTTP_OK, [], [
72
- 'groups' => ['article:read']
73
- ]);
74
  }
75
 
76
  #[IsGranted('ROLE_ADMIN')]
 
77
  #[Route('/create', name: 'article_create', methods: ['POST'])]
78
  public function create(Request $request): JsonResponse
79
  {
80
- try {
81
- $article = $this->serializer->deserialize(
82
- $request->getContent(),
83
- Article::class,
84
- 'json',
85
- ['groups' => ['article:write']]
86
- );
87
-
88
- $errors = $this->validator->validate($article);
89
-
90
- if (count($errors) > 0) {
91
- return $this->json($errors, Response::HTTP_BAD_REQUEST);
92
- }
93
-
94
- $this->em->persist($article);
95
- $this->em->flush();
96
-
97
- return $this->json($article, Response::HTTP_CREATED, [], [
98
- 'groups' => ['article:read']
99
- ]);
100
- } catch (Exception $e) {
101
- return new JsonResponse([
102
- 'error' => 'Ошибка при создании статьи',
103
- 'message' => $e->getMessage()
104
- ], Response::HTTP_INTERNAL_SERVER_ERROR);
105
- }
106
  }
107
 
108
  #[IsGranted('ROLE_ADMIN')]
 
109
  #[Route('/{id}', name: 'article_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
110
  public function update(Request $request, Article $article): JsonResponse
111
  {
112
- try {
113
- $this->serializer->deserialize(
114
- $request->getContent(),
115
- Article::class,
116
- 'json',
117
- [
118
- 'groups' => ['article:write'],
119
- 'object_to_populate' => $article
120
- ]
121
- );
122
-
123
- $errors = $this->validator->validate($article);
124
-
125
- if (count($errors) > 0) {
126
- return $this->json($errors, Response::HTTP_BAD_REQUEST);
127
- }
128
-
129
- $this->em->flush();
130
-
131
- return $this->json($article, Response::HTTP_OK, [], [
132
- 'groups' => ['article:read']
133
- ]);
134
- } catch (Exception $e) {
135
- return new JsonResponse([
136
- 'error' => 'Ошибка при обновлении статьи',
137
- 'message' => $e->getMessage()
138
- ], Response::HTTP_INTERNAL_SERVER_ERROR);
139
- }
140
  }
141
 
142
  #[IsGranted('ROLE_ADMIN')]
143
  #[Route('/{id}', name: 'article_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
144
  public function delete(Article $article): JsonResponse
145
  {
146
- try {
147
- $this->em->remove($article);
148
- $this->em->flush();
149
-
150
- return new JsonResponse(null, Response::HTTP_NO_CONTENT);
151
- } catch (Exception $e) {
152
- return new JsonResponse([
153
- 'error' => 'Ошибка при удалении статьи',
154
- 'message' => $e->getMessage()
155
- ], Response::HTTP_INTERNAL_SERVER_ERROR);
156
- }
157
  }
158
  }
 
2
 
3
  namespace App\Controller;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\Article;
7
  use App\Repository\ArticleRepository;
8
+ use App\Service\Crud\CrudResponder;
9
+ use App\Service\Pagination\Paginator;
10
+ use Nelmio\ApiDocBundle\Attribute\Model;
11
+ use OpenApi\Attributes as OA;
12
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
13
  use Symfony\Component\HttpFoundation\JsonResponse;
14
  use Symfony\Component\HttpFoundation\Request;
15
  use Symfony\Component\HttpFoundation\Response;
16
  use Symfony\Component\Routing\Annotation\Route;
17
  use Symfony\Component\Security\Http\Attribute\IsGranted;
 
 
 
18
 
19
  #[Route('/article')]
20
  final class ArticleController extends AbstractController
21
  {
22
+ private const READ_GROUPS = ['article:read'];
23
+ private const WRITE_GROUPS = ['article:write'];
24
+
25
  public function __construct(
26
+ private readonly CrudResponder $crud,
27
+ private readonly Paginator $paginator,
28
+ ) {
29
+ }
30
 
31
+ #[OA\Tag(name: 'Статьи')]
32
+ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))]
33
+ #[OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer'))]
34
+ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))]
35
+ #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))]
36
+ #[OA\Parameter(name: 'alias', in: 'query', schema: new OA\Schema(type: 'string'))]
37
+ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))]
38
  #[Route('/list', name: 'article_list', methods: ['GET'])]
39
  public function list(Request $request, ArticleRepository $repository): JsonResponse
40
  {
41
+ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request));
 
 
 
 
 
 
 
42
 
43
+ return $this->json($this->paginator->paginateWithLegacyMeta($qb, $request), Response::HTTP_OK, [], [
44
+ 'groups' => self::READ_GROUPS,
 
 
 
 
 
 
 
 
 
 
 
 
45
  ]);
46
  }
47
 
 
52
  if (!$article) {
53
  throw $this->createNotFoundException('Статья не найдена');
54
  }
55
+
56
+ return $this->crud->read($article, self::READ_GROUPS);
 
57
  }
58
 
59
  #[Route('/{id}', name: 'article_show', methods: ['GET'], requirements: ['id' => '\d+'])]
60
  public function show(Article $article): JsonResponse
61
  {
62
+ return $this->crud->read($article, self::READ_GROUPS);
 
 
63
  }
64
 
65
  #[IsGranted('ROLE_ADMIN')]
66
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Article::class, groups: self::WRITE_GROUPS)))]
67
  #[Route('/create', name: 'article_create', methods: ['POST'])]
68
  public function create(Request $request): JsonResponse
69
  {
70
+ return $this->crud->create($request, Article::class, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  }
72
 
73
  #[IsGranted('ROLE_ADMIN')]
74
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Article::class, groups: self::WRITE_GROUPS)))]
75
  #[Route('/{id}', name: 'article_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
76
  public function update(Request $request, Article $article): JsonResponse
77
  {
78
+ return $this->crud->update($request, $article, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  }
80
 
81
  #[IsGranted('ROLE_ADMIN')]
82
  #[Route('/{id}', name: 'article_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
83
  public function delete(Article $article): JsonResponse
84
  {
85
+ return $this->crud->delete($article);
 
 
 
 
 
 
 
 
 
 
86
  }
87
  }
src/Controller/DiseaseController.php CHANGED
@@ -2,8 +2,13 @@
2
 
3
  namespace App\Controller;
4
 
 
5
  use App\Entity\Disease;
6
- use App\Service\DiseaseCrudService;
 
 
 
 
7
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8
  use Symfony\Component\HttpFoundation\JsonResponse;
9
  use Symfony\Component\HttpFoundation\Request;
@@ -14,90 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
14
  #[Route('/disease')]
15
  final class DiseaseController extends AbstractController
16
  {
 
 
 
17
  public function __construct(
18
- private DiseaseCrudService $diseaseCrud,
 
19
  ) {
20
  }
21
 
 
 
 
 
 
 
22
  #[Route('/list', name: 'disease_list', methods: ['GET'])]
23
- public function list(Request $request): JsonResponse
24
  {
25
- $page = $request->query->getInt('page', 1);
26
- $perPage = min($request->query->getInt('perPage', 100), 500);
27
- $regionId = $request->query->getInt('regionId', 0) ?: null;
28
-
29
- $result = $this->diseaseCrud->getPaginatedList($page, $perPage, $regionId);
30
- $data = $result['data'];
31
- $total = $result['total'];
32
- $perPage = $result['per_page'];
33
- $totalPages = (int) ceil($total / $perPage);
34
 
35
- return $this->json([
36
- 'data' => $data,
37
- 'pagination' => [
38
- 'total' => $total,
39
- 'count' => count($data),
40
- 'per_page' => $perPage,
41
- 'current_page' => $result['page'],
42
- 'total_pages' => $totalPages,
43
- 'has_previous_page' => $result['page'] > 1,
44
- 'has_next_page' => $result['page'] < $totalPages,
45
- ],
46
- ], Response::HTTP_OK, [], [
47
- 'groups' => ['disease:read'],
48
  ]);
49
  }
50
 
51
  #[Route('/{id}', name: 'disease_show', methods: ['GET'], requirements: ['id' => '\d+'])]
52
  public function show(Disease $disease): JsonResponse
53
  {
54
- return $this->json($disease, Response::HTTP_OK, [], [
55
- 'groups' => ['disease:read'],
56
- ]);
57
  }
58
 
59
  #[IsGranted('ROLE_ADMIN')]
 
60
  #[Route('/create', name: 'disease_create', methods: ['POST'])]
61
  public function create(Request $request): JsonResponse
62
  {
63
- $data = json_decode($request->getContent(), true);
64
- if (!is_array($data)) {
65
- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST);
66
- }
67
-
68
- try {
69
- $disease = $this->diseaseCrud->create($data);
70
- } catch (\InvalidArgumentException $e) {
71
- return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
72
- }
73
-
74
- return $this->json($disease, Response::HTTP_CREATED, [], [
75
- 'groups' => ['disease:read'],
76
- ]);
77
  }
78
 
79
  #[IsGranted('ROLE_ADMIN')]
 
80
  #[Route('/{id}', name: 'disease_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
81
- public function update(Disease $disease, Request $request): JsonResponse
82
  {
83
- $data = json_decode($request->getContent(), true);
84
- if (!is_array($data)) {
85
- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST);
86
- }
87
-
88
- $disease = $this->diseaseCrud->update($disease, $data);
89
-
90
- return $this->json($disease, Response::HTTP_OK, [], [
91
- 'groups' => ['disease:read'],
92
- ]);
93
  }
94
 
95
  #[IsGranted('ROLE_ADMIN')]
96
  #[Route('/{id}', name: 'disease_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
97
  public function delete(Disease $disease): JsonResponse
98
  {
99
- $this->diseaseCrud->delete($disease);
100
-
101
- return new JsonResponse(null, Response::HTTP_NO_CONTENT);
102
  }
103
  }
 
2
 
3
  namespace App\Controller;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\Disease;
7
+ use App\Repository\DiseaseRepository;
8
+ use App\Service\Crud\CrudResponder;
9
+ use App\Service\Pagination\Paginator;
10
+ use Nelmio\ApiDocBundle\Attribute\Model;
11
+ use OpenApi\Attributes as OA;
12
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
13
  use Symfony\Component\HttpFoundation\JsonResponse;
14
  use Symfony\Component\HttpFoundation\Request;
 
19
  #[Route('/disease')]
20
  final class DiseaseController extends AbstractController
21
  {
22
+ private const READ_GROUPS = ['disease:read'];
23
+ private const WRITE_GROUPS = ['disease:write'];
24
+
25
  public function __construct(
26
+ private readonly CrudResponder $crud,
27
+ private readonly Paginator $paginator,
28
  ) {
29
  }
30
 
31
+ #[OA\Tag(name: 'Заболевания')]
32
+ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))]
33
+ #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))]
34
+ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))]
35
+ #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))]
36
+ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))]
37
  #[Route('/list', name: 'disease_list', methods: ['GET'])]
38
+ public function list(Request $request, DiseaseRepository $repository): JsonResponse
39
  {
40
+ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request));
 
 
 
 
 
 
 
 
41
 
42
+ return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
43
+ 'groups' => self::READ_GROUPS,
 
 
 
 
 
 
 
 
 
 
 
44
  ]);
45
  }
46
 
47
  #[Route('/{id}', name: 'disease_show', methods: ['GET'], requirements: ['id' => '\d+'])]
48
  public function show(Disease $disease): JsonResponse
49
  {
50
+ return $this->crud->read($disease, self::READ_GROUPS);
 
 
51
  }
52
 
53
  #[IsGranted('ROLE_ADMIN')]
54
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Disease::class, groups: self::WRITE_GROUPS)))]
55
  #[Route('/create', name: 'disease_create', methods: ['POST'])]
56
  public function create(Request $request): JsonResponse
57
  {
58
+ return $this->crud->create($request, Disease::class, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  }
60
 
61
  #[IsGranted('ROLE_ADMIN')]
62
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Disease::class, groups: self::WRITE_GROUPS)))]
63
  #[Route('/{id}', name: 'disease_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
64
+ public function update(Request $request, Disease $disease): JsonResponse
65
  {
66
+ return $this->crud->update($request, $disease, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
67
  }
68
 
69
  #[IsGranted('ROLE_ADMIN')]
70
  #[Route('/{id}', name: 'disease_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
71
  public function delete(Disease $disease): JsonResponse
72
  {
73
+ return $this->crud->delete($disease);
 
 
74
  }
75
  }
src/Controller/MedicalCenterController.php CHANGED
@@ -2,8 +2,13 @@
2
 
3
  namespace App\Controller;
4
 
 
5
  use App\Entity\MedicalCenter;
6
- use App\Service\MedicalCenterCrudService;
 
 
 
 
7
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8
  use Symfony\Component\HttpFoundation\JsonResponse;
9
  use Symfony\Component\HttpFoundation\Request;
@@ -14,72 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
14
  #[Route('/medical-center')]
15
  final class MedicalCenterController extends AbstractController
16
  {
 
 
 
17
  public function __construct(
18
- private MedicalCenterCrudService $medicalCenterCrud,
 
19
  ) {
20
  }
21
 
 
 
 
 
 
 
22
  #[Route('/list', name: 'medical_center_list', methods: ['GET'])]
23
- public function list(Request $request): JsonResponse
24
  {
25
- $regionId = $request->query->getInt('regionId', 0);
26
- $activeParam = $request->query->get('active');
27
- $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
28
- if ($activeParam !== null && $active === null) {
29
- return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST);
30
- }
31
 
32
- return $this->json(['data' => $this->medicalCenterCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [
33
- 'groups' => ['medical_center:read'],
34
  ]);
35
  }
36
 
37
  #[Route('/{id}', name: 'medical_center_show', methods: ['GET'], requirements: ['id' => '\d+'])]
38
  public function show(MedicalCenter $medicalCenter): JsonResponse
39
  {
40
- return $this->json($medicalCenter, Response::HTTP_OK, [], [
41
- 'groups' => ['medical_center:read'],
42
- ]);
43
  }
44
 
45
  #[IsGranted('ROLE_ADMIN')]
 
46
  #[Route('/create', name: 'medical_center_create', methods: ['POST'])]
47
  public function create(Request $request): JsonResponse
48
  {
49
- $data = json_decode($request->getContent(), true);
50
- if (!is_array($data)) {
51
- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST);
52
- }
53
-
54
- $medicalCenter = $this->medicalCenterCrud->create($data);
55
-
56
- return $this->json($medicalCenter, Response::HTTP_CREATED, [], [
57
- 'groups' => ['medical_center:read'],
58
- ]);
59
  }
60
 
61
  #[IsGranted('ROLE_ADMIN')]
 
62
  #[Route('/{id}', name: 'medical_center_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
63
- public function update(MedicalCenter $medicalCenter, Request $request): JsonResponse
64
  {
65
- $data = json_decode($request->getContent(), true);
66
- if (!is_array($data)) {
67
- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST);
68
- }
69
-
70
- $medicalCenter = $this->medicalCenterCrud->update($medicalCenter, $data);
71
-
72
- return $this->json($medicalCenter, Response::HTTP_OK, [], [
73
- 'groups' => ['medical_center:read'],
74
- ]);
75
  }
76
 
77
  #[IsGranted('ROLE_ADMIN')]
78
  #[Route('/{id}', name: 'medical_center_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
79
  public function delete(MedicalCenter $medicalCenter): JsonResponse
80
  {
81
- $this->medicalCenterCrud->delete($medicalCenter);
82
-
83
- return new JsonResponse(null, Response::HTTP_NO_CONTENT);
84
  }
85
  }
 
2
 
3
  namespace App\Controller;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\MedicalCenter;
7
+ use App\Repository\MedicalCenterRepository;
8
+ use App\Service\Crud\CrudResponder;
9
+ use App\Service\Pagination\Paginator;
10
+ use Nelmio\ApiDocBundle\Attribute\Model;
11
+ use OpenApi\Attributes as OA;
12
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
13
  use Symfony\Component\HttpFoundation\JsonResponse;
14
  use Symfony\Component\HttpFoundation\Request;
 
19
  #[Route('/medical-center')]
20
  final class MedicalCenterController extends AbstractController
21
  {
22
+ private const READ_GROUPS = ['medical_center:read'];
23
+ private const WRITE_GROUPS = ['medical_center:write'];
24
+
25
  public function __construct(
26
+ private readonly CrudResponder $crud,
27
+ private readonly Paginator $paginator,
28
  ) {
29
  }
30
 
31
+ #[OA\Tag(name: 'Центры')]
32
+ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))]
33
+ #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))]
34
+ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))]
35
+ #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))]
36
+ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))]
37
  #[Route('/list', name: 'medical_center_list', methods: ['GET'])]
38
+ public function list(Request $request, MedicalCenterRepository $repository): JsonResponse
39
  {
40
+ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true));
 
 
 
 
 
41
 
42
+ return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
43
+ 'groups' => self::READ_GROUPS,
44
  ]);
45
  }
46
 
47
  #[Route('/{id}', name: 'medical_center_show', methods: ['GET'], requirements: ['id' => '\d+'])]
48
  public function show(MedicalCenter $medicalCenter): JsonResponse
49
  {
50
+ return $this->crud->read($medicalCenter, self::READ_GROUPS);
 
 
51
  }
52
 
53
  #[IsGranted('ROLE_ADMIN')]
54
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: MedicalCenter::class, groups: self::WRITE_GROUPS)))]
55
  #[Route('/create', name: 'medical_center_create', methods: ['POST'])]
56
  public function create(Request $request): JsonResponse
57
  {
58
+ return $this->crud->create($request, MedicalCenter::class, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
59
  }
60
 
61
  #[IsGranted('ROLE_ADMIN')]
62
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: MedicalCenter::class, groups: self::WRITE_GROUPS)))]
63
  #[Route('/{id}', name: 'medical_center_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
64
+ public function update(Request $request, MedicalCenter $medicalCenter): JsonResponse
65
  {
66
+ return $this->crud->update($request, $medicalCenter, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
67
  }
68
 
69
  #[IsGranted('ROLE_ADMIN')]
70
  #[Route('/{id}', name: 'medical_center_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
71
  public function delete(MedicalCenter $medicalCenter): JsonResponse
72
  {
73
+ return $this->crud->delete($medicalCenter);
 
 
74
  }
75
  }
src/Controller/NewsController.php CHANGED
@@ -2,8 +2,13 @@
2
 
3
  namespace App\Controller;
4
 
 
5
  use App\Entity\News;
6
- use App\Service\NewsCrudService;
 
 
 
 
7
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8
  use Symfony\Component\HttpFoundation\JsonResponse;
9
  use Symfony\Component\HttpFoundation\Request;
@@ -14,72 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
14
  #[Route('/news')]
15
  final class NewsController extends AbstractController
16
  {
 
 
 
17
  public function __construct(
18
- private NewsCrudService $newsCrud,
 
19
  ) {
20
  }
21
 
 
 
 
 
 
 
22
  #[Route('/list', name: 'news_list', methods: ['GET'])]
23
- public function list(Request $request): JsonResponse
24
  {
25
- $regionId = $request->query->getInt('regionId', 0);
26
- $activeParam = $request->query->get('active');
27
- $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
28
- if ($activeParam !== null && $active === null) {
29
- return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST);
30
- }
31
 
32
- return $this->json(['data' => $this->newsCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [
33
- 'groups' => ['news:read'],
34
  ]);
35
  }
36
 
37
  #[Route('/{id}', name: 'news_show', methods: ['GET'], requirements: ['id' => '\d+'])]
38
  public function show(News $news): JsonResponse
39
  {
40
- return $this->json($news, Response::HTTP_OK, [], [
41
- 'groups' => ['news:read'],
42
- ]);
43
  }
44
 
45
  #[IsGranted('ROLE_ADMIN')]
 
46
  #[Route('/create', name: 'news_create', methods: ['POST'])]
47
  public function create(Request $request): JsonResponse
48
  {
49
- $data = json_decode($request->getContent(), true);
50
- if (!is_array($data)) {
51
- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST);
52
- }
53
-
54
- $news = $this->newsCrud->create($data);
55
-
56
- return $this->json($news, Response::HTTP_CREATED, [], [
57
- 'groups' => ['news:read'],
58
- ]);
59
  }
60
 
61
  #[IsGranted('ROLE_ADMIN')]
 
62
  #[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
63
- public function update(News $news, Request $request): JsonResponse
64
  {
65
- $data = json_decode($request->getContent(), true);
66
- if (!is_array($data)) {
67
- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST);
68
- }
69
-
70
- $news = $this->newsCrud->update($news, $data);
71
-
72
- return $this->json($news, Response::HTTP_OK, [], [
73
- 'groups' => ['news:read'],
74
- ]);
75
  }
76
 
77
  #[IsGranted('ROLE_ADMIN')]
78
  #[Route('/{id}', name: 'news_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
79
  public function delete(News $news): JsonResponse
80
  {
81
- $this->newsCrud->delete($news);
82
-
83
- return new JsonResponse(null, Response::HTTP_NO_CONTENT);
84
  }
85
  }
 
2
 
3
  namespace App\Controller;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\News;
7
+ use App\Repository\NewsRepository;
8
+ use App\Service\Crud\CrudResponder;
9
+ use App\Service\Pagination\Paginator;
10
+ use Nelmio\ApiDocBundle\Attribute\Model;
11
+ use OpenApi\Attributes as OA;
12
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
13
  use Symfony\Component\HttpFoundation\JsonResponse;
14
  use Symfony\Component\HttpFoundation\Request;
 
19
  #[Route('/news')]
20
  final class NewsController extends AbstractController
21
  {
22
+ private const READ_GROUPS = ['news:read'];
23
+ private const WRITE_GROUPS = ['news:write'];
24
+
25
  public function __construct(
26
+ private readonly CrudResponder $crud,
27
+ private readonly Paginator $paginator,
28
  ) {
29
  }
30
 
31
+ #[OA\Tag(name: 'Новости')]
32
+ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))]
33
+ #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))]
34
+ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))]
35
+ #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))]
36
+ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))]
37
  #[Route('/list', name: 'news_list', methods: ['GET'])]
38
+ public function list(Request $request, NewsRepository $repository): JsonResponse
39
  {
40
+ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true));
 
 
 
 
 
41
 
42
+ return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
43
+ 'groups' => self::READ_GROUPS,
44
  ]);
45
  }
46
 
47
  #[Route('/{id}', name: 'news_show', methods: ['GET'], requirements: ['id' => '\d+'])]
48
  public function show(News $news): JsonResponse
49
  {
50
+ return $this->crud->read($news, self::READ_GROUPS);
 
 
51
  }
52
 
53
  #[IsGranted('ROLE_ADMIN')]
54
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: News::class, groups: self::WRITE_GROUPS)))]
55
  #[Route('/create', name: 'news_create', methods: ['POST'])]
56
  public function create(Request $request): JsonResponse
57
  {
58
+ return $this->crud->create($request, News::class, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
59
  }
60
 
61
  #[IsGranted('ROLE_ADMIN')]
62
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: News::class, groups: self::WRITE_GROUPS)))]
63
  #[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
64
+ public function update(Request $request, News $news): JsonResponse
65
  {
66
+ return $this->crud->update($request, $news, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
67
  }
68
 
69
  #[IsGranted('ROLE_ADMIN')]
70
  #[Route('/{id}', name: 'news_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
71
  public function delete(News $news): JsonResponse
72
  {
73
+ return $this->crud->delete($news);
 
 
74
  }
75
  }
src/Controller/PromoController.php CHANGED
@@ -2,8 +2,13 @@
2
 
3
  namespace App\Controller;
4
 
 
5
  use App\Entity\Promo;
6
- use App\Service\PromoCrudService;
 
 
 
 
7
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8
  use Symfony\Component\HttpFoundation\JsonResponse;
9
  use Symfony\Component\HttpFoundation\Request;
@@ -14,72 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
14
  #[Route('/promo')]
15
  final class PromoController extends AbstractController
16
  {
 
 
 
17
  public function __construct(
18
- private PromoCrudService $promoCrud,
 
19
  ) {
20
  }
21
 
 
 
 
 
 
 
22
  #[Route('/list', name: 'promo_list', methods: ['GET'])]
23
- public function list(Request $request): JsonResponse
24
  {
25
- $regionId = $request->query->getInt('regionId', 0);
26
- $activeParam = $request->query->get('active');
27
- $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
28
- if ($activeParam !== null && $active === null) {
29
- return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST);
30
- }
31
 
32
- return $this->json(['data' => $this->promoCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [
33
- 'groups' => ['promo:read'],
34
  ]);
35
  }
36
 
37
  #[Route('/{id}', name: 'promo_show', methods: ['GET'], requirements: ['id' => '\d+'])]
38
  public function show(Promo $promo): JsonResponse
39
  {
40
- return $this->json($promo, Response::HTTP_OK, [], [
41
- 'groups' => ['promo:read'],
42
- ]);
43
  }
44
 
45
  #[IsGranted('ROLE_ADMIN')]
 
46
  #[Route('/create', name: 'promo_create', methods: ['POST'])]
47
  public function create(Request $request): JsonResponse
48
  {
49
- $data = json_decode($request->getContent(), true);
50
- if (!is_array($data)) {
51
- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST);
52
- }
53
-
54
- $promo = $this->promoCrud->create($data);
55
-
56
- return $this->json($promo, Response::HTTP_CREATED, [], [
57
- 'groups' => ['promo:read'],
58
- ]);
59
  }
60
 
61
  #[IsGranted('ROLE_ADMIN')]
 
62
  #[Route('/{id}', name: 'promo_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
63
- public function update(Promo $promo, Request $request): JsonResponse
64
  {
65
- $data = json_decode($request->getContent(), true);
66
- if (!is_array($data)) {
67
- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST);
68
- }
69
-
70
- $promo = $this->promoCrud->update($promo, $data);
71
-
72
- return $this->json($promo, Response::HTTP_OK, [], [
73
- 'groups' => ['promo:read'],
74
- ]);
75
  }
76
 
77
  #[IsGranted('ROLE_ADMIN')]
78
  #[Route('/{id}', name: 'promo_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
79
  public function delete(Promo $promo): JsonResponse
80
  {
81
- $this->promoCrud->delete($promo);
82
-
83
- return new JsonResponse(null, Response::HTTP_NO_CONTENT);
84
  }
85
  }
 
2
 
3
  namespace App\Controller;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\Promo;
7
+ use App\Repository\PromoRepository;
8
+ use App\Service\Crud\CrudResponder;
9
+ use App\Service\Pagination\Paginator;
10
+ use Nelmio\ApiDocBundle\Attribute\Model;
11
+ use OpenApi\Attributes as OA;
12
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
13
  use Symfony\Component\HttpFoundation\JsonResponse;
14
  use Symfony\Component\HttpFoundation\Request;
 
19
  #[Route('/promo')]
20
  final class PromoController extends AbstractController
21
  {
22
+ private const READ_GROUPS = ['promo:read'];
23
+ private const WRITE_GROUPS = ['promo:write'];
24
+
25
  public function __construct(
26
+ private readonly CrudResponder $crud,
27
+ private readonly Paginator $paginator,
28
  ) {
29
  }
30
 
31
+ #[OA\Tag(name: 'Акции')]
32
+ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))]
33
+ #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))]
34
+ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))]
35
+ #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))]
36
+ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))]
37
  #[Route('/list', name: 'promo_list', methods: ['GET'])]
38
+ public function list(Request $request, PromoRepository $repository): JsonResponse
39
  {
40
+ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true));
 
 
 
 
 
41
 
42
+ return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
43
+ 'groups' => self::READ_GROUPS,
44
  ]);
45
  }
46
 
47
  #[Route('/{id}', name: 'promo_show', methods: ['GET'], requirements: ['id' => '\d+'])]
48
  public function show(Promo $promo): JsonResponse
49
  {
50
+ return $this->crud->read($promo, self::READ_GROUPS);
 
 
51
  }
52
 
53
  #[IsGranted('ROLE_ADMIN')]
54
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Promo::class, groups: self::WRITE_GROUPS)))]
55
  #[Route('/create', name: 'promo_create', methods: ['POST'])]
56
  public function create(Request $request): JsonResponse
57
  {
58
+ return $this->crud->create($request, Promo::class, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
59
  }
60
 
61
  #[IsGranted('ROLE_ADMIN')]
62
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Promo::class, groups: self::WRITE_GROUPS)))]
63
  #[Route('/{id}', name: 'promo_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
64
+ public function update(Request $request, Promo $promo): JsonResponse
65
  {
66
+ return $this->crud->update($request, $promo, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
67
  }
68
 
69
  #[IsGranted('ROLE_ADMIN')]
70
  #[Route('/{id}', name: 'promo_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
71
  public function delete(Promo $promo): JsonResponse
72
  {
73
+ return $this->crud->delete($promo);
 
 
74
  }
75
  }
src/Controller/SiteServiceController.php CHANGED
@@ -2,8 +2,13 @@
2
 
3
  namespace App\Controller;
4
 
 
5
  use App\Entity\SiteService;
6
- use App\Service\SiteServiceCrudService;
 
 
 
 
7
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8
  use Symfony\Component\HttpFoundation\JsonResponse;
9
  use Symfony\Component\HttpFoundation\Request;
@@ -14,91 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
14
  #[Route('/site-services')]
15
  final class SiteServiceController extends AbstractController
16
  {
 
 
 
17
  public function __construct(
18
- private SiteServiceCrudService $siteServiceCrud,
 
19
  ) {
20
  }
21
 
 
 
 
 
 
 
22
  #[Route('/list', name: 'site_service_list', methods: ['GET'])]
23
- public function list(Request $request): JsonResponse
24
  {
25
- $page = $request->query->getInt('page', 1);
26
- $perPage = min($request->query->getInt('perPage', 50), 500);
27
- $regionId = $request->query->getInt('regionId', 0) ?: null;
28
- $activeParam = $request->query->get('active');
29
- $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
30
- if ($activeParam !== null && $active === null) {
31
- return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST);
32
- }
33
-
34
- $result = $this->siteServiceCrud->getPaginatedList($page, $perPage, $regionId, $active);
35
- $data = $result['data'];
36
- $total = $result['total'];
37
- $perPage = $result['per_page'];
38
- $totalPages = (int) ceil($total / $perPage);
39
 
40
- return $this->json([
41
- 'data' => $data,
42
- 'pagination' => [
43
- 'total' => $total,
44
- 'count' => count($data),
45
- 'per_page' => $perPage,
46
- 'current_page' => $result['page'],
47
- 'total_pages' => $totalPages,
48
- 'has_previous_page' => $result['page'] > 1,
49
- 'has_next_page' => $result['page'] < $totalPages,
50
- ],
51
- ], Response::HTTP_OK, [], [
52
- 'groups' => ['site_service:read'],
53
  ]);
54
  }
55
 
56
  #[Route('/{id}', name: 'site_service_show', methods: ['GET'], requirements: ['id' => '\d+'])]
57
  public function show(SiteService $siteService): JsonResponse
58
  {
59
- return $this->json($siteService, Response::HTTP_OK, [], [
60
- 'groups' => ['site_service:read'],
61
- ]);
62
  }
63
 
64
  #[IsGranted('ROLE_ADMIN')]
 
65
  #[Route('/create', name: 'site_service_create', methods: ['POST'])]
66
  public function create(Request $request): JsonResponse
67
  {
68
- $data = json_decode($request->getContent(), true);
69
- if (!is_array($data)) {
70
- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST);
71
- }
72
-
73
- $siteService = $this->siteServiceCrud->create($data);
74
-
75
- return $this->json($siteService, Response::HTTP_CREATED, [], [
76
- 'groups' => ['site_service:read'],
77
- ]);
78
  }
79
 
80
  #[IsGranted('ROLE_ADMIN')]
 
81
  #[Route('/{id}', name: 'site_service_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
82
- public function update(SiteService $siteService, Request $request): JsonResponse
83
  {
84
- $data = json_decode($request->getContent(), true);
85
- if (!is_array($data)) {
86
- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST);
87
- }
88
-
89
- $siteService = $this->siteServiceCrud->update($siteService, $data);
90
-
91
- return $this->json($siteService, Response::HTTP_OK, [], [
92
- 'groups' => ['site_service:read'],
93
- ]);
94
  }
95
 
96
  #[IsGranted('ROLE_ADMIN')]
97
  #[Route('/{id}', name: 'site_service_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
98
  public function delete(SiteService $siteService): JsonResponse
99
  {
100
- $this->siteServiceCrud->delete($siteService);
101
-
102
- return new JsonResponse(null, Response::HTTP_NO_CONTENT);
103
  }
104
  }
 
2
 
3
  namespace App\Controller;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\SiteService;
7
+ use App\Repository\SiteServiceRepository;
8
+ use App\Service\Crud\CrudResponder;
9
+ use App\Service\Pagination\Paginator;
10
+ use Nelmio\ApiDocBundle\Attribute\Model;
11
+ use OpenApi\Attributes as OA;
12
  use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
13
  use Symfony\Component\HttpFoundation\JsonResponse;
14
  use Symfony\Component\HttpFoundation\Request;
 
19
  #[Route('/site-services')]
20
  final class SiteServiceController extends AbstractController
21
  {
22
+ private const READ_GROUPS = ['site_service:read'];
23
+ private const WRITE_GROUPS = ['site_service:write'];
24
+
25
  public function __construct(
26
+ private readonly CrudResponder $crud,
27
+ private readonly Paginator $paginator,
28
  ) {
29
  }
30
 
31
+ #[OA\Tag(name: 'Услуги')]
32
+ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))]
33
+ #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))]
34
+ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))]
35
+ #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))]
36
+ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))]
37
  #[Route('/list', name: 'site_service_list', methods: ['GET'])]
38
+ public function list(Request $request, SiteServiceRepository $repository): JsonResponse
39
  {
40
+ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true));
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
+ return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [
43
+ 'groups' => self::READ_GROUPS,
 
 
 
 
 
 
 
 
 
 
 
44
  ]);
45
  }
46
 
47
  #[Route('/{id}', name: 'site_service_show', methods: ['GET'], requirements: ['id' => '\d+'])]
48
  public function show(SiteService $siteService): JsonResponse
49
  {
50
+ return $this->crud->read($siteService, self::READ_GROUPS);
 
 
51
  }
52
 
53
  #[IsGranted('ROLE_ADMIN')]
54
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: SiteService::class, groups: self::WRITE_GROUPS)))]
55
  #[Route('/create', name: 'site_service_create', methods: ['POST'])]
56
  public function create(Request $request): JsonResponse
57
  {
58
+ return $this->crud->create($request, SiteService::class, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
59
  }
60
 
61
  #[IsGranted('ROLE_ADMIN')]
62
+ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: SiteService::class, groups: self::WRITE_GROUPS)))]
63
  #[Route('/{id}', name: 'site_service_update', methods: ['PUT'], requirements: ['id' => '\d+'])]
64
+ public function update(Request $request, SiteService $siteService): JsonResponse
65
  {
66
+ return $this->crud->update($request, $siteService, self::WRITE_GROUPS, self::READ_GROUPS);
 
 
 
 
 
 
 
 
 
67
  }
68
 
69
  #[IsGranted('ROLE_ADMIN')]
70
  #[Route('/{id}', name: 'site_service_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])]
71
  public function delete(SiteService $siteService): JsonResponse
72
  {
73
+ return $this->crud->delete($siteService);
 
 
74
  }
75
  }
src/Dto/Content/ContentFilterDto.php ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace App\Dto\Content;
6
+
7
+ use Symfony\Component\HttpFoundation\Request;
8
+
9
+ final readonly class ContentFilterDto
10
+ {
11
+ public function __construct(
12
+ public ?int $regionId = null,
13
+ public ?bool $active = null,
14
+ public ?string $alias = null,
15
+ public ?string $search = null,
16
+ ) {
17
+ }
18
+
19
+ /**
20
+ * @param ?bool $defaultActive если задан (например, true), подставляется,
21
+ * когда query-параметр `active` отсутствует или пустой.
22
+ * Легаси: в старых list-эндпоинтах News/Promo/MedicalCenter/SiteService
23
+ * при отсутствии `active` подразумевалось active = true.
24
+ */
25
+ public static function fromRequest(Request $request, ?bool $defaultActive = null): self
26
+ {
27
+ $active = self::nullableBool($request->query->get('active'));
28
+
29
+ return new self(
30
+ regionId: self::positiveInt($request->query->get('regionId', $request->query->get('region_id'))),
31
+ active: $active ?? $defaultActive,
32
+ alias: self::nonEmptyString($request->query->get('alias')),
33
+ search: self::nonEmptyString($request->query->get('search', $request->query->get('q'))),
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Symfony QueryBag может отдать массив при ?regionId[]=… — не передаём его в is_numeric (TypeError в PHP 8).
39
+ */
40
+ private static function positiveInt(mixed $value): ?int
41
+ {
42
+ if ($value === null || $value === '' || !is_scalar($value) || !is_numeric($value)) {
43
+ return null;
44
+ }
45
+
46
+ $value = (int) $value;
47
+
48
+ return $value > 0 ? $value : null;
49
+ }
50
+
51
+ /**
52
+ * При ?active[]=… query->get вернёт массив — отбрасываем без вызова filter_var по нему.
53
+ */
54
+ private static function nullableBool(mixed $value): ?bool
55
+ {
56
+ if ($value === null || $value === '') {
57
+ return null;
58
+ }
59
+
60
+ if (!is_scalar($value)) {
61
+ return null;
62
+ }
63
+
64
+ if (is_bool($value)) {
65
+ return $value;
66
+ }
67
+
68
+ return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
69
+ }
70
+
71
+ private static function nonEmptyString(mixed $value): ?string
72
+ {
73
+ if (!is_string($value)) {
74
+ return null;
75
+ }
76
+
77
+ $value = trim($value);
78
+
79
+ return $value !== '' ? $value : null;
80
+ }
81
+ }
src/Entity/Article.php CHANGED
@@ -2,6 +2,7 @@
2
 
3
  namespace App\Entity;
4
 
 
5
  use App\Repository\ArticleRepository;
6
  use Doctrine\DBAL\Types\Types;
7
  use Doctrine\ORM\Mapping as ORM;
@@ -12,8 +13,11 @@ use Symfony\Component\Validator\Constraints as Assert;
12
  #[ORM\Table(name: 'article')]
13
  #[ORM\Index(name: 'idx_article_region_id', columns: ['region_id'])]
14
  #[ORM\Index(name: 'idx_article_active', columns: ['active'])]
 
15
  class Article
16
  {
 
 
17
  #[Groups(['article:read'])]
18
  #[ORM\Id]
19
  #[ORM\GeneratedValue(strategy: "IDENTITY")]
@@ -56,7 +60,7 @@ class Article
56
  #[ORM\Column(type: Types::TEXT, nullable: true)]
57
  private ?string $content = null;
58
 
59
- #[Groups(['article:read', 'article:write'])]
60
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
61
  private ?\DateTimeInterface $updateAt = null;
62
 
 
2
 
3
  namespace App\Entity;
4
 
5
+ use App\Entity\Behavior\UpdateTimestampTrait;
6
  use App\Repository\ArticleRepository;
7
  use Doctrine\DBAL\Types\Types;
8
  use Doctrine\ORM\Mapping as ORM;
 
13
  #[ORM\Table(name: 'article')]
14
  #[ORM\Index(name: 'idx_article_region_id', columns: ['region_id'])]
15
  #[ORM\Index(name: 'idx_article_active', columns: ['active'])]
16
+ #[ORM\HasLifecycleCallbacks]
17
  class Article
18
  {
19
+ use UpdateTimestampTrait;
20
+
21
  #[Groups(['article:read'])]
22
  #[ORM\Id]
23
  #[ORM\GeneratedValue(strategy: "IDENTITY")]
 
60
  #[ORM\Column(type: Types::TEXT, nullable: true)]
61
  private ?string $content = null;
62
 
63
+ #[Groups(['article:read'])]
64
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
65
  private ?\DateTimeInterface $updateAt = null;
66
 
src/Entity/Behavior/UpdateTimestampTrait.php ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace App\Entity\Behavior;
6
+
7
+ use Doctrine\ORM\Mapping as ORM;
8
+
9
+ /**
10
+ * Требует у класса-сущности свойство `$updateAt` (mapped column).
11
+ *
12
+ * @property \DateTimeInterface|null $updateAt
13
+ */
14
+ trait UpdateTimestampTrait
15
+ {
16
+ #[ORM\PrePersist]
17
+ public function setInitialUpdateAt(): void
18
+ {
19
+ if ($this->updateAt === null) {
20
+ $this->updateAt = new \DateTimeImmutable();
21
+ }
22
+ }
23
+
24
+ #[ORM\PreUpdate]
25
+ public function refreshUpdateAt(): void
26
+ {
27
+ $this->updateAt = new \DateTimeImmutable();
28
+ }
29
+ }
src/Entity/Disease.php CHANGED
@@ -2,6 +2,7 @@
2
 
3
  namespace App\Entity;
4
 
 
5
  use App\Repository\DiseaseRepository;
6
  use Doctrine\DBAL\Types\Types;
7
  use Doctrine\ORM\Mapping as ORM;
@@ -11,10 +12,14 @@ use Symfony\Component\Serializer\Annotation\Groups;
11
  #[ORM\Table(name: 'disease')]
12
  #[ORM\Index(name: 'idx_disease_region_id', columns: ['region_id'])]
13
  #[ORM\Index(name: 'idx_disease_active', columns: ['active'])]
 
14
  class Disease
15
  {
 
 
16
  #[Groups(['disease:read'])]
17
  #[ORM\Id]
 
18
  #[ORM\Column(type: Types::INTEGER)]
19
  private ?int $id = null;
20
 
@@ -42,7 +47,7 @@ class Disease
42
  #[ORM\Column(type: Types::TEXT, nullable: true)]
43
  private ?string $anons = null;
44
 
45
- #[Groups(['disease:read', 'disease:write'])]
46
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
47
  private ?\DateTimeInterface $updateAt = null;
48
 
 
2
 
3
  namespace App\Entity;
4
 
5
+ use App\Entity\Behavior\UpdateTimestampTrait;
6
  use App\Repository\DiseaseRepository;
7
  use Doctrine\DBAL\Types\Types;
8
  use Doctrine\ORM\Mapping as ORM;
 
12
  #[ORM\Table(name: 'disease')]
13
  #[ORM\Index(name: 'idx_disease_region_id', columns: ['region_id'])]
14
  #[ORM\Index(name: 'idx_disease_active', columns: ['active'])]
15
+ #[ORM\HasLifecycleCallbacks]
16
  class Disease
17
  {
18
+ use UpdateTimestampTrait;
19
+
20
  #[Groups(['disease:read'])]
21
  #[ORM\Id]
22
+ #[ORM\GeneratedValue(strategy: "IDENTITY")]
23
  #[ORM\Column(type: Types::INTEGER)]
24
  private ?int $id = null;
25
 
 
47
  #[ORM\Column(type: Types::TEXT, nullable: true)]
48
  private ?string $anons = null;
49
 
50
+ #[Groups(['disease:read'])]
51
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
52
  private ?\DateTimeInterface $updateAt = null;
53
 
src/Entity/MedicalCenter.php CHANGED
@@ -2,6 +2,7 @@
2
 
3
  namespace App\Entity;
4
 
 
5
  use App\Repository\MedicalCenterRepository;
6
  use Doctrine\DBAL\Types\Types;
7
  use Doctrine\ORM\Mapping as ORM;
@@ -9,9 +10,13 @@ use Symfony\Component\Serializer\Annotation\Groups;
9
 
10
  #[ORM\Entity(repositoryClass: MedicalCenterRepository::class)]
11
  #[ORM\Table(name: 'medical_center')]
 
12
  class MedicalCenter
13
  {
 
 
14
  #[ORM\Id]
 
15
  #[ORM\Column(type: Types::INTEGER)]
16
  #[Groups(['medical_center:read'])]
17
  private ?int $id = null;
@@ -41,7 +46,7 @@ class MedicalCenter
41
  private ?string $content = null;
42
 
43
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
44
- #[Groups(['medical_center:read', 'medical_center:write'])]
45
  private ?\DateTimeInterface $updateAt = null;
46
 
47
  #[ORM\Column(name: 'kod_uslug', type: 'jsonb', nullable: true)]
 
2
 
3
  namespace App\Entity;
4
 
5
+ use App\Entity\Behavior\UpdateTimestampTrait;
6
  use App\Repository\MedicalCenterRepository;
7
  use Doctrine\DBAL\Types\Types;
8
  use Doctrine\ORM\Mapping as ORM;
 
10
 
11
  #[ORM\Entity(repositoryClass: MedicalCenterRepository::class)]
12
  #[ORM\Table(name: 'medical_center')]
13
+ #[ORM\HasLifecycleCallbacks]
14
  class MedicalCenter
15
  {
16
+ use UpdateTimestampTrait;
17
+
18
  #[ORM\Id]
19
+ #[ORM\GeneratedValue(strategy: "IDENTITY")]
20
  #[ORM\Column(type: Types::INTEGER)]
21
  #[Groups(['medical_center:read'])]
22
  private ?int $id = null;
 
46
  private ?string $content = null;
47
 
48
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
49
+ #[Groups(['medical_center:read'])]
50
  private ?\DateTimeInterface $updateAt = null;
51
 
52
  #[ORM\Column(name: 'kod_uslug', type: 'jsonb', nullable: true)]
src/Entity/News.php CHANGED
@@ -2,6 +2,7 @@
2
 
3
  namespace App\Entity;
4
 
 
5
  use App\Repository\NewsRepository;
6
  use Doctrine\DBAL\Types\Types;
7
  use Doctrine\ORM\Mapping as ORM;
@@ -11,9 +12,13 @@ use Symfony\Component\Serializer\Annotation\Groups;
11
  #[ORM\Table(name: 'news')]
12
  #[ORM\Index(name: 'idx_news_region_id', columns: ['region_id'])]
13
  #[ORM\Index(name: 'idx_news_active', columns: ['active'])]
 
14
  class News
15
  {
 
 
16
  #[ORM\Id]
 
17
  #[ORM\Column(type: Types::INTEGER)]
18
  #[Groups(['news:read'])]
19
  private ?int $id = null;
@@ -43,7 +48,7 @@ class News
43
  private ?string $content = null;
44
 
45
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
46
- #[Groups(['news:read', 'news:write'])]
47
  private ?\DateTimeInterface $updateAt = null;
48
 
49
  #[ORM\Column(name: 'link_el_price', type: Types::TEXT, nullable: true)]
 
2
 
3
  namespace App\Entity;
4
 
5
+ use App\Entity\Behavior\UpdateTimestampTrait;
6
  use App\Repository\NewsRepository;
7
  use Doctrine\DBAL\Types\Types;
8
  use Doctrine\ORM\Mapping as ORM;
 
12
  #[ORM\Table(name: 'news')]
13
  #[ORM\Index(name: 'idx_news_region_id', columns: ['region_id'])]
14
  #[ORM\Index(name: 'idx_news_active', columns: ['active'])]
15
+ #[ORM\HasLifecycleCallbacks]
16
  class News
17
  {
18
+ use UpdateTimestampTrait;
19
+
20
  #[ORM\Id]
21
+ #[ORM\GeneratedValue(strategy: "IDENTITY")]
22
  #[ORM\Column(type: Types::INTEGER)]
23
  #[Groups(['news:read'])]
24
  private ?int $id = null;
 
48
  private ?string $content = null;
49
 
50
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
51
+ #[Groups(['news:read'])]
52
  private ?\DateTimeInterface $updateAt = null;
53
 
54
  #[ORM\Column(name: 'link_el_price', type: Types::TEXT, nullable: true)]
src/Entity/Promo.php CHANGED
@@ -2,6 +2,7 @@
2
 
3
  namespace App\Entity;
4
 
 
5
  use App\Repository\PromoRepository;
6
  use Doctrine\DBAL\Types\Types;
7
  use Doctrine\ORM\Mapping as ORM;
@@ -11,9 +12,13 @@ use Symfony\Component\Serializer\Annotation\Groups;
11
  #[ORM\Table(name: 'promo')]
12
  #[ORM\Index(name: 'idx_promo_region_id', columns: ['region_id'])]
13
  #[ORM\Index(name: 'idx_promo_active', columns: ['active'])]
 
14
  class Promo
15
  {
 
 
16
  #[ORM\Id]
 
17
  #[ORM\Column(type: Types::INTEGER)]
18
  #[Groups(['promo:read'])]
19
  private ?int $id = null;
@@ -43,7 +48,7 @@ class Promo
43
  private ?string $content = null;
44
 
45
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
46
- #[Groups(['promo:read', 'promo:write'])]
47
  private ?\DateTimeInterface $updateAt = null;
48
 
49
  #[ORM\Column(type: 'jsonb', nullable: true)]
 
2
 
3
  namespace App\Entity;
4
 
5
+ use App\Entity\Behavior\UpdateTimestampTrait;
6
  use App\Repository\PromoRepository;
7
  use Doctrine\DBAL\Types\Types;
8
  use Doctrine\ORM\Mapping as ORM;
 
12
  #[ORM\Table(name: 'promo')]
13
  #[ORM\Index(name: 'idx_promo_region_id', columns: ['region_id'])]
14
  #[ORM\Index(name: 'idx_promo_active', columns: ['active'])]
15
+ #[ORM\HasLifecycleCallbacks]
16
  class Promo
17
  {
18
+ use UpdateTimestampTrait;
19
+
20
  #[ORM\Id]
21
+ #[ORM\GeneratedValue(strategy: "IDENTITY")]
22
  #[ORM\Column(type: Types::INTEGER)]
23
  #[Groups(['promo:read'])]
24
  private ?int $id = null;
 
48
  private ?string $content = null;
49
 
50
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
51
+ #[Groups(['promo:read'])]
52
  private ?\DateTimeInterface $updateAt = null;
53
 
54
  #[ORM\Column(type: 'jsonb', nullable: true)]
src/Entity/SiteService.php CHANGED
@@ -2,6 +2,7 @@
2
 
3
  namespace App\Entity;
4
 
 
5
  use App\Repository\SiteServiceRepository;
6
  use Doctrine\DBAL\Types\Types;
7
  use Doctrine\ORM\Mapping as ORM;
@@ -11,9 +12,13 @@ use Symfony\Component\Serializer\Annotation\Groups;
11
  #[ORM\Table(name: 'site_services')]
12
  #[ORM\Index(name: 'idx_site_services_region_id', columns: ['region_id'])]
13
  #[ORM\Index(name: 'idx_site_services_active', columns: ['active'])]
 
14
  class SiteService
15
  {
 
 
16
  #[ORM\Id]
 
17
  #[ORM\Column(type: Types::INTEGER)]
18
  #[Groups(['site_service:read'])]
19
  private ?int $id = null;
@@ -43,7 +48,7 @@ class SiteService
43
  private ?string $content = null;
44
 
45
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
46
- #[Groups(['site_service:read', 'site_service:write'])]
47
  private ?\DateTimeInterface $updateAt = null;
48
 
49
  #[ORM\Column(name: 'link_videoreviews', type: 'jsonb', nullable: true)]
 
2
 
3
  namespace App\Entity;
4
 
5
+ use App\Entity\Behavior\UpdateTimestampTrait;
6
  use App\Repository\SiteServiceRepository;
7
  use Doctrine\DBAL\Types\Types;
8
  use Doctrine\ORM\Mapping as ORM;
 
12
  #[ORM\Table(name: 'site_services')]
13
  #[ORM\Index(name: 'idx_site_services_region_id', columns: ['region_id'])]
14
  #[ORM\Index(name: 'idx_site_services_active', columns: ['active'])]
15
+ #[ORM\HasLifecycleCallbacks]
16
  class SiteService
17
  {
18
+ use UpdateTimestampTrait;
19
+
20
  #[ORM\Id]
21
+ #[ORM\GeneratedValue(strategy: "IDENTITY")]
22
  #[ORM\Column(type: Types::INTEGER)]
23
  #[Groups(['site_service:read'])]
24
  private ?int $id = null;
 
48
  private ?string $content = null;
49
 
50
  #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)]
51
+ #[Groups(['site_service:read'])]
52
  private ?\DateTimeInterface $updateAt = null;
53
 
54
  #[ORM\Column(name: 'link_videoreviews', type: 'jsonb', nullable: true)]
src/Repository/ArticleRepository.php CHANGED
@@ -2,8 +2,10 @@
2
 
3
  namespace App\Repository;
4
 
 
5
  use App\Entity\Article;
6
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 
7
  use Doctrine\Persistence\ManagerRegistry;
8
 
9
  /**
@@ -11,63 +13,34 @@ use Doctrine\Persistence\ManagerRegistry;
11
  */
12
  class ArticleRepository extends ServiceEntityRepository
13
  {
 
 
14
  public function __construct(ManagerRegistry $registry)
15
  {
16
  parent::__construct($registry, Article::class);
17
  }
18
 
19
- public function findByFilters(array $filters, int $page = 1, int $limit = 20): array
 
 
20
  {
21
- $qb = $this->createQueryBuilder('a');
22
-
23
- if (isset($filters['alias']) && $filters['alias'] !== '') {
24
- $qb->andWhere('a.alias = :alias')
25
- ->setParameter('alias', $filters['alias']);
26
- }
27
- if (isset($filters['active']) && $filters['active'] !== '') {
28
- $qb->andWhere('a.active = :active')
29
- ->setParameter('active', filter_var($filters['active'], FILTER_VALIDATE_BOOLEAN));
30
- }
31
- if (isset($filters['regionId']) && $filters['regionId'] !== '') {
32
- $qb->andWhere('a.regionId = :regionId')
33
- ->setParameter('regionId', (int) $filters['regionId']);
34
- }
35
 
36
- $qb->orderBy('a.id', 'DESC');
37
 
38
- $qb->setFirstResult(($page - 1) * $limit)
39
- ->setMaxResults($limit);
40
-
41
- return $qb->getQuery()->getResult();
42
- }
43
-
44
- public function countByFilters(array $filters): int
45
- {
46
- $qb = $this->createQueryBuilder('a')
47
- ->select('COUNT(a.id)');
48
-
49
- if (isset($filters['alias']) && $filters['alias'] !== '') {
50
- $qb->andWhere('a.alias = :alias')
51
- ->setParameter('alias', $filters['alias']);
52
- }
53
- if (isset($filters['active']) && $filters['active'] !== '') {
54
- $qb->andWhere('a.active = :active')
55
- ->setParameter('active', filter_var($filters['active'], FILTER_VALIDATE_BOOLEAN));
56
- }
57
- if (isset($filters['regionId']) && $filters['regionId'] !== '') {
58
- $qb->andWhere('a.regionId = :regionId')
59
- ->setParameter('regionId', (int) $filters['regionId']);
60
- }
61
-
62
- return (int) $qb->getQuery()->getSingleScalarResult();
63
  }
64
 
 
 
 
65
  public function findOneByAlias(string $alias): ?Article
66
  {
67
  $alias = trim($alias);
68
  if ($alias === '') {
69
  return null;
70
  }
 
71
  $variants = [
72
  $alias,
73
  $alias . '-',
@@ -79,16 +52,18 @@ class ArticleRepository extends ServiceEntityRepository
79
  return $article;
80
  }
81
  }
82
- // Поиск по TRIM(alias) в БД (нативный SQL для совместимости с PostgreSQL)
 
83
  $conn = $this->getEntityManager()->getConnection();
84
  $id = $conn->fetchOne(
85
  'SELECT id FROM article WHERE TRIM(alias) = :alias LIMIT 1',
86
  ['alias' => $alias],
87
- ['alias' => \PDO::PARAM_STR]
88
  );
89
  if ($id !== false) {
90
  return $this->find($id);
91
  }
 
92
  return null;
93
  }
94
  }
 
2
 
3
  namespace App\Repository;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\Article;
7
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
8
+ use Doctrine\ORM\QueryBuilder;
9
  use Doctrine\Persistence\ManagerRegistry;
10
 
11
  /**
 
13
  */
14
  class ArticleRepository extends ServiceEntityRepository
15
  {
16
+ use ContentFilterTrait;
17
+
18
  public function __construct(ManagerRegistry $registry)
19
  {
20
  parent::__construct($registry, Article::class);
21
  }
22
 
23
+ /**
24
+ */
25
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
26
  {
27
+ $qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC');
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
+ $this->applyCommonFilters($qb, 'a', $filters);
30
 
31
+ return $qb;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
 
34
+ /**
35
+ * Поиск статьи по alias с учётом возможных вариантов написания (исторический функционал).
36
+ */
37
  public function findOneByAlias(string $alias): ?Article
38
  {
39
  $alias = trim($alias);
40
  if ($alias === '') {
41
  return null;
42
  }
43
+
44
  $variants = [
45
  $alias,
46
  $alias . '-',
 
52
  return $article;
53
  }
54
  }
55
+
56
+ // Фолбэк по TRIM(alias) в БД для совместимости со старыми данными.
57
  $conn = $this->getEntityManager()->getConnection();
58
  $id = $conn->fetchOne(
59
  'SELECT id FROM article WHERE TRIM(alias) = :alias LIMIT 1',
60
  ['alias' => $alias],
61
+ ['alias' => \PDO::PARAM_STR],
62
  );
63
  if ($id !== false) {
64
  return $this->find($id);
65
  }
66
+
67
  return null;
68
  }
69
  }
src/Repository/ContentFilterTrait.php ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace App\Repository;
6
+
7
+ use App\Dto\Content\ContentFilterDto;
8
+ use Doctrine\ORM\QueryBuilder;
9
+
10
+ /**
11
+ * Общие фильтры для контентных репозиториев (News/Promo/Disease/MedicalCenter/Article/SiteService).
12
+ *
13
+ * Trait подключается в Doctrine-репозитории, чтобы не держать бизнес-фильтры
14
+ * в статическом helper-классе и при этом не копировать одинаковые if-блоки.
15
+ *
16
+ * Поддерживается:
17
+ * - regionId / region_id: целое > 0;
18
+ * - active: bool;
19
+ * - alias: точное совпадение;
20
+ * - search / q: LIKE по lower-case значению заданного поля (по умолчанию `name`).
21
+ *
22
+ * Поле поиска параметризовано через $searchField на случай сущностей,
23
+ * где основное текстовое поле называется иначе (например, `title`).
24
+ * Если у сущности нет такого свойства, Doctrine упадёт с QueryException — это
25
+ * лучше ловится тестами на этапе разработки, чем 500 в проде.
26
+ *
27
+ * Важно: LOWER($alias.$searchField) при больших таблицах требует функционального
28
+ * индекса в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)).
29
+ */
30
+ trait ContentFilterTrait
31
+ {
32
+ private function applyCommonFilters(
33
+ QueryBuilder $qb,
34
+ string $alias,
35
+ ContentFilterDto $filters,
36
+ string $searchField = 'name',
37
+ ): void {
38
+ if ($filters->regionId !== null) {
39
+ $qb->andWhere("$alias.regionId = :regionId")
40
+ ->setParameter('regionId', $filters->regionId);
41
+ }
42
+
43
+ if ($filters->active !== null) {
44
+ $qb->andWhere("$alias.active = :active")
45
+ ->setParameter('active', $filters->active);
46
+ }
47
+
48
+ if ($filters->alias !== null) {
49
+ $qb->andWhere("$alias.alias = :aliasValue")
50
+ ->setParameter('aliasValue', $filters->alias);
51
+ }
52
+
53
+ if ($filters->search !== null) {
54
+ $qb->andWhere("LOWER($alias.$searchField) LIKE :search")
55
+ ->setParameter('search', '%' . mb_strtolower($filters->search) . '%');
56
+ }
57
+ }
58
+ }
src/Repository/DiseaseRepository.php CHANGED
@@ -2,8 +2,10 @@
2
 
3
  namespace App\Repository;
4
 
 
5
  use App\Entity\Disease;
6
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 
7
  use Doctrine\Persistence\ManagerRegistry;
8
 
9
  /**
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
14
  */
15
  class DiseaseRepository extends ServiceEntityRepository
16
  {
 
 
17
  public function __construct(ManagerRegistry $registry)
18
  {
19
  parent::__construct($registry, Disease::class);
20
  }
 
 
 
 
 
 
 
 
 
 
 
21
  }
 
2
 
3
  namespace App\Repository;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\Disease;
7
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
8
+ use Doctrine\ORM\QueryBuilder;
9
  use Doctrine\Persistence\ManagerRegistry;
10
 
11
  /**
 
16
  */
17
  class DiseaseRepository extends ServiceEntityRepository
18
  {
19
+ use ContentFilterTrait;
20
+
21
  public function __construct(ManagerRegistry $registry)
22
  {
23
  parent::__construct($registry, Disease::class);
24
  }
25
+
26
+ /**
27
+ */
28
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
29
+ {
30
+ $qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC');
31
+
32
+ $this->applyCommonFilters($qb, 'd', $filters);
33
+
34
+ return $qb;
35
+ }
36
  }
src/Repository/MedicalCenterRepository.php CHANGED
@@ -2,8 +2,10 @@
2
 
3
  namespace App\Repository;
4
 
 
5
  use App\Entity\MedicalCenter;
6
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 
7
  use Doctrine\Persistence\ManagerRegistry;
8
 
9
  /**
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
14
  */
15
  class MedicalCenterRepository extends ServiceEntityRepository
16
  {
 
 
17
  public function __construct(ManagerRegistry $registry)
18
  {
19
  parent::__construct($registry, MedicalCenter::class);
20
  }
 
 
 
 
 
 
 
 
 
 
 
21
  }
 
2
 
3
  namespace App\Repository;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\MedicalCenter;
7
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
8
+ use Doctrine\ORM\QueryBuilder;
9
  use Doctrine\Persistence\ManagerRegistry;
10
 
11
  /**
 
16
  */
17
  class MedicalCenterRepository extends ServiceEntityRepository
18
  {
19
+ use ContentFilterTrait;
20
+
21
  public function __construct(ManagerRegistry $registry)
22
  {
23
  parent::__construct($registry, MedicalCenter::class);
24
  }
25
+
26
+ /**
27
+ */
28
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
29
+ {
30
+ $qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC');
31
+
32
+ $this->applyCommonFilters($qb, 'm', $filters);
33
+
34
+ return $qb;
35
+ }
36
  }
src/Repository/NewsRepository.php CHANGED
@@ -2,8 +2,10 @@
2
 
3
  namespace App\Repository;
4
 
 
5
  use App\Entity\News;
6
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 
7
  use Doctrine\Persistence\ManagerRegistry;
8
 
9
  /**
@@ -14,8 +16,25 @@ use Doctrine\Persistence\ManagerRegistry;
14
  */
15
  class NewsRepository extends ServiceEntityRepository
16
  {
 
 
17
  public function __construct(ManagerRegistry $registry)
18
  {
19
  parent::__construct($registry, News::class);
20
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  }
 
2
 
3
  namespace App\Repository;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\News;
7
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
8
+ use Doctrine\ORM\QueryBuilder;
9
  use Doctrine\Persistence\ManagerRegistry;
10
 
11
  /**
 
16
  */
17
  class NewsRepository extends ServiceEntityRepository
18
  {
19
+ use ContentFilterTrait;
20
+
21
  public function __construct(ManagerRegistry $registry)
22
  {
23
  parent::__construct($registry, News::class);
24
  }
25
+
26
+ /**
27
+ * Готовит QueryBuilder под пагинацию (Pagerfanta\QueryAdapter).
28
+ *
29
+ * Поддерживаемые фильтры: regionId, active (по умолчанию true), alias, search.
30
+ *
31
+ */
32
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
33
+ {
34
+ $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC');
35
+
36
+ $this->applyCommonFilters($qb, 'n', $filters);
37
+
38
+ return $qb;
39
+ }
40
  }
src/Repository/PromoRepository.php CHANGED
@@ -2,8 +2,10 @@
2
 
3
  namespace App\Repository;
4
 
 
5
  use App\Entity\Promo;
6
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 
7
  use Doctrine\Persistence\ManagerRegistry;
8
 
9
  /**
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
14
  */
15
  class PromoRepository extends ServiceEntityRepository
16
  {
 
 
17
  public function __construct(ManagerRegistry $registry)
18
  {
19
  parent::__construct($registry, Promo::class);
20
  }
 
 
 
 
 
 
 
 
 
 
 
21
  }
 
2
 
3
  namespace App\Repository;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\Promo;
7
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
8
+ use Doctrine\ORM\QueryBuilder;
9
  use Doctrine\Persistence\ManagerRegistry;
10
 
11
  /**
 
16
  */
17
  class PromoRepository extends ServiceEntityRepository
18
  {
19
+ use ContentFilterTrait;
20
+
21
  public function __construct(ManagerRegistry $registry)
22
  {
23
  parent::__construct($registry, Promo::class);
24
  }
25
+
26
+ /**
27
+ */
28
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
29
+ {
30
+ $qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC');
31
+
32
+ $this->applyCommonFilters($qb, 'p', $filters);
33
+
34
+ return $qb;
35
+ }
36
  }
src/Repository/SiteServiceRepository.php CHANGED
@@ -2,8 +2,10 @@
2
 
3
  namespace App\Repository;
4
 
 
5
  use App\Entity\SiteService;
6
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 
7
  use Doctrine\Persistence\ManagerRegistry;
8
 
9
  /**
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
14
  */
15
  class SiteServiceRepository extends ServiceEntityRepository
16
  {
 
 
17
  public function __construct(ManagerRegistry $registry)
18
  {
19
  parent::__construct($registry, SiteService::class);
20
  }
 
 
 
 
 
 
 
 
 
 
 
21
  }
 
2
 
3
  namespace App\Repository;
4
 
5
+ use App\Dto\Content\ContentFilterDto;
6
  use App\Entity\SiteService;
7
  use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
8
+ use Doctrine\ORM\QueryBuilder;
9
  use Doctrine\Persistence\ManagerRegistry;
10
 
11
  /**
 
16
  */
17
  class SiteServiceRepository extends ServiceEntityRepository
18
  {
19
+ use ContentFilterTrait;
20
+
21
  public function __construct(ManagerRegistry $registry)
22
  {
23
  parent::__construct($registry, SiteService::class);
24
  }
25
+
26
+ /**
27
+ */
28
+ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder
29
+ {
30
+ $qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC');
31
+
32
+ $this->applyCommonFilters($qb, 's', $filters);
33
+
34
+ return $qb;
35
+ }
36
  }
src/Service/Crud/CrudResponder.php ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace App\Service\Crud;
6
+
7
+ use Doctrine\DBAL\Exception as DbalException;
8
+ use Doctrine\ORM\EntityManagerInterface;
9
+ use JsonException;
10
+ use Symfony\Component\HttpFoundation\JsonResponse;
11
+ use Symfony\Component\HttpFoundation\Request;
12
+ use Symfony\Component\HttpFoundation\Response;
13
+ use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface;
14
+ use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
15
+ use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
16
+ use Symfony\Component\Serializer\SerializerInterface;
17
+ use Symfony\Component\Validator\Validator\ValidatorInterface;
18
+
19
+ /**
20
+ * Универсальный CRUD-ответчик для тонких контент-контроллеров.
21
+ *
22
+ * Контракт ответов специально сохранён близким к старым *CrudService/контроллерам,
23
+ * чтобы не ломать существующих клиентов (фронтенд/мобильное):
24
+ * - валидация: HTTP 400 + сериализованный ConstraintViolationList
25
+ * (формат Symfony Serializer по умолчанию, т.е. RFC 7807 с ключом violations);
26
+ * - удаление с ошибкой БД (например, FK constraint): HTTP 500 + {error, message};
27
+ * - JSON-ключи запросов/ответов используют camelCase (см. свойства сущностей и группы *:write).
28
+ * Name converter в config/packages/serializer.yaml не задан намеренно — клиенту
29
+ * нужен консистентный camelCase, иначе незнакомые ключи будут проигнорированы.
30
+ */
31
+ final class CrudResponder
32
+ {
33
+ public function __construct(
34
+ private EntityManagerInterface $em,
35
+ private SerializerInterface $serializer,
36
+ private DenormalizerInterface $denormalizer,
37
+ private ValidatorInterface $validator,
38
+ ) {
39
+ }
40
+
41
+ /**
42
+ * @param list<string> $readGroups
43
+ */
44
+ public function read(object $entity, array $readGroups): JsonResponse
45
+ {
46
+ return $this->json($entity, Response::HTTP_OK, $readGroups);
47
+ }
48
+
49
+ /**
50
+ * @template T of object
51
+ *
52
+ * @param class-string<T> $entityClass
53
+ * @param list<string> $writeGroups
54
+ * @param list<string> $readGroups
55
+ */
56
+ public function create(
57
+ Request $request,
58
+ string $entityClass,
59
+ array $writeGroups,
60
+ array $readGroups,
61
+ ): JsonResponse {
62
+ $payload = $this->decodePayload($request);
63
+ if ($payload === null) {
64
+ return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
65
+ }
66
+ unset($payload['id']);
67
+
68
+ try {
69
+ /** @var T $entity */
70
+ $entity = $this->denormalizer->denormalize(
71
+ $payload,
72
+ $entityClass,
73
+ null,
74
+ [
75
+ AbstractNormalizer::GROUPS => $writeGroups,
76
+ ],
77
+ );
78
+ } catch (SerializerExceptionInterface $e) {
79
+ return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
80
+ }
81
+
82
+ if (($validationResponse = $this->validate($entity)) !== null) {
83
+ return $validationResponse;
84
+ }
85
+
86
+ $this->em->persist($entity);
87
+ $this->em->flush();
88
+
89
+ return $this->json($entity, Response::HTTP_CREATED, $readGroups);
90
+ }
91
+
92
+ /**
93
+ * @param list<string> $writeGroups
94
+ * @param list<string> $readGroups
95
+ */
96
+ public function update(
97
+ Request $request,
98
+ object $entity,
99
+ array $writeGroups,
100
+ array $readGroups,
101
+ ): JsonResponse {
102
+ $payload = $this->decodePayload($request);
103
+ if ($payload === null) {
104
+ return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST);
105
+ }
106
+ unset($payload['id']);
107
+
108
+ try {
109
+ $this->denormalizer->denormalize(
110
+ $payload,
111
+ $entity::class,
112
+ null,
113
+ [
114
+ AbstractNormalizer::GROUPS => $writeGroups,
115
+ AbstractNormalizer::OBJECT_TO_POPULATE => $entity,
116
+ ],
117
+ );
118
+ } catch (SerializerExceptionInterface $e) {
119
+ return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST);
120
+ }
121
+
122
+ if (($validationResponse = $this->validate($entity)) !== null) {
123
+ return $validationResponse;
124
+ }
125
+
126
+ $this->em->flush();
127
+
128
+ return $this->json($entity, Response::HTTP_OK, $readGroups);
129
+ }
130
+
131
+ public function delete(object $entity): JsonResponse
132
+ {
133
+ try {
134
+ $this->em->remove($entity);
135
+ $this->em->flush();
136
+ } catch (DbalException $e) {
137
+ // Сохраняем легаси-контракт: при FK / NOT NULL / unique ошибках БД
138
+ // отдаём 500 + {error, message}. См. старый ArticleController::delete.
139
+ return new JsonResponse(
140
+ ['error' => 'Ошибка при удалении записи', 'message' => $e->getMessage()],
141
+ Response::HTTP_INTERNAL_SERVER_ERROR,
142
+ );
143
+ }
144
+
145
+ return new JsonResponse(null, Response::HTTP_NO_CONTENT);
146
+ }
147
+
148
+ /**
149
+ * @return array<string, mixed>|null null если тело не является JSON-объектом
150
+ *
151
+ * Ловим как нативный \JsonException, так и Symfony\...\HttpFoundation\Exception\JsonException
152
+ * (последний наследует UnexpectedValueException, а не \JsonException, и без
153
+ * широкого перехвата Symfony ErrorListener перехватит ошибку до нашего try/catch).
154
+ */
155
+ private function decodePayload(Request $request): ?array
156
+ {
157
+ try {
158
+ return $request->toArray();
159
+ } catch (JsonException|\UnexpectedValueException) {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ private function validate(object $entity): ?JsonResponse
165
+ {
166
+ $errors = $this->validator->validate($entity);
167
+ if (count($errors) === 0) {
168
+ return null;
169
+ }
170
+
171
+ // BC: легаси-контроллеры возвращали именно сериализованный ConstraintViolationList
172
+ // с кодом 400. Этот же формат продолжаем отдавать здесь, чтобы фронтенду
173
+ // не пришлось переписывать парсинг ошибок.
174
+ $json = $this->serializer->serialize($errors, 'json');
175
+
176
+ return new JsonResponse($json, Response::HTTP_BAD_REQUEST, [], true);
177
+ }
178
+
179
+ /**
180
+ * @param list<string> $groups
181
+ */
182
+ private function json(mixed $data, int $status, array $groups): JsonResponse
183
+ {
184
+ $json = $this->serializer->serialize($data, 'json', [
185
+ AbstractNormalizer::GROUPS => $groups,
186
+ ]);
187
+
188
+ return new JsonResponse($json, $status, [], true);
189
+ }
190
+
191
+ private function jsonError(string $message, int $status): JsonResponse
192
+ {
193
+ return new JsonResponse(['error' => $message], $status);
194
+ }
195
+ }
src/Service/DiseaseCrudService.php CHANGED
@@ -2,206 +2,26 @@
2
 
3
  namespace App\Service;
4
 
5
- use App\Entity\Disease;
6
- use App\Repository\DiseaseRepository;
7
  use Doctrine\ORM\EntityManagerInterface;
8
 
 
 
 
 
 
9
  final class DiseaseCrudService
10
  {
11
  public function __construct(
12
  private EntityManagerInterface $em,
13
- private DiseaseRepository $diseaseRepository,
14
  ) {
15
  }
16
 
17
- /**
18
- * @return array{data: Disease[], total: int, page: int, per_page: int}
19
- */
20
- public function getPaginatedList(int $page, int $perPage, ?int $regionId = null): array
21
- {
22
- $page = max(1, $page);
23
- $perPage = min(max(1, $perPage), 500);
24
-
25
- $qb = $this->diseaseRepository->createQueryBuilder('d')
26
- ->orderBy('d.id', 'ASC');
27
-
28
- if ($regionId !== null) {
29
- $qb->andWhere('d.regionId = :regionId')
30
- ->setParameter('regionId', $regionId);
31
- }
32
-
33
- $countQb = $this->diseaseRepository->createQueryBuilder('d')
34
- ->select('COUNT(d.id)');
35
- if ($regionId !== null) {
36
- $countQb->andWhere('d.regionId = :regionId')
37
- ->setParameter('regionId', $regionId);
38
- }
39
- $total = (int) $countQb->getQuery()->getSingleScalarResult();
40
-
41
- $qb->setFirstResult(($page - 1) * $perPage)
42
- ->setMaxResults($perPage);
43
-
44
- $data = $qb->getQuery()->getResult();
45
-
46
- return [
47
- 'data' => $data,
48
- 'total' => $total,
49
- 'page' => $page,
50
- 'per_page' => $perPage,
51
- ];
52
- }
53
-
54
- public function getShow(int $id): ?Disease
55
- {
56
- return $this->diseaseRepository->find($id);
57
- }
58
-
59
- public function create(array $data): Disease
60
- {
61
- if (!array_key_exists('id', $data) || $data['id'] === null || $data['id'] === '') {
62
- throw new \InvalidArgumentException('Поле id обязательно.');
63
- }
64
-
65
- $disease = new Disease();
66
- $this->updateEntity($disease, $data);
67
-
68
- $this->em->persist($disease);
69
- $this->em->flush();
70
-
71
- return $disease;
72
- }
73
-
74
- public function update(Disease $disease, array $data): Disease
75
- {
76
- unset($data['id']);
77
- $this->updateEntity($disease, $data);
78
-
79
- $this->em->flush();
80
-
81
- return $disease;
82
- }
83
-
84
- public function delete(Disease $disease): void
85
- {
86
- $this->em->remove($disease);
87
- $this->em->flush();
88
- }
89
-
90
- private function updateEntity(Disease $disease, array $data): void
91
- {
92
- if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') {
93
- $disease->setId((int) $data['id']);
94
- }
95
-
96
- if (array_key_exists('name', $data)) {
97
- $disease->setName($data['name']);
98
- }
99
-
100
- if (array_key_exists('previewPicture', $data) || array_key_exists('preview_picture', $data)) {
101
- $disease->setPreviewPicture($data['previewPicture'] ?? $data['preview_picture']);
102
- }
103
-
104
- if (array_key_exists('active', $data)) {
105
- $disease->setActive($data['active']);
106
- }
107
-
108
- if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) {
109
- $v = $data['regionId'] ?? $data['region_id'];
110
- $disease->setRegionId($v === null || $v === '' ? null : (int) $v);
111
- }
112
-
113
- if (array_key_exists('alias', $data)) {
114
- $disease->setAlias($data['alias']);
115
- }
116
-
117
- if (array_key_exists('anons', $data)) {
118
- $disease->setAnons($data['anons']);
119
- }
120
-
121
- if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) {
122
- $raw = $data['updateAt'] ?? $data['update_at'];
123
- if ($raw === null || $raw === '') {
124
- $disease->setUpdateAt(null);
125
- } elseif ($raw instanceof \DateTimeInterface) {
126
- $disease->setUpdateAt($raw);
127
- } elseif (is_string($raw)) {
128
- $disease->setUpdateAt(new \DateTimeImmutable($raw));
129
- }
130
- }
131
-
132
- if (array_key_exists('hidePicture', $data) || array_key_exists('hide_picture', $data)) {
133
- $disease->setHidePicture($data['hidePicture'] ?? $data['hide_picture']);
134
- }
135
-
136
- if (array_key_exists('readTime', $data) || array_key_exists('read_time', $data)) {
137
- $disease->setReadTime($data['readTime'] ?? $data['read_time']);
138
- }
139
-
140
- if (array_key_exists('diseasesName', $data) || array_key_exists('diseases_name', $data)) {
141
- $disease->setDiseasesName($data['diseasesName'] ?? $data['diseases_name']);
142
- }
143
-
144
- if (array_key_exists('tagsImportant', $data) || array_key_exists('tags_important', $data)) {
145
- $disease->setTagsImportant($data['tagsImportant'] ?? $data['tags_important']);
146
- }
147
-
148
- if (array_key_exists('tags', $data)) {
149
- $disease->setTags($data['tags']);
150
- }
151
-
152
- if (array_key_exists('diseasesOtherName', $data) || array_key_exists('diseases_other_name', $data)) {
153
- $disease->setDiseasesOtherName($data['diseasesOtherName'] ?? $data['diseases_other_name']);
154
- }
155
-
156
- if (array_key_exists('symptom', $data)) {
157
- $disease->setSymptom($data['symptom']);
158
- }
159
-
160
- if (array_key_exists('staff', $data)) {
161
- $disease->setStaff($data['staff']);
162
- }
163
-
164
- if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) {
165
- $disease->setLinkServices($data['linkServices'] ?? $data['link_services']);
166
- }
167
-
168
- if (array_key_exists('staffList', $data) || array_key_exists('staff_list', $data)) {
169
- $disease->setStaffList($data['staffList'] ?? $data['staff_list']);
170
- }
171
-
172
- if (array_key_exists('staffPost', $data) || array_key_exists('staff_post', $data)) {
173
- $disease->setStaffPost($data['staffPost'] ?? $data['staff_post']);
174
- }
175
-
176
- if (array_key_exists('staffPostExclude', $data) || array_key_exists('staff_post_exclude', $data)) {
177
- $disease->setStaffPostExclude($data['staffPostExclude'] ?? $data['staff_post_exclude']);
178
- }
179
-
180
- if (array_key_exists('linkFaq', $data) || array_key_exists('link_faq', $data)) {
181
- $disease->setLinkFaq($data['linkFaq'] ?? $data['link_faq']);
182
- }
183
-
184
- if (array_key_exists('bibliography', $data)) {
185
- $disease->setBibliography($data['bibliography']);
186
- }
187
-
188
- if (array_key_exists('staffCheck', $data) || array_key_exists('staff_check', $data)) {
189
- $disease->setStaffCheck($data['staffCheck'] ?? $data['staff_check']);
190
- }
191
-
192
- if (array_key_exists('content', $data)) {
193
- $disease->setContent($data['content']);
194
- }
195
- }
196
-
197
  public function syncFromViewDisease(string $viewName = 'public.view_disease'): int
198
  {
199
  if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
200
  throw new \InvalidArgumentException('Invalid view name');
201
  }
202
 
203
- $connection = $this->em->getConnection();
204
-
205
  $sql = sprintf(
206
  'INSERT INTO disease (
207
  id,
@@ -282,6 +102,6 @@ final class DiseaseCrudService
282
  $viewName
283
  );
284
 
285
- return (int) $connection->executeStatement($sql);
286
  }
287
  }
 
2
 
3
  namespace App\Service;
4
 
 
 
5
  use Doctrine\ORM\EntityManagerInterface;
6
 
7
+ /**
8
+ * Импорт заболеваний из материализованного представления (Bitrix view).
9
+ *
10
+ * См. DiseaseController + CrudResponder для CRUD; этот сервис — только syncFromView*.
11
+ */
12
  final class DiseaseCrudService
13
  {
14
  public function __construct(
15
  private EntityManagerInterface $em,
 
16
  ) {
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  public function syncFromViewDisease(string $viewName = 'public.view_disease'): int
20
  {
21
  if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
22
  throw new \InvalidArgumentException('Invalid view name');
23
  }
24
 
 
 
25
  $sql = sprintf(
26
  'INSERT INTO disease (
27
  id,
 
102
  $viewName
103
  );
104
 
105
+ return (int) $this->em->getConnection()->executeStatement($sql);
106
  }
107
  }
src/Service/MedicalCenterCrudService.php CHANGED
@@ -2,312 +2,127 @@
2
 
3
  namespace App\Service;
4
 
5
- use App\Entity\MedicalCenter;
6
- use App\Repository\MedicalCenterRepository;
7
  use Doctrine\ORM\EntityManagerInterface;
8
 
 
 
 
 
 
9
  final class MedicalCenterCrudService
10
  {
11
- public function __construct(
12
- private EntityManagerInterface $em,
13
- private MedicalCenterRepository $medicalCenterRepository
14
- ) {
15
- }
16
-
17
- /**
18
- * @return MedicalCenter[]
19
- */
20
- public function getList(?int $regionId = null, ?bool $active = true): array
21
- {
22
- $criteria = [];
23
- if ($regionId !== null) {
24
- $criteria['regionId'] = $regionId;
25
- }
26
- if ($active !== null) {
27
- $criteria['active'] = $active;
28
- }
29
-
30
- return $this->medicalCenterRepository->findBy($criteria, ['id' => 'ASC']);
31
- }
32
-
33
- public function getShow(int $id): ?MedicalCenter
34
- {
35
- return $this->medicalCenterRepository->find($id);
36
- }
37
-
38
- public function create(array $data): MedicalCenter
39
- {
40
- $medicalCenter = new MedicalCenter();
41
- $this->updateEntity($medicalCenter, $data);
42
-
43
- $this->em->persist($medicalCenter);
44
- $this->em->flush();
45
-
46
- return $medicalCenter;
47
- }
48
-
49
- public function update(MedicalCenter $medicalCenter, array $data): MedicalCenter
50
- {
51
- unset($data['id']);
52
- $this->updateEntity($medicalCenter, $data);
53
-
54
- $this->em->flush();
55
- return $medicalCenter;
56
- }
57
-
58
- public function delete(MedicalCenter $medicalCenter): void
59
- {
60
- $this->em->remove($medicalCenter);
61
- $this->em->flush();
62
- }
63
-
64
- private function updateEntity(MedicalCenter $medicalCenter, array $data): void
65
- {
66
- if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') {
67
- $medicalCenter->setId((int) $data['id']);
68
- }
69
-
70
- if (array_key_exists('name', $data)) {
71
- $medicalCenter->setName($data['name']);
72
- }
73
-
74
- if (array_key_exists('active', $data)) {
75
- $medicalCenter->setActive($data['active']);
76
- }
77
-
78
- if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) {
79
- $v = $data['regionId'] ?? $data['region_id'];
80
- $medicalCenter->setRegionId($v === null || $v === '' ? null : (int) $v);
81
- }
82
-
83
- if (array_key_exists('alias', $data)) {
84
- $medicalCenter->setAlias($data['alias']);
85
- }
86
-
87
- if (array_key_exists('anons', $data)) {
88
- $medicalCenter->setAnons($data['anons']);
89
- }
90
-
91
- if (array_key_exists('content', $data)) {
92
- $medicalCenter->setContent($data['content']);
93
- }
94
-
95
- if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) {
96
- $raw = $data['updateAt'] ?? $data['update_at'];
97
- if ($raw === null || $raw === '') {
98
- $medicalCenter->setUpdateAt(null);
99
- } elseif ($raw instanceof \DateTimeInterface) {
100
- $medicalCenter->setUpdateAt($raw);
101
- } elseif (is_string($raw)) {
102
- $medicalCenter->setUpdateAt(new \DateTimeImmutable($raw));
103
- }
104
- }
105
-
106
- if (array_key_exists('kodUslug', $data) || array_key_exists('kod_uslug', $data)) {
107
- $medicalCenter->setKodUslug($data['kodUslug'] ?? $data['kod_uslug']);
108
- }
109
-
110
- if (array_key_exists('doctors', $data)) {
111
- $medicalCenter->setDoctors($data['doctors']);
112
- }
113
-
114
- if (array_key_exists('services', $data)) {
115
- $medicalCenter->setServices($data['services']);
116
- }
117
-
118
- if (array_key_exists('articles', $data)) {
119
- $medicalCenter->setArticles($data['articles']);
120
- }
121
-
122
- if (array_key_exists('txtUp', $data) || array_key_exists('txt_up', $data)) {
123
- $medicalCenter->setTxtUp($data['txtUp'] ?? $data['txt_up']);
124
- }
125
-
126
- if (array_key_exists('mainLinkStaff', $data) || array_key_exists('main_link_staff', $data)) {
127
- $medicalCenter->setMainLinkStaff($data['mainLinkStaff'] ?? $data['main_link_staff']);
128
- }
129
-
130
- if (array_key_exists('contraindications', $data)) {
131
- $medicalCenter->setContraindications($data['contraindications']);
132
- }
133
-
134
- if (array_key_exists('hidePicture', $data) || array_key_exists('hide_picture', $data)) {
135
- $v = $data['hidePicture'] ?? $data['hide_picture'];
136
- $medicalCenter->setHidePicture($v === null || $v === '' ? null : (int) $v);
137
- }
138
-
139
- if (array_key_exists('indications', $data)) {
140
- $medicalCenter->setIndications($data['indications']);
141
- }
142
-
143
- if (array_key_exists('linkSale', $data) || array_key_exists('link_sale', $data)) {
144
- $medicalCenter->setLinkSale($data['linkSale'] ?? $data['link_sale']);
145
- }
146
-
147
- if (array_key_exists('plusList', $data) || array_key_exists('plus_list', $data)) {
148
- $medicalCenter->setPlusList($data['plusList'] ?? $data['plus_list']);
149
- }
150
-
151
- if (array_key_exists('plusText', $data) || array_key_exists('plus_text', $data)) {
152
- $medicalCenter->setPlusText($data['plusText'] ?? $data['plus_text']);
153
- }
154
-
155
- if (array_key_exists('plusTitle', $data) || array_key_exists('plus_title', $data)) {
156
- $medicalCenter->setPlusTitle($data['plusTitle'] ?? $data['plus_title']);
157
- }
158
-
159
- if (array_key_exists('processText', $data) || array_key_exists('process_text', $data)) {
160
- $medicalCenter->setProcessText($data['processText'] ?? $data['process_text']);
161
- }
162
-
163
- if (array_key_exists('processTitle', $data) || array_key_exists('process_title', $data)) {
164
- $medicalCenter->setProcessTitle($data['processTitle'] ?? $data['process_title']);
165
- }
166
-
167
- if (array_key_exists('servicesList', $data) || array_key_exists('services_list', $data)) {
168
- $medicalCenter->setServicesList($data['servicesList'] ?? $data['services_list']);
169
- }
170
-
171
- if (array_key_exists('servicesPhotos', $data) || array_key_exists('services_photos', $data)) {
172
- $medicalCenter->setServicesPhotos($data['servicesPhotos'] ?? $data['services_photos']);
173
- }
174
-
175
- if (array_key_exists('servicesTitle', $data) || array_key_exists('services_title', $data)) {
176
- $medicalCenter->setServicesTitle($data['servicesTitle'] ?? $data['services_title']);
177
- }
178
-
179
- if (array_key_exists('sortStaff', $data) || array_key_exists('sort_staff', $data)) {
180
- $medicalCenter->setSortStaff($data['sortStaff'] ?? $data['sort_staff']);
181
- }
182
-
183
- if (array_key_exists('trainingText', $data) || array_key_exists('training_text', $data)) {
184
- $medicalCenter->setTrainingText($data['trainingText'] ?? $data['training_text']);
185
- }
186
-
187
- if (array_key_exists('trainingTextTitle', $data) || array_key_exists('training_text_title', $data)) {
188
- $medicalCenter->setTrainingTextTitle($data['trainingTextTitle'] ?? $data['training_text_title']);
189
- }
190
-
191
- if (array_key_exists('whyText', $data) || array_key_exists('why_text', $data)) {
192
- $medicalCenter->setWhyText($data['whyText'] ?? $data['why_text']);
193
- }
194
-
195
- if (array_key_exists('whyTitle', $data) || array_key_exists('why_title', $data)) {
196
- $medicalCenter->setWhyTitle($data['whyTitle'] ?? $data['why_title']);
197
- }
198
- }
199
-
200
- public function syncFromViewCenters(string $viewName = 'public.view_centers'): int
201
- {
202
- // В опции разрешаем только идентификаторы (буквы/цифры/underscore) и точку для схемы.
203
- if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
204
- throw new \InvalidArgumentException('Invalid view name');
205
- }
206
-
207
- $connection = $this->em->getConnection();
208
-
209
- $sql = sprintf(
210
- 'INSERT INTO medical_center (
211
- id,
212
- name,
213
- active,
214
- region_id,
215
- alias,
216
- anons,
217
- content,
218
- update_at,
219
- kod_uslug,
220
- doctors,
221
- services,
222
- articles,
223
- txt_up,
224
- main_link_staff,
225
- contraindications,
226
- hide_picture,
227
- indications,
228
- link_sale,
229
- plus_list,
230
- plus_text,
231
- plus_title,
232
- process_text,
233
- process_title,
234
- services_list,
235
- services_photos,
236
- services_title,
237
- sort_staff,
238
- training_text,
239
- training_text_title,
240
- why_text,
241
- why_title
242
- )
243
- SELECT
244
- id,
245
- name,
246
- active,
247
- region_id,
248
- alias,
249
- anons,
250
- content,
251
- update_at,
252
- kod_uslug,
253
- doctors,
254
- services,
255
- articles,
256
- txt_up,
257
- main_link_staff,
258
- contraindications,
259
- hide_picture,
260
- indications,
261
- link_sale,
262
- plus_list,
263
- plus_text,
264
- plus_title,
265
- process_text,
266
- process_title,
267
- services_list,
268
- services_photos,
269
- services_title,
270
- sort_staff,
271
- training_text,
272
- training_text_title,
273
- why_text,
274
- why_title
275
- FROM %s
276
- ON CONFLICT (id) DO UPDATE SET
277
- name = EXCLUDED.name,
278
- active = EXCLUDED.active,
279
- region_id = EXCLUDED.region_id,
280
- alias = EXCLUDED.alias,
281
- anons = EXCLUDED.anons,
282
- content = EXCLUDED.content,
283
- update_at = EXCLUDED.update_at,
284
- kod_uslug = EXCLUDED.kod_uslug,
285
- doctors = EXCLUDED.doctors,
286
- services = EXCLUDED.services,
287
- articles = EXCLUDED.articles,
288
- txt_up = EXCLUDED.txt_up,
289
- main_link_staff = EXCLUDED.main_link_staff,
290
- contraindications = EXCLUDED.contraindications,
291
- hide_picture = EXCLUDED.hide_picture,
292
- indications = EXCLUDED.indications,
293
- link_sale = EXCLUDED.link_sale,
294
- plus_list = EXCLUDED.plus_list,
295
- plus_text = EXCLUDED.plus_text,
296
- plus_title = EXCLUDED.plus_title,
297
- process_text = EXCLUDED.process_text,
298
- process_title = EXCLUDED.process_title,
299
- services_list = EXCLUDED.services_list,
300
- services_photos = EXCLUDED.services_photos,
301
- services_title = EXCLUDED.services_title,
302
- sort_staff = EXCLUDED.sort_staff,
303
- training_text = EXCLUDED.training_text,
304
- training_text_title = EXCLUDED.training_text_title,
305
- why_text = EXCLUDED.why_text,
306
- why_title = EXCLUDED.why_title',
307
- $viewName
308
- );
309
-
310
- return (int) $connection->executeStatement($sql);
311
- }
312
  }
313
-
 
2
 
3
  namespace App\Service;
4
 
 
 
5
  use Doctrine\ORM\EntityManagerInterface;
6
 
7
+ /**
8
+ * Импорт центров из материализованного представления (Bitrix view).
9
+ *
10
+ * См. MedicalCenterController + CrudResponder для CRUD; этот сервис — только syncFromView*.
11
+ */
12
  final class MedicalCenterCrudService
13
  {
14
+ public function __construct(
15
+ private EntityManagerInterface $em,
16
+ ) {
17
+ }
18
+
19
+ public function syncFromViewCenters(string $viewName = 'public.view_centers'): int
20
+ {
21
+ if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
22
+ throw new \InvalidArgumentException('Invalid view name');
23
+ }
24
+
25
+ $sql = sprintf(
26
+ 'INSERT INTO medical_center (
27
+ id,
28
+ name,
29
+ active,
30
+ region_id,
31
+ alias,
32
+ anons,
33
+ content,
34
+ update_at,
35
+ kod_uslug,
36
+ doctors,
37
+ services,
38
+ articles,
39
+ txt_up,
40
+ main_link_staff,
41
+ contraindications,
42
+ hide_picture,
43
+ indications,
44
+ link_sale,
45
+ plus_list,
46
+ plus_text,
47
+ plus_title,
48
+ process_text,
49
+ process_title,
50
+ services_list,
51
+ services_photos,
52
+ services_title,
53
+ sort_staff,
54
+ training_text,
55
+ training_text_title,
56
+ why_text,
57
+ why_title
58
+ )
59
+ SELECT
60
+ id,
61
+ name,
62
+ active,
63
+ region_id,
64
+ alias,
65
+ anons,
66
+ content,
67
+ update_at,
68
+ kod_uslug,
69
+ doctors,
70
+ services,
71
+ articles,
72
+ txt_up,
73
+ main_link_staff,
74
+ contraindications,
75
+ hide_picture,
76
+ indications,
77
+ link_sale,
78
+ plus_list,
79
+ plus_text,
80
+ plus_title,
81
+ process_text,
82
+ process_title,
83
+ services_list,
84
+ services_photos,
85
+ services_title,
86
+ sort_staff,
87
+ training_text,
88
+ training_text_title,
89
+ why_text,
90
+ why_title
91
+ FROM %s
92
+ ON CONFLICT (id) DO UPDATE SET
93
+ name = EXCLUDED.name,
94
+ active = EXCLUDED.active,
95
+ region_id = EXCLUDED.region_id,
96
+ alias = EXCLUDED.alias,
97
+ anons = EXCLUDED.anons,
98
+ content = EXCLUDED.content,
99
+ update_at = EXCLUDED.update_at,
100
+ kod_uslug = EXCLUDED.kod_uslug,
101
+ doctors = EXCLUDED.doctors,
102
+ services = EXCLUDED.services,
103
+ articles = EXCLUDED.articles,
104
+ txt_up = EXCLUDED.txt_up,
105
+ main_link_staff = EXCLUDED.main_link_staff,
106
+ contraindications = EXCLUDED.contraindications,
107
+ hide_picture = EXCLUDED.hide_picture,
108
+ indications = EXCLUDED.indications,
109
+ link_sale = EXCLUDED.link_sale,
110
+ plus_list = EXCLUDED.plus_list,
111
+ plus_text = EXCLUDED.plus_text,
112
+ plus_title = EXCLUDED.plus_title,
113
+ process_text = EXCLUDED.process_text,
114
+ process_title = EXCLUDED.process_title,
115
+ services_list = EXCLUDED.services_list,
116
+ services_photos = EXCLUDED.services_photos,
117
+ services_title = EXCLUDED.services_title,
118
+ sort_staff = EXCLUDED.sort_staff,
119
+ training_text = EXCLUDED.training_text,
120
+ training_text_title = EXCLUDED.training_text_title,
121
+ why_text = EXCLUDED.why_text,
122
+ why_title = EXCLUDED.why_title',
123
+ $viewName
124
+ );
125
+
126
+ return (int) $this->em->getConnection()->executeStatement($sql);
127
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  }
 
src/Service/NewsCrudService.php CHANGED
@@ -2,148 +2,28 @@
2
 
3
  namespace App\Service;
4
 
5
- use App\Entity\News;
6
- use App\Repository\NewsRepository;
7
  use Doctrine\ORM\EntityManagerInterface;
8
 
 
 
 
 
 
 
 
9
  final class NewsCrudService
10
  {
11
  public function __construct(
12
  private EntityManagerInterface $em,
13
- private NewsRepository $newsRepository
14
  ) {
15
  }
16
 
17
- /**
18
- * @return News[]
19
- */
20
- public function getList(?int $regionId = null, ?bool $active = true): array
21
- {
22
- $criteria = [];
23
- if ($regionId !== null) {
24
- $criteria['regionId'] = $regionId;
25
- }
26
- if ($active !== null) {
27
- $criteria['active'] = $active;
28
- }
29
-
30
- return $this->newsRepository->findBy($criteria, ['id' => 'ASC']);
31
- }
32
-
33
- public function getShow(int $id): ?News
34
- {
35
- return $this->newsRepository->find($id);
36
- }
37
-
38
- public function create(array $data): News
39
- {
40
- $news = new News();
41
- $this->updateEntity($news, $data);
42
-
43
- $this->em->persist($news);
44
- $this->em->flush();
45
-
46
- return $news;
47
- }
48
-
49
- public function update(News $news, array $data): News
50
- {
51
- unset($data['id']);
52
- $this->updateEntity($news, $data);
53
-
54
- $this->em->flush();
55
- return $news;
56
- }
57
-
58
- public function delete(News $news): void
59
- {
60
- $this->em->remove($news);
61
- $this->em->flush();
62
- }
63
-
64
- private function updateEntity(News $news, array $data): void
65
- {
66
- if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') {
67
- $news->setId((int) $data['id']);
68
- }
69
-
70
- if (array_key_exists('name', $data)) {
71
- $news->setName($data['name']);
72
- }
73
-
74
- if (array_key_exists('active', $data)) {
75
- $news->setActive($data['active']);
76
- }
77
-
78
- if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) {
79
- $v = $data['regionId'] ?? $data['region_id'];
80
- $news->setRegionId($v === null || $v === '' ? null : (int) $v);
81
- }
82
-
83
- if (array_key_exists('alias', $data)) {
84
- $news->setAlias($data['alias']);
85
- }
86
-
87
- if (array_key_exists('anons', $data)) {
88
- $news->setAnons($data['anons']);
89
- }
90
-
91
- if (array_key_exists('content', $data)) {
92
- $news->setContent($data['content']);
93
- }
94
-
95
- if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) {
96
- $raw = $data['updateAt'] ?? $data['update_at'];
97
- if ($raw === null || $raw === '') {
98
- $news->setUpdateAt(null);
99
- } elseif ($raw instanceof \DateTimeInterface) {
100
- $news->setUpdateAt($raw);
101
- } elseif (is_string($raw)) {
102
- $news->setUpdateAt(new \DateTimeImmutable($raw));
103
- }
104
- }
105
-
106
- if (array_key_exists('linkElPrice', $data) || array_key_exists('link_el_price', $data)) {
107
- $news->setLinkElPrice($data['linkElPrice'] ?? $data['link_el_price']);
108
- }
109
-
110
- if (array_key_exists('shortName', $data) || array_key_exists('short_name', $data)) {
111
- $news->setShortName($data['shortName'] ?? $data['short_name']);
112
- }
113
-
114
- if (array_key_exists('timer', $data)) {
115
- $news->setTimer($data['timer']);
116
- }
117
-
118
- if (array_key_exists('timerBg', $data) || array_key_exists('timer_bg', $data)) {
119
- $news->setTimerBg($data['timerBg'] ?? $data['timer_bg']);
120
- }
121
-
122
- if (array_key_exists('formOrder', $data) || array_key_exists('form_order', $data)) {
123
- $news->setFormOrder($data['formOrder'] ?? $data['form_order']);
124
- }
125
-
126
- if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) {
127
- $news->setLinkServices($data['linkServices'] ?? $data['link_services']);
128
- }
129
-
130
- if (array_key_exists('linkStaff', $data) || array_key_exists('link_staff', $data)) {
131
- $news->setLinkStaff($data['linkStaff'] ?? $data['link_staff']);
132
- }
133
-
134
- if (array_key_exists('photos', $data)) {
135
- $news->setPhotos($data['photos']);
136
- }
137
- }
138
-
139
  public function syncFromViewNews(string $viewName = 'public.view_news'): int
140
  {
141
  if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
142
  throw new \InvalidArgumentException('Invalid view name');
143
  }
144
 
145
- $connection = $this->em->getConnection();
146
-
147
  $sql = sprintf(
148
  'INSERT INTO news (
149
  id,
@@ -200,6 +80,6 @@ final class NewsCrudService
200
  $viewName
201
  );
202
 
203
- return (int) $connection->executeStatement($sql);
204
  }
205
  }
 
2
 
3
  namespace App\Service;
4
 
 
 
5
  use Doctrine\ORM\EntityManagerInterface;
6
 
7
+ /**
8
+ * Импорт новостей из материализованного представления (Bitrix view).
9
+ *
10
+ * CRUD (create/update/delete/list) живёт теперь в NewsController через
11
+ * общие App\Service\Crud\CrudResponder и App\Service\Pagination\Paginator —
12
+ * этот сервис отвечает только за синхронизацию (см. App\Command\UploadNewsCommand).
13
+ */
14
  final class NewsCrudService
15
  {
16
  public function __construct(
17
  private EntityManagerInterface $em,
 
18
  ) {
19
  }
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  public function syncFromViewNews(string $viewName = 'public.view_news'): int
22
  {
23
  if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
24
  throw new \InvalidArgumentException('Invalid view name');
25
  }
26
 
 
 
27
  $sql = sprintf(
28
  'INSERT INTO news (
29
  id,
 
80
  $viewName
81
  );
82
 
83
+ return (int) $this->em->getConnection()->executeStatement($sql);
84
  }
85
  }
src/Service/Pagination/Paginator.php ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace App\Service\Pagination;
6
+
7
+ use Doctrine\ORM\QueryBuilder;
8
+ use Pagerfanta\Doctrine\ORM\QueryAdapter;
9
+ use Pagerfanta\Exception\NotValidCurrentPageException;
10
+ use Pagerfanta\Pagerfanta;
11
+ use Symfony\Component\HttpFoundation\Request;
12
+
13
+ /**
14
+ * Унифицированная обёртка над Pagerfanta + QueryAdapter.
15
+ *
16
+ * Соответствует существующему стилю проекта (см. PriceListController/SpecialistController):
17
+ * читает page/perPage из Request, ограничивает perPage и возвращает массив
18
+ * ['data' => [...], 'pagination' => [...]] в едином формате для новых list-контрактов.
19
+ */
20
+ final class Paginator
21
+ {
22
+ public const DEFAULT_PER_PAGE = 50;
23
+ public const MAX_PER_PAGE = 500;
24
+
25
+ /**
26
+ * @return array{data: list<mixed>, pagination: array<string, int|bool>}
27
+ */
28
+ public function paginate(
29
+ QueryBuilder $qb,
30
+ Request $request,
31
+ int $defaultPerPage = self::DEFAULT_PER_PAGE,
32
+ int $maxPerPage = self::MAX_PER_PAGE,
33
+ ): array {
34
+ $page = max(1, $request->query->getInt('page', 1));
35
+ $perPage = min(
36
+ max(1, $request->query->getInt('perPage', $defaultPerPage)),
37
+ $maxPerPage,
38
+ );
39
+
40
+ $pagerfanta = (new Pagerfanta(new QueryAdapter($qb)))
41
+ ->setMaxPerPage($perPage);
42
+
43
+ try {
44
+ $pagerfanta->setCurrentPage($page);
45
+ } catch (NotValidCurrentPageException) {
46
+ // выходим за пределы — возвращаем пустую страницу с корректным total
47
+ $pagerfanta->setCurrentPage(max(1, $pagerfanta->getNbPages()));
48
+ }
49
+
50
+ $data = iterator_to_array($pagerfanta->getCurrentPageResults(), false);
51
+
52
+ return [
53
+ 'data' => $data,
54
+ 'pagination' => [
55
+ 'total' => $pagerfanta->getNbResults(),
56
+ 'count' => count($data),
57
+ 'per_page' => $pagerfanta->getMaxPerPage(),
58
+ 'current_page' => $pagerfanta->getCurrentPage(),
59
+ 'total_pages' => $pagerfanta->getNbPages(),
60
+ 'has_previous_page' => $pagerfanta->hasPreviousPage(),
61
+ 'has_next_page' => $pagerfanta->hasNextPage(),
62
+ ],
63
+ ];
64
+ }
65
+
66
+ /**
67
+ * Legacy-формат для ArticleController.
68
+ *
69
+ * Старый контракт /article/list уже использовался клиентами:
70
+ * - размер страницы приходит в query-параметре limit;
71
+ * - метаданные лежат в ключе meta;
72
+ * - поля называются total/page/limit/totalPages.
73
+ *
74
+ * @return array{data: list<mixed>, meta: array{total: int, page: int, limit: int, totalPages: int}}
75
+ */
76
+ public function paginateWithLegacyMeta(
77
+ QueryBuilder $qb,
78
+ Request $request,
79
+ int $defaultLimit = 20,
80
+ int $maxLimit = 100,
81
+ ): array {
82
+ $page = max(1, $request->query->getInt('page', 1));
83
+ $limit = min(
84
+ max(1, $request->query->getInt('limit', $defaultLimit)),
85
+ $maxLimit,
86
+ );
87
+
88
+ $pagerfanta = (new Pagerfanta(new QueryAdapter($qb)))
89
+ ->setMaxPerPage($limit);
90
+
91
+ try {
92
+ $pagerfanta->setCurrentPage($page);
93
+ } catch (NotValidCurrentPageException) {
94
+ $pagerfanta->setCurrentPage(max(1, $pagerfanta->getNbPages()));
95
+ }
96
+
97
+ return [
98
+ 'data' => iterator_to_array($pagerfanta->getCurrentPageResults(), false),
99
+ 'meta' => [
100
+ 'total' => $pagerfanta->getNbResults(),
101
+ 'page' => $pagerfanta->getCurrentPage(),
102
+ 'limit' => $pagerfanta->getMaxPerPage(),
103
+ 'totalPages' => $pagerfanta->getNbPages(),
104
+ ],
105
+ ];
106
+ }
107
+ }
src/Service/PromoCrudService.php CHANGED
@@ -2,148 +2,26 @@
2
 
3
  namespace App\Service;
4
 
5
- use App\Entity\Promo;
6
- use App\Repository\PromoRepository;
7
  use Doctrine\ORM\EntityManagerInterface;
8
 
 
 
 
 
 
9
  final class PromoCrudService
10
  {
11
  public function __construct(
12
  private EntityManagerInterface $em,
13
- private PromoRepository $promoRepository
14
  ) {
15
  }
16
 
17
- /**
18
- * @return Promo[]
19
- */
20
- public function getList(?int $regionId = null, ?bool $active = true): array
21
- {
22
- $criteria = [];
23
- if ($regionId !== null) {
24
- $criteria['regionId'] = $regionId;
25
- }
26
- if ($active !== null) {
27
- $criteria['active'] = $active;
28
- }
29
-
30
- return $this->promoRepository->findBy($criteria, ['id' => 'ASC']);
31
- }
32
-
33
- public function getShow(int $id): ?Promo
34
- {
35
- return $this->promoRepository->find($id);
36
- }
37
-
38
- public function create(array $data): Promo
39
- {
40
- $promo = new Promo();
41
- $this->updateEntity($promo, $data);
42
-
43
- $this->em->persist($promo);
44
- $this->em->flush();
45
-
46
- return $promo;
47
- }
48
-
49
- public function update(Promo $promo, array $data): Promo
50
- {
51
- unset($data['id']);
52
- $this->updateEntity($promo, $data);
53
-
54
- $this->em->flush();
55
- return $promo;
56
- }
57
-
58
- public function delete(Promo $promo): void
59
- {
60
- $this->em->remove($promo);
61
- $this->em->flush();
62
- }
63
-
64
- private function updateEntity(Promo $promo, array $data): void
65
- {
66
- if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') {
67
- $promo->setId((int) $data['id']);
68
- }
69
-
70
- if (array_key_exists('name', $data)) {
71
- $promo->setName($data['name']);
72
- }
73
-
74
- if (array_key_exists('active', $data)) {
75
- $promo->setActive($data['active']);
76
- }
77
-
78
- if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) {
79
- $v = $data['regionId'] ?? $data['region_id'];
80
- $promo->setRegionId($v === null || $v === '' ? null : (int) $v);
81
- }
82
-
83
- if (array_key_exists('alias', $data)) {
84
- $promo->setAlias($data['alias']);
85
- }
86
-
87
- if (array_key_exists('anons', $data)) {
88
- $promo->setAnons($data['anons']);
89
- }
90
-
91
- if (array_key_exists('content', $data)) {
92
- $promo->setContent($data['content']);
93
- }
94
-
95
- if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) {
96
- $raw = $data['updateAt'] ?? $data['update_at'];
97
- if ($raw === null || $raw === '') {
98
- $promo->setUpdateAt(null);
99
- } elseif ($raw instanceof \DateTimeInterface) {
100
- $promo->setUpdateAt($raw);
101
- } elseif (is_string($raw)) {
102
- $promo->setUpdateAt(new \DateTimeImmutable($raw));
103
- }
104
- }
105
-
106
- if (array_key_exists('clinics', $data)) {
107
- $promo->setClinics($data['clinics']);
108
- }
109
-
110
- if (array_key_exists('timer', $data)) {
111
- $promo->setTimer($data['timer']);
112
- }
113
-
114
- if (array_key_exists('timerBg', $data) || array_key_exists('timer_bg', $data)) {
115
- $promo->setTimerBg($data['timerBg'] ?? $data['timer_bg']);
116
- }
117
-
118
- if (array_key_exists('shortName', $data) || array_key_exists('short_name', $data)) {
119
- $promo->setShortName($data['shortName'] ?? $data['short_name']);
120
- }
121
-
122
- if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) {
123
- $promo->setLinkServices($data['linkServices'] ?? $data['link_services']);
124
- }
125
-
126
- if (array_key_exists('linkStaff', $data) || array_key_exists('link_staff', $data)) {
127
- $promo->setLinkStaff($data['linkStaff'] ?? $data['link_staff']);
128
- }
129
-
130
- if (array_key_exists('period', $data)) {
131
- $promo->setPeriod($data['period']);
132
- }
133
-
134
- if (array_key_exists('photos', $data)) {
135
- $promo->setPhotos($data['photos']);
136
- }
137
- }
138
-
139
  public function syncFromViewPromo(string $viewName = 'public.view_promo'): int
140
  {
141
  if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
142
  throw new \InvalidArgumentException('Invalid view name');
143
  }
144
 
145
- $connection = $this->em->getConnection();
146
-
147
  $sql = sprintf(
148
  'INSERT INTO promo (
149
  id,
@@ -200,6 +78,6 @@ final class PromoCrudService
200
  $viewName
201
  );
202
 
203
- return (int) $connection->executeStatement($sql);
204
  }
205
  }
 
2
 
3
  namespace App\Service;
4
 
 
 
5
  use Doctrine\ORM\EntityManagerInterface;
6
 
7
+ /**
8
+ * Импорт акций из материализованного представления (Bitrix view).
9
+ *
10
+ * См. PromoController + CrudResponder для CRUD; этот сервис — только syncFromView*.
11
+ */
12
  final class PromoCrudService
13
  {
14
  public function __construct(
15
  private EntityManagerInterface $em,
 
16
  ) {
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  public function syncFromViewPromo(string $viewName = 'public.view_promo'): int
20
  {
21
  if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) {
22
  throw new \InvalidArgumentException('Invalid view name');
23
  }
24
 
 
 
25
  $sql = sprintf(
26
  'INSERT INTO promo (
27
  id,
 
78
  $viewName
79
  );
80
 
81
+ return (int) $this->em->getConnection()->executeStatement($sql);
82
  }
83
  }
src/Service/SiteServiceCrudService.php CHANGED
@@ -2,358 +2,26 @@
2
 
3
  namespace App\Service;
4
 
5
- use App\Entity\SiteService;
6
- use App\Repository\SiteServiceRepository;
7
  use Doctrine\ORM\EntityManagerInterface;
8
 
 
 
 
 
 
9
  final class SiteServiceCrudService
10
  {
11
  public function __construct(
12
  private EntityManagerInterface $em,
13
- private SiteServiceRepository $siteServiceRepository,
14
  ) {
15
  }
16
 
17
- /**
18
- * @return SiteService[]
19
- */
20
- public function getList(?int $regionId = null, ?bool $active = true): array
21
- {
22
- $criteria = [];
23
- if ($regionId !== null) {
24
- $criteria['regionId'] = $regionId;
25
- }
26
- if ($active !== null) {
27
- $criteria['active'] = $active;
28
- }
29
-
30
- return $this->siteServiceRepository->findBy($criteria, ['id' => 'ASC']);
31
- }
32
-
33
- /**
34
- * @return array{data: SiteService[], total: int, page: int, per_page: int}
35
- */
36
- public function getPaginatedList(int $page, int $perPage, ?int $regionId = null, ?bool $active = true): array
37
- {
38
- $page = max(1, $page);
39
- $perPage = min(max(1, $perPage), 500);
40
-
41
- $countQb = $this->siteServiceRepository->createQueryBuilder('s')
42
- ->select('COUNT(s.id)');
43
- if ($regionId !== null) {
44
- $countQb->andWhere('s.regionId = :regionId')
45
- ->setParameter('regionId', $regionId);
46
- }
47
- if ($active !== null) {
48
- $countQb->andWhere('s.active = :active')
49
- ->setParameter('active', $active);
50
- }
51
- $total = (int) $countQb->getQuery()->getSingleScalarResult();
52
-
53
- $qb = $this->siteServiceRepository->createQueryBuilder('s')
54
- ->orderBy('s.id', 'ASC');
55
- if ($regionId !== null) {
56
- $qb->andWhere('s.regionId = :regionId')
57
- ->setParameter('regionId', $regionId);
58
- }
59
- if ($active !== null) {
60
- $qb->andWhere('s.active = :active')
61
- ->setParameter('active', $active);
62
- }
63
- $qb->setFirstResult(($page - 1) * $perPage)
64
- ->setMaxResults($perPage);
65
-
66
- $data = $qb->getQuery()->getResult();
67
-
68
- return [
69
- 'data' => $data,
70
- 'total' => $total,
71
- 'page' => $page,
72
- 'per_page' => $perPage,
73
- ];
74
- }
75
-
76
- public function getShow(int $id): ?SiteService
77
- {
78
- return $this->siteServiceRepository->find($id);
79
- }
80
-
81
- public function create(array $data): SiteService
82
- {
83
- $siteService = new SiteService();
84
- $this->updateEntity($siteService, $data);
85
-
86
- $this->em->persist($siteService);
87
- $this->em->flush();
88
-
89
- return $siteService;
90
- }
91
-
92
- public function update(SiteService $siteService, array $data): SiteService
93
- {
94
- unset($data['id']);
95
- $this->updateEntity($siteService, $data);
96
-
97
- $this->em->flush();
98
-
99
- return $siteService;
100
- }
101
-
102
- public function delete(SiteService $siteService): void
103
- {
104
- $this->em->remove($siteService);
105
- $this->em->flush();
106
- }
107
-
108
- private function updateEntity(SiteService $siteService, array $data): void
109
- {
110
- if (array_key_exists('id', $data)) {
111
- $v = $data['id'];
112
- $siteService->setId($v === null || $v === '' ? null : (int) $v);
113
- }
114
-
115
- if (array_key_exists('name', $data)) {
116
- $siteService->setName($data['name']);
117
- }
118
-
119
- if (array_key_exists('active', $data)) {
120
- $siteService->setActive($data['active']);
121
- }
122
-
123
- if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) {
124
- $v = $data['regionId'] ?? $data['region_id'];
125
- $siteService->setRegionId($v === null || $v === '' ? null : (int) $v);
126
- }
127
-
128
- if (array_key_exists('alias', $data)) {
129
- $siteService->setAlias($data['alias']);
130
- }
131
-
132
- if (array_key_exists('anons', $data)) {
133
- $siteService->setAnons($data['anons']);
134
- }
135
-
136
- if (array_key_exists('content', $data)) {
137
- $siteService->setContent($data['content']);
138
- }
139
-
140
- if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) {
141
- $raw = $data['updateAt'] ?? $data['update_at'];
142
- if ($raw === null || $raw === '') {
143
- $siteService->setUpdateAt(null);
144
- } elseif ($raw instanceof \DateTimeInterface) {
145
- $siteService->setUpdateAt($raw);
146
- } elseif (is_string($raw)) {
147
- $siteService->setUpdateAt(new \DateTimeImmutable($raw));
148
- }
149
- }
150
-
151
- if (array_key_exists('linkVideoreviews', $data) || array_key_exists('link_videoreviews', $data)) {
152
- $siteService->setLinkVideoreviews($data['linkVideoreviews'] ?? $data['link_videoreviews']);
153
- }
154
-
155
- if (array_key_exists('previewImg', $data) || array_key_exists('preview_img', $data)) {
156
- $siteService->setPreviewImg($data['previewImg'] ?? $data['preview_img']);
157
- }
158
-
159
- if (array_key_exists('faq', $data)) {
160
- $siteService->setFaq($data['faq']);
161
- }
162
-
163
- if (array_key_exists('partPrice', $data) || array_key_exists('part_price', $data)) {
164
- $siteService->setPartPrice($data['partPrice'] ?? $data['part_price']);
165
- }
166
-
167
- if (array_key_exists('pokazaniya', $data)) {
168
- $siteService->setPokazaniya($data['pokazaniya']);
169
- }
170
-
171
- if (array_key_exists('preparation', $data)) {
172
- $siteService->setPreparation($data['preparation']);
173
- }
174
-
175
- if (array_key_exists('protivopokazaniya', $data)) {
176
- $siteService->setProtivopokazaniya($data['protivopokazaniya']);
177
- }
178
-
179
- if (array_key_exists('hideSignBtn', $data) || array_key_exists('hide_sign_btn', $data)) {
180
- $siteService->setHideSignBtn($data['hideSignBtn'] ?? $data['hide_sign_btn']);
181
- }
182
-
183
- if (array_key_exists('quiz', $data)) {
184
- $siteService->setQuiz($data['quiz']);
185
- }
186
-
187
- if (array_key_exists('tags', $data)) {
188
- $siteService->setTags($data['tags']);
189
- }
190
-
191
- if (array_key_exists('tagsImportant', $data) || array_key_exists('tags_important', $data)) {
192
- $siteService->setTagsImportant($data['tagsImportant'] ?? $data['tags_important']);
193
- }
194
-
195
- if (array_key_exists('bannerImg', $data) || array_key_exists('banner_img', $data)) {
196
- $siteService->setBannerImg($data['bannerImg'] ?? $data['banner_img']);
197
- }
198
-
199
- if (array_key_exists('bannerImgM', $data) || array_key_exists('banner_img_m', $data)) {
200
- $siteService->setBannerImgM($data['bannerImgM'] ?? $data['banner_img_m']);
201
- }
202
-
203
- if (array_key_exists('bannerImgUrl', $data) || array_key_exists('banner_img_url', $data)) {
204
- $siteService->setBannerImgUrl($data['bannerImgUrl'] ?? $data['banner_img_url']);
205
- }
206
-
207
- if (array_key_exists('clinics', $data)) {
208
- $siteService->setClinics($data['clinics']);
209
- }
210
-
211
- if (array_key_exists('downloadFile', $data) || array_key_exists('download_file', $data)) {
212
- $siteService->setDownloadFile($data['downloadFile'] ?? $data['download_file']);
213
- }
214
-
215
- if (array_key_exists('fullWidthBanner', $data) || array_key_exists('full_width_banner', $data)) {
216
- $siteService->setFullWidthBanner($data['fullWidthBanner'] ?? $data['full_width_banner']);
217
- }
218
-
219
- if (array_key_exists('staffUp', $data) || array_key_exists('staff_up', $data)) {
220
- $siteService->setStaffUp($data['staffUp'] ?? $data['staff_up']);
221
- }
222
-
223
- if (array_key_exists('advantages', $data)) {
224
- $siteService->setAdvantages($data['advantages']);
225
- }
226
-
227
- if (array_key_exists('hidePicture', $data) || array_key_exists('hide_picture', $data)) {
228
- $v = $data['hidePicture'] ?? $data['hide_picture'];
229
- $siteService->setHidePicture($v === null || $v === '' ? null : (int) $v);
230
- }
231
-
232
- if (array_key_exists('kodUslug', $data) || array_key_exists('kod_uslug', $data)) {
233
- $siteService->setKodUslug($data['kodUslug'] ?? $data['kod_uslug']);
234
- }
235
-
236
- if (array_key_exists('linkPrice', $data) || array_key_exists('link_price', $data)) {
237
- $siteService->setLinkPrice($data['linkPrice'] ?? $data['link_price']);
238
- }
239
-
240
- if (array_key_exists('photosTitle', $data) || array_key_exists('photos_title', $data)) {
241
- $siteService->setPhotosTitle($data['photosTitle'] ?? $data['photos_title']);
242
- }
243
-
244
- if (array_key_exists('saleId', $data) || array_key_exists('sale_id', $data)) {
245
- $siteService->setSaleId($data['saleId'] ?? $data['sale_id']);
246
- }
247
-
248
- if (array_key_exists('sortStaff', $data) || array_key_exists('sort_staff', $data)) {
249
- $siteService->setSortStaff($data['sortStaff'] ?? $data['sort_staff']);
250
- }
251
-
252
- if (array_key_exists('contraindicationsList', $data) || array_key_exists('contraindications_list', $data)) {
253
- $siteService->setContraindicationsList($data['contraindicationsList'] ?? $data['contraindications_list']);
254
- }
255
-
256
- if (array_key_exists('customBlockText', $data) || array_key_exists('custom_block_text', $data)) {
257
- $siteService->setCustomBlockText($data['customBlockText'] ?? $data['custom_block_text']);
258
- }
259
-
260
- if (array_key_exists('customBlockText2', $data) || array_key_exists('custom_block_text2', $data)) {
261
- $siteService->setCustomBlockText2($data['customBlockText2'] ?? $data['custom_block_text2']);
262
- }
263
-
264
- if (array_key_exists('customBlockTitle', $data) || array_key_exists('custom_block_title', $data)) {
265
- $siteService->setCustomBlockTitle($data['customBlockTitle'] ?? $data['custom_block_title']);
266
- }
267
-
268
- if (array_key_exists('customBlockTitle2', $data) || array_key_exists('custom_block_title2', $data)) {
269
- $siteService->setCustomBlockTitle2($data['customBlockTitle2'] ?? $data['custom_block_title2']);
270
- }
271
-
272
- if (array_key_exists('indicationsList', $data) || array_key_exists('indications_list', $data)) {
273
- $siteService->setIndicationsList($data['indicationsList'] ?? $data['indications_list']);
274
- }
275
-
276
- if (array_key_exists('linkArticlesServices', $data) || array_key_exists('link_articles_services', $data)) {
277
- $siteService->setLinkArticlesServices($data['linkArticlesServices'] ?? $data['link_articles_services']);
278
- }
279
-
280
- if (array_key_exists('plusList', $data) || array_key_exists('plus_list', $data)) {
281
- $siteService->setPlusList($data['plusList'] ?? $data['plus_list']);
282
- }
283
-
284
- if (array_key_exists('plusText', $data) || array_key_exists('plus_text', $data)) {
285
- $siteService->setPlusText($data['plusText'] ?? $data['plus_text']);
286
- }
287
-
288
- if (array_key_exists('plusTitle', $data) || array_key_exists('plus_title', $data)) {
289
- $siteService->setPlusTitle($data['plusTitle'] ?? $data['plus_title']);
290
- }
291
-
292
- if (array_key_exists('prepareTitle', $data) || array_key_exists('prepare_title', $data)) {
293
- $siteService->setPrepareTitle($data['prepareTitle'] ?? $data['prepare_title']);
294
- }
295
-
296
- if (array_key_exists('processText', $data) || array_key_exists('process_text', $data)) {
297
- $siteService->setProcessText($data['processText'] ?? $data['process_text']);
298
- }
299
-
300
- if (array_key_exists('processTitle', $data) || array_key_exists('process_title', $data)) {
301
- $siteService->setProcessTitle($data['processTitle'] ?? $data['process_title']);
302
- }
303
-
304
- if (array_key_exists('servicesList', $data) || array_key_exists('services_list', $data)) {
305
- $siteService->setServicesList($data['servicesList'] ?? $data['services_list']);
306
- }
307
-
308
- if (array_key_exists('servicesPhotos', $data) || array_key_exists('services_photos', $data)) {
309
- $siteService->setServicesPhotos($data['servicesPhotos'] ?? $data['services_photos']);
310
- }
311
-
312
- if (array_key_exists('servicesTitle', $data) || array_key_exists('services_title', $data)) {
313
- $siteService->setServicesTitle($data['servicesTitle'] ?? $data['services_title']);
314
- }
315
-
316
- if (array_key_exists('textUp', $data) || array_key_exists('text_up', $data)) {
317
- $siteService->setTextUp($data['textUp'] ?? $data['text_up']);
318
- }
319
-
320
- if (array_key_exists('trainingText', $data) || array_key_exists('training_text', $data)) {
321
- $siteService->setTrainingText($data['trainingText'] ?? $data['training_text']);
322
- }
323
-
324
- if (array_key_exists('whyText', $data) || array_key_exists('why_text', $data)) {
325
- $siteService->setWhyText($data['whyText'] ?? $data['why_text']);
326
- }
327
-
328
- if (array_key_exists('whyTitle', $data) || array_key_exists('why_title', $data)) {
329
- $siteService->setWhyTitle($data['whyTitle'] ?? $data['why_title']);
330
- }
331
-
332
- if (array_key_exists('linkFaq', $data) || array_key_exists('link_faq', $data)) {
333
- $siteService->setLinkFaq($data['linkFaq'] ?? $data['link_faq']);
334
- }
335
-
336
- if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) {
337
- $siteService->setLinkServices($data['linkServices'] ?? $data['link_services']);
338
- }
339
-
340
- if (array_key_exists('linkStaff', $data) || array_key_exists('link_staff', $data)) {
341
- $siteService->setLinkStaff($data['linkStaff'] ?? $data['link_staff']);
342
- }
343
-
344
- if (array_key_exists('photos', $data)) {
345
- $siteService->setPhotos($data['photos']);
346
- }
347
-
348
- }
349
-
350
  public function syncFromViewServices(string $viewName = 'public.view_services'): int
351
  {
352
- if (! preg_match('/^[A-Za-z0-9_.]+$/', $viewName)) {
353
  throw new \InvalidArgumentException('Invalid view name');
354
  }
355
 
356
- $connection = $this->em->getConnection();
357
  $sql = sprintf(
358
  'INSERT INTO site_services (
359
  id,
@@ -533,6 +201,6 @@ final class SiteServiceCrudService
533
  $viewName
534
  );
535
 
536
- return (int) $connection->executeStatement($sql);
537
  }
538
  }
 
2
 
3
  namespace App\Service;
4
 
 
 
5
  use Doctrine\ORM\EntityManagerInterface;
6
 
7
+ /**
8
+ * Импорт услуг из материализованного представления (Bitrix view).
9
+ *
10
+ * См. SiteServiceController + CrudResponder для CRUD; этот сервис — только syncFromView*.
11
+ */
12
  final class SiteServiceCrudService
13
  {
14
  public function __construct(
15
  private EntityManagerInterface $em,
 
16
  ) {
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  public function syncFromViewServices(string $viewName = 'public.view_services'): int
20
  {
21
+ if (!preg_match('/^[A-Za-z0-9_.]+$/', $viewName)) {
22
  throw new \InvalidArgumentException('Invalid view name');
23
  }
24
 
 
25
  $sql = sprintf(
26
  'INSERT INTO site_services (
27
  id,
 
201
  $viewName
202
  );
203
 
204
+ return (int) $this->em->getConnection()->executeStatement($sql);
205
  }
206
  }