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