From 166cdb148e4f3b8101d5bce367d853ccbf58be3c Mon Sep 17 00:00:00 2001 From: sova-bootstrap Date: Wed, 27 May 2026 19:36:32 +0300 Subject: [PATCH] chore: initial import for test contour --- .DS_Store | Bin 0 -> 12292 bytes .cursorignore | 3 + .editorconfig | 17 + .env.dev | 23 + .env.test | 3 + .gitea/workflows/build.yml | 92 + .gitignore | 34 + Dockerfile | 46 + assets/.DS_Store | Bin 0 -> 6148 bytes assets/app.js | 8 + assets/bootstrap.js | 5 + assets/controllers.json | 15 + .../controllers/csrf_protection_controller.js | 79 + assets/controllers/hello_controller.js | 16 + assets/stimulus_bootstrap.js | 5 + assets/styles/app.css | 54 + bin/console | 21 + bin/phpunit | 23 + compose.override.yaml | 18 + compose.yaml | 25 + composer.json | 136 + config/.DS_Store | Bin 0 -> 6148 bytes config/bundles.php | 20 + config/packages/asset_mapper.yaml | 11 + config/packages/cache.yaml | 10 + config/packages/csrf.yaml | 11 + config/packages/debug.yaml | 5 + config/packages/dev/web_profiler.yaml | 14 + config/packages/doctrine.yaml | 92 + config/packages/doctrine_migrations.yaml | 6 + config/packages/framework.yaml | 31 + config/packages/lexik_jwt_authentication.yaml | 5 + config/packages/lock.yaml | 2 + config/packages/mailer.yaml | 3 + config/packages/messenger.yaml | 24 + config/packages/monolog.yaml | 76 + config/packages/nelmio_api_doc.yaml | 26 + config/packages/nelmio_cors.yaml | 12 + config/packages/notifier.yaml | 12 + config/packages/property_info.yaml | 3 + config/packages/routing.yaml | 11 + config/packages/scheduler.yaml | 3 + config/packages/security.yaml | 38 + config/packages/serializer.yaml | 5 + config/packages/translation.yaml | 5 + config/packages/twig.yaml | 6 + config/packages/ux_turbo.yaml | 4 + config/packages/validator.yaml | 12 + config/packages/web_profiler.yaml | 13 + config/preload.php | 5 + config/reference.php | 1891 + config/routes.yaml | 5 + config/routes/framework.yaml | 4 + config/routes/nelmio_api_doc.yaml | 12 + config/routes/security.yaml | 3 + config/routes/web_profiler.yaml | 8 + config/services.php | 12 + config/services.yaml | 232 + config/services_stub.yaml | 13 + docker/fpm-pool.conf | 11 + docker/nginx/default.conf | 32 + importmap.php | 28 + issues-27.html | 59347 ++++++++++++++++ migrations/.gitignore | 0 migrations/Version20260213132749.php | 44 + migrations/Version20260213132759.php | 114 + migrations/Version20260311212936.php | 35 + migrations/Version20260417120000.php | 28 + migrations/Version20260515142000.php | 53 + mr.diff | 3255 + phpunit.dist.xml | 34 + public/favicon.ico | Bin 0 -> 27724 bytes public/images/logo/comfort.jpg | Bin 0 -> 6860 bytes public/images/logo/sovamed.png | Bin 0 -> 10931 bytes public/images/logo/sovenok.png | Bin 0 -> 6913 bytes public/images/logo/wmtmed.png | Bin 0 -> 14410 bytes public/index.php | 9 + public/robots.txt | 3 + public/swagger.json | 3 + src/.DS_Store | Bin 0 -> 8196 bytes src/Command/BitrixUpdateDoctorsCommand.php | 84 + src/Command/BitrixUpdateReviewsCommand.php | 243 + src/Command/ClearScheduleCacheCommand.php | 83 + src/Command/UploadDepartmentsCommand.php | 306 + src/Command/UploadDiseasesCommand.php | 60 + src/Command/UploadDoctorsCommand.php | 262 + src/Command/UploadFilialsCommand.php | 104 + src/Command/UploadMedicalCentersCommand.php | 61 + src/Command/UploadNewsCommand.php | 60 + src/Command/UploadPriceCommand.php | 181 + src/Command/UploadPriceDepCommand.php | 76 + src/Command/UploadPromoCommand.php | 60 + src/Command/UploadSiteServicesCommand.php | 60 + src/Controller/.gitignore | 0 src/Controller/ArticleController.php | 87 + src/Controller/CalltouchController.php | 44 + src/Controller/DefaultController.php | 19 + src/Controller/DepartmentController.php | 103 + src/Controller/DiseaseController.php | 75 + src/Controller/FilialController.php | 187 + src/Controller/HelperController.php | 28 + src/Controller/InfoclinicaController.php | 151 + src/Controller/LocationController.php | 148 + src/Controller/MedicalCenterController.php | 75 + src/Controller/NewsController.php | 75 + src/Controller/PriceDepartmentController.php | 30 + src/Controller/PriceListController.php | 75 + src/Controller/PromoController.php | 75 + src/Controller/ReviewController.php | 222 + src/Controller/ServiceController.php | 75 + src/Controller/SiteServiceController.php | 75 + src/Controller/SpecialistController.php | 360 + .../SpecialistDcodeDescriptionController.php | 184 + src/Controller/SpecialistDocsController.php | 223 + src/Controller/StockController.php | 266 + src/Controller/UserController.php | 286 + src/Controller/UsrlogController.php | 90 + src/Controller/WebGetDocinfoController.php | 152 + src/Controller/XmlFeedController.php | 102 + src/Dto/AnonymousReserveRequestDto.php | 101 + src/Dto/CalltouchCreateRequestDto.php | 40 + src/Dto/Content/ContentFilterDto.php | 81 + src/Dto/FileUploadDto.php | 17 + src/Dto/RegionDto.php | 13 + src/Dto/RegistrationDto.php | 42 + src/Dto/ReviewInputDto.php | 31 + src/Dto/ScheduleDayDto.php | 32 + src/Dto/ScheduleDto.php | 43 + src/Dto/SpecialistFilterDto.php | 88 + src/Dto/UserAuthDto.php | 38 + src/Dto/UserLoginDto.php | 21 + src/Dto/UserUidAuthDto.php | 20 + src/Entity/.gitignore | 0 src/Entity/AlertSms.php | 66 + src/Entity/Article.php | 191 + src/Entity/Banner.php | 93 + src/Entity/Behavior/UpdateTimestampTrait.php | 29 + src/Entity/Department.php | 129 + src/Entity/Disease.php | 405 + src/Entity/Filial.php | 231 + src/Entity/Idoctor.php | 128 + src/Entity/Location.php | 135 + src/Entity/MarkKiosk.php | 96 + src/Entity/MedicalCenter.php | 515 + src/Entity/News.php | 277 + src/Entity/PriceDepartment.php | 86 + src/Entity/PriceList.php | 246 + src/Entity/Promo.php | 277 + src/Entity/Record.php | 122 + src/Entity/Review.php | 161 + src/Entity/Schedule.php | 249 + src/Entity/SiteService.php | 933 + src/Entity/Specialist.php | 720 + src/Entity/SpecialistDcodeDescription.php | 121 + src/Entity/SpecialistDocs.php | 122 + src/Entity/Stock.php | 158 + src/Entity/User.php | 192 + src/Entity/WebGetDocinfo.php | 284 + src/Entity/WidgetForm.php | 83 + src/Entity/WidgetFormInput.php | 96 + src/EventListener/JsonExceptionHandler.php | 41 + src/Form/WidgetFormInputType.php | 39 + src/Form/WidgetFormType.php | 25 + src/Kernel.php | 11 + .../GetAnonymousReserveRequestMessage.php | 19 + src/Message/GetScheduleMessage.php | 31 + src/Message/GetSpecialistPictureMessage.php | 18 + ...tAnonymousReserveRequestMessageHandler.php | 75 + .../GetScheduleMessageHandler.php | 95 + .../GetSpecialistPictureMessageHandler.php | 102 + src/Repository/.gitignore | 0 src/Repository/AlertSmsRepository.php | 21 + src/Repository/ArticleRepository.php | 69 + src/Repository/BannerRepository.php | 21 + src/Repository/ContentFilterTrait.php | 58 + src/Repository/DepartmentRepository.php | 87 + src/Repository/DiseaseRepository.php | 36 + src/Repository/FilialRepository.php | 70 + src/Repository/IdoctorRepository.php | 67 + src/Repository/LocationRepository.php | 95 + src/Repository/MarkKioskRepository.php | 43 + src/Repository/MedicalCenterRepository.php | 36 + src/Repository/NewsRepository.php | 40 + src/Repository/PriceDepartmentRepository.php | 43 + src/Repository/PriceListRepository.php | 108 + src/Repository/PromoRepository.php | 36 + src/Repository/RecordRepository.php | 21 + src/Repository/ReviewRepository.php | 66 + src/Repository/ScheduleRepository.php | 155 + src/Repository/SiteServiceRepository.php | 36 + .../SpecialistDcodeDescriptionRepository.php | 90 + src/Repository/SpecialistDocsRepository.php | 43 + src/Repository/SpecialistRepository.php | 227 + src/Repository/StockRepository.php | 48 + src/Repository/UserRepository.php | 39 + src/Repository/WebGetDocinfoRepository.php | 78 + src/Repository/WidgetFormInputRepository.php | 22 + src/Repository/WidgetFormRepository.php | 21 + src/Schedule.php | 28 + .../Normalizer/FilialNormalizer.php | 42 + .../Normalizer/SpecialistDocsNormalizer.php | 42 + .../Normalizer/SpecialistNormalizer.php | 56 + src/Serializer/Normalizer/StockNormalizer.php | 43 + src/Service/Bitrix/BitrixService.php | 205 + .../Client/AbstractHttpClientService.php | 163 + src/Service/Client/BitrixClientService.php | 21 + src/Service/Client/CalltouchClientService.php | 65 + .../Client/InfoclinicaClientService.php | 94 + .../AbstractHttpClientServiceInterface.php | 10 + .../BitrixClientServiceInterface.php | 8 + .../CalltouchClientServiceInterface.php | 10 + .../InfoclinicaClientServiceInterface.php | 12 + .../SmartCaptchaClientServiceInterface.php | 8 + .../Interfaces/SmsClientServiceInterface.php | 10 + .../Client/SmartCaptchaClientService.php | 33 + src/Service/Client/Sms4bClientService.php | 72 + src/Service/Client/SmsruClientService.php | 66 + .../AlwaysValidSmartCaptchaClientService.php | 23 + .../Stub/NoopCalltouchClientService.php | 24 + .../Client/Stub/NoopSmsClientService.php | 35 + src/Service/Crud/CrudResponder.php | 195 + src/Service/Crypt/AESCryptService.php | 66 + .../Interfaces/AESCryptServiceInterface.php | 9 + .../Interfaces/JWTDecoderServiceInterface.php | 10 + src/Service/DecoderJWT/JWTDecoderService.php | 37 + src/Service/Department/DepartmentService.php | 34 + src/Service/DiseaseCrudService.php | 107 + .../ScheduleErrorHandlerService.php | 54 + .../FileUploader/FileUploaderService.php | 59 + .../FileUploaderServiceInterface.php | 13 + src/Service/Filial/FilialService.php | 34 + src/Service/Helper/HelperService.php | 33 + src/Service/Image/ImageService.php | 164 + .../Interfaces/ImageServiceInterface.php | 10 + src/Service/Location/LocationService.php | 34 + src/Service/Mail/SendMailConfig.php | 18 + src/Service/Mail/SendMailService.php | 29 + src/Service/MedicalCenterCrudService.php | 128 + src/Service/NewsCrudService.php | 85 + src/Service/Pagination/Paginator.php | 107 + .../Performance/PerformanceTrackerService.php | 36 + src/Service/PriceList/PriceListService.php | 34 + src/Service/PromoCrudService.php | 83 + .../ScheduleCache/ScheduleCacheService.php | 208 + src/Service/Sequence/SequenceService.php | 64 + src/Service/SiteServiceCrudService.php | 206 + .../Interfaces/SpecialistServiceInterface.php | 17 + src/Service/Specialist/SpecialistService.php | 100 + .../Interfaces/TransliteServiceInterface.php | 8 + src/Service/Translite/TransliteService.php | 35 + src/Service/User/AuthenticationService.php | 56 + .../AuthenticationServiceInterface.php | 12 + .../RegistrationServiceInterface.php | 11 + .../UserProfileServiceInterface.php | 11 + src/Service/User/RegistrationService.php | 79 + src/Service/User/UserProfileService.php | 28 + .../XmlFeedGeneratorService.php | 623 + .../XmlFeedGeneratorV1Service.php | 264 + templates/.DS_Store | Bin 0 -> 6148 bytes templates/base.html.twig | 18 + templates/base_plain.html.twig | 19 + .../SwaggerUi/index.html.twig | 46 + templates/service/comingsoon.html.twig | 24 + tests/.DS_Store | Bin 0 -> 6148 bytes tests/Controller/CalltouchControllerTest.php | 16 + .../Controller/CertificateControllerTest.php | 16 + .../Controller/InfoclinicaControllerTest.php | 88 + .../InfoclinicaDoctorControllerTest.php | 16 + tests/Controller/LocationControllerTest.php | 16 + tests/Controller/ServiceControllerTest.php | 16 + tests/Controller/StockControllerTest.php | 16 + tests/Service/AESCryptServiceTest.php | 29 + tests/Service/BitrixServiceTest.php | 31 + tests/Service/CalltouchClientServiceTest.php | 57 + tests/Service/ImageServiceTest.php | 76 + .../Service/InfoclinicaClientServiceTest.php | 88 + tests/Service/MessageSenderServiceTest.php | 29 + tests/Service/SchedulerTransportTest.php | 42 + tests/Service/Sms4bClientServiceTest.php | 45 + tests/Service/SmsruClientServiceTest.php | 45 + tests/bootstrap.php | 13 + translations/.gitignore | 0 282 files changed, 84872 insertions(+) create mode 100644 .DS_Store create mode 100644 .cursorignore create mode 100644 .editorconfig create mode 100644 .env.dev create mode 100644 .env.test create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 assets/.DS_Store create mode 100644 assets/app.js create mode 100644 assets/bootstrap.js create mode 100644 assets/controllers.json create mode 100644 assets/controllers/csrf_protection_controller.js create mode 100644 assets/controllers/hello_controller.js create mode 100644 assets/stimulus_bootstrap.js create mode 100644 assets/styles/app.css create mode 100644 bin/console create mode 100644 bin/phpunit create mode 100644 compose.override.yaml create mode 100644 compose.yaml create mode 100644 composer.json create mode 100644 config/.DS_Store create mode 100644 config/bundles.php create mode 100644 config/packages/asset_mapper.yaml create mode 100644 config/packages/cache.yaml create mode 100644 config/packages/csrf.yaml create mode 100644 config/packages/debug.yaml create mode 100644 config/packages/dev/web_profiler.yaml create mode 100644 config/packages/doctrine.yaml create mode 100644 config/packages/doctrine_migrations.yaml create mode 100644 config/packages/framework.yaml create mode 100644 config/packages/lexik_jwt_authentication.yaml create mode 100644 config/packages/lock.yaml create mode 100644 config/packages/mailer.yaml create mode 100644 config/packages/messenger.yaml create mode 100644 config/packages/monolog.yaml create mode 100644 config/packages/nelmio_api_doc.yaml create mode 100644 config/packages/nelmio_cors.yaml create mode 100644 config/packages/notifier.yaml create mode 100644 config/packages/property_info.yaml create mode 100644 config/packages/routing.yaml create mode 100644 config/packages/scheduler.yaml create mode 100644 config/packages/security.yaml create mode 100644 config/packages/serializer.yaml create mode 100644 config/packages/translation.yaml create mode 100644 config/packages/twig.yaml create mode 100644 config/packages/ux_turbo.yaml create mode 100644 config/packages/validator.yaml create mode 100644 config/packages/web_profiler.yaml create mode 100644 config/preload.php create mode 100644 config/reference.php create mode 100644 config/routes.yaml create mode 100644 config/routes/framework.yaml create mode 100644 config/routes/nelmio_api_doc.yaml create mode 100644 config/routes/security.yaml create mode 100644 config/routes/web_profiler.yaml create mode 100644 config/services.php create mode 100644 config/services.yaml create mode 100644 config/services_stub.yaml create mode 100644 docker/fpm-pool.conf create mode 100644 docker/nginx/default.conf create mode 100644 importmap.php create mode 100644 issues-27.html create mode 100644 migrations/.gitignore create mode 100644 migrations/Version20260213132749.php create mode 100644 migrations/Version20260213132759.php create mode 100644 migrations/Version20260311212936.php create mode 100644 migrations/Version20260417120000.php create mode 100644 migrations/Version20260515142000.php create mode 100644 mr.diff create mode 100644 phpunit.dist.xml create mode 100644 public/favicon.ico create mode 100644 public/images/logo/comfort.jpg create mode 100644 public/images/logo/sovamed.png create mode 100644 public/images/logo/sovenok.png create mode 100644 public/images/logo/wmtmed.png create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 public/swagger.json create mode 100644 src/.DS_Store create mode 100644 src/Command/BitrixUpdateDoctorsCommand.php create mode 100644 src/Command/BitrixUpdateReviewsCommand.php create mode 100644 src/Command/ClearScheduleCacheCommand.php create mode 100644 src/Command/UploadDepartmentsCommand.php create mode 100644 src/Command/UploadDiseasesCommand.php create mode 100644 src/Command/UploadDoctorsCommand.php create mode 100644 src/Command/UploadFilialsCommand.php create mode 100644 src/Command/UploadMedicalCentersCommand.php create mode 100644 src/Command/UploadNewsCommand.php create mode 100644 src/Command/UploadPriceCommand.php create mode 100644 src/Command/UploadPriceDepCommand.php create mode 100644 src/Command/UploadPromoCommand.php create mode 100644 src/Command/UploadSiteServicesCommand.php create mode 100644 src/Controller/.gitignore create mode 100644 src/Controller/ArticleController.php create mode 100644 src/Controller/CalltouchController.php create mode 100644 src/Controller/DefaultController.php create mode 100644 src/Controller/DepartmentController.php create mode 100644 src/Controller/DiseaseController.php create mode 100644 src/Controller/FilialController.php create mode 100644 src/Controller/HelperController.php create mode 100644 src/Controller/InfoclinicaController.php create mode 100644 src/Controller/LocationController.php create mode 100644 src/Controller/MedicalCenterController.php create mode 100644 src/Controller/NewsController.php create mode 100644 src/Controller/PriceDepartmentController.php create mode 100644 src/Controller/PriceListController.php create mode 100644 src/Controller/PromoController.php create mode 100644 src/Controller/ReviewController.php create mode 100644 src/Controller/ServiceController.php create mode 100644 src/Controller/SiteServiceController.php create mode 100644 src/Controller/SpecialistController.php create mode 100644 src/Controller/SpecialistDcodeDescriptionController.php create mode 100644 src/Controller/SpecialistDocsController.php create mode 100644 src/Controller/StockController.php create mode 100644 src/Controller/UserController.php create mode 100644 src/Controller/UsrlogController.php create mode 100644 src/Controller/WebGetDocinfoController.php create mode 100644 src/Controller/XmlFeedController.php create mode 100644 src/Dto/AnonymousReserveRequestDto.php create mode 100644 src/Dto/CalltouchCreateRequestDto.php create mode 100644 src/Dto/Content/ContentFilterDto.php create mode 100644 src/Dto/FileUploadDto.php create mode 100644 src/Dto/RegionDto.php create mode 100644 src/Dto/RegistrationDto.php create mode 100644 src/Dto/ReviewInputDto.php create mode 100644 src/Dto/ScheduleDayDto.php create mode 100644 src/Dto/ScheduleDto.php create mode 100644 src/Dto/SpecialistFilterDto.php create mode 100644 src/Dto/UserAuthDto.php create mode 100644 src/Dto/UserLoginDto.php create mode 100644 src/Dto/UserUidAuthDto.php create mode 100644 src/Entity/.gitignore create mode 100644 src/Entity/AlertSms.php create mode 100644 src/Entity/Article.php create mode 100644 src/Entity/Banner.php create mode 100644 src/Entity/Behavior/UpdateTimestampTrait.php create mode 100644 src/Entity/Department.php create mode 100644 src/Entity/Disease.php create mode 100644 src/Entity/Filial.php create mode 100644 src/Entity/Idoctor.php create mode 100644 src/Entity/Location.php create mode 100644 src/Entity/MarkKiosk.php create mode 100644 src/Entity/MedicalCenter.php create mode 100644 src/Entity/News.php create mode 100644 src/Entity/PriceDepartment.php create mode 100644 src/Entity/PriceList.php create mode 100644 src/Entity/Promo.php create mode 100644 src/Entity/Record.php create mode 100644 src/Entity/Review.php create mode 100644 src/Entity/Schedule.php create mode 100644 src/Entity/SiteService.php create mode 100644 src/Entity/Specialist.php create mode 100644 src/Entity/SpecialistDcodeDescription.php create mode 100644 src/Entity/SpecialistDocs.php create mode 100644 src/Entity/Stock.php create mode 100644 src/Entity/User.php create mode 100644 src/Entity/WebGetDocinfo.php create mode 100644 src/Entity/WidgetForm.php create mode 100644 src/Entity/WidgetFormInput.php create mode 100644 src/EventListener/JsonExceptionHandler.php create mode 100644 src/Form/WidgetFormInputType.php create mode 100644 src/Form/WidgetFormType.php create mode 100644 src/Kernel.php create mode 100644 src/Message/GetAnonymousReserveRequestMessage.php create mode 100644 src/Message/GetScheduleMessage.php create mode 100644 src/Message/GetSpecialistPictureMessage.php create mode 100644 src/MessageHandler/GetAnonymousReserveRequestMessageHandler.php create mode 100644 src/MessageHandler/GetScheduleMessageHandler.php create mode 100644 src/MessageHandler/GetSpecialistPictureMessageHandler.php create mode 100644 src/Repository/.gitignore create mode 100644 src/Repository/AlertSmsRepository.php create mode 100644 src/Repository/ArticleRepository.php create mode 100644 src/Repository/BannerRepository.php create mode 100644 src/Repository/ContentFilterTrait.php create mode 100644 src/Repository/DepartmentRepository.php create mode 100644 src/Repository/DiseaseRepository.php create mode 100644 src/Repository/FilialRepository.php create mode 100644 src/Repository/IdoctorRepository.php create mode 100644 src/Repository/LocationRepository.php create mode 100644 src/Repository/MarkKioskRepository.php create mode 100644 src/Repository/MedicalCenterRepository.php create mode 100644 src/Repository/NewsRepository.php create mode 100644 src/Repository/PriceDepartmentRepository.php create mode 100644 src/Repository/PriceListRepository.php create mode 100644 src/Repository/PromoRepository.php create mode 100644 src/Repository/RecordRepository.php create mode 100644 src/Repository/ReviewRepository.php create mode 100644 src/Repository/ScheduleRepository.php create mode 100644 src/Repository/SiteServiceRepository.php create mode 100644 src/Repository/SpecialistDcodeDescriptionRepository.php create mode 100644 src/Repository/SpecialistDocsRepository.php create mode 100644 src/Repository/SpecialistRepository.php create mode 100644 src/Repository/StockRepository.php create mode 100644 src/Repository/UserRepository.php create mode 100644 src/Repository/WebGetDocinfoRepository.php create mode 100644 src/Repository/WidgetFormInputRepository.php create mode 100644 src/Repository/WidgetFormRepository.php create mode 100644 src/Schedule.php create mode 100644 src/Serializer/Normalizer/FilialNormalizer.php create mode 100644 src/Serializer/Normalizer/SpecialistDocsNormalizer.php create mode 100644 src/Serializer/Normalizer/SpecialistNormalizer.php create mode 100644 src/Serializer/Normalizer/StockNormalizer.php create mode 100644 src/Service/Bitrix/BitrixService.php create mode 100644 src/Service/Client/AbstractHttpClientService.php create mode 100644 src/Service/Client/BitrixClientService.php create mode 100644 src/Service/Client/CalltouchClientService.php create mode 100644 src/Service/Client/InfoclinicaClientService.php create mode 100644 src/Service/Client/Interfaces/AbstractHttpClientServiceInterface.php create mode 100644 src/Service/Client/Interfaces/BitrixClientServiceInterface.php create mode 100644 src/Service/Client/Interfaces/CalltouchClientServiceInterface.php create mode 100644 src/Service/Client/Interfaces/InfoclinicaClientServiceInterface.php create mode 100644 src/Service/Client/Interfaces/SmartCaptchaClientServiceInterface.php create mode 100644 src/Service/Client/Interfaces/SmsClientServiceInterface.php create mode 100644 src/Service/Client/SmartCaptchaClientService.php create mode 100644 src/Service/Client/Sms4bClientService.php create mode 100644 src/Service/Client/SmsruClientService.php create mode 100644 src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php create mode 100644 src/Service/Client/Stub/NoopCalltouchClientService.php create mode 100644 src/Service/Client/Stub/NoopSmsClientService.php create mode 100644 src/Service/Crud/CrudResponder.php create mode 100644 src/Service/Crypt/AESCryptService.php create mode 100644 src/Service/Crypt/Interfaces/AESCryptServiceInterface.php create mode 100644 src/Service/DecoderJWT/Interfaces/JWTDecoderServiceInterface.php create mode 100644 src/Service/DecoderJWT/JWTDecoderService.php create mode 100644 src/Service/Department/DepartmentService.php create mode 100644 src/Service/DiseaseCrudService.php create mode 100644 src/Service/ErrorHandler/ScheduleErrorHandlerService.php create mode 100644 src/Service/FileUploader/FileUploaderService.php create mode 100644 src/Service/FileUploader/Interfaces/FileUploaderServiceInterface.php create mode 100644 src/Service/Filial/FilialService.php create mode 100644 src/Service/Helper/HelperService.php create mode 100644 src/Service/Image/ImageService.php create mode 100644 src/Service/Image/Interfaces/ImageServiceInterface.php create mode 100644 src/Service/Location/LocationService.php create mode 100644 src/Service/Mail/SendMailConfig.php create mode 100644 src/Service/Mail/SendMailService.php create mode 100644 src/Service/MedicalCenterCrudService.php create mode 100644 src/Service/NewsCrudService.php create mode 100644 src/Service/Pagination/Paginator.php create mode 100644 src/Service/Performance/PerformanceTrackerService.php create mode 100644 src/Service/PriceList/PriceListService.php create mode 100644 src/Service/PromoCrudService.php create mode 100644 src/Service/ScheduleCache/ScheduleCacheService.php create mode 100644 src/Service/Sequence/SequenceService.php create mode 100644 src/Service/SiteServiceCrudService.php create mode 100644 src/Service/Specialist/Interfaces/SpecialistServiceInterface.php create mode 100644 src/Service/Specialist/SpecialistService.php create mode 100644 src/Service/Translite/Interfaces/TransliteServiceInterface.php create mode 100644 src/Service/Translite/TransliteService.php create mode 100644 src/Service/User/AuthenticationService.php create mode 100644 src/Service/User/Interfaces/AuthenticationServiceInterface.php create mode 100644 src/Service/User/Interfaces/RegistrationServiceInterface.php create mode 100644 src/Service/User/Interfaces/UserProfileServiceInterface.php create mode 100644 src/Service/User/RegistrationService.php create mode 100644 src/Service/User/UserProfileService.php create mode 100644 src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php create mode 100644 src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php create mode 100644 templates/.DS_Store create mode 100644 templates/base.html.twig create mode 100644 templates/base_plain.html.twig create mode 100644 templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig create mode 100644 templates/service/comingsoon.html.twig create mode 100644 tests/.DS_Store create mode 100644 tests/Controller/CalltouchControllerTest.php create mode 100644 tests/Controller/CertificateControllerTest.php create mode 100644 tests/Controller/InfoclinicaControllerTest.php create mode 100644 tests/Controller/InfoclinicaDoctorControllerTest.php create mode 100644 tests/Controller/LocationControllerTest.php create mode 100644 tests/Controller/ServiceControllerTest.php create mode 100644 tests/Controller/StockControllerTest.php create mode 100644 tests/Service/AESCryptServiceTest.php create mode 100644 tests/Service/BitrixServiceTest.php create mode 100644 tests/Service/CalltouchClientServiceTest.php create mode 100644 tests/Service/ImageServiceTest.php create mode 100644 tests/Service/InfoclinicaClientServiceTest.php create mode 100644 tests/Service/MessageSenderServiceTest.php create mode 100644 tests/Service/SchedulerTransportTest.php create mode 100644 tests/Service/Sms4bClientServiceTest.php create mode 100644 tests/Service/SmsruClientServiceTest.php create mode 100644 tests/bootstrap.php create mode 100644 translations/.gitignore diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..aafb0a1593f182e15c28090619a6084a4dcde349 GIT binary patch literal 12292 zcmeHNU2Ggz6+UPDlNo1hPg+(fshS4IapJU2nzma11d@`(X*RB%G|hH*XKYV0 zJF}gcUB@*k7J`?Sg5ZV!w!E}J)jU)!R6I}+&=#Ih2}rFF66Hq)B#IC(KqWZ$-dShv z{t$!^s>sZ0=FZ&voqNyu?$4aNX8-^vD&}s0?Es+C$x=fHXzv0@ucwFD!OGd$aI{el z(s_tZx;Fpvr;CKZ9T@G!}4eqo2fYO}N8!UQDhl`$j+_zZy zY|(QnB{#?!8;W_SSaI^9HSRcB`(candV`|xInHq}uvs;y=0fX2$jTau=1vEmA5QYh za)wa}tP>X1Wml~!sxg?esYxc-SI07HZ}jvU8?L*)Z{rP{(l_tewRhiL<8wu?RI=PK zm~rw2%bBeT?e90Ks!89Xt9de@%FeKiqn>Hc+mv(OcFmJ+zGR(c*+=q0I9;|}%e>sb zdCU6-Qu>XvRXeZ?jy1Dno8jWAd{DGp({>kTNYJ{VZonbUSSQe1^=+zQYDR~6yhXoN zH9n*no#Jt5U|2O?R1Jkc>bnO)A{dfzG;6A(?58*MK#uJ#u89a+Q%wqvfETM~KdM=Q0PBP3bl$fDP_p$16syvx)pXox}g&2i>iq9LYE9 z_}Z0{xb~D={H4%7s=ef0(Yvu!);LW7*a$`Npaf;Qvc1tTrBzCO`q^OeoMqZMmB5iT z$E{@6BQeeOhMJnoeWp&XyH3t09nO(1UOiW=MTx6sk`Bog;k>M?LQAQnYbS5hg?X}Z zLB=BKC$%DB<$kIDnXpoMymwyjkBu$Ha@1bDk6K}*o>GoeGtfo56>b~v9j3VhmaIFo zQ_{VgP@->r`q!#N{a7Y;%diOLI!V7yvWFzuZn;9O1+bI$N@FCs$ZM$^37p+O#zsOH zwM4wq2+9-Q=9UL?doV%mJ#jS|neB8BXQ4^man=b_n-fj-h_in+tkes@dtn{f#zv?~ zcFn_lqg5DZ!Vp{&i!R~ANG{-a7pSH>Ci!X*XQh+mE3|GnBwdu-;l2(`2bs zR}04KFiqDI2_rFtoS`-vFMflzY*xj!jZl)F>dQ>esHBC4Yo$$fi%KP}dZ|tTowQme z#^?EHT&A9Ax>B$nka|#~zOqQJOn5e$X*S6Hz~@}tvL+Iel`#4=>C*(&!s4dLrq(w> zN=%RubaV(S=vr?HTLa>j#E`ND*5~DV;+jAA_SxDSH6|aQbwQ|W@y5YyHzZnZ$zj$xf z>B_wMM`E6#^#4p9mhDJ~TVV@JK{@)@=A~LJ>}R!j@j7+A`aqTf!r3q%R)UGVe?NOV z>3L3}cAqcYM`7Ze(KqS2VcvEvp9|QgMUXSI6gu^mCcKJk23$5P3RF$iaz_8k%AWnV z52bJ0J$i8{iU0lQG4e}2n{M2;ednIBJ2M9lAHDaoh?wj8X=2+AS@_$p4{Fzf-8_EG zLfhKqUJ(dSwSvx73id-4J6tvfRh74qzA2?kL3WCIQ#}-~zxk$ADjtuI1a94t(s^i} z;^6`~yi3=4Xr7|rB4>23o)Uq5im;1z_U+eGwJ1Nu*+u4EeR^u`OMobEd?J;q3-40| zUEn`JWv7G1yl+h}(J+m2@1b1&*#qUXZvqaQcIfd!V&fQDaf*E{Fm9TbNfKN7H}|Hp zsTtLHU8T<$Y`1FD)Syov#0>dw1?qnazkuJtJMdQu)OVtWUAP|ma0_n3A-oO8C}h7M zCn#WlFAv$D!9}$39EIsG;KR5=!TOKmqZFopoC5Wq#V_NN_!NGHLiEq$3-}@h>0idz zqR{+X_-hKz{}$iDzv18UU8O_Wr1UGBm95H zBn|nT6k#Z5bFbF^Bo`>Qo<$qJMOuElR>8eO6 WhmQaGM*zwDKY9PhVini&`~RQ0t`4UF literal 0 HcmV?d00001 diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..0bb07d1 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,3 @@ +.env +.env.* +config/secrets/ \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6699076 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{compose.yaml,compose.*.yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..fde4db9 --- /dev/null +++ b/.env.dev @@ -0,0 +1,23 @@ +# In all environments, the following files are loaded if they exist, +# the latter taking precedence over the former: +# +# * .env contains default values for the environment variables needed by the app +# * .env.local uncommitted file with local overrides +# * .env.$APP_ENV committed environment-specific defaults +# * .env.$APP_ENV.local uncommitted environment-specific overrides +# +# Real environment variables win over .env files. +# +# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. +# https://symfony.com/doc/current/configuration/secrets.html +# +# Run "composer dump-env dev" to compile .env files for production use (requires symfony/flex >=1.2). +# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration + +###> symfony/framework-bundle ### +APP_ENV=dev +APP_DEBUG=1 +APP_RUNTIME_ENV=dev +APP_RUNTIME_MODE=debug +SYMFONY_ENV=dev +###< symfony/framework-bundle ### diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..b77aa8d --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +SYMFONY_DEPRECATIONS_HELPER=999999 diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..430bf0c --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,92 @@ +name: backend-ci-cd + +on: + push: + tags: + - 'backend-v*' + pull_request: + branches: [main] + +env: + REGISTRY: git.sova.local + IMAGE: git.sova.local/sova/backend + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: pdo_pgsql, redis, intl, zip, gd + - run: composer install --prefer-dist --no-interaction + - run: composer phpunit || true + - run: composer audit || true + + parse-tag: + if: startsWith(github.ref, 'refs/tags/backend-v') + runs-on: ubuntu-latest + outputs: + full_tag: ${{ steps.meta.outputs.full_tag }} + env: ${{ steps.meta.outputs.env }} + version: ${{ steps.meta.outputs.version }} + steps: + - name: Parse tag + id: meta + run: | + TAG="${GITHUB_REF#refs/tags/}" + echo "full_tag=$TAG" >> "$GITHUB_OUTPUT" + echo "env=$(echo "$TAG" | sed -E 's/backend-v([0-9.]+)-([a-z]+)/\2/')" >> "$GITHUB_OUTPUT" + echo "version=$(echo "$TAG" | sed -E 's/backend-v([0-9.]+).*/\1/')" >> "$GITHUB_OUTPUT" + + build-and-push: + needs: [test, parse-tag] + if: startsWith(github.ref, 'refs/tags/backend-v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Docker login + run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u sova-ci --password-stdin + - name: Build and push + run: | + TAG="${{ needs.parse-tag.outputs.full_tag }}" + docker build -f Dockerfile -t "$IMAGE:${TAG}" -t "$IMAGE:${{ needs.parse-tag.outputs.version }}" . + docker push "$IMAGE:${TAG}" + docker push "$IMAGE:${{ needs.parse-tag.outputs.version }}" + + deploy-gitops: + needs: [build-and-push, parse-tag] + if: startsWith(github.ref, 'refs/tags/backend-v') + runs-on: ubuntu-latest + steps: + - name: Bump image tag in sova-deploy + env: + DEPLOY_KEY: ${{ secrets.SOVA_DEPLOY_KEY }} + run: | + eval "$(ssh-agent -s)" + echo "$DEPLOY_KEY" | ssh-add - + git clone git@gitea.sova.local:sova/sova-deploy.git + cd sova-deploy + ENV="${{ needs.parse-tag.outputs.env }}" + TAG="${{ needs.parse-tag.outputs.full_tag }}" + git config user.email "ci-bot@sova.local" + git config user.name "sova-ci" + MAX_RETRIES=5 + for attempt in $(seq 1 $MAX_RETRIES); do + git pull --rebase origin main + yq -i ".image.tag = \"${TAG}\"" "apps/backend/values-${ENV}.yaml" + git add "apps/backend/values-${ENV}.yaml" + git diff --cached --quiet && { echo "No changes"; exit 0; } + git commit -m "chore(backend): bump ${ENV} to ${TAG}" + if git push origin main; then + echo "Push OK on attempt ${attempt}" + exit 0 + fi + echo "Push failed, retry ${attempt}/${MAX_RETRIES}..." + git reset --hard HEAD~1 + sleep $((attempt * 2)) + done + echo "Failed to push after ${MAX_RETRIES} attempts" + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21c50bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ + +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/public/uploads/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +composer.lock +symfony.lock +yarn.lock + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### + +###> phpunit/phpunit ### +/phpunit.xml +/.phpunit.cache/ +###< phpunit/phpunit ### + +###> symfony/asset-mapper ### +/public/assets/ +/assets/vendor/ +###< symfony/asset-mapper ### + +/php: + +.cursorignore +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..14a830a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1 + +FROM composer:2 AS vendor +WORKDIR /app +COPY composer.json composer.lock symfony.lock ./ +RUN composer install --no-dev --no-scripts --prefer-dist --no-interaction --ignore-platform-reqs +COPY . . +RUN composer dump-autoload --classmap-authoritative --no-dev \ + && composer run-script --no-dev post-install-cmd || true + +FROM php:8.4-fpm-alpine AS runtime + +WORKDIR /app + +RUN apk add --no-cache \ + tzdata postgresql-dev postgresql-client libpq libzip-dev \ + libjpeg-turbo-dev freetype-dev libwebp-dev libpng-dev icu-dev \ + oniguruma-dev bash autoconf g++ make \ + && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \ + && docker-php-ext-install -j$(nproc) \ + zip pdo pdo_pgsql pdo_mysql gd intl opcache \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && rm -rf /tmp/pear /var/cache/apk/* + +ENV TZ=Europe/Moscow +RUN cp /usr/share/zoneinfo/$TZ /etc/localtime && echo "$TZ" > /etc/timezone + +COPY docker/fpm-pool.conf /usr/local/etc/php-fpm.d/zz-docker.conf +COPY --from=vendor /app /app + +RUN mkdir -p var/cache var/log public/uploads \ + && chown -R www-data:www-data var public/uploads \ + && chmod -R 775 var + +ENV APP_ENV=prod APP_DEBUG=0 +RUN APP_SECRET=build-placeholder \ + DATABASE_URL="postgresql://build:build@127.0.0.1:5432/build" \ + DATABASE_CABINET_URL="postgresql://build:build@127.0.0.1:5432/build" \ + DATABASE_BITRIX_URL="mysql://build:build@127.0.0.1:3306/build" \ + REDIS_URL="redis://127.0.0.1:6379" \ + php bin/console cache:warmup --env=prod || true + +USER www-data +EXPOSE 9000 +CMD ["php-fpm", "-F"] diff --git a/assets/.DS_Store b/assets/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c7ebb9aacb59720b8daeb73d16d852de57905e41 GIT binary patch literal 6148 zcmeHKOHRWu5FNJ_Dk_UES^5gSfvLg?dI8iF!6G453k!BW5eqKC9oTUb-h5O@lOVA{ zfM%lcbH+3FqeY>N3?Apiv?W>!`s%+}5rhWE2>{&NOv96jmJb1qH?e*p2EAP!WytlWXv*7oX zPH2x@ALTte%5wa4JU)twPQuNjta0c28jP-WT_5X7C{NE1pJ7>Td_*V>sNwF%HcqFT>ormLE s8=+TF7WT`EhY)n66vLNG@i|lk_Q)MzGR%swK>SBQXz<1v_)!Ml0YiCFSO5S3 literal 0 HcmV?d00001 diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..a725fbe --- /dev/null +++ b/assets/app.js @@ -0,0 +1,8 @@ +import './bootstrap.js'; +/* + * Welcome to your app's main JavaScript file! + * + * This file will be included onto the page via the importmap() Twig function, + * which should already be in your base.html.twig. + */ +import './styles/app.css'; diff --git a/assets/bootstrap.js b/assets/bootstrap.js new file mode 100644 index 0000000..d4e50c9 --- /dev/null +++ b/assets/bootstrap.js @@ -0,0 +1,5 @@ +import { startStimulusApp } from '@symfony/stimulus-bundle'; + +const app = startStimulusApp(); +// register any custom, 3rd party controllers here +// app.register('some_controller_name', SomeImportedController); diff --git a/assets/controllers.json b/assets/controllers.json new file mode 100644 index 0000000..29ea244 --- /dev/null +++ b/assets/controllers.json @@ -0,0 +1,15 @@ +{ + "controllers": { + "@symfony/ux-turbo": { + "turbo-core": { + "enabled": true, + "fetch": "eager" + }, + "mercure-turbo-stream": { + "enabled": false, + "fetch": "eager" + } + } + }, + "entrypoints": [] +} diff --git a/assets/controllers/csrf_protection_controller.js b/assets/controllers/csrf_protection_controller.js new file mode 100644 index 0000000..c722f02 --- /dev/null +++ b/assets/controllers/csrf_protection_controller.js @@ -0,0 +1,79 @@ +const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/; +const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/; + +// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager +document.addEventListener('submit', function (event) { + generateCsrfToken(event.target); +}, true); + +// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie +// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked +document.addEventListener('turbo:submit-start', function (event) { + const h = generateCsrfHeaders(event.detail.formSubmission.formElement); + Object.keys(h).map(function (k) { + event.detail.formSubmission.fetchRequest.headers[k] = h[k]; + }); +}); + +// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted +document.addEventListener('turbo:submit-end', function (event) { + removeCsrfToken(event.detail.formSubmission.formElement); +}); + +export function generateCsrfToken (formElement) { + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return; + } + + let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + let csrfToken = csrfField.value; + + if (!csrfCookie && nameCheck.test(csrfToken)) { + csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken); + csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18)))); + csrfField.dispatchEvent(new Event('change', { bubbles: true })); + } + + if (csrfCookie && tokenCheck.test(csrfToken)) { + const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict'; + document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; + } +} + +export function generateCsrfHeaders (formElement) { + const headers = {}; + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return headers; + } + + const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + + if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { + headers[csrfCookie] = csrfField.value; + } + + return headers; +} + +export function removeCsrfToken (formElement) { + const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); + + if (!csrfField) { + return; + } + + const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); + + if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { + const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0'; + + document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; + } +} + +/* stimulusFetch: 'lazy' */ +export default 'csrf-protection-controller'; diff --git a/assets/controllers/hello_controller.js b/assets/controllers/hello_controller.js new file mode 100644 index 0000000..e847027 --- /dev/null +++ b/assets/controllers/hello_controller.js @@ -0,0 +1,16 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * This is an example Stimulus controller! + * + * Any element with a data-controller="hello" attribute will cause + * this controller to be executed. The name "hello" comes from the filename: + * hello_controller.js -> "hello" + * + * Delete this file or adapt it for your use! + */ +export default class extends Controller { + connect() { + this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js'; + } +} diff --git a/assets/stimulus_bootstrap.js b/assets/stimulus_bootstrap.js new file mode 100644 index 0000000..d4e50c9 --- /dev/null +++ b/assets/stimulus_bootstrap.js @@ -0,0 +1,5 @@ +import { startStimulusApp } from '@symfony/stimulus-bundle'; + +const app = startStimulusApp(); +// register any custom, 3rd party controllers here +// app.register('some_controller_name', SomeImportedController); diff --git a/assets/styles/app.css b/assets/styles/app.css new file mode 100644 index 0000000..d7bd5c4 --- /dev/null +++ b/assets/styles/app.css @@ -0,0 +1,54 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} +body { + font-family: 'Ubuntu', sans-serif; + height: 100vh; + min-height: 600px; + display: flex; + flex-direction: column; + color: #344A5E; +} +.central-content { + display: flex; + flex-grow: 1; + flex-shrink: 0; + flex-direction: column; + justify-content: center; + align-items: center; +} +.bottom-content { + height: 116px; + display: flex; + justify-content: center; + align-items: center; +} +.caption { + margin-top: 15px; + text-align: center; +} +.big-text { + font-weight: 500; + font-size: 18px; +} +.small-text { + font-size: 14px; +} +.ref { + font-size: 14px; + color: #0279C0; + text-decoration: none; +} +.ref:hover { + text-decoration: underline; +} +.pic { + margin-left: 45px; + margin-bottom: 15px; + margin-top: -70px; +} +.b-text_lang_ru { + display: none; +} \ No newline at end of file diff --git a/bin/console b/bin/console new file mode 100644 index 0000000..d8d530e --- /dev/null +++ b/bin/console @@ -0,0 +1,21 @@ +#!/usr/bin/env php += 80000) { + require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit'; + } else { + define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php'); + require PHPUNIT_COMPOSER_INSTALL; + PHPUnit\TextUI\Command::main(); + } +} else { + if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) { + echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n"; + exit(1); + } + + require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php'; +} diff --git a/compose.override.yaml b/compose.override.yaml new file mode 100644 index 0000000..8dc54de --- /dev/null +++ b/compose.override.yaml @@ -0,0 +1,18 @@ + +services: +###> doctrine/doctrine-bundle ### + database: + ports: + - "5432" +###< doctrine/doctrine-bundle ### + +###> symfony/mailer ### + mailer: + image: axllent/mailpit + ports: + - "1025" + - "8025" + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 +###< symfony/mailer ### diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..89c74d1 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,25 @@ + +services: +###> doctrine/doctrine-bundle ### + database: + image: postgres:${POSTGRES_VERSION:-16}-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-app} + # You should definitely change the password in production + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} + POSTGRES_USER: ${POSTGRES_USER:-app} + healthcheck: + test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"] + timeout: 5s + retries: 5 + start_period: 60s + volumes: + - database_data:/var/lib/postgresql/data:rw + # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! + # - ./docker/db/data:/var/lib/postgresql/data:rw +###< doctrine/doctrine-bundle ### + +volumes: +###> doctrine/doctrine-bundle ### + database_data: +###< doctrine/doctrine-bundle ### diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..cd80aa0 --- /dev/null +++ b/composer.json @@ -0,0 +1,136 @@ +{ + "name": "sova/exo", + "description": "Единое хранилище данных ", + "type": "project", + "license": "proprietary", + "minimum-stability": "stable", + "prefer-stable": true, + "require": { + "php": ">=8.2", + "ext-ctype": "*", + "ext-gd": "*", + "ext-iconv": "*", + "babdev/pagerfanta-bundle": "^4.5", + "doctrine/dbal": "^4.3.2", + "doctrine/doctrine-bundle": "^2.15.1", + "doctrine/doctrine-migrations-bundle": "^3.4.2", + "doctrine/orm": "^3.5.2", + "dragonmantank/cron-expression": "^3.4", + "lexik/jwt-authentication-bundle": "^3.1.1", + "nelmio/api-doc-bundle": "^5.5", + "nelmio/cors-bundle": "^2.5", + "pagerfanta/doctrine-orm-adapter": "^4.7.1", + "phpdocumentor/reflection-docblock": "^5.6.2", + "phpstan/phpdoc-parser": "^2.2", + "predis/predis": "^3.2", + "scienta/doctrine-json-functions": "^6.3", + "symfony/asset": "^7.3.0", + "symfony/asset-mapper": "^7.3.2", + "symfony/cache": "^7.3.2", + "symfony/config": "^7.3.2", + "symfony/console": "^7.3.2", + "symfony/doctrine-messenger": "^7.3.2", + "symfony/dotenv": "^7.3.2", + "symfony/event-dispatcher": "^7.3.0", + "symfony/expression-language": "^7.3.2", + "symfony/flex": "^2.8.1", + "symfony/form": "^7.3.2", + "symfony/framework-bundle": "^7.3.2", + "symfony/http-client": "^7.3.2", + "symfony/intl": "^7.3.2", + "symfony/lock": "^7.3.2", + "symfony/mailer": "^7.3.2", + "symfony/messenger": "^7.3.2", + "symfony/mime": "^7.3.2", + "symfony/monolog-bundle": "^3.10", + "symfony/notifier": "^7.3.0", + "symfony/password-hasher": "^7.3.0", + "symfony/process": "^7.3.0", + "symfony/property-access": "^7.3.2", + "symfony/property-info": "^7.3.1", + "symfony/rate-limiter": "^7.3.2", + "symfony/runtime": "^7.3.1", + "symfony/scheduler": "^7.3.2", + "symfony/security-bundle": "^7.3.2", + "symfony/serializer": "^7.3.2", + "symfony/stimulus-bundle": "^2.29.1", + "symfony/string": "^7.3.2", + "symfony/translation": "^7.3.2", + "symfony/twig-bundle": "^7.3.2", + "symfony/ux-turbo": "^2.29.1", + "symfony/validator": "^7.3.2", + "symfony/web-link": "^7.3.0", + "symfony/yaml": "^7.3.2", + "twig/extra-bundle": "^2.12|^3.21", + "twig/twig": "^2.12|^3.21.1" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "bump-after-update": true, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*", + "symfony/polyfill-php74": "*", + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*", + "symfony/polyfill-php82": "*" + }, + "scripts": { + "cache:clear-all": [ + "@php bin/console doctrine:cache:clear-metadata", + "@php bin/console doctrine:cache:clear-query", + "@php bin/console doctrine:cache:clear-result", + "@php bin/console cache:clear" + ], + "phpunit": "phpunit", + "generate-swagger": "php ./vendor/bin/openapi --output ./public/swagger.json ./src", + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd", + "importmap:install": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": "false", + "require": "^7.3.0" + } + }, + "require-dev": { + "phpspec/prophecy": "^1.22", + "phpunit/phpunit": "^12.3.5", + "symfony/browser-kit": "^7.3.2", + "symfony/css-selector": "^7.3.0", + "symfony/debug-bundle": "^7.3.0", + "symfony/maker-bundle": "^1.64", + "symfony/stopwatch": "^7.3.0", + "symfony/web-profiler-bundle": "^7.3.0" + } +} diff --git a/config/.DS_Store b/config/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a8eabaa14d3f31c1afe6618de3baa65d24c8aa16 GIT binary patch literal 6148 zcmeHKJ5Iwu5Ph3O$cQE-5wE*#UXHTO@gEt` zhudO>3eU9t^Q)S=)eYa?5HlR))J;*Wcg;G+fa>e>V{trh^Q*k}DKGmq-8-kk1zyl# zkGG$9s*huOe;7ZppTQh;b966*oWVX2WFIERfH7bUoErn|*&^AMqFH0W7%&EU2ITwT zse&P4rx-sSH1P;PoY5VHV>wGmjZYX7c8ZKpoR$)`)TJYa({lQK$_oiQMJ$8@-$Xx11o28Ikw^>Qx#|L*kpe^_K!#(*(!Rt&gVQ5H+yQtGXvo0DGa rsSi{U$?FvR5)NW3X0Ej2Yibnwy$pyUVW&tB#eM`L4Q7miKV{$>z{N|_ literal 0 HcmV?d00001 diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 0000000..a13925f --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,20 @@ + ['all' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], + Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], + Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], + Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], + Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], + Symfony\UX\Turbo\TurboBundle::class => ['all' => true], + Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], + BabDev\PagerfantaBundle\BabDevPagerfantaBundle::class => ['all' => true], + Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true], + Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], +]; diff --git a/config/packages/asset_mapper.yaml b/config/packages/asset_mapper.yaml new file mode 100644 index 0000000..f7653e9 --- /dev/null +++ b/config/packages/asset_mapper.yaml @@ -0,0 +1,11 @@ +framework: + asset_mapper: + # The paths to make available to the asset mapper. + paths: + - assets/ + missing_import_mode: strict + +when@prod: + framework: + asset_mapper: + missing_import_mode: warn diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml new file mode 100644 index 0000000..77cea6b --- /dev/null +++ b/config/packages/cache.yaml @@ -0,0 +1,10 @@ +framework: + cache: + app: cache.adapter.redis + default_redis_provider: '%env(resolve:REDIS_URL)%' + + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.app \ No newline at end of file diff --git a/config/packages/csrf.yaml b/config/packages/csrf.yaml new file mode 100644 index 0000000..40d4040 --- /dev/null +++ b/config/packages/csrf.yaml @@ -0,0 +1,11 @@ +# Enable stateless CSRF protection for forms and logins/logouts +framework: + form: + csrf_protection: + token_id: submit + + csrf_protection: + stateless_token_ids: + - submit + - authenticate + - logout diff --git a/config/packages/debug.yaml b/config/packages/debug.yaml new file mode 100644 index 0000000..ad874af --- /dev/null +++ b/config/packages/debug.yaml @@ -0,0 +1,5 @@ +when@dev: + debug: + # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. + # See the "server:dump" command to start a new server. + dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" diff --git a/config/packages/dev/web_profiler.yaml b/config/packages/dev/web_profiler.yaml new file mode 100644 index 0000000..9031562 --- /dev/null +++ b/config/packages/dev/web_profiler.yaml @@ -0,0 +1,14 @@ +web_profiler: + toolbar: true + intercept_redirects: false + +framework: + profiler: + collect: true + only_exceptions: false + collect_serializer_data: true + + # Уберите опцию profiling, она больше не существует + http_client: + # Профилирование теперь включается автоматически в dev среде + # при наличии установленного web_profiler \ No newline at end of file diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 0000000..3328107 --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,92 @@ +doctrine: + dbal: + connections: + default: # PostgreSQL + schema_filter: ~^(?!cron)~ + url: '%env(resolve:DATABASE_URL)%' + logging: true + profiling: true + profiling_collect_backtrace: '%kernel.debug%' + use_savepoints: true + + mysql: # Bitrix MySQL + url: '%env(resolve:DATABASE_BITRIX_URL)%' + driver: pdo_mysql + logging: true + profiling: true + + cabinet: # Cabinet PostgreSQL + url: '%env(resolve:DATABASE_CABINET_URL)%' + logging: true + profiling: true + orm: + dql: + string_functions: + JSONB_CONTAINS: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Postgresql\JsonbContains + JSON_CONTAINS: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Postgresql\JsonContains + JSONB_EXISTS: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Postgresql\JsonbExists + JSONB_EXISTS_ANY: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Postgresql\JsonbExistsAny + JSONB_EXISTS_ALL: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Postgresql\JsonbExistsAll + auto_generate_proxy_classes: false + metadata_cache_driver: + type: pool + pool: doctrine.system_cache_pool + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + enable_lazy_ghost_objects: true + report_fields_where_declared: true + validate_xml_mapping: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + identity_generation_preferences: + Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity + auto_mapping: true + mappings: + App: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Entity' + prefix: 'App\Entity' + alias: App + controller_resolver: + auto_mapping: false + +when@dev: + doctrine: + orm: + # In dev, avoid Redis-backed metadata/query cache: stale ClassMetadata (e.g. removed fields) breaks warmup. + metadata_cache_driver: + type: pool + pool: cache.system + query_cache_driver: + type: pool + pool: cache.system + +when@test: + doctrine: + dbal: + # "TEST_TOKEN" is typically set by ParaTest + # dbname_suffix: '_test%env(default::TEST_TOKEN)%' + +when@prod: + doctrine: + orm: + auto_generate_proxy_classes: false + proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + + framework: + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.system diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml new file mode 100644 index 0000000..29231d9 --- /dev/null +++ b/config/packages/doctrine_migrations.yaml @@ -0,0 +1,6 @@ +doctrine_migrations: + migrations_paths: + # namespace is arbitrary but should be different from App\Migrations + # as migrations classes should NOT be autoloaded + 'DoctrineMigrations': '%kernel.project_dir%/migrations' + enable_profiler: false diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 0000000..d5cbe09 --- /dev/null +++ b/config/packages/framework.yaml @@ -0,0 +1,31 @@ +framework: + http_method_override: false + handle_all_throwables: true + secret: '%env(APP_SECRET)%' + + session: + handler_id: null + cookie_secure: auto + cookie_samesite: lax + storage_factory_id: session.storage.factory.native + + php_errors: + log: true + + http_client: + default_options: + max_duration: 30 + + #esi: false + #fragments: false + +when@dev: + framework: + php_errors: + throw: true + +when@test: + framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file \ No newline at end of file diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml new file mode 100644 index 0000000..bf5eef0 --- /dev/null +++ b/config/packages/lexik_jwt_authentication.yaml @@ -0,0 +1,5 @@ +lexik_jwt_authentication: + secret_key: '%env(resolve:JWT_SECRET_KEY)%' + public_key: '%env(resolve:JWT_PUBLIC_KEY)%' + pass_phrase: '%env(JWT_PASSPHRASE)%' + token_ttl: 31536000 diff --git a/config/packages/lock.yaml b/config/packages/lock.yaml new file mode 100644 index 0000000..574879f --- /dev/null +++ b/config/packages/lock.yaml @@ -0,0 +1,2 @@ +framework: + lock: '%env(LOCK_DSN)%' diff --git a/config/packages/mailer.yaml b/config/packages/mailer.yaml new file mode 100644 index 0000000..56a650d --- /dev/null +++ b/config/packages/mailer.yaml @@ -0,0 +1,3 @@ +framework: + mailer: + dsn: '%env(MAILER_DSN)%' diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml new file mode 100644 index 0000000..1199269 --- /dev/null +++ b/config/packages/messenger.yaml @@ -0,0 +1,24 @@ +framework: + messenger: + enabled: true + failure_transport: failed + transports: + sync: 'sync://' + failed: 'doctrine://default?queue_name=failed' + scheduler_default: + dsn: '%env(resolve:MESSENGER_TRANSPORT_DSN)%' + options: + queue_name: scheduler_default + routing: + Symfony\Component\Scheduler\Messenger\SchedulerTransport: scheduler_default + App\Message\GetScheduleMessage: sync + App\Message\GetSpecialistPictureMessage: sync + App\Message\GetAnonymousReserveRequestMessage: sync + +# when@test: +# framework: +# messenger: +# transports: +# # replace with your transport name here (e.g., my_transport: 'in-memory://') +# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test +# async: 'in-memory://' diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml new file mode 100644 index 0000000..9e57e6d --- /dev/null +++ b/config/packages/monolog.yaml @@ -0,0 +1,76 @@ +monolog: + channels: + - infoclinica + - deprecation + - bitrix + + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + channels: ["!event", "!http_client"] + http_client: + type: stream + path: "%kernel.logs_dir%/http_client.log" + level: debug + channels: ["http_client"] + messenger: + type: stream + path: "%kernel.logs_dir%/messenger.log" + level: debug + channels: ["messenger"] + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine", "!console"] + infoclinica: + type: rotating_file + path: "%kernel.logs_dir%/infoclinica-%kernel.environment%.log" + formatter: monolog.formatter.json + channels: ["infoclinica"] + bitrix: + type: rotating_file + path: "%kernel.logs_dir%/bitrix-%kernel.environment%.log" + formatter: monolog.formatter.json + channels: ["bitrix"] + +when@dev: + monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + channels: ["!event", "!doctrine"] + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine", "!console"] + infoclinica: + type: rotating_file + path: "%kernel.logs_dir%/infoclinica-%kernel.environment%.log" + formatter: monolog.formatter.json + channels: ["infoclinica"] +when@prod: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + path: "%kernel.logs_dir%/%kernel.environment%.log" + excluded_http_codes: [404, 405] + buffer_size: 50 + formatter: monolog.formatter.json + nested: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + console: + type: rotating_file + path: "%kernel.logs_dir%/console-%kernel.environment%.log" + max_files: 7 + level: debug + channels: ["!event", "!doctrine"] + formatter: monolog.formatter.json \ No newline at end of file diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml new file mode 100644 index 0000000..af132f8 --- /dev/null +++ b/config/packages/nelmio_api_doc.yaml @@ -0,0 +1,26 @@ +nelmio_api_doc: + documentation: + servers: + - url: https://api.sovamed.ru/ + description: Public API - sovamed + - url: https://api.wmtmed.ru/ + description: Public API - wmtmed + info: + title: Public API + description: Справочник методов доступных в Public API + version: 1.0.0 + areas: + path_patterns: [ + '^/filial/list$', + '^/department/list$', + '^/specialist/list$', + '^/specialist/schedule$', + '^/pricelist/list$', + '^/pricelist/department$', + '^/news($|/)', + '^/promo($|/)', + '^/disease($|/)', + '^/medical-center($|/)', + '^/article($|/)', + '^/site-services($|/)' + ] diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml new file mode 100644 index 0000000..923ccd5 --- /dev/null +++ b/config/packages/nelmio_cors.yaml @@ -0,0 +1,12 @@ +nelmio_cors: + defaults: + origin_regex: true + allow_credentials: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization'] + expose_headers: ['Link'] + max_age: 3600 + skip_same_as_origin: true + paths: + '^/': ~ diff --git a/config/packages/notifier.yaml b/config/packages/notifier.yaml new file mode 100644 index 0000000..d02f986 --- /dev/null +++ b/config/packages/notifier.yaml @@ -0,0 +1,12 @@ +framework: + notifier: + chatter_transports: + texter_transports: + channel_policy: + # use chat/slack, chat/telegram, sms/twilio or sms/nexmo + urgent: ['email'] + high: ['email'] + medium: ['email'] + low: ['email'] + admin_recipients: + - { email: admin@example.com } diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml new file mode 100644 index 0000000..dd31b9d --- /dev/null +++ b/config/packages/property_info.yaml @@ -0,0 +1,3 @@ +framework: + property_info: + with_constructor_extractor: true diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml new file mode 100644 index 0000000..7d0f316 --- /dev/null +++ b/config/packages/routing.yaml @@ -0,0 +1,11 @@ +framework: + router: + strict_requirements: false + # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. + # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands + #default_uri: http://localhost + +when@prod: + framework: + router: + strict_requirements: null diff --git a/config/packages/scheduler.yaml b/config/packages/scheduler.yaml new file mode 100644 index 0000000..8c47c0b --- /dev/null +++ b/config/packages/scheduler.yaml @@ -0,0 +1,3 @@ +framework: + scheduler: + enabled: false \ No newline at end of file diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 0000000..5b2139b --- /dev/null +++ b/config/packages/security.yaml @@ -0,0 +1,38 @@ +security: + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + + providers: + app_user_provider: + entity: + class: App\Entity\User + property: email + + firewalls: + api: + pattern: ^/ + stateless: true + provider: app_user_provider + jwt: ~ + + main: + lazy: true + provider: app_user_provider + login_throttling: + max_attempts: 3 + interval: '15 minutes' + logout: + path: user_logout + + access_control: + # - { path: ^/api/auth, roles: PUBLIC_ACCESS } + # - { path: ^/api, roles: ROLE_USER } + +when@test: + security: + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + cost: 4 + time_cost: 3 + memory_cost: 10 diff --git a/config/packages/serializer.yaml b/config/packages/serializer.yaml new file mode 100644 index 0000000..6a77adf --- /dev/null +++ b/config/packages/serializer.yaml @@ -0,0 +1,5 @@ +framework: + serializer: + enabled: true + default_context: + date_format: 'Y-m-d' \ No newline at end of file diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml new file mode 100644 index 0000000..c1dbc9a --- /dev/null +++ b/config/packages/translation.yaml @@ -0,0 +1,5 @@ +framework: + default_locale: ru + translator: + default_path: '%kernel.project_dir%/translations' + providers: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml new file mode 100644 index 0000000..3f795d9 --- /dev/null +++ b/config/packages/twig.yaml @@ -0,0 +1,6 @@ +twig: + file_name_pattern: '*.twig' + +when@test: + twig: + strict_variables: true diff --git a/config/packages/ux_turbo.yaml b/config/packages/ux_turbo.yaml new file mode 100644 index 0000000..11a65a4 --- /dev/null +++ b/config/packages/ux_turbo.yaml @@ -0,0 +1,4 @@ +# Enable stateless CSRF protection for forms and logins/logouts +framework: + csrf_protection: + check_header: true \ No newline at end of file diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml new file mode 100644 index 0000000..ab48876 --- /dev/null +++ b/config/packages/validator.yaml @@ -0,0 +1,12 @@ +framework: + validation: + email_validation_mode: html5 + # Enables validator auto-mapping support. + # For instance, basic validation constraints will be inferred from Doctrine's metadata. + #auto_mapping: + # App\Entity\: [] + +when@test: + framework: + validation: + not_compromised_password: false diff --git a/config/packages/web_profiler.yaml b/config/packages/web_profiler.yaml new file mode 100644 index 0000000..0eac3c9 --- /dev/null +++ b/config/packages/web_profiler.yaml @@ -0,0 +1,13 @@ +when@dev: + web_profiler: + toolbar: true + + framework: + profiler: + collect_serializer_data: true + +when@test: + framework: + profiler: + collect: false + collect_serializer_data: true diff --git a/config/preload.php b/config/preload.php new file mode 100644 index 0000000..5ebcdb2 --- /dev/null +++ b/config/preload.php @@ -0,0 +1,5 @@ + [ + * 'App\\' => [ + * 'resource' => '../src/', + * ], + * ], + * ]); + * ``` + * + * @psalm-type ImportsConfig = list + * @psalm-type ParametersConfig = array|Param|null>|Param|null> + * @psalm-type ArgumentsType = list|array + * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} + * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key + * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator|ExpressionConfigurator + * @psalm-type DeprecationType = array{package: string, version: string, message?: string} + * @psalm-type DefaultsType = array{ + * public?: bool, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * } + * @psalm-type InstanceofType = array{ + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type DefinitionType = array{ + * class?: string, + * file?: string, + * parent?: string, + * shared?: bool, + * synthetic?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * configurator?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * decorates?: string, + * decoration_inner_name?: string, + * decoration_priority?: int, + * decoration_on_invalid?: 'exception'|'ignore'|null, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * from_callable?: CallbackType, + * } + * @psalm-type AliasType = string|array{ + * alias: string, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type PrototypeType = array{ + * resource: string, + * namespace?: string, + * exclude?: string|list, + * parent?: string, + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type StackType = array{ + * stack: list>, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type ServicesConfig = array{ + * _defaults?: DefaultsType, + * _instanceof?: InstanceofType, + * ... + * } + * @psalm-type ExtensionType = array + * @psalm-type FrameworkConfig = array{ + * secret?: scalar|Param|null, + * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false + * allowed_http_method_override?: null|list, + * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" + * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" + * test?: bool|Param, + * default_locale?: scalar|Param|null, // Default: "en" + * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false + * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false + * enabled_locales?: list, + * trusted_hosts?: string|list, + * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] + * trusted_headers?: string|list, + * error_controller?: scalar|Param|null, // Default: "error_controller" + * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * stateless_token_ids?: list, + * check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false + * cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" + * }, + * form?: bool|array{ // Form configuration + * enabled?: bool|Param, // Default: true + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * token_id?: scalar|Param|null, // Default: null + * field_name?: scalar|Param|null, // Default: "_token" + * field_attr?: array, + * }, + * }, + * http_cache?: bool|array{ // HTTP cache configuration + * enabled?: bool|Param, // Default: false + * debug?: bool|Param, // Default: "%kernel.debug%" + * trace_level?: "none"|"short"|"full"|Param, + * trace_header?: scalar|Param|null, + * default_ttl?: int|Param, + * private_headers?: list, + * skip_response_headers?: list, + * allow_reload?: bool|Param, + * allow_revalidate?: bool|Param, + * stale_while_revalidate?: int|Param, + * stale_if_error?: int|Param, + * terminate_on_cache_hit?: bool|Param, + * }, + * esi?: bool|array{ // ESI configuration + * enabled?: bool|Param, // Default: false + * }, + * ssi?: bool|array{ // SSI configuration + * enabled?: bool|Param, // Default: false + * }, + * fragments?: bool|array{ // Fragments configuration + * enabled?: bool|Param, // Default: false + * hinclude_default_template?: scalar|Param|null, // Default: null + * path?: scalar|Param|null, // Default: "/_fragment" + * }, + * profiler?: bool|array{ // Profiler configuration + * enabled?: bool|Param, // Default: false + * collect?: bool|Param, // Default: true + * collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null + * only_exceptions?: bool|Param, // Default: false + * only_main_requests?: bool|Param, // Default: false + * dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler" + * collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false + * }, + * workflows?: bool|array{ + * enabled?: bool|Param, // Default: false + * workflows?: array, + * definition_validators?: list, + * support_strategy?: scalar|Param|null, + * initial_marking?: \BackedEnum|string|list, + * events_to_dispatch?: null|list, + * places?: string|list, + * }>, + * transitions?: list, + * to?: \BackedEnum|string|list, + * weight?: int|Param, // Default: 1 + * metadata?: array, + * }>, + * metadata?: array, + * }>, + * }, + * router?: bool|array{ // Router configuration + * enabled?: bool|Param, // Default: false + * resource?: scalar|Param|null, + * type?: scalar|Param|null, + * cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%" + * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null + * http_port?: scalar|Param|null, // Default: 80 + * https_port?: scalar|Param|null, // Default: 443 + * strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true + * utf8?: bool|Param, // Default: true + * }, + * session?: bool|array{ // Session configuration + * enabled?: bool|Param, // Default: false + * storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native" + * handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. + * name?: scalar|Param|null, + * cookie_lifetime?: scalar|Param|null, + * cookie_path?: scalar|Param|null, + * cookie_domain?: scalar|Param|null, + * cookie_secure?: true|false|"auto"|Param, // Default: "auto" + * cookie_httponly?: bool|Param, // Default: true + * cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" + * use_cookies?: bool|Param, + * gc_divisor?: scalar|Param|null, + * gc_probability?: scalar|Param|null, + * gc_maxlifetime?: scalar|Param|null, + * save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. + * metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0 + * sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. + * sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. + * }, + * request?: bool|array{ // Request configuration + * enabled?: bool|Param, // Default: false + * formats?: array>, + * }, + * assets?: bool|array{ // Assets configuration + * enabled?: bool|Param, // Default: true + * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, // Default: null + * version_format?: scalar|Param|null, // Default: "%%s?%%s" + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: string|list, + * packages?: array, + * }>, + * }, + * asset_mapper?: bool|array{ // Asset Mapper configuration + * enabled?: bool|Param, // Default: true + * paths?: string|array, + * excluded_patterns?: list, + * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true + * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true + * public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" + * missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" + * extensions?: array, + * importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" + * importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" + * importmap_script_attributes?: array, + * vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" + * precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip. + * enabled?: bool|Param, // Default: false + * formats?: list, + * extensions?: list, + * }, + * }, + * translator?: bool|array{ // Translator configuration + * enabled?: bool|Param, // Default: true + * fallbacks?: string|list, + * logging?: bool|Param, // Default: false + * formatter?: scalar|Param|null, // Default: "translator.formatter.default" + * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" + * default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" + * paths?: list, + * pseudo_localization?: bool|array{ + * enabled?: bool|Param, // Default: false + * accents?: bool|Param, // Default: true + * expansion_factor?: float|Param, // Default: 1.0 + * brackets?: bool|Param, // Default: true + * parse_html?: bool|Param, // Default: false + * localizable_html_attributes?: list, + * }, + * providers?: array, + * locales?: list, + * }>, + * globals?: array, + * domain?: string|Param, + * }>, + * }, + * validation?: bool|array{ // Validation configuration + * enabled?: bool|Param, // Default: true + * cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. + * enable_attributes?: bool|Param, // Default: true + * static_method?: string|list, + * translation_domain?: scalar|Param|null, // Default: "validators" + * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5" + * mapping?: array{ + * paths?: list, + * }, + * not_compromised_password?: bool|array{ + * enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true + * endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null + * }, + * disable_translation?: bool|Param, // Default: false + * auto_mapping?: array, + * }>, + * }, + * annotations?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * serializer?: bool|array{ // Serializer configuration + * enabled?: bool|Param, // Default: true + * enable_attributes?: bool|Param, // Default: true + * name_converter?: scalar|Param|null, + * circular_reference_handler?: scalar|Param|null, + * max_depth_handler?: scalar|Param|null, + * mapping?: array{ + * paths?: list, + * }, + * default_context?: array, + * named_serializers?: array, + * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true + * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true + * }>, + * }, + * property_access?: bool|array{ // Property access configuration + * enabled?: bool|Param, // Default: true + * magic_call?: bool|Param, // Default: false + * magic_get?: bool|Param, // Default: true + * magic_set?: bool|Param, // Default: true + * throw_exception_on_invalid_index?: bool|Param, // Default: false + * throw_exception_on_invalid_property_path?: bool|Param, // Default: true + * }, + * type_info?: bool|array{ // Type info configuration + * enabled?: bool|Param, // Default: true + * aliases?: array, + * }, + * property_info?: bool|array{ // Property info configuration + * enabled?: bool|Param, // Default: true + * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. + * }, + * cache?: array{ // Cache configuration + * prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" + * app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" + * system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system" + * directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app" + * default_psr6_provider?: scalar|Param|null, + * default_redis_provider?: scalar|Param|null, // Default: "redis://localhost" + * default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost" + * default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost" + * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" + * default_pdo_provider?: scalar|Param|null, // Default: null + * pools?: array, + * tags?: scalar|Param|null, // Default: null + * public?: bool|Param, // Default: false + * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. + * provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter. + * early_expiration_message_bus?: scalar|Param|null, + * clearer?: scalar|Param|null, + * }>, + * }, + * php_errors?: array{ // PHP errors handling configuration + * log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true + * throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true + * }, + * exceptions?: array, + * web_link?: bool|array{ // Web links configuration + * enabled?: bool|Param, // Default: true + * }, + * lock?: bool|string|array{ // Lock configuration + * enabled?: bool|Param, // Default: true + * resources?: string|array>, + * }, + * semaphore?: bool|string|array{ // Semaphore configuration + * enabled?: bool|Param, // Default: false + * resources?: string|array, + * }, + * messenger?: bool|array{ // Messenger configuration + * enabled?: bool|Param, // Default: true + * routing?: array, + * }>, + * serializer?: array{ + * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" + * symfony_serializer?: array{ + * format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" + * context?: array, + * }, + * }, + * transports?: array, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * retry_strategy?: string|array{ + * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 + * }, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null + * }>, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * stop_worker_on_signals?: int|string|list, + * default_bus?: scalar|Param|null, // Default: null + * buses?: array, + * }>, + * }>, + * }, + * scheduler?: bool|array{ // Scheduler configuration + * enabled?: bool|Param, // Default: true + * }, + * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true + * http_client?: bool|array{ // HTTP Client configuration + * enabled?: bool|Param, // Default: true + * max_host_connections?: int|Param, // The maximum number of connections to a single host. + * default_options?: array{ + * headers?: array, + * vars?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: int|string|array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }, + * mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. + * scoped_clients?: array, + * headers?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: int|string|array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }>, + * }, + * mailer?: bool|array{ // Mailer configuration + * enabled?: bool|Param, // Default: true + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * dsn?: scalar|Param|null, // Default: null + * transports?: array, + * envelope?: array{ // Mailer Envelope configuration + * sender?: scalar|Param|null, + * recipients?: string|list, + * allowed_recipients?: string|list, + * }, + * headers?: array, + * dkim_signer?: bool|array{ // DKIM signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" + * domain?: scalar|Param|null, // Default: "" + * select?: scalar|Param|null, // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: "" + * options?: array, + * }, + * smime_signer?: bool|array{ // S/MIME signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Path to key (in PEM format) // Default: "" + * certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: null + * extra_certificates?: scalar|Param|null, // Default: null + * sign_options?: int|Param, // Default: null + * }, + * smime_encrypter?: bool|array{ // S/MIME encrypter configuration + * enabled?: bool|Param, // Default: false + * repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" + * cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null + * }, + * }, + * secrets?: bool|array{ + * enabled?: bool|Param, // Default: true + * vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" + * local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local" + * decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" + * }, + * notifier?: bool|array{ // Notifier configuration + * enabled?: bool|Param, // Default: true + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * chatter_transports?: array, + * texter_transports?: array, + * notification_on_failed_messages?: bool|Param, // Default: false + * channel_policy?: array>, + * admin_recipients?: list, + * }, + * rate_limiter?: bool|array{ // Rate limiter configuration + * enabled?: bool|Param, // Default: true + * limiters?: array, + * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. + * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". + * interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 + * }, + * }>, + * }, + * uid?: bool|array{ // Uid configuration + * enabled?: bool|Param, // Default: false + * default_uuid_version?: 7|6|4|1|Param, // Default: 7 + * name_based_uuid_version?: 5|3|Param, // Default: 5 + * name_based_uuid_namespace?: scalar|Param|null, + * time_based_uuid_version?: 7|6|1|Param, // Default: 7 + * time_based_uuid_node?: scalar|Param|null, + * }, + * html_sanitizer?: bool|array{ // HtmlSanitizer configuration + * enabled?: bool|Param, // Default: false + * sanitizers?: array, + * block_elements?: string|list, + * drop_elements?: string|list, + * allow_attributes?: array, + * drop_attributes?: array, + * force_attributes?: array>, + * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false + * allowed_link_schemes?: string|list, + * allowed_link_hosts?: null|string|list, + * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false + * allowed_media_schemes?: string|list, + * allowed_media_hosts?: null|string|list, + * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false + * with_attribute_sanitizers?: string|list, + * without_attribute_sanitizers?: string|list, + * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 + * }>, + * }, + * webhook?: bool|array{ // Webhook configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" + * routing?: array, + * }, + * remote-event?: bool|array{ // RemoteEvent configuration + * enabled?: bool|Param, // Default: false + * }, + * json_streamer?: bool|array{ // JSON streamer configuration + * enabled?: bool|Param, // Default: false + * }, + * } + * @psalm-type DoctrineConfig = array{ + * dbal?: array{ + * default_connection?: scalar|Param|null, + * types?: array, + * driver_schemes?: array, + * connections?: array, + * mapping_types?: array, + * default_table_options?: array, + * schema_manager_factory?: scalar|Param|null, // Default: "doctrine.dbal.default_schema_manager_factory" + * result_cache?: scalar|Param|null, + * slaves?: array, + * replicas?: array, + * }>, + * }, + * orm?: array{ + * default_entity_manager?: scalar|Param|null, + * auto_generate_proxy_classes?: scalar|Param|null, // Auto generate mode possible values are: "NEVER", "ALWAYS", "FILE_NOT_EXISTS", "EVAL", "FILE_NOT_EXISTS_OR_CHANGED", this option is ignored when the "enable_native_lazy_objects" option is true // Default: false + * enable_lazy_ghost_objects?: bool|Param, // Enables the new implementation of proxies based on lazy ghosts instead of using the legacy implementation // Default: true + * enable_native_lazy_objects?: bool|Param, // Enables the new native implementation of PHP lazy objects instead of generated proxies // Default: false + * proxy_dir?: scalar|Param|null, // Configures the path where generated proxy classes are saved when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "%kernel.build_dir%/doctrine/orm/Proxies" + * proxy_namespace?: scalar|Param|null, // Defines the root namespace for generated proxy classes when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "Proxies" + * controller_resolver?: bool|array{ + * enabled?: bool|Param, // Default: true + * auto_mapping?: bool|Param|null, // Set to false to disable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: null + * evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false + * }, + * entity_managers?: array, + * }>, + * }>, + * }, + * connection?: scalar|Param|null, + * class_metadata_factory_name?: scalar|Param|null, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory" + * default_repository_class?: scalar|Param|null, // Default: "Doctrine\\ORM\\EntityRepository" + * auto_mapping?: scalar|Param|null, // Default: false + * naming_strategy?: scalar|Param|null, // Default: "doctrine.orm.naming_strategy.default" + * quote_strategy?: scalar|Param|null, // Default: "doctrine.orm.quote_strategy.default" + * typed_field_mapper?: scalar|Param|null, // Default: "doctrine.orm.typed_field_mapper.default" + * entity_listener_resolver?: scalar|Param|null, // Default: null + * fetch_mode_subselect_batch_size?: scalar|Param|null, + * repository_factory?: scalar|Param|null, // Default: "doctrine.orm.container_repository_factory" + * schema_ignore_classes?: list, + * report_fields_where_declared?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.16 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/10455. // Default: true + * validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14. See https://github.com/doctrine/orm/pull/6728. // Default: false + * second_level_cache?: array{ + * region_cache_driver?: string|array{ + * type?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, + * pool?: scalar|Param|null, + * }, + * region_lock_lifetime?: scalar|Param|null, // Default: 60 + * log_enabled?: bool|Param, // Default: true + * region_lifetime?: scalar|Param|null, // Default: 3600 + * enabled?: bool|Param, // Default: true + * factory?: scalar|Param|null, + * regions?: array, + * loggers?: array, + * }, + * hydrators?: array, + * mappings?: array, + * dql?: array{ + * string_functions?: array, + * numeric_functions?: array, + * datetime_functions?: array, + * }, + * filters?: array, + * }>, + * identity_generation_preferences?: array, + * }>, + * resolve_target_entities?: array, + * }, + * } + * @psalm-type DoctrineMigrationsConfig = array{ + * enable_service_migrations?: bool|Param, // Whether to enable fetching migrations from the service container. // Default: false + * migrations_paths?: array, + * services?: array, + * factories?: array, + * storage?: array{ // Storage to use for migration status metadata. + * table_storage?: array{ // The default metadata storage, implemented as a table in the database. + * table_name?: scalar|Param|null, // Default: null + * version_column_name?: scalar|Param|null, // Default: null + * version_column_length?: scalar|Param|null, // Default: null + * executed_at_column_name?: scalar|Param|null, // Default: null + * execution_time_column_name?: scalar|Param|null, // Default: null + * }, + * }, + * migrations?: list, + * connection?: scalar|Param|null, // Connection name to use for the migrations database. // Default: null + * em?: scalar|Param|null, // Entity manager name to use for the migrations database (available when doctrine/orm is installed). // Default: null + * all_or_nothing?: scalar|Param|null, // Run all migrations in a transaction. // Default: false + * check_database_platform?: scalar|Param|null, // Adds an extra check in the generated migrations to allow execution only on the same platform as they were initially generated on. // Default: true + * custom_template?: scalar|Param|null, // Custom template path for generated migration classes. // Default: null + * organize_migrations?: scalar|Param|null, // Organize migrations mode. Possible values are: "BY_YEAR", "BY_YEAR_AND_MONTH", false // Default: false + * enable_profiler?: bool|Param, // Whether or not to enable the profiler collector to calculate and visualize migration status. This adds some queries overhead. // Default: false + * transactional?: bool|Param, // Whether or not to wrap migrations in a single transaction. // Default: true + * } + * @psalm-type MakerConfig = array{ + * root_namespace?: scalar|Param|null, // Default: "App" + * generate_final_classes?: bool|Param, // Default: true + * generate_final_entities?: bool|Param, // Default: false + * } + * @psalm-type SecurityConfig = array{ + * access_denied_url?: scalar|Param|null, // Default: null + * session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate" + * hide_user_not_found?: bool|Param, // Deprecated: The "hide_user_not_found" option is deprecated and will be removed in 8.0. Use the "expose_security_errors" option instead. + * expose_security_errors?: \Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::None|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::AccountStatus|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::All|Param, // Default: "none" + * erase_credentials?: bool|Param, // Default: true + * access_decision_manager?: array{ + * strategy?: "affirmative"|"consensus"|"unanimous"|"priority"|Param, + * service?: scalar|Param|null, + * strategy_service?: scalar|Param|null, + * allow_if_all_abstain?: bool|Param, // Default: false + * allow_if_equal_granted_denied?: bool|Param, // Default: true + * }, + * password_hashers?: array, + * hash_algorithm?: scalar|Param|null, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" + * key_length?: scalar|Param|null, // Default: 40 + * ignore_case?: bool|Param, // Default: false + * encode_as_base64?: bool|Param, // Default: true + * iterations?: scalar|Param|null, // Default: 5000 + * cost?: int|Param, // Default: null + * memory_cost?: scalar|Param|null, // Default: null + * time_cost?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, + * }>, + * providers?: array, + * }, + * entity?: array{ + * class?: scalar|Param|null, // The full entity class name of your user class. + * property?: scalar|Param|null, // Default: null + * manager_name?: scalar|Param|null, // Default: null + * }, + * memory?: array{ + * users?: array, + * }>, + * }, + * ldap?: array{ + * service?: scalar|Param|null, + * base_dn?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: null + * search_password?: scalar|Param|null, // Default: null + * extra_fields?: list, + * default_roles?: string|list, + * role_fetcher?: scalar|Param|null, // Default: null + * uid_key?: scalar|Param|null, // Default: "sAMAccountName" + * filter?: scalar|Param|null, // Default: "({uid_key}={user_identifier})" + * password_attribute?: scalar|Param|null, // Default: null + * }, + * lexik_jwt?: array{ + * class?: scalar|Param|null, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser" + * }, + * }>, + * firewalls?: array, + * security?: bool|Param, // Default: true + * user_checker?: scalar|Param|null, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" + * request_matcher?: scalar|Param|null, + * access_denied_url?: scalar|Param|null, + * access_denied_handler?: scalar|Param|null, + * entry_point?: scalar|Param|null, // An enabled authenticator name or a service id that implements "Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface". + * provider?: scalar|Param|null, + * stateless?: bool|Param, // Default: false + * lazy?: bool|Param, // Default: false + * context?: scalar|Param|null, + * logout?: array{ + * enable_csrf?: bool|Param|null, // Default: null + * csrf_token_id?: scalar|Param|null, // Default: "logout" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_manager?: scalar|Param|null, + * path?: scalar|Param|null, // Default: "/logout" + * target?: scalar|Param|null, // Default: "/" + * invalidate_session?: bool|Param, // Default: true + * clear_site_data?: string|list<"*"|"cache"|"cookies"|"storage"|"executionContexts"|Param>, + * delete_cookies?: string|array, + * }, + * switch_user?: array{ + * provider?: scalar|Param|null, + * parameter?: scalar|Param|null, // Default: "_switch_user" + * role?: scalar|Param|null, // Default: "ROLE_ALLOWED_TO_SWITCH" + * target_route?: scalar|Param|null, // Default: null + * }, + * required_badges?: list, + * custom_authenticators?: list, + * login_throttling?: array{ + * limiter?: scalar|Param|null, // A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface". + * max_attempts?: int|Param, // Default: 5 + * interval?: scalar|Param|null, // Default: "1 minute" + * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null + * cache_pool?: string|Param, // The cache pool to use for storing the limiter state // Default: "cache.rate_limiter" + * storage_service?: string|Param, // The service ID of a custom storage implementation, this precedes any configured "cache_pool" // Default: null + * }, + * x509?: array{ + * provider?: scalar|Param|null, + * user?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN_Email" + * credentials?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN" + * user_identifier?: scalar|Param|null, // Default: "emailAddress" + * }, + * remote_user?: array{ + * provider?: scalar|Param|null, + * user?: scalar|Param|null, // Default: "REMOTE_USER" + * }, + * jwt?: array{ + * provider?: scalar|Param|null, // Default: null + * authenticator?: scalar|Param|null, // Default: "lexik_jwt_authentication.security.jwt_authenticator" + * }, + * login_link?: array{ + * check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". + * check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false + * signature_properties?: list, + * lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600 + * max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null + * used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set. + * success_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface. + * failure_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface. + * provider?: scalar|Param|null, // The user provider to load users from. + * secret?: scalar|Param|null, // Default: "%kernel.secret%" + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * login_path?: scalar|Param|null, // Default: "/login" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * }, + * form_login?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_parameter?: scalar|Param|null, // Default: "_username" + * password_parameter?: scalar|Param|null, // Default: "_password" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "authenticate" + * enable_csrf?: bool|Param, // Default: false + * post_only?: bool|Param, // Default: true + * form_only?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * }, + * form_login_ldap?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_parameter?: scalar|Param|null, // Default: "_username" + * password_parameter?: scalar|Param|null, // Default: "_password" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "authenticate" + * enable_csrf?: bool|Param, // Default: false + * post_only?: bool|Param, // Default: true + * form_only?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * json_login?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_path?: scalar|Param|null, // Default: "username" + * password_path?: scalar|Param|null, // Default: "password" + * }, + * json_login_ldap?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_path?: scalar|Param|null, // Default: "username" + * password_path?: scalar|Param|null, // Default: "password" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * access_token?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: null + * token_extractors?: string|list, + * token_handler?: string|array{ + * id?: scalar|Param|null, + * oidc_user_info?: string|array{ + * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). + * discovery?: array{ // Enable the OIDC discovery. + * cache?: array{ + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * }, + * }, + * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" + * client?: scalar|Param|null, // HttpClient service id to use to call the OIDC server. + * }, + * oidc?: array{ + * discovery?: array{ // Enable the OIDC discovery. + * base_uri?: string|list, + * cache?: array{ + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * }, + * }, + * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" + * audience?: scalar|Param|null, // Audience set in the token, for validation purpose. + * issuers?: list, + * algorithm?: array, + * algorithms?: list, + * key?: scalar|Param|null, // Deprecated: The "key" option is deprecated and will be removed in 8.0. Use the "keyset" option instead. // JSON-encoded JWK used to sign the token (must contain a "kty" key). + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false + * algorithms?: list, + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). + * }, + * }, + * cas?: array{ + * validation_url?: scalar|Param|null, // CAS server validation URL + * prefix?: scalar|Param|null, // CAS prefix // Default: "cas" + * http_client?: scalar|Param|null, // HTTP Client service // Default: null + * }, + * oauth2?: scalar|Param|null, + * }, + * }, + * http_basic?: array{ + * provider?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: "Secured Area" + * }, + * http_basic_ldap?: array{ + * provider?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: "Secured Area" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * remember_me?: array{ + * secret?: scalar|Param|null, // Default: "%kernel.secret%" + * service?: scalar|Param|null, + * user_providers?: string|list, + * catch_exceptions?: bool|Param, // Default: true + * signature_properties?: list, + * token_provider?: string|array{ + * service?: scalar|Param|null, // The service ID of a custom remember-me token provider. + * doctrine?: bool|array{ + * enabled?: bool|Param, // Default: false + * connection?: scalar|Param|null, // Default: null + * }, + * }, + * token_verifier?: scalar|Param|null, // The service ID of a custom rememberme token verifier. + * name?: scalar|Param|null, // Default: "REMEMBERME" + * lifetime?: int|Param, // Default: 31536000 + * path?: scalar|Param|null, // Default: "/" + * domain?: scalar|Param|null, // Default: null + * secure?: true|false|"auto"|Param, // Default: null + * httponly?: bool|Param, // Default: true + * samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" + * always_remember_me?: bool|Param, // Default: false + * remember_me_parameter?: scalar|Param|null, // Default: "_remember_me" + * }, + * }>, + * access_control?: list, + * attributes?: array, + * route?: scalar|Param|null, // Default: null + * methods?: string|list, + * allow_if?: scalar|Param|null, // Default: null + * roles?: string|list, + * }>, + * role_hierarchy?: array>, + * } + * @psalm-type LexikJwtAuthenticationConfig = array{ + * public_key?: scalar|Param|null, // The key used to sign tokens (useless for HMAC). If not set, the key will be automatically computed from the secret key. // Default: null + * additional_public_keys?: list, + * secret_key?: scalar|Param|null, // The key used to sign tokens. It can be a raw secret (for HMAC), a raw RSA/ECDSA key or the path to a file itself being plaintext or PEM. // Default: null + * pass_phrase?: scalar|Param|null, // The key passphrase (useless for HMAC) // Default: "" + * token_ttl?: scalar|Param|null, // Default: 3600 + * allow_no_expiration?: bool|Param, // Allow tokens without "exp" claim (i.e. indefinitely valid, no lifetime) to be considered valid. Caution: usage of this should be rare. // Default: false + * clock_skew?: scalar|Param|null, // Default: 0 + * encoder?: array{ + * service?: scalar|Param|null, // Default: "lexik_jwt_authentication.encoder.lcobucci" + * signature_algorithm?: scalar|Param|null, // Default: "RS256" + * }, + * user_id_claim?: scalar|Param|null, // Default: "username" + * token_extractors?: array{ + * authorization_header?: bool|array{ + * enabled?: bool|Param, // Default: true + * prefix?: scalar|Param|null, // Default: "Bearer" + * name?: scalar|Param|null, // Default: "Authorization" + * }, + * cookie?: bool|array{ + * enabled?: bool|Param, // Default: false + * name?: scalar|Param|null, // Default: "BEARER" + * }, + * query_parameter?: bool|array{ + * enabled?: bool|Param, // Default: false + * name?: scalar|Param|null, // Default: "bearer" + * }, + * split_cookie?: bool|array{ + * enabled?: bool|Param, // Default: false + * cookies?: list, + * }, + * }, + * remove_token_from_body_when_cookies_used?: scalar|Param|null, // Default: true + * set_cookies?: array, + * }>, + * api_platform?: bool|array{ // API Platform compatibility: add check_path in OpenAPI documentation. + * enabled?: bool|Param, // Default: false + * check_path?: scalar|Param|null, // The login check path to add in OpenAPI. // Default: null + * username_path?: scalar|Param|null, // The path to the username in the JSON body. // Default: null + * password_path?: scalar|Param|null, // The path to the password in the JSON body. // Default: null + * }, + * access_token_issuance?: bool|array{ + * enabled?: bool|Param, // Default: false + * signature?: array{ + * algorithm?: scalar|Param|null, // The algorithm use to sign the access tokens. + * key?: scalar|Param|null, // The signature key. It shall be JWK encoded. + * }, + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * key_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token. + * content_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token. + * key?: scalar|Param|null, // The encryption key. It shall be JWK encoded. + * }, + * }, + * access_token_verification?: bool|array{ + * enabled?: bool|Param, // Default: false + * signature?: array{ + * header_checkers?: list, + * claim_checkers?: list, + * mandatory_claims?: list, + * allowed_algorithms?: list, + * keyset?: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded. + * }, + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * continue_on_decryption_failure?: bool|Param, // If enable, non-encrypted tokens or tokens that failed during decryption or verification processes are accepted. // Default: false + * header_checkers?: list, + * allowed_key_encryption_algorithms?: list, + * allowed_content_encryption_algorithms?: list, + * keyset?: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded. + * }, + * }, + * blocklist_token?: bool|array{ + * enabled?: bool|Param, // Default: false + * cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app" + * }, + * } + * @psalm-type MonologConfig = array{ + * use_microseconds?: scalar|Param|null, // Default: true + * channels?: list, + * handlers?: array, + * excluded_http_codes?: list, + * }>, + * accepted_levels?: list, + * min_level?: scalar|Param|null, // Default: "DEBUG" + * max_level?: scalar|Param|null, // Default: "EMERGENCY" + * buffer_size?: scalar|Param|null, // Default: 0 + * flush_on_overflow?: bool|Param, // Default: false + * handler?: scalar|Param|null, + * url?: scalar|Param|null, + * exchange?: scalar|Param|null, + * exchange_name?: scalar|Param|null, // Default: "log" + * room?: scalar|Param|null, + * message_format?: scalar|Param|null, // Default: "text" + * api_version?: scalar|Param|null, // Default: null + * channel?: scalar|Param|null, // Default: null + * bot_name?: scalar|Param|null, // Default: "Monolog" + * use_attachment?: scalar|Param|null, // Default: true + * use_short_attachment?: scalar|Param|null, // Default: false + * include_extra?: scalar|Param|null, // Default: false + * icon_emoji?: scalar|Param|null, // Default: null + * webhook_url?: scalar|Param|null, + * exclude_fields?: list, + * team?: scalar|Param|null, + * notify?: scalar|Param|null, // Default: false + * nickname?: scalar|Param|null, // Default: "Monolog" + * token?: scalar|Param|null, + * region?: scalar|Param|null, + * source?: scalar|Param|null, + * use_ssl?: bool|Param, // Default: true + * user?: mixed, + * title?: scalar|Param|null, // Default: null + * host?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 514 + * config?: list, + * members?: list, + * connection_string?: scalar|Param|null, + * timeout?: scalar|Param|null, + * time?: scalar|Param|null, // Default: 60 + * deduplication_level?: scalar|Param|null, // Default: 400 + * store?: scalar|Param|null, // Default: null + * connection_timeout?: scalar|Param|null, + * persistent?: bool|Param, + * dsn?: scalar|Param|null, + * hub_id?: scalar|Param|null, // Default: null + * client_id?: scalar|Param|null, // Default: null + * auto_log_stacks?: scalar|Param|null, // Default: false + * release?: scalar|Param|null, // Default: null + * environment?: scalar|Param|null, // Default: null + * message_type?: scalar|Param|null, // Default: 0 + * parse_mode?: scalar|Param|null, // Default: null + * disable_webpage_preview?: bool|Param|null, // Default: null + * disable_notification?: bool|Param|null, // Default: null + * split_long_messages?: bool|Param, // Default: false + * delay_between_messages?: bool|Param, // Default: false + * topic?: int|Param, // Default: null + * factor?: int|Param, // Default: 1 + * tags?: string|list, + * console_formater_options?: mixed, // Deprecated: "monolog.handlers..console_formater_options.console_formater_options" is deprecated, use "monolog.handlers..console_formater_options.console_formatter_options" instead. + * console_formatter_options?: mixed, // Default: [] + * formatter?: scalar|Param|null, + * nested?: bool|Param, // Default: false + * publisher?: string|array{ + * id?: scalar|Param|null, + * hostname?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 12201 + * chunk_size?: scalar|Param|null, // Default: 1420 + * encoder?: "json"|"compressed_json"|Param, + * }, + * mongo?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 27017 + * user?: scalar|Param|null, + * pass?: scalar|Param|null, + * database?: scalar|Param|null, // Default: "monolog" + * collection?: scalar|Param|null, // Default: "logs" + * }, + * mongodb?: string|array{ + * id?: scalar|Param|null, // ID of a MongoDB\Client service + * uri?: scalar|Param|null, + * username?: scalar|Param|null, + * password?: scalar|Param|null, + * database?: scalar|Param|null, // Default: "monolog" + * collection?: scalar|Param|null, // Default: "logs" + * }, + * elasticsearch?: string|array{ + * id?: scalar|Param|null, + * hosts?: list, + * host?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 9200 + * transport?: scalar|Param|null, // Default: "Http" + * user?: scalar|Param|null, // Default: null + * password?: scalar|Param|null, // Default: null + * }, + * index?: scalar|Param|null, // Default: "monolog" + * document_type?: scalar|Param|null, // Default: "logs" + * ignore_error?: scalar|Param|null, // Default: false + * redis?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * password?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 6379 + * database?: scalar|Param|null, // Default: 0 + * key_name?: scalar|Param|null, // Default: "monolog_redis" + * }, + * predis?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * }, + * from_email?: scalar|Param|null, + * to_email?: string|list, + * subject?: scalar|Param|null, + * content_type?: scalar|Param|null, // Default: null + * headers?: list, + * mailer?: scalar|Param|null, // Default: null + * email_prototype?: string|array{ + * id?: scalar|Param|null, + * method?: scalar|Param|null, // Default: null + * }, + * lazy?: bool|Param, // Default: true + * verbosity_levels?: array{ + * VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR" + * VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING" + * VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE" + * VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO" + * VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG" + * }, + * channels?: string|array{ + * type?: scalar|Param|null, + * elements?: list, + * }, + * }>, + * } + * @psalm-type NelmioCorsConfig = array{ + * defaults?: array{ + * allow_credentials?: bool|Param, // Default: false + * allow_origin?: list, + * allow_headers?: list, + * allow_methods?: list, + * allow_private_network?: bool|Param, // Default: false + * expose_headers?: list, + * max_age?: scalar|Param|null, // Default: 0 + * hosts?: list, + * origin_regex?: bool|Param, // Default: false + * forced_allow_origin_value?: scalar|Param|null, // Default: null + * skip_same_as_origin?: bool|Param, // Default: true + * }, + * paths?: array, + * allow_headers?: list, + * allow_methods?: list, + * allow_private_network?: bool|Param, + * expose_headers?: list, + * max_age?: scalar|Param|null, // Default: 0 + * hosts?: list, + * origin_regex?: bool|Param, + * forced_allow_origin_value?: scalar|Param|null, // Default: null + * skip_same_as_origin?: bool|Param, + * }>, + * } + * @psalm-type DebugConfig = array{ + * max_items?: int|Param, // Max number of displayed items past the first level, -1 means no limit. // Default: 2500 + * min_depth?: int|Param, // Minimum tree depth to clone all the items, 1 is default. // Default: 1 + * max_string_length?: int|Param, // Max length of displayed strings, -1 means no limit. // Default: -1 + * dump_destination?: scalar|Param|null, // A stream URL where dumps should be written to. // Default: null + * theme?: "dark"|"light"|Param, // Changes the color of the dump() output when rendered directly on the templating. "dark" (default) or "light". // Default: "dark" + * } + * @psalm-type TwigConfig = array{ + * form_themes?: list, + * globals?: array, + * autoescape_service?: scalar|Param|null, // Default: null + * autoescape_service_method?: scalar|Param|null, // Default: null + * base_template_class?: scalar|Param|null, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated. + * cache?: scalar|Param|null, // Default: true + * charset?: scalar|Param|null, // Default: "%kernel.charset%" + * debug?: bool|Param, // Default: "%kernel.debug%" + * strict_variables?: bool|Param, // Default: "%kernel.debug%" + * auto_reload?: scalar|Param|null, + * optimizations?: int|Param, + * default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" + * file_name_pattern?: string|list, + * paths?: array, + * date?: array{ // The default format options used by the date filter. + * format?: scalar|Param|null, // Default: "F j, Y H:i" + * interval_format?: scalar|Param|null, // Default: "%d days" + * timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null + * }, + * number_format?: array{ // The default format options for the number_format filter. + * decimals?: int|Param, // Default: 0 + * decimal_point?: scalar|Param|null, // Default: "." + * thousands_separator?: scalar|Param|null, // Default: "," + * }, + * mailer?: array{ + * html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null + * }, + * } + * @psalm-type StimulusConfig = array{ + * controller_paths?: list, + * controllers_json?: scalar|Param|null, // Default: "%kernel.project_dir%/assets/controllers.json" + * } + * @psalm-type TurboConfig = array{ + * broadcast?: bool|array{ + * enabled?: bool|Param, // Default: true + * entity_template_prefixes?: list, + * doctrine_orm?: bool|array{ // Enable the Doctrine ORM integration + * enabled?: bool|Param, // Default: true + * }, + * }, + * default_transport?: scalar|Param|null, // Default: "default" + * } + * @psalm-type TwigExtraConfig = array{ + * cache?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * html?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * markdown?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * intl?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * cssinliner?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * inky?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * string?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * commonmark?: array{ + * renderer?: array{ // Array of options for rendering HTML. + * block_separator?: scalar|Param|null, + * inner_separator?: scalar|Param|null, + * soft_break?: scalar|Param|null, + * }, + * html_input?: "strip"|"allow"|"escape"|Param, // How to handle HTML input. + * allow_unsafe_links?: bool|Param, // Remove risky link and image URLs by setting this to false. // Default: true + * max_nesting_level?: int|Param, // The maximum nesting level for blocks. // Default: 9223372036854775807 + * max_delimiters_per_line?: int|Param, // The maximum number of strong/emphasis delimiters per line. // Default: 9223372036854775807 + * slug_normalizer?: array{ // Array of options for configuring how URL-safe slugs are created. + * instance?: mixed, + * max_length?: int|Param, // Default: 255 + * unique?: mixed, + * }, + * commonmark?: array{ // Array of options for configuring the CommonMark core extension. + * enable_em?: bool|Param, // Default: true + * enable_strong?: bool|Param, // Default: true + * use_asterisk?: bool|Param, // Default: true + * use_underscore?: bool|Param, // Default: true + * unordered_list_markers?: list, + * }, + * ... + * }, + * } + * @psalm-type BabdevPagerfantaConfig = array{ + * default_view?: scalar|Param|null, // Default: "default" + * default_twig_template?: scalar|Param|null, // Default: "@BabDevPagerfanta/default.html.twig" + * exceptions_strategy?: array{ + * out_of_range_page?: "to_http_not_found"|"custom"|Param, // Default: "to_http_not_found" + * not_valid_current_page?: "to_http_not_found"|"custom"|Param, // Default: "to_http_not_found" + * }, + * } + * @psalm-type NelmioApiDocConfig = array{ + * type_info?: bool|Param, // Use the symfony/type-info component for determining types. // Default: true + * use_validation_groups?: bool|Param, // If true, `groups` passed to #[Model] attributes will be used to limit validation constraints // Default: false + * operation_id_generation?: \Nelmio\ApiDocBundle\Describer\OperationIdGeneration::ALWAYS_PREPEND|\Nelmio\ApiDocBundle\Describer\OperationIdGeneration::CONDITIONALLY_PREPEND|\Nelmio\ApiDocBundle\Describer\OperationIdGeneration::NO_PREPEND|"always_prepend"|"conditionally_prepend"|"no_prepend"|Param, // How to generate operation ids // Default: "always_prepend" + * cache?: array{ + * pool?: scalar|Param|null, // define cache pool to use // Default: null + * item_id?: scalar|Param|null, // define cache item id // Default: null + * }, + * documentation?: array, + * media_types?: list, + * html_config?: array{ // UI configuration options + * assets_mode?: scalar|Param|null, // Default: "cdn" + * swagger_ui_config?: array, + * redocly_config?: array, + * scalar_config?: array, + * stoplight_config?: array, + * }, + * areas?: array, + * host_patterns?: list, + * name_patterns?: list, + * security?: array + * }>, + * with_attribute?: bool|Param, // whether to filter by attributes // Default: false + * disable_default_routes?: bool|Param, // if set disables default routes without attributes // Default: false + * documentation?: array, + * cache?: array{ + * pool?: scalar|Param|null, // define cache pool to use // Default: null + * item_id?: scalar|Param|null, // define cache item id // Default: null + * }, + * }>, + * models?: array{ + * use_jms?: bool|Param, // Default: false + * names?: list, + * areas?: list, + * }>, + * }, + * } + * @psalm-type WebProfilerConfig = array{ + * toolbar?: bool|array{ // Profiler toolbar configuration + * enabled?: bool|Param, // Default: false + * ajax_replace?: bool|Param, // Replace toolbar on AJAX requests // Default: false + * }, + * intercept_redirects?: bool|Param, // Default: false + * excluded_ajax_paths?: scalar|Param|null, // Default: "^/((index|app(_[\\w]+)?)\\.php/)?_wdt" + * } + * @psalm-type ConfigType = array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * monolog?: MonologConfig, + * nelmio_cors?: NelmioCorsConfig, + * twig?: TwigConfig, + * stimulus?: StimulusConfig, + * turbo?: TurboConfig, + * twig_extra?: TwigExtraConfig, + * babdev_pagerfanta?: BabdevPagerfantaConfig, + * nelmio_api_doc?: NelmioApiDocConfig, + * "when@dev"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * maker?: MakerConfig, + * security?: SecurityConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * monolog?: MonologConfig, + * nelmio_cors?: NelmioCorsConfig, + * debug?: DebugConfig, + * twig?: TwigConfig, + * stimulus?: StimulusConfig, + * turbo?: TurboConfig, + * twig_extra?: TwigExtraConfig, + * babdev_pagerfanta?: BabdevPagerfantaConfig, + * nelmio_api_doc?: NelmioApiDocConfig, + * web_profiler?: WebProfilerConfig, + * }, + * "when@prod"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * monolog?: MonologConfig, + * nelmio_cors?: NelmioCorsConfig, + * twig?: TwigConfig, + * stimulus?: StimulusConfig, + * turbo?: TurboConfig, + * twig_extra?: TwigExtraConfig, + * babdev_pagerfanta?: BabdevPagerfantaConfig, + * nelmio_api_doc?: NelmioApiDocConfig, + * }, + * "when@test"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * monolog?: MonologConfig, + * nelmio_cors?: NelmioCorsConfig, + * twig?: TwigConfig, + * stimulus?: StimulusConfig, + * turbo?: TurboConfig, + * twig_extra?: TwigExtraConfig, + * babdev_pagerfanta?: BabdevPagerfantaConfig, + * nelmio_api_doc?: NelmioApiDocConfig, + * web_profiler?: WebProfilerConfig, + * }, + * ..., + * }> + * } + */ +final class App +{ + /** + * @param ConfigType $config + * + * @psalm-return ConfigType + */ + public static function config(array $config): array + { + /** @var ConfigType $config */ + $config = AppReference::config($config); + + return $config; + } +} + +namespace Symfony\Component\Routing\Loader\Configurator; + +/** + * This class provides array-shapes for configuring the routes of an application. + * + * Example: + * + * ```php + * // config/routes.php + * namespace Symfony\Component\Routing\Loader\Configurator; + * + * return Routes::config([ + * 'controllers' => [ + * 'resource' => 'routing.controllers', + * ], + * ]); + * ``` + * + * @psalm-type RouteConfig = array{ + * path: string|array, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type ImportConfig = array{ + * resource: string, + * type?: string, + * exclude?: string|list, + * prefix?: string|array, + * name_prefix?: string, + * trailing_slash_on_root?: bool, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type AliasConfig = array{ + * alias: string, + * deprecated?: array{package:string, version:string, message?:string}, + * } + * @psalm-type RoutesConfig = array{ + * "when@dev"?: array, + * "when@prod"?: array, + * "when@test"?: array, + * ... + * } + */ +final class Routes +{ + /** + * @param RoutesConfig $config + * + * @psalm-return RoutesConfig + */ + public static function config(array $config): array + { + return $config; + } +} diff --git a/config/routes.yaml b/config/routes.yaml new file mode 100644 index 0000000..41ef814 --- /dev/null +++ b/config/routes.yaml @@ -0,0 +1,5 @@ +controllers: + resource: + path: ../src/Controller/ + namespace: App\Controller + type: attribute diff --git a/config/routes/framework.yaml b/config/routes/framework.yaml new file mode 100644 index 0000000..0fc74bb --- /dev/null +++ b/config/routes/framework.yaml @@ -0,0 +1,4 @@ +when@dev: + _errors: + resource: '@FrameworkBundle/Resources/config/routing/errors.xml' + prefix: /_error diff --git a/config/routes/nelmio_api_doc.yaml b/config/routes/nelmio_api_doc.yaml new file mode 100644 index 0000000..f117078 --- /dev/null +++ b/config/routes/nelmio_api_doc.yaml @@ -0,0 +1,12 @@ +# Expose your documentation as JSON swagger compliant +app.swagger: + path: /api/doc.json + methods: GET + defaults: { _controller: nelmio_api_doc.controller.swagger } + +## Requires the Asset component and the Twig bundle +## $ composer require twig asset +app.swagger_ui: + path: docs + methods: GET + defaults: { _controller: nelmio_api_doc.controller.swagger_ui } diff --git a/config/routes/security.yaml b/config/routes/security.yaml new file mode 100644 index 0000000..f853be1 --- /dev/null +++ b/config/routes/security.yaml @@ -0,0 +1,3 @@ +_security_logout: + resource: security.route_loader.logout + type: service diff --git a/config/routes/web_profiler.yaml b/config/routes/web_profiler.yaml new file mode 100644 index 0000000..b3b7b4b --- /dev/null +++ b/config/routes/web_profiler.yaml @@ -0,0 +1,8 @@ +when@dev: + web_profiler_wdt: + resource: '@WebProfilerBundle/Resources/config/routing/wdt.php' + prefix: /_wdt + + web_profiler_profiler: + resource: '@WebProfilerBundle/Resources/config/routing/profiler.php' + prefix: /_profiler diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..f56aa72 --- /dev/null +++ b/config/services.php @@ -0,0 +1,12 @@ +import('./services_stub.yaml'); + } +}; diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..81c413c --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,232 @@ +# This file is the entry point to configure your own services. +# Files in the packages/ subdirectory configure your dependencies. + +# Put parameters here that don't need to change on each machine where the app is deployed +# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration +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: '' + +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + + # makes classes in src/ available to be used as services + # this creates a service per class whose id is the fully-qualified class name + App\: + resource: '../src/' + exclude: + - '../src/DependencyInjection/' + - '../src/Entity/' + - '../src/Kernel.php' + + # add more service definitions when explicit configuration is needed + # please note that last definitions always *replace* previous ones + Psr\Log\LoggerInterface: '@logger' + + App\MessageHandler\SchedulerDefaultMessageHandler: + tags: ['monolog.logger'] + # arguments: + # $application: '@console.messenger.application' + + App\Serializer\Normalizer\SpecialistNormalizer: + public: true + tags: [serializer.normalizer] + + App\Serializer\Normalizer\StockNormalizer: + public: true + tags: [serializer.normalizer] + + App\Serializer\Normalizer\SpecialistDocsNormalizer: + public: true + tags: [serializer.normalizer] + + App\Service\FileUploader\FileUploaderService: + tags: ['monolog.logger'] + public: true + arguments: + $targetDirectory: '%upload_directory%' + + App\EventListener\JsonExceptionHandler: + tags: + - { name: kernel.event_listener, event: kernel.exception, method: onKernelException } + + App\Service\Translite\Interfaces\TransliteServiceInterface: + alias: App\Service\Translite\TransliteService + + App\Command\UploadFilialsCommand: + arguments: + $widgetApiUrl: '%widget_api_url%' + tags: ['console.command'] + + App\Command\UploadDoctorsCommand: + tags: ['console.command'] + + App\Command\UploadDepartmentsCommand: + 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: + arguments: + $logger: '@logger' + $entityManager: '@doctrine.orm.entity_manager' + $bitrixService: '@App\Service\Bitrix\BitrixService' + tags: ['console.command'] + + App\Command\BitrixUpdateReviewsCommand: + arguments: + $logger: '@logger' + $entityManager: '@doctrine.orm.entity_manager' + $bitrixService: '@App\Service\Bitrix\BitrixService' + tags: ['console.command'] + + App\Service\Crypt\AESCryptService: + arguments: + $secretKey: '%env(string:AES_SECRET_KEY)%' + $cipher: '%env(string:AES_CIPHER_METHOD)%' + + App\Service\Client\AbstractHttpClientService: + abstract: true + public: false + arguments: + $userAgent: '%env(string:API_CLIENT)%' + $baseUrl: '@api.baseurl' + + App\Service\Client\CalltouchClientService: + parent: App\Service\Client\AbstractHttpClientService + arguments: + $userAgent: '%env(string:API_CLIENT)%' + $baseUrl: '%env(string:CT_URL)%' + $params: '%env(string:CT_PARAMS)%' + + App\Service\Client\BitrixClientService: + parent: App\Service\Client\AbstractHttpClientService + arguments: + $userAgent: '%env(string:API_CLIENT)%' + $baseUrl: '%env(string:BITRIX_URL)%' + + App\Service\Client\InfoclinicaClientService: + parent: App\Service\Client\AbstractHttpClientService + arguments: + $userAgent: '%env(string:API_CLIENT)%' + $baseUrl: '%env(string:MIS_URL)%' + + App\Service\Client\SmartCaptchaClientService: + parent: App\Service\Client\AbstractHttpClientService + arguments: + $userAgent: '%env(string:API_CLIENT)%' + $baseUrl: '%env(string:SMARTCAPTCHA_URL)%' + $secret: '%env(string:SMARTCAPTCHA_KEY)%' + + App\Service\Client\Sms4bClientService: + parent: App\Service\Client\AbstractHttpClientService + arguments: + $userAgent: '%env(string:API_CLIENT)%' + $baseUrl: '%env(string:SMS4B_URL)%' + $token: '%env(string:SMS4B_TOKEN)%' + $sender: '%env(string:SMS4B_SENDER)%' + + App\Service\Client\SmsruClientService: + parent: App\Service\Client\AbstractHttpClientService + arguments: + $userAgent: '%env(string:API_CLIENT)%' + $baseUrl: '%env(string:SMSRU_URL)%' + $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: + $connection: '@doctrine.dbal.mysql_connection' + + App\Service\PriceList\PriceListService: + arguments: + $priceListRepository: '@App\Repository\PriceListRepository' + + App\Service\Location\LocationService: + arguments: + $locationRepository: '@App\Repository\LocationRepository' + + App\Service\Specialist\SpecialistService: + arguments: + $messageBus: '@messenger.default_bus' + $specialistRepository: '@App\Repository\SpecialistRepository' + + App\Service\Filial\FilialService: + arguments: + $filialRepository: '@App\Repository\FilialRepository' + + App\Service\XmlFeedGenerator\XmlFeedGeneratorService: + arguments: + $priceListService: '@App\Service\PriceList\PriceListService' + $departmentService: '@App\Service\Department\DepartmentService' + $specialistService: '@App\Service\Specialist\SpecialistService' + $locationService: '@App\Service\Location\LocationService' + $filialService: '@App\Service\Filial\FilialService' + $apiPublicUrl: '%api.public_url%' + + App\Service\XmlFeedGenerator\XmlFeedGeneratorV1Service: + arguments: + $priceListService: '@App\Service\PriceList\PriceListService' + $specialistService: '@App\Service\Specialist\SpecialistService' + $helperService: '@App\Service\Helper\HelperService' + $connection: '@doctrine.dbal.default_connection' + $logger: '@logger' + $apiPublicUrl: '%api.public_url%' + + App\Service\ScheduleCache\ScheduleCacheService: + arguments: + $logger: '@logger' + + App\Service\ErrorHandler\ScheduleErrorHandlerService: + arguments: + $logger: '@logger' + + App\Service\Performance\PerformanceTrackerService: ~ + + App\Service\Mail\SendMailService: + arguments: + $fromEmail: '%mailer_from_email%' + $fromName: '%mailer_from_name%' + + App\Service\Mail\SendMailConfig: + arguments: + $accessToken: '%env(default:mailer_access_token:MAILER_ACCESS_TOKEN)%' + + App\MessageHandler\GetScheduleMessageHandler: + arguments: + $clientService: '@App\Service\Client\InfoclinicaClientService' + $cacheService: '@App\Service\ScheduleCache\ScheduleCacheService' + $errorHandler: '@App\Service\ErrorHandler\ScheduleErrorHandlerService' + $performanceTracker: '@App\Service\Performance\PerformanceTrackerService' + tags: + - { name: messenger.message_handler } \ No newline at end of file diff --git a/config/services_stub.yaml b/config/services_stub.yaml new file mode 100644 index 0000000..ac16ec7 --- /dev/null +++ b/config/services_stub.yaml @@ -0,0 +1,13 @@ +services: + App\Service\Client\Stub\NoopSmsClientService: ~ + App\Service\Client\Stub\NoopCalltouchClientService: ~ + App\Service\Client\Stub\AlwaysValidSmartCaptchaClientService: ~ + + App\Service\Client\Interfaces\SmsClientServiceInterface: + alias: App\Service\Client\Stub\NoopSmsClientService + + App\Service\Client\Interfaces\CalltouchClientServiceInterface: + alias: App\Service\Client\Stub\NoopCalltouchClientService + + App\Service\Client\Interfaces\SmartCaptchaClientServiceInterface: + alias: App\Service\Client\Stub\AlwaysValidSmartCaptchaClientService diff --git a/docker/fpm-pool.conf b/docker/fpm-pool.conf new file mode 100644 index 0000000..c50b74a --- /dev/null +++ b/docker/fpm-pool.conf @@ -0,0 +1,11 @@ +[www] +user = www-data +group = www-data +listen = 127.0.0.1:9000 +pm = dynamic +pm.max_children = 20 +pm.start_servers = 4 +pm.min_spare_servers = 2 +pm.max_spare_servers = 8 +clear_env = no +catch_workers_output = yes diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..3b9260b --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,32 @@ +server { + listen 8080; + server_name _; + root /app/public; + index index.php; + client_max_body_size 108M; + + location / { + try_files $uri /index.php$is_args$args; + } + + location ~* \.(?:jpg|jpeg|gif|png|ico|css|js|svg|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + location ~ ^/index\.php(/|$) { + fastcgi_pass 127.0.0.1:9000; + fastcgi_split_path_info ^(.+\.php)(/.*)$; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_param HTTP_PROXY ""; + internal; + } + + location ~ \.php$ { + return 404; + } +} diff --git a/importmap.php b/importmap.php new file mode 100644 index 0000000..b73b323 --- /dev/null +++ b/importmap.php @@ -0,0 +1,28 @@ + [ + 'path' => './assets/app.js', + 'entrypoint' => true, + ], + '@hotwired/stimulus' => [ + 'version' => '3.2.2', + ], + '@symfony/stimulus-bundle' => [ + 'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js', + ], + '@hotwired/turbo' => [ + 'version' => '7.3.0', + ], +]; diff --git a/issues-27.html b/issues-27.html new file mode 100644 index 0000000..c75123d --- /dev/null +++ b/issues-27.html @@ -0,0 +1,59347 @@ + + + + + Diff to HTML by rtfpessoa + + + + + + + + + + + + + +

Diff to HTML by rtfpessoa

+ +
+
+
+ Files changed (30) + hide + show +
+
    +
  1. + + config/packages/nelmio_api_doc.yaml + + +8 + -2 + + +
  2. +
  3. + + migrations/Version20260515142000.php + + +53 + -0 + + +
  4. +
  5. + + src/Controller/ArticleController.php + + +30 + -101 + + +
  6. +
  7. + + src/Controller/DiseaseController.php + + +28 + -56 + + +
  8. +
  9. + + src/Controller/MedicalCenterController.php + + +28 + -38 + + +
  10. +
  11. + + src/Controller/NewsController.php + + +28 + -38 + + +
  12. +
  13. + + src/Controller/PromoController.php + + +28 + -38 + + +
  14. +
  15. + + src/Controller/SiteServiceController.php + + +28 + -57 + + +
  16. +
  17. + + src/Dto/Content/ContentFilterDto.php + + +81 + -0 + + +
  18. +
  19. + + src/Entity/Article.php + + +5 + -1 + + +
  20. +
  21. + + src/Entity/Behavior/UpdateTimestampTrait.php + + +29 + -0 + + +
  22. +
  23. + + src/Entity/Disease.php + + +6 + -1 + + +
  24. +
  25. + + src/Entity/MedicalCenter.php + + +6 + -1 + + +
  26. +
  27. + + src/Entity/News.php + + +6 + -1 + + +
  28. +
  29. + + src/Entity/Promo.php + + +6 + -1 + + +
  30. +
  31. + + src/Entity/SiteService.php + + +6 + -1 + + +
  32. +
  33. + + src/Repository/ArticleRepository.php + + +18 + -43 + + +
  34. +
  35. + + src/Repository/ContentFilterTrait.php + + +58 + -0 + + +
  36. +
  37. + + src/Repository/DiseaseRepository.php + + +15 + -0 + + +
  38. +
  39. + + src/Repository/MedicalCenterRepository.php + + +15 + -0 + + +
  40. +
  41. + + src/Repository/NewsRepository.php + + +19 + -0 + + +
  42. +
  43. + + src/Repository/PromoRepository.php + + +15 + -0 + + +
  44. +
  45. + + src/Repository/SiteServiceRepository.php + + +15 + -0 + + +
  46. +
  47. + + src/Service/Crud/CrudResponder.php + + +195 + -0 + + +
  48. +
  49. + + src/Service/DiseaseCrudService.php + + +6 + -186 + + +
  50. +
  51. + + src/Service/MedicalCenterCrudService.php + + +119 + -304 + + +
  52. +
  53. + + src/Service/NewsCrudService.php + + +8 + -128 + + +
  54. +
  55. + + src/Service/Pagination/Paginator.php + + +107 + -0 + + +
  56. +
  57. + + src/Service/PromoCrudService.php + + +6 + -128 + + +
  58. +
  59. + + src/Service/SiteServiceCrudService.php + + +7 + -339 + + +
  60. +
+
+
+
+ + config/packages/nelmio_api_doc.yaml + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -16,5 +16,11 @@ nelmio_api_doc:
+
+ 16 + +
+   + '^/specialist/list$', +
+
+ 17 + +
+   + '^/specialist/schedule$', +
+
+ 18 + +
+   + '^/pricelist/list$', +
+
+ 19 + +
+ - + '^/pricelist/department$' +
+
+ 20 + +
+ - + ] +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 16 + +
+   + '^/specialist/list$', +
+
+ 17 + +
+   + '^/specialist/schedule$', +
+
+ 18 + +
+   + '^/pricelist/list$', +
+
+ 19 + +
+ + + '^/pricelist/department$', +
+
+ 20 + +
+ + + '^/news($|/)', +
+
+ 21 + +
+ + + '^/promo($|/)', +
+
+ 22 + +
+ + + '^/disease($|/)', +
+
+ 23 + +
+ + + '^/medical-center($|/)', +
+
+ 24 + +
+ + + '^/article($|/)', +
+
+ 25 + +
+ + + '^/site-services($|/)' +
+
+ 26 + +
+ + + ] +
+
+
+
+
+
+
+
+ + migrations/Version20260515142000.php + ADDED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -0,0 +1,53 @@
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 1 + +
+ + + <?php +
+
+ 2 + +
+ + +
+
+
+ 3 + +
+ + + declare(strict_types=1); +
+
+ 4 + +
+ + +
+
+
+ 5 + +
+ + + namespace DoctrineMigrations; +
+
+ 6 + +
+ + +
+
+
+ 7 + +
+ + + use Doctrine\DBAL\Schema\Schema; +
+
+ 8 + +
+ + + use Doctrine\Migrations\AbstractMigration; +
+
+ 9 + +
+ + +
+
+
+ 10 + +
+ + + final class Version20260515142000 extends AbstractMigration +
+
+ 11 + +
+ + + { +
+
+ 12 + +
+ + + private const TABLES = [ +
+
+ 13 + +
+ + + 'news', +
+
+ 14 + +
+ + + 'promo', +
+
+ 15 + +
+ + + 'disease', +
+
+ 16 + +
+ + + 'medical_center', +
+
+ 17 + +
+ + + 'site_services', +
+
+ 18 + +
+ + + ]; +
+
+ 19 + +
+ + +
+
+
+ 20 + +
+ + + public function getDescription(): string +
+
+ 21 + +
+ + + { +
+
+ 22 + +
+ + + return 'Add generated id defaults for content CRUD entities'; +
+
+ 23 + +
+ + + } +
+
+ 24 + +
+ + +
+
+
+ 25 + +
+ + + public function up(Schema $schema): void +
+
+ 26 + +
+ + + { +
+
+ 27 + +
+ + + foreach (self::TABLES as $table) { +
+
+ 28 + +
+ + + $sequence = $table . '_id_seq'; +
+
+ 29 + +
+ + +
+
+
+ 30 + +
+ + + $this->addSql(sprintf('CREATE SEQUENCE IF NOT EXISTS %s OWNED BY %s.id', $sequence, $table)); +
+
+ 31 + +
+ + + $this->addSql(sprintf( +
+
+ 32 + +
+ + + 'SELECT setval(\'%s\', COALESCE((SELECT MAX(id) FROM %s), 0) + 1, false)', +
+
+ 33 + +
+ + + $sequence, +
+
+ 34 + +
+ + + $table, +
+
+ 35 + +
+ + + )); +
+
+ 36 + +
+ + + $this->addSql(sprintf( +
+
+ 37 + +
+ + + 'ALTER TABLE %s ALTER COLUMN id SET DEFAULT nextval(\'%s\')', +
+
+ 38 + +
+ + + $table, +
+
+ 39 + +
+ + + $sequence, +
+
+ 40 + +
+ + + )); +
+
+ 41 + +
+ + + } +
+
+ 42 + +
+ + + } +
+
+ 43 + +
+ + +
+
+
+ 44 + +
+ + + public function down(Schema $schema): void +
+
+ 45 + +
+ + + { +
+
+ 46 + +
+ + + foreach (array_reverse(self::TABLES) as $table) { +
+
+ 47 + +
+ + + $sequence = $table . '_id_seq'; +
+
+ 48 + +
+ + +
+
+
+ 49 + +
+ + + $this->addSql(sprintf('ALTER TABLE %s ALTER COLUMN id DROP DEFAULT', $table)); +
+
+ 50 + +
+ + + $this->addSql(sprintf('DROP SEQUENCE IF EXISTS %s', $sequence)); +
+
+ 51 + +
+ + + } +
+
+ 52 + +
+ + + } +
+
+ 53 + +
+ + + } +
+
+
+
+
+
+
+
+ + src/Controller/ArticleController.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,54 +2,46 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\Article; +
+
+ 6 + +
+   + use App\Repository\ArticleRepository; +
+
+ 7 + +
+ - + use Doctrine\ORM\EntityManagerInterface; +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 8 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 9 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 10 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+ 11 + +
+   + use Symfony\Component\HttpFoundation\Response; +
+
+ 12 + +
+   + use Symfony\Component\Routing\Annotation\Route; +
+
+ 13 + +
+   + use Symfony\Component\Security\Http\Attribute\IsGranted; +
+
+ 14 + +
+ - + use Symfony\Component\Serializer\SerializerInterface; +
+
+ 15 + +
+ - + use Symfony\Component\Validator\Validator\ValidatorInterface; +
+
+ 16 + +
+ - + use Exception; +
+
+ 17 + +
+   +
+
+
+ 18 + +
+   + #[Route('/article')] +
+
+ 19 + +
+   + final class ArticleController extends AbstractController +
+
+ 20 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 21 + +
+   + public function __construct( +
+
+ 22 + +
+ - + private EntityManagerInterface $em, +
+
+ 23 + +
+ - + private ValidatorInterface $validator, +
+
+ 24 + +
+ - + private SerializerInterface $serializer +
+
+ 25 + +
+ - + ) { } +
+
+ 26 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 27 + +
+   + #[Route('/list', name: 'article_list', methods: ['GET'])] +
+
+ 28 + +
+   + public function list(Request $request, ArticleRepository $repository): JsonResponse +
+
+ 29 + +
+   + { +
+
+ 30 + +
+ - + $page = max(1, (int) $request->query->get('page', 1)); +
+
+ 31 + +
+ - + $limit = min(100, max(1, (int) $request->query->get('limit', 20))); +
+
+ 32 + +
+ - +
+
+
+ 33 + +
+ - + $filters = [ +
+
+ 34 + +
+ - + 'alias' => $request->query->get('alias', ''), +
+
+ 35 + +
+ - + 'active' => $request->query->get('active', ''), +
+
+ 36 + +
+ - + 'regionId' => $request->query->get('regionId', ''), +
+
+ 37 + +
+ - + ]; +
+
+ 38 + +
+   +
+
+
+ 39 + +
+ - + $articles = $repository->findByFilters($filters, $page, $limit); +
+
+ 40 + +
+ - + $total = $repository->countByFilters($filters); +
+
+ 41 + +
+ - + $totalPages = (int) ceil($total / $limit); +
+
+ 42 + +
+ - +
+
+
+ 43 + +
+ - + return $this->json([ +
+
+ 44 + +
+ - + 'data' => $articles, +
+
+ 45 + +
+ - + 'meta' => [ +
+
+ 46 + +
+ - + 'total' => $total, +
+
+ 47 + +
+ - + 'page' => $page, +
+
+ 48 + +
+ - + 'limit' => $limit, +
+
+ 49 + +
+ - + 'totalPages' => $totalPages, +
+
+ 50 + +
+ - + ], +
+
+ 51 + +
+ - + ], Response::HTTP_OK, [], [ +
+
+ 52 + +
+ - + 'groups' => ['article:read'] +
+
+ 53 + +
+   + ]); +
+
+ 54 + +
+   + } +
+
+ 55 + +
+   +
+
+
+
@@ -60,99 +52,36 @@ final class ArticleController extends AbstractController
+
+ 60 + +
+   + if (!$article) { +
+
+ 61 + +
+   + throw $this->createNotFoundException('Статья не найдена'); +
+
+ 62 + +
+   + } +
+
+ 63 + +
+ - + return $this->json($article, Response::HTTP_OK, [], [ +
+
+ 64 + +
+ - + 'groups' => ['article:read'] +
+
+ 65 + +
+ - + ]); +
+
+ 66 + +
+   + } +
+
+ 67 + +
+   +
+
+
+ 68 + +
+   + #[Route('/{id}', name: 'article_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 69 + +
+   + public function show(Article $article): JsonResponse +
+
+ 70 + +
+   + { +
+
+ 71 + +
+ - + return $this->json($article, Response::HTTP_OK, [], [ +
+
+ 72 + +
+ - + 'groups' => ['article:read'] +
+
+ 73 + +
+ - + ]); +
+
+ 74 + +
+   + } +
+
+ 75 + +
+   +
+
+
+ 76 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 77 + +
+   + #[Route('/create', name: 'article_create', methods: ['POST'])] +
+
+ 78 + +
+   + public function create(Request $request): JsonResponse +
+
+ 79 + +
+   + { +
+
+ 80 + +
+ - + try { +
+
+ 81 + +
+ - + $article = $this->serializer->deserialize( +
+
+ 82 + +
+ - + $request->getContent(), +
+
+ 83 + +
+ - + Article::class, +
+
+ 84 + +
+ - + 'json', +
+
+ 85 + +
+ - + ['groups' => ['article:write']] +
+
+ 86 + +
+ - + ); +
+
+ 87 + +
+ - +
+
+
+ 88 + +
+ - + $errors = $this->validator->validate($article); +
+
+ 89 + +
+ - +
+
+
+ 90 + +
+ - + if (count($errors) > 0) { +
+
+ 91 + +
+ - + return $this->json($errors, Response::HTTP_BAD_REQUEST); +
+
+ 92 + +
+ - + } +
+
+ 93 + +
+ - +
+
+
+ 94 + +
+ - + $this->em->persist($article); +
+
+ 95 + +
+ - + $this->em->flush(); +
+
+ 96 + +
+ - +
+
+
+ 97 + +
+ - + return $this->json($article, Response::HTTP_CREATED, [], [ +
+
+ 98 + +
+ - + 'groups' => ['article:read'] +
+
+ 99 + +
+ - + ]); +
+
+ 100 + +
+ - + } catch (Exception $e) { +
+
+ 101 + +
+ - + return new JsonResponse([ +
+
+ 102 + +
+ - + 'error' => 'Ошибка при создании статьи', +
+
+ 103 + +
+ - + 'message' => $e->getMessage() +
+
+ 104 + +
+ - + ], Response::HTTP_INTERNAL_SERVER_ERROR); +
+
+ 105 + +
+ - + } +
+
+ 106 + +
+   + } +
+
+ 107 + +
+   +
+
+
+ 108 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 109 + +
+   + #[Route('/{id}', name: 'article_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 110 + +
+   + public function update(Request $request, Article $article): JsonResponse +
+
+ 111 + +
+   + { +
+
+ 112 + +
+ - + try { +
+
+ 113 + +
+ - + $this->serializer->deserialize( +
+
+ 114 + +
+ - + $request->getContent(), +
+
+ 115 + +
+ - + Article::class, +
+
+ 116 + +
+ - + 'json', +
+
+ 117 + +
+ - + [ +
+
+ 118 + +
+ - + 'groups' => ['article:write'], +
+
+ 119 + +
+ - + 'object_to_populate' => $article +
+
+ 120 + +
+ - + ] +
+
+ 121 + +
+ - + ); +
+
+ 122 + +
+ - +
+
+
+ 123 + +
+ - + $errors = $this->validator->validate($article); +
+
+ 124 + +
+ - +
+
+
+ 125 + +
+ - + if (count($errors) > 0) { +
+
+ 126 + +
+ - + return $this->json($errors, Response::HTTP_BAD_REQUEST); +
+
+ 127 + +
+ - + } +
+
+ 128 + +
+ - +
+
+
+ 129 + +
+ - + $this->em->flush(); +
+
+ 130 + +
+ - +
+
+
+ 131 + +
+ - + return $this->json($article, Response::HTTP_OK, [], [ +
+
+ 132 + +
+ - + 'groups' => ['article:read'] +
+
+ 133 + +
+ - + ]); +
+
+ 134 + +
+ - + } catch (Exception $e) { +
+
+ 135 + +
+ - + return new JsonResponse([ +
+
+ 136 + +
+ - + 'error' => 'Ошибка при обновлении статьи', +
+
+ 137 + +
+ - + 'message' => $e->getMessage() +
+
+ 138 + +
+ - + ], Response::HTTP_INTERNAL_SERVER_ERROR); +
+
+ 139 + +
+ - + } +
+
+ 140 + +
+   + } +
+
+ 141 + +
+   +
+
+
+ 142 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 143 + +
+   + #[Route('/{id}', name: 'article_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 144 + +
+   + public function delete(Article $article): JsonResponse +
+
+ 145 + +
+   + { +
+
+ 146 + +
+ - + try { +
+
+ 147 + +
+ - + $this->em->remove($article); +
+
+ 148 + +
+ - + $this->em->flush(); +
+
+ 149 + +
+ - +
+
+
+ 150 + +
+ - + return new JsonResponse(null, Response::HTTP_NO_CONTENT); +
+
+ 151 + +
+ - + } catch (Exception $e) { +
+
+ 152 + +
+ - + return new JsonResponse([ +
+
+ 153 + +
+ - + 'error' => 'Ошибка при удалении статьи', +
+
+ 154 + +
+ - + 'message' => $e->getMessage() +
+
+ 155 + +
+ - + ], Response::HTTP_INTERNAL_SERVER_ERROR); +
+
+ 156 + +
+ - + } +
+
+ 157 + +
+   + } +
+
+ 158 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\Article; +
+
+ 7 + +
+   + use App\Repository\ArticleRepository; +
+
+ 8 + +
+ + + use App\Service\Crud\CrudResponder; +
+
+ 9 + +
+ + + use App\Service\Pagination\Paginator; +
+
+ 10 + +
+ + + use Nelmio\ApiDocBundle\Attribute\Model; +
+
+ 11 + +
+ + + use OpenApi\Attributes as OA; +
+
+ 12 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 13 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 14 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+ 15 + +
+   + use Symfony\Component\HttpFoundation\Response; +
+
+ 16 + +
+   + use Symfony\Component\Routing\Annotation\Route; +
+
+ 17 + +
+   + use Symfony\Component\Security\Http\Attribute\IsGranted; +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 18 + +
+   +
+
+
+ 19 + +
+   + #[Route('/article')] +
+
+ 20 + +
+   + final class ArticleController extends AbstractController +
+
+ 21 + +
+   + { +
+
+ 22 + +
+ + + private const READ_GROUPS = ['article:read']; +
+
+ 23 + +
+ + + private const WRITE_GROUPS = ['article:write']; +
+
+ 24 + +
+ + +
+
+
+ 25 + +
+   + public function __construct( +
+
+ 26 + +
+ + + private readonly CrudResponder $crud, +
+
+ 27 + +
+ + + private readonly Paginator $paginator, +
+
+ 28 + +
+ + + ) { +
+
+ 29 + +
+ + + } +
+
+ 30 + +
+   +
+
+
+ 31 + +
+ + + #[OA\Tag(name: 'Статьи')] +
+
+ 32 + +
+ + + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 33 + +
+ + + #[OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 34 + +
+ + + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 35 + +
+ + + #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] +
+
+ 36 + +
+ + + #[OA\Parameter(name: 'alias', in: 'query', schema: new OA\Schema(type: 'string'))] +
+
+ 37 + +
+ + + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] +
+
+ 38 + +
+   + #[Route('/list', name: 'article_list', methods: ['GET'])] +
+
+ 39 + +
+   + public function list(Request $request, ArticleRepository $repository): JsonResponse +
+
+ 40 + +
+   + { +
+
+ 41 + +
+ + + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 42 + +
+   +
+
+
+ 43 + +
+ + + return $this->json($this->paginator->paginateWithLegacyMeta($qb, $request), Response::HTTP_OK, [], [ +
+
+ 44 + +
+ + + 'groups' => self::READ_GROUPS, +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 45 + +
+   + ]); +
+
+ 46 + +
+   + } +
+
+ 47 + +
+   +
+
+
+
 
+
+ 52 + +
+   + if (!$article) { +
+
+ 53 + +
+   + throw $this->createNotFoundException('Статья не найдена'); +
+
+ 54 + +
+   + } +
+
+ 55 + +
+ + +
+
+
+ 56 + +
+ + + return $this->crud->read($article, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ 57 + +
+   + } +
+
+ 58 + +
+   +
+
+
+ 59 + +
+   + #[Route('/{id}', name: 'article_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 60 + +
+   + public function show(Article $article): JsonResponse +
+
+ 61 + +
+   + { +
+
+ 62 + +
+ + + return $this->crud->read($article, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 63 + +
+   + } +
+
+ 64 + +
+   +
+
+
+ 65 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 66 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Article::class, groups: self::WRITE_GROUPS)))] +
+
+ 67 + +
+   + #[Route('/create', name: 'article_create', methods: ['POST'])] +
+
+ 68 + +
+   + public function create(Request $request): JsonResponse +
+
+ 69 + +
+   + { +
+
+ 70 + +
+ + + return $this->crud->create($request, Article::class, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 71 + +
+   + } +
+
+ 72 + +
+   +
+
+
+ 73 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 74 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Article::class, groups: self::WRITE_GROUPS)))] +
+
+ 75 + +
+   + #[Route('/{id}', name: 'article_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 76 + +
+   + public function update(Request $request, Article $article): JsonResponse +
+
+ 77 + +
+   + { +
+
+ 78 + +
+ + + return $this->crud->update($request, $article, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 79 + +
+   + } +
+
+ 80 + +
+   +
+
+
+ 81 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 82 + +
+   + #[Route('/{id}', name: 'article_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 83 + +
+   + public function delete(Article $article): JsonResponse +
+
+ 84 + +
+   + { +
+
+ 85 + +
+ + + return $this->crud->delete($article); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 86 + +
+   + } +
+
+ 87 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Controller/DiseaseController.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,13 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\Disease; +
+
+ 6 + +
+ - + use App\Service\DiseaseCrudService; +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 7 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 8 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 9 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+
@@ -14,90 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
+
+ 14 + +
+   + #[Route('/disease')] +
+
+ 15 + +
+   + final class DiseaseController extends AbstractController +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + public function __construct( +
+
+ 18 + +
+ - + private DiseaseCrudService $diseaseCrud, +
+
+ + +
+   +
+
+
+ 19 + +
+   + ) { +
+
+ 20 + +
+   + } +
+
+ 21 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 22 + +
+   + #[Route('/list', name: 'disease_list', methods: ['GET'])] +
+
+ 23 + +
+ - + public function list(Request $request): JsonResponse +
+
+ 24 + +
+   + { +
+
+ 25 + +
+ - + $page = $request->query->getInt('page', 1); +
+
+ 26 + +
+ - + $perPage = min($request->query->getInt('perPage', 100), 500); +
+
+ 27 + +
+ - + $regionId = $request->query->getInt('regionId', 0) ?: null; +
+
+ 28 + +
+ - +
+
+
+ 29 + +
+ - + $result = $this->diseaseCrud->getPaginatedList($page, $perPage, $regionId); +
+
+ 30 + +
+ - + $data = $result['data']; +
+
+ 31 + +
+ - + $total = $result['total']; +
+
+ 32 + +
+ - + $perPage = $result['per_page']; +
+
+ 33 + +
+ - + $totalPages = (int) ceil($total / $perPage); +
+
+ 34 + +
+   +
+
+
+ 35 + +
+ - + return $this->json([ +
+
+ 36 + +
+ - + 'data' => $data, +
+
+ 37 + +
+ - + 'pagination' => [ +
+
+ 38 + +
+ - + 'total' => $total, +
+
+ 39 + +
+ - + 'count' => count($data), +
+
+ 40 + +
+ - + 'per_page' => $perPage, +
+
+ 41 + +
+ - + 'current_page' => $result['page'], +
+
+ 42 + +
+ - + 'total_pages' => $totalPages, +
+
+ 43 + +
+ - + 'has_previous_page' => $result['page'] > 1, +
+
+ 44 + +
+ - + 'has_next_page' => $result['page'] < $totalPages, +
+
+ 45 + +
+ - + ], +
+
+ 46 + +
+ - + ], Response::HTTP_OK, [], [ +
+
+ 47 + +
+ - + 'groups' => ['disease:read'], +
+
+ 48 + +
+   + ]); +
+
+ 49 + +
+   + } +
+
+ 50 + +
+   +
+
+
+ 51 + +
+   + #[Route('/{id}', name: 'disease_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 52 + +
+   + public function show(Disease $disease): JsonResponse +
+
+ 53 + +
+   + { +
+
+ 54 + +
+ - + return $this->json($disease, Response::HTTP_OK, [], [ +
+
+ 55 + +
+ - + 'groups' => ['disease:read'], +
+
+ 56 + +
+ - + ]); +
+
+ 57 + +
+   + } +
+
+ 58 + +
+   +
+
+
+ 59 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 60 + +
+   + #[Route('/create', name: 'disease_create', methods: ['POST'])] +
+
+ 61 + +
+   + public function create(Request $request): JsonResponse +
+
+ 62 + +
+   + { +
+
+ 63 + +
+ - + $data = json_decode($request->getContent(), true); +
+
+ 64 + +
+ - + if (!is_array($data)) { +
+
+ 65 + +
+ - + return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +
+
+ 66 + +
+ - + } +
+
+ 67 + +
+ - +
+
+
+ 68 + +
+ - + try { +
+
+ 69 + +
+ - + $disease = $this->diseaseCrud->create($data); +
+
+ 70 + +
+ - + } catch (\InvalidArgumentException $e) { +
+
+ 71 + +
+ - + return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); +
+
+ 72 + +
+ - + } +
+
+ 73 + +
+ - +
+
+
+ 74 + +
+ - + return $this->json($disease, Response::HTTP_CREATED, [], [ +
+
+ 75 + +
+ - + 'groups' => ['disease:read'], +
+
+ 76 + +
+ - + ]); +
+
+ 77 + +
+   + } +
+
+ 78 + +
+   +
+
+
+ 79 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 80 + +
+   + #[Route('/{id}', name: 'disease_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 81 + +
+ - + public function update(Disease $disease, Request $request): JsonResponse +
+
+ 82 + +
+   + { +
+
+ 83 + +
+ - + $data = json_decode($request->getContent(), true); +
+
+ 84 + +
+ - + if (!is_array($data)) { +
+
+ 85 + +
+ - + return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +
+
+ 86 + +
+ - + } +
+
+ 87 + +
+ - +
+
+
+ 88 + +
+ - + $disease = $this->diseaseCrud->update($disease, $data); +
+
+ 89 + +
+ - +
+
+
+ 90 + +
+ - + return $this->json($disease, Response::HTTP_OK, [], [ +
+
+ 91 + +
+ - + 'groups' => ['disease:read'], +
+
+ 92 + +
+ - + ]); +
+
+ 93 + +
+   + } +
+
+ 94 + +
+   +
+
+
+ 95 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 96 + +
+   + #[Route('/{id}', name: 'disease_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 97 + +
+   + public function delete(Disease $disease): JsonResponse +
+
+ 98 + +
+   + { +
+
+ 99 + +
+ - + $this->diseaseCrud->delete($disease); +
+
+ 100 + +
+ - +
+
+
+ 101 + +
+ - + return new JsonResponse(null, Response::HTTP_NO_CONTENT); +
+
+ 102 + +
+   + } +
+
+ 103 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\Disease; +
+
+ 7 + +
+ + + use App\Repository\DiseaseRepository; +
+
+ 8 + +
+ + + use App\Service\Crud\CrudResponder; +
+
+ 9 + +
+ + + use App\Service\Pagination\Paginator; +
+
+ 10 + +
+ + + use Nelmio\ApiDocBundle\Attribute\Model; +
+
+ 11 + +
+ + + use OpenApi\Attributes as OA; +
+
+ 12 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 13 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 14 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+
 
+
+ 19 + +
+   + #[Route('/disease')] +
+
+ 20 + +
+   + final class DiseaseController extends AbstractController +
+
+ 21 + +
+   + { +
+
+ 22 + +
+ + + private const READ_GROUPS = ['disease:read']; +
+
+ 23 + +
+ + + private const WRITE_GROUPS = ['disease:write']; +
+
+ 24 + +
+ + +
+
+
+ 25 + +
+   + public function __construct( +
+
+ 26 + +
+ + + private readonly CrudResponder $crud, +
+
+ 27 + +
+ + + private readonly Paginator $paginator, +
+
+ 28 + +
+   + ) { +
+
+ 29 + +
+   + } +
+
+ 30 + +
+   +
+
+
+ 31 + +
+ + + #[OA\Tag(name: 'Заболевания')] +
+
+ 32 + +
+ + + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 33 + +
+ + + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 34 + +
+ + + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 35 + +
+ + + #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] +
+
+ 36 + +
+ + + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] +
+
+ 37 + +
+   + #[Route('/list', name: 'disease_list', methods: ['GET'])] +
+
+ 38 + +
+ + + public function list(Request $request, DiseaseRepository $repository): JsonResponse +
+
+ 39 + +
+   + { +
+
+ 40 + +
+ + + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 41 + +
+   +
+
+
+ 42 + +
+ + + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ +
+
+ 43 + +
+ + + 'groups' => self::READ_GROUPS, +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 44 + +
+   + ]); +
+
+ 45 + +
+   + } +
+
+ 46 + +
+   +
+
+
+ 47 + +
+   + #[Route('/{id}', name: 'disease_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 48 + +
+   + public function show(Disease $disease): JsonResponse +
+
+ 49 + +
+   + { +
+
+ 50 + +
+ + + return $this->crud->read($disease, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 51 + +
+   + } +
+
+ 52 + +
+   +
+
+
+ 53 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 54 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Disease::class, groups: self::WRITE_GROUPS)))] +
+
+ 55 + +
+   + #[Route('/create', name: 'disease_create', methods: ['POST'])] +
+
+ 56 + +
+   + public function create(Request $request): JsonResponse +
+
+ 57 + +
+   + { +
+
+ 58 + +
+ + + return $this->crud->create($request, Disease::class, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 59 + +
+   + } +
+
+ 60 + +
+   +
+
+
+ 61 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 62 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Disease::class, groups: self::WRITE_GROUPS)))] +
+
+ 63 + +
+   + #[Route('/{id}', name: 'disease_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 64 + +
+ + + public function update(Request $request, Disease $disease): JsonResponse +
+
+ 65 + +
+   + { +
+
+ 66 + +
+ + + return $this->crud->update($request, $disease, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 67 + +
+   + } +
+
+ 68 + +
+   +
+
+
+ 69 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 70 + +
+   + #[Route('/{id}', name: 'disease_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 71 + +
+   + public function delete(Disease $disease): JsonResponse +
+
+ 72 + +
+   + { +
+
+ 73 + +
+ + + return $this->crud->delete($disease); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 74 + +
+   + } +
+
+ 75 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Controller/MedicalCenterController.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,13 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\MedicalCenter; +
+
+ 6 + +
+ - + use App\Service\MedicalCenterCrudService; +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 7 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 8 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 9 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+
@@ -14,72 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
+
+ 14 + +
+   + #[Route('/medical-center')] +
+
+ 15 + +
+   + final class MedicalCenterController extends AbstractController +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + public function __construct( +
+
+ 18 + +
+ - + private MedicalCenterCrudService $medicalCenterCrud, +
+
+ + +
+   +
+
+
+ 19 + +
+   + ) { +
+
+ 20 + +
+   + } +
+
+ 21 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 22 + +
+   + #[Route('/list', name: 'medical_center_list', methods: ['GET'])] +
+
+ 23 + +
+ - + public function list(Request $request): JsonResponse +
+
+ 24 + +
+   + { +
+
+ 25 + +
+ - + $regionId = $request->query->getInt('regionId', 0); +
+
+ 26 + +
+ - + $activeParam = $request->query->get('active'); +
+
+ 27 + +
+ - + $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); +
+
+ 28 + +
+ - + if ($activeParam !== null && $active === null) { +
+
+ 29 + +
+ - + return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); +
+
+ 30 + +
+ - + } +
+
+ 31 + +
+   +
+
+
+ 32 + +
+ - + return $this->json(['data' => $this->medicalCenterCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [ +
+
+ 33 + +
+ - + 'groups' => ['medical_center:read'], +
+
+ 34 + +
+   + ]); +
+
+ 35 + +
+   + } +
+
+ 36 + +
+   +
+
+
+ 37 + +
+   + #[Route('/{id}', name: 'medical_center_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 38 + +
+   + public function show(MedicalCenter $medicalCenter): JsonResponse +
+
+ 39 + +
+   + { +
+
+ 40 + +
+ - + return $this->json($medicalCenter, Response::HTTP_OK, [], [ +
+
+ 41 + +
+ - + 'groups' => ['medical_center:read'], +
+
+ 42 + +
+ - + ]); +
+
+ 43 + +
+   + } +
+
+ 44 + +
+   +
+
+
+ 45 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 46 + +
+   + #[Route('/create', name: 'medical_center_create', methods: ['POST'])] +
+
+ 47 + +
+   + public function create(Request $request): JsonResponse +
+
+ 48 + +
+   + { +
+
+ 49 + +
+ - + $data = json_decode($request->getContent(), true); +
+
+ 50 + +
+ - + if (!is_array($data)) { +
+
+ 51 + +
+ - + return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +
+
+ 52 + +
+ - + } +
+
+ 53 + +
+ - +
+
+
+ 54 + +
+ - + $medicalCenter = $this->medicalCenterCrud->create($data); +
+
+ 55 + +
+ - +
+
+
+ 56 + +
+ - + return $this->json($medicalCenter, Response::HTTP_CREATED, [], [ +
+
+ 57 + +
+ - + 'groups' => ['medical_center:read'], +
+
+ 58 + +
+ - + ]); +
+
+ 59 + +
+   + } +
+
+ 60 + +
+   +
+
+
+ 61 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 62 + +
+   + #[Route('/{id}', name: 'medical_center_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 63 + +
+ - + public function update(MedicalCenter $medicalCenter, Request $request): JsonResponse +
+
+ 64 + +
+   + { +
+
+ 65 + +
+ - + $data = json_decode($request->getContent(), true); +
+
+ 66 + +
+ - + if (!is_array($data)) { +
+
+ 67 + +
+ - + return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +
+
+ 68 + +
+ - + } +
+
+ 69 + +
+ - +
+
+
+ 70 + +
+ - + $medicalCenter = $this->medicalCenterCrud->update($medicalCenter, $data); +
+
+ 71 + +
+ - +
+
+
+ 72 + +
+ - + return $this->json($medicalCenter, Response::HTTP_OK, [], [ +
+
+ 73 + +
+ - + 'groups' => ['medical_center:read'], +
+
+ 74 + +
+ - + ]); +
+
+ 75 + +
+   + } +
+
+ 76 + +
+   +
+
+
+ 77 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 78 + +
+   + #[Route('/{id}', name: 'medical_center_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 79 + +
+   + public function delete(MedicalCenter $medicalCenter): JsonResponse +
+
+ 80 + +
+   + { +
+
+ 81 + +
+ - + $this->medicalCenterCrud->delete($medicalCenter); +
+
+ 82 + +
+ - +
+
+
+ 83 + +
+ - + return new JsonResponse(null, Response::HTTP_NO_CONTENT); +
+
+ 84 + +
+   + } +
+
+ 85 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\MedicalCenter; +
+
+ 7 + +
+ + + use App\Repository\MedicalCenterRepository; +
+
+ 8 + +
+ + + use App\Service\Crud\CrudResponder; +
+
+ 9 + +
+ + + use App\Service\Pagination\Paginator; +
+
+ 10 + +
+ + + use Nelmio\ApiDocBundle\Attribute\Model; +
+
+ 11 + +
+ + + use OpenApi\Attributes as OA; +
+
+ 12 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 13 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 14 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+
 
+
+ 19 + +
+   + #[Route('/medical-center')] +
+
+ 20 + +
+   + final class MedicalCenterController extends AbstractController +
+
+ 21 + +
+   + { +
+
+ 22 + +
+ + + private const READ_GROUPS = ['medical_center:read']; +
+
+ 23 + +
+ + + private const WRITE_GROUPS = ['medical_center:write']; +
+
+ 24 + +
+ + +
+
+
+ 25 + +
+   + public function __construct( +
+
+ 26 + +
+ + + private readonly CrudResponder $crud, +
+
+ 27 + +
+ + + private readonly Paginator $paginator, +
+
+ 28 + +
+   + ) { +
+
+ 29 + +
+   + } +
+
+ 30 + +
+   +
+
+
+ 31 + +
+ + + #[OA\Tag(name: 'Центры')] +
+
+ 32 + +
+ + + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 33 + +
+ + + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 34 + +
+ + + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 35 + +
+ + + #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] +
+
+ 36 + +
+ + + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] +
+
+ 37 + +
+   + #[Route('/list', name: 'medical_center_list', methods: ['GET'])] +
+
+ 38 + +
+ + + public function list(Request $request, MedicalCenterRepository $repository): JsonResponse +
+
+ 39 + +
+   + { +
+
+ 40 + +
+ + + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 41 + +
+   +
+
+
+ 42 + +
+ + + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ +
+
+ 43 + +
+ + + 'groups' => self::READ_GROUPS, +
+
+ 44 + +
+   + ]); +
+
+ 45 + +
+   + } +
+
+ 46 + +
+   +
+
+
+ 47 + +
+   + #[Route('/{id}', name: 'medical_center_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 48 + +
+   + public function show(MedicalCenter $medicalCenter): JsonResponse +
+
+ 49 + +
+   + { +
+
+ 50 + +
+ + + return $this->crud->read($medicalCenter, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 51 + +
+   + } +
+
+ 52 + +
+   +
+
+
+ 53 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 54 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: MedicalCenter::class, groups: self::WRITE_GROUPS)))] +
+
+ 55 + +
+   + #[Route('/create', name: 'medical_center_create', methods: ['POST'])] +
+
+ 56 + +
+   + public function create(Request $request): JsonResponse +
+
+ 57 + +
+   + { +
+
+ 58 + +
+ + + return $this->crud->create($request, MedicalCenter::class, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 59 + +
+   + } +
+
+ 60 + +
+   +
+
+
+ 61 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 62 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: MedicalCenter::class, groups: self::WRITE_GROUPS)))] +
+
+ 63 + +
+   + #[Route('/{id}', name: 'medical_center_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 64 + +
+ + + public function update(Request $request, MedicalCenter $medicalCenter): JsonResponse +
+
+ 65 + +
+   + { +
+
+ 66 + +
+ + + return $this->crud->update($request, $medicalCenter, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 67 + +
+   + } +
+
+ 68 + +
+   +
+
+
+ 69 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 70 + +
+   + #[Route('/{id}', name: 'medical_center_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 71 + +
+   + public function delete(MedicalCenter $medicalCenter): JsonResponse +
+
+ 72 + +
+   + { +
+
+ 73 + +
+ + + return $this->crud->delete($medicalCenter); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 74 + +
+   + } +
+
+ 75 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Controller/NewsController.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,13 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\News; +
+
+ 6 + +
+ - + use App\Service\NewsCrudService; +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 7 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 8 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 9 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+
@@ -14,72 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
+
+ 14 + +
+   + #[Route('/news')] +
+
+ 15 + +
+   + final class NewsController extends AbstractController +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + public function __construct( +
+
+ 18 + +
+ - + private NewsCrudService $newsCrud, +
+
+ + +
+   +
+
+
+ 19 + +
+   + ) { +
+
+ 20 + +
+   + } +
+
+ 21 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 22 + +
+   + #[Route('/list', name: 'news_list', methods: ['GET'])] +
+
+ 23 + +
+ - + public function list(Request $request): JsonResponse +
+
+ 24 + +
+   + { +
+
+ 25 + +
+ - + $regionId = $request->query->getInt('regionId', 0); +
+
+ 26 + +
+ - + $activeParam = $request->query->get('active'); +
+
+ 27 + +
+ - + $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); +
+
+ 28 + +
+ - + if ($activeParam !== null && $active === null) { +
+
+ 29 + +
+ - + return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); +
+
+ 30 + +
+ - + } +
+
+ 31 + +
+   +
+
+
+ 32 + +
+ - + return $this->json(['data' => $this->newsCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [ +
+
+ 33 + +
+ - + 'groups' => ['news:read'], +
+
+ 34 + +
+   + ]); +
+
+ 35 + +
+   + } +
+
+ 36 + +
+   +
+
+
+ 37 + +
+   + #[Route('/{id}', name: 'news_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 38 + +
+   + public function show(News $news): JsonResponse +
+
+ 39 + +
+   + { +
+
+ 40 + +
+ - + return $this->json($news, Response::HTTP_OK, [], [ +
+
+ 41 + +
+ - + 'groups' => ['news:read'], +
+
+ 42 + +
+ - + ]); +
+
+ 43 + +
+   + } +
+
+ 44 + +
+   +
+
+
+ 45 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 46 + +
+   + #[Route('/create', name: 'news_create', methods: ['POST'])] +
+
+ 47 + +
+   + public function create(Request $request): JsonResponse +
+
+ 48 + +
+   + { +
+
+ 49 + +
+ - + $data = json_decode($request->getContent(), true); +
+
+ 50 + +
+ - + if (!is_array($data)) { +
+
+ 51 + +
+ - + return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +
+
+ 52 + +
+ - + } +
+
+ 53 + +
+ - +
+
+
+ 54 + +
+ - + $news = $this->newsCrud->create($data); +
+
+ 55 + +
+ - +
+
+
+ 56 + +
+ - + return $this->json($news, Response::HTTP_CREATED, [], [ +
+
+ 57 + +
+ - + 'groups' => ['news:read'], +
+
+ 58 + +
+ - + ]); +
+
+ 59 + +
+   + } +
+
+ 60 + +
+   +
+
+
+ 61 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 62 + +
+   + #[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 63 + +
+ - + public function update(News $news, Request $request): JsonResponse +
+
+ 64 + +
+   + { +
+
+ 65 + +
+ - + $data = json_decode($request->getContent(), true); +
+
+ 66 + +
+ - + if (!is_array($data)) { +
+
+ 67 + +
+ - + return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +
+
+ 68 + +
+ - + } +
+
+ 69 + +
+ - +
+
+
+ 70 + +
+ - + $news = $this->newsCrud->update($news, $data); +
+
+ 71 + +
+ - +
+
+
+ 72 + +
+ - + return $this->json($news, Response::HTTP_OK, [], [ +
+
+ 73 + +
+ - + 'groups' => ['news:read'], +
+
+ 74 + +
+ - + ]); +
+
+ 75 + +
+   + } +
+
+ 76 + +
+   +
+
+
+ 77 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 78 + +
+   + #[Route('/{id}', name: 'news_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 79 + +
+   + public function delete(News $news): JsonResponse +
+
+ 80 + +
+   + { +
+
+ 81 + +
+ - + $this->newsCrud->delete($news); +
+
+ 82 + +
+ - +
+
+
+ 83 + +
+ - + return new JsonResponse(null, Response::HTTP_NO_CONTENT); +
+
+ 84 + +
+   + } +
+
+ 85 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\News; +
+
+ 7 + +
+ + + use App\Repository\NewsRepository; +
+
+ 8 + +
+ + + use App\Service\Crud\CrudResponder; +
+
+ 9 + +
+ + + use App\Service\Pagination\Paginator; +
+
+ 10 + +
+ + + use Nelmio\ApiDocBundle\Attribute\Model; +
+
+ 11 + +
+ + + use OpenApi\Attributes as OA; +
+
+ 12 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 13 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 14 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+
 
+
+ 19 + +
+   + #[Route('/news')] +
+
+ 20 + +
+   + final class NewsController extends AbstractController +
+
+ 21 + +
+   + { +
+
+ 22 + +
+ + + private const READ_GROUPS = ['news:read']; +
+
+ 23 + +
+ + + private const WRITE_GROUPS = ['news:write']; +
+
+ 24 + +
+ + +
+
+
+ 25 + +
+   + public function __construct( +
+
+ 26 + +
+ + + private readonly CrudResponder $crud, +
+
+ 27 + +
+ + + private readonly Paginator $paginator, +
+
+ 28 + +
+   + ) { +
+
+ 29 + +
+   + } +
+
+ 30 + +
+   +
+
+
+ 31 + +
+ + + #[OA\Tag(name: 'Новости')] +
+
+ 32 + +
+ + + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 33 + +
+ + + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 34 + +
+ + + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 35 + +
+ + + #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] +
+
+ 36 + +
+ + + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] +
+
+ 37 + +
+   + #[Route('/list', name: 'news_list', methods: ['GET'])] +
+
+ 38 + +
+ + + public function list(Request $request, NewsRepository $repository): JsonResponse +
+
+ 39 + +
+   + { +
+
+ 40 + +
+ + + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 41 + +
+   +
+
+
+ 42 + +
+ + + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ +
+
+ 43 + +
+ + + 'groups' => self::READ_GROUPS, +
+
+ 44 + +
+   + ]); +
+
+ 45 + +
+   + } +
+
+ 46 + +
+   +
+
+
+ 47 + +
+   + #[Route('/{id}', name: 'news_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 48 + +
+   + public function show(News $news): JsonResponse +
+
+ 49 + +
+   + { +
+
+ 50 + +
+ + + return $this->crud->read($news, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 51 + +
+   + } +
+
+ 52 + +
+   +
+
+
+ 53 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 54 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: News::class, groups: self::WRITE_GROUPS)))] +
+
+ 55 + +
+   + #[Route('/create', name: 'news_create', methods: ['POST'])] +
+
+ 56 + +
+   + public function create(Request $request): JsonResponse +
+
+ 57 + +
+   + { +
+
+ 58 + +
+ + + return $this->crud->create($request, News::class, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 59 + +
+   + } +
+
+ 60 + +
+   +
+
+
+ 61 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 62 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: News::class, groups: self::WRITE_GROUPS)))] +
+
+ 63 + +
+   + #[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 64 + +
+ + + public function update(Request $request, News $news): JsonResponse +
+
+ 65 + +
+   + { +
+
+ 66 + +
+ + + return $this->crud->update($request, $news, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 67 + +
+   + } +
+
+ 68 + +
+   +
+
+
+ 69 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 70 + +
+   + #[Route('/{id}', name: 'news_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 71 + +
+   + public function delete(News $news): JsonResponse +
+
+ 72 + +
+   + { +
+
+ 73 + +
+ + + return $this->crud->delete($news); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 74 + +
+   + } +
+
+ 75 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Controller/PromoController.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,13 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\Promo; +
+
+ 6 + +
+ - + use App\Service\PromoCrudService; +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 7 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 8 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 9 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+
@@ -14,72 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
+
+ 14 + +
+   + #[Route('/promo')] +
+
+ 15 + +
+   + final class PromoController extends AbstractController +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + public function __construct( +
+
+ 18 + +
+ - + private PromoCrudService $promoCrud, +
+
+ + +
+   +
+
+
+ 19 + +
+   + ) { +
+
+ 20 + +
+   + } +
+
+ 21 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 22 + +
+   + #[Route('/list', name: 'promo_list', methods: ['GET'])] +
+
+ 23 + +
+ - + public function list(Request $request): JsonResponse +
+
+ 24 + +
+   + { +
+
+ 25 + +
+ - + $regionId = $request->query->getInt('regionId', 0); +
+
+ 26 + +
+ - + $activeParam = $request->query->get('active'); +
+
+ 27 + +
+ - + $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); +
+
+ 28 + +
+ - + if ($activeParam !== null && $active === null) { +
+
+ 29 + +
+ - + return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); +
+
+ 30 + +
+ - + } +
+
+ 31 + +
+   +
+
+
+ 32 + +
+ - + return $this->json(['data' => $this->promoCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [ +
+
+ 33 + +
+ - + 'groups' => ['promo:read'], +
+
+ 34 + +
+   + ]); +
+
+ 35 + +
+   + } +
+
+ 36 + +
+   +
+
+
+ 37 + +
+   + #[Route('/{id}', name: 'promo_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 38 + +
+   + public function show(Promo $promo): JsonResponse +
+
+ 39 + +
+   + { +
+
+ 40 + +
+ - + return $this->json($promo, Response::HTTP_OK, [], [ +
+
+ 41 + +
+ - + 'groups' => ['promo:read'], +
+
+ 42 + +
+ - + ]); +
+
+ 43 + +
+   + } +
+
+ 44 + +
+   +
+
+
+ 45 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 46 + +
+   + #[Route('/create', name: 'promo_create', methods: ['POST'])] +
+
+ 47 + +
+   + public function create(Request $request): JsonResponse +
+
+ 48 + +
+   + { +
+
+ 49 + +
+ - + $data = json_decode($request->getContent(), true); +
+
+ 50 + +
+ - + if (!is_array($data)) { +
+
+ 51 + +
+ - + return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +
+
+ 52 + +
+ - + } +
+
+ 53 + +
+ - +
+
+
+ 54 + +
+ - + $promo = $this->promoCrud->create($data); +
+
+ 55 + +
+ - +
+
+
+ 56 + +
+ - + return $this->json($promo, Response::HTTP_CREATED, [], [ +
+
+ 57 + +
+ - + 'groups' => ['promo:read'], +
+
+ 58 + +
+ - + ]); +
+
+ 59 + +
+   + } +
+
+ 60 + +
+   +
+
+
+ 61 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 62 + +
+   + #[Route('/{id}', name: 'promo_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 63 + +
+ - + public function update(Promo $promo, Request $request): JsonResponse +
+
+ 64 + +
+   + { +
+
+ 65 + +
+ - + $data = json_decode($request->getContent(), true); +
+
+ 66 + +
+ - + if (!is_array($data)) { +
+
+ 67 + +
+ - + return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +
+
+ 68 + +
+ - + } +
+
+ 69 + +
+ - +
+
+
+ 70 + +
+ - + $promo = $this->promoCrud->update($promo, $data); +
+
+ 71 + +
+ - +
+
+
+ 72 + +
+ - + return $this->json($promo, Response::HTTP_OK, [], [ +
+
+ 73 + +
+ - + 'groups' => ['promo:read'], +
+
+ 74 + +
+ - + ]); +
+
+ 75 + +
+   + } +
+
+ 76 + +
+   +
+
+
+ 77 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 78 + +
+   + #[Route('/{id}', name: 'promo_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 79 + +
+   + public function delete(Promo $promo): JsonResponse +
+
+ 80 + +
+   + { +
+
+ 81 + +
+ - + $this->promoCrud->delete($promo); +
+
+ 82 + +
+ - +
+
+
+ 83 + +
+ - + return new JsonResponse(null, Response::HTTP_NO_CONTENT); +
+
+ 84 + +
+   + } +
+
+ 85 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\Promo; +
+
+ 7 + +
+ + + use App\Repository\PromoRepository; +
+
+ 8 + +
+ + + use App\Service\Crud\CrudResponder; +
+
+ 9 + +
+ + + use App\Service\Pagination\Paginator; +
+
+ 10 + +
+ + + use Nelmio\ApiDocBundle\Attribute\Model; +
+
+ 11 + +
+ + + use OpenApi\Attributes as OA; +
+
+ 12 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 13 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 14 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+
 
+
+ 19 + +
+   + #[Route('/promo')] +
+
+ 20 + +
+   + final class PromoController extends AbstractController +
+
+ 21 + +
+   + { +
+
+ 22 + +
+ + + private const READ_GROUPS = ['promo:read']; +
+
+ 23 + +
+ + + private const WRITE_GROUPS = ['promo:write']; +
+
+ 24 + +
+ + +
+
+
+ 25 + +
+   + public function __construct( +
+
+ 26 + +
+ + + private readonly CrudResponder $crud, +
+
+ 27 + +
+ + + private readonly Paginator $paginator, +
+
+ 28 + +
+   + ) { +
+
+ 29 + +
+   + } +
+
+ 30 + +
+   +
+
+
+ 31 + +
+ + + #[OA\Tag(name: 'Акции')] +
+
+ 32 + +
+ + + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 33 + +
+ + + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 34 + +
+ + + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 35 + +
+ + + #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] +
+
+ 36 + +
+ + + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] +
+
+ 37 + +
+   + #[Route('/list', name: 'promo_list', methods: ['GET'])] +
+
+ 38 + +
+ + + public function list(Request $request, PromoRepository $repository): JsonResponse +
+
+ 39 + +
+   + { +
+
+ 40 + +
+ + + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 41 + +
+   +
+
+
+ 42 + +
+ + + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ +
+
+ 43 + +
+ + + 'groups' => self::READ_GROUPS, +
+
+ 44 + +
+   + ]); +
+
+ 45 + +
+   + } +
+
+ 46 + +
+   +
+
+
+ 47 + +
+   + #[Route('/{id}', name: 'promo_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 48 + +
+   + public function show(Promo $promo): JsonResponse +
+
+ 49 + +
+   + { +
+
+ 50 + +
+ + + return $this->crud->read($promo, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 51 + +
+   + } +
+
+ 52 + +
+   +
+
+
+ 53 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 54 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Promo::class, groups: self::WRITE_GROUPS)))] +
+
+ 55 + +
+   + #[Route('/create', name: 'promo_create', methods: ['POST'])] +
+
+ 56 + +
+   + public function create(Request $request): JsonResponse +
+
+ 57 + +
+   + { +
+
+ 58 + +
+ + + return $this->crud->create($request, Promo::class, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 59 + +
+   + } +
+
+ 60 + +
+   +
+
+
+ 61 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 62 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Promo::class, groups: self::WRITE_GROUPS)))] +
+
+ 63 + +
+   + #[Route('/{id}', name: 'promo_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 64 + +
+ + + public function update(Request $request, Promo $promo): JsonResponse +
+
+ 65 + +
+   + { +
+
+ 66 + +
+ + + return $this->crud->update($request, $promo, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 67 + +
+   + } +
+
+ 68 + +
+   +
+
+
+ 69 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 70 + +
+   + #[Route('/{id}', name: 'promo_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 71 + +
+   + public function delete(Promo $promo): JsonResponse +
+
+ 72 + +
+   + { +
+
+ 73 + +
+ + + return $this->crud->delete($promo); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 74 + +
+   + } +
+
+ 75 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Controller/SiteServiceController.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,13 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\SiteService; +
+
+ 6 + +
+ - + use App\Service\SiteServiceCrudService; +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 7 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 8 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 9 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+
@@ -14,91 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
+
+ 14 + +
+   + #[Route('/site-services')] +
+
+ 15 + +
+   + final class SiteServiceController extends AbstractController +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + public function __construct( +
+
+ 18 + +
+ - + private SiteServiceCrudService $siteServiceCrud, +
+
+ + +
+   +
+
+
+ 19 + +
+   + ) { +
+
+ 20 + +
+   + } +
+
+ 21 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 22 + +
+   + #[Route('/list', name: 'site_service_list', methods: ['GET'])] +
+
+ 23 + +
+ - + public function list(Request $request): JsonResponse +
+
+ 24 + +
+   + { +
+
+ 25 + +
+ - + $page = $request->query->getInt('page', 1); +
+
+ 26 + +
+ - + $perPage = min($request->query->getInt('perPage', 50), 500); +
+
+ 27 + +
+ - + $regionId = $request->query->getInt('regionId', 0) ?: null; +
+
+ 28 + +
+ - + $activeParam = $request->query->get('active'); +
+
+ 29 + +
+ - + $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); +
+
+ 30 + +
+ - + if ($activeParam !== null && $active === null) { +
+
+ 31 + +
+ - + return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); +
+
+ 32 + +
+ - + } +
+
+ 33 + +
+ - +
+
+
+ 34 + +
+ - + $result = $this->siteServiceCrud->getPaginatedList($page, $perPage, $regionId, $active); +
+
+ 35 + +
+ - + $data = $result['data']; +
+
+ 36 + +
+ - + $total = $result['total']; +
+
+ 37 + +
+ - + $perPage = $result['per_page']; +
+
+ 38 + +
+ - + $totalPages = (int) ceil($total / $perPage); +
+
+ 39 + +
+   +
+
+
+ 40 + +
+ - + return $this->json([ +
+
+ 41 + +
+ - + 'data' => $data, +
+
+ 42 + +
+ - + 'pagination' => [ +
+
+ 43 + +
+ - + 'total' => $total, +
+
+ 44 + +
+ - + 'count' => count($data), +
+
+ 45 + +
+ - + 'per_page' => $perPage, +
+
+ 46 + +
+ - + 'current_page' => $result['page'], +
+
+ 47 + +
+ - + 'total_pages' => $totalPages, +
+
+ 48 + +
+ - + 'has_previous_page' => $result['page'] > 1, +
+
+ 49 + +
+ - + 'has_next_page' => $result['page'] < $totalPages, +
+
+ 50 + +
+ - + ], +
+
+ 51 + +
+ - + ], Response::HTTP_OK, [], [ +
+
+ 52 + +
+ - + 'groups' => ['site_service:read'], +
+
+ 53 + +
+   + ]); +
+
+ 54 + +
+   + } +
+
+ 55 + +
+   +
+
+
+ 56 + +
+   + #[Route('/{id}', name: 'site_service_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 57 + +
+   + public function show(SiteService $siteService): JsonResponse +
+
+ 58 + +
+   + { +
+
+ 59 + +
+ - + return $this->json($siteService, Response::HTTP_OK, [], [ +
+
+ 60 + +
+ - + 'groups' => ['site_service:read'], +
+
+ 61 + +
+ - + ]); +
+
+ 62 + +
+   + } +
+
+ 63 + +
+   +
+
+
+ 64 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 65 + +
+   + #[Route('/create', name: 'site_service_create', methods: ['POST'])] +
+
+ 66 + +
+   + public function create(Request $request): JsonResponse +
+
+ 67 + +
+   + { +
+
+ 68 + +
+ - + $data = json_decode($request->getContent(), true); +
+
+ 69 + +
+ - + if (!is_array($data)) { +
+
+ 70 + +
+ - + return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +
+
+ 71 + +
+ - + } +
+
+ 72 + +
+ - +
+
+
+ 73 + +
+ - + $siteService = $this->siteServiceCrud->create($data); +
+
+ 74 + +
+ - +
+
+
+ 75 + +
+ - + return $this->json($siteService, Response::HTTP_CREATED, [], [ +
+
+ 76 + +
+ - + 'groups' => ['site_service:read'], +
+
+ 77 + +
+ - + ]); +
+
+ 78 + +
+   + } +
+
+ 79 + +
+   +
+
+
+ 80 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ + +
+   +
+
+
+ 81 + +
+   + #[Route('/{id}', name: 'site_service_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 82 + +
+ - + public function update(SiteService $siteService, Request $request): JsonResponse +
+
+ 83 + +
+   + { +
+
+ 84 + +
+ - + $data = json_decode($request->getContent(), true); +
+
+ 85 + +
+ - + if (!is_array($data)) { +
+
+ 86 + +
+ - + return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +
+
+ 87 + +
+ - + } +
+
+ 88 + +
+ - +
+
+
+ 89 + +
+ - + $siteService = $this->siteServiceCrud->update($siteService, $data); +
+
+ 90 + +
+ - +
+
+
+ 91 + +
+ - + return $this->json($siteService, Response::HTTP_OK, [], [ +
+
+ 92 + +
+ - + 'groups' => ['site_service:read'], +
+
+ 93 + +
+ - + ]); +
+
+ 94 + +
+   + } +
+
+ 95 + +
+   +
+
+
+ 96 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 97 + +
+   + #[Route('/{id}', name: 'site_service_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 98 + +
+   + public function delete(SiteService $siteService): JsonResponse +
+
+ 99 + +
+   + { +
+
+ 100 + +
+ - + $this->siteServiceCrud->delete($siteService); +
+
+ 101 + +
+ - +
+
+
+ 102 + +
+ - + return new JsonResponse(null, Response::HTTP_NO_CONTENT); +
+
+ 103 + +
+   + } +
+
+ 104 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Controller; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\SiteService; +
+
+ 7 + +
+ + + use App\Repository\SiteServiceRepository; +
+
+ 8 + +
+ + + use App\Service\Crud\CrudResponder; +
+
+ 9 + +
+ + + use App\Service\Pagination\Paginator; +
+
+ 10 + +
+ + + use Nelmio\ApiDocBundle\Attribute\Model; +
+
+ 11 + +
+ + + use OpenApi\Attributes as OA; +
+
+ 12 + +
+   + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +
+
+ 13 + +
+   + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 14 + +
+   + use Symfony\Component\HttpFoundation\Request; +
+
+
 
+
+ 19 + +
+   + #[Route('/site-services')] +
+
+ 20 + +
+   + final class SiteServiceController extends AbstractController +
+
+ 21 + +
+   + { +
+
+ 22 + +
+ + + private const READ_GROUPS = ['site_service:read']; +
+
+ 23 + +
+ + + private const WRITE_GROUPS = ['site_service:write']; +
+
+ 24 + +
+ + +
+
+
+ 25 + +
+   + public function __construct( +
+
+ 26 + +
+ + + private readonly CrudResponder $crud, +
+
+ 27 + +
+ + + private readonly Paginator $paginator, +
+
+ 28 + +
+   + ) { +
+
+ 29 + +
+   + } +
+
+ 30 + +
+   +
+
+
+ 31 + +
+ + + #[OA\Tag(name: 'Услуги')] +
+
+ 32 + +
+ + + #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 33 + +
+ + + #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 34 + +
+ + + #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] +
+
+ 35 + +
+ + + #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] +
+
+ 36 + +
+ + + #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] +
+
+ 37 + +
+   + #[Route('/list', name: 'site_service_list', methods: ['GET'])] +
+
+ 38 + +
+ + + public function list(Request $request, SiteServiceRepository $repository): JsonResponse +
+
+ 39 + +
+   + { +
+
+ 40 + +
+ + + $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 41 + +
+   +
+
+
+ 42 + +
+ + + return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ +
+
+ 43 + +
+ + + 'groups' => self::READ_GROUPS, +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 44 + +
+   + ]); +
+
+ 45 + +
+   + } +
+
+ 46 + +
+   +
+
+
+ 47 + +
+   + #[Route('/{id}', name: 'site_service_show', methods: ['GET'], requirements: ['id' => '\d+'])] +
+
+ 48 + +
+   + public function show(SiteService $siteService): JsonResponse +
+
+ 49 + +
+   + { +
+
+ 50 + +
+ + + return $this->crud->read($siteService, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 51 + +
+   + } +
+
+ 52 + +
+   +
+
+
+ 53 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 54 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: SiteService::class, groups: self::WRITE_GROUPS)))] +
+
+ 55 + +
+   + #[Route('/create', name: 'site_service_create', methods: ['POST'])] +
+
+ 56 + +
+   + public function create(Request $request): JsonResponse +
+
+ 57 + +
+   + { +
+
+ 58 + +
+ + + return $this->crud->create($request, SiteService::class, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 59 + +
+   + } +
+
+ 60 + +
+   +
+
+
+ 61 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 62 + +
+ + + #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: SiteService::class, groups: self::WRITE_GROUPS)))] +
+
+ 63 + +
+   + #[Route('/{id}', name: 'site_service_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +
+
+ 64 + +
+ + + public function update(Request $request, SiteService $siteService): JsonResponse +
+
+ 65 + +
+   + { +
+
+ 66 + +
+ + + return $this->crud->update($request, $siteService, self::WRITE_GROUPS, self::READ_GROUPS); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 67 + +
+   + } +
+
+ 68 + +
+   +
+
+
+ 69 + +
+   + #[IsGranted('ROLE_ADMIN')] +
+
+ 70 + +
+   + #[Route('/{id}', name: 'site_service_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] +
+
+ 71 + +
+   + public function delete(SiteService $siteService): JsonResponse +
+
+ 72 + +
+   + { +
+
+ 73 + +
+ + + return $this->crud->delete($siteService); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 74 + +
+   + } +
+
+ 75 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Dto/Content/ContentFilterDto.php + ADDED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -0,0 +1,81 @@
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 1 + +
+ + + <?php +
+
+ 2 + +
+ + +
+
+
+ 3 + +
+ + + declare(strict_types=1); +
+
+ 4 + +
+ + +
+
+
+ 5 + +
+ + + namespace App\Dto\Content; +
+
+ 6 + +
+ + +
+
+
+ 7 + +
+ + + use Symfony\Component\HttpFoundation\Request; +
+
+ 8 + +
+ + +
+
+
+ 9 + +
+ + + final readonly class ContentFilterDto +
+
+ 10 + +
+ + + { +
+
+ 11 + +
+ + + public function __construct( +
+
+ 12 + +
+ + + public ?int $regionId = null, +
+
+ 13 + +
+ + + public ?bool $active = null, +
+
+ 14 + +
+ + + public ?string $alias = null, +
+
+ 15 + +
+ + + public ?string $search = null, +
+
+ 16 + +
+ + + ) { +
+
+ 17 + +
+ + + } +
+
+ 18 + +
+ + +
+
+
+ 19 + +
+ + + /** +
+
+ 20 + +
+ + + * @param ?bool $defaultActive если задан (например, true), подставляется, +
+
+ 21 + +
+ + + * когда query-параметр `active` отсутствует или пустой. +
+
+ 22 + +
+ + + * Легаси: в старых list-эндпоинтах News/Promo/MedicalCenter/SiteService +
+
+ 23 + +
+ + + * при отсутствии `active` подразумевалось active = true. +
+
+ 24 + +
+ + + */ +
+
+ 25 + +
+ + + public static function fromRequest(Request $request, ?bool $defaultActive = null): self +
+
+ 26 + +
+ + + { +
+
+ 27 + +
+ + + $active = self::nullableBool($request->query->get('active')); +
+
+ 28 + +
+ + +
+
+
+ 29 + +
+ + + return new self( +
+
+ 30 + +
+ + + regionId: self::positiveInt($request->query->get('regionId', $request->query->get('region_id'))), +
+
+ 31 + +
+ + + active: $active ?? $defaultActive, +
+
+ 32 + +
+ + + alias: self::nonEmptyString($request->query->get('alias')), +
+
+ 33 + +
+ + + search: self::nonEmptyString($request->query->get('search', $request->query->get('q'))), +
+
+ 34 + +
+ + + ); +
+
+ 35 + +
+ + + } +
+
+ 36 + +
+ + +
+
+
+ 37 + +
+ + + /** +
+
+ 38 + +
+ + + * Symfony QueryBag может отдать массив при ?regionId[]=… — не передаём его в is_numeric (TypeError в PHP 8). +
+
+ 39 + +
+ + + */ +
+
+ 40 + +
+ + + private static function positiveInt(mixed $value): ?int +
+
+ 41 + +
+ + + { +
+
+ 42 + +
+ + + if ($value === null || $value === '' || !is_scalar($value) || !is_numeric($value)) { +
+
+ 43 + +
+ + + return null; +
+
+ 44 + +
+ + + } +
+
+ 45 + +
+ + +
+
+
+ 46 + +
+ + + $value = (int) $value; +
+
+ 47 + +
+ + +
+
+
+ 48 + +
+ + + return $value > 0 ? $value : null; +
+
+ 49 + +
+ + + } +
+
+ 50 + +
+ + +
+
+
+ 51 + +
+ + + /** +
+
+ 52 + +
+ + + * При ?active[]=… query->get вернёт массив — отбрасываем без вызова filter_var по нему. +
+
+ 53 + +
+ + + */ +
+
+ 54 + +
+ + + private static function nullableBool(mixed $value): ?bool +
+
+ 55 + +
+ + + { +
+
+ 56 + +
+ + + if ($value === null || $value === '') { +
+
+ 57 + +
+ + + return null; +
+
+ 58 + +
+ + + } +
+
+ 59 + +
+ + +
+
+
+ 60 + +
+ + + if (!is_scalar($value)) { +
+
+ 61 + +
+ + + return null; +
+
+ 62 + +
+ + + } +
+
+ 63 + +
+ + +
+
+
+ 64 + +
+ + + if (is_bool($value)) { +
+
+ 65 + +
+ + + return $value; +
+
+ 66 + +
+ + + } +
+
+ 67 + +
+ + +
+
+
+ 68 + +
+ + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); +
+
+ 69 + +
+ + + } +
+
+ 70 + +
+ + +
+
+
+ 71 + +
+ + + private static function nonEmptyString(mixed $value): ?string +
+
+ 72 + +
+ + + { +
+
+ 73 + +
+ + + if (!is_string($value)) { +
+
+ 74 + +
+ + + return null; +
+
+ 75 + +
+ + + } +
+
+ 76 + +
+ + +
+
+
+ 77 + +
+ + + $value = trim($value); +
+
+ 78 + +
+ + +
+
+
+ 79 + +
+ + + return $value !== '' ? $value : null; +
+
+ 80 + +
+ + + } +
+
+ 81 + +
+ + + } +
+
+
+
+
+
+
+
+ + src/Entity/Article.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,6 +2,7 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Repository\ArticleRepository; +
+
+ 6 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 7 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
@@ -12,8 +13,11 @@ use Symfony\Component\Validator\Constraints as Assert;
+
+ 12 + +
+   + #[ORM\Table(name: 'article')] +
+
+ 13 + +
+   + #[ORM\Index(name: 'idx_article_region_id', columns: ['region_id'])] +
+
+ 14 + +
+   + #[ORM\Index(name: 'idx_article_active', columns: ['active'])] +
+
+ + +
+   +
+
+
+ 15 + +
+   + class Article +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + #[Groups(['article:read'])] +
+
+ 18 + +
+   + #[ORM\Id] +
+
+ 19 + +
+   + #[ORM\GeneratedValue(strategy: "IDENTITY")] +
+
+
@@ -56,7 +60,7 @@ class Article
+
+ 56 + +
+   + #[ORM\Column(type: Types::TEXT, nullable: true)] +
+
+ 57 + +
+   + private ?string $content = null; +
+
+ 58 + +
+   +
+
+
+ 59 + +
+ - + #[Groups(['article:read', 'article:write'])] +
+
+ 60 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 61 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 62 + +
+   +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Entity\Behavior\UpdateTimestampTrait; +
+
+ 6 + +
+   + use App\Repository\ArticleRepository; +
+
+ 7 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 8 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
 
+
+ 13 + +
+   + #[ORM\Table(name: 'article')] +
+
+ 14 + +
+   + #[ORM\Index(name: 'idx_article_region_id', columns: ['region_id'])] +
+
+ 15 + +
+   + #[ORM\Index(name: 'idx_article_active', columns: ['active'])] +
+
+ 16 + +
+ + + #[ORM\HasLifecycleCallbacks] +
+
+ 17 + +
+   + class Article +
+
+ 18 + +
+   + { +
+
+ 19 + +
+ + + use UpdateTimestampTrait; +
+
+ 20 + +
+ + +
+
+
+ 21 + +
+   + #[Groups(['article:read'])] +
+
+ 22 + +
+   + #[ORM\Id] +
+
+ 23 + +
+   + #[ORM\GeneratedValue(strategy: "IDENTITY")] +
+
+
 
+
+ 60 + +
+   + #[ORM\Column(type: Types::TEXT, nullable: true)] +
+
+ 61 + +
+   + private ?string $content = null; +
+
+ 62 + +
+   +
+
+
+ 63 + +
+ + + #[Groups(['article:read'])] +
+
+ 64 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 65 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 66 + +
+   +
+
+
+
+
+
+
+
+
+ + src/Entity/Behavior/UpdateTimestampTrait.php + ADDED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -0,0 +1,29 @@
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 1 + +
+ + + <?php +
+
+ 2 + +
+ + +
+
+
+ 3 + +
+ + + declare(strict_types=1); +
+
+ 4 + +
+ + +
+
+
+ 5 + +
+ + + namespace App\Entity\Behavior; +
+
+ 6 + +
+ + +
+
+
+ 7 + +
+ + + use Doctrine\ORM\Mapping as ORM; +
+
+ 8 + +
+ + +
+
+
+ 9 + +
+ + + /** +
+
+ 10 + +
+ + + * Требует у класса-сущности свойство `$updateAt` (mapped column). +
+
+ 11 + +
+ + + * +
+
+ 12 + +
+ + + * @property \DateTimeInterface|null $updateAt +
+
+ 13 + +
+ + + */ +
+
+ 14 + +
+ + + trait UpdateTimestampTrait +
+
+ 15 + +
+ + + { +
+
+ 16 + +
+ + + #[ORM\PrePersist] +
+
+ 17 + +
+ + + public function setInitialUpdateAt(): void +
+
+ 18 + +
+ + + { +
+
+ 19 + +
+ + + if ($this->updateAt === null) { +
+
+ 20 + +
+ + + $this->updateAt = new \DateTimeImmutable(); +
+
+ 21 + +
+ + + } +
+
+ 22 + +
+ + + } +
+
+ 23 + +
+ + +
+
+
+ 24 + +
+ + + #[ORM\PreUpdate] +
+
+ 25 + +
+ + + public function refreshUpdateAt(): void +
+
+ 26 + +
+ + + { +
+
+ 27 + +
+ + + $this->updateAt = new \DateTimeImmutable(); +
+
+ 28 + +
+ + + } +
+
+ 29 + +
+ + + } +
+
+
+
+
+
+
+
+ + src/Entity/Disease.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,6 +2,7 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Repository\DiseaseRepository; +
+
+ 6 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 7 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
@@ -11,10 +12,14 @@ use Symfony\Component\Serializer\Annotation\Groups;
+
+ 11 + +
+   + #[ORM\Table(name: 'disease')] +
+
+ 12 + +
+   + #[ORM\Index(name: 'idx_disease_region_id', columns: ['region_id'])] +
+
+ 13 + +
+   + #[ORM\Index(name: 'idx_disease_active', columns: ['active'])] +
+
+ + +
+   +
+
+
+ 14 + +
+   + class Disease +
+
+ 15 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 16 + +
+   + #[Groups(['disease:read'])] +
+
+ 17 + +
+   + #[ORM\Id] +
+
+ + +
+   +
+
+
+ 18 + +
+   + #[ORM\Column(type: Types::INTEGER)] +
+
+ 19 + +
+   + private ?int $id = null; +
+
+ 20 + +
+   +
+
+
+
@@ -42,7 +47,7 @@ class Disease
+
+ 42 + +
+   + #[ORM\Column(type: Types::TEXT, nullable: true)] +
+
+ 43 + +
+   + private ?string $anons = null; +
+
+ 44 + +
+   +
+
+
+ 45 + +
+ - + #[Groups(['disease:read', 'disease:write'])] +
+
+ 46 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 47 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 48 + +
+   +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Entity\Behavior\UpdateTimestampTrait; +
+
+ 6 + +
+   + use App\Repository\DiseaseRepository; +
+
+ 7 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 8 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
 
+
+ 12 + +
+   + #[ORM\Table(name: 'disease')] +
+
+ 13 + +
+   + #[ORM\Index(name: 'idx_disease_region_id', columns: ['region_id'])] +
+
+ 14 + +
+   + #[ORM\Index(name: 'idx_disease_active', columns: ['active'])] +
+
+ 15 + +
+ + + #[ORM\HasLifecycleCallbacks] +
+
+ 16 + +
+   + class Disease +
+
+ 17 + +
+   + { +
+
+ 18 + +
+ + + use UpdateTimestampTrait; +
+
+ 19 + +
+ + +
+
+
+ 20 + +
+   + #[Groups(['disease:read'])] +
+
+ 21 + +
+   + #[ORM\Id] +
+
+ 22 + +
+ + + #[ORM\GeneratedValue(strategy: "IDENTITY")] +
+
+ 23 + +
+   + #[ORM\Column(type: Types::INTEGER)] +
+
+ 24 + +
+   + private ?int $id = null; +
+
+ 25 + +
+   +
+
+
+
 
+
+ 47 + +
+   + #[ORM\Column(type: Types::TEXT, nullable: true)] +
+
+ 48 + +
+   + private ?string $anons = null; +
+
+ 49 + +
+   +
+
+
+ 50 + +
+ + + #[Groups(['disease:read'])] +
+
+ 51 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 52 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 53 + +
+   +
+
+
+
+
+
+
+
+
+ + src/Entity/MedicalCenter.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,6 +2,7 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Repository\MedicalCenterRepository; +
+
+ 6 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 7 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
@@ -9,9 +10,13 @@ use Symfony\Component\Serializer\Annotation\Groups;
+
+ 9 + +
+   +
+
+
+ 10 + +
+   + #[ORM\Entity(repositoryClass: MedicalCenterRepository::class)] +
+
+ 11 + +
+   + #[ORM\Table(name: 'medical_center')] +
+
+ + +
+   +
+
+
+ 12 + +
+   + class MedicalCenter +
+
+ 13 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 14 + +
+   + #[ORM\Id] +
+
+ + +
+   +
+
+
+ 15 + +
+   + #[ORM\Column(type: Types::INTEGER)] +
+
+ 16 + +
+   + #[Groups(['medical_center:read'])] +
+
+ 17 + +
+   + private ?int $id = null; +
+
+
@@ -41,7 +46,7 @@ class MedicalCenter
+
+ 41 + +
+   + private ?string $content = null; +
+
+ 42 + +
+   +
+
+
+ 43 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 44 + +
+ - + #[Groups(['medical_center:read', 'medical_center:write'])] +
+
+ 45 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 46 + +
+   +
+
+
+ 47 + +
+   + #[ORM\Column(name: 'kod_uslug', type: 'jsonb', nullable: true)] +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Entity\Behavior\UpdateTimestampTrait; +
+
+ 6 + +
+   + use App\Repository\MedicalCenterRepository; +
+
+ 7 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 8 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
 
+
+ 10 + +
+   +
+
+
+ 11 + +
+   + #[ORM\Entity(repositoryClass: MedicalCenterRepository::class)] +
+
+ 12 + +
+   + #[ORM\Table(name: 'medical_center')] +
+
+ 13 + +
+ + + #[ORM\HasLifecycleCallbacks] +
+
+ 14 + +
+   + class MedicalCenter +
+
+ 15 + +
+   + { +
+
+ 16 + +
+ + + use UpdateTimestampTrait; +
+
+ 17 + +
+ + +
+
+
+ 18 + +
+   + #[ORM\Id] +
+
+ 19 + +
+ + + #[ORM\GeneratedValue(strategy: "IDENTITY")] +
+
+ 20 + +
+   + #[ORM\Column(type: Types::INTEGER)] +
+
+ 21 + +
+   + #[Groups(['medical_center:read'])] +
+
+ 22 + +
+   + private ?int $id = null; +
+
+
 
+
+ 46 + +
+   + private ?string $content = null; +
+
+ 47 + +
+   +
+
+
+ 48 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 49 + +
+ + + #[Groups(['medical_center:read'])] +
+
+ 50 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 51 + +
+   +
+
+
+ 52 + +
+   + #[ORM\Column(name: 'kod_uslug', type: 'jsonb', nullable: true)] +
+
+
+
+
+
+
+
+ + src/Entity/News.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,6 +2,7 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Repository\NewsRepository; +
+
+ 6 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 7 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
@@ -11,9 +12,13 @@ use Symfony\Component\Serializer\Annotation\Groups;
+
+ 11 + +
+   + #[ORM\Table(name: 'news')] +
+
+ 12 + +
+   + #[ORM\Index(name: 'idx_news_region_id', columns: ['region_id'])] +
+
+ 13 + +
+   + #[ORM\Index(name: 'idx_news_active', columns: ['active'])] +
+
+ + +
+   +
+
+
+ 14 + +
+   + class News +
+
+ 15 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 16 + +
+   + #[ORM\Id] +
+
+ + +
+   +
+
+
+ 17 + +
+   + #[ORM\Column(type: Types::INTEGER)] +
+
+ 18 + +
+   + #[Groups(['news:read'])] +
+
+ 19 + +
+   + private ?int $id = null; +
+
+
@@ -43,7 +48,7 @@ class News
+
+ 43 + +
+   + private ?string $content = null; +
+
+ 44 + +
+   +
+
+
+ 45 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 46 + +
+ - + #[Groups(['news:read', 'news:write'])] +
+
+ 47 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 48 + +
+   +
+
+
+ 49 + +
+   + #[ORM\Column(name: 'link_el_price', type: Types::TEXT, nullable: true)] +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Entity\Behavior\UpdateTimestampTrait; +
+
+ 6 + +
+   + use App\Repository\NewsRepository; +
+
+ 7 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 8 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
 
+
+ 12 + +
+   + #[ORM\Table(name: 'news')] +
+
+ 13 + +
+   + #[ORM\Index(name: 'idx_news_region_id', columns: ['region_id'])] +
+
+ 14 + +
+   + #[ORM\Index(name: 'idx_news_active', columns: ['active'])] +
+
+ 15 + +
+ + + #[ORM\HasLifecycleCallbacks] +
+
+ 16 + +
+   + class News +
+
+ 17 + +
+   + { +
+
+ 18 + +
+ + + use UpdateTimestampTrait; +
+
+ 19 + +
+ + +
+
+
+ 20 + +
+   + #[ORM\Id] +
+
+ 21 + +
+ + + #[ORM\GeneratedValue(strategy: "IDENTITY")] +
+
+ 22 + +
+   + #[ORM\Column(type: Types::INTEGER)] +
+
+ 23 + +
+   + #[Groups(['news:read'])] +
+
+ 24 + +
+   + private ?int $id = null; +
+
+
 
+
+ 48 + +
+   + private ?string $content = null; +
+
+ 49 + +
+   +
+
+
+ 50 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 51 + +
+ + + #[Groups(['news:read'])] +
+
+ 52 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 53 + +
+   +
+
+
+ 54 + +
+   + #[ORM\Column(name: 'link_el_price', type: Types::TEXT, nullable: true)] +
+
+
+
+
+
+
+
+ + src/Entity/Promo.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,6 +2,7 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Repository\PromoRepository; +
+
+ 6 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 7 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
@@ -11,9 +12,13 @@ use Symfony\Component\Serializer\Annotation\Groups;
+
+ 11 + +
+   + #[ORM\Table(name: 'promo')] +
+
+ 12 + +
+   + #[ORM\Index(name: 'idx_promo_region_id', columns: ['region_id'])] +
+
+ 13 + +
+   + #[ORM\Index(name: 'idx_promo_active', columns: ['active'])] +
+
+ + +
+   +
+
+
+ 14 + +
+   + class Promo +
+
+ 15 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 16 + +
+   + #[ORM\Id] +
+
+ + +
+   +
+
+
+ 17 + +
+   + #[ORM\Column(type: Types::INTEGER)] +
+
+ 18 + +
+   + #[Groups(['promo:read'])] +
+
+ 19 + +
+   + private ?int $id = null; +
+
+
@@ -43,7 +48,7 @@ class Promo
+
+ 43 + +
+   + private ?string $content = null; +
+
+ 44 + +
+   +
+
+
+ 45 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 46 + +
+ - + #[Groups(['promo:read', 'promo:write'])] +
+
+ 47 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 48 + +
+   +
+
+
+ 49 + +
+   + #[ORM\Column(type: 'jsonb', nullable: true)] +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Entity\Behavior\UpdateTimestampTrait; +
+
+ 6 + +
+   + use App\Repository\PromoRepository; +
+
+ 7 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 8 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
 
+
+ 12 + +
+   + #[ORM\Table(name: 'promo')] +
+
+ 13 + +
+   + #[ORM\Index(name: 'idx_promo_region_id', columns: ['region_id'])] +
+
+ 14 + +
+   + #[ORM\Index(name: 'idx_promo_active', columns: ['active'])] +
+
+ 15 + +
+ + + #[ORM\HasLifecycleCallbacks] +
+
+ 16 + +
+   + class Promo +
+
+ 17 + +
+   + { +
+
+ 18 + +
+ + + use UpdateTimestampTrait; +
+
+ 19 + +
+ + +
+
+
+ 20 + +
+   + #[ORM\Id] +
+
+ 21 + +
+ + + #[ORM\GeneratedValue(strategy: "IDENTITY")] +
+
+ 22 + +
+   + #[ORM\Column(type: Types::INTEGER)] +
+
+ 23 + +
+   + #[Groups(['promo:read'])] +
+
+ 24 + +
+   + private ?int $id = null; +
+
+
 
+
+ 48 + +
+   + private ?string $content = null; +
+
+ 49 + +
+   +
+
+
+ 50 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 51 + +
+ + + #[Groups(['promo:read'])] +
+
+ 52 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 53 + +
+   +
+
+
+ 54 + +
+   + #[ORM\Column(type: 'jsonb', nullable: true)] +
+
+
+
+
+
+
+
+ + src/Entity/SiteService.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,6 +2,7 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Repository\SiteServiceRepository; +
+
+ 6 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 7 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
@@ -11,9 +12,13 @@ use Symfony\Component\Serializer\Annotation\Groups;
+
+ 11 + +
+   + #[ORM\Table(name: 'site_services')] +
+
+ 12 + +
+   + #[ORM\Index(name: 'idx_site_services_region_id', columns: ['region_id'])] +
+
+ 13 + +
+   + #[ORM\Index(name: 'idx_site_services_active', columns: ['active'])] +
+
+ + +
+   +
+
+
+ 14 + +
+   + class SiteService +
+
+ 15 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 16 + +
+   + #[ORM\Id] +
+
+ + +
+   +
+
+
+ 17 + +
+   + #[ORM\Column(type: Types::INTEGER)] +
+
+ 18 + +
+   + #[Groups(['site_service:read'])] +
+
+ 19 + +
+   + private ?int $id = null; +
+
+
@@ -43,7 +48,7 @@ class SiteService
+
+ 43 + +
+   + private ?string $content = null; +
+
+ 44 + +
+   +
+
+
+ 45 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 46 + +
+ - + #[Groups(['site_service:read', 'site_service:write'])] +
+
+ 47 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 48 + +
+   +
+
+
+ 49 + +
+   + #[ORM\Column(name: 'link_videoreviews', type: 'jsonb', nullable: true)] +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Entity; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Entity\Behavior\UpdateTimestampTrait; +
+
+ 6 + +
+   + use App\Repository\SiteServiceRepository; +
+
+ 7 + +
+   + use Doctrine\DBAL\Types\Types; +
+
+ 8 + +
+   + use Doctrine\ORM\Mapping as ORM; +
+
+
 
+
+ 12 + +
+   + #[ORM\Table(name: 'site_services')] +
+
+ 13 + +
+   + #[ORM\Index(name: 'idx_site_services_region_id', columns: ['region_id'])] +
+
+ 14 + +
+   + #[ORM\Index(name: 'idx_site_services_active', columns: ['active'])] +
+
+ 15 + +
+ + + #[ORM\HasLifecycleCallbacks] +
+
+ 16 + +
+   + class SiteService +
+
+ 17 + +
+   + { +
+
+ 18 + +
+ + + use UpdateTimestampTrait; +
+
+ 19 + +
+ + +
+
+
+ 20 + +
+   + #[ORM\Id] +
+
+ 21 + +
+ + + #[ORM\GeneratedValue(strategy: "IDENTITY")] +
+
+ 22 + +
+   + #[ORM\Column(type: Types::INTEGER)] +
+
+ 23 + +
+   + #[Groups(['site_service:read'])] +
+
+ 24 + +
+   + private ?int $id = null; +
+
+
 
+
+ 48 + +
+   + private ?string $content = null; +
+
+ 49 + +
+   +
+
+
+ 50 + +
+   + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +
+
+ 51 + +
+ + + #[Groups(['site_service:read'])] +
+
+ 52 + +
+   + private ?\DateTimeInterface $updateAt = null; +
+
+ 53 + +
+   +
+
+
+ 54 + +
+   + #[ORM\Column(name: 'link_videoreviews', type: 'jsonb', nullable: true)] +
+
+
+
+
+
+
+
+ + src/Repository/ArticleRepository.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,10 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\Article; +
+
+ 6 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ + +
+   +
+
+
+ 7 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 8 + +
+   +
+
+
+ 9 + +
+   + /** +
+
+
@@ -11,63 +13,34 @@ use Doctrine\Persistence\ManagerRegistry;
+
+ 11 + +
+   + */ +
+
+ 12 + +
+   + class ArticleRepository extends ServiceEntityRepository +
+
+ 13 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 14 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 15 + +
+   + { +
+
+ 16 + +
+   + parent::__construct($registry, Article::class); +
+
+ 17 + +
+   + } +
+
+ 18 + +
+   +
+
+
+ 19 + +
+ - + public function findByFilters(array $filters, int $page = 1, int $limit = 20): array +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 20 + +
+   + { +
+
+ 21 + +
+ - + $qb = $this->createQueryBuilder('a'); +
+
+ 22 + +
+ - +
+
+
+ 23 + +
+ - + if (isset($filters['alias']) && $filters['alias'] !== '') { +
+
+ 24 + +
+ - + $qb->andWhere('a.alias = :alias') +
+
+ 25 + +
+ - + ->setParameter('alias', $filters['alias']); +
+
+ 26 + +
+ - + } +
+
+ 27 + +
+ - + if (isset($filters['active']) && $filters['active'] !== '') { +
+
+ 28 + +
+ - + $qb->andWhere('a.active = :active') +
+
+ 29 + +
+ - + ->setParameter('active', filter_var($filters['active'], FILTER_VALIDATE_BOOLEAN)); +
+
+ 30 + +
+ - + } +
+
+ 31 + +
+ - + if (isset($filters['regionId']) && $filters['regionId'] !== '') { +
+
+ 32 + +
+ - + $qb->andWhere('a.regionId = :regionId') +
+
+ 33 + +
+ - + ->setParameter('regionId', (int) $filters['regionId']); +
+
+ 34 + +
+ - + } +
+
+ 35 + +
+   +
+
+
+ 36 + +
+ - + $qb->orderBy('a.id', 'DESC'); +
+
+ 37 + +
+   +
+
+
+ 38 + +
+ - + $qb->setFirstResult(($page - 1) * $limit) +
+
+ 39 + +
+ - + ->setMaxResults($limit); +
+
+ 40 + +
+ - +
+
+
+ 41 + +
+ - + return $qb->getQuery()->getResult(); +
+
+ 42 + +
+ - + } +
+
+ 43 + +
+ - +
+
+
+ 44 + +
+ - + public function countByFilters(array $filters): int +
+
+ 45 + +
+ - + { +
+
+ 46 + +
+ - + $qb = $this->createQueryBuilder('a') +
+
+ 47 + +
+ - + ->select('COUNT(a.id)'); +
+
+ 48 + +
+ - +
+
+
+ 49 + +
+ - + if (isset($filters['alias']) && $filters['alias'] !== '') { +
+
+ 50 + +
+ - + $qb->andWhere('a.alias = :alias') +
+
+ 51 + +
+ - + ->setParameter('alias', $filters['alias']); +
+
+ 52 + +
+ - + } +
+
+ 53 + +
+ - + if (isset($filters['active']) && $filters['active'] !== '') { +
+
+ 54 + +
+ - + $qb->andWhere('a.active = :active') +
+
+ 55 + +
+ - + ->setParameter('active', filter_var($filters['active'], FILTER_VALIDATE_BOOLEAN)); +
+
+ 56 + +
+ - + } +
+
+ 57 + +
+ - + if (isset($filters['regionId']) && $filters['regionId'] !== '') { +
+
+ 58 + +
+ - + $qb->andWhere('a.regionId = :regionId') +
+
+ 59 + +
+ - + ->setParameter('regionId', (int) $filters['regionId']); +
+
+ 60 + +
+ - + } +
+
+ 61 + +
+ - +
+
+
+ 62 + +
+ - + return (int) $qb->getQuery()->getSingleScalarResult(); +
+
+ 63 + +
+   + } +
+
+ 64 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 65 + +
+   + public function findOneByAlias(string $alias): ?Article +
+
+ 66 + +
+   + { +
+
+ 67 + +
+   + $alias = trim($alias); +
+
+ 68 + +
+   + if ($alias === '') { +
+
+ 69 + +
+   + return null; +
+
+ 70 + +
+   + } +
+
+ + +
+   +
+
+
+ 71 + +
+   + $variants = [ +
+
+ 72 + +
+   + $alias, +
+
+ 73 + +
+   + $alias . '-', +
+
+
@@ -79,16 +52,18 @@ class ArticleRepository extends ServiceEntityRepository
+
+ 79 + +
+   + return $article; +
+
+ 80 + +
+   + } +
+
+ 81 + +
+   + } +
+
+ 82 + +
+ - + // Поиск по TRIM(alias) в БД (нативный SQL для совместимости с PostgreSQL) +
+
+ + +
+   +
+
+
+ 83 + +
+   + $conn = $this->getEntityManager()->getConnection(); +
+
+ 84 + +
+   + $id = $conn->fetchOne( +
+
+ 85 + +
+   + 'SELECT id FROM article WHERE TRIM(alias) = :alias LIMIT 1', +
+
+ 86 + +
+   + ['alias' => $alias], +
+
+ 87 + +
+ - + ['alias' => \PDO::PARAM_STR] +
+
+ 88 + +
+   + ); +
+
+ 89 + +
+   + if ($id !== false) { +
+
+ 90 + +
+   + return $this->find($id); +
+
+ 91 + +
+   + } +
+
+ + +
+   +
+
+
+ 92 + +
+   + return null; +
+
+ 93 + +
+   + } +
+
+ 94 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\Article; +
+
+ 7 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ 8 + +
+ + + use Doctrine\ORM\QueryBuilder; +
+
+ 9 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 10 + +
+   +
+
+
+ 11 + +
+   + /** +
+
+
 
+
+ 13 + +
+   + */ +
+
+ 14 + +
+   + class ArticleRepository extends ServiceEntityRepository +
+
+ 15 + +
+   + { +
+
+ 16 + +
+ + + use ContentFilterTrait; +
+
+ 17 + +
+ + +
+
+
+ 18 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 19 + +
+   + { +
+
+ 20 + +
+   + parent::__construct($registry, Article::class); +
+
+ 21 + +
+   + } +
+
+ 22 + +
+   +
+
+
+ 23 + +
+ + + /** +
+
+ 24 + +
+ + + */ +
+
+ 25 + +
+ + + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder +
+
+ 26 + +
+   + { +
+
+ 27 + +
+ + + $qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC'); +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 28 + +
+   +
+
+
+ 29 + +
+ + + $this->applyCommonFilters($qb, 'a', $filters); +
+
+ 30 + +
+   +
+
+
+ 31 + +
+ + + return $qb; +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 32 + +
+   + } +
+
+ 33 + +
+   +
+
+
+ 34 + +
+ + + /** +
+
+ 35 + +
+ + + * Поиск статьи по alias с учётом возможных вариантов написания (исторический функционал). +
+
+ 36 + +
+ + + */ +
+
+ 37 + +
+   + public function findOneByAlias(string $alias): ?Article +
+
+ 38 + +
+   + { +
+
+ 39 + +
+   + $alias = trim($alias); +
+
+ 40 + +
+   + if ($alias === '') { +
+
+ 41 + +
+   + return null; +
+
+ 42 + +
+   + } +
+
+ 43 + +
+ + +
+
+
+ 44 + +
+   + $variants = [ +
+
+ 45 + +
+   + $alias, +
+
+ 46 + +
+   + $alias . '-', +
+
+
 
+
+ 52 + +
+   + return $article; +
+
+ 53 + +
+   + } +
+
+ 54 + +
+   + } +
+
+ 55 + +
+ + +
+
+
+ 56 + +
+ + + // Фолбэк по TRIM(alias) в БД для совместимости со старыми данными. +
+
+ 57 + +
+   + $conn = $this->getEntityManager()->getConnection(); +
+
+ 58 + +
+   + $id = $conn->fetchOne( +
+
+ 59 + +
+   + 'SELECT id FROM article WHERE TRIM(alias) = :alias LIMIT 1', +
+
+ 60 + +
+   + ['alias' => $alias], +
+
+ 61 + +
+ + + ['alias' => \PDO::PARAM_STR], +
+
+ 62 + +
+   + ); +
+
+ 63 + +
+   + if ($id !== false) { +
+
+ 64 + +
+   + return $this->find($id); +
+
+ 65 + +
+   + } +
+
+ 66 + +
+ + +
+
+
+ 67 + +
+   + return null; +
+
+ 68 + +
+   + } +
+
+ 69 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Repository/ContentFilterTrait.php + ADDED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -0,0 +1,58 @@
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 1 + +
+ + + <?php +
+
+ 2 + +
+ + +
+
+
+ 3 + +
+ + + declare(strict_types=1); +
+
+ 4 + +
+ + +
+
+
+ 5 + +
+ + + namespace App\Repository; +
+
+ 6 + +
+ + +
+
+
+ 7 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 8 + +
+ + + use Doctrine\ORM\QueryBuilder; +
+
+ 9 + +
+ + +
+
+
+ 10 + +
+ + + /** +
+
+ 11 + +
+ + + * Общие фильтры для контентных репозиториев (News/Promo/Disease/MedicalCenter/Article/SiteService). +
+
+ 12 + +
+ + + * +
+
+ 13 + +
+ + + * Trait подключается в Doctrine-репозитории, чтобы не держать бизнес-фильтры +
+
+ 14 + +
+ + + * в статическом helper-классе и при этом не копировать одинаковые if-блоки. +
+
+ 15 + +
+ + + * +
+
+ 16 + +
+ + + * Поддерживается: +
+
+ 17 + +
+ + + * - regionId / region_id: целое > 0; +
+
+ 18 + +
+ + + * - active: bool; +
+
+ 19 + +
+ + + * - alias: точное совпадение; +
+
+ 20 + +
+ + + * - search / q: LIKE по lower-case значению заданного поля (по умолчанию `name`). +
+
+ 21 + +
+ + + * +
+
+ 22 + +
+ + + * Поле поиска параметризовано через $searchField на случай сущностей, +
+
+ 23 + +
+ + + * где основное текстовое поле называется иначе (например, `title`). +
+
+ 24 + +
+ + + * Если у сущности нет такого свойства, Doctrine упадёт с QueryException — это +
+
+ 25 + +
+ + + * лучше ловится тестами на этапе разработки, чем 500 в проде. +
+
+ 26 + +
+ + + * +
+
+ 27 + +
+ + + * Важно: LOWER($alias.$searchField) при больших таблицах требует функционального +
+
+ 28 + +
+ + + * индекса в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)). +
+
+ 29 + +
+ + + */ +
+
+ 30 + +
+ + + trait ContentFilterTrait +
+
+ 31 + +
+ + + { +
+
+ 32 + +
+ + + private function applyCommonFilters( +
+
+ 33 + +
+ + + QueryBuilder $qb, +
+
+ 34 + +
+ + + string $alias, +
+
+ 35 + +
+ + + ContentFilterDto $filters, +
+
+ 36 + +
+ + + string $searchField = 'name', +
+
+ 37 + +
+ + + ): void { +
+
+ 38 + +
+ + + if ($filters->regionId !== null) { +
+
+ 39 + +
+ + + $qb->andWhere("$alias.regionId = :regionId") +
+
+ 40 + +
+ + + ->setParameter('regionId', $filters->regionId); +
+
+ 41 + +
+ + + } +
+
+ 42 + +
+ + +
+
+
+ 43 + +
+ + + if ($filters->active !== null) { +
+
+ 44 + +
+ + + $qb->andWhere("$alias.active = :active") +
+
+ 45 + +
+ + + ->setParameter('active', $filters->active); +
+
+ 46 + +
+ + + } +
+
+ 47 + +
+ + +
+
+
+ 48 + +
+ + + if ($filters->alias !== null) { +
+
+ 49 + +
+ + + $qb->andWhere("$alias.alias = :aliasValue") +
+
+ 50 + +
+ + + ->setParameter('aliasValue', $filters->alias); +
+
+ 51 + +
+ + + } +
+
+ 52 + +
+ + +
+
+
+ 53 + +
+ + + if ($filters->search !== null) { +
+
+ 54 + +
+ + + $qb->andWhere("LOWER($alias.$searchField) LIKE :search") +
+
+ 55 + +
+ + + ->setParameter('search', '%' . mb_strtolower($filters->search) . '%'); +
+
+ 56 + +
+ + + } +
+
+ 57 + +
+ + + } +
+
+ 58 + +
+ + + } +
+
+
+
+
+
+
+
+ + src/Repository/DiseaseRepository.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,10 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\Disease; +
+
+ 6 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ + +
+   +
+
+
+ 7 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 8 + +
+   +
+
+
+ 9 + +
+   + /** +
+
+
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
+
+ 14 + +
+   + */ +
+
+ 15 + +
+   + class DiseaseRepository extends ServiceEntityRepository +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 18 + +
+   + { +
+
+ 19 + +
+   + parent::__construct($registry, Disease::class); +
+
+ 20 + +
+   + } +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 21 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\Disease; +
+
+ 7 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ 8 + +
+ + + use Doctrine\ORM\QueryBuilder; +
+
+ 9 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 10 + +
+   +
+
+
+ 11 + +
+   + /** +
+
+
 
+
+ 16 + +
+   + */ +
+
+ 17 + +
+   + class DiseaseRepository extends ServiceEntityRepository +
+
+ 18 + +
+   + { +
+
+ 19 + +
+ + + use ContentFilterTrait; +
+
+ 20 + +
+ + +
+
+
+ 21 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 22 + +
+   + { +
+
+ 23 + +
+   + parent::__construct($registry, Disease::class); +
+
+ 24 + +
+   + } +
+
+ 25 + +
+ + +
+
+
+ 26 + +
+ + + /** +
+
+ 27 + +
+ + + */ +
+
+ 28 + +
+ + + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder +
+
+ 29 + +
+ + + { +
+
+ 30 + +
+ + + $qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC'); +
+
+ 31 + +
+ + +
+
+
+ 32 + +
+ + + $this->applyCommonFilters($qb, 'd', $filters); +
+
+ 33 + +
+ + +
+
+
+ 34 + +
+ + + return $qb; +
+
+ 35 + +
+ + + } +
+
+ 36 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Repository/MedicalCenterRepository.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,10 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\MedicalCenter; +
+
+ 6 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ + +
+   +
+
+
+ 7 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 8 + +
+   +
+
+
+ 9 + +
+   + /** +
+
+
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
+
+ 14 + +
+   + */ +
+
+ 15 + +
+   + class MedicalCenterRepository extends ServiceEntityRepository +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 18 + +
+   + { +
+
+ 19 + +
+   + parent::__construct($registry, MedicalCenter::class); +
+
+ 20 + +
+   + } +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 21 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\MedicalCenter; +
+
+ 7 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ 8 + +
+ + + use Doctrine\ORM\QueryBuilder; +
+
+ 9 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 10 + +
+   +
+
+
+ 11 + +
+   + /** +
+
+
 
+
+ 16 + +
+   + */ +
+
+ 17 + +
+   + class MedicalCenterRepository extends ServiceEntityRepository +
+
+ 18 + +
+   + { +
+
+ 19 + +
+ + + use ContentFilterTrait; +
+
+ 20 + +
+ + +
+
+
+ 21 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 22 + +
+   + { +
+
+ 23 + +
+   + parent::__construct($registry, MedicalCenter::class); +
+
+ 24 + +
+   + } +
+
+ 25 + +
+ + +
+
+
+ 26 + +
+ + + /** +
+
+ 27 + +
+ + + */ +
+
+ 28 + +
+ + + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder +
+
+ 29 + +
+ + + { +
+
+ 30 + +
+ + + $qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC'); +
+
+ 31 + +
+ + +
+
+
+ 32 + +
+ + + $this->applyCommonFilters($qb, 'm', $filters); +
+
+ 33 + +
+ + +
+
+
+ 34 + +
+ + + return $qb; +
+
+ 35 + +
+ + + } +
+
+ 36 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Repository/NewsRepository.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,10 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\News; +
+
+ 6 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ + +
+   +
+
+
+ 7 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 8 + +
+   +
+
+
+ 9 + +
+   + /** +
+
+
@@ -14,8 +16,25 @@ use Doctrine\Persistence\ManagerRegistry;
+
+ 14 + +
+   + */ +
+
+ 15 + +
+   + class NewsRepository extends ServiceEntityRepository +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 18 + +
+   + { +
+
+ 19 + +
+   + parent::__construct($registry, News::class); +
+
+ 20 + +
+   + } +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 21 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\News; +
+
+ 7 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ 8 + +
+ + + use Doctrine\ORM\QueryBuilder; +
+
+ 9 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 10 + +
+   +
+
+
+ 11 + +
+   + /** +
+
+
 
+
+ 16 + +
+   + */ +
+
+ 17 + +
+   + class NewsRepository extends ServiceEntityRepository +
+
+ 18 + +
+   + { +
+
+ 19 + +
+ + + use ContentFilterTrait; +
+
+ 20 + +
+ + +
+
+
+ 21 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 22 + +
+   + { +
+
+ 23 + +
+   + parent::__construct($registry, News::class); +
+
+ 24 + +
+   + } +
+
+ 25 + +
+ + +
+
+
+ 26 + +
+ + + /** +
+
+ 27 + +
+ + + * Готовит QueryBuilder под пагинацию (Pagerfanta\QueryAdapter). +
+
+ 28 + +
+ + + * +
+
+ 29 + +
+ + + * Поддерживаемые фильтры: regionId, active (по умолчанию true), alias, search. +
+
+ 30 + +
+ + + * +
+
+ 31 + +
+ + + */ +
+
+ 32 + +
+ + + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder +
+
+ 33 + +
+ + + { +
+
+ 34 + +
+ + + $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC'); +
+
+ 35 + +
+ + +
+
+
+ 36 + +
+ + + $this->applyCommonFilters($qb, 'n', $filters); +
+
+ 37 + +
+ + +
+
+
+ 38 + +
+ + + return $qb; +
+
+ 39 + +
+ + + } +
+
+ 40 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Repository/PromoRepository.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,10 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\Promo; +
+
+ 6 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ + +
+   +
+
+
+ 7 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 8 + +
+   +
+
+
+ 9 + +
+   + /** +
+
+
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
+
+ 14 + +
+   + */ +
+
+ 15 + +
+   + class PromoRepository extends ServiceEntityRepository +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 18 + +
+   + { +
+
+ 19 + +
+   + parent::__construct($registry, Promo::class); +
+
+ 20 + +
+   + } +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 21 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\Promo; +
+
+ 7 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ 8 + +
+ + + use Doctrine\ORM\QueryBuilder; +
+
+ 9 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 10 + +
+   +
+
+
+ 11 + +
+   + /** +
+
+
 
+
+ 16 + +
+   + */ +
+
+ 17 + +
+   + class PromoRepository extends ServiceEntityRepository +
+
+ 18 + +
+   + { +
+
+ 19 + +
+ + + use ContentFilterTrait; +
+
+ 20 + +
+ + +
+
+
+ 21 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 22 + +
+   + { +
+
+ 23 + +
+   + parent::__construct($registry, Promo::class); +
+
+ 24 + +
+   + } +
+
+ 25 + +
+ + +
+
+
+ 26 + +
+ + + /** +
+
+ 27 + +
+ + + */ +
+
+ 28 + +
+ + + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder +
+
+ 29 + +
+ + + { +
+
+ 30 + +
+ + + $qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC'); +
+
+ 31 + +
+ + +
+
+
+ 32 + +
+ + + $this->applyCommonFilters($qb, 'p', $filters); +
+
+ 33 + +
+ + +
+
+
+ 34 + +
+ + + return $qb; +
+
+ 35 + +
+ + + } +
+
+ 36 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Repository/SiteServiceRepository.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,8 +2,10 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use App\Entity\SiteService; +
+
+ 6 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ + +
+   +
+
+
+ 7 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 8 + +
+   +
+
+
+ 9 + +
+   + /** +
+
+
@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry;
+
+ 14 + +
+   + */ +
+
+ 15 + +
+   + class SiteServiceRepository extends ServiceEntityRepository +
+
+ 16 + +
+   + { +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 17 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 18 + +
+   + { +
+
+ 19 + +
+   + parent::__construct($registry, SiteService::class); +
+
+ 20 + +
+   + } +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 21 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Repository; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ + + use App\Dto\Content\ContentFilterDto; +
+
+ 6 + +
+   + use App\Entity\SiteService; +
+
+ 7 + +
+   + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +
+
+ 8 + +
+ + + use Doctrine\ORM\QueryBuilder; +
+
+ 9 + +
+   + use Doctrine\Persistence\ManagerRegistry; +
+
+ 10 + +
+   +
+
+
+ 11 + +
+   + /** +
+
+
 
+
+ 16 + +
+   + */ +
+
+ 17 + +
+   + class SiteServiceRepository extends ServiceEntityRepository +
+
+ 18 + +
+   + { +
+
+ 19 + +
+ + + use ContentFilterTrait; +
+
+ 20 + +
+ + +
+
+
+ 21 + +
+   + public function __construct(ManagerRegistry $registry) +
+
+ 22 + +
+   + { +
+
+ 23 + +
+   + parent::__construct($registry, SiteService::class); +
+
+ 24 + +
+   + } +
+
+ 25 + +
+ + +
+
+
+ 26 + +
+ + + /** +
+
+ 27 + +
+ + + */ +
+
+ 28 + +
+ + + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder +
+
+ 29 + +
+ + + { +
+
+ 30 + +
+ + + $qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC'); +
+
+ 31 + +
+ + +
+
+
+ 32 + +
+ + + $this->applyCommonFilters($qb, 's', $filters); +
+
+ 33 + +
+ + +
+
+
+ 34 + +
+ + + return $qb; +
+
+ 35 + +
+ + + } +
+
+ 36 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Service/Crud/CrudResponder.php + ADDED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -0,0 +1,195 @@
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 1 + +
+ + + <?php +
+
+ 2 + +
+ + +
+
+
+ 3 + +
+ + + declare(strict_types=1); +
+
+ 4 + +
+ + +
+
+
+ 5 + +
+ + + namespace App\Service\Crud; +
+
+ 6 + +
+ + +
+
+
+ 7 + +
+ + + use Doctrine\DBAL\Exception as DbalException; +
+
+ 8 + +
+ + + use Doctrine\ORM\EntityManagerInterface; +
+
+ 9 + +
+ + + use JsonException; +
+
+ 10 + +
+ + + use Symfony\Component\HttpFoundation\JsonResponse; +
+
+ 11 + +
+ + + use Symfony\Component\HttpFoundation\Request; +
+
+ 12 + +
+ + + use Symfony\Component\HttpFoundation\Response; +
+
+ 13 + +
+ + + use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; +
+
+ 14 + +
+ + + use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +
+
+ 15 + +
+ + + use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +
+
+ 16 + +
+ + + use Symfony\Component\Serializer\SerializerInterface; +
+
+ 17 + +
+ + + use Symfony\Component\Validator\Validator\ValidatorInterface; +
+
+ 18 + +
+ + +
+
+
+ 19 + +
+ + + /** +
+
+ 20 + +
+ + + * Универсальный CRUD-ответчик для тонких контент-контроллеров. +
+
+ 21 + +
+ + + * +
+
+ 22 + +
+ + + * Контракт ответов специально сохранён близким к старым *CrudService/контроллерам, +
+
+ 23 + +
+ + + * чтобы не ломать существующих клиентов (фронтенд/мобильное): +
+
+ 24 + +
+ + + * - валидация: HTTP 400 + сериализованный ConstraintViolationList +
+
+ 25 + +
+ + + * (формат Symfony Serializer по умолчанию, т.е. RFC 7807 с ключом violations); +
+
+ 26 + +
+ + + * - удаление с ошибкой БД (например, FK constraint): HTTP 500 + {error, message}; +
+
+ 27 + +
+ + + * - JSON-ключи запросов/ответов используют camelCase (см. свойства сущностей и группы *:write). +
+
+ 28 + +
+ + + * Name converter в config/packages/serializer.yaml не задан намеренно — клиенту +
+
+ 29 + +
+ + + * нужен консистентный camelCase, иначе незнакомые ключи будут проигнорированы. +
+
+ 30 + +
+ + + */ +
+
+ 31 + +
+ + + final class CrudResponder +
+
+ 32 + +
+ + + { +
+
+ 33 + +
+ + + public function __construct( +
+
+ 34 + +
+ + + private EntityManagerInterface $em, +
+
+ 35 + +
+ + + private SerializerInterface $serializer, +
+
+ 36 + +
+ + + private DenormalizerInterface $denormalizer, +
+
+ 37 + +
+ + + private ValidatorInterface $validator, +
+
+ 38 + +
+ + + ) { +
+
+ 39 + +
+ + + } +
+
+ 40 + +
+ + +
+
+
+ 41 + +
+ + + /** +
+
+ 42 + +
+ + + * @param list<string> $readGroups +
+
+ 43 + +
+ + + */ +
+
+ 44 + +
+ + + public function read(object $entity, array $readGroups): JsonResponse +
+
+ 45 + +
+ + + { +
+
+ 46 + +
+ + + return $this->json($entity, Response::HTTP_OK, $readGroups); +
+
+ 47 + +
+ + + } +
+
+ 48 + +
+ + +
+
+
+ 49 + +
+ + + /** +
+
+ 50 + +
+ + + * @template T of object +
+
+ 51 + +
+ + + * +
+
+ 52 + +
+ + + * @param class-string<T> $entityClass +
+
+ 53 + +
+ + + * @param list<string> $writeGroups +
+
+ 54 + +
+ + + * @param list<string> $readGroups +
+
+ 55 + +
+ + + */ +
+
+ 56 + +
+ + + public function create( +
+
+ 57 + +
+ + + Request $request, +
+
+ 58 + +
+ + + string $entityClass, +
+
+ 59 + +
+ + + array $writeGroups, +
+
+ 60 + +
+ + + array $readGroups, +
+
+ 61 + +
+ + + ): JsonResponse { +
+
+ 62 + +
+ + + $payload = $this->decodePayload($request); +
+
+ 63 + +
+ + + if ($payload === null) { +
+
+ 64 + +
+ + + return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST); +
+
+ 65 + +
+ + + } +
+
+ 66 + +
+ + + unset($payload['id']); +
+
+ 67 + +
+ + +
+
+
+ 68 + +
+ + + try { +
+
+ 69 + +
+ + + /** @var T $entity */ +
+
+ 70 + +
+ + + $entity = $this->denormalizer->denormalize( +
+
+ 71 + +
+ + + $payload, +
+
+ 72 + +
+ + + $entityClass, +
+
+ 73 + +
+ + + null, +
+
+ 74 + +
+ + + [ +
+
+ 75 + +
+ + + AbstractNormalizer::GROUPS => $writeGroups, +
+
+ 76 + +
+ + + ], +
+
+ 77 + +
+ + + ); +
+
+ 78 + +
+ + + } catch (SerializerExceptionInterface $e) { +
+
+ 79 + +
+ + + return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); +
+
+ 80 + +
+ + + } +
+
+ 81 + +
+ + +
+
+
+ 82 + +
+ + + if (($validationResponse = $this->validate($entity)) !== null) { +
+
+ 83 + +
+ + + return $validationResponse; +
+
+ 84 + +
+ + + } +
+
+ 85 + +
+ + +
+
+
+ 86 + +
+ + + $this->em->persist($entity); +
+
+ 87 + +
+ + + $this->em->flush(); +
+
+ 88 + +
+ + +
+
+
+ 89 + +
+ + + return $this->json($entity, Response::HTTP_CREATED, $readGroups); +
+
+ 90 + +
+ + + } +
+
+ 91 + +
+ + +
+
+
+ 92 + +
+ + + /** +
+
+ 93 + +
+ + + * @param list<string> $writeGroups +
+
+ 94 + +
+ + + * @param list<string> $readGroups +
+
+ 95 + +
+ + + */ +
+
+ 96 + +
+ + + public function update( +
+
+ 97 + +
+ + + Request $request, +
+
+ 98 + +
+ + + object $entity, +
+
+ 99 + +
+ + + array $writeGroups, +
+
+ 100 + +
+ + + array $readGroups, +
+
+ 101 + +
+ + + ): JsonResponse { +
+
+ 102 + +
+ + + $payload = $this->decodePayload($request); +
+
+ 103 + +
+ + + if ($payload === null) { +
+
+ 104 + +
+ + + return $this->jsonError('Ожидается JSON-объект в теле запроса', Response::HTTP_BAD_REQUEST); +
+
+ 105 + +
+ + + } +
+
+ 106 + +
+ + + unset($payload['id']); +
+
+ 107 + +
+ + +
+
+
+ 108 + +
+ + + try { +
+
+ 109 + +
+ + + $this->denormalizer->denormalize( +
+
+ 110 + +
+ + + $payload, +
+
+ 111 + +
+ + + $entity::class, +
+
+ 112 + +
+ + + null, +
+
+ 113 + +
+ + + [ +
+
+ 114 + +
+ + + AbstractNormalizer::GROUPS => $writeGroups, +
+
+ 115 + +
+ + + AbstractNormalizer::OBJECT_TO_POPULATE => $entity, +
+
+ 116 + +
+ + + ], +
+
+ 117 + +
+ + + ); +
+
+ 118 + +
+ + + } catch (SerializerExceptionInterface $e) { +
+
+ 119 + +
+ + + return $this->jsonError('Ошибка десериализации: ' . $e->getMessage(), Response::HTTP_BAD_REQUEST); +
+
+ 120 + +
+ + + } +
+
+ 121 + +
+ + +
+
+
+ 122 + +
+ + + if (($validationResponse = $this->validate($entity)) !== null) { +
+
+ 123 + +
+ + + return $validationResponse; +
+
+ 124 + +
+ + + } +
+
+ 125 + +
+ + +
+
+
+ 126 + +
+ + + $this->em->flush(); +
+
+ 127 + +
+ + +
+
+
+ 128 + +
+ + + return $this->json($entity, Response::HTTP_OK, $readGroups); +
+
+ 129 + +
+ + + } +
+
+ 130 + +
+ + +
+
+
+ 131 + +
+ + + public function delete(object $entity): JsonResponse +
+
+ 132 + +
+ + + { +
+
+ 133 + +
+ + + try { +
+
+ 134 + +
+ + + $this->em->remove($entity); +
+
+ 135 + +
+ + + $this->em->flush(); +
+
+ 136 + +
+ + + } catch (DbalException $e) { +
+
+ 137 + +
+ + + // Сохраняем легаси-контракт: при FK / NOT NULL / unique ошибках БД +
+
+ 138 + +
+ + + // отдаём 500 + {error, message}. См. старый ArticleController::delete. +
+
+ 139 + +
+ + + return new JsonResponse( +
+
+ 140 + +
+ + + ['error' => 'Ошибка при удалении записи', 'message' => $e->getMessage()], +
+
+ 141 + +
+ + + Response::HTTP_INTERNAL_SERVER_ERROR, +
+
+ 142 + +
+ + + ); +
+
+ 143 + +
+ + + } +
+
+ 144 + +
+ + +
+
+
+ 145 + +
+ + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); +
+
+ 146 + +
+ + + } +
+
+ 147 + +
+ + +
+
+
+ 148 + +
+ + + /** +
+
+ 149 + +
+ + + * @return array<string, mixed>|null null если тело не является JSON-объектом +
+
+ 150 + +
+ + + * +
+
+ 151 + +
+ + + * Ловим как нативный \JsonException, так и Symfony\...\HttpFoundation\Exception\JsonException +
+
+ 152 + +
+ + + * (последний наследует UnexpectedValueException, а не \JsonException, и без +
+
+ 153 + +
+ + + * широкого перехвата Symfony ErrorListener перехватит ошибку до нашего try/catch). +
+
+ 154 + +
+ + + */ +
+
+ 155 + +
+ + + private function decodePayload(Request $request): ?array +
+
+ 156 + +
+ + + { +
+
+ 157 + +
+ + + try { +
+
+ 158 + +
+ + + return $request->toArray(); +
+
+ 159 + +
+ + + } catch (JsonException|\UnexpectedValueException) { +
+
+ 160 + +
+ + + return null; +
+
+ 161 + +
+ + + } +
+
+ 162 + +
+ + + } +
+
+ 163 + +
+ + +
+
+
+ 164 + +
+ + + private function validate(object $entity): ?JsonResponse +
+
+ 165 + +
+ + + { +
+
+ 166 + +
+ + + $errors = $this->validator->validate($entity); +
+
+ 167 + +
+ + + if (count($errors) === 0) { +
+
+ 168 + +
+ + + return null; +
+
+ 169 + +
+ + + } +
+
+ 170 + +
+ + +
+
+
+ 171 + +
+ + + // BC: легаси-контроллеры возвращали именно сериализованный ConstraintViolationList +
+
+ 172 + +
+ + + // с кодом 400. Этот же формат продолжаем отдавать здесь, чтобы фронтенду +
+
+ 173 + +
+ + + // не пришлось переписывать парсинг ошибок. +
+
+ 174 + +
+ + + $json = $this->serializer->serialize($errors, 'json'); +
+
+ 175 + +
+ + +
+
+
+ 176 + +
+ + + return new JsonResponse($json, Response::HTTP_BAD_REQUEST, [], true); +
+
+ 177 + +
+ + + } +
+
+ 178 + +
+ + +
+
+
+ 179 + +
+ + + /** +
+
+ 180 + +
+ + + * @param list<string> $groups +
+
+ 181 + +
+ + + */ +
+
+ 182 + +
+ + + private function json(mixed $data, int $status, array $groups): JsonResponse +
+
+ 183 + +
+ + + { +
+
+ 184 + +
+ + + $json = $this->serializer->serialize($data, 'json', [ +
+
+ 185 + +
+ + + AbstractNormalizer::GROUPS => $groups, +
+
+ 186 + +
+ + + ]); +
+
+ 187 + +
+ + +
+
+
+ 188 + +
+ + + return new JsonResponse($json, $status, [], true); +
+
+ 189 + +
+ + + } +
+
+ 190 + +
+ + +
+
+
+ 191 + +
+ + + private function jsonError(string $message, int $status): JsonResponse +
+
+ 192 + +
+ + + { +
+
+ 193 + +
+ + + return new JsonResponse(['error' => $message], $status); +
+
+ 194 + +
+ + + } +
+
+ 195 + +
+ + + } +
+
+
+
+
+
+
+
+ + src/Service/DiseaseCrudService.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,206 +2,26 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Service; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ - + use App\Entity\Disease; +
+
+ 6 + +
+ - + use App\Repository\DiseaseRepository; +
+
+ 7 + +
+   + use Doctrine\ORM\EntityManagerInterface; +
+
+ 8 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 9 + +
+   + final class DiseaseCrudService +
+
+ 10 + +
+   + { +
+
+ 11 + +
+   + public function __construct( +
+
+ 12 + +
+   + private EntityManagerInterface $em, +
+
+ 13 + +
+ - + private DiseaseRepository $diseaseRepository, +
+
+ 14 + +
+   + ) { +
+
+ 15 + +
+   + } +
+
+ 16 + +
+   +
+
+
+ 17 + +
+ - + /** +
+
+ 18 + +
+ - + * @return array{data: Disease[], total: int, page: int, per_page: int} +
+
+ 19 + +
+ - + */ +
+
+ 20 + +
+ - + public function getPaginatedList(int $page, int $perPage, ?int $regionId = null): array +
+
+ 21 + +
+ - + { +
+
+ 22 + +
+ - + $page = max(1, $page); +
+
+ 23 + +
+ - + $perPage = min(max(1, $perPage), 500); +
+
+ 24 + +
+ - +
+
+
+ 25 + +
+ - + $qb = $this->diseaseRepository->createQueryBuilder('d') +
+
+ 26 + +
+ - + ->orderBy('d.id', 'ASC'); +
+
+ 27 + +
+ - +
+
+
+ 28 + +
+ - + if ($regionId !== null) { +
+
+ 29 + +
+ - + $qb->andWhere('d.regionId = :regionId') +
+
+ 30 + +
+ - + ->setParameter('regionId', $regionId); +
+
+ 31 + +
+ - + } +
+
+ 32 + +
+ - +
+
+
+ 33 + +
+ - + $countQb = $this->diseaseRepository->createQueryBuilder('d') +
+
+ 34 + +
+ - + ->select('COUNT(d.id)'); +
+
+ 35 + +
+ - + if ($regionId !== null) { +
+
+ 36 + +
+ - + $countQb->andWhere('d.regionId = :regionId') +
+
+ 37 + +
+ - + ->setParameter('regionId', $regionId); +
+
+ 38 + +
+ - + } +
+
+ 39 + +
+ - + $total = (int) $countQb->getQuery()->getSingleScalarResult(); +
+
+ 40 + +
+ - +
+
+
+ 41 + +
+ - + $qb->setFirstResult(($page - 1) * $perPage) +
+
+ 42 + +
+ - + ->setMaxResults($perPage); +
+
+ 43 + +
+ - +
+
+
+ 44 + +
+ - + $data = $qb->getQuery()->getResult(); +
+
+ 45 + +
+ - +
+
+
+ 46 + +
+ - + return [ +
+
+ 47 + +
+ - + 'data' => $data, +
+
+ 48 + +
+ - + 'total' => $total, +
+
+ 49 + +
+ - + 'page' => $page, +
+
+ 50 + +
+ - + 'per_page' => $perPage, +
+
+ 51 + +
+ - + ]; +
+
+ 52 + +
+ - + } +
+
+ 53 + +
+ - +
+
+
+ 54 + +
+ - + public function getShow(int $id): ?Disease +
+
+ 55 + +
+ - + { +
+
+ 56 + +
+ - + return $this->diseaseRepository->find($id); +
+
+ 57 + +
+ - + } +
+
+ 58 + +
+ - +
+
+
+ 59 + +
+ - + public function create(array $data): Disease +
+
+ 60 + +
+ - + { +
+
+ 61 + +
+ - + if (!array_key_exists('id', $data) || $data['id'] === null || $data['id'] === '') { +
+
+ 62 + +
+ - + throw new \InvalidArgumentException('Поле id обязательно.'); +
+
+ 63 + +
+ - + } +
+
+ 64 + +
+ - +
+
+
+ 65 + +
+ - + $disease = new Disease(); +
+
+ 66 + +
+ - + $this->updateEntity($disease, $data); +
+
+ 67 + +
+ - +
+
+
+ 68 + +
+ - + $this->em->persist($disease); +
+
+ 69 + +
+ - + $this->em->flush(); +
+
+ 70 + +
+ - +
+
+
+ 71 + +
+ - + return $disease; +
+
+ 72 + +
+ - + } +
+
+ 73 + +
+ - +
+
+
+ 74 + +
+ - + public function update(Disease $disease, array $data): Disease +
+
+ 75 + +
+ - + { +
+
+ 76 + +
+ - + unset($data['id']); +
+
+ 77 + +
+ - + $this->updateEntity($disease, $data); +
+
+ 78 + +
+ - +
+
+
+ 79 + +
+ - + $this->em->flush(); +
+
+ 80 + +
+ - +
+
+
+ 81 + +
+ - + return $disease; +
+
+ 82 + +
+ - + } +
+
+ 83 + +
+ - +
+
+
+ 84 + +
+ - + public function delete(Disease $disease): void +
+
+ 85 + +
+ - + { +
+
+ 86 + +
+ - + $this->em->remove($disease); +
+
+ 87 + +
+ - + $this->em->flush(); +
+
+ 88 + +
+ - + } +
+
+ 89 + +
+ - +
+
+
+ 90 + +
+ - + private function updateEntity(Disease $disease, array $data): void +
+
+ 91 + +
+ - + { +
+
+ 92 + +
+ - + if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') { +
+
+ 93 + +
+ - + $disease->setId((int) $data['id']); +
+
+ 94 + +
+ - + } +
+
+ 95 + +
+ - +
+
+
+ 96 + +
+ - + if (array_key_exists('name', $data)) { +
+
+ 97 + +
+ - + $disease->setName($data['name']); +
+
+ 98 + +
+ - + } +
+
+ 99 + +
+ - +
+
+
+ 100 + +
+ - + if (array_key_exists('previewPicture', $data) || array_key_exists('preview_picture', $data)) { +
+
+ 101 + +
+ - + $disease->setPreviewPicture($data['previewPicture'] ?? $data['preview_picture']); +
+
+ 102 + +
+ - + } +
+
+ 103 + +
+ - +
+
+
+ 104 + +
+ - + if (array_key_exists('active', $data)) { +
+
+ 105 + +
+ - + $disease->setActive($data['active']); +
+
+ 106 + +
+ - + } +
+
+ 107 + +
+ - +
+
+
+ 108 + +
+ - + if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { +
+
+ 109 + +
+ - + $v = $data['regionId'] ?? $data['region_id']; +
+
+ 110 + +
+ - + $disease->setRegionId($v === null || $v === '' ? null : (int) $v); +
+
+ 111 + +
+ - + } +
+
+ 112 + +
+ - +
+
+
+ 113 + +
+ - + if (array_key_exists('alias', $data)) { +
+
+ 114 + +
+ - + $disease->setAlias($data['alias']); +
+
+ 115 + +
+ - + } +
+
+ 116 + +
+ - +
+
+
+ 117 + +
+ - + if (array_key_exists('anons', $data)) { +
+
+ 118 + +
+ - + $disease->setAnons($data['anons']); +
+
+ 119 + +
+ - + } +
+
+ 120 + +
+ - +
+
+
+ 121 + +
+ - + if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { +
+
+ 122 + +
+ - + $raw = $data['updateAt'] ?? $data['update_at']; +
+
+ 123 + +
+ - + if ($raw === null || $raw === '') { +
+
+ 124 + +
+ - + $disease->setUpdateAt(null); +
+
+ 125 + +
+ - + } elseif ($raw instanceof \DateTimeInterface) { +
+
+ 126 + +
+ - + $disease->setUpdateAt($raw); +
+
+ 127 + +
+ - + } elseif (is_string($raw)) { +
+
+ 128 + +
+ - + $disease->setUpdateAt(new \DateTimeImmutable($raw)); +
+
+ 129 + +
+ - + } +
+
+ 130 + +
+ - + } +
+
+ 131 + +
+ - +
+
+
+ 132 + +
+ - + if (array_key_exists('hidePicture', $data) || array_key_exists('hide_picture', $data)) { +
+
+ 133 + +
+ - + $disease->setHidePicture($data['hidePicture'] ?? $data['hide_picture']); +
+
+ 134 + +
+ - + } +
+
+ 135 + +
+ - +
+
+
+ 136 + +
+ - + if (array_key_exists('readTime', $data) || array_key_exists('read_time', $data)) { +
+
+ 137 + +
+ - + $disease->setReadTime($data['readTime'] ?? $data['read_time']); +
+
+ 138 + +
+ - + } +
+
+ 139 + +
+ - +
+
+
+ 140 + +
+ - + if (array_key_exists('diseasesName', $data) || array_key_exists('diseases_name', $data)) { +
+
+ 141 + +
+ - + $disease->setDiseasesName($data['diseasesName'] ?? $data['diseases_name']); +
+
+ 142 + +
+ - + } +
+
+ 143 + +
+ - +
+
+
+ 144 + +
+ - + if (array_key_exists('tagsImportant', $data) || array_key_exists('tags_important', $data)) { +
+
+ 145 + +
+ - + $disease->setTagsImportant($data['tagsImportant'] ?? $data['tags_important']); +
+
+ 146 + +
+ - + } +
+
+ 147 + +
+ - +
+
+
+ 148 + +
+ - + if (array_key_exists('tags', $data)) { +
+
+ 149 + +
+ - + $disease->setTags($data['tags']); +
+
+ 150 + +
+ - + } +
+
+ 151 + +
+ - +
+
+
+ 152 + +
+ - + if (array_key_exists('diseasesOtherName', $data) || array_key_exists('diseases_other_name', $data)) { +
+
+ 153 + +
+ - + $disease->setDiseasesOtherName($data['diseasesOtherName'] ?? $data['diseases_other_name']); +
+
+ 154 + +
+ - + } +
+
+ 155 + +
+ - +
+
+
+ 156 + +
+ - + if (array_key_exists('symptom', $data)) { +
+
+ 157 + +
+ - + $disease->setSymptom($data['symptom']); +
+
+ 158 + +
+ - + } +
+
+ 159 + +
+ - +
+
+
+ 160 + +
+ - + if (array_key_exists('staff', $data)) { +
+
+ 161 + +
+ - + $disease->setStaff($data['staff']); +
+
+ 162 + +
+ - + } +
+
+ 163 + +
+ - +
+
+
+ 164 + +
+ - + if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) { +
+
+ 165 + +
+ - + $disease->setLinkServices($data['linkServices'] ?? $data['link_services']); +
+
+ 166 + +
+ - + } +
+
+ 167 + +
+ - +
+
+
+ 168 + +
+ - + if (array_key_exists('staffList', $data) || array_key_exists('staff_list', $data)) { +
+
+ 169 + +
+ - + $disease->setStaffList($data['staffList'] ?? $data['staff_list']); +
+
+ 170 + +
+ - + } +
+
+ 171 + +
+ - +
+
+
+ 172 + +
+ - + if (array_key_exists('staffPost', $data) || array_key_exists('staff_post', $data)) { +
+
+ 173 + +
+ - + $disease->setStaffPost($data['staffPost'] ?? $data['staff_post']); +
+
+ 174 + +
+ - + } +
+
+ 175 + +
+ - +
+
+
+ 176 + +
+ - + if (array_key_exists('staffPostExclude', $data) || array_key_exists('staff_post_exclude', $data)) { +
+
+ 177 + +
+ - + $disease->setStaffPostExclude($data['staffPostExclude'] ?? $data['staff_post_exclude']); +
+
+ 178 + +
+ - + } +
+
+ 179 + +
+ - +
+
+
+ 180 + +
+ - + if (array_key_exists('linkFaq', $data) || array_key_exists('link_faq', $data)) { +
+
+ 181 + +
+ - + $disease->setLinkFaq($data['linkFaq'] ?? $data['link_faq']); +
+
+ 182 + +
+ - + } +
+
+ 183 + +
+ - +
+
+
+ 184 + +
+ - + if (array_key_exists('bibliography', $data)) { +
+
+ 185 + +
+ - + $disease->setBibliography($data['bibliography']); +
+
+ 186 + +
+ - + } +
+
+ 187 + +
+ - +
+
+
+ 188 + +
+ - + if (array_key_exists('staffCheck', $data) || array_key_exists('staff_check', $data)) { +
+
+ 189 + +
+ - + $disease->setStaffCheck($data['staffCheck'] ?? $data['staff_check']); +
+
+ 190 + +
+ - + } +
+
+ 191 + +
+ - +
+
+
+ 192 + +
+ - + if (array_key_exists('content', $data)) { +
+
+ 193 + +
+ - + $disease->setContent($data['content']); +
+
+ 194 + +
+ - + } +
+
+ 195 + +
+ - + } +
+
+ 196 + +
+ - +
+
+
+ 197 + +
+   + public function syncFromViewDisease(string $viewName = 'public.view_disease'): int +
+
+ 198 + +
+   + { +
+
+ 199 + +
+   + if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { +
+
+ 200 + +
+   + throw new \InvalidArgumentException('Invalid view name'); +
+
+ 201 + +
+   + } +
+
+ 202 + +
+   +
+
+
+ 203 + +
+ - + $connection = $this->em->getConnection(); +
+
+ 204 + +
+ - +
+
+
+ 205 + +
+   + $sql = sprintf( +
+
+ 206 + +
+   + 'INSERT INTO disease ( +
+
+ 207 + +
+   + id, +
+
+
@@ -282,6 +102,6 @@ final class DiseaseCrudService
+
+ 282 + +
+   + $viewName +
+
+ 283 + +
+   + ); +
+
+ 284 + +
+   +
+
+
+ 285 + +
+ - + return (int) $connection->executeStatement($sql); +
+
+ 286 + +
+   + } +
+
+ 287 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Service; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use Doctrine\ORM\EntityManagerInterface; +
+
+ 6 + +
+   +
+
+
+ 7 + +
+ + + /** +
+
+ 8 + +
+ + + * Импорт заболеваний из материализованного представления (Bitrix view). +
+
+ 9 + +
+ + + * +
+
+ 10 + +
+ + + * См. DiseaseController + CrudResponder для CRUD; этот сервис — только syncFromView*. +
+
+ 11 + +
+ + + */ +
+
+ 12 + +
+   + final class DiseaseCrudService +
+
+ 13 + +
+   + { +
+
+ 14 + +
+   + public function __construct( +
+
+ 15 + +
+   + private EntityManagerInterface $em, +
+
+ + +
+   +
+
+
+ 16 + +
+   + ) { +
+
+ 17 + +
+   + } +
+
+ 18 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 19 + +
+   + public function syncFromViewDisease(string $viewName = 'public.view_disease'): int +
+
+ 20 + +
+   + { +
+
+ 21 + +
+   + if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { +
+
+ 22 + +
+   + throw new \InvalidArgumentException('Invalid view name'); +
+
+ 23 + +
+   + } +
+
+ 24 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 25 + +
+   + $sql = sprintf( +
+
+ 26 + +
+   + 'INSERT INTO disease ( +
+
+ 27 + +
+   + id, +
+
+
 
+
+ 102 + +
+   + $viewName +
+
+ 103 + +
+   + ); +
+
+ 104 + +
+   +
+
+
+ 105 + +
+ + + return (int) $this->em->getConnection()->executeStatement($sql); +
+
+ 106 + +
+   + } +
+
+ 107 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Service/MedicalCenterCrudService.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,312 +2,127 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Service; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ - + use App\Entity\MedicalCenter; +
+
+ 6 + +
+ - + use App\Repository\MedicalCenterRepository; +
+
+ 7 + +
+   + use Doctrine\ORM\EntityManagerInterface; +
+
+ 8 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 9 + +
+   + final class MedicalCenterCrudService +
+
+ 10 + +
+   + { +
+
+ 11 + +
+ - + public function __construct( +
+
+ 12 + +
+ - + private EntityManagerInterface $em, +
+
+ 13 + +
+ - + private MedicalCenterRepository $medicalCenterRepository +
+
+ 14 + +
+ - + ) { +
+
+ 15 + +
+ - + } +
+
+ 16 + +
+ - +
+
+
+ 17 + +
+ - + /** +
+
+ 18 + +
+ - + * @return MedicalCenter[] +
+
+ 19 + +
+ - + */ +
+
+ 20 + +
+ - + public function getList(?int $regionId = null, ?bool $active = true): array +
+
+ 21 + +
+ - + { +
+
+ 22 + +
+ - + $criteria = []; +
+
+ 23 + +
+ - + if ($regionId !== null) { +
+
+ 24 + +
+ - + $criteria['regionId'] = $regionId; +
+
+ 25 + +
+ - + } +
+
+ 26 + +
+ - + if ($active !== null) { +
+
+ 27 + +
+ - + $criteria['active'] = $active; +
+
+ 28 + +
+ - + } +
+
+ 29 + +
+ - +
+
+
+ 30 + +
+ - + return $this->medicalCenterRepository->findBy($criteria, ['id' => 'ASC']); +
+
+ 31 + +
+ - + } +
+
+ 32 + +
+ - +
+
+
+ 33 + +
+ - + public function getShow(int $id): ?MedicalCenter +
+
+ 34 + +
+ - + { +
+
+ 35 + +
+ - + return $this->medicalCenterRepository->find($id); +
+
+ 36 + +
+ - + } +
+
+ 37 + +
+ - +
+
+
+ 38 + +
+ - + public function create(array $data): MedicalCenter +
+
+ 39 + +
+ - + { +
+
+ 40 + +
+ - + $medicalCenter = new MedicalCenter(); +
+
+ 41 + +
+ - + $this->updateEntity($medicalCenter, $data); +
+
+ 42 + +
+ - +
+
+
+ 43 + +
+ - + $this->em->persist($medicalCenter); +
+
+ 44 + +
+ - + $this->em->flush(); +
+
+ 45 + +
+ - +
+
+
+ 46 + +
+ - + return $medicalCenter; +
+
+ 47 + +
+ - + } +
+
+ 48 + +
+ - +
+
+
+ 49 + +
+ - + public function update(MedicalCenter $medicalCenter, array $data): MedicalCenter +
+
+ 50 + +
+ - + { +
+
+ 51 + +
+ - + unset($data['id']); +
+
+ 52 + +
+ - + $this->updateEntity($medicalCenter, $data); +
+
+ 53 + +
+ - +
+
+
+ 54 + +
+ - + $this->em->flush(); +
+
+ 55 + +
+ - + return $medicalCenter; +
+
+ 56 + +
+ - + } +
+
+ 57 + +
+ - +
+
+
+ 58 + +
+ - + public function delete(MedicalCenter $medicalCenter): void +
+
+ 59 + +
+ - + { +
+
+ 60 + +
+ - + $this->em->remove($medicalCenter); +
+
+ 61 + +
+ - + $this->em->flush(); +
+
+ 62 + +
+ - + } +
+
+ 63 + +
+ - +
+
+
+ 64 + +
+ - + private function updateEntity(MedicalCenter $medicalCenter, array $data): void +
+
+ 65 + +
+ - + { +
+
+ 66 + +
+ - + if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') { +
+
+ 67 + +
+ - + $medicalCenter->setId((int) $data['id']); +
+
+ 68 + +
+ - + } +
+
+ 69 + +
+ - +
+
+
+ 70 + +
+ - + if (array_key_exists('name', $data)) { +
+
+ 71 + +
+ - + $medicalCenter->setName($data['name']); +
+
+ 72 + +
+ - + } +
+
+ 73 + +
+ - +
+
+
+ 74 + +
+ - + if (array_key_exists('active', $data)) { +
+
+ 75 + +
+ - + $medicalCenter->setActive($data['active']); +
+
+ 76 + +
+ - + } +
+
+ 77 + +
+ - +
+
+
+ 78 + +
+ - + if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { +
+
+ 79 + +
+ - + $v = $data['regionId'] ?? $data['region_id']; +
+
+ 80 + +
+ - + $medicalCenter->setRegionId($v === null || $v === '' ? null : (int) $v); +
+
+ 81 + +
+ - + } +
+
+ 82 + +
+ - +
+
+
+ 83 + +
+ - + if (array_key_exists('alias', $data)) { +
+
+ 84 + +
+ - + $medicalCenter->setAlias($data['alias']); +
+
+ 85 + +
+ - + } +
+
+ 86 + +
+ - +
+
+
+ 87 + +
+ - + if (array_key_exists('anons', $data)) { +
+
+ 88 + +
+ - + $medicalCenter->setAnons($data['anons']); +
+
+ 89 + +
+ - + } +
+
+ 90 + +
+ - +
+
+
+ 91 + +
+ - + if (array_key_exists('content', $data)) { +
+
+ 92 + +
+ - + $medicalCenter->setContent($data['content']); +
+
+ 93 + +
+ - + } +
+
+ 94 + +
+ - +
+
+
+ 95 + +
+ - + if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { +
+
+ 96 + +
+ - + $raw = $data['updateAt'] ?? $data['update_at']; +
+
+ 97 + +
+ - + if ($raw === null || $raw === '') { +
+
+ 98 + +
+ - + $medicalCenter->setUpdateAt(null); +
+
+ 99 + +
+ - + } elseif ($raw instanceof \DateTimeInterface) { +
+
+ 100 + +
+ - + $medicalCenter->setUpdateAt($raw); +
+
+ 101 + +
+ - + } elseif (is_string($raw)) { +
+
+ 102 + +
+ - + $medicalCenter->setUpdateAt(new \DateTimeImmutable($raw)); +
+
+ 103 + +
+ - + } +
+
+ 104 + +
+ - + } +
+
+ 105 + +
+ - +
+
+
+ 106 + +
+ - + if (array_key_exists('kodUslug', $data) || array_key_exists('kod_uslug', $data)) { +
+
+ 107 + +
+ - + $medicalCenter->setKodUslug($data['kodUslug'] ?? $data['kod_uslug']); +
+
+ 108 + +
+ - + } +
+
+ 109 + +
+ - +
+
+
+ 110 + +
+ - + if (array_key_exists('doctors', $data)) { +
+
+ 111 + +
+ - + $medicalCenter->setDoctors($data['doctors']); +
+
+ 112 + +
+ - + } +
+
+ 113 + +
+ - +
+
+
+ 114 + +
+ - + if (array_key_exists('services', $data)) { +
+
+ 115 + +
+ - + $medicalCenter->setServices($data['services']); +
+
+ 116 + +
+ - + } +
+
+ 117 + +
+ - +
+
+
+ 118 + +
+ - + if (array_key_exists('articles', $data)) { +
+
+ 119 + +
+ - + $medicalCenter->setArticles($data['articles']); +
+
+ 120 + +
+ - + } +
+
+ 121 + +
+ - +
+
+
+ 122 + +
+ - + if (array_key_exists('txtUp', $data) || array_key_exists('txt_up', $data)) { +
+
+ 123 + +
+ - + $medicalCenter->setTxtUp($data['txtUp'] ?? $data['txt_up']); +
+
+ 124 + +
+ - + } +
+
+ 125 + +
+ - +
+
+
+ 126 + +
+ - + if (array_key_exists('mainLinkStaff', $data) || array_key_exists('main_link_staff', $data)) { +
+
+ 127 + +
+ - + $medicalCenter->setMainLinkStaff($data['mainLinkStaff'] ?? $data['main_link_staff']); +
+
+ 128 + +
+ - + } +
+
+ 129 + +
+ - +
+
+
+ 130 + +
+ - + if (array_key_exists('contraindications', $data)) { +
+
+ 131 + +
+ - + $medicalCenter->setContraindications($data['contraindications']); +
+
+ 132 + +
+ - + } +
+
+ 133 + +
+ - +
+
+
+ 134 + +
+ - + if (array_key_exists('hidePicture', $data) || array_key_exists('hide_picture', $data)) { +
+
+ 135 + +
+ - + $v = $data['hidePicture'] ?? $data['hide_picture']; +
+
+ 136 + +
+ - + $medicalCenter->setHidePicture($v === null || $v === '' ? null : (int) $v); +
+
+ 137 + +
+ - + } +
+
+ 138 + +
+ - +
+
+
+ 139 + +
+ - + if (array_key_exists('indications', $data)) { +
+
+ 140 + +
+ - + $medicalCenter->setIndications($data['indications']); +
+
+ 141 + +
+ - + } +
+
+ 142 + +
+ - +
+
+
+ 143 + +
+ - + if (array_key_exists('linkSale', $data) || array_key_exists('link_sale', $data)) { +
+
+ 144 + +
+ - + $medicalCenter->setLinkSale($data['linkSale'] ?? $data['link_sale']); +
+
+ 145 + +
+ - + } +
+
+ 146 + +
+ - +
+
+
+ 147 + +
+ - + if (array_key_exists('plusList', $data) || array_key_exists('plus_list', $data)) { +
+
+ 148 + +
+ - + $medicalCenter->setPlusList($data['plusList'] ?? $data['plus_list']); +
+
+ 149 + +
+ - + } +
+
+ 150 + +
+ - +
+
+
+ 151 + +
+ - + if (array_key_exists('plusText', $data) || array_key_exists('plus_text', $data)) { +
+
+ 152 + +
+ - + $medicalCenter->setPlusText($data['plusText'] ?? $data['plus_text']); +
+
+ 153 + +
+ - + } +
+
+ 154 + +
+ - +
+
+
+ 155 + +
+ - + if (array_key_exists('plusTitle', $data) || array_key_exists('plus_title', $data)) { +
+
+ 156 + +
+ - + $medicalCenter->setPlusTitle($data['plusTitle'] ?? $data['plus_title']); +
+
+ 157 + +
+ - + } +
+
+ 158 + +
+ - +
+
+
+ 159 + +
+ - + if (array_key_exists('processText', $data) || array_key_exists('process_text', $data)) { +
+
+ 160 + +
+ - + $medicalCenter->setProcessText($data['processText'] ?? $data['process_text']); +
+
+ 161 + +
+ - + } +
+
+ 162 + +
+ - +
+
+
+ 163 + +
+ - + if (array_key_exists('processTitle', $data) || array_key_exists('process_title', $data)) { +
+
+ 164 + +
+ - + $medicalCenter->setProcessTitle($data['processTitle'] ?? $data['process_title']); +
+
+ 165 + +
+ - + } +
+
+ 166 + +
+ - +
+
+
+ 167 + +
+ - + if (array_key_exists('servicesList', $data) || array_key_exists('services_list', $data)) { +
+
+ 168 + +
+ - + $medicalCenter->setServicesList($data['servicesList'] ?? $data['services_list']); +
+
+ 169 + +
+ - + } +
+
+ 170 + +
+ - +
+
+
+ 171 + +
+ - + if (array_key_exists('servicesPhotos', $data) || array_key_exists('services_photos', $data)) { +
+
+ 172 + +
+ - + $medicalCenter->setServicesPhotos($data['servicesPhotos'] ?? $data['services_photos']); +
+
+ 173 + +
+ - + } +
+
+ 174 + +
+ - +
+
+
+ 175 + +
+ - + if (array_key_exists('servicesTitle', $data) || array_key_exists('services_title', $data)) { +
+
+ 176 + +
+ - + $medicalCenter->setServicesTitle($data['servicesTitle'] ?? $data['services_title']); +
+
+ 177 + +
+ - + } +
+
+ 178 + +
+ - +
+
+
+ 179 + +
+ - + if (array_key_exists('sortStaff', $data) || array_key_exists('sort_staff', $data)) { +
+
+ 180 + +
+ - + $medicalCenter->setSortStaff($data['sortStaff'] ?? $data['sort_staff']); +
+
+ 181 + +
+ - + } +
+
+ 182 + +
+ - +
+
+
+ 183 + +
+ - + if (array_key_exists('trainingText', $data) || array_key_exists('training_text', $data)) { +
+
+ 184 + +
+ - + $medicalCenter->setTrainingText($data['trainingText'] ?? $data['training_text']); +
+
+ 185 + +
+ - + } +
+
+ 186 + +
+ - +
+
+
+ 187 + +
+ - + if (array_key_exists('trainingTextTitle', $data) || array_key_exists('training_text_title', $data)) { +
+
+ 188 + +
+ - + $medicalCenter->setTrainingTextTitle($data['trainingTextTitle'] ?? $data['training_text_title']); +
+
+ 189 + +
+ - + } +
+
+ 190 + +
+ - +
+
+
+ 191 + +
+ - + if (array_key_exists('whyText', $data) || array_key_exists('why_text', $data)) { +
+
+ 192 + +
+ - + $medicalCenter->setWhyText($data['whyText'] ?? $data['why_text']); +
+
+ 193 + +
+ - + } +
+
+ 194 + +
+ - +
+
+
+ 195 + +
+ - + if (array_key_exists('whyTitle', $data) || array_key_exists('why_title', $data)) { +
+
+ 196 + +
+ - + $medicalCenter->setWhyTitle($data['whyTitle'] ?? $data['why_title']); +
+
+ 197 + +
+ - + } +
+
+ 198 + +
+ - + } +
+
+ 199 + +
+ - +
+
+
+ 200 + +
+ - + public function syncFromViewCenters(string $viewName = 'public.view_centers'): int +
+
+ 201 + +
+ - + { +
+
+ 202 + +
+ - + // В опции разрешаем только идентификаторы (буквы/цифры/underscore) и точку для схемы. +
+
+ 203 + +
+ - + if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { +
+
+ 204 + +
+ - + throw new \InvalidArgumentException('Invalid view name'); +
+
+ 205 + +
+ - + } +
+
+ 206 + +
+ - +
+
+
+ 207 + +
+ - + $connection = $this->em->getConnection(); +
+
+ 208 + +
+ - +
+
+
+ 209 + +
+ - + $sql = sprintf( +
+
+ 210 + +
+ - + 'INSERT INTO medical_center ( +
+
+ 211 + +
+ - + id, +
+
+ 212 + +
+ - + name, +
+
+ 213 + +
+ - + active, +
+
+ 214 + +
+ - + region_id, +
+
+ 215 + +
+ - + alias, +
+
+ 216 + +
+ - + anons, +
+
+ 217 + +
+ - + content, +
+
+ 218 + +
+ - + update_at, +
+
+ 219 + +
+ - + kod_uslug, +
+
+ 220 + +
+ - + doctors, +
+
+ 221 + +
+ - + services, +
+
+ 222 + +
+ - + articles, +
+
+ 223 + +
+ - + txt_up, +
+
+ 224 + +
+ - + main_link_staff, +
+
+ 225 + +
+ - + contraindications, +
+
+ 226 + +
+ - + hide_picture, +
+
+ 227 + +
+ - + indications, +
+
+ 228 + +
+ - + link_sale, +
+
+ 229 + +
+ - + plus_list, +
+
+ 230 + +
+ - + plus_text, +
+
+ 231 + +
+ - + plus_title, +
+
+ 232 + +
+ - + process_text, +
+
+ 233 + +
+ - + process_title, +
+
+ 234 + +
+ - + services_list, +
+
+ 235 + +
+ - + services_photos, +
+
+ 236 + +
+ - + services_title, +
+
+ 237 + +
+ - + sort_staff, +
+
+ 238 + +
+ - + training_text, +
+
+ 239 + +
+ - + training_text_title, +
+
+ 240 + +
+ - + why_text, +
+
+ 241 + +
+ - + why_title +
+
+ 242 + +
+ - + ) +
+
+ 243 + +
+ - + SELECT +
+
+ 244 + +
+ - + id, +
+
+ 245 + +
+ - + name, +
+
+ 246 + +
+ - + active, +
+
+ 247 + +
+ - + region_id, +
+
+ 248 + +
+ - + alias, +
+
+ 249 + +
+ - + anons, +
+
+ 250 + +
+ - + content, +
+
+ 251 + +
+ - + update_at, +
+
+ 252 + +
+ - + kod_uslug, +
+
+ 253 + +
+ - + doctors, +
+
+ 254 + +
+ - + services, +
+
+ 255 + +
+ - + articles, +
+
+ 256 + +
+ - + txt_up, +
+
+ 257 + +
+ - + main_link_staff, +
+
+ 258 + +
+ - + contraindications, +
+
+ 259 + +
+ - + hide_picture, +
+
+ 260 + +
+ - + indications, +
+
+ 261 + +
+ - + link_sale, +
+
+ 262 + +
+ - + plus_list, +
+
+ 263 + +
+ - + plus_text, +
+
+ 264 + +
+ - + plus_title, +
+
+ 265 + +
+ - + process_text, +
+
+ 266 + +
+ - + process_title, +
+
+ 267 + +
+ - + services_list, +
+
+ 268 + +
+ - + services_photos, +
+
+ 269 + +
+ - + services_title, +
+
+ 270 + +
+ - + sort_staff, +
+
+ 271 + +
+ - + training_text, +
+
+ 272 + +
+ - + training_text_title, +
+
+ 273 + +
+ - + why_text, +
+
+ 274 + +
+ - + why_title +
+
+ 275 + +
+ - + FROM %s +
+
+ 276 + +
+ - + ON CONFLICT (id) DO UPDATE SET +
+
+ 277 + +
+ - + name = EXCLUDED.name, +
+
+ 278 + +
+ - + active = EXCLUDED.active, +
+
+ 279 + +
+ - + region_id = EXCLUDED.region_id, +
+
+ 280 + +
+ - + alias = EXCLUDED.alias, +
+
+ 281 + +
+ - + anons = EXCLUDED.anons, +
+
+ 282 + +
+ - + content = EXCLUDED.content, +
+
+ 283 + +
+ - + update_at = EXCLUDED.update_at, +
+
+ 284 + +
+ - + kod_uslug = EXCLUDED.kod_uslug, +
+
+ 285 + +
+ - + doctors = EXCLUDED.doctors, +
+
+ 286 + +
+ - + services = EXCLUDED.services, +
+
+ 287 + +
+ - + articles = EXCLUDED.articles, +
+
+ 288 + +
+ - + txt_up = EXCLUDED.txt_up, +
+
+ 289 + +
+ - + main_link_staff = EXCLUDED.main_link_staff, +
+
+ 290 + +
+ - + contraindications = EXCLUDED.contraindications, +
+
+ 291 + +
+ - + hide_picture = EXCLUDED.hide_picture, +
+
+ 292 + +
+ - + indications = EXCLUDED.indications, +
+
+ 293 + +
+ - + link_sale = EXCLUDED.link_sale, +
+
+ 294 + +
+ - + plus_list = EXCLUDED.plus_list, +
+
+ 295 + +
+ - + plus_text = EXCLUDED.plus_text, +
+
+ 296 + +
+ - + plus_title = EXCLUDED.plus_title, +
+
+ 297 + +
+ - + process_text = EXCLUDED.process_text, +
+
+ 298 + +
+ - + process_title = EXCLUDED.process_title, +
+
+ 299 + +
+ - + services_list = EXCLUDED.services_list, +
+
+ 300 + +
+ - + services_photos = EXCLUDED.services_photos, +
+
+ 301 + +
+ - + services_title = EXCLUDED.services_title, +
+
+ 302 + +
+ - + sort_staff = EXCLUDED.sort_staff, +
+
+ 303 + +
+ - + training_text = EXCLUDED.training_text, +
+
+ 304 + +
+ - + training_text_title = EXCLUDED.training_text_title, +
+
+ 305 + +
+ - + why_text = EXCLUDED.why_text, +
+
+ 306 + +
+ - + why_title = EXCLUDED.why_title', +
+
+ 307 + +
+ - + $viewName +
+
+ 308 + +
+ - + ); +
+
+ 309 + +
+ - +
+
+
+ 310 + +
+ - + return (int) $connection->executeStatement($sql); +
+
+ 311 + +
+ - + } +
+
+ 312 + +
+   + } +
+
+ 313 + +
+ - +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Service; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use Doctrine\ORM\EntityManagerInterface; +
+
+ 6 + +
+   +
+
+
+ 7 + +
+ + + /** +
+
+ 8 + +
+ + + * Импорт центров из материализованного представления (Bitrix view). +
+
+ 9 + +
+ + + * +
+
+ 10 + +
+ + + * См. MedicalCenterController + CrudResponder для CRUD; этот сервис — только syncFromView*. +
+
+ 11 + +
+ + + */ +
+
+ 12 + +
+   + final class MedicalCenterCrudService +
+
+ 13 + +
+   + { +
+
+ 14 + +
+ + + public function __construct( +
+
+ 15 + +
+ + + private EntityManagerInterface $em, +
+
+ 16 + +
+ + + ) { +
+
+ 17 + +
+ + + } +
+
+ 18 + +
+ + +
+
+
+ 19 + +
+ + + public function syncFromViewCenters(string $viewName = 'public.view_centers'): int +
+
+ 20 + +
+ + + { +
+
+ 21 + +
+ + + if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { +
+
+ 22 + +
+ + + throw new \InvalidArgumentException('Invalid view name'); +
+
+ 23 + +
+ + + } +
+
+ 24 + +
+ + +
+
+
+ 25 + +
+ + + $sql = sprintf( +
+
+ 26 + +
+ + + 'INSERT INTO medical_center ( +
+
+ 27 + +
+ + + id, +
+
+ 28 + +
+ + + name, +
+
+ 29 + +
+ + + active, +
+
+ 30 + +
+ + + region_id, +
+
+ 31 + +
+ + + alias, +
+
+ 32 + +
+ + + anons, +
+
+ 33 + +
+ + + content, +
+
+ 34 + +
+ + + update_at, +
+
+ 35 + +
+ + + kod_uslug, +
+
+ 36 + +
+ + + doctors, +
+
+ 37 + +
+ + + services, +
+
+ 38 + +
+ + + articles, +
+
+ 39 + +
+ + + txt_up, +
+
+ 40 + +
+ + + main_link_staff, +
+
+ 41 + +
+ + + contraindications, +
+
+ 42 + +
+ + + hide_picture, +
+
+ 43 + +
+ + + indications, +
+
+ 44 + +
+ + + link_sale, +
+
+ 45 + +
+ + + plus_list, +
+
+ 46 + +
+ + + plus_text, +
+
+ 47 + +
+ + + plus_title, +
+
+ 48 + +
+ + + process_text, +
+
+ 49 + +
+ + + process_title, +
+
+ 50 + +
+ + + services_list, +
+
+ 51 + +
+ + + services_photos, +
+
+ 52 + +
+ + + services_title, +
+
+ 53 + +
+ + + sort_staff, +
+
+ 54 + +
+ + + training_text, +
+
+ 55 + +
+ + + training_text_title, +
+
+ 56 + +
+ + + why_text, +
+
+ 57 + +
+ + + why_title +
+
+ 58 + +
+ + + ) +
+
+ 59 + +
+ + + SELECT +
+
+ 60 + +
+ + + id, +
+
+ 61 + +
+ + + name, +
+
+ 62 + +
+ + + active, +
+
+ 63 + +
+ + + region_id, +
+
+ 64 + +
+ + + alias, +
+
+ 65 + +
+ + + anons, +
+
+ 66 + +
+ + + content, +
+
+ 67 + +
+ + + update_at, +
+
+ 68 + +
+ + + kod_uslug, +
+
+ 69 + +
+ + + doctors, +
+
+ 70 + +
+ + + services, +
+
+ 71 + +
+ + + articles, +
+
+ 72 + +
+ + + txt_up, +
+
+ 73 + +
+ + + main_link_staff, +
+
+ 74 + +
+ + + contraindications, +
+
+ 75 + +
+ + + hide_picture, +
+
+ 76 + +
+ + + indications, +
+
+ 77 + +
+ + + link_sale, +
+
+ 78 + +
+ + + plus_list, +
+
+ 79 + +
+ + + plus_text, +
+
+ 80 + +
+ + + plus_title, +
+
+ 81 + +
+ + + process_text, +
+
+ 82 + +
+ + + process_title, +
+
+ 83 + +
+ + + services_list, +
+
+ 84 + +
+ + + services_photos, +
+
+ 85 + +
+ + + services_title, +
+
+ 86 + +
+ + + sort_staff, +
+
+ 87 + +
+ + + training_text, +
+
+ 88 + +
+ + + training_text_title, +
+
+ 89 + +
+ + + why_text, +
+
+ 90 + +
+ + + why_title +
+
+ 91 + +
+ + + FROM %s +
+
+ 92 + +
+ + + ON CONFLICT (id) DO UPDATE SET +
+
+ 93 + +
+ + + name = EXCLUDED.name, +
+
+ 94 + +
+ + + active = EXCLUDED.active, +
+
+ 95 + +
+ + + region_id = EXCLUDED.region_id, +
+
+ 96 + +
+ + + alias = EXCLUDED.alias, +
+
+ 97 + +
+ + + anons = EXCLUDED.anons, +
+
+ 98 + +
+ + + content = EXCLUDED.content, +
+
+ 99 + +
+ + + update_at = EXCLUDED.update_at, +
+
+ 100 + +
+ + + kod_uslug = EXCLUDED.kod_uslug, +
+
+ 101 + +
+ + + doctors = EXCLUDED.doctors, +
+
+ 102 + +
+ + + services = EXCLUDED.services, +
+
+ 103 + +
+ + + articles = EXCLUDED.articles, +
+
+ 104 + +
+ + + txt_up = EXCLUDED.txt_up, +
+
+ 105 + +
+ + + main_link_staff = EXCLUDED.main_link_staff, +
+
+ 106 + +
+ + + contraindications = EXCLUDED.contraindications, +
+
+ 107 + +
+ + + hide_picture = EXCLUDED.hide_picture, +
+
+ 108 + +
+ + + indications = EXCLUDED.indications, +
+
+ 109 + +
+ + + link_sale = EXCLUDED.link_sale, +
+
+ 110 + +
+ + + plus_list = EXCLUDED.plus_list, +
+
+ 111 + +
+ + + plus_text = EXCLUDED.plus_text, +
+
+ 112 + +
+ + + plus_title = EXCLUDED.plus_title, +
+
+ 113 + +
+ + + process_text = EXCLUDED.process_text, +
+
+ 114 + +
+ + + process_title = EXCLUDED.process_title, +
+
+ 115 + +
+ + + services_list = EXCLUDED.services_list, +
+
+ 116 + +
+ + + services_photos = EXCLUDED.services_photos, +
+
+ 117 + +
+ + + services_title = EXCLUDED.services_title, +
+
+ 118 + +
+ + + sort_staff = EXCLUDED.sort_staff, +
+
+ 119 + +
+ + + training_text = EXCLUDED.training_text, +
+
+ 120 + +
+ + + training_text_title = EXCLUDED.training_text_title, +
+
+ 121 + +
+ + + why_text = EXCLUDED.why_text, +
+
+ 122 + +
+ + + why_title = EXCLUDED.why_title', +
+
+ 123 + +
+ + + $viewName +
+
+ 124 + +
+ + + ); +
+
+ 125 + +
+ + +
+
+
+ 126 + +
+ + + return (int) $this->em->getConnection()->executeStatement($sql); +
+
+ 127 + +
+ + + } +
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 128 + +
+   + } +
+
+ + +
+   +
+
+
+
+
+
+
+
+
+ + src/Service/NewsCrudService.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,148 +2,28 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Service; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ - + use App\Entity\News; +
+
+ 6 + +
+ - + use App\Repository\NewsRepository; +
+
+ 7 + +
+   + use Doctrine\ORM\EntityManagerInterface; +
+
+ 8 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 9 + +
+   + final class NewsCrudService +
+
+ 10 + +
+   + { +
+
+ 11 + +
+   + public function __construct( +
+
+ 12 + +
+   + private EntityManagerInterface $em, +
+
+ 13 + +
+ - + private NewsRepository $newsRepository +
+
+ 14 + +
+   + ) { +
+
+ 15 + +
+   + } +
+
+ 16 + +
+   +
+
+
+ 17 + +
+ - + /** +
+
+ 18 + +
+ - + * @return News[] +
+
+ 19 + +
+ - + */ +
+
+ 20 + +
+ - + public function getList(?int $regionId = null, ?bool $active = true): array +
+
+ 21 + +
+ - + { +
+
+ 22 + +
+ - + $criteria = []; +
+
+ 23 + +
+ - + if ($regionId !== null) { +
+
+ 24 + +
+ - + $criteria['regionId'] = $regionId; +
+
+ 25 + +
+ - + } +
+
+ 26 + +
+ - + if ($active !== null) { +
+
+ 27 + +
+ - + $criteria['active'] = $active; +
+
+ 28 + +
+ - + } +
+
+ 29 + +
+ - +
+
+
+ 30 + +
+ - + return $this->newsRepository->findBy($criteria, ['id' => 'ASC']); +
+
+ 31 + +
+ - + } +
+
+ 32 + +
+ - +
+
+
+ 33 + +
+ - + public function getShow(int $id): ?News +
+
+ 34 + +
+ - + { +
+
+ 35 + +
+ - + return $this->newsRepository->find($id); +
+
+ 36 + +
+ - + } +
+
+ 37 + +
+ - +
+
+
+ 38 + +
+ - + public function create(array $data): News +
+
+ 39 + +
+ - + { +
+
+ 40 + +
+ - + $news = new News(); +
+
+ 41 + +
+ - + $this->updateEntity($news, $data); +
+
+ 42 + +
+ - +
+
+
+ 43 + +
+ - + $this->em->persist($news); +
+
+ 44 + +
+ - + $this->em->flush(); +
+
+ 45 + +
+ - +
+
+
+ 46 + +
+ - + return $news; +
+
+ 47 + +
+ - + } +
+
+ 48 + +
+ - +
+
+
+ 49 + +
+ - + public function update(News $news, array $data): News +
+
+ 50 + +
+ - + { +
+
+ 51 + +
+ - + unset($data['id']); +
+
+ 52 + +
+ - + $this->updateEntity($news, $data); +
+
+ 53 + +
+ - +
+
+
+ 54 + +
+ - + $this->em->flush(); +
+
+ 55 + +
+ - + return $news; +
+
+ 56 + +
+ - + } +
+
+ 57 + +
+ - +
+
+
+ 58 + +
+ - + public function delete(News $news): void +
+
+ 59 + +
+ - + { +
+
+ 60 + +
+ - + $this->em->remove($news); +
+
+ 61 + +
+ - + $this->em->flush(); +
+
+ 62 + +
+ - + } +
+
+ 63 + +
+ - +
+
+
+ 64 + +
+ - + private function updateEntity(News $news, array $data): void +
+
+ 65 + +
+ - + { +
+
+ 66 + +
+ - + if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') { +
+
+ 67 + +
+ - + $news->setId((int) $data['id']); +
+
+ 68 + +
+ - + } +
+
+ 69 + +
+ - +
+
+
+ 70 + +
+ - + if (array_key_exists('name', $data)) { +
+
+ 71 + +
+ - + $news->setName($data['name']); +
+
+ 72 + +
+ - + } +
+
+ 73 + +
+ - +
+
+
+ 74 + +
+ - + if (array_key_exists('active', $data)) { +
+
+ 75 + +
+ - + $news->setActive($data['active']); +
+
+ 76 + +
+ - + } +
+
+ 77 + +
+ - +
+
+
+ 78 + +
+ - + if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { +
+
+ 79 + +
+ - + $v = $data['regionId'] ?? $data['region_id']; +
+
+ 80 + +
+ - + $news->setRegionId($v === null || $v === '' ? null : (int) $v); +
+
+ 81 + +
+ - + } +
+
+ 82 + +
+ - +
+
+
+ 83 + +
+ - + if (array_key_exists('alias', $data)) { +
+
+ 84 + +
+ - + $news->setAlias($data['alias']); +
+
+ 85 + +
+ - + } +
+
+ 86 + +
+ - +
+
+
+ 87 + +
+ - + if (array_key_exists('anons', $data)) { +
+
+ 88 + +
+ - + $news->setAnons($data['anons']); +
+
+ 89 + +
+ - + } +
+
+ 90 + +
+ - +
+
+
+ 91 + +
+ - + if (array_key_exists('content', $data)) { +
+
+ 92 + +
+ - + $news->setContent($data['content']); +
+
+ 93 + +
+ - + } +
+
+ 94 + +
+ - +
+
+
+ 95 + +
+ - + if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { +
+
+ 96 + +
+ - + $raw = $data['updateAt'] ?? $data['update_at']; +
+
+ 97 + +
+ - + if ($raw === null || $raw === '') { +
+
+ 98 + +
+ - + $news->setUpdateAt(null); +
+
+ 99 + +
+ - + } elseif ($raw instanceof \DateTimeInterface) { +
+
+ 100 + +
+ - + $news->setUpdateAt($raw); +
+
+ 101 + +
+ - + } elseif (is_string($raw)) { +
+
+ 102 + +
+ - + $news->setUpdateAt(new \DateTimeImmutable($raw)); +
+
+ 103 + +
+ - + } +
+
+ 104 + +
+ - + } +
+
+ 105 + +
+ - +
+
+
+ 106 + +
+ - + if (array_key_exists('linkElPrice', $data) || array_key_exists('link_el_price', $data)) { +
+
+ 107 + +
+ - + $news->setLinkElPrice($data['linkElPrice'] ?? $data['link_el_price']); +
+
+ 108 + +
+ - + } +
+
+ 109 + +
+ - +
+
+
+ 110 + +
+ - + if (array_key_exists('shortName', $data) || array_key_exists('short_name', $data)) { +
+
+ 111 + +
+ - + $news->setShortName($data['shortName'] ?? $data['short_name']); +
+
+ 112 + +
+ - + } +
+
+ 113 + +
+ - +
+
+
+ 114 + +
+ - + if (array_key_exists('timer', $data)) { +
+
+ 115 + +
+ - + $news->setTimer($data['timer']); +
+
+ 116 + +
+ - + } +
+
+ 117 + +
+ - +
+
+
+ 118 + +
+ - + if (array_key_exists('timerBg', $data) || array_key_exists('timer_bg', $data)) { +
+
+ 119 + +
+ - + $news->setTimerBg($data['timerBg'] ?? $data['timer_bg']); +
+
+ 120 + +
+ - + } +
+
+ 121 + +
+ - +
+
+
+ 122 + +
+ - + if (array_key_exists('formOrder', $data) || array_key_exists('form_order', $data)) { +
+
+ 123 + +
+ - + $news->setFormOrder($data['formOrder'] ?? $data['form_order']); +
+
+ 124 + +
+ - + } +
+
+ 125 + +
+ - +
+
+
+ 126 + +
+ - + if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) { +
+
+ 127 + +
+ - + $news->setLinkServices($data['linkServices'] ?? $data['link_services']); +
+
+ 128 + +
+ - + } +
+
+ 129 + +
+ - +
+
+
+ 130 + +
+ - + if (array_key_exists('linkStaff', $data) || array_key_exists('link_staff', $data)) { +
+
+ 131 + +
+ - + $news->setLinkStaff($data['linkStaff'] ?? $data['link_staff']); +
+
+ 132 + +
+ - + } +
+
+ 133 + +
+ - +
+
+
+ 134 + +
+ - + if (array_key_exists('photos', $data)) { +
+
+ 135 + +
+ - + $news->setPhotos($data['photos']); +
+
+ 136 + +
+ - + } +
+
+ 137 + +
+ - + } +
+
+ 138 + +
+ - +
+
+
+ 139 + +
+   + public function syncFromViewNews(string $viewName = 'public.view_news'): int +
+
+ 140 + +
+   + { +
+
+ 141 + +
+   + if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { +
+
+ 142 + +
+   + throw new \InvalidArgumentException('Invalid view name'); +
+
+ 143 + +
+   + } +
+
+ 144 + +
+   +
+
+
+ 145 + +
+ - + $connection = $this->em->getConnection(); +
+
+ 146 + +
+ - +
+
+
+ 147 + +
+   + $sql = sprintf( +
+
+ 148 + +
+   + 'INSERT INTO news ( +
+
+ 149 + +
+   + id, +
+
+
@@ -200,6 +80,6 @@ final class NewsCrudService
+
+ 200 + +
+   + $viewName +
+
+ 201 + +
+   + ); +
+
+ 202 + +
+   +
+
+
+ 203 + +
+ - + return (int) $connection->executeStatement($sql); +
+
+ 204 + +
+   + } +
+
+ 205 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Service; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use Doctrine\ORM\EntityManagerInterface; +
+
+ 6 + +
+   +
+
+
+ 7 + +
+ + + /** +
+
+ 8 + +
+ + + * Импорт новостей из материализованного представления (Bitrix view). +
+
+ 9 + +
+ + + * +
+
+ 10 + +
+ + + * CRUD (create/update/delete/list) живёт теперь в NewsController через +
+
+ 11 + +
+ + + * общие App\Service\Crud\CrudResponder и App\Service\Pagination\Paginator — +
+
+ 12 + +
+ + + * этот сервис отвечает только за синхронизацию (см. App\Command\UploadNewsCommand). +
+
+ 13 + +
+ + + */ +
+
+ 14 + +
+   + final class NewsCrudService +
+
+ 15 + +
+   + { +
+
+ 16 + +
+   + public function __construct( +
+
+ 17 + +
+   + private EntityManagerInterface $em, +
+
+ + +
+   +
+
+
+ 18 + +
+   + ) { +
+
+ 19 + +
+   + } +
+
+ 20 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 21 + +
+   + public function syncFromViewNews(string $viewName = 'public.view_news'): int +
+
+ 22 + +
+   + { +
+
+ 23 + +
+   + if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { +
+
+ 24 + +
+   + throw new \InvalidArgumentException('Invalid view name'); +
+
+ 25 + +
+   + } +
+
+ 26 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 27 + +
+   + $sql = sprintf( +
+
+ 28 + +
+   + 'INSERT INTO news ( +
+
+ 29 + +
+   + id, +
+
+
 
+
+ 80 + +
+   + $viewName +
+
+ 81 + +
+   + ); +
+
+ 82 + +
+   +
+
+
+ 83 + +
+ + + return (int) $this->em->getConnection()->executeStatement($sql); +
+
+ 84 + +
+   + } +
+
+ 85 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Service/Pagination/Paginator.php + ADDED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -0,0 +1,107 @@
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 1 + +
+ + + <?php +
+
+ 2 + +
+ + +
+
+
+ 3 + +
+ + + declare(strict_types=1); +
+
+ 4 + +
+ + +
+
+
+ 5 + +
+ + + namespace App\Service\Pagination; +
+
+ 6 + +
+ + +
+
+
+ 7 + +
+ + + use Doctrine\ORM\QueryBuilder; +
+
+ 8 + +
+ + + use Pagerfanta\Doctrine\ORM\QueryAdapter; +
+
+ 9 + +
+ + + use Pagerfanta\Exception\NotValidCurrentPageException; +
+
+ 10 + +
+ + + use Pagerfanta\Pagerfanta; +
+
+ 11 + +
+ + + use Symfony\Component\HttpFoundation\Request; +
+
+ 12 + +
+ + +
+
+
+ 13 + +
+ + + /** +
+
+ 14 + +
+ + + * Унифицированная обёртка над Pagerfanta + QueryAdapter. +
+
+ 15 + +
+ + + * +
+
+ 16 + +
+ + + * Соответствует существующему стилю проекта (см. PriceListController/SpecialistController): +
+
+ 17 + +
+ + + * читает page/perPage из Request, ограничивает perPage и возвращает массив +
+
+ 18 + +
+ + + * ['data' => [...], 'pagination' => [...]] в едином формате для новых list-контрактов. +
+
+ 19 + +
+ + + */ +
+
+ 20 + +
+ + + final class Paginator +
+
+ 21 + +
+ + + { +
+
+ 22 + +
+ + + public const DEFAULT_PER_PAGE = 50; +
+
+ 23 + +
+ + + public const MAX_PER_PAGE = 500; +
+
+ 24 + +
+ + +
+
+
+ 25 + +
+ + + /** +
+
+ 26 + +
+ + + * @return array{data: list<mixed>, pagination: array<string, int|bool>} +
+
+ 27 + +
+ + + */ +
+
+ 28 + +
+ + + public function paginate( +
+
+ 29 + +
+ + + QueryBuilder $qb, +
+
+ 30 + +
+ + + Request $request, +
+
+ 31 + +
+ + + int $defaultPerPage = self::DEFAULT_PER_PAGE, +
+
+ 32 + +
+ + + int $maxPerPage = self::MAX_PER_PAGE, +
+
+ 33 + +
+ + + ): array { +
+
+ 34 + +
+ + + $page = max(1, $request->query->getInt('page', 1)); +
+
+ 35 + +
+ + + $perPage = min( +
+
+ 36 + +
+ + + max(1, $request->query->getInt('perPage', $defaultPerPage)), +
+
+ 37 + +
+ + + $maxPerPage, +
+
+ 38 + +
+ + + ); +
+
+ 39 + +
+ + +
+
+
+ 40 + +
+ + + $pagerfanta = (new Pagerfanta(new QueryAdapter($qb))) +
+
+ 41 + +
+ + + ->setMaxPerPage($perPage); +
+
+ 42 + +
+ + +
+
+
+ 43 + +
+ + + try { +
+
+ 44 + +
+ + + $pagerfanta->setCurrentPage($page); +
+
+ 45 + +
+ + + } catch (NotValidCurrentPageException) { +
+
+ 46 + +
+ + + // выходим за пределы — возвращаем пустую страницу с корректным total +
+
+ 47 + +
+ + + $pagerfanta->setCurrentPage(max(1, $pagerfanta->getNbPages())); +
+
+ 48 + +
+ + + } +
+
+ 49 + +
+ + +
+
+
+ 50 + +
+ + + $data = iterator_to_array($pagerfanta->getCurrentPageResults(), false); +
+
+ 51 + +
+ + +
+
+
+ 52 + +
+ + + return [ +
+
+ 53 + +
+ + + 'data' => $data, +
+
+ 54 + +
+ + + 'pagination' => [ +
+
+ 55 + +
+ + + 'total' => $pagerfanta->getNbResults(), +
+
+ 56 + +
+ + + 'count' => count($data), +
+
+ 57 + +
+ + + 'per_page' => $pagerfanta->getMaxPerPage(), +
+
+ 58 + +
+ + + 'current_page' => $pagerfanta->getCurrentPage(), +
+
+ 59 + +
+ + + 'total_pages' => $pagerfanta->getNbPages(), +
+
+ 60 + +
+ + + 'has_previous_page' => $pagerfanta->hasPreviousPage(), +
+
+ 61 + +
+ + + 'has_next_page' => $pagerfanta->hasNextPage(), +
+
+ 62 + +
+ + + ], +
+
+ 63 + +
+ + + ]; +
+
+ 64 + +
+ + + } +
+
+ 65 + +
+ + +
+
+
+ 66 + +
+ + + /** +
+
+ 67 + +
+ + + * Legacy-формат для ArticleController. +
+
+ 68 + +
+ + + * +
+
+ 69 + +
+ + + * Старый контракт /article/list уже использовался клиентами: +
+
+ 70 + +
+ + + * - размер страницы приходит в query-параметре limit; +
+
+ 71 + +
+ + + * - метаданные лежат в ключе meta; +
+
+ 72 + +
+ + + * - поля называются total/page/limit/totalPages. +
+
+ 73 + +
+ + + * +
+
+ 74 + +
+ + + * @return array{data: list<mixed>, meta: array{total: int, page: int, limit: int, totalPages: int}} +
+
+ 75 + +
+ + + */ +
+
+ 76 + +
+ + + public function paginateWithLegacyMeta( +
+
+ 77 + +
+ + + QueryBuilder $qb, +
+
+ 78 + +
+ + + Request $request, +
+
+ 79 + +
+ + + int $defaultLimit = 20, +
+
+ 80 + +
+ + + int $maxLimit = 100, +
+
+ 81 + +
+ + + ): array { +
+
+ 82 + +
+ + + $page = max(1, $request->query->getInt('page', 1)); +
+
+ 83 + +
+ + + $limit = min( +
+
+ 84 + +
+ + + max(1, $request->query->getInt('limit', $defaultLimit)), +
+
+ 85 + +
+ + + $maxLimit, +
+
+ 86 + +
+ + + ); +
+
+ 87 + +
+ + +
+
+
+ 88 + +
+ + + $pagerfanta = (new Pagerfanta(new QueryAdapter($qb))) +
+
+ 89 + +
+ + + ->setMaxPerPage($limit); +
+
+ 90 + +
+ + +
+
+
+ 91 + +
+ + + try { +
+
+ 92 + +
+ + + $pagerfanta->setCurrentPage($page); +
+
+ 93 + +
+ + + } catch (NotValidCurrentPageException) { +
+
+ 94 + +
+ + + $pagerfanta->setCurrentPage(max(1, $pagerfanta->getNbPages())); +
+
+ 95 + +
+ + + } +
+
+ 96 + +
+ + +
+
+
+ 97 + +
+ + + return [ +
+
+ 98 + +
+ + + 'data' => iterator_to_array($pagerfanta->getCurrentPageResults(), false), +
+
+ 99 + +
+ + + 'meta' => [ +
+
+ 100 + +
+ + + 'total' => $pagerfanta->getNbResults(), +
+
+ 101 + +
+ + + 'page' => $pagerfanta->getCurrentPage(), +
+
+ 102 + +
+ + + 'limit' => $pagerfanta->getMaxPerPage(), +
+
+ 103 + +
+ + + 'totalPages' => $pagerfanta->getNbPages(), +
+
+ 104 + +
+ + + ], +
+
+ 105 + +
+ + + ]; +
+
+ 106 + +
+ + + } +
+
+ 107 + +
+ + + } +
+
+
+
+
+
+
+
+ + src/Service/PromoCrudService.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,148 +2,26 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Service; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ - + use App\Entity\Promo; +
+
+ 6 + +
+ - + use App\Repository\PromoRepository; +
+
+ 7 + +
+   + use Doctrine\ORM\EntityManagerInterface; +
+
+ 8 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 9 + +
+   + final class PromoCrudService +
+
+ 10 + +
+   + { +
+
+ 11 + +
+   + public function __construct( +
+
+ 12 + +
+   + private EntityManagerInterface $em, +
+
+ 13 + +
+ - + private PromoRepository $promoRepository +
+
+ 14 + +
+   + ) { +
+
+ 15 + +
+   + } +
+
+ 16 + +
+   +
+
+
+ 17 + +
+ - + /** +
+
+ 18 + +
+ - + * @return Promo[] +
+
+ 19 + +
+ - + */ +
+
+ 20 + +
+ - + public function getList(?int $regionId = null, ?bool $active = true): array +
+
+ 21 + +
+ - + { +
+
+ 22 + +
+ - + $criteria = []; +
+
+ 23 + +
+ - + if ($regionId !== null) { +
+
+ 24 + +
+ - + $criteria['regionId'] = $regionId; +
+
+ 25 + +
+ - + } +
+
+ 26 + +
+ - + if ($active !== null) { +
+
+ 27 + +
+ - + $criteria['active'] = $active; +
+
+ 28 + +
+ - + } +
+
+ 29 + +
+ - +
+
+
+ 30 + +
+ - + return $this->promoRepository->findBy($criteria, ['id' => 'ASC']); +
+
+ 31 + +
+ - + } +
+
+ 32 + +
+ - +
+
+
+ 33 + +
+ - + public function getShow(int $id): ?Promo +
+
+ 34 + +
+ - + { +
+
+ 35 + +
+ - + return $this->promoRepository->find($id); +
+
+ 36 + +
+ - + } +
+
+ 37 + +
+ - +
+
+
+ 38 + +
+ - + public function create(array $data): Promo +
+
+ 39 + +
+ - + { +
+
+ 40 + +
+ - + $promo = new Promo(); +
+
+ 41 + +
+ - + $this->updateEntity($promo, $data); +
+
+ 42 + +
+ - +
+
+
+ 43 + +
+ - + $this->em->persist($promo); +
+
+ 44 + +
+ - + $this->em->flush(); +
+
+ 45 + +
+ - +
+
+
+ 46 + +
+ - + return $promo; +
+
+ 47 + +
+ - + } +
+
+ 48 + +
+ - +
+
+
+ 49 + +
+ - + public function update(Promo $promo, array $data): Promo +
+
+ 50 + +
+ - + { +
+
+ 51 + +
+ - + unset($data['id']); +
+
+ 52 + +
+ - + $this->updateEntity($promo, $data); +
+
+ 53 + +
+ - +
+
+
+ 54 + +
+ - + $this->em->flush(); +
+
+ 55 + +
+ - + return $promo; +
+
+ 56 + +
+ - + } +
+
+ 57 + +
+ - +
+
+
+ 58 + +
+ - + public function delete(Promo $promo): void +
+
+ 59 + +
+ - + { +
+
+ 60 + +
+ - + $this->em->remove($promo); +
+
+ 61 + +
+ - + $this->em->flush(); +
+
+ 62 + +
+ - + } +
+
+ 63 + +
+ - +
+
+
+ 64 + +
+ - + private function updateEntity(Promo $promo, array $data): void +
+
+ 65 + +
+ - + { +
+
+ 66 + +
+ - + if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') { +
+
+ 67 + +
+ - + $promo->setId((int) $data['id']); +
+
+ 68 + +
+ - + } +
+
+ 69 + +
+ - +
+
+
+ 70 + +
+ - + if (array_key_exists('name', $data)) { +
+
+ 71 + +
+ - + $promo->setName($data['name']); +
+
+ 72 + +
+ - + } +
+
+ 73 + +
+ - +
+
+
+ 74 + +
+ - + if (array_key_exists('active', $data)) { +
+
+ 75 + +
+ - + $promo->setActive($data['active']); +
+
+ 76 + +
+ - + } +
+
+ 77 + +
+ - +
+
+
+ 78 + +
+ - + if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { +
+
+ 79 + +
+ - + $v = $data['regionId'] ?? $data['region_id']; +
+
+ 80 + +
+ - + $promo->setRegionId($v === null || $v === '' ? null : (int) $v); +
+
+ 81 + +
+ - + } +
+
+ 82 + +
+ - +
+
+
+ 83 + +
+ - + if (array_key_exists('alias', $data)) { +
+
+ 84 + +
+ - + $promo->setAlias($data['alias']); +
+
+ 85 + +
+ - + } +
+
+ 86 + +
+ - +
+
+
+ 87 + +
+ - + if (array_key_exists('anons', $data)) { +
+
+ 88 + +
+ - + $promo->setAnons($data['anons']); +
+
+ 89 + +
+ - + } +
+
+ 90 + +
+ - +
+
+
+ 91 + +
+ - + if (array_key_exists('content', $data)) { +
+
+ 92 + +
+ - + $promo->setContent($data['content']); +
+
+ 93 + +
+ - + } +
+
+ 94 + +
+ - +
+
+
+ 95 + +
+ - + if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { +
+
+ 96 + +
+ - + $raw = $data['updateAt'] ?? $data['update_at']; +
+
+ 97 + +
+ - + if ($raw === null || $raw === '') { +
+
+ 98 + +
+ - + $promo->setUpdateAt(null); +
+
+ 99 + +
+ - + } elseif ($raw instanceof \DateTimeInterface) { +
+
+ 100 + +
+ - + $promo->setUpdateAt($raw); +
+
+ 101 + +
+ - + } elseif (is_string($raw)) { +
+
+ 102 + +
+ - + $promo->setUpdateAt(new \DateTimeImmutable($raw)); +
+
+ 103 + +
+ - + } +
+
+ 104 + +
+ - + } +
+
+ 105 + +
+ - +
+
+
+ 106 + +
+ - + if (array_key_exists('clinics', $data)) { +
+
+ 107 + +
+ - + $promo->setClinics($data['clinics']); +
+
+ 108 + +
+ - + } +
+
+ 109 + +
+ - +
+
+
+ 110 + +
+ - + if (array_key_exists('timer', $data)) { +
+
+ 111 + +
+ - + $promo->setTimer($data['timer']); +
+
+ 112 + +
+ - + } +
+
+ 113 + +
+ - +
+
+
+ 114 + +
+ - + if (array_key_exists('timerBg', $data) || array_key_exists('timer_bg', $data)) { +
+
+ 115 + +
+ - + $promo->setTimerBg($data['timerBg'] ?? $data['timer_bg']); +
+
+ 116 + +
+ - + } +
+
+ 117 + +
+ - +
+
+
+ 118 + +
+ - + if (array_key_exists('shortName', $data) || array_key_exists('short_name', $data)) { +
+
+ 119 + +
+ - + $promo->setShortName($data['shortName'] ?? $data['short_name']); +
+
+ 120 + +
+ - + } +
+
+ 121 + +
+ - +
+
+
+ 122 + +
+ - + if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) { +
+
+ 123 + +
+ - + $promo->setLinkServices($data['linkServices'] ?? $data['link_services']); +
+
+ 124 + +
+ - + } +
+
+ 125 + +
+ - +
+
+
+ 126 + +
+ - + if (array_key_exists('linkStaff', $data) || array_key_exists('link_staff', $data)) { +
+
+ 127 + +
+ - + $promo->setLinkStaff($data['linkStaff'] ?? $data['link_staff']); +
+
+ 128 + +
+ - + } +
+
+ 129 + +
+ - +
+
+
+ 130 + +
+ - + if (array_key_exists('period', $data)) { +
+
+ 131 + +
+ - + $promo->setPeriod($data['period']); +
+
+ 132 + +
+ - + } +
+
+ 133 + +
+ - +
+
+
+ 134 + +
+ - + if (array_key_exists('photos', $data)) { +
+
+ 135 + +
+ - + $promo->setPhotos($data['photos']); +
+
+ 136 + +
+ - + } +
+
+ 137 + +
+ - + } +
+
+ 138 + +
+ - +
+
+
+ 139 + +
+   + public function syncFromViewPromo(string $viewName = 'public.view_promo'): int +
+
+ 140 + +
+   + { +
+
+ 141 + +
+   + if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { +
+
+ 142 + +
+   + throw new \InvalidArgumentException('Invalid view name'); +
+
+ 143 + +
+   + } +
+
+ 144 + +
+   +
+
+
+ 145 + +
+ - + $connection = $this->em->getConnection(); +
+
+ 146 + +
+ - +
+
+
+ 147 + +
+   + $sql = sprintf( +
+
+ 148 + +
+   + 'INSERT INTO promo ( +
+
+ 149 + +
+   + id, +
+
+
@@ -200,6 +78,6 @@ final class PromoCrudService
+
+ 200 + +
+   + $viewName +
+
+ 201 + +
+   + ); +
+
+ 202 + +
+   +
+
+
+ 203 + +
+ - + return (int) $connection->executeStatement($sql); +
+
+ 204 + +
+   + } +
+
+ 205 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Service; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use Doctrine\ORM\EntityManagerInterface; +
+
+ 6 + +
+   +
+
+
+ 7 + +
+ + + /** +
+
+ 8 + +
+ + + * Импорт акций из материализованного представления (Bitrix view). +
+
+ 9 + +
+ + + * +
+
+ 10 + +
+ + + * См. PromoController + CrudResponder для CRUD; этот сервис — только syncFromView*. +
+
+ 11 + +
+ + + */ +
+
+ 12 + +
+   + final class PromoCrudService +
+
+ 13 + +
+   + { +
+
+ 14 + +
+   + public function __construct( +
+
+ 15 + +
+   + private EntityManagerInterface $em, +
+
+ + +
+   +
+
+
+ 16 + +
+   + ) { +
+
+ 17 + +
+   + } +
+
+ 18 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 19 + +
+   + public function syncFromViewPromo(string $viewName = 'public.view_promo'): int +
+
+ 20 + +
+   + { +
+
+ 21 + +
+   + if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { +
+
+ 22 + +
+   + throw new \InvalidArgumentException('Invalid view name'); +
+
+ 23 + +
+   + } +
+
+ 24 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 25 + +
+   + $sql = sprintf( +
+
+ 26 + +
+   + 'INSERT INTO promo ( +
+
+ 27 + +
+   + id, +
+
+
 
+
+ 78 + +
+   + $viewName +
+
+ 79 + +
+   + ); +
+
+ 80 + +
+   +
+
+
+ 81 + +
+ + + return (int) $this->em->getConnection()->executeStatement($sql); +
+
+ 82 + +
+   + } +
+
+ 83 + +
+   + } +
+
+
+
+
+
+
+
+ + src/Service/SiteServiceCrudService.php + CHANGED + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -2,358 +2,26 @@
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Service; +
+
+ 4 + +
+   +
+
+
+ 5 + +
+ - + use App\Entity\SiteService; +
+
+ 6 + +
+ - + use App\Repository\SiteServiceRepository; +
+
+ 7 + +
+   + use Doctrine\ORM\EntityManagerInterface; +
+
+ 8 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 9 + +
+   + final class SiteServiceCrudService +
+
+ 10 + +
+   + { +
+
+ 11 + +
+   + public function __construct( +
+
+ 12 + +
+   + private EntityManagerInterface $em, +
+
+ 13 + +
+ - + private SiteServiceRepository $siteServiceRepository, +
+
+ 14 + +
+   + ) { +
+
+ 15 + +
+   + } +
+
+ 16 + +
+   +
+
+
+ 17 + +
+ - + /** +
+
+ 18 + +
+ - + * @return SiteService[] +
+
+ 19 + +
+ - + */ +
+
+ 20 + +
+ - + public function getList(?int $regionId = null, ?bool $active = true): array +
+
+ 21 + +
+ - + { +
+
+ 22 + +
+ - + $criteria = []; +
+
+ 23 + +
+ - + if ($regionId !== null) { +
+
+ 24 + +
+ - + $criteria['regionId'] = $regionId; +
+
+ 25 + +
+ - + } +
+
+ 26 + +
+ - + if ($active !== null) { +
+
+ 27 + +
+ - + $criteria['active'] = $active; +
+
+ 28 + +
+ - + } +
+
+ 29 + +
+ - +
+
+
+ 30 + +
+ - + return $this->siteServiceRepository->findBy($criteria, ['id' => 'ASC']); +
+
+ 31 + +
+ - + } +
+
+ 32 + +
+ - +
+
+
+ 33 + +
+ - + /** +
+
+ 34 + +
+ - + * @return array{data: SiteService[], total: int, page: int, per_page: int} +
+
+ 35 + +
+ - + */ +
+
+ 36 + +
+ - + public function getPaginatedList(int $page, int $perPage, ?int $regionId = null, ?bool $active = true): array +
+
+ 37 + +
+ - + { +
+
+ 38 + +
+ - + $page = max(1, $page); +
+
+ 39 + +
+ - + $perPage = min(max(1, $perPage), 500); +
+
+ 40 + +
+ - +
+
+
+ 41 + +
+ - + $countQb = $this->siteServiceRepository->createQueryBuilder('s') +
+
+ 42 + +
+ - + ->select('COUNT(s.id)'); +
+
+ 43 + +
+ - + if ($regionId !== null) { +
+
+ 44 + +
+ - + $countQb->andWhere('s.regionId = :regionId') +
+
+ 45 + +
+ - + ->setParameter('regionId', $regionId); +
+
+ 46 + +
+ - + } +
+
+ 47 + +
+ - + if ($active !== null) { +
+
+ 48 + +
+ - + $countQb->andWhere('s.active = :active') +
+
+ 49 + +
+ - + ->setParameter('active', $active); +
+
+ 50 + +
+ - + } +
+
+ 51 + +
+ - + $total = (int) $countQb->getQuery()->getSingleScalarResult(); +
+
+ 52 + +
+ - +
+
+
+ 53 + +
+ - + $qb = $this->siteServiceRepository->createQueryBuilder('s') +
+
+ 54 + +
+ - + ->orderBy('s.id', 'ASC'); +
+
+ 55 + +
+ - + if ($regionId !== null) { +
+
+ 56 + +
+ - + $qb->andWhere('s.regionId = :regionId') +
+
+ 57 + +
+ - + ->setParameter('regionId', $regionId); +
+
+ 58 + +
+ - + } +
+
+ 59 + +
+ - + if ($active !== null) { +
+
+ 60 + +
+ - + $qb->andWhere('s.active = :active') +
+
+ 61 + +
+ - + ->setParameter('active', $active); +
+
+ 62 + +
+ - + } +
+
+ 63 + +
+ - + $qb->setFirstResult(($page - 1) * $perPage) +
+
+ 64 + +
+ - + ->setMaxResults($perPage); +
+
+ 65 + +
+ - +
+
+
+ 66 + +
+ - + $data = $qb->getQuery()->getResult(); +
+
+ 67 + +
+ - +
+
+
+ 68 + +
+ - + return [ +
+
+ 69 + +
+ - + 'data' => $data, +
+
+ 70 + +
+ - + 'total' => $total, +
+
+ 71 + +
+ - + 'page' => $page, +
+
+ 72 + +
+ - + 'per_page' => $perPage, +
+
+ 73 + +
+ - + ]; +
+
+ 74 + +
+ - + } +
+
+ 75 + +
+ - +
+
+
+ 76 + +
+ - + public function getShow(int $id): ?SiteService +
+
+ 77 + +
+ - + { +
+
+ 78 + +
+ - + return $this->siteServiceRepository->find($id); +
+
+ 79 + +
+ - + } +
+
+ 80 + +
+ - +
+
+
+ 81 + +
+ - + public function create(array $data): SiteService +
+
+ 82 + +
+ - + { +
+
+ 83 + +
+ - + $siteService = new SiteService(); +
+
+ 84 + +
+ - + $this->updateEntity($siteService, $data); +
+
+ 85 + +
+ - +
+
+
+ 86 + +
+ - + $this->em->persist($siteService); +
+
+ 87 + +
+ - + $this->em->flush(); +
+
+ 88 + +
+ - +
+
+
+ 89 + +
+ - + return $siteService; +
+
+ 90 + +
+ - + } +
+
+ 91 + +
+ - +
+
+
+ 92 + +
+ - + public function update(SiteService $siteService, array $data): SiteService +
+
+ 93 + +
+ - + { +
+
+ 94 + +
+ - + unset($data['id']); +
+
+ 95 + +
+ - + $this->updateEntity($siteService, $data); +
+
+ 96 + +
+ - +
+
+
+ 97 + +
+ - + $this->em->flush(); +
+
+ 98 + +
+ - +
+
+
+ 99 + +
+ - + return $siteService; +
+
+ 100 + +
+ - + } +
+
+ 101 + +
+ - +
+
+
+ 102 + +
+ - + public function delete(SiteService $siteService): void +
+
+ 103 + +
+ - + { +
+
+ 104 + +
+ - + $this->em->remove($siteService); +
+
+ 105 + +
+ - + $this->em->flush(); +
+
+ 106 + +
+ - + } +
+
+ 107 + +
+ - +
+
+
+ 108 + +
+ - + private function updateEntity(SiteService $siteService, array $data): void +
+
+ 109 + +
+ - + { +
+
+ 110 + +
+ - + if (array_key_exists('id', $data)) { +
+
+ 111 + +
+ - + $v = $data['id']; +
+
+ 112 + +
+ - + $siteService->setId($v === null || $v === '' ? null : (int) $v); +
+
+ 113 + +
+ - + } +
+
+ 114 + +
+ - +
+
+
+ 115 + +
+ - + if (array_key_exists('name', $data)) { +
+
+ 116 + +
+ - + $siteService->setName($data['name']); +
+
+ 117 + +
+ - + } +
+
+ 118 + +
+ - +
+
+
+ 119 + +
+ - + if (array_key_exists('active', $data)) { +
+
+ 120 + +
+ - + $siteService->setActive($data['active']); +
+
+ 121 + +
+ - + } +
+
+ 122 + +
+ - +
+
+
+ 123 + +
+ - + if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { +
+
+ 124 + +
+ - + $v = $data['regionId'] ?? $data['region_id']; +
+
+ 125 + +
+ - + $siteService->setRegionId($v === null || $v === '' ? null : (int) $v); +
+
+ 126 + +
+ - + } +
+
+ 127 + +
+ - +
+
+
+ 128 + +
+ - + if (array_key_exists('alias', $data)) { +
+
+ 129 + +
+ - + $siteService->setAlias($data['alias']); +
+
+ 130 + +
+ - + } +
+
+ 131 + +
+ - +
+
+
+ 132 + +
+ - + if (array_key_exists('anons', $data)) { +
+
+ 133 + +
+ - + $siteService->setAnons($data['anons']); +
+
+ 134 + +
+ - + } +
+
+ 135 + +
+ - +
+
+
+ 136 + +
+ - + if (array_key_exists('content', $data)) { +
+
+ 137 + +
+ - + $siteService->setContent($data['content']); +
+
+ 138 + +
+ - + } +
+
+ 139 + +
+ - +
+
+
+ 140 + +
+ - + if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { +
+
+ 141 + +
+ - + $raw = $data['updateAt'] ?? $data['update_at']; +
+
+ 142 + +
+ - + if ($raw === null || $raw === '') { +
+
+ 143 + +
+ - + $siteService->setUpdateAt(null); +
+
+ 144 + +
+ - + } elseif ($raw instanceof \DateTimeInterface) { +
+
+ 145 + +
+ - + $siteService->setUpdateAt($raw); +
+
+ 146 + +
+ - + } elseif (is_string($raw)) { +
+
+ 147 + +
+ - + $siteService->setUpdateAt(new \DateTimeImmutable($raw)); +
+
+ 148 + +
+ - + } +
+
+ 149 + +
+ - + } +
+
+ 150 + +
+ - +
+
+
+ 151 + +
+ - + if (array_key_exists('linkVideoreviews', $data) || array_key_exists('link_videoreviews', $data)) { +
+
+ 152 + +
+ - + $siteService->setLinkVideoreviews($data['linkVideoreviews'] ?? $data['link_videoreviews']); +
+
+ 153 + +
+ - + } +
+
+ 154 + +
+ - +
+
+
+ 155 + +
+ - + if (array_key_exists('previewImg', $data) || array_key_exists('preview_img', $data)) { +
+
+ 156 + +
+ - + $siteService->setPreviewImg($data['previewImg'] ?? $data['preview_img']); +
+
+ 157 + +
+ - + } +
+
+ 158 + +
+ - +
+
+
+ 159 + +
+ - + if (array_key_exists('faq', $data)) { +
+
+ 160 + +
+ - + $siteService->setFaq($data['faq']); +
+
+ 161 + +
+ - + } +
+
+ 162 + +
+ - +
+
+
+ 163 + +
+ - + if (array_key_exists('partPrice', $data) || array_key_exists('part_price', $data)) { +
+
+ 164 + +
+ - + $siteService->setPartPrice($data['partPrice'] ?? $data['part_price']); +
+
+ 165 + +
+ - + } +
+
+ 166 + +
+ - +
+
+
+ 167 + +
+ - + if (array_key_exists('pokazaniya', $data)) { +
+
+ 168 + +
+ - + $siteService->setPokazaniya($data['pokazaniya']); +
+
+ 169 + +
+ - + } +
+
+ 170 + +
+ - +
+
+
+ 171 + +
+ - + if (array_key_exists('preparation', $data)) { +
+
+ 172 + +
+ - + $siteService->setPreparation($data['preparation']); +
+
+ 173 + +
+ - + } +
+
+ 174 + +
+ - +
+
+
+ 175 + +
+ - + if (array_key_exists('protivopokazaniya', $data)) { +
+
+ 176 + +
+ - + $siteService->setProtivopokazaniya($data['protivopokazaniya']); +
+
+ 177 + +
+ - + } +
+
+ 178 + +
+ - +
+
+
+ 179 + +
+ - + if (array_key_exists('hideSignBtn', $data) || array_key_exists('hide_sign_btn', $data)) { +
+
+ 180 + +
+ - + $siteService->setHideSignBtn($data['hideSignBtn'] ?? $data['hide_sign_btn']); +
+
+ 181 + +
+ - + } +
+
+ 182 + +
+ - +
+
+
+ 183 + +
+ - + if (array_key_exists('quiz', $data)) { +
+
+ 184 + +
+ - + $siteService->setQuiz($data['quiz']); +
+
+ 185 + +
+ - + } +
+
+ 186 + +
+ - +
+
+
+ 187 + +
+ - + if (array_key_exists('tags', $data)) { +
+
+ 188 + +
+ - + $siteService->setTags($data['tags']); +
+
+ 189 + +
+ - + } +
+
+ 190 + +
+ - +
+
+
+ 191 + +
+ - + if (array_key_exists('tagsImportant', $data) || array_key_exists('tags_important', $data)) { +
+
+ 192 + +
+ - + $siteService->setTagsImportant($data['tagsImportant'] ?? $data['tags_important']); +
+
+ 193 + +
+ - + } +
+
+ 194 + +
+ - +
+
+
+ 195 + +
+ - + if (array_key_exists('bannerImg', $data) || array_key_exists('banner_img', $data)) { +
+
+ 196 + +
+ - + $siteService->setBannerImg($data['bannerImg'] ?? $data['banner_img']); +
+
+ 197 + +
+ - + } +
+
+ 198 + +
+ - +
+
+
+ 199 + +
+ - + if (array_key_exists('bannerImgM', $data) || array_key_exists('banner_img_m', $data)) { +
+
+ 200 + +
+ - + $siteService->setBannerImgM($data['bannerImgM'] ?? $data['banner_img_m']); +
+
+ 201 + +
+ - + } +
+
+ 202 + +
+ - +
+
+
+ 203 + +
+ - + if (array_key_exists('bannerImgUrl', $data) || array_key_exists('banner_img_url', $data)) { +
+
+ 204 + +
+ - + $siteService->setBannerImgUrl($data['bannerImgUrl'] ?? $data['banner_img_url']); +
+
+ 205 + +
+ - + } +
+
+ 206 + +
+ - +
+
+
+ 207 + +
+ - + if (array_key_exists('clinics', $data)) { +
+
+ 208 + +
+ - + $siteService->setClinics($data['clinics']); +
+
+ 209 + +
+ - + } +
+
+ 210 + +
+ - +
+
+
+ 211 + +
+ - + if (array_key_exists('downloadFile', $data) || array_key_exists('download_file', $data)) { +
+
+ 212 + +
+ - + $siteService->setDownloadFile($data['downloadFile'] ?? $data['download_file']); +
+
+ 213 + +
+ - + } +
+
+ 214 + +
+ - +
+
+
+ 215 + +
+ - + if (array_key_exists('fullWidthBanner', $data) || array_key_exists('full_width_banner', $data)) { +
+
+ 216 + +
+ - + $siteService->setFullWidthBanner($data['fullWidthBanner'] ?? $data['full_width_banner']); +
+
+ 217 + +
+ - + } +
+
+ 218 + +
+ - +
+
+
+ 219 + +
+ - + if (array_key_exists('staffUp', $data) || array_key_exists('staff_up', $data)) { +
+
+ 220 + +
+ - + $siteService->setStaffUp($data['staffUp'] ?? $data['staff_up']); +
+
+ 221 + +
+ - + } +
+
+ 222 + +
+ - +
+
+
+ 223 + +
+ - + if (array_key_exists('advantages', $data)) { +
+
+ 224 + +
+ - + $siteService->setAdvantages($data['advantages']); +
+
+ 225 + +
+ - + } +
+
+ 226 + +
+ - +
+
+
+ 227 + +
+ - + if (array_key_exists('hidePicture', $data) || array_key_exists('hide_picture', $data)) { +
+
+ 228 + +
+ - + $v = $data['hidePicture'] ?? $data['hide_picture']; +
+
+ 229 + +
+ - + $siteService->setHidePicture($v === null || $v === '' ? null : (int) $v); +
+
+ 230 + +
+ - + } +
+
+ 231 + +
+ - +
+
+
+ 232 + +
+ - + if (array_key_exists('kodUslug', $data) || array_key_exists('kod_uslug', $data)) { +
+
+ 233 + +
+ - + $siteService->setKodUslug($data['kodUslug'] ?? $data['kod_uslug']); +
+
+ 234 + +
+ - + } +
+
+ 235 + +
+ - +
+
+
+ 236 + +
+ - + if (array_key_exists('linkPrice', $data) || array_key_exists('link_price', $data)) { +
+
+ 237 + +
+ - + $siteService->setLinkPrice($data['linkPrice'] ?? $data['link_price']); +
+
+ 238 + +
+ - + } +
+
+ 239 + +
+ - +
+
+
+ 240 + +
+ - + if (array_key_exists('photosTitle', $data) || array_key_exists('photos_title', $data)) { +
+
+ 241 + +
+ - + $siteService->setPhotosTitle($data['photosTitle'] ?? $data['photos_title']); +
+
+ 242 + +
+ - + } +
+
+ 243 + +
+ - +
+
+
+ 244 + +
+ - + if (array_key_exists('saleId', $data) || array_key_exists('sale_id', $data)) { +
+
+ 245 + +
+ - + $siteService->setSaleId($data['saleId'] ?? $data['sale_id']); +
+
+ 246 + +
+ - + } +
+
+ 247 + +
+ - +
+
+
+ 248 + +
+ - + if (array_key_exists('sortStaff', $data) || array_key_exists('sort_staff', $data)) { +
+
+ 249 + +
+ - + $siteService->setSortStaff($data['sortStaff'] ?? $data['sort_staff']); +
+
+ 250 + +
+ - + } +
+
+ 251 + +
+ - +
+
+
+ 252 + +
+ - + if (array_key_exists('contraindicationsList', $data) || array_key_exists('contraindications_list', $data)) { +
+
+ 253 + +
+ - + $siteService->setContraindicationsList($data['contraindicationsList'] ?? $data['contraindications_list']); +
+
+ 254 + +
+ - + } +
+
+ 255 + +
+ - +
+
+
+ 256 + +
+ - + if (array_key_exists('customBlockText', $data) || array_key_exists('custom_block_text', $data)) { +
+
+ 257 + +
+ - + $siteService->setCustomBlockText($data['customBlockText'] ?? $data['custom_block_text']); +
+
+ 258 + +
+ - + } +
+
+ 259 + +
+ - +
+
+
+ 260 + +
+ - + if (array_key_exists('customBlockText2', $data) || array_key_exists('custom_block_text2', $data)) { +
+
+ 261 + +
+ - + $siteService->setCustomBlockText2($data['customBlockText2'] ?? $data['custom_block_text2']); +
+
+ 262 + +
+ - + } +
+
+ 263 + +
+ - +
+
+
+ 264 + +
+ - + if (array_key_exists('customBlockTitle', $data) || array_key_exists('custom_block_title', $data)) { +
+
+ 265 + +
+ - + $siteService->setCustomBlockTitle($data['customBlockTitle'] ?? $data['custom_block_title']); +
+
+ 266 + +
+ - + } +
+
+ 267 + +
+ - +
+
+
+ 268 + +
+ - + if (array_key_exists('customBlockTitle2', $data) || array_key_exists('custom_block_title2', $data)) { +
+
+ 269 + +
+ - + $siteService->setCustomBlockTitle2($data['customBlockTitle2'] ?? $data['custom_block_title2']); +
+
+ 270 + +
+ - + } +
+
+ 271 + +
+ - +
+
+
+ 272 + +
+ - + if (array_key_exists('indicationsList', $data) || array_key_exists('indications_list', $data)) { +
+
+ 273 + +
+ - + $siteService->setIndicationsList($data['indicationsList'] ?? $data['indications_list']); +
+
+ 274 + +
+ - + } +
+
+ 275 + +
+ - +
+
+
+ 276 + +
+ - + if (array_key_exists('linkArticlesServices', $data) || array_key_exists('link_articles_services', $data)) { +
+
+ 277 + +
+ - + $siteService->setLinkArticlesServices($data['linkArticlesServices'] ?? $data['link_articles_services']); +
+
+ 278 + +
+ - + } +
+
+ 279 + +
+ - +
+
+
+ 280 + +
+ - + if (array_key_exists('plusList', $data) || array_key_exists('plus_list', $data)) { +
+
+ 281 + +
+ - + $siteService->setPlusList($data['plusList'] ?? $data['plus_list']); +
+
+ 282 + +
+ - + } +
+
+ 283 + +
+ - +
+
+
+ 284 + +
+ - + if (array_key_exists('plusText', $data) || array_key_exists('plus_text', $data)) { +
+
+ 285 + +
+ - + $siteService->setPlusText($data['plusText'] ?? $data['plus_text']); +
+
+ 286 + +
+ - + } +
+
+ 287 + +
+ - +
+
+
+ 288 + +
+ - + if (array_key_exists('plusTitle', $data) || array_key_exists('plus_title', $data)) { +
+
+ 289 + +
+ - + $siteService->setPlusTitle($data['plusTitle'] ?? $data['plus_title']); +
+
+ 290 + +
+ - + } +
+
+ 291 + +
+ - +
+
+
+ 292 + +
+ - + if (array_key_exists('prepareTitle', $data) || array_key_exists('prepare_title', $data)) { +
+
+ 293 + +
+ - + $siteService->setPrepareTitle($data['prepareTitle'] ?? $data['prepare_title']); +
+
+ 294 + +
+ - + } +
+
+ 295 + +
+ - +
+
+
+ 296 + +
+ - + if (array_key_exists('processText', $data) || array_key_exists('process_text', $data)) { +
+
+ 297 + +
+ - + $siteService->setProcessText($data['processText'] ?? $data['process_text']); +
+
+ 298 + +
+ - + } +
+
+ 299 + +
+ - +
+
+
+ 300 + +
+ - + if (array_key_exists('processTitle', $data) || array_key_exists('process_title', $data)) { +
+
+ 301 + +
+ - + $siteService->setProcessTitle($data['processTitle'] ?? $data['process_title']); +
+
+ 302 + +
+ - + } +
+
+ 303 + +
+ - +
+
+
+ 304 + +
+ - + if (array_key_exists('servicesList', $data) || array_key_exists('services_list', $data)) { +
+
+ 305 + +
+ - + $siteService->setServicesList($data['servicesList'] ?? $data['services_list']); +
+
+ 306 + +
+ - + } +
+
+ 307 + +
+ - +
+
+
+ 308 + +
+ - + if (array_key_exists('servicesPhotos', $data) || array_key_exists('services_photos', $data)) { +
+
+ 309 + +
+ - + $siteService->setServicesPhotos($data['servicesPhotos'] ?? $data['services_photos']); +
+
+ 310 + +
+ - + } +
+
+ 311 + +
+ - +
+
+
+ 312 + +
+ - + if (array_key_exists('servicesTitle', $data) || array_key_exists('services_title', $data)) { +
+
+ 313 + +
+ - + $siteService->setServicesTitle($data['servicesTitle'] ?? $data['services_title']); +
+
+ 314 + +
+ - + } +
+
+ 315 + +
+ - +
+
+
+ 316 + +
+ - + if (array_key_exists('textUp', $data) || array_key_exists('text_up', $data)) { +
+
+ 317 + +
+ - + $siteService->setTextUp($data['textUp'] ?? $data['text_up']); +
+
+ 318 + +
+ - + } +
+
+ 319 + +
+ - +
+
+
+ 320 + +
+ - + if (array_key_exists('trainingText', $data) || array_key_exists('training_text', $data)) { +
+
+ 321 + +
+ - + $siteService->setTrainingText($data['trainingText'] ?? $data['training_text']); +
+
+ 322 + +
+ - + } +
+
+ 323 + +
+ - +
+
+
+ 324 + +
+ - + if (array_key_exists('whyText', $data) || array_key_exists('why_text', $data)) { +
+
+ 325 + +
+ - + $siteService->setWhyText($data['whyText'] ?? $data['why_text']); +
+
+ 326 + +
+ - + } +
+
+ 327 + +
+ - +
+
+
+ 328 + +
+ - + if (array_key_exists('whyTitle', $data) || array_key_exists('why_title', $data)) { +
+
+ 329 + +
+ - + $siteService->setWhyTitle($data['whyTitle'] ?? $data['why_title']); +
+
+ 330 + +
+ - + } +
+
+ 331 + +
+ - +
+
+
+ 332 + +
+ - + if (array_key_exists('linkFaq', $data) || array_key_exists('link_faq', $data)) { +
+
+ 333 + +
+ - + $siteService->setLinkFaq($data['linkFaq'] ?? $data['link_faq']); +
+
+ 334 + +
+ - + } +
+
+ 335 + +
+ - +
+
+
+ 336 + +
+ - + if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) { +
+
+ 337 + +
+ - + $siteService->setLinkServices($data['linkServices'] ?? $data['link_services']); +
+
+ 338 + +
+ - + } +
+
+ 339 + +
+ - +
+
+
+ 340 + +
+ - + if (array_key_exists('linkStaff', $data) || array_key_exists('link_staff', $data)) { +
+
+ 341 + +
+ - + $siteService->setLinkStaff($data['linkStaff'] ?? $data['link_staff']); +
+
+ 342 + +
+ - + } +
+
+ 343 + +
+ - +
+
+
+ 344 + +
+ - + if (array_key_exists('photos', $data)) { +
+
+ 345 + +
+ - + $siteService->setPhotos($data['photos']); +
+
+ 346 + +
+ - + } +
+
+ 347 + +
+ - +
+
+
+ 348 + +
+ - + } +
+
+ 349 + +
+ - +
+
+
+ 350 + +
+   + public function syncFromViewServices(string $viewName = 'public.view_services'): int +
+
+ 351 + +
+   + { +
+
+ 352 + +
+ - + if (! preg_match('/^[A-Za-z0-9_.]+$/', $viewName)) { +
+
+ 353 + +
+   + throw new \InvalidArgumentException('Invalid view name'); +
+
+ 354 + +
+   + } +
+
+ 355 + +
+   +
+
+
+ 356 + +
+ - + $connection = $this->em->getConnection(); +
+
+ 357 + +
+   + $sql = sprintf( +
+
+ 358 + +
+   + 'INSERT INTO site_services ( +
+
+ 359 + +
+   + id, +
+
+
@@ -533,6 +201,6 @@ final class SiteServiceCrudService
+
+ 533 + +
+   + $viewName +
+
+ 534 + +
+   + ); +
+
+ 535 + +
+   +
+
+
+ 536 + +
+ - + return (int) $connection->executeStatement($sql); +
+
+ 537 + +
+   + } +
+
+ 538 + +
+   + } +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
 
+
+ 2 + +
+   +
+
+
+ 3 + +
+   + namespace App\Service; +
+
+ 4 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 5 + +
+   + use Doctrine\ORM\EntityManagerInterface; +
+
+ 6 + +
+   +
+
+
+ 7 + +
+ + + /** +
+
+ 8 + +
+ + + * Импорт услуг из материализованного представления (Bitrix view). +
+
+ 9 + +
+ + + * +
+
+ 10 + +
+ + + * См. SiteServiceController + CrudResponder для CRUD; этот сервис — только syncFromView*. +
+
+ 11 + +
+ + + */ +
+
+ 12 + +
+   + final class SiteServiceCrudService +
+
+ 13 + +
+   + { +
+
+ 14 + +
+   + public function __construct( +
+
+ 15 + +
+   + private EntityManagerInterface $em, +
+
+ + +
+   +
+
+
+ 16 + +
+   + ) { +
+
+ 17 + +
+   + } +
+
+ 18 + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ + +
+   +
+
+
+ 19 + +
+   + public function syncFromViewServices(string $viewName = 'public.view_services'): int +
+
+ 20 + +
+   + { +
+
+ 21 + +
+ + + if (!preg_match('/^[A-Za-z0-9_.]+$/', $viewName)) { +
+
+ 22 + +
+   + throw new \InvalidArgumentException('Invalid view name'); +
+
+ 23 + +
+   + } +
+
+ 24 + +
+   +
+
+
+ + +
+   +
+
+
+ 25 + +
+   + $sql = sprintf( +
+
+ 26 + +
+   + 'INSERT INTO site_services ( +
+
+ 27 + +
+   + id, +
+
+
 
+
+ 201 + +
+   + $viewName +
+
+ 202 + +
+   + ); +
+
+ 203 + +
+   +
+
+
+ 204 + +
+ + + return (int) $this->em->getConnection()->executeStatement($sql); +
+
+ 205 + +
+   + } +
+
+ 206 + +
+   + } +
+
+
+
+
+
+
+
+ + diff --git a/migrations/.gitignore b/migrations/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/migrations/Version20260213132749.php b/migrations/Version20260213132749.php new file mode 100644 index 0000000..9454901 --- /dev/null +++ b/migrations/Version20260213132749.php @@ -0,0 +1,44 @@ +addSql('CREATE TABLE article ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + name TEXT, + preview_picture TEXT, + active BOOLEAN DEFAULT NULL, + doctors JSONB DEFAULT NULL, + services JSONB DEFAULT NULL, + region_id INT DEFAULT NULL, + alias TEXT, + anons TEXT, + content TEXT, + update_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + PRIMARY KEY (id) + )'); + $this->addSql('CREATE INDEX idx_article_active ON article (active)'); + $this->addSql('CREATE INDEX idx_article_region_id ON article (region_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE article'); + } +} diff --git a/migrations/Version20260213132759.php b/migrations/Version20260213132759.php new file mode 100644 index 0000000..ec084a5 --- /dev/null +++ b/migrations/Version20260213132759.php @@ -0,0 +1,114 @@ +addSql('CREATE OR REPLACE VIEW public.view_article +AS WITH article_data AS ( + SELECT el.id AS article_id, + TRIM(TRAILING \'-\'::text FROM el.code) AS group_code, + el.id, + el.name, + el.preview_picture, + f.subdir, + f.file_name, + el.active, + el.iblock_id, + el.iblock_section_id, + el.code, + el.preview_text, + el.detail_text, + el.timestamp_x, + vap_doctors.value AS doctors, + vap_services.value AS services + FROM b_iblock_element el + JOIN b_file f ON f.id = el.preview_picture + LEFT JOIN view_article_props vap_doctors ON el.id = vap_doctors.id AND vap_doctors.code = \'LINK_STAFF\'::text + LEFT JOIN view_article_props vap_services ON el.id = vap_services.id AND vap_services.code = \'LINK_SERVICES\'::text + WHERE el.iblock_id = ANY (ARRAY[69, 70, 71, 149, 179, 231]) + ), grouped_articles AS ( + SELECT d.group_code, + COALESCE(max(d.id), NULL::integer) AS id, + COALESCE(NULLIF(TRIM(BOTH FROM max(d.name)), \'\'::text), NULL::text) AS name, + COALESCE(max( + CASE + WHEN d.preview_picture IS NOT NULL THEN concat_ws(\'/\'::text, COALESCE(\'/upload\'::text, \'\'::text), COALESCE(d.subdir, \'\'::text), COALESCE(d.file_name, \'\'::text)) + ELSE NULL::text + END), NULL::text) AS preview_picture, + COALESCE( + CASE + WHEN max( + CASE + WHEN d.active = true THEN 1 + ELSE 0 + END) = 1 THEN true + ELSE false + END, NULL::boolean) AS active, + CASE + WHEN count(d.doctors) FILTER (WHERE d.doctors IS NOT NULL AND TRIM(BOTH FROM d.doctors) <> \'\'::text) > 0 THEN jsonb_agg(DISTINCT TRIM(BOTH FROM d.doctors)) FILTER (WHERE d.doctors IS NOT NULL AND TRIM(BOTH FROM d.doctors) <> \'\'::text) + ELSE NULL::jsonb + END AS doctors, + CASE + WHEN count(d.services) FILTER (WHERE d.services IS NOT NULL AND TRIM(BOTH FROM d.services) <> \'\'::text) > 0 THEN jsonb_agg(DISTINCT TRIM(BOTH FROM d.services)) FILTER (WHERE d.services IS NOT NULL AND TRIM(BOTH FROM d.services) <> \'\'::text) + ELSE NULL::jsonb + END AS services, + COALESCE(max( + CASE d.iblock_id + WHEN 69 THEN 91 + WHEN 149 THEN 91 + WHEN 179 THEN 91 + WHEN 70 THEN 92 + WHEN 71 THEN 93 + WHEN 231 THEN 94 + ELSE d.iblock_id + END), NULL::integer) AS region_id, + COALESCE(max(d.code), NULL::text) AS alias, + COALESCE(max(d.preview_text), NULL::text) AS anons, + COALESCE(max(d.detail_text), NULL::text) AS content, + COALESCE(max(d.timestamp_x), NULL::timestamp without time zone) AS update_at + FROM article_data d + GROUP BY d.group_code + ) + SELECT ga.id, + ga.name, + ga.preview_picture, + ga.active, + ga.doctors, + ga.services, + ga.region_id, + ga.alias, + ga.anons, + ga.content, + ga.update_at + FROM grouped_articles ga + ORDER BY ga.id'); + + // Наполняем таблицу article данными из представления + $this->addSql('INSERT INTO article (id, name, preview_picture, active, doctors, services, region_id, alias, anons, content, update_at) + SELECT id, name, preview_picture, active, doctors, services, region_id, alias, anons, content, update_at + FROM view_article'); + } + + public function down(Schema $schema): void + { + $this->addSql('TRUNCATE TABLE article'); + $this->addSql('DROP VIEW IF EXISTS public.view_article'); + } +} diff --git a/migrations/Version20260311212936.php b/migrations/Version20260311212936.php new file mode 100644 index 0000000..2fbf5ee --- /dev/null +++ b/migrations/Version20260311212936.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE medical_center (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, alias VARCHAR(255) DEFAULT NULL, anons TEXT DEFAULT NULL, content TEXT DEFAULT NULL, kod_uslug JSONB DEFAULT NULL, doctors JSONB DEFAULT NULL, services JSONB DEFAULT NULL, articles JSONB DEFAULT NULL, txt_up TEXT DEFAULT NULL, PRIMARY KEY (id))'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE medical_center'); + $this->addSql('ALTER TABLE article ALTER doctors TYPE JSONB'); + $this->addSql('ALTER TABLE article ALTER services TYPE JSONB'); + $this->addSql('CREATE INDEX idx_article_region_id ON article (region_id)'); + $this->addSql('CREATE INDEX idx_article_active ON article (active)'); + } +} diff --git a/migrations/Version20260417120000.php b/migrations/Version20260417120000.php new file mode 100644 index 0000000..6a3d68d --- /dev/null +++ b/migrations/Version20260417120000.php @@ -0,0 +1,28 @@ +addSql('ALTER TABLE specialist_dcode_description ADD department BIGINT DEFAULT NULL'); + $this->addSql('CREATE INDEX idx_specialist_dcode_description_department ON specialist_dcode_description (department)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_specialist_dcode_description_department'); + $this->addSql('ALTER TABLE specialist_dcode_description DROP department'); + } +} 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/mr.diff b/mr.diff new file mode 100644 index 0000000..048cbe3 --- /dev/null +++ b/mr.diff @@ -0,0 +1,3255 @@ +diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml +index 26c60a8..af132f8 100644 +--- a/config/packages/nelmio_api_doc.yaml ++++ b/config/packages/nelmio_api_doc.yaml +@@ -16,5 +16,11 @@ nelmio_api_doc: + '^/specialist/list$', + '^/specialist/schedule$', + '^/pricelist/list$', +- '^/pricelist/department$' +- ] +\ No newline at end of file ++ '^/pricelist/department$', ++ '^/news($|/)', ++ '^/promo($|/)', ++ '^/disease($|/)', ++ '^/medical-center($|/)', ++ '^/article($|/)', ++ '^/site-services($|/)' ++ ] +diff --git a/migrations/Version20260515142000.php b/migrations/Version20260515142000.php +new file mode 100644 +index 0000000..e3fb5e2 +--- /dev/null ++++ b/migrations/Version20260515142000.php +@@ -0,0 +1,53 @@ ++addSql(sprintf('CREATE SEQUENCE IF NOT EXISTS %s OWNED BY %s.id', $sequence, $table)); ++ $this->addSql(sprintf( ++ 'SELECT setval(\'%s\', COALESCE((SELECT MAX(id) FROM %s), 0) + 1, false)', ++ $sequence, ++ $table, ++ )); ++ $this->addSql(sprintf( ++ 'ALTER TABLE %s ALTER COLUMN id SET DEFAULT nextval(\'%s\')', ++ $table, ++ $sequence, ++ )); ++ } ++ } ++ ++ public function down(Schema $schema): void ++ { ++ foreach (array_reverse(self::TABLES) as $table) { ++ $sequence = $table . '_id_seq'; ++ ++ $this->addSql(sprintf('ALTER TABLE %s ALTER COLUMN id DROP DEFAULT', $table)); ++ $this->addSql(sprintf('DROP SEQUENCE IF EXISTS %s', $sequence)); ++ } ++ } ++} +diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php +index ef454fe..5ab7989 100644 +--- a/src/Controller/ArticleController.php ++++ b/src/Controller/ArticleController.php +@@ -2,54 +2,46 @@ + + namespace App\Controller; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\Article; + use App\Repository\ArticleRepository; +-use Doctrine\ORM\EntityManagerInterface; ++use App\Service\Crud\CrudResponder; ++use App\Service\Pagination\Paginator; ++use Nelmio\ApiDocBundle\Attribute\Model; ++use OpenApi\Attributes as OA; + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\HttpFoundation\JsonResponse; + use Symfony\Component\HttpFoundation\Request; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\Routing\Annotation\Route; + use Symfony\Component\Security\Http\Attribute\IsGranted; +-use Symfony\Component\Serializer\SerializerInterface; +-use Symfony\Component\Validator\Validator\ValidatorInterface; +-use Exception; + + #[Route('/article')] + final class ArticleController extends AbstractController + { ++ private const READ_GROUPS = ['article:read']; ++ private const WRITE_GROUPS = ['article:write']; ++ + public function __construct( +- private EntityManagerInterface $em, +- private ValidatorInterface $validator, +- private SerializerInterface $serializer +- ) { } ++ private readonly CrudResponder $crud, ++ private readonly Paginator $paginator, ++ ) { ++ } + ++ #[OA\Tag(name: 'Статьи')] ++ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] ++ #[OA\Parameter(name: 'alias', in: 'query', schema: new OA\Schema(type: 'string'))] ++ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] + #[Route('/list', name: 'article_list', methods: ['GET'])] + public function list(Request $request, ArticleRepository $repository): JsonResponse + { +- $page = max(1, (int) $request->query->get('page', 1)); +- $limit = min(100, max(1, (int) $request->query->get('limit', 20))); +- +- $filters = [ +- 'alias' => $request->query->get('alias', ''), +- 'active' => $request->query->get('active', ''), +- 'regionId' => $request->query->get('regionId', ''), +- ]; ++ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); + +- $articles = $repository->findByFilters($filters, $page, $limit); +- $total = $repository->countByFilters($filters); +- $totalPages = (int) ceil($total / $limit); +- +- return $this->json([ +- 'data' => $articles, +- 'meta' => [ +- 'total' => $total, +- 'page' => $page, +- 'limit' => $limit, +- 'totalPages' => $totalPages, +- ], +- ], Response::HTTP_OK, [], [ +- 'groups' => ['article:read'] ++ return $this->json($this->paginator->paginateWithLegacyMeta($qb, $request), Response::HTTP_OK, [], [ ++ 'groups' => self::READ_GROUPS, + ]); + } + +@@ -60,99 +52,36 @@ final class ArticleController extends AbstractController + if (!$article) { + throw $this->createNotFoundException('Статья не найдена'); + } +- return $this->json($article, Response::HTTP_OK, [], [ +- 'groups' => ['article:read'] +- ]); ++ ++ return $this->crud->read($article, self::READ_GROUPS); + } + + #[Route('/{id}', name: 'article_show', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(Article $article): JsonResponse + { +- return $this->json($article, Response::HTTP_OK, [], [ +- 'groups' => ['article:read'] +- ]); ++ return $this->crud->read($article, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Article::class, groups: self::WRITE_GROUPS)))] + #[Route('/create', name: 'article_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { +- try { +- $article = $this->serializer->deserialize( +- $request->getContent(), +- Article::class, +- 'json', +- ['groups' => ['article:write']] +- ); +- +- $errors = $this->validator->validate($article); +- +- if (count($errors) > 0) { +- return $this->json($errors, Response::HTTP_BAD_REQUEST); +- } +- +- $this->em->persist($article); +- $this->em->flush(); +- +- return $this->json($article, Response::HTTP_CREATED, [], [ +- 'groups' => ['article:read'] +- ]); +- } catch (Exception $e) { +- return new JsonResponse([ +- 'error' => 'Ошибка при создании статьи', +- 'message' => $e->getMessage() +- ], Response::HTTP_INTERNAL_SERVER_ERROR); +- } ++ return $this->crud->create($request, Article::class, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Article::class, groups: self::WRITE_GROUPS)))] + #[Route('/{id}', name: 'article_update', methods: ['PUT'], requirements: ['id' => '\d+'])] + public function update(Request $request, Article $article): JsonResponse + { +- try { +- $this->serializer->deserialize( +- $request->getContent(), +- Article::class, +- 'json', +- [ +- 'groups' => ['article:write'], +- 'object_to_populate' => $article +- ] +- ); +- +- $errors = $this->validator->validate($article); +- +- if (count($errors) > 0) { +- return $this->json($errors, Response::HTTP_BAD_REQUEST); +- } +- +- $this->em->flush(); +- +- return $this->json($article, Response::HTTP_OK, [], [ +- 'groups' => ['article:read'] +- ]); +- } catch (Exception $e) { +- return new JsonResponse([ +- 'error' => 'Ошибка при обновлении статьи', +- 'message' => $e->getMessage() +- ], Response::HTTP_INTERNAL_SERVER_ERROR); +- } ++ return $this->crud->update($request, $article, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/{id}', name: 'article_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(Article $article): JsonResponse + { +- try { +- $this->em->remove($article); +- $this->em->flush(); +- +- return new JsonResponse(null, Response::HTTP_NO_CONTENT); +- } catch (Exception $e) { +- return new JsonResponse([ +- 'error' => 'Ошибка при удалении статьи', +- 'message' => $e->getMessage() +- ], Response::HTTP_INTERNAL_SERVER_ERROR); +- } ++ return $this->crud->delete($article); + } + } +diff --git a/src/Controller/DiseaseController.php b/src/Controller/DiseaseController.php +index 04b4ce0..34ccc34 100644 +--- a/src/Controller/DiseaseController.php ++++ b/src/Controller/DiseaseController.php +@@ -2,8 +2,13 @@ + + namespace App\Controller; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\Disease; +-use App\Service\DiseaseCrudService; ++use App\Repository\DiseaseRepository; ++use App\Service\Crud\CrudResponder; ++use App\Service\Pagination\Paginator; ++use Nelmio\ApiDocBundle\Attribute\Model; ++use OpenApi\Attributes as OA; + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\HttpFoundation\JsonResponse; + use Symfony\Component\HttpFoundation\Request; +@@ -14,90 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; + #[Route('/disease')] + final class DiseaseController extends AbstractController + { ++ private const READ_GROUPS = ['disease:read']; ++ private const WRITE_GROUPS = ['disease:write']; ++ + public function __construct( +- private DiseaseCrudService $diseaseCrud, ++ private readonly CrudResponder $crud, ++ private readonly Paginator $paginator, + ) { + } + ++ #[OA\Tag(name: 'Заболевания')] ++ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))] ++ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] + #[Route('/list', name: 'disease_list', methods: ['GET'])] +- public function list(Request $request): JsonResponse ++ public function list(Request $request, DiseaseRepository $repository): JsonResponse + { +- $page = $request->query->getInt('page', 1); +- $perPage = min($request->query->getInt('perPage', 100), 500); +- $regionId = $request->query->getInt('regionId', 0) ?: null; +- +- $result = $this->diseaseCrud->getPaginatedList($page, $perPage, $regionId); +- $data = $result['data']; +- $total = $result['total']; +- $perPage = $result['per_page']; +- $totalPages = (int) ceil($total / $perPage); ++ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); + +- return $this->json([ +- 'data' => $data, +- 'pagination' => [ +- 'total' => $total, +- 'count' => count($data), +- 'per_page' => $perPage, +- 'current_page' => $result['page'], +- 'total_pages' => $totalPages, +- 'has_previous_page' => $result['page'] > 1, +- 'has_next_page' => $result['page'] < $totalPages, +- ], +- ], Response::HTTP_OK, [], [ +- 'groups' => ['disease:read'], ++ return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ ++ 'groups' => self::READ_GROUPS, + ]); + } + + #[Route('/{id}', name: 'disease_show', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(Disease $disease): JsonResponse + { +- return $this->json($disease, Response::HTTP_OK, [], [ +- 'groups' => ['disease:read'], +- ]); ++ return $this->crud->read($disease, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Disease::class, groups: self::WRITE_GROUPS)))] + #[Route('/create', name: 'disease_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { +- $data = json_decode($request->getContent(), true); +- if (!is_array($data)) { +- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +- } +- +- try { +- $disease = $this->diseaseCrud->create($data); +- } catch (\InvalidArgumentException $e) { +- return $this->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); +- } +- +- return $this->json($disease, Response::HTTP_CREATED, [], [ +- 'groups' => ['disease:read'], +- ]); ++ return $this->crud->create($request, Disease::class, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Disease::class, groups: self::WRITE_GROUPS)))] + #[Route('/{id}', name: 'disease_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +- public function update(Disease $disease, Request $request): JsonResponse ++ public function update(Request $request, Disease $disease): JsonResponse + { +- $data = json_decode($request->getContent(), true); +- if (!is_array($data)) { +- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +- } +- +- $disease = $this->diseaseCrud->update($disease, $data); +- +- return $this->json($disease, Response::HTTP_OK, [], [ +- 'groups' => ['disease:read'], +- ]); ++ return $this->crud->update($request, $disease, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/{id}', name: 'disease_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(Disease $disease): JsonResponse + { +- $this->diseaseCrud->delete($disease); +- +- return new JsonResponse(null, Response::HTTP_NO_CONTENT); ++ return $this->crud->delete($disease); + } + } +diff --git a/src/Controller/MedicalCenterController.php b/src/Controller/MedicalCenterController.php +index 0492347..111bd43 100644 +--- a/src/Controller/MedicalCenterController.php ++++ b/src/Controller/MedicalCenterController.php +@@ -2,8 +2,13 @@ + + namespace App\Controller; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\MedicalCenter; +-use App\Service\MedicalCenterCrudService; ++use App\Repository\MedicalCenterRepository; ++use App\Service\Crud\CrudResponder; ++use App\Service\Pagination\Paginator; ++use Nelmio\ApiDocBundle\Attribute\Model; ++use OpenApi\Attributes as OA; + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\HttpFoundation\JsonResponse; + use Symfony\Component\HttpFoundation\Request; +@@ -14,72 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; + #[Route('/medical-center')] + final class MedicalCenterController extends AbstractController + { ++ private const READ_GROUPS = ['medical_center:read']; ++ private const WRITE_GROUPS = ['medical_center:write']; ++ + public function __construct( +- private MedicalCenterCrudService $medicalCenterCrud, ++ private readonly CrudResponder $crud, ++ private readonly Paginator $paginator, + ) { + } + ++ #[OA\Tag(name: 'Центры')] ++ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] ++ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] + #[Route('/list', name: 'medical_center_list', methods: ['GET'])] +- public function list(Request $request): JsonResponse ++ public function list(Request $request, MedicalCenterRepository $repository): JsonResponse + { +- $regionId = $request->query->getInt('regionId', 0); +- $activeParam = $request->query->get('active'); +- $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); +- if ($activeParam !== null && $active === null) { +- return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); +- } ++ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); + +- return $this->json(['data' => $this->medicalCenterCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [ +- 'groups' => ['medical_center:read'], ++ return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ ++ 'groups' => self::READ_GROUPS, + ]); + } + + #[Route('/{id}', name: 'medical_center_show', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(MedicalCenter $medicalCenter): JsonResponse + { +- return $this->json($medicalCenter, Response::HTTP_OK, [], [ +- 'groups' => ['medical_center:read'], +- ]); ++ return $this->crud->read($medicalCenter, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: MedicalCenter::class, groups: self::WRITE_GROUPS)))] + #[Route('/create', name: 'medical_center_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { +- $data = json_decode($request->getContent(), true); +- if (!is_array($data)) { +- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +- } +- +- $medicalCenter = $this->medicalCenterCrud->create($data); +- +- return $this->json($medicalCenter, Response::HTTP_CREATED, [], [ +- 'groups' => ['medical_center:read'], +- ]); ++ return $this->crud->create($request, MedicalCenter::class, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: MedicalCenter::class, groups: self::WRITE_GROUPS)))] + #[Route('/{id}', name: 'medical_center_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +- public function update(MedicalCenter $medicalCenter, Request $request): JsonResponse ++ public function update(Request $request, MedicalCenter $medicalCenter): JsonResponse + { +- $data = json_decode($request->getContent(), true); +- if (!is_array($data)) { +- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +- } +- +- $medicalCenter = $this->medicalCenterCrud->update($medicalCenter, $data); +- +- return $this->json($medicalCenter, Response::HTTP_OK, [], [ +- 'groups' => ['medical_center:read'], +- ]); ++ return $this->crud->update($request, $medicalCenter, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/{id}', name: 'medical_center_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(MedicalCenter $medicalCenter): JsonResponse + { +- $this->medicalCenterCrud->delete($medicalCenter); +- +- return new JsonResponse(null, Response::HTTP_NO_CONTENT); ++ return $this->crud->delete($medicalCenter); + } + } +diff --git a/src/Controller/NewsController.php b/src/Controller/NewsController.php +index c0c8c4e..fd9fb10 100644 +--- a/src/Controller/NewsController.php ++++ b/src/Controller/NewsController.php +@@ -2,8 +2,13 @@ + + namespace App\Controller; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\News; +-use App\Service\NewsCrudService; ++use App\Repository\NewsRepository; ++use App\Service\Crud\CrudResponder; ++use App\Service\Pagination\Paginator; ++use Nelmio\ApiDocBundle\Attribute\Model; ++use OpenApi\Attributes as OA; + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\HttpFoundation\JsonResponse; + use Symfony\Component\HttpFoundation\Request; +@@ -14,72 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; + #[Route('/news')] + final class NewsController extends AbstractController + { ++ private const READ_GROUPS = ['news:read']; ++ private const WRITE_GROUPS = ['news:write']; ++ + public function __construct( +- private NewsCrudService $newsCrud, ++ private readonly CrudResponder $crud, ++ private readonly Paginator $paginator, + ) { + } + ++ #[OA\Tag(name: 'Новости')] ++ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] ++ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] + #[Route('/list', name: 'news_list', methods: ['GET'])] +- public function list(Request $request): JsonResponse ++ public function list(Request $request, NewsRepository $repository): JsonResponse + { +- $regionId = $request->query->getInt('regionId', 0); +- $activeParam = $request->query->get('active'); +- $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); +- if ($activeParam !== null && $active === null) { +- return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); +- } ++ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); + +- return $this->json(['data' => $this->newsCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [ +- 'groups' => ['news:read'], ++ return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ ++ 'groups' => self::READ_GROUPS, + ]); + } + + #[Route('/{id}', name: 'news_show', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(News $news): JsonResponse + { +- return $this->json($news, Response::HTTP_OK, [], [ +- 'groups' => ['news:read'], +- ]); ++ return $this->crud->read($news, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: News::class, groups: self::WRITE_GROUPS)))] + #[Route('/create', name: 'news_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { +- $data = json_decode($request->getContent(), true); +- if (!is_array($data)) { +- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +- } +- +- $news = $this->newsCrud->create($data); +- +- return $this->json($news, Response::HTTP_CREATED, [], [ +- 'groups' => ['news:read'], +- ]); ++ return $this->crud->create($request, News::class, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: News::class, groups: self::WRITE_GROUPS)))] + #[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +- public function update(News $news, Request $request): JsonResponse ++ public function update(Request $request, News $news): JsonResponse + { +- $data = json_decode($request->getContent(), true); +- if (!is_array($data)) { +- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +- } +- +- $news = $this->newsCrud->update($news, $data); +- +- return $this->json($news, Response::HTTP_OK, [], [ +- 'groups' => ['news:read'], +- ]); ++ return $this->crud->update($request, $news, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/{id}', name: 'news_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(News $news): JsonResponse + { +- $this->newsCrud->delete($news); +- +- return new JsonResponse(null, Response::HTTP_NO_CONTENT); ++ return $this->crud->delete($news); + } + } +diff --git a/src/Controller/PromoController.php b/src/Controller/PromoController.php +index dee5970..48aafdd 100644 +--- a/src/Controller/PromoController.php ++++ b/src/Controller/PromoController.php +@@ -2,8 +2,13 @@ + + namespace App\Controller; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\Promo; +-use App\Service\PromoCrudService; ++use App\Repository\PromoRepository; ++use App\Service\Crud\CrudResponder; ++use App\Service\Pagination\Paginator; ++use Nelmio\ApiDocBundle\Attribute\Model; ++use OpenApi\Attributes as OA; + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\HttpFoundation\JsonResponse; + use Symfony\Component\HttpFoundation\Request; +@@ -14,72 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; + #[Route('/promo')] + final class PromoController extends AbstractController + { ++ private const READ_GROUPS = ['promo:read']; ++ private const WRITE_GROUPS = ['promo:write']; ++ + public function __construct( +- private PromoCrudService $promoCrud, ++ private readonly CrudResponder $crud, ++ private readonly Paginator $paginator, + ) { + } + ++ #[OA\Tag(name: 'Акции')] ++ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] ++ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] + #[Route('/list', name: 'promo_list', methods: ['GET'])] +- public function list(Request $request): JsonResponse ++ public function list(Request $request, PromoRepository $repository): JsonResponse + { +- $regionId = $request->query->getInt('regionId', 0); +- $activeParam = $request->query->get('active'); +- $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); +- if ($activeParam !== null && $active === null) { +- return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); +- } ++ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); + +- return $this->json(['data' => $this->promoCrud->getList($regionId > 0 ? $regionId : null, $active)], Response::HTTP_OK, [], [ +- 'groups' => ['promo:read'], ++ return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ ++ 'groups' => self::READ_GROUPS, + ]); + } + + #[Route('/{id}', name: 'promo_show', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(Promo $promo): JsonResponse + { +- return $this->json($promo, Response::HTTP_OK, [], [ +- 'groups' => ['promo:read'], +- ]); ++ return $this->crud->read($promo, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Promo::class, groups: self::WRITE_GROUPS)))] + #[Route('/create', name: 'promo_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { +- $data = json_decode($request->getContent(), true); +- if (!is_array($data)) { +- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +- } +- +- $promo = $this->promoCrud->create($data); +- +- return $this->json($promo, Response::HTTP_CREATED, [], [ +- 'groups' => ['promo:read'], +- ]); ++ return $this->crud->create($request, Promo::class, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: Promo::class, groups: self::WRITE_GROUPS)))] + #[Route('/{id}', name: 'promo_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +- public function update(Promo $promo, Request $request): JsonResponse ++ public function update(Request $request, Promo $promo): JsonResponse + { +- $data = json_decode($request->getContent(), true); +- if (!is_array($data)) { +- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +- } +- +- $promo = $this->promoCrud->update($promo, $data); +- +- return $this->json($promo, Response::HTTP_OK, [], [ +- 'groups' => ['promo:read'], +- ]); ++ return $this->crud->update($request, $promo, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/{id}', name: 'promo_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(Promo $promo): JsonResponse + { +- $this->promoCrud->delete($promo); +- +- return new JsonResponse(null, Response::HTTP_NO_CONTENT); ++ return $this->crud->delete($promo); + } + } +diff --git a/src/Controller/SiteServiceController.php b/src/Controller/SiteServiceController.php +index f078bad..5443153 100644 +--- a/src/Controller/SiteServiceController.php ++++ b/src/Controller/SiteServiceController.php +@@ -2,8 +2,13 @@ + + namespace App\Controller; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\SiteService; +-use App\Service\SiteServiceCrudService; ++use App\Repository\SiteServiceRepository; ++use App\Service\Crud\CrudResponder; ++use App\Service\Pagination\Paginator; ++use Nelmio\ApiDocBundle\Attribute\Model; ++use OpenApi\Attributes as OA; + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\HttpFoundation\JsonResponse; + use Symfony\Component\HttpFoundation\Request; +@@ -14,91 +19,57 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; + #[Route('/site-services')] + final class SiteServiceController extends AbstractController + { ++ private const READ_GROUPS = ['site_service:read']; ++ private const WRITE_GROUPS = ['site_service:write']; ++ + public function __construct( +- private SiteServiceCrudService $siteServiceCrud, ++ private readonly CrudResponder $crud, ++ private readonly Paginator $paginator, + ) { + } + ++ #[OA\Tag(name: 'Услуги')] ++ #[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'perPage', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))] ++ #[OA\Parameter(name: 'active', description: 'Если не передан — фильтр active=true (как в старом API).', in: 'query', schema: new OA\Schema(type: 'boolean'))] ++ #[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))] + #[Route('/list', name: 'site_service_list', methods: ['GET'])] +- public function list(Request $request): JsonResponse ++ public function list(Request $request, SiteServiceRepository $repository): JsonResponse + { +- $page = $request->query->getInt('page', 1); +- $perPage = min($request->query->getInt('perPage', 50), 500); +- $regionId = $request->query->getInt('regionId', 0) ?: null; +- $activeParam = $request->query->get('active'); +- $active = $activeParam === null ? true : filter_var($activeParam, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); +- if ($activeParam !== null && $active === null) { +- return $this->json(['error' => 'Параметр active должен быть boolean'], Response::HTTP_BAD_REQUEST); +- } +- +- $result = $this->siteServiceCrud->getPaginatedList($page, $perPage, $regionId, $active); +- $data = $result['data']; +- $total = $result['total']; +- $perPage = $result['per_page']; +- $totalPages = (int) ceil($total / $perPage); ++ $qb = $repository->createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); + +- return $this->json([ +- 'data' => $data, +- 'pagination' => [ +- 'total' => $total, +- 'count' => count($data), +- 'per_page' => $perPage, +- 'current_page' => $result['page'], +- 'total_pages' => $totalPages, +- 'has_previous_page' => $result['page'] > 1, +- 'has_next_page' => $result['page'] < $totalPages, +- ], +- ], Response::HTTP_OK, [], [ +- 'groups' => ['site_service:read'], ++ return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ ++ 'groups' => self::READ_GROUPS, + ]); + } + + #[Route('/{id}', name: 'site_service_show', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(SiteService $siteService): JsonResponse + { +- return $this->json($siteService, Response::HTTP_OK, [], [ +- 'groups' => ['site_service:read'], +- ]); ++ return $this->crud->read($siteService, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: SiteService::class, groups: self::WRITE_GROUPS)))] + #[Route('/create', name: 'site_service_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { +- $data = json_decode($request->getContent(), true); +- if (!is_array($data)) { +- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +- } +- +- $siteService = $this->siteServiceCrud->create($data); +- +- return $this->json($siteService, Response::HTTP_CREATED, [], [ +- 'groups' => ['site_service:read'], +- ]); ++ return $this->crud->create($request, SiteService::class, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] ++ #[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: SiteService::class, groups: self::WRITE_GROUPS)))] + #[Route('/{id}', name: 'site_service_update', methods: ['PUT'], requirements: ['id' => '\d+'])] +- public function update(SiteService $siteService, Request $request): JsonResponse ++ public function update(Request $request, SiteService $siteService): JsonResponse + { +- $data = json_decode($request->getContent(), true); +- if (!is_array($data)) { +- return $this->json(['error' => 'Ожидается JSON-объект в теле запроса'], Response::HTTP_BAD_REQUEST); +- } +- +- $siteService = $this->siteServiceCrud->update($siteService, $data); +- +- return $this->json($siteService, Response::HTTP_OK, [], [ +- 'groups' => ['site_service:read'], +- ]); ++ return $this->crud->update($request, $siteService, self::WRITE_GROUPS, self::READ_GROUPS); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/{id}', name: 'site_service_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(SiteService $siteService): JsonResponse + { +- $this->siteServiceCrud->delete($siteService); +- +- return new JsonResponse(null, Response::HTTP_NO_CONTENT); ++ return $this->crud->delete($siteService); + } + } +diff --git a/src/Dto/Content/ContentFilterDto.php b/src/Dto/Content/ContentFilterDto.php +new file mode 100644 +index 0000000..1902b5f +--- /dev/null ++++ b/src/Dto/Content/ContentFilterDto.php +@@ -0,0 +1,81 @@ ++query->get('active')); ++ ++ return new self( ++ regionId: self::positiveInt($request->query->get('regionId', $request->query->get('region_id'))), ++ active: $active ?? $defaultActive, ++ alias: self::nonEmptyString($request->query->get('alias')), ++ search: self::nonEmptyString($request->query->get('search', $request->query->get('q'))), ++ ); ++ } ++ ++ /** ++ * Symfony QueryBag может отдать массив при ?regionId[]=… — не передаём его в is_numeric (TypeError в PHP 8). ++ */ ++ private static function positiveInt(mixed $value): ?int ++ { ++ if ($value === null || $value === '' || !is_scalar($value) || !is_numeric($value)) { ++ return null; ++ } ++ ++ $value = (int) $value; ++ ++ return $value > 0 ? $value : null; ++ } ++ ++ /** ++ * При ?active[]=… query->get вернёт массив — отбрасываем без вызова filter_var по нему. ++ */ ++ private static function nullableBool(mixed $value): ?bool ++ { ++ if ($value === null || $value === '') { ++ return null; ++ } ++ ++ if (!is_scalar($value)) { ++ return null; ++ } ++ ++ if (is_bool($value)) { ++ return $value; ++ } ++ ++ return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); ++ } ++ ++ private static function nonEmptyString(mixed $value): ?string ++ { ++ if (!is_string($value)) { ++ return null; ++ } ++ ++ $value = trim($value); ++ ++ return $value !== '' ? $value : null; ++ } ++} +diff --git a/src/Entity/Article.php b/src/Entity/Article.php +index dffa469..675d15f 100644 +--- a/src/Entity/Article.php ++++ b/src/Entity/Article.php +@@ -2,6 +2,7 @@ + + namespace App\Entity; + ++use App\Entity\Behavior\UpdateTimestampTrait; + use App\Repository\ArticleRepository; + use Doctrine\DBAL\Types\Types; + use Doctrine\ORM\Mapping as ORM; +@@ -12,8 +13,11 @@ use Symfony\Component\Validator\Constraints as Assert; + #[ORM\Table(name: 'article')] + #[ORM\Index(name: 'idx_article_region_id', columns: ['region_id'])] + #[ORM\Index(name: 'idx_article_active', columns: ['active'])] ++#[ORM\HasLifecycleCallbacks] + class Article + { ++ use UpdateTimestampTrait; ++ + #[Groups(['article:read'])] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: "IDENTITY")] +@@ -56,7 +60,7 @@ class Article + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $content = null; + +- #[Groups(['article:read', 'article:write'])] ++ #[Groups(['article:read'])] + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] + private ?\DateTimeInterface $updateAt = null; + +diff --git a/src/Entity/Behavior/UpdateTimestampTrait.php b/src/Entity/Behavior/UpdateTimestampTrait.php +new file mode 100644 +index 0000000..f68363f +--- /dev/null ++++ b/src/Entity/Behavior/UpdateTimestampTrait.php +@@ -0,0 +1,29 @@ ++updateAt === null) { ++ $this->updateAt = new \DateTimeImmutable(); ++ } ++ } ++ ++ #[ORM\PreUpdate] ++ public function refreshUpdateAt(): void ++ { ++ $this->updateAt = new \DateTimeImmutable(); ++ } ++} +diff --git a/src/Entity/Disease.php b/src/Entity/Disease.php +index 26fc0bd..1851e00 100644 +--- a/src/Entity/Disease.php ++++ b/src/Entity/Disease.php +@@ -2,6 +2,7 @@ + + namespace App\Entity; + ++use App\Entity\Behavior\UpdateTimestampTrait; + use App\Repository\DiseaseRepository; + use Doctrine\DBAL\Types\Types; + use Doctrine\ORM\Mapping as ORM; +@@ -11,10 +12,14 @@ use Symfony\Component\Serializer\Annotation\Groups; + #[ORM\Table(name: 'disease')] + #[ORM\Index(name: 'idx_disease_region_id', columns: ['region_id'])] + #[ORM\Index(name: 'idx_disease_active', columns: ['active'])] ++#[ORM\HasLifecycleCallbacks] + class Disease + { ++ use UpdateTimestampTrait; ++ + #[Groups(['disease:read'])] + #[ORM\Id] ++ #[ORM\GeneratedValue(strategy: "IDENTITY")] + #[ORM\Column(type: Types::INTEGER)] + private ?int $id = null; + +@@ -42,7 +47,7 @@ class Disease + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $anons = null; + +- #[Groups(['disease:read', 'disease:write'])] ++ #[Groups(['disease:read'])] + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] + private ?\DateTimeInterface $updateAt = null; + +diff --git a/src/Entity/MedicalCenter.php b/src/Entity/MedicalCenter.php +index b116c78..6e01ced 100644 +--- a/src/Entity/MedicalCenter.php ++++ b/src/Entity/MedicalCenter.php +@@ -2,6 +2,7 @@ + + namespace App\Entity; + ++use App\Entity\Behavior\UpdateTimestampTrait; + use App\Repository\MedicalCenterRepository; + use Doctrine\DBAL\Types\Types; + use Doctrine\ORM\Mapping as ORM; +@@ -9,9 +10,13 @@ use Symfony\Component\Serializer\Annotation\Groups; + + #[ORM\Entity(repositoryClass: MedicalCenterRepository::class)] + #[ORM\Table(name: 'medical_center')] ++#[ORM\HasLifecycleCallbacks] + class MedicalCenter + { ++ use UpdateTimestampTrait; ++ + #[ORM\Id] ++ #[ORM\GeneratedValue(strategy: "IDENTITY")] + #[ORM\Column(type: Types::INTEGER)] + #[Groups(['medical_center:read'])] + private ?int $id = null; +@@ -41,7 +46,7 @@ class MedicalCenter + private ?string $content = null; + + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +- #[Groups(['medical_center:read', 'medical_center:write'])] ++ #[Groups(['medical_center:read'])] + private ?\DateTimeInterface $updateAt = null; + + #[ORM\Column(name: 'kod_uslug', type: 'jsonb', nullable: true)] +diff --git a/src/Entity/News.php b/src/Entity/News.php +index 94dd5e9..f91922a 100644 +--- a/src/Entity/News.php ++++ b/src/Entity/News.php +@@ -2,6 +2,7 @@ + + namespace App\Entity; + ++use App\Entity\Behavior\UpdateTimestampTrait; + use App\Repository\NewsRepository; + use Doctrine\DBAL\Types\Types; + use Doctrine\ORM\Mapping as ORM; +@@ -11,9 +12,13 @@ use Symfony\Component\Serializer\Annotation\Groups; + #[ORM\Table(name: 'news')] + #[ORM\Index(name: 'idx_news_region_id', columns: ['region_id'])] + #[ORM\Index(name: 'idx_news_active', columns: ['active'])] ++#[ORM\HasLifecycleCallbacks] + class News + { ++ use UpdateTimestampTrait; ++ + #[ORM\Id] ++ #[ORM\GeneratedValue(strategy: "IDENTITY")] + #[ORM\Column(type: Types::INTEGER)] + #[Groups(['news:read'])] + private ?int $id = null; +@@ -43,7 +48,7 @@ class News + private ?string $content = null; + + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +- #[Groups(['news:read', 'news:write'])] ++ #[Groups(['news:read'])] + private ?\DateTimeInterface $updateAt = null; + + #[ORM\Column(name: 'link_el_price', type: Types::TEXT, nullable: true)] +diff --git a/src/Entity/Promo.php b/src/Entity/Promo.php +index 94bb004..63076f0 100644 +--- a/src/Entity/Promo.php ++++ b/src/Entity/Promo.php +@@ -2,6 +2,7 @@ + + namespace App\Entity; + ++use App\Entity\Behavior\UpdateTimestampTrait; + use App\Repository\PromoRepository; + use Doctrine\DBAL\Types\Types; + use Doctrine\ORM\Mapping as ORM; +@@ -11,9 +12,13 @@ use Symfony\Component\Serializer\Annotation\Groups; + #[ORM\Table(name: 'promo')] + #[ORM\Index(name: 'idx_promo_region_id', columns: ['region_id'])] + #[ORM\Index(name: 'idx_promo_active', columns: ['active'])] ++#[ORM\HasLifecycleCallbacks] + class Promo + { ++ use UpdateTimestampTrait; ++ + #[ORM\Id] ++ #[ORM\GeneratedValue(strategy: "IDENTITY")] + #[ORM\Column(type: Types::INTEGER)] + #[Groups(['promo:read'])] + private ?int $id = null; +@@ -43,7 +48,7 @@ class Promo + private ?string $content = null; + + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +- #[Groups(['promo:read', 'promo:write'])] ++ #[Groups(['promo:read'])] + private ?\DateTimeInterface $updateAt = null; + + #[ORM\Column(type: 'jsonb', nullable: true)] +diff --git a/src/Entity/SiteService.php b/src/Entity/SiteService.php +index cf48e0f..9691651 100644 +--- a/src/Entity/SiteService.php ++++ b/src/Entity/SiteService.php +@@ -2,6 +2,7 @@ + + namespace App\Entity; + ++use App\Entity\Behavior\UpdateTimestampTrait; + use App\Repository\SiteServiceRepository; + use Doctrine\DBAL\Types\Types; + use Doctrine\ORM\Mapping as ORM; +@@ -11,9 +12,13 @@ use Symfony\Component\Serializer\Annotation\Groups; + #[ORM\Table(name: 'site_services')] + #[ORM\Index(name: 'idx_site_services_region_id', columns: ['region_id'])] + #[ORM\Index(name: 'idx_site_services_active', columns: ['active'])] ++#[ORM\HasLifecycleCallbacks] + class SiteService + { ++ use UpdateTimestampTrait; ++ + #[ORM\Id] ++ #[ORM\GeneratedValue(strategy: "IDENTITY")] + #[ORM\Column(type: Types::INTEGER)] + #[Groups(['site_service:read'])] + private ?int $id = null; +@@ -43,7 +48,7 @@ class SiteService + private ?string $content = null; + + #[ORM\Column(name: 'update_at', type: Types::DATETIME_MUTABLE, nullable: true)] +- #[Groups(['site_service:read', 'site_service:write'])] ++ #[Groups(['site_service:read'])] + private ?\DateTimeInterface $updateAt = null; + + #[ORM\Column(name: 'link_videoreviews', type: 'jsonb', nullable: true)] +diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php +index 697d852..e194953 100644 +--- a/src/Repository/ArticleRepository.php ++++ b/src/Repository/ArticleRepository.php +@@ -2,8 +2,10 @@ + + namespace App\Repository; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\Article; + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; ++use Doctrine\ORM\QueryBuilder; + use Doctrine\Persistence\ManagerRegistry; + + /** +@@ -11,63 +13,34 @@ use Doctrine\Persistence\ManagerRegistry; + */ + class ArticleRepository extends ServiceEntityRepository + { ++ use ContentFilterTrait; ++ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Article::class); + } + +- public function findByFilters(array $filters, int $page = 1, int $limit = 20): array ++ /** ++ */ ++ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder + { +- $qb = $this->createQueryBuilder('a'); +- +- if (isset($filters['alias']) && $filters['alias'] !== '') { +- $qb->andWhere('a.alias = :alias') +- ->setParameter('alias', $filters['alias']); +- } +- if (isset($filters['active']) && $filters['active'] !== '') { +- $qb->andWhere('a.active = :active') +- ->setParameter('active', filter_var($filters['active'], FILTER_VALIDATE_BOOLEAN)); +- } +- if (isset($filters['regionId']) && $filters['regionId'] !== '') { +- $qb->andWhere('a.regionId = :regionId') +- ->setParameter('regionId', (int) $filters['regionId']); +- } ++ $qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC'); + +- $qb->orderBy('a.id', 'DESC'); ++ $this->applyCommonFilters($qb, 'a', $filters); + +- $qb->setFirstResult(($page - 1) * $limit) +- ->setMaxResults($limit); +- +- return $qb->getQuery()->getResult(); +- } +- +- public function countByFilters(array $filters): int +- { +- $qb = $this->createQueryBuilder('a') +- ->select('COUNT(a.id)'); +- +- if (isset($filters['alias']) && $filters['alias'] !== '') { +- $qb->andWhere('a.alias = :alias') +- ->setParameter('alias', $filters['alias']); +- } +- if (isset($filters['active']) && $filters['active'] !== '') { +- $qb->andWhere('a.active = :active') +- ->setParameter('active', filter_var($filters['active'], FILTER_VALIDATE_BOOLEAN)); +- } +- if (isset($filters['regionId']) && $filters['regionId'] !== '') { +- $qb->andWhere('a.regionId = :regionId') +- ->setParameter('regionId', (int) $filters['regionId']); +- } +- +- return (int) $qb->getQuery()->getSingleScalarResult(); ++ return $qb; + } + ++ /** ++ * Поиск статьи по alias с учётом возможных вариантов написания (исторический функционал). ++ */ + public function findOneByAlias(string $alias): ?Article + { + $alias = trim($alias); + if ($alias === '') { + return null; + } ++ + $variants = [ + $alias, + $alias . '-', +@@ -79,16 +52,18 @@ class ArticleRepository extends ServiceEntityRepository + return $article; + } + } +- // Поиск по TRIM(alias) в БД (нативный SQL для совместимости с PostgreSQL) ++ ++ // Фолбэк по TRIM(alias) в БД для совместимости со старыми данными. + $conn = $this->getEntityManager()->getConnection(); + $id = $conn->fetchOne( + 'SELECT id FROM article WHERE TRIM(alias) = :alias LIMIT 1', + ['alias' => $alias], +- ['alias' => \PDO::PARAM_STR] ++ ['alias' => \PDO::PARAM_STR], + ); + if ($id !== false) { + return $this->find($id); + } ++ + return null; + } + } +diff --git a/src/Repository/ContentFilterTrait.php b/src/Repository/ContentFilterTrait.php +new file mode 100644 +index 0000000..87366e9 +--- /dev/null ++++ b/src/Repository/ContentFilterTrait.php +@@ -0,0 +1,58 @@ ++ 0; ++ * - active: bool; ++ * - alias: точное совпадение; ++ * - search / q: LIKE по lower-case значению заданного поля (по умолчанию `name`). ++ * ++ * Поле поиска параметризовано через $searchField на случай сущностей, ++ * где основное текстовое поле называется иначе (например, `title`). ++ * Если у сущности нет такого свойства, Doctrine упадёт с QueryException — это ++ * лучше ловится тестами на этапе разработки, чем 500 в проде. ++ * ++ * Важно: LOWER($alias.$searchField) при больших таблицах требует функционального ++ * индекса в PostgreSQL, например CREATE INDEX ... ON table (LOWER(name)). ++ */ ++trait ContentFilterTrait ++{ ++ private function applyCommonFilters( ++ QueryBuilder $qb, ++ string $alias, ++ ContentFilterDto $filters, ++ string $searchField = 'name', ++ ): void { ++ if ($filters->regionId !== null) { ++ $qb->andWhere("$alias.regionId = :regionId") ++ ->setParameter('regionId', $filters->regionId); ++ } ++ ++ if ($filters->active !== null) { ++ $qb->andWhere("$alias.active = :active") ++ ->setParameter('active', $filters->active); ++ } ++ ++ if ($filters->alias !== null) { ++ $qb->andWhere("$alias.alias = :aliasValue") ++ ->setParameter('aliasValue', $filters->alias); ++ } ++ ++ if ($filters->search !== null) { ++ $qb->andWhere("LOWER($alias.$searchField) LIKE :search") ++ ->setParameter('search', '%' . mb_strtolower($filters->search) . '%'); ++ } ++ } ++} +diff --git a/src/Repository/DiseaseRepository.php b/src/Repository/DiseaseRepository.php +index 24f4994..33dd6b1 100644 +--- a/src/Repository/DiseaseRepository.php ++++ b/src/Repository/DiseaseRepository.php +@@ -2,8 +2,10 @@ + + namespace App\Repository; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\Disease; + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; ++use Doctrine\ORM\QueryBuilder; + use Doctrine\Persistence\ManagerRegistry; + + /** +@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry; + */ + class DiseaseRepository extends ServiceEntityRepository + { ++ use ContentFilterTrait; ++ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Disease::class); + } ++ ++ /** ++ */ ++ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder ++ { ++ $qb = $this->createQueryBuilder('d')->orderBy('d.id', 'ASC'); ++ ++ $this->applyCommonFilters($qb, 'd', $filters); ++ ++ return $qb; ++ } + } +diff --git a/src/Repository/MedicalCenterRepository.php b/src/Repository/MedicalCenterRepository.php +index 7088a7c..021af74 100644 +--- a/src/Repository/MedicalCenterRepository.php ++++ b/src/Repository/MedicalCenterRepository.php +@@ -2,8 +2,10 @@ + + namespace App\Repository; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\MedicalCenter; + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; ++use Doctrine\ORM\QueryBuilder; + use Doctrine\Persistence\ManagerRegistry; + + /** +@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry; + */ + class MedicalCenterRepository extends ServiceEntityRepository + { ++ use ContentFilterTrait; ++ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, MedicalCenter::class); + } ++ ++ /** ++ */ ++ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder ++ { ++ $qb = $this->createQueryBuilder('m')->orderBy('m.id', 'DESC'); ++ ++ $this->applyCommonFilters($qb, 'm', $filters); ++ ++ return $qb; ++ } + } +diff --git a/src/Repository/NewsRepository.php b/src/Repository/NewsRepository.php +index 9607b31..4520283 100644 +--- a/src/Repository/NewsRepository.php ++++ b/src/Repository/NewsRepository.php +@@ -2,8 +2,10 @@ + + namespace App\Repository; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\News; + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; ++use Doctrine\ORM\QueryBuilder; + use Doctrine\Persistence\ManagerRegistry; + + /** +@@ -14,8 +16,25 @@ use Doctrine\Persistence\ManagerRegistry; + */ + class NewsRepository extends ServiceEntityRepository + { ++ use ContentFilterTrait; ++ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, News::class); + } ++ ++ /** ++ * Готовит QueryBuilder под пагинацию (Pagerfanta\QueryAdapter). ++ * ++ * Поддерживаемые фильтры: regionId, active (по умолчанию true), alias, search. ++ * ++ */ ++ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder ++ { ++ $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC'); ++ ++ $this->applyCommonFilters($qb, 'n', $filters); ++ ++ return $qb; ++ } + } +diff --git a/src/Repository/PromoRepository.php b/src/Repository/PromoRepository.php +index 5a5c4c6..3d73d2b 100644 +--- a/src/Repository/PromoRepository.php ++++ b/src/Repository/PromoRepository.php +@@ -2,8 +2,10 @@ + + namespace App\Repository; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\Promo; + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; ++use Doctrine\ORM\QueryBuilder; + use Doctrine\Persistence\ManagerRegistry; + + /** +@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry; + */ + class PromoRepository extends ServiceEntityRepository + { ++ use ContentFilterTrait; ++ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Promo::class); + } ++ ++ /** ++ */ ++ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder ++ { ++ $qb = $this->createQueryBuilder('p')->orderBy('p.id', 'DESC'); ++ ++ $this->applyCommonFilters($qb, 'p', $filters); ++ ++ return $qb; ++ } + } +diff --git a/src/Repository/SiteServiceRepository.php b/src/Repository/SiteServiceRepository.php +index 1a07399..73d834a 100644 +--- a/src/Repository/SiteServiceRepository.php ++++ b/src/Repository/SiteServiceRepository.php +@@ -2,8 +2,10 @@ + + namespace App\Repository; + ++use App\Dto\Content\ContentFilterDto; + use App\Entity\SiteService; + use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; ++use Doctrine\ORM\QueryBuilder; + use Doctrine\Persistence\ManagerRegistry; + + /** +@@ -14,8 +16,21 @@ use Doctrine\Persistence\ManagerRegistry; + */ + class SiteServiceRepository extends ServiceEntityRepository + { ++ use ContentFilterTrait; ++ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SiteService::class); + } ++ ++ /** ++ */ ++ public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder ++ { ++ $qb = $this->createQueryBuilder('s')->orderBy('s.id', 'ASC'); ++ ++ $this->applyCommonFilters($qb, 's', $filters); ++ ++ return $qb; ++ } + } +diff --git a/src/Service/Crud/CrudResponder.php b/src/Service/Crud/CrudResponder.php +new file mode 100644 +index 0000000..a775767 +--- /dev/null ++++ b/src/Service/Crud/CrudResponder.php +@@ -0,0 +1,195 @@ ++ $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 +- ) { +- } +- +- /** +- * @return MedicalCenter[] +- */ +- public function getList(?int $regionId = null, ?bool $active = true): array +- { +- $criteria = []; +- if ($regionId !== null) { +- $criteria['regionId'] = $regionId; +- } +- if ($active !== null) { +- $criteria['active'] = $active; +- } +- +- return $this->medicalCenterRepository->findBy($criteria, ['id' => 'ASC']); +- } +- +- public function getShow(int $id): ?MedicalCenter +- { +- return $this->medicalCenterRepository->find($id); +- } +- +- public function create(array $data): MedicalCenter +- { +- $medicalCenter = new MedicalCenter(); +- $this->updateEntity($medicalCenter, $data); +- +- $this->em->persist($medicalCenter); +- $this->em->flush(); +- +- return $medicalCenter; +- } +- +- public function update(MedicalCenter $medicalCenter, array $data): MedicalCenter +- { +- unset($data['id']); +- $this->updateEntity($medicalCenter, $data); +- +- $this->em->flush(); +- return $medicalCenter; +- } +- +- public function delete(MedicalCenter $medicalCenter): void +- { +- $this->em->remove($medicalCenter); +- $this->em->flush(); +- } +- +- private function updateEntity(MedicalCenter $medicalCenter, array $data): void +- { +- if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') { +- $medicalCenter->setId((int) $data['id']); +- } +- +- if (array_key_exists('name', $data)) { +- $medicalCenter->setName($data['name']); +- } +- +- if (array_key_exists('active', $data)) { +- $medicalCenter->setActive($data['active']); +- } +- +- if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { +- $v = $data['regionId'] ?? $data['region_id']; +- $medicalCenter->setRegionId($v === null || $v === '' ? null : (int) $v); +- } +- +- if (array_key_exists('alias', $data)) { +- $medicalCenter->setAlias($data['alias']); +- } +- +- if (array_key_exists('anons', $data)) { +- $medicalCenter->setAnons($data['anons']); +- } +- +- if (array_key_exists('content', $data)) { +- $medicalCenter->setContent($data['content']); +- } +- +- if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { +- $raw = $data['updateAt'] ?? $data['update_at']; +- if ($raw === null || $raw === '') { +- $medicalCenter->setUpdateAt(null); +- } elseif ($raw instanceof \DateTimeInterface) { +- $medicalCenter->setUpdateAt($raw); +- } elseif (is_string($raw)) { +- $medicalCenter->setUpdateAt(new \DateTimeImmutable($raw)); +- } +- } +- +- if (array_key_exists('kodUslug', $data) || array_key_exists('kod_uslug', $data)) { +- $medicalCenter->setKodUslug($data['kodUslug'] ?? $data['kod_uslug']); +- } +- +- if (array_key_exists('doctors', $data)) { +- $medicalCenter->setDoctors($data['doctors']); +- } +- +- if (array_key_exists('services', $data)) { +- $medicalCenter->setServices($data['services']); +- } +- +- if (array_key_exists('articles', $data)) { +- $medicalCenter->setArticles($data['articles']); +- } +- +- if (array_key_exists('txtUp', $data) || array_key_exists('txt_up', $data)) { +- $medicalCenter->setTxtUp($data['txtUp'] ?? $data['txt_up']); +- } +- +- if (array_key_exists('mainLinkStaff', $data) || array_key_exists('main_link_staff', $data)) { +- $medicalCenter->setMainLinkStaff($data['mainLinkStaff'] ?? $data['main_link_staff']); +- } +- +- if (array_key_exists('contraindications', $data)) { +- $medicalCenter->setContraindications($data['contraindications']); +- } +- +- if (array_key_exists('hidePicture', $data) || array_key_exists('hide_picture', $data)) { +- $v = $data['hidePicture'] ?? $data['hide_picture']; +- $medicalCenter->setHidePicture($v === null || $v === '' ? null : (int) $v); +- } +- +- if (array_key_exists('indications', $data)) { +- $medicalCenter->setIndications($data['indications']); +- } +- +- if (array_key_exists('linkSale', $data) || array_key_exists('link_sale', $data)) { +- $medicalCenter->setLinkSale($data['linkSale'] ?? $data['link_sale']); +- } +- +- if (array_key_exists('plusList', $data) || array_key_exists('plus_list', $data)) { +- $medicalCenter->setPlusList($data['plusList'] ?? $data['plus_list']); +- } +- +- if (array_key_exists('plusText', $data) || array_key_exists('plus_text', $data)) { +- $medicalCenter->setPlusText($data['plusText'] ?? $data['plus_text']); +- } +- +- if (array_key_exists('plusTitle', $data) || array_key_exists('plus_title', $data)) { +- $medicalCenter->setPlusTitle($data['plusTitle'] ?? $data['plus_title']); +- } +- +- if (array_key_exists('processText', $data) || array_key_exists('process_text', $data)) { +- $medicalCenter->setProcessText($data['processText'] ?? $data['process_text']); +- } +- +- if (array_key_exists('processTitle', $data) || array_key_exists('process_title', $data)) { +- $medicalCenter->setProcessTitle($data['processTitle'] ?? $data['process_title']); +- } +- +- if (array_key_exists('servicesList', $data) || array_key_exists('services_list', $data)) { +- $medicalCenter->setServicesList($data['servicesList'] ?? $data['services_list']); +- } +- +- if (array_key_exists('servicesPhotos', $data) || array_key_exists('services_photos', $data)) { +- $medicalCenter->setServicesPhotos($data['servicesPhotos'] ?? $data['services_photos']); +- } +- +- if (array_key_exists('servicesTitle', $data) || array_key_exists('services_title', $data)) { +- $medicalCenter->setServicesTitle($data['servicesTitle'] ?? $data['services_title']); +- } +- +- if (array_key_exists('sortStaff', $data) || array_key_exists('sort_staff', $data)) { +- $medicalCenter->setSortStaff($data['sortStaff'] ?? $data['sort_staff']); +- } +- +- if (array_key_exists('trainingText', $data) || array_key_exists('training_text', $data)) { +- $medicalCenter->setTrainingText($data['trainingText'] ?? $data['training_text']); +- } +- +- if (array_key_exists('trainingTextTitle', $data) || array_key_exists('training_text_title', $data)) { +- $medicalCenter->setTrainingTextTitle($data['trainingTextTitle'] ?? $data['training_text_title']); +- } +- +- if (array_key_exists('whyText', $data) || array_key_exists('why_text', $data)) { +- $medicalCenter->setWhyText($data['whyText'] ?? $data['why_text']); +- } +- +- if (array_key_exists('whyTitle', $data) || array_key_exists('why_title', $data)) { +- $medicalCenter->setWhyTitle($data['whyTitle'] ?? $data['why_title']); +- } +- } +- +- public function syncFromViewCenters(string $viewName = 'public.view_centers'): int +- { +- // В опции разрешаем только идентификаторы (буквы/цифры/underscore) и точку для схемы. +- if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { +- throw new \InvalidArgumentException('Invalid view name'); +- } +- +- $connection = $this->em->getConnection(); +- +- $sql = sprintf( +- 'INSERT INTO medical_center ( +- id, +- name, +- active, +- region_id, +- alias, +- anons, +- content, +- update_at, +- kod_uslug, +- doctors, +- services, +- articles, +- txt_up, +- main_link_staff, +- contraindications, +- hide_picture, +- indications, +- link_sale, +- plus_list, +- plus_text, +- plus_title, +- process_text, +- process_title, +- services_list, +- services_photos, +- services_title, +- sort_staff, +- training_text, +- training_text_title, +- why_text, +- why_title +- ) +- SELECT +- id, +- name, +- active, +- region_id, +- alias, +- anons, +- content, +- update_at, +- kod_uslug, +- doctors, +- services, +- articles, +- txt_up, +- main_link_staff, +- contraindications, +- hide_picture, +- indications, +- link_sale, +- plus_list, +- plus_text, +- plus_title, +- process_text, +- process_title, +- services_list, +- services_photos, +- services_title, +- sort_staff, +- training_text, +- training_text_title, +- why_text, +- why_title +- FROM %s +- ON CONFLICT (id) DO UPDATE SET +- name = EXCLUDED.name, +- active = EXCLUDED.active, +- region_id = EXCLUDED.region_id, +- alias = EXCLUDED.alias, +- anons = EXCLUDED.anons, +- content = EXCLUDED.content, +- update_at = EXCLUDED.update_at, +- kod_uslug = EXCLUDED.kod_uslug, +- doctors = EXCLUDED.doctors, +- services = EXCLUDED.services, +- articles = EXCLUDED.articles, +- txt_up = EXCLUDED.txt_up, +- main_link_staff = EXCLUDED.main_link_staff, +- contraindications = EXCLUDED.contraindications, +- hide_picture = EXCLUDED.hide_picture, +- indications = EXCLUDED.indications, +- link_sale = EXCLUDED.link_sale, +- plus_list = EXCLUDED.plus_list, +- plus_text = EXCLUDED.plus_text, +- plus_title = EXCLUDED.plus_title, +- process_text = EXCLUDED.process_text, +- process_title = EXCLUDED.process_title, +- services_list = EXCLUDED.services_list, +- services_photos = EXCLUDED.services_photos, +- services_title = EXCLUDED.services_title, +- sort_staff = EXCLUDED.sort_staff, +- training_text = EXCLUDED.training_text, +- training_text_title = EXCLUDED.training_text_title, +- why_text = EXCLUDED.why_text, +- why_title = EXCLUDED.why_title', +- $viewName +- ); +- +- return (int) $connection->executeStatement($sql); +- } ++ public function __construct( ++ private EntityManagerInterface $em, ++ ) { ++ } ++ ++ public function syncFromViewCenters(string $viewName = 'public.view_centers'): int ++ { ++ if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { ++ throw new \InvalidArgumentException('Invalid view name'); ++ } ++ ++ $sql = sprintf( ++ 'INSERT INTO medical_center ( ++ id, ++ name, ++ active, ++ region_id, ++ alias, ++ anons, ++ content, ++ update_at, ++ kod_uslug, ++ doctors, ++ services, ++ articles, ++ txt_up, ++ main_link_staff, ++ contraindications, ++ hide_picture, ++ indications, ++ link_sale, ++ plus_list, ++ plus_text, ++ plus_title, ++ process_text, ++ process_title, ++ services_list, ++ services_photos, ++ services_title, ++ sort_staff, ++ training_text, ++ training_text_title, ++ why_text, ++ why_title ++ ) ++ SELECT ++ id, ++ name, ++ active, ++ region_id, ++ alias, ++ anons, ++ content, ++ update_at, ++ kod_uslug, ++ doctors, ++ services, ++ articles, ++ txt_up, ++ main_link_staff, ++ contraindications, ++ hide_picture, ++ indications, ++ link_sale, ++ plus_list, ++ plus_text, ++ plus_title, ++ process_text, ++ process_title, ++ services_list, ++ services_photos, ++ services_title, ++ sort_staff, ++ training_text, ++ training_text_title, ++ why_text, ++ why_title ++ FROM %s ++ ON CONFLICT (id) DO UPDATE SET ++ name = EXCLUDED.name, ++ active = EXCLUDED.active, ++ region_id = EXCLUDED.region_id, ++ alias = EXCLUDED.alias, ++ anons = EXCLUDED.anons, ++ content = EXCLUDED.content, ++ update_at = EXCLUDED.update_at, ++ kod_uslug = EXCLUDED.kod_uslug, ++ doctors = EXCLUDED.doctors, ++ services = EXCLUDED.services, ++ articles = EXCLUDED.articles, ++ txt_up = EXCLUDED.txt_up, ++ main_link_staff = EXCLUDED.main_link_staff, ++ contraindications = EXCLUDED.contraindications, ++ hide_picture = EXCLUDED.hide_picture, ++ indications = EXCLUDED.indications, ++ link_sale = EXCLUDED.link_sale, ++ plus_list = EXCLUDED.plus_list, ++ plus_text = EXCLUDED.plus_text, ++ plus_title = EXCLUDED.plus_title, ++ process_text = EXCLUDED.process_text, ++ process_title = EXCLUDED.process_title, ++ services_list = EXCLUDED.services_list, ++ services_photos = EXCLUDED.services_photos, ++ services_title = EXCLUDED.services_title, ++ sort_staff = EXCLUDED.sort_staff, ++ training_text = EXCLUDED.training_text, ++ training_text_title = EXCLUDED.training_text_title, ++ why_text = EXCLUDED.why_text, ++ why_title = EXCLUDED.why_title', ++ $viewName ++ ); ++ ++ return (int) $this->em->getConnection()->executeStatement($sql); ++ } + } +- +diff --git a/src/Service/NewsCrudService.php b/src/Service/NewsCrudService.php +index 988f1ed..c5a7fd0 100644 +--- a/src/Service/NewsCrudService.php ++++ b/src/Service/NewsCrudService.php +@@ -2,148 +2,28 @@ + + namespace App\Service; + +-use App\Entity\News; +-use App\Repository\NewsRepository; + use Doctrine\ORM\EntityManagerInterface; + ++/** ++ * Импорт новостей из материализованного представления (Bitrix view). ++ * ++ * CRUD (create/update/delete/list) живёт теперь в NewsController через ++ * общие App\Service\Crud\CrudResponder и App\Service\Pagination\Paginator — ++ * этот сервис отвечает только за синхронизацию (см. App\Command\UploadNewsCommand). ++ */ + final class NewsCrudService + { + public function __construct( + private EntityManagerInterface $em, +- private NewsRepository $newsRepository + ) { + } + +- /** +- * @return News[] +- */ +- public function getList(?int $regionId = null, ?bool $active = true): array +- { +- $criteria = []; +- if ($regionId !== null) { +- $criteria['regionId'] = $regionId; +- } +- if ($active !== null) { +- $criteria['active'] = $active; +- } +- +- return $this->newsRepository->findBy($criteria, ['id' => 'ASC']); +- } +- +- public function getShow(int $id): ?News +- { +- return $this->newsRepository->find($id); +- } +- +- public function create(array $data): News +- { +- $news = new News(); +- $this->updateEntity($news, $data); +- +- $this->em->persist($news); +- $this->em->flush(); +- +- return $news; +- } +- +- public function update(News $news, array $data): News +- { +- unset($data['id']); +- $this->updateEntity($news, $data); +- +- $this->em->flush(); +- return $news; +- } +- +- public function delete(News $news): void +- { +- $this->em->remove($news); +- $this->em->flush(); +- } +- +- private function updateEntity(News $news, array $data): void +- { +- if (array_key_exists('id', $data) && $data['id'] !== null && $data['id'] !== '') { +- $news->setId((int) $data['id']); +- } +- +- if (array_key_exists('name', $data)) { +- $news->setName($data['name']); +- } +- +- if (array_key_exists('active', $data)) { +- $news->setActive($data['active']); +- } +- +- if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { +- $v = $data['regionId'] ?? $data['region_id']; +- $news->setRegionId($v === null || $v === '' ? null : (int) $v); +- } +- +- if (array_key_exists('alias', $data)) { +- $news->setAlias($data['alias']); +- } +- +- if (array_key_exists('anons', $data)) { +- $news->setAnons($data['anons']); +- } +- +- if (array_key_exists('content', $data)) { +- $news->setContent($data['content']); +- } +- +- if (array_key_exists('updateAt', $data) || array_key_exists('update_at', $data)) { +- $raw = $data['updateAt'] ?? $data['update_at']; +- if ($raw === null || $raw === '') { +- $news->setUpdateAt(null); +- } elseif ($raw instanceof \DateTimeInterface) { +- $news->setUpdateAt($raw); +- } elseif (is_string($raw)) { +- $news->setUpdateAt(new \DateTimeImmutable($raw)); +- } +- } +- +- if (array_key_exists('linkElPrice', $data) || array_key_exists('link_el_price', $data)) { +- $news->setLinkElPrice($data['linkElPrice'] ?? $data['link_el_price']); +- } +- +- if (array_key_exists('shortName', $data) || array_key_exists('short_name', $data)) { +- $news->setShortName($data['shortName'] ?? $data['short_name']); +- } +- +- if (array_key_exists('timer', $data)) { +- $news->setTimer($data['timer']); +- } +- +- if (array_key_exists('timerBg', $data) || array_key_exists('timer_bg', $data)) { +- $news->setTimerBg($data['timerBg'] ?? $data['timer_bg']); +- } +- +- if (array_key_exists('formOrder', $data) || array_key_exists('form_order', $data)) { +- $news->setFormOrder($data['formOrder'] ?? $data['form_order']); +- } +- +- if (array_key_exists('linkServices', $data) || array_key_exists('link_services', $data)) { +- $news->setLinkServices($data['linkServices'] ?? $data['link_services']); +- } +- +- if (array_key_exists('linkStaff', $data) || array_key_exists('link_staff', $data)) { +- $news->setLinkStaff($data['linkStaff'] ?? $data['link_staff']); +- } +- +- if (array_key_exists('photos', $data)) { +- $news->setPhotos($data['photos']); +- } +- } +- + public function syncFromViewNews(string $viewName = 'public.view_news'): int + { + if (!preg_match('/^[A-Za-z0-9_\.]+$/', $viewName)) { + throw new \InvalidArgumentException('Invalid view name'); + } + +- $connection = $this->em->getConnection(); +- + $sql = sprintf( + 'INSERT INTO news ( + id, +@@ -200,6 +80,6 @@ final class NewsCrudService + $viewName + ); + +- return (int) $connection->executeStatement($sql); ++ return (int) $this->em->getConnection()->executeStatement($sql); + } + } +diff --git a/src/Service/Pagination/Paginator.php b/src/Service/Pagination/Paginator.php +new file mode 100644 +index 0000000..bb53ee6 +--- /dev/null ++++ b/src/Service/Pagination/Paginator.php +@@ -0,0 +1,107 @@ ++ [...], '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/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..0b31da2 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + tests + + + + + + src + + + + + + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..aef26cbe1199d85586155d35e4c76fb37c8e691a GIT binary patch literal 27724 zcmeHwcYIIR|Nj-ViCrtUi1MLaxqF0!JB21htf(6IeMdy5?5GixYVBGzqBbG4R7ur{ z)@+Q@QmZv;RE^lGM!&CjhVd@%cOHKF&+qZh19{(j?s?sFp6B@*=k+>?k3&1PuUM{5 zIRrr}b_kNg;Jc6gujd=^Z{py=x8a*td{D1M1Swz5{{qvc=Y<3nb9K<-^SUVyQ}nCSmjeh z`>;yYb~{FoE!U%Zjko3ob>BYnaJit&v1jKu%ZU&^8T;P2?(cKg8lGJC_0YljSFb+! z<-w+lb1y#JpMIcn##>dJPPwpfAi{@@ZsUKn;iS$%+xKkF@Td`Vx{Obi3whr~l-+?K z*Gs3T4NLm6?ZZ-E#33F?{NYw_SKW2ypHUk_oAyL}rXZ(%t6nT$YH}E|yH&T{)sfw` zkT1J_Wt2mvAxL!k5YZP|Q4zUvyF*}E;b7wAC7o+oU?^yUa>Ux}SV51wS9&Z}&Q3k2f=|KMR ze&^30DILG7)R)Oyqq2G<$9o_HPTlWAp75IgUb!C!-yfIGJ#5!t-h$BGZR*!6SHAq! zCbk8evERo9{zIOLJ3VgLU1)uLV(@ij(Cl`zR^=`l^=Ob5o3c7=;>El&A|8A1V&liV zMh|~9vH8KX)SCBO*7)Q`_{TY+z9GBPzX_WnH`+7(Y}HG{i5m@PJo1{FS`o<}(>b@( z`ZvGMi_RT(t(3>OO5ul6kaojvq2ue6pMq40posK)2r_l+{YmXwltT8+T>LwNZ2F_t z`^%?!2k-Pm5PANHcfJ(ftgx+mc5bD)+v;!1_4d%-klR&wJGV_0@r`pC^~x=%HAAS~ zE!Y2OnXkVrU1$DVv(Hpnw+-hiUOZj({SlYrz2E3xYuxz35z|l#DP=#1U0^iCRFDqyx;Ec$tQOE5T88! zWK(;5+K9DXO8@MU-0qv@4eNZebAIB%B&u?sj2R1~4%S)MARuG?!es~F{UbRE8T!dY zJ>wu=$r^u5dw={#O8<|;>ona}Q$HV0_n1WM^~2ee8g+B$Y5AUSdyLDdcEz*rhgH6d z9yxh(`|#j!X?c??jn`G(=-Jb2)Q9+Y|MQ)?yz{yygR9=(o@Vo3GdWz%ZhEazgGTe3 z^!;C@YW=1Lyi(V={YsGt);=;E%60}oY+oEzNq^5mF3zn$}3o007^ zbIOLvl_z)p^ueC*&uysgzhms#V6TO5hb^8~wIgk|Jzr+Y=Y%+G{)#U9vgZBlxnivU z=kIlhnV%Bs*Y%^UYE<&9o0aY`Qyw(QGdL7 z>EfkPw^J)r98+=Bt{_{p+EKMXsJ*P#hT49!f|r*4Y38im-LPMXy0aoatNdA9_waY_ zzB8o8lpm^2_n+Ql`mpI2S9I&1*uCxH^cCg1t?XWTW#1k{m#W>r3jICH>*ty)4`%I2=mEe@J59+4)eGuGLN?c)OTT`se3xoERs>FfNlxgGa6+Fx=12WV-u zExP8&z*W|&VXN5VgO6`HZo8UrwdU2!$McipGuvgh%)H|t<9{#NYa_F<%f`_gPb44s zGw>($9lTmFnTO@H}@%F_#&TVPF zZ(7uBYvaLnKPBzmySwGd?4#Lxu1w?)&yAX=Z8^5-#KQ@rmnd_0bR5Tw+co3(j3Iet zXN1hSm;G6fz#i#6?)3O2yTg(9FSooLbEIc!j905|WblrbNeYy1e)d z^?la%tnV-NjHny2ATje6?Y;Ef$$3?0rp)y28nf(^WwD4cIYs<%5t`bL#Ht^L;?RaQphb@Uw&V{+_vP&a&@rO}^;YH>Gc{z7IAx z=={~(rt^+qF}qJ}?|pi6-Ut2t0|U&z_g^X^TD5gv}NfQ-%|lw8V@@%Y}T+h zAI^Kw`tQ6)lm6OzCjDm38=cDbFSV`I`BIxl)EV(r>8R2T%gidX-mqrT2p` z?OpFV_H_H`u{jMRYtQn&KeT%DcZe0O{_L`@Yto>kdo7P-pX>D?V)Misld{Ht)B41~ z^$95v*#o1)qV+3tax!vOGX z7|KtZJ8kZwu!@m0*0uNr+t6%7XRAevsTI$qUx@jk?$nQdxOcAc{lr@*YDA9?KDcOI z*y_+qHG}<@w@B+>ixctbPLpD$TGBoGQua4%V^}2D|cdPPk z&Bo)pdZ)I+`-E*Y(ju=P8k#pCZ|#gnp=ZOxy5Ag_e)8tTuu+GWERkA_>`6_}nzkn| zcJ=M|zR_!F(~a)=17{xY-DCJW-qZVK$7e5J{nwXj&V{o*JM}v}Sfee!xdTD5Y+mSatiAKChA>Ib(fjjCUx^>6vpu59|cMOx3O&C1X_ zv4^gW{B=?tTiuOYFAV!Nr+sSY9rGrAu;ha`?<)D12PVZupZsF$*0o+SVa$XELH_aQ z=VZhkpL2C~>eun_y;JEs;rzD$omx8NgWp5{Sg`;5li%+-_ zXPr#H7yNKmRG%(=Hb+PY=C$=sx)!{l-@QGze!DoS&-G35w|(!~PBhu@?y7**0quvy zKROfiMen%F=DobDJ=?5Kx*c-4^}+m~hW|Y<_i*AbfBn*`&*$lz^LJjUyCJseuk~`8 zY`pVV%H6BpspYN(pX@VuQrZ^}w;X@;=bL|5{&V=c$7{^onA3mt&+UKU`(BmvD|vl1 z@A0&Me){Olq4s;*A8xERo-ir1VZ*u=>ukPh?4NpO+1h2*4*W2z$3N+p6IWh5_2}$_ zcMpU=JbJzS{c#7ju1?Rom5{$O>kI!~jdwNNv_C!I#?ptY_MaGkpspxBYy53LZ&{3h))|FZ2ly3H6cU5=A5!q*#t@Ch7^1@kxp{ld(|xa0)gR8q{?q{c5AM11i+;>F^gG{1BfhW?~yd~!l`0l
{CIXw%pJ5qrt-$hhH3vO^ zx40oXvcQ7GYYB;_T`>cEi#79!$$4T??0pIp++Wk}wkJ@x)7LcG_&s;`>9C&kEb12d z@8v8R1cmaS>HhL}qz*7+ozcHgRKZg(7m^rfC8cT!rVs%PDT!tlN`1bYqxd#{c3T}C z`5!DqqL%XiwhSfHs{b#R;VFyt0a|Q?X$1Ne6S(+B$yTm#J)hUCVr~7mCboHMS&A)z z0G7?Ju!+wzqCjCqE#mpR_F)(C2O~Hh$0$O@IaN|{LE$78S7Z)X2$p1d+7;0PF_I!B zp28$n!bqBC8Jgo|n&fzuV?B!goT2aeLYwgwWoDykfYNtnVBFbSMwR8E#SNe~oLpujh{5@1EDIP$DL7Sv5pb9&QW!3h z93kNXE>N_jax^6tL_t(QfiRwscv;3p3bca66xp>spcF70BqdWg4yueP zb^#nE(J(9#X5bn`npD7q5Cny@7%8zNkdYT@7UyIJ<3!%IIy1B?h$>4dtRiugz)Ols z2^b+^pdEyQsV+eT_5t?+H(ZtkmV|*2yrQZU&0#PX)%7Z{1S^2fa12dhpw2Q_C5!}n zMydqPaIEVo6j(tpLO4syq|AU}1~yTw43<$PSrT_S1$e(qC=$kyBo0=IS7{a#X$JTW z43qd0Oabg3u!rYxu!JP8Dl{QWA_25kVMRE_i?;;wlhumTVMNM&h} z;BghG10+;nO?iQ*c--|e3LFXci)T4pkik3Rl%gsUp#Ya~8V3b&Jp~C9WK0Ch2CkUo z1cp%o7Fo3;An=@PWfH(@fB_&S8PwSRS&%S@F(mN$fTieKnM9ZY3BFYV;y`zW;viTN za0oPTQeqg_s~~~|hGB6#1RO8`cnvn5r&KWV0wMD*F%TtL!;cqx;hQxGbVvcU5S_$o}W&&K5x zAi97Tkrd!4La3-hl*`$JJQ?T>q;zf8L6ZoQmH`F~ComoZZNMptDBFD`p^89D7OV>iI)#_OFUTqc#ymVulZ3#jFlbH`FqMG_o&!d@22p`GL0|*Q zBSC+hqCgcf6$pugen7zP5<~@TDyu4B*m(}rh`?Yz7^O0x+7d-Eu0d3BiINqF?Pb{L zU^pj$g9REA1aO)K9dS7VRg?q~hzu?r!b1pJ!MTAZ0;MDvObJ{YxFOhG$S`X_LV-aj z1UPpUqaftRz_Pd;!Hcs@u$QA4il$KtqQ;^ulVPx^!PsmZ%bEshxekI~BHAPw$X{`& z20M#F(KZg(P@dB;4K$F%EZ6jZsc0L)8#D@AFuG7Q54IWAURL z4CPFXrdZCT4BU0N^5RjGreOo^BvIVPa77H!O#|gYc{K|MUQ*gCstg5!VXq2o< z(XVUNG^aBLW+Ux@6lbbm6CkvW(s(ds4Y83LYMC5u<1vDwSl9dd7xS9sY=%kb zP;@XLW&c_k!s>=e@=yZ+K#CNddtHDSI%VmUiLxwSw2Y9y18pQo&D2cG#u2Z&j2vZY z48*zrg=iKQ5_X_=Z7fom*QJe|#+o*ZhkA>_Sg&^(IRgx}P8$CKAUp+@S+f<`kk_o} zJk4r07AVbfSP`#zn~g#(hJw_bLF$bDstyd$zzD`9{|i8LR_8R%0*_7=+w0fl(KQy+ zEyMaRc&)LJ;iOp>q64DHH`-_@FX|+iQyZ=8m}R}9mx0H&i?6nR@ zOnAfM{{si6+bE0%l7S5Y1{MHfV@w^@A@hM-EMei4>%`5A-L3&Cd7XfQKiJk{m3d8j z8HnjLjnbj&4lFDj_3PRd7@7?-m>36G7xVgcIWUvA5l}m~i+3<3gP=V0^l`a>Mk zEigBghMbIW+QOTpe@33ov%tx zhEo|T)G?qe2=yDBmn9r>>X1Xn6-ee0kPgFLODR6y0XupI_Q$X@ft@@`nLsbF?>b7c zEN!rq#W0%d?bQ=uCPUaD4~KFL28Um45X^BJ!J{;sQK4{qIAe3Yn=clIW00xGH4W8l zmVI|a!H&nmA<)7sP*m6lv#jfV&J$twav22NC`6cGSV_uGN)G4)M`R3|x3L8WWn5Iz ziiHsb2iBe=P!czBlwe?FHj>m)jD}$`kYfOs;F2N5!eCbm!=0% zv&n}}u<^QO!!*}+w^$et z2^J2rLy%$w?kf-uDo}zWK?8UJDx|K{I>mw@TMRi`4zjm6)U!Yk6jL~;brOQ4!hP3X z_lY2y<3s}T<|581AWD=ZMdm@E3S}jzkd~MZ64d1gsJOE#E80iIi%_a&phAknVFIcI zRm$QK0U{I}Q{Z^0S{4X`tgs?bB&0NPdxp2fBeGW~puon$wvSfq)mJD`NGjBtAW;to zJX}{PibteUQ2T}28z~nGf{ZZ~@kB}jz-6uk8QLd;7y{}hEDKdD`>HV_)L=LQj*CFP zkP%97vg3&$?lp}_<1~r`MMW_P1X10t#2T-G_rO@t7O2)jaiqk6!)}*gAn$-OIA}6& zfEBe-kTfwYo2lVoUUZ}6s-i;zgyCU3Mrxdn+AI*pgLy(}&_~u@cCmEVyXs=ROcUcY z1`aa9aA2CceVRH45=j%Zn`d#N1c~5cVHUwarV}GjoMb^F2hj%{i-fE)$D6bXD{B!i zNs$#I`TXglHhv{LyY$QvCn+vEE+Hh&FarV3r@f!vetN{}nO^V%gQ0~#FBq5@)?W1K zpg@wJhrBY;3V?hn=9%Z7kz?roR$M}imJ}EnqeYm0@v#wY{GQ%?=1KcGUVk`H35WZ% zf`g18{a^PSpAWYmjEqh$cpfSk@CB#6;j5_Yv(1iOdpYm=Y_nt6r#-vJMkWP<`7Icz z!|i8!*$*507vS6#&c&O7Fg3?pj!%b1rkT;W+?K8_79?AYsN9iM3^ z>R3pwXHKsd9C0hW3uoiuSiawrbNT-|{r2QA>FfOPbVjdJs)H=|OB?`ke(8qGIX(`p zZnzu(aenEB%Q-#{t!}s+0C9flhRZoV4y|su8~|~C>4wWWJ`SyJxEug+e(8qGIX(`p zZnzu(aenEB%Q-#{t!}s+0C9flhRZoV4y|su8~|~C>4wWWJ`SyJxEug+e(8qGIX(`p zZnzu(aenEB%Q-#{t!}s+0C9flhRZoV4y|su8~|~C>4wWWJ`SyJxEug+e(8qGIX(`p zZnzu(aenEB%Q-#{t!}s+0C9flhRZoV4y|su8~|~C>4wWWJ`SyJxEug+e(8qGIX(`p zZnzu(aenEB%Q-#{t!}s+0C9flhRZoV4y|su8~|~C=@hPVPk+wy|7Qr|9rtO!9V&u&yw^b%x}_b@ zp4oY9=PuQEwasT2otoGsEclQ8kCt@kB8Tkz&ZGRA__vyNteFy4Go|a)>1&zt&ByK8 z{bf#*cgIf%-cwx}GqG;tHJ>e<7P)j*#I2=i!}2%Z8kRrwGJ36bX5yColehAfLG`{G z+3b^MBM05B5MQoVnZX&#>BiS<)vDO{x0=WP+O=#<#-O`ge8YggrN13Hb4kZCY z>+$1!a{L>9-u}M*=k}>QWSM{9n|{65dQybg;PAP6{l6M?H+=@yV%^p(@6tPu{rZl_ z&SL@Xx<+!XgtMa=j|0_r2A1*ba{Z8JU*G8KS|{#X$fwJpy$?78&#;a+xh^_uDEhXuT+Ahw@AOvVB+5 zmV^d;KJ3ORirPO9X*%uw1|E;vy*q1hmorp_zSpLE?$lN#o{pXsnX~>vB8uWi>#4@Q*k@b#7G zMSk1fUAg!3gB?!CR%EX?z2Eu$KbDvKv_q;_tx`Q&WOnkHIcnz2eV3f_KJ`Odc1~{I=lm}Ph2JXBmEWtXYigT+w6wOhcXW0Q4h@ft zV#mfOaIosbJN#=*8D_{E}{nhZrta z@%zQq>pPmekU59;_KDt*YZiJM5JH(w$O?Sw=9xFmW#|?f@btiex=}_sg3j4)M2b#{ zwoauZ*!Ny0x6cHaXst|JYsMrR1_{FBcYAD}`gbo}M=qr2Fyq&~5e5?&r}a^EC4q|T>p`zJrj>-cUI zDTtDLl)3H>ZVQaz1yE)q<3xDt_GKA|dx-bX6UG*O3nMtI8UmdQkF*a}1$PhU7DG>; zefcDP5Q~141p~kzO7D5+uBhG?aWFg;TE1eXjcD!gzttnwo|j_0_$be1@B*KQUXMmS z)8=OAisQv1z7+oV>$Zav#^u9bW+K)8#7JV$&J7!?k>?Eg5a2F%SLeG5^Q_D(o~NHS z&P#O~-)5AMG11d(670Sw=!O1Jq4%B1+}5d`WM|{Fz`8SZeS5V%#fK4Go@xgoaN|GR zUyqt$kmc-0-dt+EVCgXoUKM+mU2iogXYbIM^~Jyo!XX>e_h}O;v#+P$%%L-Q!8?%U znetz2o8AL|{V); zg|)x#U&{DcfuaF6fi`jIC(qwWb}G0sQ^DQH{_DtT6tw|@FLk6wyHd$CU~lck>WRYy zREd7eXeP+paZCY;e)z~zcCKsfNqSJ}q@4|$ey4g^s!h?xnbzzo6}BBtk=BY97BX(e z3iZGg;cBxYgu^@w;I(@lDzWrr6IS)Sq)J%sahv(DKPi5%aPO<)LGX^2^0jHU*?PZ{ zsg<3HCU=BOFnb^RWCo_h`}xNhje(5Q1*Q@R6#nfRa+mAvHUwuxK2Msa9Cstf5$)dZ z#W(bBIh&XJ8DsZ~8k~BW8s8)@Sae5q^yxLojxd{SHT7D(KzK@=Q+}CrY2bF8mK`VI zWM}5@!9Bw(_is?5mXYS8Ut47dJp%r(77+F(vtIH>P!Rth3_&4U{{>3mEL<*ygE3wArrUo z%G@F+&+FBf8fz{s`wKM!!*;2`8IWkXw51kl{gd793g$I4oJ&r*ve+iW;nGLM_m5t{ zg3@u;dqOOg^7`J!ah>Pp^yA9&#D5mIWn}c7Y5ayVZAcl!`JeX`ST}N!X$awd@xm(; zee>S3)K=P?s7~$kZ#VDe2lKH3a!uCUEi<6We&!;g6--C}-SHgCIoC$Gp8?U95{Ay| zWaQ~eugM*|aq|@KlTV-6%})^#)3=^zZ*N+JCqPn z>3$|6`!J*WUK&Cr#ml=-r#T)-`u*FxCPgSD(?KZvQ%@))R`+aenEV12TTA5?entaY zupZnm(rYq1mRhVA5@jaWmdosS=<8(oYGNeq1+96MCcii)>A(AUm9=)*>er6|uK2r2 zSHu`^xd{rIKhXY#Ikv(tcT-fp2D;NzO@8GL{ek*;5c=i>b=U#gTfG$hG-Gx>u$||8 z@!qL$?fW|gFT*a=0Bq4dhI4l;RbVj_X(M@;qFs={B>mfLE=9)lZbCx0X~j^^_J;i2 z^RNwJR6$YD)afrpVjsRwjkCYEfT=yG|MKnXRhVQ7$$I#f0Zi%CZkaei^`5`?_O07R z%}1CAT_J2bcd#OwAK%z7|3K`ldasD+r7_x9YW*zK+IhPbDb^4iEV%ixKuQDaUjuD6 z6WUh86O+}we8FF|>PlV#1_F7d73tEd7s0-J7i7Ux0Rg<1DW8S0;zc1;aofae&pM`e zMh#*h2E$nO@GD*=ni_eqo|2!(D6O;PsH>iWT z!8JM{yeG}Y+9pXNyRO%w5F2cJFuEv|YH9zgg3VN-xJe^ppzR)tljQf?oiAtV;Pt>- zy7Z|nDLCs(XR429dil|j8C3PQ#K)NvHSvvEDt~2|NXj4XLP~6e)mASwJTGukvNiJi ze0TKbb^Y79q5%h~+dX^gSx#t|?BYay)6S_F@Fg}WAor|D_G4M|e1}^!pbQ;re(+lG zGgW9HedqJ9#B9(F8H<$VJZ<^Z7q|>?s8>IvaIxfa?#k%8ppZ~V$7Xqy@s0d5mO=%R zbqb6&rvc7Sc_l1#r`eC8v~l2XeRm85`cwY_10%y9FwwOIBQq0S^wAp&>j_pC7B;#B zWaD6CJHbu|GwVrC4)&9O?7#ILef6JJ^vce{%<^Z({|x_!zB~8-^&L?8|Gd7VkR^GC zW6_{r6`8)xaSRJA?`US_8TGgAr&XIgf9<4$A0QR1x<+kck5-yg&- z5ffy3WV%0$cy~I8trfVPW|5V7xHn7+po&ZpT&WRcaT@Sgg$5A1)^wR^0OK2i1WKjs zdA(%bs&d7vL9ZS4x^oB3-#6L`U_!%jkBYojffCIi2l6@%xK!vCA|R{dy$~3Q4rij@ z-D9#Ykp={TU&Vf&Fe{guGar6;Srr1)o>&?;R^RA2YK`Pcp0IhB?5@pzk_~0yy^a~X zKxMmt#ASH&HA7BSvMQ?8eYTfV_v&5o5yls&BDqDFb~^MB+7SQO1hjXLGojPa+)MuB z&c-1`F%93H$8X2JnX*>aT|_oA=udFNjl6=8EFp&4(1&csg_G^n0m`H& z4KTDzM0s#BMin?+$a7!aHOfkM=92`oDSEFhS9-SA?$<@kHiYh250{>=8&+pco1OL4 z?N~>c(}0^r{W>?`k4?{==OBw86%o?yy_y}uZB2`27i^l#I_wl65E!Xg{%(| z7;yS&*i2D;N_oCR*>tt?;Txl0i*Dx?o%Vjn`ht(L3cNH(l~2E8mD>INRNf_N^Qu(q z88BfaXn-GPd}4Y(ka+0to!92tAJow}edlrbSGU&$PIq6UPj_UCo@<%952r{xP0tCt zuvzERztkBLH7BE^?((k6c05zsG~^&c+3ZugUF$89>n?`M>T_gbN(jU0HVTfEvQJDu z-M@4`;C*$a_VyU*O?3V;XDI12hMu7_=}@kDV!+YOkcfxJE3ql23QPJ4b#Dp33!yyl z;toX43cMO0>n`VXn_u%0Y#Wm@9d(F2b!QZE`j9VaB>aGEbOQ$7V~$lP*)3qn;4tej z8Zgn5XT+%V-ejoDM zT*#{YLK;%dJR9dN7mD#DL6inyU}7if#x9i1-FGqZ7Nsok@VrI4>CmQc&i;LtokQ)i zVWOcJ_)wJw9LEvRkt~$5dJPZvgfk8vLsMfj(t&cAk1~mE7>9wXcuL&kJjnWD3b}9F zXi3+Gul#vKjSYs&-p#I$27Je?n?b!l_n!&Eed@^)c#QmZNJVs3iESw;S=;r434Ua- z>i#E!X45n`(a{K>_)KD8OW|DF>h7h&pH?#EOAqwSXh2uP=Gl_i{HXMD|Hat%xA$yU zF{+(i1=vg(z45IOw?LyRY5kFLcbm-*oIAkQbFNtppO0E*qC0lp}U za2QzRC9w8s7Vr{CF{o0i^ZTvTjK_avD^-GGRO}5WMMK?oB z>}G=(sxd__Sq|%&jUQDB)`8hojk4hiBUCo>rQv$jl4fVjkh^k9d$$gf{l#jrHbp1C ziQ_H2Z%^7ReM-AXJ;%v&4ZUjUZ}p@$GbJvg4(%RkS*O?k2zaep zX{YU>_jn#VU$tRF*z}vQoAb?^jg2WOo|G?ns(6r7b$w%sI?(C5k{(>9_aSZ8bh|#O zs;JzRG;mwkpw%WoN=8Qa_05R`A1af!*=2U#3?l_Ey$k1~pTOk!2B)W5{rrUA-ZGoj z$}yq$EVuzL34xM%j`p4X#xXN!|9i?`2z=GeJC?c-sW;m#EbA|}hRe5{c9|F>H<}M! zo9>9Q7MVEh9PS>9Z5Qtdm)loq-ABU}iJ~xGy8)4luLeIrdIX2Y2=)g~nuCGU+>UoE z?%CeBhrOYOA4hjdBr`K62PA>?ytKuu9jdYxAm26DZL%YWe+${l)xw{xTTouyu%0xc zsJ=#WLw*LA{Y1Wk1G)}Jdc-&AEnY7xhm2O|+KW!3>nB$vFRwj`z7S9OeEe$V$5|AU zmy^c8;?SOtexGG|hDj&m&r4S)Ar>KJDSg#eoT(Te{!S|l(uTAdR%09C$kg!hAnR9E z77=m2M+alpc#Z}%Nj35lM^}x}#7c7xQ&BlsOq;7DrJWtvzh#%Qio(PG!d|~$A8}rZ z2C$&6b1RWv&qO94QpKWGO?>-JuGM<)mG7aea~Q-QX+mtMcKZCE(%FvUr>QJe{)>tq z2y7a+wt0*vuI^J79yT)4AKQx|3HT!n@(?(|HZrl$@vB3r!d`Rh?Fiv)_kvVbdMf!I@GJcGs+HPE0vV14l8OCb^j3Y-f zLHp{5vPX!i@JszP!0Q(!td;LzLVP*B^hJP=7>nf;=O+1sQ8>bgSq`CHHd?iEYpDei zL*j2mp6JXaKDkyga$kXo-KtGUWMES1k-=5A_>zW3didK+UB;DiY|s`}Y|xe5aM=huW`?Y?Z;d>WBaHxYr1 zWXpule6Nbn{M*iIc(GqBBssi*KtI2u##!&_xU|9M;%i8c)t4rU-%LPfISr2=VY{Ze zq3)c|rdbtktvPXxiY5qGO_XG7o_c&?EAcF6sqB2g;`pL%jq*E6E?-{+v;|`mu?mWiY6_fQg2l9ZE~&oXP*q=jjSj9W`s zw-f-#vfE~BR5k-bn)5bs07nB5ZNErarB5EYKR&VW*HbAUBD9%}^#?f5KZ!2Q}15Vw_Xj zLQRd*1;chq0ek`SyevaaGOVweVOMU#zaMh&M?$j;SS@t=V z10~)^JV$W+P2=#=AS3DS2a!~TeANva0J@FRT@leB#ti{ zWV_#g$yxg*VJ@aE0A4O~?4xB3-uJyir5{IWnRv0swiGJdzX$DSqxYBgVhu71Sz+`P z>WSif0byTJn&4yTt#NLgohQ|0np?FSOw{e&&WR_Po#|LzAiOIYMx^gWl5nH>S_4> zhu$Ir{d;0Gz+B@-C%M(16t`}UM_ImpkLQdXdPv@`pyx`8RCdkkon`CM2j(LZKEg2j zB16;g@iVA(ZG1{+6dXj1_^Nu;eTN40)}lBf`$QPF$xA& z!)x8W(3WnO4WH+38A(FtPWNO4HN4b#vyq@R2ZveY>=qmY!N9{V^1L@LGSVA_HvS*_ C3e?*G literal 0 HcmV?d00001 diff --git a/public/images/logo/sovamed.png b/public/images/logo/sovamed.png new file mode 100644 index 0000000000000000000000000000000000000000..7c3a0aee5594e35b2727447b7d9ebbbfb478482c GIT binary patch literal 10931 zcmV;kDooXhP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!~g&e!~vBn4jTXfDo9C0K~#8N-JJ=5 zTve5ZPgQsImUPnD6S5PMKmr0IVFVIXKoB$_DoE6E0|aDbQ4s|f7BirN3JRzQXp|u; zC_)G$yDYL}2ul)7LP*FSvhD+!7fN^>UsBG>h&H<49) zbcpFsT7Po!xK)TB(z0dp4Q736%?1t- z0VJk$G#WuX2R$oH^Xs%HZn?C?+9pEHEoR%cm~Gr>)}jn!2HA*1kFXm~zt~Qgynj$m z@J^A=T3kKI)JwAvLmp~>Jc#x>dQ~A9RjUFPmhjewtqL3V@}{+R_BEH-qrd*SS+x?z zjW+8m!PoUsfrJuND5Em^Z&Ichp;!P&DJhqe0Cmb=$mS~jsxF3BKq7T2Kdv~Jk(gVy zn{C-@wsyHIzGnJ#``rcKvzZ4R;zb?u3>hh>qCzwj=rPn&z(KE|R~3RB87mkg3cLEe z#V^{-i%&Ou=2^2zqs&H05phY9aSZn;;m7C=0!4GG-aCS)2t-1q=HY@@aK-OLl+DY+ zOhWWI(q`&ZFUmEm_q?`9JoK|~UUa2h_VKg4j3cBMjV-;6@+d@>nxUPFCfwI%Ev3Xn zTvqO`BoNEo1%I_!pEydF)o=EiWHxxX3Z{aBX(^#HAq$o*VwDAmJI$R6$wg8oTPQHE zBCoNa+9+FqtYx8#GNSh6d!2G7q@1LB&g*|STei*4yz&Qj!{^Qq@(Jmz6esy4undtv zLG&6rr1RI_1NRtZQ3zfoPU~S*bTTSa<%}y)j4n?#*@TbGFjLR41Kw%z>RP9pLz6z@ zheC>kr9&ROu6ie;GP}&bi%em!(cq>=YeukmvDx;lUG(+u+w4!C5#*7(b7K+Pkx|fl zF$8aa8Bi95$i}^Az+_URUp1CT!-UWN@Dlsw_b)Y@K1GA`AWI~r2sJ(pN!8hXiT{%z z-f(3(#ML+wBrZ5pi7l|iX;pgd!UnxQ47$LBo$ zpndwY?=w5(5UbEgpUKLORGzDq8U^V<-cta5{q3fc$WDUh1Q4Hb$st=U#uqHGgWq$4 zJ^iCO!6nVoTD7L?)2=|zQIbO7m1^D7=qy@+$3r*ujXBrZ>?-nMUEVUxf3k?>;J5ZXjpK%7t_x-Sw5l?5Kg(k2Va z>ZDR*MuiqyH$QQoec{ZL%-*f3tr&`l`55B~E$T`E-LLtdi$Y%A9Vh@#i(6K`J(kiM zYs5Hv?Qc)m{$HBq2Fcb+R&wFUx+H~+pb;sko9Mn+LPn!ST88c-=3aSgot=8diDrjQ zQ=$e5XP^id-LL8Y9{+$>bd;v8lP222zdm5!{^Kp4h|X~F05N$iIPEsNJ%%7+R;E?d z6j?pd>6+S(9u$ldOwKjqim416a-N6$VcuQ=4UVk1w;>^TPoO>QjwcmS@5n8F*&e;) zX`3-_vSrnKa`GDa?fVkj-tFwP+m%h@!^+@zs2Zymeb4OgyUT0aPOS z|8e2d_UHMxnhn()oxKx{DNMG!XOJgihGQw9OveDaI;RDN*NTNQfu50OkcXxWFjXD5 zbd?&&g~5Go=B4L(KISsq_{y@O=Gy5#N}Bd&WDOMz)?zC5{(Z~_H7J7^iiEDRc>!=F zJ-XISFr{WPlZj?GfS!g$a0G?42|D+!c;aF&8t1|d<32hf zr`|^uF$OUA83E}^(o>Q|5>rj7N@;;dN) z#2z;5@}QSgs3F*Az9HR2X)ATbn{>V3;bS$Bj|?OeRamaU+pR4)VqqTONl92`#%FR# z1s%~I^}+(maBV6is?BPo6%Dxg8(cuNTuOv;0h&O}F@|PVEwhJzKHuK6&%gOh)ICZG zaw&Q14Umkzt ziFVORpR@h;811<`f@d49x%apBU%$HEw(fY#thSG6RJxIJPv4>kHiBx(0>&?B<*LLh zQ{j!P?Xer?+w{Gr1Q|*|XHyZQ@QNW30tAtpv?6zBu5Rcv2r=RQKA&$l5ml;?gT6DP zN;Wszh`Pb{%unapZRdX5m62=BS}vKjw5&5F*O2B`tj@WAf5EXIvn_M~Vn-ZwMDTD! zHX|dJ!AUWGBPt|aF23YJ*jn?#s1=bfM5JJ5;c2#BP zYtR$F-o8BIwp~`XcvNGI*rkIwT;T%$@4 zL5gQD25(a#FfQB>mWK~_mn6)2`dHi9&guHsL5S8%E zqRE3|Y21f!abG<^+V9Dj zU3=l>W^ZlPl$eQ#rl;<5yAn$e5U}oTaF?H`w0oX-z*5|K)HMu);CaHV#n4V6P7f`6 zM8+xt%;Y$IVe!jmRcyYiNTEW=UoJS8xxfv#HrgGRUgv39Cpb!bx6&1=Wlwc=xR*0DQ4MJV1(BPL)gO+UG zyAPK|PtY!K3!F{j}DgE)1*3fMz(*e(99YnKiWrTPkUoSAK>q}Cby0OEg|PbO2|e6Zuof_T?(Z)KTM-5B%)v2=mjxmYy=2BtKI(({ zx|q`TR3TVqu+u-$5D#JM@;6p0Cl|t$=mN(mUKWTLGp)*6n=nZ01_{h%sbsf}9>_1P!b}L5 z_<;Al^Dy(|4L5rYMveSZ3MPlJVx_aYU-+pk5 z>ZLa1A%#pFHi}K7NMb-j#D8|;zWpR{*R++TbI zLjr3&_Wz1a^g1adD}}^mP8I6o87ZVyMEaj_kY(!qJtCFpZG|-3r62#2ee0ypSW-q& z6>WpiRlfJl4SA)GRA)c>g;zBK8lMSDxO3Drjo7AD?}h{pZmic4MfKaiA05Q7vXt zy8!I+99fN&>Cf`9NbKhR7!(=zrGICeFsfR*n+By>Q*`#OP*folie>?A z+@0&yUeN&(SWOMPS5gSVi>mG+3lwvUL^9!EOB94M%SXz69-IiWEcus(xHz}%+JXEkxqRuzU96c0DNsy0UziE?4gGu7Bd89Ln&a(W8iMY<(>g0`m%G(8{vOofBZnn!l z{Z;$siJ$bH+RE^jD?;s2%_``#b>gzrW{rop&%a9}y=ILa&AAs4OQ!9;(+;yS1BY6h zn5c?Zc<=D%fWth6C_2mXRl3gO`q!xbHT2}etc|?}EUPG)azO&pI^>JTf5xu;?0La+ zU_9g?U{pP-Sp{)@Zn?sf%a_=phih>-a-hz7Um>=Bv)z5?6ZXLa4zZMa2*%Ex*rc3J zxR7XvP6`P-nfzVWDpfe8?zY#NGi~LT^}&>}H`gIdebOnLF@Ar0^vaw3&DTm^UFMQx z=Q0fi+g;$keTGFjg&QB5Z=e6+g=PaajqiECYmbh4jn3(T>o(f!e|px&)%N#W&%Ct6 zSi;S}Xa-n_qSA#P=>(rfFsZPMj<65ujymMtNV!w%@|cbO+p!gBkqiL3W*q}@m%mb zT*BC)dx(k@l9x~~A*A2hw%sOv!MJN^Y4lfs$z3;ilq;#2?rv>fXZ9ofB zQ9THcpw;Yv0fVJ~CF;6QxD}1E6+=6P0MAT0g&c&0X_Kb<0|!B^y$LE7OB$=@J^7H% zce0Ib^h6=tZ1XRyh-`Cft1W#sxbqnpn+ReAKze5+0NSM%@ppLp4yK(*Jv_LCod%Uwj z6NH|Hn_-9U9qeIo)2dybF3?UP5nLhSmM=17MQF+%qAB88Ka849%0gZhA&E@v$OV60qR$B!xs*)>mFS`rsgj7g2U_gJ;Qpi3B z=v?xW8|EKyp_8WwIw(X%RG23uxb?}q=h1B7BF(a4v^+~cp2Q~!+-C-2WW(Ka^J;)h?<-2)R`v*=TT3j+8|aCX^##HQ9k|Q$#(L)lOTs$ zzTb#ZHfY$W;JGH{2_7_4eiYIL@sfc=p+y6j$M>(d$DV!IPQQM(r?F^f9N~4blv?jR zz_7BzCre0-$^EphLFT3h?y_&(@LjXvZ0SQ>`6CoWdkp6{JV1{Kgp+AI_2>_CQWDxJ zB!Vj?B<@`;Wc-X1K5o{s15E`b$;Sm}q73jl17h*Ci~=7tVwn9xgYjuMe%G4V1xE17 z!)?$C4V>I$_D45W78eyqagb2>#l3gh=db&gPP9Pd3qO^(V_krMuS;Qf0UZJdKkAPL z^Ks+$wo!Z?FS^)GwA(siCdEv^iKwf)!KxM?-WA`_(rCkuKS)C2Q5vAcKnmgcZlXZZ zydMHG?gw;X?OJ>PjN@#UtnLE`O}9bSwLyj=cw*_R_RELvwp-@kZka@z7XSU^$}}#r z?SM~1(Xd?997{xA-tO>&)3-L;FD|^wPJQnOBp#)gu-(*QFAIAM#t?3(xG2A;! z(S6?~XWIh{AG3IM-{3_ZxImC-Y8j=StQ4|r(E_s}gT-fcKvsWf1MbWbDZdpo2K zSFg(}1eAeY7m^=?^-e}c@#WLLW>=kbnr3=DB+lfbu+m+llR}95sxsX6M*BRCPPIJ7 zLZwGve74>H(qH@&t`$jDs;mBfU0GTv6ez(@FcOZW6URlSHM!t3umm10VSg$Lo;}PT z@f4D1cPs}Oza(&9B=G&ntu3~7{@<)#X~?T^{m~s1SDng3!pVfRIPi&88gt`$-_#U% zyWEI+hTN9#d3oc@gs@JBE5jo@^GhX`Sn)pu zG`lM}1QVfHneNi*F4`$WN5)dQ%suy|Dz*cVCD1R>NL|Yx}M^5Dy#@hkh`{*d_m8%5_$n07~j1 z&ICeMsHv>7KYjbBev48IgCLQBd4$n?_d}lgP;Va*M!p5p)#eoET2y}Q~Iw6+w+ww^U0rl%Wj=}lf}mGsY!>l;+%*k z`GV-bK72Poa)=heYPW6OZ1t7(_R25svC;hpx~%+C)ZiYpJCJlYkrytJXcteo?u?7< zxMNPV-0Mp<^I?S1IKxadS`q)NgE50PGkVAp%4$J0v~Pp`?rT4^5Akl`Yk#*)9h-T&_^+%G8I`}Zge$`O z=^3k6St3?v&s=xAy>sF|{%w^4$?P_EOCbT1K{w@t$s-#E*-c-*!p=JNOJ3vT=1yP`s(TK*J2~bSdRNBL2mC^ z!3(8250ZAG<9SDLy}Rg07j#8CsuzQa;xwDVo6kKhS!AC$^{aNpDQDW~A$&I_B!xT_ z3Af938>J}(4pgx-QV1Fmf~bGiPFV2zYc}f#7uwNm406_kt0Ds&*TTPRH--2nMB{*bf=5OKL?%O?BP<9FLdKe$Ymw%u&(c(YnHs*O!S zWeF8RgIMD*&5tH#nG4__f$yy|tk;?6lX=NDI-)vKqB8Y9Kj z8v6`!brsbfNQgjjHZlc&!C#l0V)xm|10#nLiVk8B3AMqFwmd2C9`bu9S%&CM8`LY- z*wiCVu$#`g#3l_NWdr)wyIaaB2e&196J=M3e<(~eA|Oa^tAZUGM_ymE%6{M z|I8OwHf*@rfI;F!b?~!C%101WUN^}99#x>*?jtB3ps=f`J-j#0z8vpSS{pxgw1O7_ z?ca_(&c5;C({08+``h6D4PGRvGR)v1(c8bV*efopP$5_r^5kuX$heYnX>V>?XaD{1 zJ$A$Wf3P*rJRICq8aBeLPrWi%`0r66Jgm|M8yR;;kPf=KM!Jt_NRt!A+td^Ie&dF9 zL5~={r=9hwv+Og6&$Nj{_wXJ8*okl@FujkORIHvdqNqarMpO?K(hh2j?>NY^{V}d= z#{^|hYa-(pgBa_ADj@=+<21&yob0l zBKkcAbap|bvqH!%vySmxl+aE&3QmhiC}@nZOHaZ@4k}JLRu_e}3)>TfFg2YhAxu1FQzwRyq7W+=$}ll*)3Gil4y)bVVKr=R;?N z?K~YL1_lmP&l_obkK5a3OxfSwci=tsPAtHHV8wY1m{zv{4;@)NgBl} zFyU}++q1kAs*=Z&B6*N4Y`(*bAvXm)2G7082XsPbg{XUSuL~tm&|i?mauiQ1P+9cO zI0xU=RC-as00&+7EVNXagXG|^yY?akNN$l^Y0IQ6sdY_8kyLiO-~w<8|vIN&SfjWjnxv=OEi zqO+2eiF|aafcrqxE_Cr9{Fs;0L&97O6<;1eH+m~cQ(xR1_osQ;$y*)fiNGS1-rPwU z_Vh^DqD!tQM+H2W z#dQ7LpIm8c1d^3{?+l|rR@J})fG+gY3LLc_=oF|r)gjG8l%Hc5QVM{k5y}NgEVn(W+CrLh zpL;x4_3p_zn?5FY?~)h2vJA4+e|Uvw;>FNz4B^8lym`yR%)E~5w`*lhBR_Ghtx2ty zs$~I<&35cz$J+c$!*@3n_-O{F`aTc}k^hg3ATkO-enMedJ46|PGB8j^@MuTNiimZA z{FY0I=oYl2!?ip@n#=~hqlF>ZPhgxC5w+3B12X$MCt%|EOV6{rAD^$qZeP)f*|2QA zt-Iq9eOKIU!y9<-JLoOJ2PQ)SfP%(*um`v28h?Z+?bFt(U6g*6{0mnQ!;JS z&pyQ#E_%*vP(RbWTjhgqI{M3|WL&wyUjFSPHf7i-O;`#V%?-Q`$ji*3@>Q~Qe`86~ zI%@X$s-%w=%~A$^9YMn^(}%vQc;sMvY0VNFangZy_+?*o5)AsRwdln>)z~V|cBm{r z8sHC)$vn_&Fk5^30j^p@UNL2ihUR*Y=dsWNWt?{HZ0mRI-nMY#Yc30VhW|#p1c`Zj z@4@HRRocSWmLOFZAxj7U>A`?ZJ5cRT=!|;;ys7%L`FGeuKl)MdDLgjRm>#GP`=Ss^ zP-ey4Prh>CAbaeYhb?yK7(3|v|FD%Q^&V(JO8Kkhj4QQ@j_1_j)y>m*XOVk50=uh8 zX-t&+ZOf#koM5`_#w_h5b@=G)3oSNdoZWQat!lgE!`@d_mGPkf+&15c@!1r_SX66q zn!&ROu8>Hap3)vct^#BvZ5TQAxR2QB-}}DVBTravTa#7A#1j5SRH(=6qWh&Qg4fL& z)LlmmwC9&SXJe0?WRuT0!Cu+APFAHJQ>l=sltTE93KpViXQ!Iq9$=d%rJzyKpQ^7> zjJ>=ijhpSjGd^n7hmWy4p1D(P9bmCK#){x0Hpqfs*de$w)B}8LCn@86YLPXtSxS-u zKRsB}g-v#zWvPq#yjcR|<0z{dH``D6GE&*0*lA*>iqsKc0J|1ly>)8-3>+VH);cS8@c|*3g1&(a?i^)Y2a`TL6VKiG8gdA#(KAFk4{@B z%(AcuCTB6sIQ+!;kze|pJ@ltr%=X*Q;{6&#R6`bw&?WP;4ISB(L}4u07zA@tPE(UJ z)@Q_cJL!mH?5yKIWd{!*<8<4B-qdR&0%>pZu|>aAPs>wo;2{qg?!*1U3=+>vHW^_sBA3;19H z30%19a@RzgaYu;?OSC9WZaleNhPd!0d&kUA+KboxIv_02jzDYC-_Gc)5VvCaOh#ly zQ*vSnZpR8QZg|5E_|$P`%U(76clDaeYPC)cmFyX$nO*T~W&E&Yuzt`5l;iV9<>NEo zTbdQN%F+_H=d?rY3o}1%ANu$A*o21YHM!zI*pbrFzqkvVr@EAYLTDwok7Mjnp)CEY zU`v|JU0{83yWM) zKsJ%EOhWBj@VwmFWP9-HId;U}`v-YSKqr?)-=L{{yPP3|Uq$2og}(*lLN=T8x4+n@ z|3ivlinrH9F{KuZF?U-CO_IWLeBe?80>c(D8izDFvd-BN-lJ%ilIk=D^=+_y$4s&# z4?WxtoqCY%J7I!N=|9x#++8f+@|G=F@~SP}u+siGf1W+H;uX1}ZDtxDePY7E%)OUr z11hfw73Q1{7V5y;fUG2-EaL>^`&<6&%bBZpzb_*vNF~97u53I7(Vz!5j{uIEQG2xGWl0dQKAy* zIn;rF>E^r!-n{X=weS=mmatIL>VEv5mSM$DLtSvtQbvN3 zC|xNxhrrw~SF0X`k5H%#L`R@Jd4!FQWR_>6Fh7ZCoxJ%vB_CMf)amJB)F>_z5Tt{$ z3MiT$u}p*fO7*zObU z+>6)PV=Go_tV!Dkzw&jvTD@UXU9e3A((0Z2v0v&%v_vgCFy zD^y5VK&;*?!!{P;**BNlxmRCiPd<8&8n-D}*$y9RHejHLs~)+OTrw#y7D$z;G-H_) z6uq53Jq&;fiAO?=u7~>d^H3rQrQva$hVs)qJ`CeCHm2qrX`j1NRr=OOvyJP`)@tfH ze3(t%e}?_!+>7lfzUuQ&Kv9KcrG)5fpwYTySD@{lLygbMucJ(E0FhQ$vPI#FJ8rR` z-#ynh-pj725|~=@egy+9j{wn&s1JXMz|-ojOf(etpX-YV;#Q(K`65JVt~~wWi2v|~ z97#&RO)_a<6}@GP*(NEWNduTVgMH`_yZpE_?W|KiWrH9QLCWK8V~Y1CiYo+v7%HJV z3c)b*Jwy!ijV&Ig=AHJO2H+UN2zx*wUfr<4?qB$%J+SauoA3npp#8pZ{XV%s#!Ee{px=qIxW^&3p zRLUPY!45xox_$PD6YZFNr`d=BN-G2nLlG6+9o525r+C2LL{WtlDxo_Hal;Fu!3jV) z(u4ON8Wt*xG=9QpSH5*kdb_>8`b}G*uypMkwsOk`TO`-Fv2lwfHLcyYqs6v0Y7M~z z#48ZrisU4`e*ODfbu3{GTG$Qh*I=Uu4!5Zz#@oa_#@e`1qivMj(3nIA!52dOdlNXS z*^qZ7{14nht2A&fhs2Gi0G&=6{GY}sgw)KiwNdBaw1Tx;vMzNN+eHrw9T z>W>4Z(rLNWB89_BxR%jmA*po;Wwy06IgM2t*Er3^E0)?S6zf|lMF4*hkN<8pq8b1I002ovPDHLkV1j*TR;vI2 literal 0 HcmV?d00001 diff --git a/public/images/logo/sovenok.png b/public/images/logo/sovenok.png new file mode 100644 index 0000000000000000000000000000000000000000..5258fddcead5909e902ef5db85a486355f940c67 GIT binary patch literal 6913 zcmV+c8~)^pP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!~g&e!~vBn4jTXf8kR{!K~#8Nb=^#x_aM|5XjbKX%mwOxWI6Pj57m+c=V_domd*7>iU0qdO%d4j2eBH0!tGoXH z|K0oFd*4=$6aljUEQCWIR)EQ+*i+`rLNWGlI;JO=(c`C(I@^!Y{sABog~y@5nWAzW4yfdz9O5q}oRvm4 zCeDzcDq^ILr>+)(mKG@U+7P^OKI)e&fOk%_D1Wx2%7moAr8~%o`4Ar%LGPB=F}ia% z#*dtUpNM1QyJ%BsgQ`$|g-a6YrwS{}W^%YLg}$bAI;I}P4Ix1!MtJXbA-KE~^=nq3 z{j#-ydvXI|GZd0@rcgCPxKL9))0Et%1*kdqJiiG8e|#0G-hKoW7hFy!6mmZdnVw8c z$vFp;kruaGHMHJ`dT?$$VjX3rM>&w-FMs7D-r^eGH&d=>RB7u;^Qv<)eka!5s4RdGZgX3wZ9gxXXKxwcq5 zj!}<_c^|wUbFaEg#=l9ZIw5_VUc<5VPoargzsv1O6TtuMiWKS+x}ww9s#A~!Lk?1( zrv?sc1>tA};pRrHy!8|C%x#gWD1Ii#78?jLvu6pN(DC}W?}K{q1Ok2^1sG!jeOP-y z_V|#eG#iksWP=oOXdG)v41_1peBC-M_|Ua7ZU#{mLMD!#!Jcn^AI&r$Rmnve!>UMP zF~KYZNk^?kA(MooQH1BWVC|j%N;5qpmS%4bp2k%XHo7cn>cLlb;P3-KMXNtZgQ77) zOFgMFk>or83rxcc&B_K`4qS5AZBQEPa=0)16|!{?%R;?_NUh1|}m&Q&<+1i{|B;6#IN4 zgCWGF2IZh$-tP2b$5+0Ec;6reT1{rZQnnB>xb+R3TK_Z}0=49tND1qxeZGVBU{YK@ z?EJTH1L2qq7Z6R&R+K$;e0Y9yL()@^cc>1uz=_RY zB~lF7Pe+M8L{O4qADY3g|F{<|K0gUiJF;O@kBYrUq!j#vxds1VVVV zApcAey6^olnxdNUv+Sxeu*DP3K9G(sj~fYc(F3pTme!c0cjYMFawZGhQfN2)#z7=@ zc0+Z$IZ#B=g1ai+cs7C0G}QZRaP+|qRdEDmH|0YQK8kv;;i~wbn5gQB!8aX7Q{zhk zv^CJzgFpHo8OE@wB^FPJ#nLB!wGnmUI8sguGzF2o*n)hzmD3A%cT_&0)&ce!!*_mZNJ()l<7Dqf16??ua=n(5Bj;Eav*~pt+#B`v%nKBFz z@qFpI|K-g_K;+kY}nS1jKJ3zkX zJr<7vt&Q+6??kYz4T=*O9~{BNp<{>~Ie`WmOx;eG$S+RQ5K*y29Anh90?RK(VBvgt z0zM?d5rle9Bedrb0?{~p9uNCoDKDukPw{a5EM2*fvWKH#wBGblw5;ti!plcNLO4p= zTS~q8leeQ?btA>s!6}ih@D)FbD?%wzrj$Ru=TJWH#jSfZG6981SaQSr;hmf3mYr}6 zN1l2XL%;tMn!P?~)RTr>Da`%AdolOwcNmGL;@q}9IKJU2)Pi{-E@QP$A4YVE5dDtGlvVddLRj=K|A^*y ztQOxz!BZ@i2}|}3Ve?)0pl<#=yyc6Z7GVN8vRQ+=?_ZA;t*~o|OeRC8iAh=FTfsJ> ztyhoZ?GM~-1eTA2gjjLxiQgdc+ZW-Y0iCrlGZm}Ql=Q+u;jMSpss6iCYV3P zZ73orXJs0>A=S%g2?#P%2HD>&*!#l`P&V&|hh12Ct!Xl-E|zyR62YQ7Z$)6i93!GU z6g{Yi_aB0b9J+KhQs2tdNg+-HcH^~IPa;Fwo=Ub#HmNd7LH}to7#t@vn!7AyLYIH? z285dGh*6D#bEhsEl%4It(*_TXPord`N--@VrpTi`r{GZ4vPsCcse=}qA&(0SEjJQO zN|OWW<*At{9gqz(!rV_Tbs3Q|7K<9+DgYJBu8$p`n+ORVIc`LhPjp^F%pzHpc>fT5 zS~6R&@<0Xi3A%VvL-RU$bD9-}s*u83Qe^icbH}%!3QSdx^$IMR552twj-*cC%Qv0? z>B7B4A@316dD;v#_N5~)A=V;C&h){TQY#f*f`zAoB1CZe+gBNZGXw4~re2Mgtwvnu zb&3&CCdBlF2X>kuMEdy>Z)M0!h!rWC)hOv!S0PAvwn110ymMM**bKp{L16I$#8R3h zhy^NhGcQ;3&Pz7Z2SzEPd=zEn@u9&<=d;QNlfhGFn(q3Wn~cC2hB1%_+nSJwC&jHk z6>kZ-cO^883=NC&%SBPs-aQX)fPd>=INY>sE_bR(wTKxSlfw7YcC=#oS3fJ`W)SAu zDm5O(w(s1JT8~%CRRP$5++6uIPuqvDL+jE-BD@?FMabTJABKDH5vWe`GnLHUnH1j9 zlrz%Wh_1UnFT-aLTz`f3uPTc2LrOeh990Oigo8#=l?K4*up9u>tzp_`VGpv&kR#gc zK~aRHXf`5mX|<_sQ7_|XMUdnZCrbci;agJUjM5byYCSVi=@wZ&P&#`om*b^Sy6Ct>foMssmOp`^skj&Tfo#ABN)O z_Gk}1JT@j{Ni@IvGBjK;C*2UT>z)op5pw9!r{Q>U8&tQ8YcN791XY`&xqE2A9IX1% zO-5i9K-w8=H;I^zEjQkdCJIT}v9>b63&cot0v%tz1GKHAN*>(4Q2%Z^)LSv%1 z^_7FgzTAzt=2J@IgqQ*6#+F;|K>fK9 z;a<39b4-f#snB)?WO=%#!9TA<233F}dWf_}U`~fP2c@Lsn#7J4#+oS9O&nw#oebsk z`4H?qhb=eXj?+YrNK(jZ1q5LE_Wxi5j(+QYv^m@~bLR<(5lL_q=Nt-xi!)aPN$M&7 zMe{{CqueofTB#)efVXoIG^#{AJ80FcQi!6ARz}uW6(`!=0SuG-+Wyt=KpP>?Tw-;D z+jnEjXYPQyZ4Vj(0h&!w0R@>YA6zPLQL;_&;w2)CQSRCCqH{HNEk%s4Llam4zUn8} z-1b43j0~4j9jKw+(%3tUZJ)mrJ-_~gh@XPAjSV@p6yCV!hdBAYN6?mX3KzvB%Y-&J zBm=^QQ#T2xl4w}I&fh2goHw9R@D&RCWumr3|)((c6p}=E6-77 zP=JuB!Uwx9rpl8dN{%C@LF|Sh7WWo9tnv9#*E@u*pS=T-)BQ4zVHmvk`IoT&>;H*% z(E5U3RKS6>3E70K9AI+~p>nx2wm_T~xQ&;t5y1rvS`xT7-vWWxqB%z|mp&xpt= zpaVnXIXW)CL`L(6nXU{JAY`fnEtjrD$VEzXVslRo6P3PGxxGb(1CpAq4b^<*6NZ(Bv~ z)u@L*DyXoib6Hj#ef(EQKK}}ww7{DA=g|$TP>T#ZiGWxfHPmysSMYtt43mtiK#-9+ zV3$2IF^C4<2|g^b});n#E`gVz?^E2ImR zWJqGFg0#MLK-KVKP+u_5HZm9m3CTH^_PNn`)jN?)#7U^BgjEx4YQEc^NfCFi2>VMS z=pdcanu=pR|j{(Tp)=Yv;EnO4eIZreQmno^>V>z3u zGI(Z5)$jvFjunfr<6K50Z_N2{Da6?=Rnl@Z2_N#$DuWjOrW2grvn<M5v&|(&TcW0L}Y$D=D+u98D%p{AcTdo z8lEx-y;ywn$1y}xKS2tEm)3uFAR<^lUBiH)VeRea9hVkk*o4xpXLJ#MD-dsuYgeG| zx^)Oe!bA!$-k3Bw(4N0w>CH{<_zB?t$T(Kq@j04lIz_Pkm+VR+ge%Voz_0)2jj`y1 z*FbsuD&bole4c~S@pyZF(!w&Ey2_rx4XuBC97}Hf6lyNyi%?RtYWRYZ%bc>XH@>d; z^hcnt?Ls(AJ%t4(M&v~xPLP%7ya35ex^mArH$H~Nw|o?htCtyZ)kDdrPDG2LMOi-V z|H+exZQP7{e?VNG5#ilA(;dpk~tu?jQwFV8hw88&O)II=^;lg~goeg;9l{ZhOao=K1fV@MZ)dB2Vw6h;h-n z$+$dD3~4b0FI|nrAHE*0rl8Dcf`zGAh|*AXmz4_>M99hCyoiylI}s!&?WKWVbvi{$ z&VY_9F^15JK7>m$S~{(?6Iv3Hq=rbX7p+&k6Bl0dZYVV#kwt*A%*=_(+?X*!_zaiq zD_mVh-E*6FVStDk*>eCj@uWeHDteZ14@S~rg)~d%rJJr%FXf(~Y2uC=2 zY&C)xFECOvCd>3@q6|Xp5`Jnqp%Z5jI(P&l2ah9px)&3@z3@;nCz0c%w7mY|9Ey=o zjyH1E%(HKuRN)`fOE3u@7lL!=z&XDI!On$fUfKy~V}LW*i!3#!YE>HEL_(+}b|vgm z3SlII(ZK=4hR4X)g^`Sep~uYMF;tglS!4*&s>=LwNqSuZ!Q^k|^IU1$u~~AWcQJ zu`tG-dlA}iH{tbfd>@EgEoDDUwQ?2DhSf7EENNJ#+=j5Ewv`wzssB&gv6aZUXdr|G zk323TDzHm_LPVKT!9DjqjAmM4`)J?{oan=rn{KD6>Kh_11L;mgp4@gR8UXoFQ|UAE$<(8h4>n?~o@2jcl|_#A6s%9ccU8b+{<|ZVW>++0vw9vkkdq zGsG4M(}MNTV^2fhcLa6hniNH)afxie34|T!K-_^&&Q#OTn~30T_ud7yw)7Vw6eOhk zk)I>}!mDTq1dUZO)i0$mgq6qq&jj&r$_}eQ+f|q2qU+YdQ6pU$V>S?yPlkMFaff#u zz=>Zz2OK_yAiwL^a`_-#3ad|UoRkPybm>j$v^ zz8|5P9}zPihT+Cwo(RlTks&6LNMJY-L(R&i=va3p8dqIxq$v)?iEhFafBGCkRU)KN zN}f@k-1ssEHogi^XaY5Emwc|>%q4c#I_a2;rLxDu6Hrz!Mc1vLl+k6NAR%f0N%QMn z-+d6+dze~^pL(Xeol^E1ZZWcjq+1Tf@t&_U@B=>MZYP@FwhGOcu13R3`RG^mVKrJ| zXhWkovt<{Cwroe@SP$x)UbtLt;}$cimwEj}-cQWoOa$wxPy>u(h^Ac&KYjyR-tktB zEZZaGA%ufuVbT*ULexUD8?ciX%Uj zBY$l{>XxEe)d&A}({BCE9Dat{?9wG~;53$eGa%96Vaqf4U#5+TrF8)bw z4$brwDG=u~dJP?#%Q-$%t5L7fk~*R(4dWCaoZAldqIsyF*8yio8yec%;B2Wk%BcY1 z7(#vL5I)}uU1OS%fm@9WhOZz@hB#0 zy_o-@>(TbEwMK%e_9++B5|SGk$>W2gIP&DP7=3j+8l5h+@Qpc6>MrFd&Uup5f)fr6G4gYK!$W;*8ys_*QFp;ScrKiWrbQRgEH3wT_vzaI zp9LYMKp!5*`0xlKp)mCrBT$&BNHdT|JfYwl9y~M_z{|fXHyNhV)iP|kn3aV5e@1mi zrcyx7p|lpksm;xbTw&)Q-h4s`!fh07OHOTWR^$ph-vIm{V;)PG=3JkQ00000NkvXX Hu0mjf)u!8} literal 0 HcmV?d00001 diff --git a/public/images/logo/wmtmed.png b/public/images/logo/wmtmed.png new file mode 100644 index 0000000000000000000000000000000000000000..667d6d028f54d6f90a0a2ed016643e1bb8848bca GIT binary patch literal 14410 zcmeIYWl&sQw=PNu?rtHt6KJ||YutjnySux)H*UctxVsZ1xI=;^kj4q_4wrZDUHhJU z?w|YBJ$0&1)wim~no`dg&sfhKKgOz>(Mk$Z=qMy8FfcIaGScEIZ==~ihJ^6;cG^r+ zdmG>^MdU?bU>YEPkIE@;YYJ0o6?qt#NK6=*xHo%#a|#xQf${f-fjNkUff3k&fgzrB z6$tl)fk6RSi-{@8h>1}+xj0%_+nK|_NRQ;Ed#b4|5k$`Pc={AhvCR|vq_DCDU9nPp zgi|K6Kp+~28AOw}iz*iu$HsmqkysG)uDDRTI?9BSq1*^l8cWH9EvCHOO!DVvKX32T zot@i}o{d$@Q`g%S-~1L<7#c-#RseiDEJYUmA4DIX1>$; z#Nx8U`5d;BRW`xF#(t{6*9DZq0~=f@l#({O-E_dpI94HNzy%X)`SOX_+5Vu2O81M7 zt`5l;;;aDW^4HEgZrYP$sveZPU+hsoZzGTEo>czA7){35G#z5ke-#*dw4pHHxv_oGn=rKuYu<_AO88V z)+zcfX*&LqbPtC}&Jy{aIM#O47I`)ZhUOjq=zIKvcW%LyZ0|L`5%9or*Tdcg8)0Gm zge8bV91Fq(iLfrCp$KCy!{3B_R(k(egxcyIy)YsVI&09xAHH_vlORMRxVX?gBZTgE zmsoJdlpyioZAx`%R8Y_)1%er7hX`1S0uMt%%oz(4N3>&>pd5)QoT*T!9Q%QQ4_-g0 zR`mU>pcUSikbv;@K_t15ih58r`iDWZW#pTPP$;JpX%52PxBD}NcG%j`g#P0*_&Yv7 zM6%%g!6qc(C?s@cF*Iz1&5%1vZ0Sz}Qd|__(!45>%@q3502fqZkXQ;P3O#vVjGIBC+L`}lG z@QC+_!h`gYKx(LBbJFRg9f=e(Ba(T96FR+#;Edbu-Hm5}>KJ-E*mS;fhvO&MiyHV| z2$D}Pjq8Tw5oRO`Ys_94GAr6H?j`LdL4)%n`sWY~RYrTXmWY5tw#1IaVOUxhM{x zg7$!>Ljxq$sCQAajXq}0*qz#%8jGiD6RMWB$XH=qp=}-0T+y8G*67yYmU5+jAVXXo z*BY0Nrb}nITR$n01w;{@D`;2JN@Yp)8K)iR!58~c=%b8VF0trX{=7)6O}t{>SlFoD zsNQI4CS`>rpS>#~Ta-4dFdJcYn!x~Y))~-lG?kf1?x%HiiE)9rkamc6Xms$qobDW4 zI9xhzc3>eSVF2XJ=&n zjg!ZStsbvu(TCB|`uDuCSaM;CafERbv>bbCbcA81d~iR!q0~9-++oXLOOcL%PKNH9 z4pYutPBU{i%OmT>rqpp5R0Zm?SK#vG-mqG?esRg_2TeDIPMqi{eez>z)ppbMYi@4b z)@{{N(6-i*&{fw(XD>$!lmu?YaIUVd_!M7TA9`}7bHQfga`i2ftkSJ=tthZAX-{h# z^!ZIH{DxR-+H#vHwk5O*nF(zc?0$1AclFvVhmLyKIpsLbyMA`!+r1iC8Ol2SwJWe^ zvemf0)6Km37pk+rdP=uNu&Hryv#kGHaJpy3e|T`fGXvAL{m&tb+GU=B#vEMA&K zslq?hyz)xv4((>{$-mowJoE4LGv2`O_4OmKUDO%VU4bG#bwAzQ30^FnD_*{WVQ~MD zE8(AU%yO`C*KU!)#Y;^3Vs^1!?`hsNE*xFdU&61Y#KfhGj;{T+p5yYh>{vO9i91p>Wb%=TS zc4hh#XldGQQfX?T#aiuOy=N!oLF^l2I>NXkH?;aaeOO_5<@<4Tc8q5fAR&LZ(}}qQ zfGdD&gR_hCjFUjaN)t}oLbsJ%oT7Mc<`vzYhnJRGof@nbx)`#U#F5?HW6kd87$C-% z+Dq4?DvbXuca7{Gt{YxVd_$~G?8q+0u4o}+dEWTYc-`b_rnud{=huVL0%@t`*JSZ@ zJ=ymuyKebVfDZi7$!%jdxxv#rX1Xvp6*d)}9XQH9#ysWQLu)6i+3WIB`qWTErkjmQ zgK`HyxMtV(!%oV6&*5~#xYzU*q8NTOU@Ji38!=NdlUa{S=WT33bwQu)D|4~S7 zSaZF?rPiiKqjFtY&@;&2=&WF?Kb>UfOVO9x$o9ySpFWGNOV&$iVnme<>it^wRtpd)a){d1X8TV@lUIveV3%Bv`Ag*QhxS&%zO()8JMuqv~HyXT5Wi@)R5bJBuM zS=IDt9wVS+hF zho*vIONY;2k}aJP_ld(}e*%AdA+4v`pN=aV0fT=f_v8<=_1{k*PGj()8MN>C9A4Ph ztu38aceAtMf!sWKT)ehfA5XS>N_+vQmcQ}?>klX&jG@#OQacH0WJ}NGx2A_tJEK|3 zOLM*SB8p^j4a0e_sV@tW=TBwZWjnZ)xbZ0#DP?89${fJD&vsAVe=qM3Lh`=`)&>+l zZgv@x1|DQGV?Lw>u6 zN0BiJKIC0s??9jS#P0gD54T~LkCXh}c5iJavXiv7D+~;d+duYh-GbZ;1_qVLT20GM zOJ0u8)X|>R*v!$yoYl+T=?(qXcM5qq8JpUgyHS{!TUt8^(w}zq(NkEP3DRqF%CpHk ziJ4njOZ&K(tNJLYnfln8@|w{LfzeR}y!hTC*qgfV7|BcKidE* zuz-u11)qwzVk1dFS;gPXAz zi-Rlme+3XXcQtjfc5<_JbfEYrps|UgyPF`DySud+pM|jnr-_*vCyR+OyBP}*XlBe} zY{m{`F=J;pNiia|HldfdDGNeC{V(O;&H&(L?DR&+_K!$!7(Ou8n6HWl|j~M?$qW^0%|No>GSisDb&-9<%9bNvDUe(#j4; zLVJC=z8iDuG)|=K2o7an2yw_(*d!Y%y;%g5hmswwPc`hS*>Ny%!};yE{wc)OY!~WD zH~tW4*iEV%c*(piNa^N*`KOY3df3{Qb+|f>u-Nznv{!vF0<5FVl0#{G+3}W6a4&3e z2Uo&6T`7Oc@mkf-;oi0|b%i3w~ zB@sX;%g-pCKx~@`pG2EYe07gD2IpHkBmpJXzKdeRXOQ)W3rr~#3JWF5FFeH+7GBeM z^>E1Gb^fg&TJ_>@j8+yO+1g+0z-Qig)SlF79k$LToAg63tWFuDk(82ZC=0|yKt#lj z7Ee?tOPbh81SF>V1&eP(ZxEoU8vTYp!Yb2uq{~#I6eQU|ppci~fQ871RenTQnJuwy zn#tpv#9vzq&jqt`*KGSM;_R#Efu?VR8J0k_{`9#KpdM+@zUQ(Fnpp;eyS0*?~J z=O$={n=FBdDBO>_+rEr5i(na7g2tNonadix`mICLv9Ew2XzN z+3fv#pW0(j6#}Q-n3!9_WfA{6W}$VNb3R6tToK1!dC=dCOZ7S{RBu@av*Sc7(B?J> zr$`g3oJ!k|q8-xmc(Z(nPxv_BVPGQ-<~5;e)&ob9a;hXF!PyeQpnXW;)z8!r-L9vn zqmSciFTr1gO`^mRkFrsh)LnLf*Hsu&Gcl+-q8&Mvpj#EY&}ZNY!;2-mVto83glq8fHxo}&4lxc5V zgt%XZt<)lmNvj{nxtu|eiTyzj<-^P27SdAlY(hKREPv2=sf3pM!L{^v`pkSY8I5-x zCV5bx`L{y?79F*}+YEzPMa~XSE8J|n?YXe&tMhSe)@i?YISNr!8PLS8_YDB*x{s35 zU$tvoVZ_SL*xB#LYCPZLE^yuAqcLIR|EUuDDTKI>L~(4}sdNspYp7^RNf9;2OoGYMY8lq?Mzrh8 z5tkYlV7GqWVooJ&n?3FuUbwU2p-HqjEVE{<0F@~ZkzRIybKtq{QUIx^z)G-^d77&i zIUd38B{2p^xSJj6*eDk0T$yIXSL^n28LP2^A)K~9N!ns4#(+lPi?I#TN6X-Wy{Fs6 zAt!Spbx!qiM>}zIciOu1d5)6I0`s_hCZ*Vmb%gEE8it77{*TqP{oC<^QJ;5c=wQ86 zQHoH1sE%!~P(Sk<2hydr=58%j~vjB!1xMPG=GF$w%)1zaZ`r z^7DKA8N${0Q`$xDiqQvTfQ^M*QviQK_WxkeNcv(YU=$@P5Cs!Li%C8uODmRT_Vf)dzR)w`+6B)@tW|4Pl~ zL(W?~)3QdFkBuYACMLh#S#DZ16O*Ph&G8q&9(nSJ*hp56- zP5ThmcAfhwEI=vi5M|;_)|mT*J^Z+ni`P1YrukhWT+)ck;=+5_;%*~cJ#|#5BTsZG$nIpv$@JA#DJm{lzfb&@S*U-WlU(d~ z4*qU?t;#+#6^F5SY8b#Z4Ie0AvC&_m?Q(#oAb73jdYlw-_~Prp9B`^@C?Y0!%p*S& z)0P|D_uNL#KNT4%4vMbL1M|JGJ;SE3MEt0j)0f6@v|JJ4U!RGTo##G?&sD6=A24c{ zF)ms7x^q*?+XO6g#~}Ig0n6lDA~V~W5yO{>p?8M(-SPC)N^P*c*uAF0ISVSrFzxkJ@)tYp|jPA8A&QWk(B|&3itEJ0OgzkyPOU5;Do zerk(cu5x5YM?@fUalb2D%t7*6XV^>-xu^*mBL*dtPF6ax?2rl1eMyS)n2aAyLk1Q! z0-zUNI|V50tyl^3AeL08@7aRYb3g9;vT`F?fWJ_fWK8YVU;PUtV6kmyQ73f|e`=i0 zAdZnR76~UxIlYe3v|dstjIg>?WV^+_zy8GBGzm5C6+>8w9 z$y8B)OfYd<6AS7iD~vENH+*@FLc=sV?#UNx?~HEhiGt|ArzyiA?|6BaOtDd@*-v|% zct%SCPtH3X*32Ou5Ed3i(}UtrUN@5bU3T&rf_FnLes|xqF^ntJ3E#q@Tj=ynoUd_- z{Aqis{-QcIx5k4rOJaHy` zDL(AN0>S0q>tkU*V;dUK9v(;P-F|em-{Bf^)9kNmVv9m;bz273Q^jH+*4jHcoKiQ% zQJUCPXt$o5wRJDiV}MGV!>n2FDS8k~O{|sUD>3Ia;%l?yteE}p*7zP-;yZHeqCgJq z3))3Q6#Yj7x}Yn3u6t@?o!{4EanYYz*J$K04D_|dC2tD+2N#y_j^6M8?!`EL89c1t zeJ1C&2g&F6S-ty9vS)IW<5R6Q7ePB=;R@$~c6DQ7W(D)Hi<;;wLf{#oH#*JIKzieA zPE~WG5i;;ldt4&5Wi zq_blbS(~Oy`jfeg8Umg7GVptf^!oF&PfEi4y`@LLxGy}NTWm(lX0y8NJ=&nv24`Ga z*(Y)H@O<8Bzez9@N3Ho2E#;L^MHr=2DN{z8P|_3^E*jY_ZmqQzCb8sEXs`3#Cq4&TV{%II6^?ljBqKoFRd#QP%9Ln-HM+mn zBd^qRC!p1LZizdlMt8NPKCY+__Hu8=q;_!B#RYHhc*ewRL{-ir1&Ny#_@zjoLRhrL zK3ydh(6A349*P#ksQB8kLOcfD;^ZtLE%seK-+a$a1zHTQ{L9xQzFAb}(E-x(dq8a1 zuda$SCGhF=Wy^|knI&b5r3^FBspfrMlcrVDeh37{$V|IuZ=RF zM8w=4H(aJa*q9;Lb|S{J=(u^Jp{psDqzG~RGjeih1bgoW|1X!PD=7&g_8lVX>Yb>$EY4nnlQ{NnUNn_+SfpKlyS zDAKgGs?VGJw;ye9x|X1SuzXewVO-O5f@8tY`Z)nB zuObDavL9sbth%0qVd%OHzojGAamwVtmGzUfSYmG<#a@Y3PI0YGl@-v)KI*{jBDe@_ zG^gSB|BQs&dkJZ##?Jo($4HeoO`SK&c4(P76`dFd3FW1d@ibj?^rZOfu*H)};Ubqc z2KKsCmU9S15>tFdPwpa@0%nNh$&?oWZRdA5Z>Yn#L)a-V=Hj{lO?~>?FH~ zUXMC-;(woxa%K71%^Gf^oZ-ezs@gUCj@rIpHOF)qx}*bKl?fW0DO}Cv-fe~^9@ANdj#3#7;MaATFJ2AY7AE!|N zZa;mvEocmYoKpg7Po?bqFkeni+{+yuiiM>4#TUZv-$%(SWMT>6$8J?=_MvmOaPG2{ zm!!^oRn{~N#Z}acgGXJPl&#X^i!fSH$#GgQlh&|57>*34R*=r4t7!6VlPQ&>5jBbN z`6z>T(42NBqona|H9ZmEI?Hq4AX!)?wPK<+mMB;AWSfQf#z!#Z?|YFEYHI!En=Ae0 z#vAMIr*Vad6ZN}?=e5Za#-*VPol*t@;_s@r>rpow0%99UW_g2paU{84$%+ijI4%>Fnu4N&rM6$5 zk0qR@%NM`=m+!WJkA9cLF0bAlx=7B%D{i77V9}4GoyeddPM{nqY4l#{;5mO%%=GP5 zCUq8F)UN|)DwLrS54#;0Gom3+J-LFepHYxiMs3q&%^J-*8JtWTbGomu??4AeNX*K;26iXPwYE3z4&sIB_>YXibRCA3Bv*o zGaIwfan;X&F}DgyHX|sVoRyKT6BJx*?MhIB8(0gh)tT7-`9Vnz667nM1%%`gx&^h zq~D70nSs71esYo#D;kk0fG0E?OBT*8KS5G_(TKk7R%oJu2Y_WDUlwVtwEkd07oUHjeB;3|Svu!Pi5cM46iYESOWE^POzz zeYRs!49@3@sZWMVT9QV1>FfT?pVHW5*?l%-Y1a};wd~xm#?Boi!9U9F>^KeS*Ixno zVjI))-A%TqoVx*#tVXlcTkE#tcKIpO7!vk#>_v{5e)Ifi3~=BcL5|mnc_z^b7hlOy z;NYrdz3t#7l6sp_x9$79&YfbSwWi+!kNr++NXX%$7Ia#ocJ{_oVgBDPA~{RgL?_tJ zL2Kr;#b{qdRqfLU8Kw2hxYqJpQO}HwE=}p;Jbd+uNaD})co(Ov2QSzI5^e+y_!O6j zsCX{z^A*Z_+W?k+H<>?jl6O0mC!*ed>TLTX!T#*M7b!BI_%HpXMtHa%`j}E)Wr?%0VmE-_0w*=bDB`JygJ8PImR;*x)@&k zvV@B7>l-JiF$iC;IXO8kHU>^nphX9WNi5U&2)p%LHzS7|JtyRVT7%g zdbe+OzV_2Qti)9A!pC(}(SSog)(Kb-Y}wh7V)|cxwy|C8C<-f0r)R1kq z_e16^*5f}Ct77H0ywU0qR>K?k;?F-Z)iBTb@L=ZkVM<-vP@)qEl7ww!Tx@GDbhrVT z5kb}hZV2dySo$>YJ-p|dh)}=n@utoMec7w|Ce`;M!1@r&PXv|tQ%d|{wdwt!f7kw< z?b&wC(%BtY1yUK2**ze0a#RiCoXXPvTzJMp$wb?K0tnJXc66NEtFbGq0Jd<1`Il^B zmpuGr7e)O>er4(@MNSS%*x54}|HTmfNz6&#ky_YZv1g|Lq^=J?7H<5l7Y@9|8yorKpz0#8XaixcG9BkY!=cK(eyI zVLyw=st^$)i2cDyes657Y~iyoYSe9?Xj7(T=8*(dl8759=F`+bV-uhli)#1wq&1;J zbg8xHHh7i8;~qo4d39B%DR7^`oRHAq(q8W+Ei%bmGGCF7ZMkm^PWX_ld#ssUUG!=y z8EnB|9dpU{X&w)MYkQ)3TUeBVezx*lBmwJIqFgA(V% zw?D0f6+M%8xvmhDZ2;52!*EP#j1DDeJX#r>5vQG{P9jdwVBKMC%(9#jSCF9;F`j+N zXV>YKj4oW}leR0o37U|7VC&3<&)>Jhb&W#e-yJU1Ftx!T%<$V)_~bv5Cas7@l}5y! z&-79KpMnNcgX%2q*&6$qRtb1piR6>GctY9+W?RD?+SJ7%zuWd`QRQwP@`Be4AEF2U ze4~i|TvXHx&v~iHa*w&E$A5Wzz4M%E8v#KjsY5~rJz|DcV#Pe+6!VjhY=4t1$s*l# zb$n?%?(_Vi3ET_2EHKPh|2f6SCrhuIB=?n#ml&87N2USW%D(nf1LkXn*w;flw^HKD zOU+)s=&yhHBS-Mj zBpf|c42js}LzP~r)3v~;tFRE8^)M?kQ&dm}9iI|%U^#ZLli(Bn^;x*h9hy$qcFC4M zC<`um&e-B8u9=U!(DzZ6=mW{n%IPD`^lOS^H)j~qLopx%hJfXlJne8|QP#N51fj!) zid(uNoNN%Sn8a*AQlSG|D?IevP??SL(*2<+K@h_c9Ng4<$K!I)jvYZO%)p(rGsMuQ zd{LQZ@@xjsQ#R(HrV%ZtUDxMkkG^n9Msp zIl;!JIX~Wpc+iar&yBkz_}so5VcB5!qr>qdCCMEeE=Msf&5&QSo&}Y7+$dXL9|dqX zL#}(;CYZJ$he#LYgoy*)F99=!yU*Rv`?n6-wOdiZImsuVQ$zO295zwHYU0MQRKk`6 z0ST*qrP)-c#Fc(8ha=VNnEoj>9LFf2y`YI%M{%9HR1}bmwyfWt{{TfX~wYJ5tUZ7MPHz?nSG z4=gM!yWQOHf9sUWbwsTn?^nHsLd&JJRSOZhk_Zv;&Gw1h$ex71OL8%N=@{<6G?Xd8 zp}UNZq1;?q$1;Q1Wp-5EHcIjPy!p6VCclpKCnNJzMl`ZHy zZJb1DwVv4*^!K`tj(9*lermfRe|QeAus=ATTcOlwA6E0*QmQ z@G>=vp(bm^s6c=Ri;+sFWt1!YQRl0JZEob>zc#KXy3y9O;e7 zqSx?ukdcJ?SSnAi1l@NYZPm{=J&m=*9kpdVkFp`ZuPz9-%gxnYHB8oJPXDyz^}Kx3 zJG_JEb@z+Z=DMaVvm9P&-yH!;L1icQ@4gqZKh(egas%zI-%rY! z_m+BL_soiAMm=$VZY1PeiBz-)j_P__F;G``KNuFaW`z-pAYBj9_>rByK-5|dnmTG* z3EbV?%WG=I?3_>~`ME%OwAA0gepoeuKki>|ou_YI`YhS`@ zvv(!uTYH@Ac7q4sHOyo)*ElLQ+=s{4zO)%VAX82*olT-|%VHorU)djrwtf3hYeoj` zUr4Z*G^2^A3wX7hCLTd3&BsDns&2k8EL0l*h!Ga)_=>}Rnv-=eE-vBE*6W=+ z$K;p89A`VDRpdECHWstbLOIGveiUw}Uc|W{0^X5dhO_7*Hn{3y64VIblfO*#vF19V z8NY!K6N3$@ZY&L5rg>V~j>EjfemLRt z#O0*Rf}|xMa?^26?#qO}s$Yr-Dc?a8mXcG6PJA4C7JQEWi3=k-H8DxvcFuO0x4+JP z8F=ygkge5{UX(-K^VcO#rw8WNKlL91T>|!} z>x@S_rIeL*6ZlW`Bkeyvw?A8ALYtdrfX5?D7DG05Y~7FE&3$L&Ld2!*iCz_e3s;0nUrBxNMGR`>id?Su!-6-cA1mSB2EUnTklJcJ>RiQ zib0CQh@oN-Q*sC{XwWGqkvQW2acN@&Im?(wVH9o9S=55)e}+iDyL(McSn&7e^_BkT P+O>>?f_SZ{QON%Vj)HDh literal 0 HcmV?d00001 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..9982c21 --- /dev/null +++ b/public/index.php @@ -0,0 +1,9 @@ +>#>BU~a1(%>J!v7->-%UO1a1l6^4H1vE05nVbY z{(w~Q3H$+Xb{CVq+c{_wMPm1~JNC_+o#(f=x3^~iX6yB26W9Q-#wzyCO*WAteyOd} z^JnhU8pZ=79APp&*c^_l7CN0s3M2)R0!e|SKvLj3D8Mya%9^t7J8x=}0!e}YQUSR? zWUOLyZ)<1mt%Hr-0?_t(+7^E1KETFg-saxc&e~A+>2?pshZ?`dFfN?_OyIP+x3#k_ z9L9yi_{_%dP>jvaxXi*~a%WSU6i5oR6_CCADxRQ1$>YNQy_`Al_fKs^Srx_JwA!Ob zrrr3*`IpBr)Z`yfvzPL=z-M!_b=~qg!ZGILHRZ97xqGYd8~5!U^F?tAHw7DI7{vxW zdmot|D=5gZ3Yg*H`5%wM#mALZG`sf(`EVxr2H58Nm17*Tz6rAS9{p_kmE1ue3WER> z@K_NVxJ4c(924R4jBFOP@~t^a7|7MpfmS$nMnpC1hzR(*#ifrOURQIJfrc2IlI_x` zaJ%4-GFke=ycxUZJlmy43mzjJU_Z=$<8vFKaA@?SjA8>7@_8V9ZijsE)Xo3Me8at0 zf5R(WJ98+^J7XfhV;#?_>x^T%uyaGkHk=>$x-jhdY&ibIBs00z1^A&8wiD43l&wbfeQHa-!oY% zm!yEZn`1-v|J(Ze|4YiAx=jis1+Ievre6$;Ei&lP>3YiF!4SfvQAcGiZl5r~|( z+i}|EABOy#d0*~r?JPYkSNlogger->info('start: bitrix-update-doctors'); + $io->info('Началось выполнение.'); + + $specialistList = $this->entityManager->getRepository(Specialist::class)->findAll(); + $count = 0; + + foreach ($specialistList as $specialist) { + $dcodes = $specialist->getDcodes(); + + if (empty($dcodes) || $dcodes === '0' || $dcodes === 0) { + $specialist->setDcodes(null); + } else { + $dcodesArray = explode(',', $dcodes); + + $filteredDcodes = array_filter( + array_unique($dcodesArray), + function($item) { + return strlen(trim($item)) >= 7 && trim($item) !== '0'; + } + ); + + if (empty($filteredDcodes)) { + $specialist->setDcodes(null); + } else { + $specialist->setDcodes(implode(',', $filteredDcodes)); + } + } + + // $kodoper = $this->bitrixService->getServiceCode($specialist->getId()); + + // if (!empty($kodoper)) { + // $specialist->setKodoper($kodoper); + // $this->entityManager->persist($specialist); + // } + + $count ++; + } + + $this->entityManager->flush(); + $this->logger->info('end: bitrix-update-doctors ' . $count); + $io->success('load: ' . $count); + + return Command::SUCCESS; + } +} diff --git a/src/Command/BitrixUpdateReviewsCommand.php b/src/Command/BitrixUpdateReviewsCommand.php new file mode 100644 index 0000000..c6877fe --- /dev/null +++ b/src/Command/BitrixUpdateReviewsCommand.php @@ -0,0 +1,243 @@ + null, + 'rating' => 5 + ]; + + private function safeUtf8Clean($text) + { + $result = ''; + $length = strlen($text); + + for ($i = 0; $i < $length; $i++) { + $byte = ord($text[$i]); + + // Валидные ASCII символы + if ($byte <= 0x7F) { + // Разрешаем только печатаемые ASCII и управляющие символы + if ($byte >= 0x20 || $byte == 0x09 || $byte == 0x0A || $byte == 0x0D) { + $result .= $text[$i]; + } + continue; + } + + // Многобайтовые UTF-8 последовательности + if (($byte & 0xE0) == 0xC0) { + // 2-байтовая последовательность + if ($i + 1 < $length) { + $byte2 = ord($text[$i + 1]); + if (($byte2 & 0xC0) == 0x80) { + $result .= $text[$i] . $text[$i + 1]; + $i++; + } + } + } elseif (($byte & 0xF0) == 0xE0) { + // 3-байтовая последовательность + if ($i + 2 < $length) { + $byte2 = ord($text[$i + 1]); + $byte3 = ord($text[$i + 2]); + if (($byte2 & 0xC0) == 0x80 && ($byte3 & 0xC0) == 0x80) { + $result .= $text[$i] . $text[$i + 1] . $text[$i + 2]; + $i += 2; + } + } + } elseif (($byte & 0xF8) == 0xF0) { + // 4-байтовая последовательность + if ($i + 3 < $length) { + $byte2 = ord($text[$i + 1]); + $byte3 = ord($text[$i + 2]); + $byte4 = ord($text[$i + 3]); + if (($byte2 & 0xC0) == 0x80 && ($byte3 & 0xC0) == 0x80 && ($byte4 & 0xC0) == 0x80) { + $result .= $text[$i] . $text[$i + 1] . $text[$i + 2] . $text[$i + 3]; + $i += 3; + } + } + } + } + + return $result; + } + + private function processName(array $data): void + { + $name = $this->safeUtf8Clean($data['VALUE']); + + $this->review['name'] = empty($name)? 'Анонимно': $name; + $this->review['dateCreate'] = \DateTime::createFromFormat('Y-m-d H:i:s', $data['DATE_CREATE']); + $this->review['active'] = ($data['ACTIVE'] == 'Y')? true: false; + } + + private function processMessage(string $value): void + { + $message = $this->safeUtf8Clean($value); + + $message = preg_replace('/a:\d+:\{.*\}|s:\d+:".*"|\w+:\d+:/', '', $message); + $message = str_replace('{', '', $message); + $message = str_replace('}', '', $message); + + $this->review['message'] = trim(strip_tags($message)); + } + + private function processSourceLink(string $value): void + { + preg_match('/https?:\/\/[^\s<>"\']+/i', $value, $matches); + + if (!empty($matches[0])) { + $this->review['source'] = $this->safeUtf8Clean(substr($matches[0], 0, 255)); + } + } + + private function processRating($value): void + { + $this->review['rating'] = max(1, min(5, $value)); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->info('Началось выполнение.'); + + $this->logger->info('start: bitrix-update-reviews'); + + $specialistAll = $this->entityManager->getRepository(Specialist::class)->findAll(); + $progressBar = new ProgressBar($output, count($specialistAll)); + + unset($specialistAll); + + $page = 1; + $batchSize = 5; + + do { + $progressBar->advance(); + $query = $this->entityManager->getRepository(Specialist::class) + ->createQueryBuilder('s') + ->setFirstResult(($page - 1) * $batchSize) + ->setMaxResults($batchSize) + ->getQuery(); + + $paginator = new Paginator($query); + $specialistList = iterator_to_array($paginator); + $page++; + + $this->loadData($specialistList); + $this->entityManager->clear(); + + gc_collect_cycles(); + } while (count($specialistList) > 0); + + $this->entityManager->clear(); + $progressBar->finish(); + $output->writeln(''); + + gc_collect_cycles(); + + return Command::SUCCESS; + } + + private function loadData(array $specialistList) + { + $count = 0; + foreach ($specialistList as $specialist) { + + $reviews = $this->bitrixService->getReviews($specialist->getId()); + + foreach ($reviews as $key => $params) { + $this->review['externalId'] = (int) $params['REVIEW_ID']; + + $review = $this->entityManager->getRepository(Review::class) + ->findOneBy(['externalId' => $this->review['externalId']]); + + foreach ($params['DATA'] as $data) { + $code = $data['CODE']; + $value = $data['VALUE']; + + match ($code) { + "NAME" => $this->processName($data), + "MESSAGE" => $this->processMessage($value), + "SOURCE_LINK" => $this->processSourceLink($value), + "RATING" => $this->processRating((int) $value), + default => null // Игнорируем неизвестные коды + }; + } + + if (!$this->review['active'] || empty($this->review['message'])) continue; + + if (!$review) { + $review = new Review; + $review->setExternalId($this->review['externalId']); + } + + $review + ->setActive($this->review['active']) + ->setDateCreate($this->review['dateCreate']) + ->setSource($this->review['source']) + ->setRating($this->review['rating']) + ->setAuthor($this->review['name']) + ->setMessage($this->review['message']); + + $specialist->addReview($review); + } + + try { + $this->entityManager->flush(); + } catch (DriverException $e) { + $this->logger->error('Problematic parameters: ' . print_r($this->review, true)); + + // Проверяем каждый параметр на валидность UTF-8 + foreach ($this->review as $index => $param) { + if (is_string($param) && !mb_check_encoding($param, 'UTF-8')) { + $this->logger->error("Invalid UTF-8 in parameter $index: " . bin2hex($param)); + } + } + + throw $e; + } + + $count ++; + } + + $this->entityManager->flush(); + + return true; + } +} diff --git a/src/Command/ClearScheduleCacheCommand.php b/src/Command/ClearScheduleCacheCommand.php new file mode 100644 index 0000000..d935121 --- /dev/null +++ b/src/Command/ClearScheduleCacheCommand.php @@ -0,0 +1,83 @@ +addOption('hours', null, InputOption::VALUE_REQUIRED, 'Clear cache older than X hours', 24) + ->addOption('stats', null, InputOption::VALUE_NONE, 'Show cache statistics') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if ($input->getOption('stats')) { + $stats = $this->cacheService->getCacheStats(); + + $io->title('Schedule Cache Statistics'); + $io->table( + ['Metric', 'Value'], + [ + ['Total Records', number_format($stats['total_records'])], + ['Unique Queries', number_format($stats['unique_queries'])], + ['Oldest Record', $stats['oldest_record']->format('Y-m-d H:i:s')], + ['Newest Record', $stats['newest_record']->format('Y-m-d H:i:s')] + ] + ); + + if (!empty($stats['last_7_days'])) { + $io->section('Last 7 Days Activity'); + $rows = []; + foreach ($stats['last_7_days'] as $day) { + $rows[] = [ + $day['day'], + number_format($day['records_count']), + number_format($day['queries_count']) + ]; + } + $io->table(['Date', 'Records', 'Queries'], $rows); + } + + return Command::SUCCESS; + } + + $hours = (int)$input->getOption('hours'); + $olderThan = new \DateTime(sprintf('-%d hours', $hours)); + + $io->note(sprintf('Clearing cache older than %s', $olderThan->format('Y-m-d H:i:s'))); + + try { + $deletedCount = $this->cacheService->clearOldCache($olderThan); + + $io->success(sprintf('Successfully cleared %d cache records older than %d hours', $deletedCount, $hours)); + + return Command::SUCCESS; + } catch (\Exception $e) { + $io->error(sprintf('Error clearing cache: %s', $e->getMessage())); + return Command::FAILURE; + } + } +} \ No newline at end of file diff --git a/src/Command/UploadDepartmentsCommand.php b/src/Command/UploadDepartmentsCommand.php new file mode 100644 index 0000000..23cc134 --- /dev/null +++ b/src/Command/UploadDepartmentsCommand.php @@ -0,0 +1,306 @@ +title('Пакетное обновление отделений'); + + try { + // Используем InfoclinicaClientServiceInterface для запроса + $httpResponse = $this->client->request('GET', '/specialists/departments'); + $responseData = $httpResponse->toArray(); + + if (empty($responseData['data'])) { + $io->success('Нет данных для обработки'); + return Command::SUCCESS; + } + + $totalRecords = count($responseData['data']); + $io->info("Загружено записей из API: {$totalRecords}"); + + // Убираем возможные дубликаты из данных API (по did) + $uniqueData = $this->removeDuplicates($responseData['data']); + $uniqueCount = count($uniqueData); + + if ($uniqueCount < $totalRecords) { + $io->note("Удалено дубликатов: " . ($totalRecords - $uniqueCount)); + $io->info("Уникальных записей для обработки: {$uniqueCount}"); + } + + // Пакетная обработка с UPSERT + $processed = $this->processWithUpsert($uniqueData, $io); + + // Статистика + $io->table( + ['Статистика', 'Значение'], + [ + ['Всего записей в API', $totalRecords], + ['Уникальных записей', $uniqueCount], + ['Успешно обработано', $processed], + ['Пропущено', $uniqueCount - $processed] + ] + ); + + $io->success('Обработка завершена успешно'); + + } catch (\Exception $e) { + $io->error('Ошибка: ' . $e->getMessage()); + $io->error('Trace: ' . $e->getTraceAsString()); + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + /** + * Удаляет дубликаты по полю id (did) + */ + private function removeDuplicates(array $data): array + { + $unique = []; + foreach ($data as $item) { + if (!isset($item['id'])) { + continue; + } + $unique[$item['id']] = $item; // id из API как ключ для уникальности + } + return array_values($unique); + } + + /** + * Основной метод обработки с UPSERT + * Возвращает количество успешно обработанных записей + */ + private function processWithUpsert(array $data, SymfonyStyle $io): int + { + $connection = $this->entityManager->getConnection(); + $tableName = $this->entityManager->getClassMetadata(Department::class)->getTableName(); + + $total = count($data); + $processed = 0; + $skipped = 0; + + $io->progressStart($total); + + // Обрабатываем пакетами + for ($i = 0; $i < $total; $i += self::BATCH_SIZE) { + $batch = array_slice($data, $i, self::BATCH_SIZE); + + try { + $batchProcessed = $this->executeUpsertBatch($connection, $tableName, $batch, $io); + $processed += $batchProcessed; + + $io->progressAdvance(count($batch)); + + // Периодически выводим статистику + if ($i % (self::BATCH_SIZE * 10) === 0 || $i + self::BATCH_SIZE >= $total) { + $io->writeln(sprintf( + ' [%s] Обработано: %d/%d (%.1f%%)', + date('H:i:s'), + $processed, + $total, + ($processed / $total) * 100 + )); + } + + } catch (\Exception $e) { + $io->warning(sprintf( + 'Ошибка в пакете %d-%d: %s', + $i + 1, + min($i + self::BATCH_SIZE, $total), + $e->getMessage() + )); + + // Если ошибка в пакете, пробуем обработать по одной записи + $batchProcessed = $this->handleBatchError($connection, $tableName, $batch, $io); + $processed += $batchProcessed; + $skipped += (count($batch) - $batchProcessed); + } + } + + $io->progressFinish(); + + if ($skipped > 0) { + $io->warning("Пропущено записей из-за ошибок: {$skipped}"); + } + + return $processed; + } + + /** + * Выполняет UPSERT для пакета данных + * Возвращает количество успешно обработанных записей в пакете + */ + private function executeUpsertBatch($connection, string $tableName, array $batch, SymfonyStyle $io): int + { + if (empty($batch)) { + return 0; + } + + $sqlParts = []; + $params = []; + $validItems = 0; + + foreach ($batch as $index => $item) { + if (!isset($item['id']) || !isset($item['name'])) { + continue; // Пропускаем некорректные записи + } + + $sqlParts[] = sprintf( + '(:did_%d, :name_%d, :alias_%d, :active_%d, :online_mode_%d)', + $index, $index, $index, $index, $index + ); + + $params['did_' . $index] = (int) $item['id']; + $params['name_' . $index] = $item['name']; + $params['alias_' . $index] = $this->transliteService->translit($item['name']); + $params['active_' . $index] = true; + $params['online_mode_' . $index] = $item['onlineMode'] ?? false; + + $validItems++; + } + + if (empty($sqlParts)) { + return 0; + } + + $valuesSql = implode(', ', $sqlParts); + $updateSql = sprintf( + 'UPDATE %1$s AS d + SET + name = src.name, + alias = src.alias, + active = CAST(src.active AS BOOLEAN), + online_mode = CAST(src.online_mode AS BOOLEAN) + FROM (VALUES %2$s) AS src(did, name, alias, active, online_mode) + WHERE d.did = CAST(src.did AS BIGINT)', + $tableName, + $valuesSql + ); + + $insertSql = sprintf( + 'INSERT INTO %1$s (did, name, alias, active, online_mode) + SELECT + CAST(src.did AS BIGINT), + src.name, + src.alias, + CAST(src.active AS BOOLEAN), + CAST(src.online_mode AS BOOLEAN) + FROM (VALUES %2$s) AS src(did, name, alias, active, online_mode) + WHERE NOT EXISTS ( + SELECT 1 FROM %1$s d WHERE d.did = CAST(src.did AS BIGINT) + )', + $tableName, + $valuesSql + ); + + $connection->executeStatement($updateSql, $params); + $connection->executeStatement($insertSql, $params); + + return $validItems; + } + + /** + * Обрабатывает ошибку пакета, пробуя вставить записи по одной + * Возвращает количество успешно обработанных записей + */ + private function handleBatchError($connection, string $tableName, array $batch, SymfonyStyle $io): int + { + $successCount = 0; + + foreach ($batch as $item) { + try { + if ($this->executeSingleUpsert($connection, $tableName, $item)) { + $successCount++; + } + } catch (\Exception $e) { + $io->warning(sprintf( + 'Не удалось обработать запись did=%s: %s', + $item['id'] ?? 'unknown', + $e->getMessage() + )); + } + } + + return $successCount; + } + + /** + * Выполняет UPSERT для одной записи + * Возвращает true если успешно + */ + private function executeSingleUpsert($connection, string $tableName, array $item): bool + { + if (!isset($item['id']) || !isset($item['name'])) { + return false; + } + + $alias = $this->transliteService->translit($item['name']); + $active = $item['active'] ?? true; + $onlineMode = $item['onlineMode'] ?? false; + + $updateSql = sprintf( + 'UPDATE %s + SET + name = :name, + alias = :alias, + active = :active, + online_mode = :online_mode + WHERE did = :did', + $tableName + ); + + $insertSql = sprintf( + 'INSERT INTO %s (did, name, alias, active, online_mode) + SELECT :did, :name, :alias, :active, :online_mode + WHERE NOT EXISTS ( + SELECT 1 FROM %s WHERE did = :did + )', + $tableName, + $tableName + ); + + $params = [ + 'did' => (int) $item['id'], + 'name' => $item['name'], + 'alias' => $alias, + 'active' => $active, + 'online_mode' => $onlineMode + ]; + + $connection->executeStatement($updateSql, $params); + $connection->executeStatement($insertSql, $params); + + return true; + } +} \ No newline at end of file diff --git a/src/Command/UploadDiseasesCommand.php b/src/Command/UploadDiseasesCommand.php new file mode 100644 index 0000000..6a73fdf --- /dev/null +++ b/src/Command/UploadDiseasesCommand.php @@ -0,0 +1,60 @@ +addOption('view', null, InputOption::VALUE_OPTIONAL, 'SQL view name', 'public.view_disease'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $viewName = $input->getOption('view'); + if (empty($viewName)) { + $viewName = 'public.view_disease'; + } + $viewName = (string) $viewName; + + $io->title('Disease: sync from view_disease'); + + try { + $this->logger->info('Disease sync start', ['view' => $viewName]); + $affected = $this->diseaseCrudService->syncFromViewDisease($viewName); + $io->success('Sync finished. Affected rows: ' . $affected); + + return Command::SUCCESS; + } catch (\Throwable $e) { + $this->logger->error('Disease sync failed', [ + 'view' => $viewName, + 'error' => $e->getMessage(), + ]); + $io->error('Ошибка: ' . $e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/src/Command/UploadDoctorsCommand.php b/src/Command/UploadDoctorsCommand.php new file mode 100644 index 0000000..90f511a --- /dev/null +++ b/src/Command/UploadDoctorsCommand.php @@ -0,0 +1,262 @@ +addArgument('onlineMode', InputArgument::OPTIONAL, 'Режим онлайн (0 или 1)', 0); + $this->addOption('department', 'd', InputOption::VALUE_OPTIONAL, 'ID конкретного отделения'); + $this->addOption('firstrow', 'f', InputOption::VALUE_OPTIONAL, 'Первая строка', 1); + $this->addOption('lastrow', 'l', InputOption::VALUE_OPTIONAL, 'Последняя строка', 900); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Пакетное обновление врачей'); + + try { + $onlineMode = (bool) $input->getArgument('onlineMode'); + $departmentId = (int) $input->getOption('department'); + $firstRow = (int) $input->getOption('firstrow'); + $lastRow = (int) $input->getOption('lastrow'); + + $departments = $this->getDepartmentsToProcess($departmentId); + + if (empty($departments)) { + $io->warning('Не найдено отделений для обработки'); + return Command::SUCCESS; + } + + $io->info('Найдено отделений: ' . count($departments)); + $io->info('Режим онлайн: ' . ($onlineMode ? 'Да' : 'Нет')); + + $totalDoctorsProcessed = 0; + + foreach ($departments as $index => $department) { + $io->section('Обработка отделения: ' . $department['name'] . ' (ID: ' . $department['did'] . ')'); + + $doctorsData = $this->fetchDoctorsData($department['did'], $onlineMode, $firstRow, $lastRow, $io); + + if (empty($doctorsData)) { + $io->note('Нет данных врачей для отделения'); + continue; + } + + $processed = $this->processDoctorsBatch($doctorsData, $department['did'], $onlineMode, $io); + $totalDoctorsProcessed += $processed; + $io->writeln(sprintf( + 'Обработано врачей в отделении: %d', + $processed + )); + + if ($index < count($departments) - 1) { + sleep(1); + } + + $this->entityManager->clear(); + } + + $io->success(sprintf('Обработка завершена. Всего обработано врачей: %d', $totalDoctorsProcessed)); + + } catch (\Exception $e) { + $io->error('Ошибка: ' . $e->getMessage()); + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + private function getDepartmentsToProcess(?int $departmentId): array + { + $repository = $this->entityManager->getRepository(Department::class); + $qb = $repository->createQueryBuilder('d') + ->select('d.did AS did, d.name AS name') + ->orderBy('d.id', 'ASC'); + + if ($departmentId) { + $qb->andWhere('d.did = :departmentId') + ->setParameter('departmentId', $departmentId); + } else { + $qb->andWhere('d.active = :active') + ->setParameter('active', true); + } + + $departments = $qb->getQuery()->getArrayResult(); + + return array_map(static fn (array $department): array => [ + 'did' => (int) $department['did'], + 'name' => (string) $department['name'], + ], $departments); + } + + private function fetchDoctorsData(int $departmentId, bool $onlineMode, int $firstRow, int $lastRow, SymfonyStyle $io): array + { + $allDoctors = []; + $chunkSize = self::CHUNK_SIZE; + + for ($start = $firstRow; $start <= $lastRow; $start += $chunkSize) { + $end = min($start + $chunkSize - 1, $lastRow); + + $path = sprintf( + '/specialists/doctors?departments=%d&onlineMode=%d&firstrow=%d&lastrow=%d', + $departmentId, + $onlineMode ? 1 : 0, + $start, + $end + ); + + try { + $httpResponse = $this->client->request('GET', $path); + $response = $httpResponse->toArray() ?: []; + + if (!empty($response['data'])) { + $allDoctors = array_merge($allDoctors, $response['data']); + $io->writeln(sprintf('Загружено врачей: %d (%d-%d)', count($response['data']), $start, $end)); + } + + if (count($response['data'] ?? []) < $chunkSize) { + break; + } + + usleep(200000); + + } catch (\Exception $e) { + $io->warning('Ошибка при загрузке данных для отделения ' . $departmentId . ': ' . $e->getMessage()); + break; + } + } + + return $allDoctors; + } + + private function processDoctorsBatch(array $doctorsData, int $departmentId, bool $onlineMode, SymfonyStyle $io): int + { + if (empty($doctorsData)) { + return 0; + } + + + // Получаем существующих врачей + $existingDoctors = $this->getExistingDoctors($doctorsData, $departmentId, $onlineMode); + + $processed = 0; + $batchCount = 0; + + foreach ($doctorsData as $index => $doctorData) { + try { + $doctorKey = $this->getDoctorKey($doctorData['dcode'], $departmentId, $onlineMode); + + $iDoctor = $existingDoctors[$doctorKey] ?? new Idoctor(); + + $this->updateDoctorEntity($iDoctor, $doctorData, $departmentId, $onlineMode); + + $this->entityManager->persist($iDoctor); + $processed++; + $batchCount++; + + if ($batchCount % self::BATCH_SIZE === 0) { + $this->entityManager->flush(); + $io->writeln(sprintf('Сохранено пакет: %d врачей', self::BATCH_SIZE)); + $batchCount = 0; + } + + } catch (\Exception $e) { + $io->warning('Ошибка при обработке врача ' . ($doctorData['dcode'] ?? 'unknown') . ': ' . $e->getMessage()); + continue; + } + } + + if ($batchCount > 0) { + $this->entityManager->flush(); + $io->writeln(sprintf('Сохранено остальных: %d врачей', $batchCount)); + } + + return $processed; + } + + /** + * Получает существующих врачей + */ + private function getExistingDoctors(array $doctorsData, int $departmentId, bool $onlineMode): array + { + $dCodes = array_column($doctorsData, 'dcode'); + + if (empty($dCodes)) { + return []; + } + + $existingDoctors = $this->entityManager->getRepository(Idoctor::class) + ->createQueryBuilder('d') + ->where('d.dcode IN (:dCodes)') + ->andWhere('d.department = :department') + ->andWhere('d.onlineMode = :onlineMode') + ->setParameter('dCodes', $dCodes) + ->setParameter('department', $departmentId) + ->setParameter('onlineMode', $onlineMode) + ->getQuery() + ->getResult(); + + $result = []; + foreach ($existingDoctors as $doctor) { + $key = $this->getDoctorKey( + $doctor->getDcode(), + $doctor->getDepartment(), + $doctor->getOnlineMode() + ); + $result[$key] = $doctor; + } + + return $result; + } + + /** + * Создает уникальный ключ для врача + */ + private function getDoctorKey(string $dcode, int $departmentId, bool $onlineMode): string + { + return sprintf('%s_%d_%d', $dcode, $departmentId, $onlineMode ? 1 : 0); + } + + /** + * Обновляет сущность врача + */ + private function updateDoctorEntity(Idoctor $doctor, array $data, int $departmentId, bool $onlineMode): void + { + $doctor + ->setDcode($data['dcode'] ?? '') + ->setName($data['name'] ?? '') + ->setDepartment($departmentId) + ->setFilial($data['filialId'] ?? null) + ->setNearestDate($data['nearestDate'] ?? null) + ->setOnlineMode($onlineMode); + } +} \ No newline at end of file diff --git a/src/Command/UploadFilialsCommand.php b/src/Command/UploadFilialsCommand.php new file mode 100644 index 0000000..d1d1e65 --- /dev/null +++ b/src/Command/UploadFilialsCommand.php @@ -0,0 +1,104 @@ +logger->info('Началось обнвление филиалов'); + + $io = new SymfonyStyle($input, $output); + + $response = $this->client->request('GET', '/filials/list', [ + 'verify_peer' => false, + 'verify_host' => false, + 'base_uri' => $this->widgetApiUrl, + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => 'sovamed_bot' + ], + ]); + + $response = $response->toArray(); + + $io->info('load:' . count($response['data'])); + foreach ($response['data'] as $item) { + $filial = $this->entityManager->getRepository(Filial::class) + ->findOneBy(['fid' => $item['id']]); + + if (is_null($filial)) { + $filial = new Filial(); + } + + preg_match('/(ул\.\s*[А-я]+(?:\s+[А-я]+)*)\s*,?\s*д\.\s*(\S+)/u', $item['address'], $matches); + + if (isset($matches[1]) && isset($matches[2])) { + $street = $matches[1]; + $house = $matches[2]; + + $filial->setShortName("$street,$house"); + } + + $filial + ->setFid($item['id']) + ->setName($item['name']) + ->setAddress($item['address']) + ->setActive(true) + ->setRegionId($this->findRegionId($item['address'])) + ; + + $this->entityManager->persist($filial); + } + + $this->entityManager->flush(); + $io->success('loaded'); + + return Command::SUCCESS; + } + + private function findRegionId($address) { + $cities = [ + 91 => "Саратов", + 92 => "Волгоград", + 93 => "Воронеж", + 94 => "Краснодар", + ]; + + foreach ($cities as $key => $city) { + if (stripos($address, $city) !== false) { + return $key; + } + } + + return null; + } +} diff --git a/src/Command/UploadMedicalCentersCommand.php b/src/Command/UploadMedicalCentersCommand.php new file mode 100644 index 0000000..ab095f9 --- /dev/null +++ b/src/Command/UploadMedicalCentersCommand.php @@ -0,0 +1,61 @@ +addOption('view', null, InputOption::VALUE_OPTIONAL, 'SQL view name', 'public.view_centers'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $viewName = $input->getOption('view'); + if (empty($viewName)) { + $viewName = 'public.view_centers'; + } + $viewName = (string) $viewName; + + $io->title('MedicalCenter: sync from view_centers'); + + try { + $this->logger->info('MedicalCenter sync start', ['view' => $viewName]); + $affected = $this->medicalCenterCrudService->syncFromViewCenters($viewName); + $io->success('Sync finished. Affected rows: ' . $affected); + + return Command::SUCCESS; + } catch (\Throwable $e) { + $this->logger->error('MedicalCenter sync failed', [ + 'view' => $viewName, + 'error' => $e->getMessage(), + ]); + $io->error('Ошибка: ' . $e->getMessage()); + + return Command::FAILURE; + } + } +} + diff --git a/src/Command/UploadNewsCommand.php b/src/Command/UploadNewsCommand.php new file mode 100644 index 0000000..ce52134 --- /dev/null +++ b/src/Command/UploadNewsCommand.php @@ -0,0 +1,60 @@ +addOption('view', null, InputOption::VALUE_OPTIONAL, 'SQL view name', 'public.view_news'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $viewName = $input->getOption('view'); + if (empty($viewName)) { + $viewName = 'public.view_news'; + } + $viewName = (string) $viewName; + + $io->title('News: sync from view_news'); + + try { + $this->logger->info('News sync start', ['view' => $viewName]); + $affected = $this->newsCrudService->syncFromViewNews($viewName); + $io->success('Sync finished. Affected rows: ' . $affected); + + return Command::SUCCESS; + } catch (\Throwable $e) { + $this->logger->error('News sync failed', [ + 'view' => $viewName, + 'error' => $e->getMessage(), + ]); + $io->error('Ошибка: ' . $e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/src/Command/UploadPriceCommand.php b/src/Command/UploadPriceCommand.php new file mode 100644 index 0000000..2435fd0 --- /dev/null +++ b/src/Command/UploadPriceCommand.php @@ -0,0 +1,181 @@ +addArgument('did', InputArgument::OPTIONAL, 'department id update') + ->addOption('debug', null, InputOption::VALUE_NONE, 'debug on') + ->addOption('nosleep', null, InputOption::VALUE_OPTIONAL, 'sleep true|false default false') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $departments = $this->entityManager->getRepository(PriceDepartment::class)->findAll(); + $departmentId = $input->getArgument('did'); + $debug = $input->getOption('debug'); + $this->nosleep = $input->getOption('nosleep'); + + if ($departmentId) { + $departments = $this->entityManager->getRepository(PriceDepartment::class) + ->findBy(['groupId' => $departmentId]); + } + + if (empty($departments)) { + $io->error('No departments found'); + return Command::FAILURE; + } + + $batchSize = 100; + $processedCount = 0; + + foreach ($departments as $department) { + $io->note("Processing department: " . $department->getGroupId()); + + foreach ($this->getPricelist($department->getGroupId()) as $collection) { + foreach ($collection as $item) { + $priceList = $this->entityManager->getRepository(PriceList::class) + ->findOneBy([ + 'kodoper' => $item['kodoper'], + 'speccode' => $item['speccode'], + 'filial' => $item['filial'], + 'groupId' => $department->getGroupId() + ]); + + $text = 'update: '; + + if (is_null($priceList)) { + $priceList = new PriceList(); + $text = 'create: '; + } + + $priceList + ->setKodoper($item['kodoper']) + ->setSchname($item['schname']) + ->setSpecname($item['specname']) + ->setSpeccode($item['speccode']) + ->setPriceInfo($item['priceInfo']) + ->setDiscpercent($item['discpercent']) + ->setDiscprice($item['discprice']) + ->setStructname($item['structname']) + ->setFname($item['fname']) + ->setFilial($item['filial']) + ->setComment($item['comment']) + ->setMediaId($item['mediaId']) + ->setDateUpdate(new \DateTime()) + ->setGroupId($department->getGroupId()); + + $this->entityManager->persist($priceList); + $processedCount++; + + // Пакетное сохранение для экономии памяти + if ($processedCount % $batchSize === 0) { + $this->entityManager->flush(); + $this->entityManager->clear(); + gc_collect_cycles(); + + if ($debug) { + $io->info("Flushed batch of {$batchSize} records. Memory usage: " . + round(memory_get_usage() / 1024 / 1024, 2) . "MB"); + } + } + } + } + + // Финальное сохранение для оставшихся записей + $this->entityManager->flush(); + $this->entityManager->clear(); + + if ($debug) { + $io->info('Sleep: 2 sec'); + } + + if (empty($this->nosleep)) { + sleep(2); + } + } + + $io->success('Successful. Total processed: ' . $processedCount); + + return Command::SUCCESS; + } + + private function getPricelist($depnum) + { + $response = []; + $flag = true; + $firstrow = 1; + $lastrow = 500; + + while ($flag) { + try { + $result = $this->client->request('GET', 'pricelist/list', [ + 'verify_peer' => false, + 'verify_host' => false, + 'timeout' => 60, + 'base_uri' => $this->widgetApiUrl, + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => 'sovamed_bot' + ], + 'query' => [ + 'depnum' => $depnum, + 'firstrow' => $firstrow, + 'lastrow' => $lastrow + ], + ]); + + $firstrow = $lastrow + 1; + $lastrow += 500; + + $resultData = $result->toArray(false); // false чтобы избежать преобразования в объекты + + if (empty($resultData['data'])) { + $flag = false; + } else { + yield $resultData['data']; // Используем генератор для экономии памяти + } + + if (empty($this->nosleep)) { + sleep(3); + } + + } catch (\Exception $e) { + $flag = false; + // Логирование ошибки можно добавить при необходимости + } + } + } +} \ No newline at end of file diff --git a/src/Command/UploadPriceDepCommand.php b/src/Command/UploadPriceDepCommand.php new file mode 100644 index 0000000..acd09a7 --- /dev/null +++ b/src/Command/UploadPriceDepCommand.php @@ -0,0 +1,76 @@ +client->request('GET', '/pricelist/departments', [ + 'verify_peer' => false, + 'verify_host' => false, + 'base_uri' => $this->widgetApiUrl, + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => 'sovamed_bot' + ], + ]); + + $response = $response->toArray(); + + foreach ($response['data'] as $item) { + $department = $this->entityManager->getRepository(PriceDepartment::class) + ->findOneBy([ + 'groupId' => $item['id'] + ]); + + if (is_null($department)) { + $department = new PriceDepartment(); + } + + if (empty($item['viewInWeb'])) { + $item['viewInWeb'] = 0; + } + + $department + ->setGroupId($item['id']) + ->setName($item['name']) + ->setViewInWeb($item['viewInWeb']) + ->setDoctCount($item['schCount']) + ; + + $this->entityManager->persist($department); + $this->entityManager->flush(); + + $io->success('load: '. $department->getId()); + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/UploadPromoCommand.php b/src/Command/UploadPromoCommand.php new file mode 100644 index 0000000..166a207 --- /dev/null +++ b/src/Command/UploadPromoCommand.php @@ -0,0 +1,60 @@ +addOption('view', null, InputOption::VALUE_OPTIONAL, 'SQL view name', 'public.view_promo'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $viewName = $input->getOption('view'); + if (empty($viewName)) { + $viewName = 'public.view_promo'; + } + $viewName = (string) $viewName; + + $io->title('Promo: sync from view_promo'); + + try { + $this->logger->info('Promo sync start', ['view' => $viewName]); + $affected = $this->promoCrudService->syncFromViewPromo($viewName); + $io->success('Sync finished. Affected rows: ' . $affected); + + return Command::SUCCESS; + } catch (\Throwable $e) { + $this->logger->error('Promo sync failed', [ + 'view' => $viewName, + 'error' => $e->getMessage(), + ]); + $io->error('Ошибка: ' . $e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/src/Command/UploadSiteServicesCommand.php b/src/Command/UploadSiteServicesCommand.php new file mode 100644 index 0000000..884953a --- /dev/null +++ b/src/Command/UploadSiteServicesCommand.php @@ -0,0 +1,60 @@ +addOption('view', null, InputOption::VALUE_OPTIONAL, 'SQL view name', 'public.view_services'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $viewName = $input->getOption('view'); + if (empty($viewName)) { + $viewName = 'public.view_services'; + } + $viewName = (string) $viewName; + + $io->title('Site services: sync from view_services'); + + try { + $this->logger->info('Site services sync start', ['view' => $viewName]); + $affected = $this->siteServiceCrudService->syncFromViewServices($viewName); + $io->success('Sync finished. Affected rows: ' . $affected); + + return Command::SUCCESS; + } catch (\Throwable $e) { + $this->logger->error('Site services sync failed', [ + 'view' => $viewName, + 'error' => $e->getMessage(), + ]); + $io->error('Ошибка: ' . $e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/src/Controller/.gitignore b/src/Controller/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php new file mode 100644 index 0000000..5ab7989 --- /dev/null +++ b/src/Controller/ArticleController.php @@ -0,0 +1,87 @@ +createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); + + return $this->json($this->paginator->paginateWithLegacyMeta($qb, $request), Response::HTTP_OK, [], [ + 'groups' => self::READ_GROUPS, + ]); + } + + #[Route('/alias/{alias}', name: 'article_show_by_alias', methods: ['GET'])] + public function showByAlias(string $alias, ArticleRepository $repository): JsonResponse + { + $article = $repository->findOneByAlias($alias); + if (!$article) { + throw $this->createNotFoundException('Статья не найдена'); + } + + 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->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 + { + 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 + { + 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 + { + return $this->crud->delete($article); + } +} diff --git a/src/Controller/CalltouchController.php b/src/Controller/CalltouchController.php new file mode 100644 index 0000000..c8095d6 --- /dev/null +++ b/src/Controller/CalltouchController.php @@ -0,0 +1,44 @@ +request->all(); + + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + return $this->json(['errors' => (string) $errors], 400); + } + + // $response = $this->calltouchClientService->requestCreate($dto); + + return $this->json([ + 'request' => $requestData, + // 'data' => $response, + ]); + } +} diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php new file mode 100644 index 0000000..0804e44 --- /dev/null +++ b/src/Controller/DefaultController.php @@ -0,0 +1,19 @@ +render('service/comingsoon.html.twig', [ + 'title' => 'Account disabled by server administrator', + 'message' => 'Account disabled by server administrator', + ]); + } +} diff --git a/src/Controller/DepartmentController.php b/src/Controller/DepartmentController.php new file mode 100644 index 0000000..4d7aa71 --- /dev/null +++ b/src/Controller/DepartmentController.php @@ -0,0 +1,103 @@ +activeAll(); + + return $this->json([ + 'data' => $departmentList + ], Response::HTTP_OK, [], [ + 'groups' => ['department:read'] + ]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/{did}', + name: 'department_update', + methods: ['PUT'], + requirements: ['did' => '\d+'] + )] + public function update(int $did, Request $request, DepartmentRepository $repository): JsonResponse + { + $department = $repository->findOneBy(['did' => $did]); + + if (!$department) { + return new JsonResponse([ + 'message' => 'Department not found' + ], Response::HTTP_NOT_FOUND); + } + + $this->serializer->deserialize( + $request->getContent(), + Department::class, + 'json', + [ + 'groups' => ['department:write'], + 'object_to_populate' => $department + ] + ); + + $errors = $this->validator->validate($department); + + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->flush(); + + return $this->json($department, Response::HTTP_OK, [], [ + 'groups' => ['department:read'] + ]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/create', name: 'department_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $department = $this->serializer->deserialize( + $request->getContent(), + Department::class, + 'json', + ['groups' => ['department:write']] + ); + + $errors = $this->validator->validate($department); + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->persist($department); + $this->em->flush(); + + return $this->json($department, Response::HTTP_CREATED, [], [ + 'groups' => ['department:read'] + ]); + } +} diff --git a/src/Controller/DiseaseController.php b/src/Controller/DiseaseController.php new file mode 100644 index 0000000..34ccc34 --- /dev/null +++ b/src/Controller/DiseaseController.php @@ -0,0 +1,75 @@ +createFilteredQueryBuilder(ContentFilterDto::fromRequest($request)); + + 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->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 + { + 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(Request $request, Disease $disease): JsonResponse + { + 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 + { + return $this->crud->delete($disease); + } +} diff --git a/src/Controller/FilialController.php b/src/Controller/FilialController.php new file mode 100644 index 0000000..4d6fb37 --- /dev/null +++ b/src/Controller/FilialController.php @@ -0,0 +1,187 @@ +query->getInt('regionId', 0); + $criteria = ['active' => true]; + if ($regionId > 0) { + $criteria['regionId'] = $regionId; + } + $filials = $repository->findBy($criteria); + + return $this->json(['data' => $filials], + Response::HTTP_OK, [], [ + 'groups' => ['filial:read'] + ]); + } + + #[Route( + path: '/by-region/{regionId}', + name: 'filial_by_region', + methods: ['GET'], + requirements: ['regionId' => '\d+'] + )] + public function byRegion(int $regionId, FilialRepository $repository): JsonResponse + { + $filials = $repository->findBy([ + 'regionId' => $regionId, + 'active' => true + ]); + + return $this->json(['data' => $filials] + , Response::HTTP_OK, [], [ + 'groups' => ['filial:read'] + ]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/{fid}', + name: 'filial_update', + methods: ['PUT'], + requirements: ['fid' => '\d+'] + )] + public function update(int $fid, Request $request, FilialRepository $repository): JsonResponse + { + $filial = $repository->findOneBy(['fid' => $fid]); + + if (!$filial) { + return new JsonResponse([ + 'message' => 'Filial not found' + ], Response::HTTP_NOT_FOUND); + } + + $this->serializer->deserialize( + $request->getContent(), + Filial::class, + 'json', + [ + 'groups' => ['filial:write'], + 'object_to_populate' => $filial + ] + ); + + $errors = $this->validator->validate($filial); + + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->flush(); + + return $this->json($filial, Response::HTTP_OK, [], [ + 'groups' => ['filial:read'] + ]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/create', name: 'filial_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $filial = $this->serializer->deserialize( + $request->getContent(), + Filial::class, + 'json', + ['groups' => ['filial:write']] + ); + + $errors = $this->validator->validate($filial); + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->persist($filial); + $this->em->flush(); + + return $this->json($filial, Response::HTTP_CREATED, [], [ + 'groups' => ['filial:read'] + ]); + } + + + #[IsGranted('ROLE_ADMIN')] + #[Route('/picture/{id}', name: 'filial_upload_picture', methods: ['POST'], requirements: ['id' => '\d+'])] + public function pictureUpload( + Filial $filial, + Request $request, + FileUploadDto $dto, + FileUploaderServiceInterface $fileUploader + ): JsonResponse { + try { + $uploadedFile = $request->files->get('picture'); + + if (!$uploadedFile instanceof UploadedFile) { + return $this->json(['error' => 'File not uploaded'], 400); + } + + $dto->file = $uploadedFile; + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + return $this->json($errors, 400); + } + + $fileUploader->remove($fileUploader->getTargetDirectory() .'/'. $filial->getPicture()); + $fileUploader->setTargetDirectory('filial'); + $fileName = $fileUploader->upload($uploadedFile); + + $filial->setPicture('filial/'. $fileName); + $this->em->persist($filial); + $this->em->flush(); + $this->em->clear(); + + return $this->json($filial, 200, [], [ + 'groups' => ['filial:read'] + ]); + + } catch (Exception $e) { + return $this->json([ + 'error' => 'File upload failed', + 'message' => $e->getMessage() + ], 500); + } + } + + #[Route('/picture/{id}', name: 'filial_picture', methods: ['GET'])] + public function filialPicture(Filial $filial, Request $request): Response + { + $height = min($request->query->getInt('height', 300), 800); + $width = min($request->query->getInt('width', 300), 600); + + $uploadDir = $this->getParameter('upload_directory'); + + return $this->imageService->getPicture($uploadDir . '/'. $filial->getPicture(), $width, $height); + } +} diff --git a/src/Controller/HelperController.php b/src/Controller/HelperController.php new file mode 100644 index 0000000..9392d37 --- /dev/null +++ b/src/Controller/HelperController.php @@ -0,0 +1,28 @@ +query->getInt('year', 0); + $exp = $request->query->get('exp', '0') === '1'; + + $response = $this->helperService->textYear($year, $exp); + + return $this->json(['data' => $response]); + } +} diff --git a/src/Controller/InfoclinicaController.php b/src/Controller/InfoclinicaController.php new file mode 100644 index 0000000..d4a84b8 --- /dev/null +++ b/src/Controller/InfoclinicaController.php @@ -0,0 +1,151 @@ + '\d+'], methods: ['GET'])] + public function clvisitsovacheckpass( + int $filial, + Request $request, + EntityManagerInterface $entityManager + ): JsonResponse { + // Получаем авторизованного пользователя + $user = $this->jwtDecoderService->getUser(); + + if (!$user) { + return $this->json([ + 'error' => 'Пользователь не найден' + ], 401); + } + + $pcode = $user->getUid(); + + // Получаем репозиторий + $markKioskRepository = $entityManager->getRepository(MarkKiosk::class); + + // Проверяем, существует ли уже запись + $markKiosk = $markKioskRepository->findOneBy([ + 'pcode' => $pcode, + 'filial' => $filial + ]); + + // Если записи нет, создаем новую + if (!$markKiosk) { + $markKiosk = new MarkKiosk(); + $now = new \DateTimeImmutable(); + + $markKiosk->setPcode($pcode); + $markKiosk->setFilial($filial); + $markKiosk->setCreatedAt($now); + $markKiosk->setModifyAt($now); + + $entityManager->persist($markKiosk); + $entityManager->flush(); + } + + $markKiosk = $markKioskRepository->findOneBy([ + 'pcode' => $pcode, + 'filial' => $filial + ]); + + return $this->json($markKiosk->isResult()); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/idoctor/list', name: 'ic_specialist_list', methods: ['GET'])] + public function list(Request $request, IdoctorRepository $repository): JsonResponse + { + $page = $request->query->getInt('page', 1); + $perPage = min($request->query->getInt('perPage', 500), 500); + + $qb = $repository->createFilteredQueryBuilder( + $request->query->all() + ); + + // Создаем адаптер и пагинатор + $adapter = new QueryAdapter($qb); + $pagerfanta = new Pagerfanta($adapter); + + // Устанавливаем текущую страницу и количество элементов на странице + $pagerfanta->setMaxPerPage($perPage); + $pagerfanta->setCurrentPage($page); + + // Получаем элементы для текущей страницы + $data = $pagerfanta->getCurrentPageResults(); + + // Формируем ответ с метаданными пагинации + $response = [ + '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(), + ], + ]; + + return $this->json($response, Response::HTTP_OK, [], [ + 'groups' => ['idoctor:read'] + ]); + } + + #[Route('/reservation/anonymous-reserve', name: 'ic_anonymous_reserve', methods: ['POST'])] + public function bookingAnonymous(Request $request): JsonResponse + { + try { + $bookingDto = $this->serializer->deserialize( + $request->getContent(), + AnonymousReserveRequestDto::class, + 'json' + ); + + $errors = $this->validator->validate($bookingDto); + + if (count($errors) > 0) { + return $this->json(['errors' => (string) $errors], 400); + } + + $reserve = $this->specialistService->createAnonymousReserve($bookingDto); + + return $this->json($reserve, $reserve['status_code'] ?? 200); + + } catch (\InvalidArgumentException $e) { + return $this->json([ + 'error' => 'Validation failed', + 'details' => $e->getMessage() + ], 400); + } + } +} diff --git a/src/Controller/LocationController.php b/src/Controller/LocationController.php new file mode 100644 index 0000000..300ba4e --- /dev/null +++ b/src/Controller/LocationController.php @@ -0,0 +1,148 @@ +findBy(['specialist' => null]); + + return $this->json([ + 'data' => $locations + ], Response::HTTP_OK, [], [ + 'groups' => ['location:read', 'to.specialist:read'] + ]); + } + + #[Route( + path: '/specialist/{id}/location/create', + name: 'locaion_create', + methods: ['POST'], + requirements: ['id' => '\d+'] + )] + public function create( + Specialist $specialist, + Request $request, + SequenceService $sequenceService + ): JsonResponse { + $debugInfo = $sequenceService->debugSequence(Location::class); + $sequenceService->syncSequence(Location::class); + + $this->em->clear(); + + $location = $this->serializer->deserialize( + $request->getContent(), + Location::class, + 'json', + [ + 'groups' => ['location:write'], + DateTimeNormalizer::FORMAT_KEY => 'Y-m-d' + ] + ); + + $location->setSpecialist($specialist); + + $errors = $this->validator->validate($location); + + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->persist($location); + $this->em->flush(); + + return $this->json($location, Response::HTTP_CREATED, [], [ + 'groups' => ['location:read', 'to.specialist:read'] + ]); + } + + #[Route( + path: '/specialist/{specialistId}/location/{id}', + name: 'location_update', + methods: ['PUT'], + requirements: ['id' => '\d+', 'specialistId' => '\d+'] + )] + public function update(Request $request, int $specialistId, Location $location): JsonResponse + { + try { + $this->serializer->deserialize( + $request->getContent(), + Location::class, + 'json', + [ + 'groups' => ['location:write'], + 'object_to_populate' => $location, + DateTimeNormalizer::FORMAT_KEY => 'Y-m-d' + ] + ); + + $specialist = $this->em->getRepository(Specialist::class)->find($specialistId); + + if (!$specialist) { + return new JsonResponse([ + 'error' => 'Специалист не найден' + ], Response::HTTP_NOT_FOUND); + } + + $location->setSpecialist($specialist); + + $errors = $this->validator->validate($location); + + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->flush(); + + return $this->json($location, Response::HTTP_OK, [], [ + 'groups' => ['location:read', 'to.specialist:read'] + ]); + } catch (\Throwable $e) { + return new JsonResponse( + ['error' => 'Произошла ошибка при обновлении локации'], + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } + + #[Route('/location/{id}', name: 'location_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(Location $location): JsonResponse { + try { + $this->em->remove($location); + $this->em->flush(); + + return new JsonResponse(null, 204); + + } catch (Exception $e) { + return new JsonResponse([ + 'error' => 'Произошла ошибка при удалении', + 'message' => $e->getMessage() + ], 500); + } + } +} diff --git a/src/Controller/MedicalCenterController.php b/src/Controller/MedicalCenterController.php new file mode 100644 index 0000000..111bd43 --- /dev/null +++ b/src/Controller/MedicalCenterController.php @@ -0,0 +1,75 @@ +createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); + + 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->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 + { + 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(Request $request, MedicalCenter $medicalCenter): JsonResponse + { + 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 + { + return $this->crud->delete($medicalCenter); + } +} diff --git a/src/Controller/NewsController.php b/src/Controller/NewsController.php new file mode 100644 index 0000000..fd9fb10 --- /dev/null +++ b/src/Controller/NewsController.php @@ -0,0 +1,75 @@ +createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); + + 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->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 + { + 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(Request $request, News $news): JsonResponse + { + 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 + { + return $this->crud->delete($news); + } +} diff --git a/src/Controller/PriceDepartmentController.php b/src/Controller/PriceDepartmentController.php new file mode 100644 index 0000000..82ba227 --- /dev/null +++ b/src/Controller/PriceDepartmentController.php @@ -0,0 +1,30 @@ +findAll(); + + return $this->json([ + 'data' => $departmentList + ], Response::HTTP_OK, [], [ + 'groups' => ['departmentPrice:read'] + ]); + } +} diff --git a/src/Controller/PriceListController.php b/src/Controller/PriceListController.php new file mode 100644 index 0000000..abe1593 --- /dev/null +++ b/src/Controller/PriceListController.php @@ -0,0 +1,75 @@ +query->getInt('page', 1); + $perPage = min($request->query->getInt('perPage', 100), 500); + + $qb = $repository->createFilteredQueryBuilder( + $request->query->all() + ); + + $adapter = new QueryAdapter($qb); + $pagerfanta = new Pagerfanta($adapter); + $pagerfanta->setMaxPerPage($perPage); + $pagerfanta->setCurrentPage($page); + $data = $pagerfanta->getCurrentPageResults(); + $response = [ + '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(), + ], + ]; + + return $this->json($response, Response::HTTP_OK, [], [ + 'groups' => ['pricelist:read'] + ]); + } +} diff --git a/src/Controller/PromoController.php b/src/Controller/PromoController.php new file mode 100644 index 0000000..48aafdd --- /dev/null +++ b/src/Controller/PromoController.php @@ -0,0 +1,75 @@ +createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); + + 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->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 + { + 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(Request $request, Promo $promo): JsonResponse + { + 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 + { + return $this->crud->delete($promo); + } +} diff --git a/src/Controller/ReviewController.php b/src/Controller/ReviewController.php new file mode 100644 index 0000000..fd271f9 --- /dev/null +++ b/src/Controller/ReviewController.php @@ -0,0 +1,222 @@ +query->getInt('page', 1); + $perPage = min($request->query->getInt('perPage', 100), 500); + + $qb = $repository->createFilteredQueryBuilder( + $request->query->all() + ); + + $adapter = new QueryAdapter($qb); + $pagerfanta = new Pagerfanta($adapter); + $pagerfanta->setMaxPerPage($perPage); + $pagerfanta->setCurrentPage($page); + $data = $pagerfanta->getCurrentPageResults(); + $response = [ + '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(), + ], + ]; + + return $this->json($response, Response::HTTP_OK, [], [ + 'groups' => ['review:read', 'to.specialist:read'], + ]); + } + + #[OA\Tag(name: 'Отзывы')] + #[Route('/{id}', name: 'review_show', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(Review $review): JsonResponse + { + return $this->json($review, Response::HTTP_OK, [], [ + 'groups' => ['review:read', 'to.specialist:read'], + ]); + } + + #[OA\Tag(name: 'Отзывы')] + #[IsGranted('ROLE_USER')] + #[Route('/create', name: 'review_create', methods: ['POST'])] + public function create(Request $request, SpecialistRepository $specialistRepository): JsonResponse + { + try { + $dto = $this->serializer->deserialize( + $request->getContent(), + ReviewInputDto::class, + 'json' + ); + } catch (ExceptionInterface $e) { + return $this->json([ + 'error' => 'Ошибка десериализации', + 'message' => $e->getMessage(), + ], Response::HTTP_BAD_REQUEST); + } + + $errors = $this->validator->validate($dto); + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $specialist = $specialistRepository->find((int) $dto->specialistId); + if ($specialist === null) { + return $this->json(['error' => 'Специалист не найден'], Response::HTTP_NOT_FOUND); + } + + $user = $this->getUser(); + if (!$user instanceof User) { + return $this->json(['error' => 'Требуется авторизация'], Response::HTTP_UNAUTHORIZED); + } + + $review = new Review(); + $this->applyInput( + $review, + $dto, + $specialist, + active: false, + externalIdFromAuthenticatedUser: $user->getId(), + isNewReview: true, + ); + + $this->em->persist($review); + try { + $this->em->flush(); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Не удалось сохранить отзыв', + 'message' => $this->getParameter('kernel.debug') ? $e->getMessage() : 'Внутренняя ошибка сервера', + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->json($review, Response::HTTP_CREATED, [], [ + 'groups' => ['review:read', 'to.specialist:read'], + ]); + } + + #[OA\Tag(name: 'Отзывы')] + #[IsGranted('ROLE_ADMIN')] + #[Route('/{id}', name: 'review_update', methods: ['PUT'], requirements: ['id' => '\d+'])] + public function update(Request $request, Review $review, SpecialistRepository $specialistRepository): JsonResponse + { + try { + $dto = $this->serializer->deserialize( + $request->getContent(), + ReviewInputDto::class, + 'json' + ); + } catch (ExceptionInterface $e) { + return $this->json([ + 'error' => 'Ошибка десериализации', + 'message' => $e->getMessage(), + ], Response::HTTP_BAD_REQUEST); + } + + $errors = $this->validator->validate($dto, null, ['Default', 'review_update']); + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $specialist = $specialistRepository->find((int) $dto->specialistId); + if ($specialist === null) { + return $this->json(['error' => 'Специалист не найден'], Response::HTTP_NOT_FOUND); + } + + $this->applyInput($review, $dto, $specialist, active: (bool) $dto->active, isNewReview: false); + + try { + $this->em->flush(); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Не удалось сохранить отзыв', + 'message' => $this->getParameter('kernel.debug') ? $e->getMessage() : 'Внутренняя ошибка сервера', + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return $this->json($review, Response::HTTP_OK, [], [ + 'groups' => ['review:read', 'to.specialist:read'], + ]); + } + + #[OA\Tag(name: 'Отзывы')] + #[IsGranted('ROLE_ADMIN')] + #[Route('/{id}', name: 'review_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(Review $review): JsonResponse + { + $this->em->remove($review); + $this->em->flush(); + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } + + /** + * Doctrine DBAL DateType ожидает {@see \DateTime}, не {@see \DateTimeImmutable}. + */ + private function toDoctrineDate(\DateTimeInterface $date): \DateTime + { + return $date instanceof \DateTime ? clone $date : \DateTime::createFromInterface($date); + } + + private function applyInput( + Review $review, + ReviewInputDto $dto, + Specialist $specialist, + bool $active, + ?int $externalIdFromAuthenticatedUser = null, + bool $isNewReview = false, + ): void { + $review + ->setSpecialist($specialist) + ->setActive($active) + ->setMessage((string) $dto->message) + ->setAuthor((string) $dto->author) + ->setRating((float) $dto->rating) + ->setSource($dto->source); + + if ($isNewReview) { + $review->setDateCreate($this->toDoctrineDate(new \DateTimeImmutable('today'))); + if ($externalIdFromAuthenticatedUser !== null) { + $review->setExternalId($externalIdFromAuthenticatedUser); + } + } + } +} diff --git a/src/Controller/ServiceController.php b/src/Controller/ServiceController.php new file mode 100644 index 0000000..a1acccc --- /dev/null +++ b/src/Controller/ServiceController.php @@ -0,0 +1,75 @@ +sendmailConfig->getAccessToken(); + if ($accessToken === '') { + return $this->json(['error' => 'Токен доступа не настроен (MAILER_ACCESS_TOKEN)'], 503); + } + $token = $request->query->get('token', ''); + if ($token === '' || !hash_equals($accessToken, $token)) { + return $this->json(['error' => 'Неверный или отсутствующий токен доступа'], 403); + } + + $mailto = $request->query->get('mailto', ''); + $subject = $request->query->get('subject', ''); + $message = $request->query->get('message', ''); + + if ($mailto === '') { + return $this->json(['error' => 'Параметр mailto обязателен'], 400); + } + $emailViolations = $this->validator->validate($mailto, [new EmailConstraint()]); + if (count($emailViolations) > 0) { + return $this->json(['error' => 'Некорректный email в параметре mailto'], 400); + } + if ($subject === '') { + return $this->json(['error' => 'Параметр subject обязателен'], 400); + } + if ($message === '') { + return $this->json(['error' => 'Параметр message обязателен'], 400); + } + + try { + $this->sendMailService->send($mailto, $subject, $message); + return $this->json(['success' => true, 'message' => 'Письмо отправлено']); + } catch (\Throwable $e) { + return $this->json([ + 'error' => 'Ошибка при отправке письма', + 'message' => $e->getMessage() + ], 500); + } + } + + #[Route('/smart-captcha', name: 'service_smart_captcha', methods: ['POST'])] + public function smartCaptcha(Request $request): JsonResponse + { + $response = $this->smartCaptha->validate( + $request->request->get('token', ''), + $request->getClientIp() + ); + + return $this->json($response); + } +} diff --git a/src/Controller/SiteServiceController.php b/src/Controller/SiteServiceController.php new file mode 100644 index 0000000..5443153 --- /dev/null +++ b/src/Controller/SiteServiceController.php @@ -0,0 +1,75 @@ +createFilteredQueryBuilder(ContentFilterDto::fromRequest($request, true)); + + 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->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 + { + 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(Request $request, SiteService $siteService): JsonResponse + { + 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 + { + return $this->crud->delete($siteService); + } +} diff --git a/src/Controller/SpecialistController.php b/src/Controller/SpecialistController.php new file mode 100644 index 0000000..316855c --- /dev/null +++ b/src/Controller/SpecialistController.php @@ -0,0 +1,360 @@ +serializer->deserialize( + $request->getContent(), + Specialist::class, + 'json', + ['groups' => ['specialist:write']], + ); + + $errors = $this->validator->validate($specialist); + + if (count($errors) > 0) { + return $this->json($errors, 400); + } + + $this->em->persist($specialist); + $this->em->flush(); + + $data = $this->serializer->serialize($specialist, 'json', [ + 'groups' => ['specialist:detail', 'from.specialist:read'], + 'image_size' => 'large' + ]); + + return new JsonResponse( + $data, + Response::HTTP_OK, + ['Content-Type' => 'application/json'], + true + ); + } catch (ExceptionInterface $e) { + return new JsonResponse([ + 'error' => 'Ошибка десериализации', + 'message' => $e->getMessage() + ], Response::HTTP_BAD_REQUEST); + } + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/{id}', name: 'specialist_update', methods: ['PUT'], requirements: ['id' => '\d+'])] + public function update(Specialist $specialist, Request $request): JsonResponse + { + try { + $this->serializer->deserialize( + $request->getContent(), + Specialist::class, + 'json', + [ + 'groups' => ['specialist:write'], + 'object_to_populate' => $specialist, + ] + ); + + $errors = $this->validator->validate($specialist); + + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->flush(); + + $data = $this->serializer->serialize($specialist, 'json', [ + 'groups' => ['specialist:detail', 'from.specialist:read'], + 'image_size' => 'large' + ]); + + return new JsonResponse( + $data, + Response::HTTP_OK, + ['Content-Type' => 'application/json'], + true + ); + + } catch (ExceptionInterface $e) { + return new JsonResponse([ + 'error' => 'Ошибка десериализации', + 'message' => $e->getMessage() + ], Response::HTTP_BAD_REQUEST); + } + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/{id}', name: 'specialist_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + public function delete(Specialist $specialist, FileUploaderServiceInterface $fileUploader): JsonResponse + { + try { + $fileUploader->remove($fileUploader->getTargetDirectory() .'/'. $specialist->getPreviewPicture()); + + $this->em->remove($specialist); + $this->em->flush(); + + return new JsonResponse(null, 204); + + } catch (Exception $e) { + return new JsonResponse([ + 'error' => 'Произошла ошибка при удалении', + 'message' => $e->getMessage() + ], 500); + } + } + + #[OA\Tag(name: 'Список врачей')] + #[OA\Parameter( + name: 'page', + in: 'query', + description: 'номер станицы', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'perPage', + in: 'query', + description: 'количество записей', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'filial', + in: 'query', + description: 'id филиала', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'department', + in: 'query', + description: 'id отделения', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'search', + in: 'query', + description: 'поиск по имени', + schema: new OA\Schema(type: 'string') + )] + #[Route('/list', name: 'specialist_list', methods: ['GET'])] + public function list( + Request $request, + SpecialistRepository $repository, + ): JsonResponse { + $page = $request->query->getInt('page', 1); + $perPage = min($request->query->getInt('perPage', 100), 500); + + $filter = SpecialistFilterDto::fromRequest($request); + $total = $this->specialistService->getFilteredCount($filter); + $qb = $repository->createFilteredQueryBuilder($filter->toArray()); + + $qb->setFirstResult(($page - 1) * $perPage) + ->setMaxResults($perPage); + + $paginator = new Paginator($qb->getQuery(), true); + $data = iterator_to_array($paginator->getIterator(), false); + $totalPages = ceil($total / $perPage); + + $response = [ + 'data' => $data, + 'pagination' => [ + 'total' => $total, + 'count' => count($data), + 'per_page' => $perPage, + 'current_page' => $page, + 'total_pages' => $totalPages, + 'has_previous_page' => $page > 1, + 'has_next_page' => $page < $totalPages, + ], + ]; + + return $this->json($response, Response::HTTP_OK, [], [ + 'groups' => ['specialist:read', 'from.specialist:read'] + ]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/post', name: 'specialist_post', methods: ['GET'])] + public function specialistPost(SpecialistRepository $specialist): JsonResponse + { + return $this->json($specialist->post()); + } + + #[Route('/picture/{id}', name: 'specialist_picture', methods: ['GET'])] + public function specialistPicture(Specialist $specialist, Request $request): Response + { + $height = min($request->query->getInt('height', 300), 800); + $width = min($request->query->getInt('width', 300), 600); + + $uploadDir = $this->getParameter('upload_directory'); + + $file = $this->specialistService->getLoadPicture($specialist->getId()); + + return $this->imageService->getPicture($uploadDir . '/' .$file, $width, $height); + } + + #[OA\Tag(name: 'Расписание врачей')] + #[OA\Parameter( + name: 'st', + in: 'query', + description: 'Начальная дата (Ymd)', + required:true, + schema: new OA\Schema(type: 'integer', format:'Ymd') + )] + #[OA\Parameter( + name: 'en', + in: 'query', + description: 'Конечна дата (Ymd)', + required:true, + schema: new OA\Schema(type: 'integer', format:'Y-m-d') + )] + #[OA\Parameter( + name: 'dcode', + in: 'query', + description: 'ID врача', + required:true, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'filial', + in: 'query', + description: 'ID филиала', + required:true, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'onlineMode', + in: 'query', + description: 'Онлайн расписание', + required:true, + schema: new OA\Schema(type: 'boolean') + )] + #[Route('/schedule', name: 'specialist_schedule', methods: ['GET'])] + public function specialistSchedule( + Request $request, + ScheduleDto $dto, + ): JsonResponse { + $dto->st = $request->query->get('st'); + $dto->en = $request->query->get('en'); + $dto->dcode = $request->query->get('dcode'); + $dto->onlineMode = $request->query->getBoolean('onlineMode')? 1: 0; + $dto->filial = $request->query->get('filial'); + + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + return $this->json(['errors' => (string) $errors], 400); + } + + $schedule = $this->specialistService->getSchedule($dto); + + return $this->json($schedule, 200, []); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/picture/{id}', name: 'specialist_upload_picture', methods: ['POST'], requirements: ['id' => '\d+'])] + public function specialistUpload( + Specialist $specialist, + Request $request, + FileUploadDto $dto, + FileUploaderServiceInterface $fileUploader + ): JsonResponse { + try { + $uploadedFile = $request->files->get('previewPicture'); + + if (!$uploadedFile instanceof UploadedFile) { + return $this->json(['error' => 'File not uploaded'], 400); + } + + $dto->file = $uploadedFile; + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + return $this->json($errors, 400); + } + + $fileUploader->remove($fileUploader->getTargetDirectory() .'/'. $specialist->getPreviewPicture()); + $fileUploader->setTargetDirectory('specialist'); + $fileName = $fileUploader->upload($uploadedFile); + + $specialist->setPreviewPicture('specialist/'. $fileName); + $this->em->persist($specialist); + $this->em->flush(); + + return $this->json($specialist, 200, [], [ + 'groups' => ['specialist:detail', 'from.specialist:read'] + ]); + + } catch (Exception $e) { + return $this->json([ + 'error' => 'File upload failed', + 'message' => $e->getMessage() + ], 500); + } + } + + #[OA\Tag(name: 'Детальная информация врача')] + #[OA\Parameter( + name: 'id', + in: 'path', + description: 'id врача', + schema: new OA\Schema(type: 'integer') + )] + #[Route('/{id}', name: 'specialist_detal', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(Specialist $specialist): JsonResponse + { + return $this->json($specialist, Response::HTTP_OK, [], [ + 'groups' => ['specialist:detail', 'from.specialist:read'] + ]); + } + + #[Route('/by/{identifier}', name: 'specialist_detal_by', methods: ['GET'], requirements: ['identifier' => '[a-zA-Z0-9\-_]+'])] + public function showBy(string $identifier, Request $request): JsonResponse + { + $regionId = $request->query->getInt('regionId'); + $specialist = $this->specialistService->getSpecialist($identifier, $regionId); + + if (!$specialist) { + return $this->json(['error' => 'not found'], Response::HTTP_NOT_FOUND); + } + + return $this->json($specialist, Response::HTTP_OK, [], [ + 'groups' => ['specialist:detail', 'from.specialist:read'] + ]); + } +} diff --git a/src/Controller/SpecialistDcodeDescriptionController.php b/src/Controller/SpecialistDcodeDescriptionController.php new file mode 100644 index 0000000..fbc039e --- /dev/null +++ b/src/Controller/SpecialistDcodeDescriptionController.php @@ -0,0 +1,184 @@ +query->getInt('page', 1); + $perPage = min($request->query->getInt('perPage', 100), 500); + + $qb = $repository->createFilteredQueryBuilder($request->query->all()); + $pager = new Pagerfanta(new QueryAdapter($qb)); + $pager->setMaxPerPage($perPage); + $pager->setCurrentPage($page); + $items = iterator_to_array($pager->getCurrentPageResults()); + $data = array_map( + fn (SpecialistDcodeDescription $item) => $this->normalizeWithSpeciality($item), + $items + ); + + $response = [ + 'data' => $data, + 'pagination' => [ + 'total' => $pager->getNbResults(), + 'count' => count($data), + 'per_page' => $pager->getMaxPerPage(), + 'current_page' => $pager->getCurrentPage(), + 'total_pages' => $pager->getNbPages(), + 'has_previous_page' => $pager->hasPreviousPage(), + 'has_next_page' => $pager->hasNextPage(), + ], + ]; + + return $this->json($response, Response::HTTP_OK); + } + + #[Route( + '/specialist-dcode-description/{id}', + name: 'specialist_dcode_description_detail', + methods: ['GET'], + requirements: ['id' => '\d+'] + )] + public function show(SpecialistDcodeDescription $specialistDcodeDescription): JsonResponse + { + return $this->json($this->normalizeWithSpeciality($specialistDcodeDescription), Response::HTTP_OK); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/specialist/{specialistId}/specialist-dcode-description/create', + name: 'specialist_dcode_description_create', + methods: ['POST'], + requirements: ['specialistId' => '\d+'] + )] + public function create(int $specialistId, Request $request): JsonResponse + { + $specialist = $this->em->getRepository(Specialist::class)->find($specialistId); + if (!$specialist) { + return $this->json(['error' => 'Специалист не найден'], Response::HTTP_NOT_FOUND); + } + + $entity = $this->serializer->deserialize( + $request->getContent(), + SpecialistDcodeDescription::class, + 'json', + ['groups' => ['specialist.dcode.description:write']] + ); + $entity->setSpecialist($specialist); + + $errors = $this->validator->validate($entity); + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->persist($entity); + $this->em->flush(); + + return $this->json($this->normalizeWithSpeciality($entity), Response::HTTP_CREATED); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/specialist/{specialistId}/specialist-dcode-description/{id}', + name: 'specialist_dcode_description_update', + methods: ['PUT'], + requirements: ['specialistId' => '\d+', 'id' => '\d+'] + )] + public function update(Request $request, int $specialistId, SpecialistDcodeDescription $entity): JsonResponse + { + try { + $specialist = $this->em->getRepository(Specialist::class)->find($specialistId); + if (!$specialist) { + return $this->json(['error' => 'Специалист не найден'], Response::HTTP_NOT_FOUND); + } + + $this->serializer->deserialize( + $request->getContent(), + SpecialistDcodeDescription::class, + 'json', + [ + 'groups' => ['specialist.dcode.description:write'], + 'object_to_populate' => $entity, + ] + ); + $entity->setSpecialist($specialist); + + $errors = $this->validator->validate($entity); + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->flush(); + + return $this->json($this->normalizeWithSpeciality($entity), Response::HTTP_OK); + } catch (Exception $e) { + return $this->json([ + 'error' => 'Произошла ошибка при обновлении', + 'message' => $e->getMessage(), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/specialist-dcode-description/{id}', + name: 'specialist_dcode_description_delete', + methods: ['DELETE'], + requirements: ['id' => '\d+'] + )] + public function delete(SpecialistDcodeDescription $entity): JsonResponse + { + try { + $this->em->remove($entity); + $this->em->flush(); + + return new JsonResponse(null, Response::HTTP_NO_CONTENT); + } catch (Exception $e) { + return $this->json([ + 'error' => 'Произошла ошибка при удалении', + 'message' => $e->getMessage(), + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + private function normalizeWithSpeciality(SpecialistDcodeDescription $entity): array + { + $normalized = json_decode($this->serializer->serialize($entity, 'json', [ + 'groups' => ['specialist.dcode.description:read', 'to.specialist:read'], + ]), true); + + $normalized['speciality'] = $entity->getSpecialist()?->getPost(); + + return $normalized; + } +} diff --git a/src/Controller/SpecialistDocsController.php b/src/Controller/SpecialistDocsController.php new file mode 100644 index 0000000..7fd055e --- /dev/null +++ b/src/Controller/SpecialistDocsController.php @@ -0,0 +1,223 @@ +json([ + 'data' => $repository->findAll() + ], Response::HTTP_OK, [], [ + 'groups' => ['specialist.docs:read', 'to.specialist:read'] + ]); + } + + #[Route('specialist-docs/{id}', name: 'specialist_docs_detal', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(SpecialistDocs $specialistDocs): JsonResponse + { + return $this->json( + $specialistDocs, + Response::HTTP_OK, [], [ + 'groups' => ['specialist.docs:read', 'to.specialist:read'] + ]); + } + + #[Route('/specialist-docs/picture/{id}', name: 'specialist_docs_picture', methods: ['GET'])] + public function specialistPicture(SpecialistDocs $specialistDocs, Request $request): Response + { + $height = min($request->query->getInt('height', 300), 800); + $width = min($request->query->getInt('width', 300), 600); + + $uploadDir = $this->getParameter('upload_directory'); + + $file = $specialistDocs->getPicture(); + + return $this->imageService->getPicture($uploadDir . '/' .$file, $width, $height); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/specialist/{id}/specialist-docs/create', + name: 'specialist_docs_create', + methods: ['POST'], + requirements: ['id' => '\d+'] + )] + public function create(Specialist $specialist, Request $request): JsonResponse + { + $specialistDocs = $this->serializer->deserialize( + $request->getContent(), + SpecialistDocs::class, + 'json', + ['groups' => ['specialist.docs:write']] + ); + + $specialistDocs->setSpecialist($specialist); + + $errors = $this->validator->validate($specialistDocs); + + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->persist($specialistDocs); + $this->em->flush(); + + return $this->json($specialistDocs, Response::HTTP_CREATED, [], [ + 'groups' => ['specialist.docs:read', 'to.specialist:read'] + ]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/specialist/{specialistId}/specialist-docs/{id}', + name: 'specialist_docs_update', + methods: ['PUT'], + requirements: ['id' => '\d+', 'specialistId' => '\d+'] + )] + public function update(Request $request, int $specialistId, SpecialistDocs $specialistDocs): JsonResponse + { + try { + $this->serializer->deserialize( + $request->getContent(), + SpecialistDocs::class, + 'json', + [ + 'groups' => ['specialist.docs:write'], + 'object_to_populate' => $specialistDocs + ] + ); + + $specialist = $this->em->getRepository(Specialist::class)->find($specialistId); + + if (!$specialist) { + return new JsonResponse([ + 'error' => 'Специалист не найден' + ], Response::HTTP_NOT_FOUND); + } + + $specialistDocs->setSpecialist($specialist); + + $errors = $this->validator->validate($specialistDocs); + + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->flush(); + + return $this->json($specialistDocs, Response::HTTP_OK, [], [ + 'groups' => ['specialist.docs:read', 'to.specialist:read'] + ]); + } catch (Exception $e) { + return new JsonResponse([ + 'error' => 'Произошла ошибка при удалении', + 'message' => $e->getMessage() + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/specialist-docs/{id}', + name: 'specialist_docs_delete', + methods: ['DELETE'], + requirements: ['id' => '\d+'] + )] + public function delete(SpecialistDocs $specialistDocs, FileUploaderServiceInterface $fileUploader): JsonResponse + { + try { + $fileUploader->remove($fileUploader->getTargetDirectory() .'/'. $specialistDocs->getPicture()); + $this->em->remove($specialistDocs); + $this->em->flush(); + + return new JsonResponse(null, 204); + + } catch (Exception $e) { + return new JsonResponse([ + 'error' => 'Произошла ошибка при удалении', + 'message' => $e->getMessage() + ], 500); + } + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/specialist-docs/picture/{id}', + name: 'specialist_docs_upload_picture', + methods: ['POST'], + requirements: ['id' => '\d+'] + )] + public function uploadPicture( + SpecialistDocs $specialistDocs, + Request $request, + FileUploadDto $dto, + FileUploaderServiceInterface $fileUploader + ): JsonResponse { + try { + $uploadedFile = $request->files->get('picture'); + + if (!$uploadedFile instanceof UploadedFile) { + return $this->json(['error' => 'File not uploaded'], 400); + } + + $dto->file = $uploadedFile; + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + return $this->json($errors, 400); + } + + $fileUploader->remove($fileUploader->getTargetDirectory() .'/'. $specialistDocs->getPicture()); + $fileUploader->setTargetDirectory($specialistDocs->getType()); + + $fileName = $fileUploader->upload($uploadedFile); + + $specialistDocs->setPicture($specialistDocs->getType() . '/' . $fileName); + $this->em->persist($specialistDocs); + $this->em->flush(); + + return $this->json($specialistDocs, 200, [], [ + 'groups' => ['specialist.docs:read', 'to.specialist:read'] + ]); + + } catch (Exception $e) { + return $this->json([ + 'error' => 'File upload failed', + 'message' => $e->getMessage() + ], 500); + } + } +} diff --git a/src/Controller/StockController.php b/src/Controller/StockController.php new file mode 100644 index 0000000..4416006 --- /dev/null +++ b/src/Controller/StockController.php @@ -0,0 +1,266 @@ +query->getInt('page', 1); + $perPage = min($request->query->getInt('perPage', 100), 500); + + $qb = $repository->createFilteredQueryBuilder( + $request->query->all() + ); + + $pager = new Pagerfanta(new QueryAdapter($qb)); + $pager->setMaxPerPage($perPage); + $pager->setCurrentPage($page); + $data = $pager->getCurrentPageResults(); + + $response = [ + 'data' => $data, + 'pagination' => [ + 'total' => $pager->getNbResults(), + 'count' => count($data), + 'per_page' => $pager->getMaxPerPage(), + 'current_page' => $pager->getCurrentPage(), + 'total_pages' => $pager->getNbPages(), + 'has_previous_page' => $pager->hasPreviousPage(), + 'has_next_page' => $pager->hasNextPage(), + ], + ]; + + return $this->json($response, Response::HTTP_OK, [], [ + 'groups' => ['stock:read', 'to.specialist:read'] + ]); + } + + #[Route('stock/{id}', name: 'stock_detal', methods: ['GET'], requirements: ['id' => '\d+'])] + public function show(Stock $stock): JsonResponse + { + return $this->json($stock, Response::HTTP_OK, [], [ + 'groups' => ['stock:read', 'to.specialist:read'] + ]); + } + + #[Route('/stock/picture/{id}', name: 'stock_picture', methods: ['GET'])] + public function picture(Stock $stock, Request $request): Response + { + $height = min($request->query->getInt('height', 300), 800); + $width = min($request->query->getInt('width', 300), 600); + + $uploadDir = $this->getParameter('upload_directory'); + + $file = $stock->getPicture(); + + return $this->imageService->getPicture($uploadDir . '/' .$file, $width, $height); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: 'stock/{id}/specialist/{specialistId}', + name: 'stock_specialist_update', + methods: ['DELETE', 'PUT'], + requirements: ['id' => '\d+'] + )] + public function updateSpecialist(Request $request, int $specialistId, Stock $stock): JsonResponse + { + $specialist = $this->em->getRepository(Specialist::class)->find($specialistId); + + if (!$specialist) { + return new JsonResponse([ + 'error' => 'Специалист не найден' + ], Response::HTTP_NOT_FOUND); + } + + $request->getMethod() === 'PUT' + ? $stock->addSpecialist($specialist) + : $stock->removeSpecialist($specialist); + + $errors = $this->validator->validate($stock); + + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->flush(); + + return $this->json($stock, Response::HTTP_OK, [], [ + 'groups' => ['stock:read', 'to.specialist:read'] + ]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/stock/create', + name: 'stock_create', + methods: ['POST'] + )] + public function create(Request $request): JsonResponse + { + $stock = $this->serializer->deserialize( + $request->getContent(), + Stock::class, + 'json', + ['groups' => ['stock:write']] + ); + + $errors = $this->validator->validate($stock); + + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->persist($stock); + $this->em->flush(); + + return $this->json($stock, Response::HTTP_CREATED, [], [ + 'groups' => ['stock:read', 'to.specialist:read'] + ]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/stock/{id}', + name: 'stock_update', + methods: ['PUT'], + requirements: ['id' => '\d+'] + )] + public function update(Request $request, Stock $stock): JsonResponse + { + try { + $this->serializer->deserialize( + $request->getContent(), + Stock::class, + 'json', + [ + 'groups' => ['stock:write'], + 'object_to_populate' => $stock + ] + ); + + $errors = $this->validator->validate($stock); + + if (count($errors) > 0) { + return $this->json($errors, Response::HTTP_BAD_REQUEST); + } + + $this->em->flush(); + + return $this->json($stock, Response::HTTP_OK, [], [ + 'groups' => ['stock:read', 'to.specialist:read'] + ]); + } catch (Exception $e) { + return new JsonResponse([ + 'error' => 'Произошла ошибка при удалении', + 'message' => $e->getMessage() + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/stock/{id}', + name: 'stock_delete', + methods: ['DELETE'], + requirements: ['id' => '\d+'] + )] + public function delete(Stock $stock, FileUploaderServiceInterface $fileUploader): JsonResponse + { + try { + $fileUploader->remove($fileUploader->getTargetDirectory() .'/'. $stock->getPicture()); + $this->em->remove($stock); + $this->em->flush(); + + return new JsonResponse(null, 204); + + } catch (Exception $e) { + return new JsonResponse([ + 'error' => 'Произошла ошибка при удалении', + 'message' => $e->getMessage() + ], 500); + } + } + + #[IsGranted('ROLE_ADMIN')] + #[Route( + path: '/stock/picture/{id}', + name: 'stock_upload_picture', + methods: ['POST'], + requirements: ['id' => '\d+'] + )] + public function uploadPicture( + Stock $stock, + Request $request, + FileUploadDto $dto, + FileUploaderServiceInterface $fileUploader + ): JsonResponse { + try { + $uploadedFile = $request->files->get('picture'); + + if (!$uploadedFile instanceof UploadedFile) { + return $this->json(['error' => 'File not uploaded'], 400); + } + + $dto->file = $uploadedFile; + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + return $this->json($errors, 400); + } + + $fileUploader->remove($fileUploader->getTargetDirectory() .'/'. $stock->getPicture()); + $fileUploader->setTargetDirectory('stock'); + + $fileName = $fileUploader->upload($uploadedFile); + + $stock->setPicture('stock/' . $fileName); + $this->em->persist($stock); + $this->em->flush(); + + return $this->json($stock, 200, [], [ + 'groups' => ['stock:read', 'to.specialist:read'] + ]); + + } catch (Exception $e) { + return $this->json([ + 'error' => 'File upload failed', + 'message' => $e->getMessage() + ], 500); + } + } +} diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..aee9b16 --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,286 @@ +json(['successful' => true]); + } + + #[Route('/login', name: 'user_login', methods: ['POST'])] + #[OA\Tag(name: 'Авторизация пользователя')] + #[OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'username', type: 'string', description: 'Email пользователя'), + new OA\Property(property: 'password', type: 'string', description: 'Пароль пользователя') + ], + required: ['username', 'password'] + ) + )] + #[OA\Response( + response: 200, + description: 'Успешная авторизация', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'successful', type: 'boolean'), + new OA\Property(property: 'token', type: 'string', description: 'JWT токен'), + new OA\Property(property: 'user', type: 'object', description: 'Данные пользователя') + ] + ) + )] + #[OA\Response( + response: 400, + description: 'Ошибка валидации или неверные учетные данные' + )] + public function login(UserLoginDto $dto, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!$data || !isset($data['username']) || !isset($data['password'])) { + return new JsonResponse(['message' => 'Missing credentials'], 400); + } + + $dto->email = $data['username'] ?? null; + $dto->password = $data['password'] ?? null; + + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + return $this->json([ + 'successful' => false, + 'errors' => (string) $errors + ], 400); + } + + $userData = $this->auth->jsonAuth($dto); + if ($userData['isPasswordValid'] === false || $userData['user'] === null) { + return $this->json([ + 'successful' => false, + 'message' => 'Не правильное имя пользователя или пароль' + ], 400); + } + + // Обновляем дату и время авторизации + $userData['user']->updateLoggedIn(); + $this->em->flush(); + + $user = $userData['user']->toArray(); + + return new JsonResponse([ + 'successful' => true, + 'token' => $this->jwtManager->create($userData['user']), + 'user' => $user + ], 200); + } + + #[IsGranted('ROLE_USER')] + #[Route('/', name: 'user_current', methods: ['GET'])] + public function index(JWTDecoderServiceInterface $jwtDecoderService): JsonResponse + { + $user = $jwtDecoderService->getUser(); + $user = $user->toArray(); + + return $this->json([ + 'user' => $user, + 'successful' => true, + ]); + } + + #[IsGranted('ROLE_USER')] + #[Route('/change-region', name: 'user_change_region', methods: ['PUT'])] + public function changeRegion( + UserProfileServiceInterface $userService, + RegionDto $dto, + Request $request + ): JsonResponse { + + $data = json_decode($request->getContent(), true); + $dto->regionId = $data['regionId']; + + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + return $this->json(['errors' => (string) $errors], 400); + } + + $user = $userService->updateRegion($dto); + $user = $user->toArray(); + + return $this->json([ + 'user' => $user, + 'successful' => true, + ]); + } + + #[Route('/auth', name: 'user_auth', methods: ['POST'])] + public function auth( + RegistrationServiceInterface $registration, + UserAuthDto $dto, + Request $request + ): JsonResponse { + $data = json_decode($request->getContent(), true); + + $dto->uid = $data['uid'] ?? null; + $dto->regionId = $data['regionId'] ?? null; + $dto->email = $data['email'] ?? null; + $dto->password = $data['password'] ?? null; + $dto->birthDate = $data['bdate'] ?? null; + + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + return $this->json([ + 'successful' => false, + 'errors' => (string) $errors + ], 400); + } + + $userData = $this->auth->jwtAuth($dto); + + if ($userData['isPasswordValid'] === false) { + return $this->json([ + 'successful' => false, + 'message' => 'Не правильное имя пользователя или пароль' + ], 400); + } + + if ($userData['user'] === null) { + $userData['user'] = $registration->create($dto); + } + + // Обновляем дату и время авторизации + $userData['user']->updateLoggedIn(); + $this->em->flush(); + + $user = $userData['user']->toArray(); + + return $this->json([ + 'successful' => true, + 'token' => $this->jwtManager->create($userData['user']), + 'user' => $user + ]); + } + + #[Route('/auth-by-pcode', name: 'user_auth_by_pcode', methods: ['POST'])] + #[OA\Tag(name: 'Авторизация пользователя')] + #[OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'pcode', type: 'integer', description: 'Pcode пользователя (UID)'), + new OA\Property(property: 'birthDate', type: 'string', description: 'Дата рождения в формате Ymd (например, 19900101) или Y-m-d (например, 1990-01-01)') + ], + required: ['pcode', 'birthDate'] + ) + )] + #[OA\Response( + response: 200, + description: 'Успешная авторизация. Если пользователь не найден, он будет создан автоматически', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'successful', type: 'boolean'), + new OA\Property(property: 'token', type: 'string', description: 'JWT токен'), + new OA\Property(property: 'user', type: 'object', description: 'Данные пользователя') + ] + ) + )] + #[OA\Response( + response: 400, + description: 'Ошибка валидации' + )] + #[OA\Response( + response: 500, + description: 'Ошибка при создании пользователя' + )] + public function authByPcode( + RegistrationServiceInterface $registration, + UserUidAuthDto $dto, + Request $request + ): JsonResponse { + $data = json_decode($request->getContent(), true); + + $dto->uid = $data['pcode'] ?? null; + $dto->birthDate = $data['birthDate'] ?? $data['bdate'] ?? null; + + $errors = $this->validator->validate($dto); + + if (count($errors) > 0) { + return $this->json([ + 'successful' => false, + 'errors' => (string) $errors + ], 400); + } + + // Парсим дату рождения + $birthDate = \DateTime::createFromFormat('Ymd', $dto->birthDate); + if (!$birthDate) { + $birthDate = \DateTime::createFromFormat('Y-m-d', $dto->birthDate); + } + if (!$birthDate) { + return $this->json([ + 'successful' => false, + 'errors' => 'Неверный формат даты рождения. Используйте формат Ymd (например, 19900101) или Y-m-d (например, 1990-01-01)' + ], 400); + } + + // Ищем пользователя по uid и birthDate + $user = $this->userRepository->findOneByUidAndBirthDate($dto->uid, $birthDate); + + // Если пользователь не найден, создаем его + if ($user === null) { + try { + $user = $registration->createByUidAndBirthDate($dto); + } catch (\Exception $e) { + return $this->json([ + 'successful' => false, + 'errors' => 'Ошибка при создании пользователя: ' . $e->getMessage() + ], 500); + } + } + + // Обновляем дату и время авторизации + $user->updateLoggedIn(); + $this->em->flush(); + + $userArray = $user->toArray(); + + return $this->json([ + 'successful' => true, + 'token' => $this->jwtManager->create($user), + 'user' => $userArray + ]); + } +} diff --git a/src/Controller/UsrlogController.php b/src/Controller/UsrlogController.php new file mode 100644 index 0000000..22e9cc3 --- /dev/null +++ b/src/Controller/UsrlogController.php @@ -0,0 +1,90 @@ +query->getInt('limit', 50)), 500); + $offset = max(0, $request->query->getInt('offset', 0)); + $usrlogTable = 'public.usrlog'; + + $date = $request->query->get('date'); + $dateFrom = null; + $dateTo = null; + + if ($date !== null && $date !== '') { + $dateFrom = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date . ' 00:00:00'); + $dateTo = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date . ' 23:59:59'); + + if (!$dateFrom || !$dateTo) { + return $this->json([ + 'error' => 'Invalid date format. Expected Y-m-d.', + ], Response::HTTP_BAD_REQUEST); + } + } + + $countQb = $this->cabinetConnection->createQueryBuilder() + ->select('COUNT(*)') + ->from($usrlogTable, 'u'); + + $listQb = $this->cabinetConnection->createQueryBuilder() + ->select('u.id', 'u.pcode', 'u.agent', 'u.client_ip', 'u.method', 'u.created_at') + ->from($usrlogTable, 'u') + ->orderBy('u.created_at', 'DESC') + ->setFirstResult($offset) + ->setMaxResults($limit); + + if ($dateFrom && $dateTo) { + $countQb + ->andWhere('u.created_at BETWEEN :date_from AND :date_to') + ->setParameter('date_from', $dateFrom->format('Y-m-d H:i:s'), ParameterType::STRING) + ->setParameter('date_to', $dateTo->format('Y-m-d H:i:s'), ParameterType::STRING); + + $listQb + ->andWhere('u.created_at BETWEEN :date_from AND :date_to') + ->setParameter('date_from', $dateFrom->format('Y-m-d H:i:s'), ParameterType::STRING) + ->setParameter('date_to', $dateTo->format('Y-m-d H:i:s'), ParameterType::STRING); + } + + $total = (int) $countQb->executeQuery()->fetchOne(); + $rows = $listQb->executeQuery()->fetchAllAssociative(); + + $currentPage = $limit > 0 ? (int) floor($offset / $limit) + 1 : 1; + $totalPages = $total > 0 ? (int) ceil($total / $limit) : 0; + + return $this->json([ + 'data' => $rows, + 'pagination' => [ + 'total' => $total, + 'count' => count($rows), + 'limit' => $limit, + 'offset' => $offset, + 'current_page' => $currentPage, + 'total_pages' => $totalPages, + 'has_previous_page' => $offset > 0, + 'has_next_page' => ($offset + $limit) < $total, + ], + ], Response::HTTP_OK); + } +} diff --git a/src/Controller/WebGetDocinfoController.php b/src/Controller/WebGetDocinfoController.php new file mode 100644 index 0000000..1fd1fc2 --- /dev/null +++ b/src/Controller/WebGetDocinfoController.php @@ -0,0 +1,152 @@ +query->getInt('page', 1); + $perPage = min($request->query->getInt('perPage', 100), 500); + + $filters = []; + if ($request->query->has('id')) { + $filters['id'] = $request->query->getInt('id'); + } + if ($request->query->has('sourceTable')) { + $filters['sourceTable'] = $request->query->get('sourceTable'); + } + if ($request->query->has('filial')) { + $filters['filial'] = $request->query->getInt('filial'); + } + if ($request->query->has('search')) { + $filters['search'] = $request->query->get('search'); + } + + $qb = $repository->createFilteredQueryBuilder($filters); + + // Подсчет общего количества + $countQb = clone $qb; + $countQb->select('COUNT(w.id)'); + $total = (int) $countQb->getQuery()->getSingleScalarResult(); + + // Пагинация + $qb->setFirstResult(($page - 1) * $perPage) + ->setMaxResults($perPage); + + $data = $qb->getQuery()->getResult(); + $totalPages = ceil($total / $perPage); + + $response = [ + 'data' => $data, + 'pagination' => [ + 'total' => $total, + 'count' => count($data), + 'per_page' => $perPage, + 'current_page' => $page, + 'total_pages' => $totalPages, + 'has_previous_page' => $page > 1, + 'has_next_page' => $page < $totalPages, + ], + ]; + + return $this->json($response, Response::HTTP_OK, [], [ + 'groups' => ['web_get_docinfo:read'] + ]); + } + + #[Route('/dms/{filial}/{dcode}', name: 'web_get_docinfo_dms', methods: ['GET'])] + #[OA\Tag(name: 'WebGetDocinfo')] + #[OA\Parameter( + name: 'filial', + in: 'path', + description: 'ID филиала', + required: true, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'dcode', + in: 'path', + description: 'ID записи (dcode)', + required: true, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Response( + response: 200, + description: 'Информация о DMS', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'dms', type: 'integer', nullable: true, description: 'Информация о DMS из поля accepts_dms') + ] + ) + )] + #[OA\Response( + response: 404, + description: 'Запись не найдена' + )] + public function getDms( + int $filial, + int $dcode, + WebGetDocinfoRepository $repository + ): JsonResponse { + $docinfo = $repository->findByFilialAndId($filial, $dcode); + + if (!$docinfo) { + return $this->json(['error' => 'not found'], Response::HTTP_NOT_FOUND); + } + + return $this->json([ + 'dms' => $docinfo->getAcceptsDms() + ], Response::HTTP_OK); + } +} diff --git a/src/Controller/XmlFeedController.php b/src/Controller/XmlFeedController.php new file mode 100644 index 0000000..8dcfab6 --- /dev/null +++ b/src/Controller/XmlFeedController.php @@ -0,0 +1,102 @@ +filialRepository->findOneBy([ + 'fid' => $request->query->getInt('filial', 0) + ]); + + if (!$currentFilial) { + return new Response(); + } + + // Собираем UTM-параметры из запроса + $utmParams = [ + 'utm_source' => $request->query->get('utm_source'), + 'utm_medium' => $request->query->get('utm_medium'), + 'utm_campaign' => $request->query->get('utm_campaign'), + 'utm_term' => $request->query->get('utm_term'), + 'utm_content' => $request->query->get('utm_content'), + ]; + + $xmlContent = $this->xmlFeed->generateFeed($currentFilial, $utmParams); + + $response = new Response($xmlContent); + $response->headers->set('Content-Type', 'application/xml; charset=utf-8'); + return $response; + } + + #[Route('/xml/feed/v1', name: 'yandex_feed_v1', methods:['GET'])] + public function generateFeedV1(Request $request): Response + { + $filialsParam = $request->query->get('filials'); + $regionId = $request->query->getInt('regionId', 0); + + if ($filialsParam !== null && $filialsParam !== '') { + $allowedFids = array_map('intval', array_filter(explode(',', $filialsParam))); + if ($allowedFids !== []) { + // При явном списке filials — загружаем по fid и active, без привязки к regionId + $filials = $this->filialRepository->findBy( + ['fid' => $allowedFids, 'active' => true], + ['fid' => 'ASC'] + ); + $byFid = []; + foreach ($filials as $f) { + $byFid[$f->getFid()] = $f; + } + $filials = []; + foreach ($allowedFids as $fid) { + if (isset($byFid[$fid])) { + $filials[] = $byFid[$fid]; + } + } + } else { + $filials = []; + } + } else { + $filials = $this->filialRepository->findBy( + [ + 'regionId' => $regionId, + 'active' => true, + ], + ['fid' => 'ASC'] + ); + } + + if ($filials === []) { + return new Response(); + } + + $xmlContent = $this->xmlFeedv1->generateFeed($filials); + + $response = new Response($xmlContent); + $response->headers->set('Content-Type', 'application/xml; charset=utf-8'); + return $response; + } +} diff --git a/src/Dto/AnonymousReserveRequestDto.php b/src/Dto/AnonymousReserveRequestDto.php new file mode 100644 index 0000000..ac573f7 --- /dev/null +++ b/src/Dto/AnonymousReserveRequestDto.php @@ -0,0 +1,101 @@ +time); + + return [ + 'date' => $this->workDate, + 'st' => $timeParts[0], + 'en' => $timeParts[1], + 'services' => $this->services, + 'filial' => $this->filial, + 'timezone' => $this->timezone, + 'schedident' => $this->schedident, + 'rnum' => $this->rnum, + 'dcode' => $this->specialist, + ]; + } + + public function toJson(): string + { + return json_encode($this->resetveToArray(), JSON_UNESCAPED_SLASHES); + } + + public function toArray(): array + { + return [ + 'accept' => $this->accept, + 'fio' => $this->fio, + 'captcha' => $this->captcha, + 'email' => $this->email, + 'phone' => $this->phone, + 'reserve' => $this->toJson() + ]; + } +} \ No newline at end of file diff --git a/src/Dto/CalltouchCreateRequestDto.php b/src/Dto/CalltouchCreateRequestDto.php new file mode 100644 index 0000000..bf1ea45 --- /dev/null +++ b/src/Dto/CalltouchCreateRequestDto.php @@ -0,0 +1,40 @@ + $this->requestNumber, + 'subject' => $this->subject, + 'requestUrl' => $this->requestUrl, + 'requestDate' => $this->requestDate, + 'sessionId' => $this->sessionId, + 'phoneNumber' => $this->phoneNumber, + 'email' => $this->email, + 'fio' => $this->fio, + 'addTags' => $this->addTags, + 'customSources' => $this->customSources, + 'customFields' => $this->customFields + ]; + } +} \ No newline at end of file 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/Dto/FileUploadDto.php b/src/Dto/FileUploadDto.php new file mode 100644 index 0000000..3ef5cbd --- /dev/null +++ b/src/Dto/FileUploadDto.php @@ -0,0 +1,17 @@ + $this->accept, + 'firstName' => $this->firstName, + 'lastName' => $this->lastName, + 'middleName' => $this->middleName, + 'refuseCall' => $this->refuseCall, + 'refuseSms' => $this->refuseSms, + 'email' => $this->email, + 'phone' => $this->phone, + 'confirmed' => $this->confirmed, + 'gender' => $this->gender, + 'checkData' => $this->checkData, + 'captcha' => $this->captcha, + 'snils' => $this->snils + ]; + } +} \ No newline at end of file diff --git a/src/Dto/ReviewInputDto.php b/src/Dto/ReviewInputDto.php new file mode 100644 index 0000000..89effca --- /dev/null +++ b/src/Dto/ReviewInputDto.php @@ -0,0 +1,31 @@ + $this->st, + 'en' => $this->en, + 'dcode' => $this->dcode, + 'onlineMode' => $this->onlineMode, + 'filialId' => $this->filial, + ]); + } +} diff --git a/src/Dto/SpecialistFilterDto.php b/src/Dto/SpecialistFilterDto.php new file mode 100644 index 0000000..aee1409 --- /dev/null +++ b/src/Dto/SpecialistFilterDto.php @@ -0,0 +1,88 @@ +active = $request->query->has('active') + ? filter_var($request->query->get('active'), FILTER_VALIDATE_BOOLEAN) + : null; + $dto->sType = $request->query->all('sType'); + $dto->regionId = $request->query->getInt('regionId', 0) ?: null; + $dto->dcode = $request->query->all('dcode'); + $dto->department = $request->query->get('department'); + $dto->alias = $request->query->get('alias'); + $dto->tags = $request->query->all('tags'); + $dto->onlineMode = $request->query->has('onlineMode') + ? filter_var($request->query->get('onlineMode'), FILTER_VALIDATE_BOOLEAN) + : null; + $dto->dateFrom = $request->query->get('dateFrom'); + $dto->dateTo = $request->query->get('dateTo'); + $dto->kodoper = $request->query->all('kodoper'); + $dto->search = $request->query->get('search'); + $dto->category = $request->query->get('category'); + $dto->patientAge = $request->query->get('patientAge'); + $dto->displaySchedule = $request->query->has('displaySchedule') + ? filter_var($request->query->get('displaySchedule'), FILTER_VALIDATE_BOOLEAN) + : null; + $dto->orderBy = $request->query->get('orderBy'); + $dto->filial = $request->query->getInt('filial', 0) ?: null; + $dto->sFilial = $request->query->all('sFilial'); + $dto->kiosk = $request->query->has('isKiosk') + ? filter_var($request->query->get('isKiosk'), FILTER_VALIDATE_BOOLEAN) + : null; + + return $dto; + } + + public function toArray(): array + { + return [ + 'active' => $this->active, + 'sType' => $this->sType, + 'regionId' => $this->regionId, + 'dcode' => $this->dcode, + 'department' => $this->department, + 'alias' => $this->alias, + 'tags' => $this->tags, + 'onlineMode' => $this->onlineMode, + 'dateFrom' => $this->dateFrom, + 'dateTo' => $this->dateTo, + 'kodoper' => $this->kodoper, + 'search' => $this->search, + 'category' => $this->category, + 'patientAge' => $this->patientAge, + 'displaySchedule' => $this->displaySchedule, + 'orderBy' => $this->orderBy, + 'filial' => $this->filial, + 'sFilial' => $this->sFilial, + 'kiosk' => $this->kiosk + ]; + } +} \ No newline at end of file diff --git a/src/Dto/UserAuthDto.php b/src/Dto/UserAuthDto.php new file mode 100644 index 0000000..3d849b8 --- /dev/null +++ b/src/Dto/UserAuthDto.php @@ -0,0 +1,38 @@ +id; + } + + public function getRecord(): ?Record + { + return $this->record; + } + + public function setRecord(?Record $record): self + { + $this->record = $record; + + return $this; + } + + public function getDateCreate(): ?DateTimeInterface + { + return $this->dateCreate; + } + + public function setDateCreate(DateTimeInterface $dateCreate): self + { + $this->dateCreate = $dateCreate; + + return $this; + } + + public function getResponse(): ?string + { + return $this->response; + } + + public function setResponse(string $response): self + { + $this->response = $response; + + return $this; + } +} \ No newline at end of file diff --git a/src/Entity/Article.php b/src/Entity/Article.php new file mode 100644 index 0000000..3f37913 --- /dev/null +++ b/src/Entity/Article.php @@ -0,0 +1,191 @@ +id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): static + { + $this->name = $name; + + return $this; + } + + public function getPreviewPicture(): ?string + { + return $this->previewPicture; + } + + public function setPreviewPicture(?string $previewPicture): static + { + $this->previewPicture = $previewPicture; + + return $this; + } + + public function getActive(): ?bool + { + return $this->active; + } + + public function setActive(?bool $active): static + { + $this->active = $active; + + return $this; + } + + public function getDoctors(): ?array + { + return $this->doctors; + } + + public function setDoctors(?array $doctors): static + { + $this->doctors = $doctors; + + return $this; + } + + public function getServices(): ?array + { + return $this->services; + } + + public function setServices(?array $services): static + { + $this->services = $services; + + return $this; + } + + public function getRegionId(): ?int + { + return $this->regionId; + } + + public function setRegionId(?int $regionId): static + { + $this->regionId = $regionId; + + return $this; + } + + public function getAlias(): ?string + { + return $this->alias; + } + + public function setAlias(?string $alias): static + { + $this->alias = $alias; + + return $this; + } + + public function getAnons(): ?string + { + return $this->anons; + } + + public function setAnons(?string $anons): static + { + $this->anons = $anons; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): static + { + $this->content = $content; + + return $this; + } + + public function getUpdateAt(): ?\DateTimeInterface + { + return $this->updateAt; + } + + public function setUpdateAt(?\DateTimeInterface $updateAt): static + { + $this->updateAt = $updateAt; + + return $this; + } +} diff --git a/src/Entity/Banner.php b/src/Entity/Banner.php new file mode 100644 index 0000000..e07b2e8 --- /dev/null +++ b/src/Entity/Banner.php @@ -0,0 +1,93 @@ +id; + } + + public function getHref(): ?string + { + return $this->href; + } + + public function setHref(string $href): self + { + $this->href = $href; + + return $this; + } + + public function getSrc(): ?string + { + return $this->src; + } + + public function setSrc(string $src): self + { + $this->src = $src; + + return $this; + } + + public function getActive(): ?bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getCity(): ?City + { + return $this->city; + } + + public function setCity(City $city): self + { + $this->city = $city; + + return $this; + } +} 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/Department.php b/src/Entity/Department.php new file mode 100644 index 0000000..9fc6133 --- /dev/null +++ b/src/Entity/Department.php @@ -0,0 +1,129 @@ + true])] + private bool $active = true; + + #[Groups(['department:read', 'department:write'])] + #[ORM\Column(name: 'group_name', type: 'string', length: 255, nullable: true)] + private ?string $groupName = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getDid(): ?string + { + return $this->did; + } + + public function setDid(?string $did): self + { + $this->did = $did; + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + return $this; + } + + public function getOnlineMode(): ?bool + { + return $this->onlineMode; + } + + public function setOnlineMode(?bool $onlineMode): self + { + $this->onlineMode = $onlineMode; + return $this; + } + + public function getAlias(): ?string + { + return $this->alias; + } + + public function setAlias(?string $alias): self + { + $this->alias = $alias; + + return $this; + } + + public function getActive(): ?bool + { + return $this->active; + } + + public function setActive(?bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getGroupName(): ?string + { + return $this->groupName; + } + + public function setGroupName(?string $groupName): self + { + $this->groupName = $groupName; + + return $this; + } + + public function toArray(): array + { + return [ + 'did' => $this->getDid(), + 'name' => $this->getName(), + 'onlineMode' => $this->getOnlineMode(), + 'alias' => $this->getAlias(), + 'active' => $this->getActive(), + 'groupName' => $this->getGroupName(), + ]; + } +} diff --git a/src/Entity/Disease.php b/src/Entity/Disease.php new file mode 100644 index 0000000..3a33ac3 --- /dev/null +++ b/src/Entity/Disease.php @@ -0,0 +1,405 @@ +id; + } + + public function setId(?int $id): static + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): static + { + $this->name = $name; + + return $this; + } + + public function getPreviewPicture(): ?string + { + return $this->previewPicture; + } + + public function setPreviewPicture(?string $previewPicture): static + { + $this->previewPicture = $previewPicture; + + return $this; + } + + public function getActive(): ?bool + { + return $this->active; + } + + public function setActive(?bool $active): static + { + $this->active = $active; + + return $this; + } + + public function getRegionId(): ?int + { + return $this->regionId; + } + + public function setRegionId(?int $regionId): static + { + $this->regionId = $regionId; + + return $this; + } + + public function getAlias(): ?string + { + return $this->alias; + } + + public function setAlias(?string $alias): static + { + $this->alias = $alias; + + return $this; + } + + public function getAnons(): ?string + { + return $this->anons; + } + + public function setAnons(?string $anons): static + { + $this->anons = $anons; + + return $this; + } + + public function getUpdateAt(): ?\DateTimeInterface + { + return $this->updateAt; + } + + public function setUpdateAt(?\DateTimeInterface $updateAt): static + { + $this->updateAt = $updateAt; + + return $this; + } + + public function getHidePicture(): ?bool + { + return $this->hidePicture; + } + + public function setHidePicture(?bool $hidePicture): static + { + $this->hidePicture = $hidePicture; + + return $this; + } + + public function getReadTime(): ?string + { + return $this->readTime; + } + + public function setReadTime(?string $readTime): static + { + $this->readTime = $readTime; + + return $this; + } + + public function getDiseasesName(): ?string + { + return $this->diseasesName; + } + + public function setDiseasesName(?string $diseasesName): static + { + $this->diseasesName = $diseasesName; + + return $this; + } + + public function getTagsImportant(): ?array + { + return $this->tagsImportant; + } + + public function setTagsImportant(?array $tagsImportant): static + { + $this->tagsImportant = $tagsImportant; + + return $this; + } + + public function getTags(): ?array + { + return $this->tags; + } + + public function setTags(?array $tags): static + { + $this->tags = $tags; + + return $this; + } + + public function getDiseasesOtherName(): ?string + { + return $this->diseasesOtherName; + } + + public function setDiseasesOtherName(?string $diseasesOtherName): static + { + $this->diseasesOtherName = $diseasesOtherName; + + return $this; + } + + public function getSymptom(): ?string + { + return $this->symptom; + } + + public function setSymptom(?string $symptom): static + { + $this->symptom = $symptom; + + return $this; + } + + public function getStaff(): ?string + { + return $this->staff; + } + + public function setStaff(?string $staff): static + { + $this->staff = $staff; + + return $this; + } + + public function getLinkServices(): ?array + { + return $this->linkServices; + } + + public function setLinkServices(?array $linkServices): static + { + $this->linkServices = $linkServices; + + return $this; + } + + public function getStaffList(): ?array + { + return $this->staffList; + } + + public function setStaffList(?array $staffList): static + { + $this->staffList = $staffList; + + return $this; + } + + public function getStaffPost(): ?array + { + return $this->staffPost; + } + + public function setStaffPost(?array $staffPost): static + { + $this->staffPost = $staffPost; + + return $this; + } + + public function getStaffPostExclude(): ?array + { + return $this->staffPostExclude; + } + + public function setStaffPostExclude(?array $staffPostExclude): static + { + $this->staffPostExclude = $staffPostExclude; + + return $this; + } + + public function getLinkFaq(): ?array + { + return $this->linkFaq; + } + + public function setLinkFaq(?array $linkFaq): static + { + $this->linkFaq = $linkFaq; + + return $this; + } + + public function getBibliography(): ?string + { + return $this->bibliography; + } + + public function setBibliography(?string $bibliography): static + { + $this->bibliography = $bibliography; + + return $this; + } + + public function getStaffCheck(): ?array + { + return $this->staffCheck; + } + + public function setStaffCheck(?array $staffCheck): static + { + $this->staffCheck = $staffCheck; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): static + { + $this->content = $content; + + return $this; + } +} diff --git a/src/Entity/Filial.php b/src/Entity/Filial.php new file mode 100644 index 0000000..4963d7f --- /dev/null +++ b/src/Entity/Filial.php @@ -0,0 +1,231 @@ + true])] + private bool $active = true; + + #[Groups(['filial:read', 'filial:write'])] + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $company = null; + + #[Groups(['filial:read', 'filial:write'])] + #[ORM\Column(length: 255, nullable: true)] + private ?string $shortName = null; + + #[Groups(['filial:read', 'filial:write'])] + #[ORM\Column(length: 255, nullable: true)] + private ?string $phone = null; + + #[Groups(['filial:read', 'filial:write'])] + #[ORM\Column(length: 255, nullable: true)] + private ?string $policy = null; + + #[Groups(['filial:read', 'filial:write'])] + #[ORM\Column(length: 255, nullable: true)] + private ?string $picture = null; + + #[Groups(['filial:read', 'filial:write'])] + #[ORM\Column(length: 255, nullable: true)] + private ?string $email = null; + + #[Groups(['filial:read', 'filial:write'])] + #[ORM\Column(length: 255, nullable: true)] + private ?string $origin = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getFid(): ?int + { + return $this->fid; + } + + public function setFid(int $fid): self + { + $this->fid = $fid; + + return $this; + } + + public function getSiteId(): ?int + { + return $this->siteId; + } + + public function setSiteId(?int $siteId): self + { + $this->siteId = $siteId; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getAddress(): ?string + { + return $this->address; + } + + public function setAddress(?string $address): self + { + $this->address = $address; + + return $this; + } + + public function getRegionId(): ?int + { + return $this->regionId; + } + + public function setRegionId(?int $regionId): self + { + $this->regionId = $regionId; + + return $this; + } + + public function getActive(): ?bool + { + return $this->active; + } + + public function setActive(?bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getCompany(): ?string + { + return $this->company; + } + + public function setCompany(?string $company): self + { + $this->company = $company; + + return $this; + } + + public function getShortName(): ?string + { + return $this->shortName; + } + + public function setShortName(?string $shortName): static + { + $this->shortName = $shortName; + + return $this; + } + + public function getPhone(): ?string + { + return $this->phone; + } + + public function setPhone(?string $phone): static + { + $this->phone = $phone; + + return $this; + } + + public function getPolicy(): ?string + { + return $this->policy; + } + + public function setPolicy(?string $policy): static + { + $this->policy = $policy; + + return $this; + } + + public function getPicture(): ?string + { + return $this->picture; + } + + public function setPicture(?string $picture): static + { + $this->picture = $picture; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + $this->email = $email; + + return $this; + } + + public function getOrigin(): ?string + { + return $this->origin; + } + + public function setOrigin(?string $origin): static + { + $this->origin = $origin; + + return $this; + } +} diff --git a/src/Entity/Idoctor.php b/src/Entity/Idoctor.php new file mode 100644 index 0000000..2c41914 --- /dev/null +++ b/src/Entity/Idoctor.php @@ -0,0 +1,128 @@ +updated = new \DateTime("now"); + } + + public function getId(): ?string + { + return $this->id; + } + + public function getDcode(): ?string + { + return $this->dcode; + } + + public function setDcode(string $dcode): self + { + $this->dcode = $dcode; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getDepartment(): ?string + { + return $this->department; + } + + public function setDepartment(string $department): self + { + $this->department = $department; + + return $this; + } + + public function getFilial(): ?int + { + return $this->filial; + } + + public function setFilial(int $filial): self + { + $this->filial = $filial; + + return $this; + } + + public function getNearestDate(): ?string + { + return $this->nearestDate; + } + + public function setNearestDate(string $nearestDate): self + { + $this->nearestDate = $nearestDate; + + return $this; + } + + public function getOnlineMode(): bool + { + return $this->onlineMode; + } + + public function setOnlineMode(bool $onlineMode): self + { + $this->onlineMode = $onlineMode; + + return $this; + } +} diff --git a/src/Entity/Location.php b/src/Entity/Location.php new file mode 100644 index 0000000..29aa62c --- /dev/null +++ b/src/Entity/Location.php @@ -0,0 +1,135 @@ +id; + } + + public function getDcode(): ?int + { + return $this->dcode; + } + + public function setDcode(?int $dcode): self + { + $this->dcode = (int) $dcode; + return $this; + } + + public function getDepartment(): int + { + return $this->department; + } + + public function setDepartment(int $department): self + { + $this->department = $department; + return $this; + } + + public function getFilial(): int + { + return $this->filial; + } + + public function setFilial(int $filial): self + { + $this->filial = $filial; + return $this; + } + + public function getOnlineMode(): bool + { + return $this->onlineMode; + } + + public function setOnlineMode(bool $onlineMode): self + { + $this->onlineMode = $onlineMode; + return $this; + } + + public function getActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + return $this; + } + + public function getNearestDate(): ?\DateTimeInterface + { + return $this->nearestDate; + } + + public function setNearestDate(?\DateTimeInterface $nearestDate): self + { + $this->nearestDate = $nearestDate; + return $this; + } + + public function getSpecialist(): ?Specialist + { + return $this->specialist; + } + + public function setSpecialist(?Specialist $specialist): self + { + $this->specialist = $specialist; + return $this; + } +} diff --git a/src/Entity/MarkKiosk.php b/src/Entity/MarkKiosk.php new file mode 100644 index 0000000..9507fc6 --- /dev/null +++ b/src/Entity/MarkKiosk.php @@ -0,0 +1,96 @@ +id; + } + + public function getPcode(): ?string + { + return $this->pcode; + } + + public function setPcode(string $pcode): static + { + $this->pcode = $pcode; + + return $this; + } + + public function getFilial(): ?int + { + return $this->filial; + } + + public function setFilial(int $filial): static + { + $this->filial = $filial; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->created_at; + } + + public function setCreatedAt(\DateTimeImmutable $created_at): static + { + $this->created_at = $created_at; + + return $this; + } + + public function getModifyAt(): ?\DateTimeImmutable + { + return $this->modify_at; + } + + public function setModifyAt(\DateTimeImmutable $modify_at): static + { + $this->modify_at = $modify_at; + + return $this; + } + + public function isResult(): ?bool + { + return $this->result; + } + + public function setResult(?bool $result): static + { + $this->result = $result; + + return $this; + } +} diff --git a/src/Entity/MedicalCenter.php b/src/Entity/MedicalCenter.php new file mode 100644 index 0000000..c3411cf --- /dev/null +++ b/src/Entity/MedicalCenter.php @@ -0,0 +1,515 @@ +id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getActive(): ?bool + { + return $this->active; + } + + public function setActive(?bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getRegionId(): ?int + { + return $this->regionId; + } + + public function setRegionId(?int $regionId): self + { + $this->regionId = $regionId; + + return $this; + } + + public function getAlias(): ?string + { + return $this->alias; + } + + public function setAlias(?string $alias): self + { + $this->alias = $alias; + + return $this; + } + + public function getAnons(): ?string + { + return $this->anons; + } + + public function setAnons(?string $anons): self + { + $this->anons = $anons; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): self + { + $this->content = $content; + + return $this; + } + + public function getUpdateAt(): ?\DateTimeInterface + { + return $this->updateAt; + } + + public function setUpdateAt(?\DateTimeInterface $updateAt): self + { + $this->updateAt = $updateAt; + + return $this; + } + + public function getKodUslug(): ?array + { + return $this->kodUslug; + } + + public function setKodUslug(?array $kodUslug): self + { + $this->kodUslug = $kodUslug; + + return $this; + } + + public function getDoctors(): ?array + { + return $this->doctors; + } + + public function setDoctors(?array $doctors): self + { + $this->doctors = $doctors; + + return $this; + } + + public function getServices(): ?array + { + return $this->services; + } + + public function setServices(?array $services): self + { + $this->services = $services; + + return $this; + } + + public function getArticles(): ?array + { + return $this->articles; + } + + public function setArticles(?array $articles): self + { + $this->articles = $articles; + + return $this; + } + + public function getTxtUp(): ?array + { + return $this->txtUp; + } + + public function setTxtUp(?array $txtUp): self + { + $this->txtUp = $txtUp; + + return $this; + } + + public function getMainLinkStaff(): ?string + { + return $this->mainLinkStaff; + } + + public function setMainLinkStaff(?string $mainLinkStaff): self + { + $this->mainLinkStaff = $mainLinkStaff; + + return $this; + } + + public function getContraindications(): ?array + { + return $this->contraindications; + } + + public function setContraindications(?array $contraindications): self + { + $this->contraindications = $contraindications; + + return $this; + } + + public function getHidePicture(): ?int + { + return $this->hidePicture; + } + + public function setHidePicture(?int $hidePicture): self + { + $this->hidePicture = $hidePicture; + + return $this; + } + + public function getIndications(): ?array + { + return $this->indications; + } + + public function setIndications(?array $indications): self + { + $this->indications = $indications; + + return $this; + } + + public function getLinkSale(): ?array + { + return $this->linkSale; + } + + public function setLinkSale(?array $linkSale): self + { + $this->linkSale = $linkSale; + + return $this; + } + + public function getPlusList(): ?array + { + return $this->plusList; + } + + public function setPlusList(?array $plusList): self + { + $this->plusList = $plusList; + + return $this; + } + + public function getPlusText(): ?string + { + return $this->plusText; + } + + public function setPlusText(?string $plusText): self + { + $this->plusText = $plusText; + + return $this; + } + + public function getPlusTitle(): ?string + { + return $this->plusTitle; + } + + public function setPlusTitle(?string $plusTitle): self + { + $this->plusTitle = $plusTitle; + + return $this; + } + + public function getProcessText(): ?string + { + return $this->processText; + } + + public function setProcessText(?string $processText): self + { + $this->processText = $processText; + + return $this; + } + + public function getProcessTitle(): ?string + { + return $this->processTitle; + } + + public function setProcessTitle(?string $processTitle): self + { + $this->processTitle = $processTitle; + + return $this; + } + + public function getServicesList(): ?array + { + return $this->servicesList; + } + + public function setServicesList(?array $servicesList): self + { + $this->servicesList = $servicesList; + + return $this; + } + + public function getServicesPhotos(): ?array + { + return $this->servicesPhotos; + } + + public function setServicesPhotos(?array $servicesPhotos): self + { + $this->servicesPhotos = $servicesPhotos; + + return $this; + } + + public function getServicesTitle(): ?string + { + return $this->servicesTitle; + } + + public function setServicesTitle(?string $servicesTitle): self + { + $this->servicesTitle = $servicesTitle; + + return $this; + } + + public function getSortStaff(): ?array + { + return $this->sortStaff; + } + + public function setSortStaff(?array $sortStaff): self + { + $this->sortStaff = $sortStaff; + + return $this; + } + + public function getTrainingText(): ?string + { + return $this->trainingText; + } + + public function setTrainingText(?string $trainingText): self + { + $this->trainingText = $trainingText; + + return $this; + } + + public function getTrainingTextTitle(): ?string + { + return $this->trainingTextTitle; + } + + public function setTrainingTextTitle(?string $trainingTextTitle): self + { + $this->trainingTextTitle = $trainingTextTitle; + + return $this; + } + + public function getWhyText(): ?string + { + return $this->whyText; + } + + public function setWhyText(?string $whyText): self + { + $this->whyText = $whyText; + + return $this; + } + + public function getWhyTitle(): ?string + { + return $this->whyTitle; + } + + public function setWhyTitle(?string $whyTitle): self + { + $this->whyTitle = $whyTitle; + + return $this; + } +} diff --git a/src/Entity/News.php b/src/Entity/News.php new file mode 100644 index 0000000..afd8194 --- /dev/null +++ b/src/Entity/News.php @@ -0,0 +1,277 @@ +id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getActive(): ?bool + { + return $this->active; + } + + public function setActive(?bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getRegionId(): ?int + { + return $this->regionId; + } + + public function setRegionId(?int $regionId): self + { + $this->regionId = $regionId; + + return $this; + } + + public function getAlias(): ?string + { + return $this->alias; + } + + public function setAlias(?string $alias): self + { + $this->alias = $alias; + + return $this; + } + + public function getAnons(): ?string + { + return $this->anons; + } + + public function setAnons(?string $anons): self + { + $this->anons = $anons; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): self + { + $this->content = $content; + + return $this; + } + + public function getUpdateAt(): ?\DateTimeInterface + { + return $this->updateAt; + } + + public function setUpdateAt(?\DateTimeInterface $updateAt): self + { + $this->updateAt = $updateAt; + + return $this; + } + + public function getLinkElPrice(): ?string + { + return $this->linkElPrice; + } + + public function setLinkElPrice(?string $linkElPrice): self + { + $this->linkElPrice = $linkElPrice; + + return $this; + } + + public function getShortName(): ?string + { + return $this->shortName; + } + + public function setShortName(?string $shortName): self + { + $this->shortName = $shortName; + + return $this; + } + + public function getTimer(): ?string + { + return $this->timer; + } + + public function setTimer(?string $timer): self + { + $this->timer = $timer; + + return $this; + } + + public function getTimerBg(): ?string + { + return $this->timerBg; + } + + public function setTimerBg(?string $timerBg): self + { + $this->timerBg = $timerBg; + + return $this; + } + + public function getFormOrder(): ?array + { + return $this->formOrder; + } + + public function setFormOrder(?array $formOrder): self + { + $this->formOrder = $formOrder; + + return $this; + } + + public function getLinkServices(): ?array + { + return $this->linkServices; + } + + public function setLinkServices(?array $linkServices): self + { + $this->linkServices = $linkServices; + + return $this; + } + + public function getLinkStaff(): ?array + { + return $this->linkStaff; + } + + public function setLinkStaff(?array $linkStaff): self + { + $this->linkStaff = $linkStaff; + + return $this; + } + + public function getPhotos(): ?array + { + return $this->photos; + } + + public function setPhotos(?array $photos): self + { + $this->photos = $photos; + + return $this; + } +} diff --git a/src/Entity/PriceDepartment.php b/src/Entity/PriceDepartment.php new file mode 100644 index 0000000..05c1e6d --- /dev/null +++ b/src/Entity/PriceDepartment.php @@ -0,0 +1,86 @@ +id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getGroupId(): ?int + { + return $this->groupId; + } + + public function setGroupId(?int $groupId): static + { + $this->groupId = $groupId; + + return $this; + } + + public function getDoctCount(): ?int + { + return $this->doctCount; + } + + public function setDoctCount(?int $doctCount): static + { + $this->doctCount = $doctCount; + + return $this; + } + + public function isViewInWeb(): ?bool + { + return $this->viewInWeb; + } + + public function setViewInWeb(bool $viewInWeb): static + { + $this->viewInWeb = $viewInWeb; + + return $this; + } +} diff --git a/src/Entity/PriceList.php b/src/Entity/PriceList.php new file mode 100644 index 0000000..c5bdb9e --- /dev/null +++ b/src/Entity/PriceList.php @@ -0,0 +1,246 @@ +id; + } + + public function getKodoper(): ?string + { + return $this->kodoper; + } + + public function setKodoper(?string $kodoper): static + { + $this->kodoper = $kodoper; + + return $this; + } + + public function getSchname(): ?string + { + return $this->schname; + } + + public function setSchname(?string $schname): static + { + $this->schname = $schname; + + return $this; + } + + public function getSpecname(): ?string + { + return $this->specname; + } + + public function setSpecname(?string $specname): static + { + $this->specname = $specname; + + return $this; + } + + public function getSpeccode(): ?string + { + return $this->speccode; + } + + public function setSpeccode(?string $speccode): static + { + $this->speccode = $speccode; + + return $this; + } + + public function getPriceInfo(): ?array + { + return $this->priceInfo; + } + + public function setPriceInfo(?array $priceInfo): static + { + $this->priceInfo = $priceInfo; + + return $this; + } + + public function getDiscprice(): ?string + { + return $this->discprice; + } + + public function setDiscprice(?string $discprice): static + { + $this->discprice = $discprice; + + return $this; + } + + public function getStructname(): ?string + { + return $this->structname; + } + + public function setStructname(?string $structname): static + { + $this->structname = $structname; + + return $this; + } + + public function getFname(): ?string + { + return $this->fname; + } + + public function setFname(?string $fname): static + { + $this->fname = $fname; + + return $this; + } + + public function getFilial(): ?int + { + return $this->filial; + } + + public function setFilial(?int $filial): static + { + $this->filial = $filial; + + return $this; + } + + public function getComment(): ?string + { + return $this->comment; + } + + public function setComment(?string $comment): static + { + $this->comment = $comment; + + return $this; + } + + public function getMediaId(): ?int + { + return $this->mediaId; + } + + public function setMediaId(?int $mediaId): static + { + $this->mediaId = $mediaId; + + return $this; + } + + public function getDateUpdate(): ?\DateTime + { + return $this->dateUpdate; + } + + public function setDateUpdate(\DateTime $dateUpdate): static + { + $this->dateUpdate = $dateUpdate; + + return $this; + } + + public function getGroupId(): ?int + { + return $this->groupId; + } + + public function setGroupId(?int $groupId): static + { + $this->groupId = $groupId; + + return $this; + } + + public function getDiscpercent(): ?string + { + return $this->discpercent; + } + + public function setDiscpercent(?string $discpercent): static + { + $this->discpercent = $discpercent; + + return $this; + } +} diff --git a/src/Entity/Promo.php b/src/Entity/Promo.php new file mode 100644 index 0000000..e612da9 --- /dev/null +++ b/src/Entity/Promo.php @@ -0,0 +1,277 @@ +id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getActive(): ?bool + { + return $this->active; + } + + public function setActive(?bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getRegionId(): ?int + { + return $this->regionId; + } + + public function setRegionId(?int $regionId): self + { + $this->regionId = $regionId; + + return $this; + } + + public function getAlias(): ?string + { + return $this->alias; + } + + public function setAlias(?string $alias): self + { + $this->alias = $alias; + + return $this; + } + + public function getAnons(): ?string + { + return $this->anons; + } + + public function setAnons(?string $anons): self + { + $this->anons = $anons; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): self + { + $this->content = $content; + + return $this; + } + + public function getUpdateAt(): ?\DateTimeInterface + { + return $this->updateAt; + } + + public function setUpdateAt(?\DateTimeInterface $updateAt): self + { + $this->updateAt = $updateAt; + + return $this; + } + + public function getClinics(): ?array + { + return $this->clinics; + } + + public function setClinics(?array $clinics): self + { + $this->clinics = $clinics; + + return $this; + } + + public function getTimer(): ?string + { + return $this->timer; + } + + public function setTimer(?string $timer): self + { + $this->timer = $timer; + + return $this; + } + + public function getTimerBg(): ?string + { + return $this->timerBg; + } + + public function setTimerBg(?string $timerBg): self + { + $this->timerBg = $timerBg; + + return $this; + } + + public function getShortName(): ?string + { + return $this->shortName; + } + + public function setShortName(?string $shortName): self + { + $this->shortName = $shortName; + + return $this; + } + + public function getLinkServices(): ?array + { + return $this->linkServices; + } + + public function setLinkServices(?array $linkServices): self + { + $this->linkServices = $linkServices; + + return $this; + } + + public function getLinkStaff(): ?array + { + return $this->linkStaff; + } + + public function setLinkStaff(?array $linkStaff): self + { + $this->linkStaff = $linkStaff; + + return $this; + } + + public function getPeriod(): ?string + { + return $this->period; + } + + public function setPeriod(?string $period): self + { + $this->period = $period; + + return $this; + } + + public function getPhotos(): ?array + { + return $this->photos; + } + + public function setPhotos(?array $photos): self + { + $this->photos = $photos; + + return $this; + } +} diff --git a/src/Entity/Record.php b/src/Entity/Record.php new file mode 100644 index 0000000..a0a8b43 --- /dev/null +++ b/src/Entity/Record.php @@ -0,0 +1,122 @@ +id; + } + + public function getSpecialistId(): ?int + { + return $this->specialistId; + } + + public function setSpecialistId(int $specialistId): self + { + $this->specialistId = $specialistId; + + return $this; + } + + public function getPhone(): ?string + { + return $this->phone; + } + + public function setPhone(string $phone): self + { + $this->phone = $phone; + + return $this; + } + + public function getCreateAt(): ?DateTimeInterface + { + return $this->createAt; + } + + public function setCreateAt(DateTimeInterface $createAt): self + { + $this->createAt = $createAt; + + return $this; + } + + public function getHash(): ?string + { + return $this->hash; + } + + public function setHash(string $hash): self + { + $this->hash = md5($hash); + + return $this; + } + + public function getReserve(): array + { + return $this->reserve; + } + + public function setReserve(array $reserve): self + { + $this->reserve = $reserve; + + return $this; + } + + public function getAlertSms(): ?AlertSms + { + return $this->alertSms; + } + + public function setAlertSms(?AlertSms $alertSms): self + { + // unset the owning side of the relation if necessary + if ($alertSms === null && $this->alertSms !== null) { + $this->alertSms->setRecord(null); + } + + // set the owning side of the relation if necessary + if ($alertSms !== null && $alertSms->getRecord() !== $this) { + $alertSms->setRecord($this); + } + + $this->alertSms = $alertSms; + + return $this; + } +} \ No newline at end of file diff --git a/src/Entity/Review.php b/src/Entity/Review.php new file mode 100644 index 0000000..145c99a --- /dev/null +++ b/src/Entity/Review.php @@ -0,0 +1,161 @@ +id; + } + + public function getActive(): ?bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getDateCreate(): ?\DateTimeInterface + { + return $this->dateCreate; + } + + public function setDateCreate(\DateTimeInterface $dateCreate): self + { + $this->dateCreate = $dateCreate; + + return $this; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(string $message): self + { + $this->message = $message; + + return $this; + } + + public function getAuthor(): ?string + { + return $this->author; + } + + public function setAuthor(string $author): self + { + $this->author = $author; + + return $this; + } + + public function getRating(): ?float + { + return $this->rating; + } + + public function setRating(float $rating): self + { + $this->rating = $rating; + + return $this; + } + + public function getSource(): ?string + { + return $this->source; + } + + public function setSource(?string $source): self + { + $this->source = $source; + + return $this; + } + + public function isActive(): ?bool + { + return $this->active; + } + + public function getSpecialist(): ?Specialist + { + return $this->specialist; + } + + public function setSpecialist(?Specialist $specialist): static + { + $this->specialist = $specialist; + + return $this; + } + + public function getExternalId(): ?int + { + return $this->externalId; + } + + public function setExternalId(?int $externalId): static + { + $this->externalId = $externalId; + + return $this; + } +} diff --git a/src/Entity/Schedule.php b/src/Entity/Schedule.php new file mode 100644 index 0000000..befb523 --- /dev/null +++ b/src/Entity/Schedule.php @@ -0,0 +1,249 @@ +id; + } + + public function getDcode(): ?string + { + return $this->dcode; + } + + public function setDcode(string $dcode): static + { + $this->dcode = $dcode; + + return $this; + } + + public function getDepartment(): ?string + { + return $this->department; + } + + public function setDepartment(string $department): static + { + $this->department = $department; + + return $this; + } + + public function getFilial(): ?int + { + return $this->filial; + } + + public function setFilial(int $filial): static + { + $this->filial = $filial; + + return $this; + } + + public function getSchedident(): ?string + { + return $this->schedident; + } + + public function setSchedident(string $schedident): static + { + $this->schedident = $schedident; + + return $this; + } + + public function getWorkdate(): ?\DateTimeInterface + { + return $this->workdate; + } + + public function setWorkdate(\DateTimeInterface $workdate): static + { + $this->workdate = $workdate; + + return $this; + } + + public function getRnum(): ?string + { + return $this->rnum; + } + + public function setRnum(string $rnum): static + { + $this->rnum = $rnum; + + return $this; + } + + public function getRfloor(): ?string + { + return $this->rfloor; + } + + public function setRfloor(?string $rfloor): static + { + $this->rfloor = $rfloor; + + return $this; + } + + public function getRbuilding(): ?string + { + return $this->rbuilding; + } + + public function setRbuilding(?string $rbuilding): static + { + $this->rbuilding = $rbuilding; + + return $this; + } + + public function getTime(): ?string + { + return $this->time; + } + + public function setTime(string $time): static + { + $this->time = $time; + + return $this; + } + + public function isFree(): ?bool + { + return $this->isFree; + } + + public function setIsFree(bool $isFree): static + { + $this->isFree = $isFree; + + return $this; + } + + public function isOnlineMode(): ?bool + { + return $this->onlineMode; + } + + public function setOnlineMode(bool $onlineMode): static + { + $this->onlineMode = $onlineMode; + + return $this; + } + + public function getQueryString(): ?string + { + return $this->queryString; + } + + public function setQueryString(?string $queryString): static + { + $this->queryString = $queryString; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeInterface $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function isIntervalIsFree(): ?bool + { + return $this->intervalIsFree; + } + + public function setIntervalIsFree(bool $intervalIsFree): static + { + $this->intervalIsFree = $intervalIsFree; + + return $this; + } + + public function getPriceInfo(): ?array + { + return $this->priceInfo; + } + + public function setPriceInfo(?array $priceInfo): static + { + $this->priceInfo = $priceInfo; + + return $this; + } +} \ No newline at end of file diff --git a/src/Entity/SiteService.php b/src/Entity/SiteService.php new file mode 100644 index 0000000..b67737f --- /dev/null +++ b/src/Entity/SiteService.php @@ -0,0 +1,933 @@ +id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getActive(): ?bool + { + return $this->active; + } + + public function setActive(?bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getRegionId(): ?int + { + return $this->regionId; + } + + public function setRegionId(?int $regionId): self + { + $this->regionId = $regionId; + + return $this; + } + + public function getAlias(): ?string + { + return $this->alias; + } + + public function setAlias(?string $alias): self + { + $this->alias = $alias; + + return $this; + } + + public function getAnons(): ?string + { + return $this->anons; + } + + public function setAnons(?string $anons): self + { + $this->anons = $anons; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): self + { + $this->content = $content; + + return $this; + } + + public function getUpdateAt(): ?\DateTimeInterface + { + return $this->updateAt; + } + + public function setUpdateAt(?\DateTimeInterface $updateAt): self + { + $this->updateAt = $updateAt; + + return $this; + } + + public function getLinkVideoreviews(): ?array + { + return $this->linkVideoreviews; + } + + public function setLinkVideoreviews(?array $linkVideoreviews): self + { + $this->linkVideoreviews = $linkVideoreviews; + + return $this; + } + + public function getPreviewImg(): ?string + { + return $this->previewImg; + } + + public function setPreviewImg(?string $previewImg): self + { + $this->previewImg = $previewImg; + + return $this; + } + + public function getFaq(): ?array + { + return $this->faq; + } + + public function setFaq(?array $faq): self + { + $this->faq = $faq; + + return $this; + } + + public function getPartPrice(): ?string + { + return $this->partPrice; + } + + public function setPartPrice(?string $partPrice): self + { + $this->partPrice = $partPrice; + + return $this; + } + + public function getPokazaniya(): ?string + { + return $this->pokazaniya; + } + + public function setPokazaniya(?string $pokazaniya): self + { + $this->pokazaniya = $pokazaniya; + + return $this; + } + + public function getPreparation(): ?string + { + return $this->preparation; + } + + public function setPreparation(?string $preparation): self + { + $this->preparation = $preparation; + + return $this; + } + + public function getProtivopokazaniya(): ?string + { + return $this->protivopokazaniya; + } + + public function setProtivopokazaniya(?string $protivopokazaniya): self + { + $this->protivopokazaniya = $protivopokazaniya; + + return $this; + } + + public function getHideSignBtn(): ?array + { + return $this->hideSignBtn; + } + + public function setHideSignBtn(?array $hideSignBtn): self + { + $this->hideSignBtn = $hideSignBtn; + + return $this; + } + + public function getQuiz(): ?array + { + return $this->quiz; + } + + public function setQuiz(?array $quiz): self + { + $this->quiz = $quiz; + + return $this; + } + + public function getTags(): ?array + { + return $this->tags; + } + + public function setTags(?array $tags): self + { + $this->tags = $tags; + + return $this; + } + + public function getTagsImportant(): ?array + { + return $this->tagsImportant; + } + + public function setTagsImportant(?array $tagsImportant): self + { + $this->tagsImportant = $tagsImportant; + + return $this; + } + + public function getBannerImg(): ?string + { + return $this->bannerImg; + } + + public function setBannerImg(?string $bannerImg): self + { + $this->bannerImg = $bannerImg; + + return $this; + } + + public function getBannerImgM(): ?string + { + return $this->bannerImgM; + } + + public function setBannerImgM(?string $bannerImgM): self + { + $this->bannerImgM = $bannerImgM; + + return $this; + } + + public function getBannerImgUrl(): ?string + { + return $this->bannerImgUrl; + } + + public function setBannerImgUrl(?string $bannerImgUrl): self + { + $this->bannerImgUrl = $bannerImgUrl; + + return $this; + } + + public function getClinics(): ?array + { + return $this->clinics; + } + + public function setClinics(?array $clinics): self + { + $this->clinics = $clinics; + + return $this; + } + + public function getDownloadFile(): ?string + { + return $this->downloadFile; + } + + public function setDownloadFile(?string $downloadFile): self + { + $this->downloadFile = $downloadFile; + + return $this; + } + + public function getFullWidthBanner(): ?string + { + return $this->fullWidthBanner; + } + + public function setFullWidthBanner(?string $fullWidthBanner): self + { + $this->fullWidthBanner = $fullWidthBanner; + + return $this; + } + + public function getStaffUp(): ?array + { + return $this->staffUp; + } + + public function setStaffUp(?array $staffUp): self + { + $this->staffUp = $staffUp; + + return $this; + } + + public function getAdvantages(): ?array + { + return $this->advantages; + } + + public function setAdvantages(?array $advantages): self + { + $this->advantages = $advantages; + + return $this; + } + + public function getHidePicture(): ?int + { + return $this->hidePicture; + } + + public function setHidePicture(?int $hidePicture): self + { + $this->hidePicture = $hidePicture; + + return $this; + } + + public function getKodUslug(): ?string + { + return $this->kodUslug; + } + + public function setKodUslug(?string $kodUslug): self + { + $this->kodUslug = $kodUslug; + + return $this; + } + + public function getLinkPrice(): ?string + { + return $this->linkPrice; + } + + public function setLinkPrice(?string $linkPrice): self + { + $this->linkPrice = $linkPrice; + + return $this; + } + + public function getPhotosTitle(): ?string + { + return $this->photosTitle; + } + + public function setPhotosTitle(?string $photosTitle): self + { + $this->photosTitle = $photosTitle; + + return $this; + } + + public function getSaleId(): ?array + { + return $this->saleId; + } + + public function setSaleId(?array $saleId): self + { + $this->saleId = $saleId; + + return $this; + } + + public function getSortStaff(): ?array + { + return $this->sortStaff; + } + + public function setSortStaff(?array $sortStaff): self + { + $this->sortStaff = $sortStaff; + + return $this; + } + + public function getContraindicationsList(): ?string + { + return $this->contraindicationsList; + } + + public function setContraindicationsList(?string $contraindicationsList): self + { + $this->contraindicationsList = $contraindicationsList; + + return $this; + } + + public function getCustomBlockText(): ?string + { + return $this->customBlockText; + } + + public function setCustomBlockText(?string $customBlockText): self + { + $this->customBlockText = $customBlockText; + + return $this; + } + + public function getCustomBlockText2(): ?string + { + return $this->customBlockText2; + } + + public function setCustomBlockText2(?string $customBlockText2): self + { + $this->customBlockText2 = $customBlockText2; + + return $this; + } + + public function getCustomBlockTitle(): ?string + { + return $this->customBlockTitle; + } + + public function setCustomBlockTitle(?string $customBlockTitle): self + { + $this->customBlockTitle = $customBlockTitle; + + return $this; + } + + public function getCustomBlockTitle2(): ?string + { + return $this->customBlockTitle2; + } + + public function setCustomBlockTitle2(?string $customBlockTitle2): self + { + $this->customBlockTitle2 = $customBlockTitle2; + + return $this; + } + + public function getIndicationsList(): ?string + { + return $this->indicationsList; + } + + public function setIndicationsList(?string $indicationsList): self + { + $this->indicationsList = $indicationsList; + + return $this; + } + + public function getLinkArticlesServices(): ?array + { + return $this->linkArticlesServices; + } + + public function setLinkArticlesServices(?array $linkArticlesServices): self + { + $this->linkArticlesServices = $linkArticlesServices; + + return $this; + } + + public function getPlusList(): ?string + { + return $this->plusList; + } + + public function setPlusList(?string $plusList): self + { + $this->plusList = $plusList; + + return $this; + } + + public function getPlusText(): ?string + { + return $this->plusText; + } + + public function setPlusText(?string $plusText): self + { + $this->plusText = $plusText; + + return $this; + } + + public function getPlusTitle(): ?string + { + return $this->plusTitle; + } + + public function setPlusTitle(?string $plusTitle): self + { + $this->plusTitle = $plusTitle; + + return $this; + } + + public function getPrepareTitle(): ?string + { + return $this->prepareTitle; + } + + public function setPrepareTitle(?string $prepareTitle): self + { + $this->prepareTitle = $prepareTitle; + + return $this; + } + + public function getProcessText(): ?string + { + return $this->processText; + } + + public function setProcessText(?string $processText): self + { + $this->processText = $processText; + + return $this; + } + + public function getProcessTitle(): ?string + { + return $this->processTitle; + } + + public function setProcessTitle(?string $processTitle): self + { + $this->processTitle = $processTitle; + + return $this; + } + + public function getServicesList(): ?string + { + return $this->servicesList; + } + + public function setServicesList(?string $servicesList): self + { + $this->servicesList = $servicesList; + + return $this; + } + + public function getServicesPhotos(): ?array + { + return $this->servicesPhotos; + } + + public function setServicesPhotos(?array $servicesPhotos): self + { + $this->servicesPhotos = $servicesPhotos; + + return $this; + } + + public function getServicesTitle(): ?string + { + return $this->servicesTitle; + } + + public function setServicesTitle(?string $servicesTitle): self + { + $this->servicesTitle = $servicesTitle; + + return $this; + } + + public function getTextUp(): ?string + { + return $this->textUp; + } + + public function setTextUp(?string $textUp): self + { + $this->textUp = $textUp; + + return $this; + } + + public function getTrainingText(): ?string + { + return $this->trainingText; + } + + public function setTrainingText(?string $trainingText): self + { + $this->trainingText = $trainingText; + + return $this; + } + + public function getWhyText(): ?string + { + return $this->whyText; + } + + public function setWhyText(?string $whyText): self + { + $this->whyText = $whyText; + + return $this; + } + + public function getWhyTitle(): ?string + { + return $this->whyTitle; + } + + public function setWhyTitle(?string $whyTitle): self + { + $this->whyTitle = $whyTitle; + + return $this; + } + + public function getLinkFaq(): ?array + { + return $this->linkFaq; + } + + public function setLinkFaq(?array $linkFaq): self + { + $this->linkFaq = $linkFaq; + + return $this; + } + + public function getLinkServices(): ?array + { + return $this->linkServices; + } + + public function setLinkServices(?array $linkServices): self + { + $this->linkServices = $linkServices; + + return $this; + } + + public function getLinkStaff(): ?array + { + return $this->linkStaff; + } + + public function setLinkStaff(?array $linkStaff): self + { + $this->linkStaff = $linkStaff; + + return $this; + } + + public function getPhotos(): ?array + { + return $this->photos; + } + + public function setPhotos(?array $photos): self + { + $this->photos = $photos; + + return $this; + } + +} diff --git a/src/Entity/Specialist.php b/src/Entity/Specialist.php new file mode 100644 index 0000000..38d8501 --- /dev/null +++ b/src/Entity/Specialist.php @@ -0,0 +1,720 @@ + + */ + #[Groups(['specialist:detail'])] + #[ORM\OneToMany( + targetEntity: SpecialistDocs::class, + mappedBy: 'specialist', + cascade: ['remove'], + orphanRemoval: true + )] + private Collection $specialistDocs; + + /** + * @var Collection + */ + #[Groups(['specialist:detail'])] + #[ORM\ManyToMany(targetEntity: Stock::class, mappedBy: 'specialist')] + private Collection $stocks; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['specialist:write', 'specialist:read', 'specialist:detail', 'to.specialist:read'])] + private ?string $degree = null; + + #[ORM\Column] + #[Groups(['specialist:write', 'specialist:read', 'specialist:detail', 'to.specialist:read'])] + private ?bool $kiosk = null; + + #[ORM\Column(type: Types::JSONB, nullable: true)] + #[Groups(['specialist:write', 'specialist:read', 'specialist:detail', 'to.specialist:read'])] + private mixed $filials = null; + + #[ORM\Column(nullable: true)] + #[Groups(['specialist:write', 'specialist:read', 'specialist:detail', 'to.specialist:read'])] + private ?bool $acceptsDms = null; + + #[ORM\Column(type: Types::JSONB, nullable: true)] + #[Groups(['specialist:write', 'specialist:read', 'specialist:detail', 'to.specialist:read'])] + private mixed $specialities = null; + + public function __construct() + { + $this->locations = new ArrayCollection(); + $this->specialistDocs = new ArrayCollection(); + $this->stocks = new ArrayCollection(); + $this->reviews = new ArrayCollection(); + } + + #[ORM\PreFlush] + public function preFlush() + { + $this->updateAt = new \DateTime(); + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + #[SerializedName('fullName')] + #[Groups(['specialist:read', 'specialist:detail', 'to.specialist:read'])] + public function getFullName(): array + { + $fio = array_map('trim', explode(' ', trim($this->name))); + $result = [ + 'lastName' => '', + 'firstName' => '', + 'middleName' => '' + ]; + + foreach ($fio as $index => $part) { + if ($index === 0) { + $result['lastName'] = $part; + } elseif ($index === 1) { + $result['firstName'] = $part; + } elseif ($index === 2) { + $result['middleName'] = $part; + break; + } + } + + return $result; + } + + public function getPreviewPicture(): ?string + { + return $this->previewPicture; + } + + public function setPreviewPicture(?string $previewPicture): self + { + $this->previewPicture = $previewPicture; + + return $this; + } + + public function getActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): self + { + $this->active = $active; + + return $this; + } + + public function getDisplaySchedule(): bool + { + return $this->displaySchedule; + } + + public function setDisplaySchedule(bool $displaySchedule): self + { + $this->displaySchedule = $displaySchedule; + + return $this; + } + + public function getDcodes(): ?string + { + return $this->dcodes; + } + + public function setDcodes(?string $dcodes): self + { + $this->dcodes = $dcodes; + + return $this; + } + + public function getRegionId(): ?int + { + return $this->regionId; + } + + public function setRegionId(?int $regionId): self + { + $this->regionId = $regionId; + + return $this; + } + + public function getAlias(): string + { + return $this->alias; + } + + public function setAlias(string $alias): self + { + $this->alias = $alias; + + return $this; + } + + public function getPost(): ?string + { + return $this->post; + } + + public function setPost(?string $post): self + { + $this->post = $post; + + return $this; + } + + public function getExperience(): ?string + { + return $this->experience; + } + + public function setExperience(?string $experience): self + { + $this->experience = $experience; + + return $this; + } + + public function getSType(): ?int + { + return $this->sType; + } + + public function setSType(?int $sType): self + { + $this->sType = $sType; + + return $this; + } + + public function getUpdateAt(): \DateTimeInterface + { + return $this->updateAt; + } + public function getLocations(): Collection + { + return $this->locations; + } + + public function getAnons(): ?string + { + return $this->anons; + } + + public function setAnons(?string $anons): static + { + $this->anons = $anons; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): static + { + $this->content = $content; + + return $this; + } + + public function getTags(): ?array + { + return $this->tags; + } + + public function setTags(?array $tags): static + { + $this->tags = $tags; + + return $this; + } + + public function getHighlightedTags(): ?array + { + return $this->highlightedTags; + } + + public function setHighlightedTags(?array $highlightedTags): static + { + $this->highlightedTags = $highlightedTags; + + return $this; + } + + public function getVideo(): ?string + { + return $this->video; + } + + public function setVideo(?string $video): static + { + $this->video = $video; + + return $this; + } + + public function getVideoVertical(): ?string + { + return $this->videoVertical; + } + + public function setVideoVertical(?string $videoVertical): static + { + $this->videoVertical = $videoVertical; + + return $this; + } + + public function getScheduleText(): ?string + { + return $this->scheduleText; + } + + public function setScheduleText(?string $scheduleText): static + { + $this->scheduleText = $scheduleText; + + return $this; + } + + public function getCategory(): ?string + { + return $this->category; + } + + public function setCategory(?string $category): static + { + $this->category = $category; + + return $this; + } + + public function getPatientAge(): ?int + { + return $this->patientAge; + } + + public function setPatientAge(?int $patientAge): static + { + $this->patientAge = $patientAge; + + return $this; + } + + public function getKodoper(): ?array + { + return $this->kodoper; + } + + public function setKodoper(?array $kodoper): static + { + $this->kodoper = $kodoper; + + return $this; + } + + public function isOnlyOnlineMode(): ?bool + { + return $this->onlyOnlineMode; + } + + public function setOnlyOnlineMode(?bool $onlyOnlineMode): static + { + $this->onlyOnlineMode = $onlyOnlineMode; + + return $this; + } + + public function isProdoctor(): ?bool + { + return $this->prodoctor; + } + + public function setProdoctor(?bool $prodoctor): static + { + $this->prodoctor = $prodoctor; + + return $this; + } + + public function getProdoctorText(): ?string + { + return $this->prodoctorText; + } + + public function setProdoctorText(?string $prodoctorText): static + { + $this->prodoctorText = $prodoctorText; + + return $this; + } + + public function getProdoctorLink(): ?string + { + return $this->prodoctorLink; + } + + public function setProdoctorLink(?string $prodoctorLink): static + { + $this->prodoctorLink = $prodoctorLink; + + return $this; + } + + /** + * @return Collection + */ + public function getSpecialistDocs(): Collection + { + return $this->specialistDocs; + } + + public function addSpecialistDoc(SpecialistDocs $specialistDoc): static + { + if (!$this->specialistDocs->contains($specialistDoc)) { + $this->specialistDocs->add($specialistDoc); + $specialistDoc->setSpecialist($this); + } + + return $this; + } + + public function removeSpecialistDoc(SpecialistDocs $specialistDoc): static + { + if ($this->specialistDocs->removeElement($specialistDoc)) { + // set the owning side to null (unless already changed) + if ($specialistDoc->getSpecialist() === $this) { + $specialistDoc->setSpecialist(null); + } + } + + return $this; + } + + /** + * @return Collection + */ + public function getStocks(): Collection + { + return $this->stocks; + } + + public function addStock(Stock $stock): static + { + if (!$this->stocks->contains($stock)) { + $this->stocks->add($stock); + $stock->addSpecialist($this); + } + + return $this; + } + + public function removeStock(Stock $stock): static + { + if ($this->stocks->removeElement($stock)) { + $stock->removeSpecialist($this); + } + + return $this; + } + + public function isActive(): ?bool + { + return $this->active; + } + + public function isDisplaySchedule(): ?bool + { + return $this->displaySchedule; + } + + public function setUpdateAt(\DateTime $updateAt): static + { + $this->updateAt = $updateAt; + + return $this; + } + + public function addLocation(Location $location): static + { + if (!$this->locations->contains($location)) { + $this->locations->add($location); + $location->setSpecialist($this); + } + + return $this; + } + + public function removeLocation(Location $location): static + { + if ($this->locations->removeElement($location)) { + // set the owning side to null (unless already changed) + if ($location->getSpecialist() === $this) { + $location->setSpecialist(null); + } + } + + return $this; + } + + /** + * @return Collection + */ + + public function getReviews(): Collection + { + return $this->reviews; + } + + public function addReview(Review $review): static + { + if (!$this->reviews->contains($review)) { + $this->reviews->add($review); + $review->setSpecialist($this); + } + + return $this; + } + + public function removeReview(Review $review): static + { + if ($this->reviews->removeElement($review)) { + // set the owning side to null (unless already changed) + if ($review->getSpecialist() === $this) { + $review->setSpecialist(null); + } + } + + return $this; + } + + #[SerializedName('reviewsCount')] + #[Groups(['specialist:read', 'specialist:detail', 'to.specialist:read'])] + public function getReviewsCount(): int + { + return count($this->reviews); + } + + public function getDegree(): ?string + { + return $this->degree; + } + + public function setDegree(?string $degree): static + { + $this->degree = $degree; + + return $this; + } + + public function isKiosk(): ?bool + { + return $this->kiosk; + } + + public function setKiosk(bool $kiosk): static + { + $this->kiosk = $kiosk; + + return $this; + } + + public function getFilials(): mixed + { + return $this->filials; + } + + public function setFilials(mixed $filials): static + { + $this->filials = $filials; + + return $this; + } + + public function isAcceptsDms(): ?bool + { + return $this->acceptsDms; + } + + public function setAcceptsDms(?bool $acceptsDms): static + { + $this->acceptsDms = $acceptsDms; + + return $this; + } + + public function getSpecialities(): mixed + { + return $this->specialities; + } + + public function setSpecialities(mixed $specialities): static + { + $this->specialities = $specialities; + + return $this; + } +} diff --git a/src/Entity/SpecialistDcodeDescription.php b/src/Entity/SpecialistDcodeDescription.php new file mode 100644 index 0000000..8a7d4fc --- /dev/null +++ b/src/Entity/SpecialistDcodeDescription.php @@ -0,0 +1,121 @@ +createAt = $now; + $this->updateAt = $now; + } + + #[ORM\PreUpdate] + public function onPreUpdate(): void + { + $this->updateAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getDcode(): ?int + { + return $this->dcode; + } + + public function setDcode(?int $dcode): static + { + $this->dcode = $dcode; + + return $this; + } + + public function getDepartment(): ?int + { + return $this->department; + } + + public function setDepartment(?int $department): static + { + $this->department = $department; + + return $this; + } + + public function getSpecialist(): ?Specialist + { + return $this->specialist; + } + + public function setSpecialist(?Specialist $specialist): static + { + $this->specialist = $specialist; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): static + { + $this->content = $content; + + return $this; + } + + public function getCreateAt(): ?\DateTimeImmutable + { + return $this->createAt; + } + + public function getUpdateAt(): ?\DateTimeImmutable + { + return $this->updateAt; + } +} diff --git a/src/Entity/SpecialistDocs.php b/src/Entity/SpecialistDocs.php new file mode 100644 index 0000000..f75cc91 --- /dev/null +++ b/src/Entity/SpecialistDocs.php @@ -0,0 +1,122 @@ +id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getPicture(): ?string + { + return $this->picture; + } + + public function setPicture(?string $picture): static + { + $this->picture = $picture; + + return $this; + } + + public function isActive(): ?bool + { + return $this->active; + } + + public function setActive(bool $active): static + { + $this->active = $active; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + + public function getSpecialist(): ?Specialist + { + return $this->specialist; + } + + public function setSpecialist(?Specialist $specialist): static + { + $this->specialist = $specialist; + + return $this; + } +} diff --git a/src/Entity/Stock.php b/src/Entity/Stock.php new file mode 100644 index 0000000..f4e2972 --- /dev/null +++ b/src/Entity/Stock.php @@ -0,0 +1,158 @@ + + */ + #[Groups(['stock:read'])] + #[ORM\ManyToMany(targetEntity: Specialist::class, inversedBy: 'stocks')] + private Collection $specialist; + + public function __construct() + { + $this->specialist = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(string $content): static + { + $this->content = $content; + + return $this; + } + + public function getPicture(): ?string + { + return $this->picture; + } + + public function setPicture(?string $picture): static + { + $this->picture = $picture; + + return $this; + } + + public function getAnons(): ?string + { + return $this->anons; + } + + public function setAnons(?string $anons): static + { + $this->anons = $anons; + + return $this; + } + + public function getStartDate(): ?\DateTime + { + return $this->startDate; + } + + public function setStartDate(\DateTime $startDate): static + { + $this->startDate = $startDate; + + return $this; + } + + public function getEndDate(): ?\DateTime + { + return $this->endDate; + } + + public function setEndDate(\DateTime $endDate): static + { + $this->endDate = $endDate; + + return $this; + } + + /** + * @return Collection + */ + public function getSpecialist(): Collection + { + return $this->specialist; + } + + public function addSpecialist(Specialist $specialist): static + { + if (!$this->specialist->contains($specialist)) { + $this->specialist->add($specialist); + } + + return $this; + } + + public function removeSpecialist(Specialist $specialist): static + { + $this->specialist->removeElement($specialist); + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..b485d03 --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,192 @@ + $this->getUid(), + 'bdate' => $this->getBirthDate(), + 'roles' => $this->getRoles(), + 'regionId' => $this->getRegionId(), + 'loggedIn' => $this->getLoggedIn() + ]; + } + + public function getId(): int + { + return $this->id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): self + { + $this->email = $email; + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + return $this; + } + + /** + * @see UserInterface + */ + public function eraseCredentials(): void + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } + + public function getSalt(): ?string + { + return null; + } + + public function getUsername(): string + { + return (string) $this->email; + } + + public function getUid(): int + { + return $this->uid; + } + + public function setUid(int $uid): self + { + $this->uid = $uid; + return $this; + } + + public function getRegionId(): ?int + { + return $this->regionId; + } + + public function setRegionId(?int $regionId): self + { + $this->regionId = $regionId; + return $this; + } + + public function getBirthDate(): ?\DateTimeInterface + { + return $this->birthDate; + } + + public function setBirthDate(\DateTimeInterface $birthDate): static + { + $this->birthDate = $birthDate; + + return $this; + } + + public function getLoggedIn(): ?\DateTimeInterface + { + return $this->loggedIn; + } + + public function setLoggedIn(?\DateTimeInterface $loggedIn): static + { + $this->loggedIn = $loggedIn; + + return $this; + } + + public function updateLoggedIn(): static + { + $this->loggedIn = new \DateTime(); + + return $this; + } + + /** + * @deprecated This method is kept for backward compatibility. The fullName field has been removed. + * @return null + */ + public function getFullName(): ?string + { + return null; + } +} \ No newline at end of file diff --git a/src/Entity/WebGetDocinfo.php b/src/Entity/WebGetDocinfo.php new file mode 100644 index 0000000..7e35909 --- /dev/null +++ b/src/Entity/WebGetDocinfo.php @@ -0,0 +1,284 @@ +id; + } + + public function setId(?int $id): self + { + $this->id = $id; + return $this; + } + + public function getSourceTable(): ?string + { + return $this->sourceTable; + } + + public function setSourceTable(?string $sourceTable): self + { + $this->sourceTable = $sourceTable; + return $this; + } + + public function getDocName(): ?string + { + return $this->docName; + } + + public function setDocName(?string $docName): self + { + $this->docName = $docName; + return $this; + } + + public function getDocPost(): ?string + { + return $this->docPost; + } + + public function setDocPost(?string $docPost): self + { + $this->docPost = $docPost; + return $this; + } + + public function getFilial(): ?int + { + return $this->filial; + } + + public function setFilial(?int $filial): self + { + $this->filial = $filial; + return $this; + } + + public function getViewinweb(): ?int + { + return $this->viewinweb; + } + + public function setViewinweb(?int $viewinweb): self + { + $this->viewinweb = $viewinweb; + return $this; + } + + public function getDepnum(): ?int + { + return $this->depnum; + } + + public function setDepnum(?int $depnum): self + { + $this->depnum = $depnum; + return $this; + } + + public function getFirstSchid(): ?int + { + return $this->firstSchid; + } + + public function setFirstSchid(?int $firstSchid): self + { + $this->firstSchid = $firstSchid; + return $this; + } + + public function getSecondSchid(): ?int + { + return $this->secondSchid; + } + + public function setSecondSchid(?int $secondSchid): self + { + $this->secondSchid = $secondSchid; + return $this; + } + + public function getAcceptsDms(): ?int + { + return $this->acceptsDms; + } + + public function setAcceptsDms(?int $acceptsDms): self + { + $this->acceptsDms = $acceptsDms; + return $this; + } + + public function getAnons(): ?string + { + return $this->anons; + } + + public function setAnons(?string $anons): self + { + $this->anons = $anons; + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): self + { + $this->content = $content; + return $this; + } + + public function getExperience(): ?\DateTimeInterface + { + return $this->experience; + } + + public function setExperience(?\DateTimeInterface $experience): self + { + $this->experience = $experience; + return $this; + } + + public function getCategory(): ?int + { + return $this->category; + } + + public function setCategory(?int $category): self + { + $this->category = $category; + return $this; + } + + public function getDegree(): ?string + { + return $this->degree; + } + + public function setDegree(?string $degree): self + { + $this->degree = $degree; + return $this; + } + + public function getPatientBage(): ?int + { + return $this->patientBage; + } + + public function setPatientBage(?int $patientBage): self + { + $this->patientBage = $patientBage; + return $this; + } + + public function getPatientFage(): ?int + { + return $this->patientFage; + } + + public function setPatientFage(?int $patientFage): self + { + $this->patientFage = $patientFage; + return $this; + } + + public function getUpdatedAt(): ?\DateTimeInterface + { + return $this->updatedAt; + } + + public function setUpdatedAt(?\DateTimeInterface $updatedAt): self + { + $this->updatedAt = $updatedAt; + return $this; + } +} diff --git a/src/Entity/WidgetForm.php b/src/Entity/WidgetForm.php new file mode 100644 index 0000000..bad9723 --- /dev/null +++ b/src/Entity/WidgetForm.php @@ -0,0 +1,83 @@ +widgetFormInputs = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * @return Collection + */ + public function getWidgetFormInputs(): Collection + { + $criteria = Criteria::create() + ->orderBy(['sort' => Criteria::ASC]); + + return $this->widgetFormInputs->matching($criteria); + } + + public function addWidgetFormInput(WidgetFormInput $widgetFormInput): self + { + if (!$this->widgetFormInputs->contains($widgetFormInput)) { + $this->widgetFormInputs->add($widgetFormInput); + $widgetFormInput->setWidgetForm($this); + } + + return $this; + } + + public function removeWidgetFormInput(WidgetFormInput $widgetFormInput): self + { + if ($this->widgetFormInputs->removeElement($widgetFormInput)) { + if ($widgetFormInput->getWidgetForm() === $this) { + $widgetFormInput->setWidgetForm(null); + } + } + + return $this; + } +} \ No newline at end of file diff --git a/src/Entity/WidgetFormInput.php b/src/Entity/WidgetFormInput.php new file mode 100644 index 0000000..b718e84 --- /dev/null +++ b/src/Entity/WidgetFormInput.php @@ -0,0 +1,96 @@ +id; + } + + public function getText(): ?string + { + return $this->text; + } + + public function setText(string $text): self + { + $this->text = $text; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): self + { + $this->type = $type; + + return $this; + } + + public function getBitrix24Id(): ?string + { + return $this->bitrix24Id; + } + + public function setBitrix24Id(string $bitrix24Id): self + { + $this->bitrix24Id = $bitrix24Id; + + return $this; + } + + public function getWidgetForm(): ?WidgetForm + { + return $this->widgetForm; + } + + public function setWidgetForm(?WidgetForm $widgetForm): self + { + $this->widgetForm = $widgetForm; + + return $this; + } + + public function getSort(): ?int + { + return $this->sort; + } + + public function setSort(int $sort): self + { + $this->sort = $sort; + + return $this; + } +} \ No newline at end of file diff --git a/src/EventListener/JsonExceptionHandler.php b/src/EventListener/JsonExceptionHandler.php new file mode 100644 index 0000000..5c640f5 --- /dev/null +++ b/src/EventListener/JsonExceptionHandler.php @@ -0,0 +1,41 @@ +getThrowable(); + + // Обрабатываем только определенные типы исключений + if (!$exception instanceof NotFoundHttpException && + !$exception instanceof BadRequestHttpException) { + return; + } + + // Определяем тип ошибки и создаем соответствующий ответ + if ($exception instanceof NotFoundHttpException) { + $response = new JsonResponse([ + 'error' => 'Not Found', + 'message' => 'The requested resource was not found', + 'code' => 404, + 'status' => 'error' + ], 404); + } elseif ($exception instanceof BadRequestHttpException) { + $response = new JsonResponse([ + 'error' => 'Bad Request', + 'message' => $exception->getMessage() ?: 'Invalid request parameters', + 'code' => 400, + 'status' => 'error' + ], 400); + } + + $event->setResponse($response); + } +} \ No newline at end of file diff --git a/src/Form/WidgetFormInputType.php b/src/Form/WidgetFormInputType.php new file mode 100644 index 0000000..30b3bfd --- /dev/null +++ b/src/Form/WidgetFormInputType.php @@ -0,0 +1,39 @@ +add('text') + ->add('type', ChoiceType::class, [ + 'choices' => [ + 'Строка' => 'text', + 'Телефон' => 'phone', + 'Календаль' => 'date', + 'Коментарий' => 'textarea', + ], + 'empty_data' => null, + 'required' => false + ]) + ->add('bitrix24Id') + ->add('sort') + + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => WidgetFormInput::class, + ]); + } +} diff --git a/src/Form/WidgetFormType.php b/src/Form/WidgetFormType.php new file mode 100644 index 0000000..4d71d3a --- /dev/null +++ b/src/Form/WidgetFormType.php @@ -0,0 +1,25 @@ +add('name') + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => WidgetForm::class, + ]); + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000..779cd1f --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,11 @@ +dto; + } +} diff --git a/src/Message/GetScheduleMessage.php b/src/Message/GetScheduleMessage.php new file mode 100644 index 0000000..8b6f424 --- /dev/null +++ b/src/Message/GetScheduleMessage.php @@ -0,0 +1,31 @@ +queryString = $queryString; + $this->isOnlineMode = $isOnlineMode; + } + + public function getQueryString(): string + { + return $this->queryString; + } + + public function isOnlineMode(): bool + { + return $this->isOnlineMode; + } + + public function getMessageKey(): string + { + return sprintf('%s_%s', $this->queryString, $this->isOnlineMode ? 'online' : 'offline'); + } +} \ No newline at end of file diff --git a/src/Message/GetSpecialistPictureMessage.php b/src/Message/GetSpecialistPictureMessage.php new file mode 100644 index 0000000..0259c20 --- /dev/null +++ b/src/Message/GetSpecialistPictureMessage.php @@ -0,0 +1,18 @@ +spesialistId; + } +} diff --git a/src/MessageHandler/GetAnonymousReserveRequestMessageHandler.php b/src/MessageHandler/GetAnonymousReserveRequestMessageHandler.php new file mode 100644 index 0000000..c52255b --- /dev/null +++ b/src/MessageHandler/GetAnonymousReserveRequestMessageHandler.php @@ -0,0 +1,75 @@ +startTime) { + return 0; + } + + return (int)round((microtime(true) - $this->startTime) * 1000); + } + + public function __invoke(GetAnonymousReserveRequestMessage $message): array + { + $this->logger->info('[GetAnonymousReserve] Starting processing request', [ + 'query' => $message->getDto(), + 'message_class' => GetAnonymousReserveRequestMessage::class + ]); + + try { + $startTime = microtime(true); + + $result = $this->clientService->anonymousReserve($message->getDto()); + + + $this->logger->info('[GetAnonymousReserve] Successfully processed request', [ + 'query' => $message->getDto(), + 'duration_ms' => $this->getDuration(), + 'result_count' => count($result) + ]); + + return $result; + + } catch (HttpExceptionInterface $e) { + $exception = [ + 'query' => $message->getDto(), + 'duration_ms' => $this->getDuration(), + 'status_code' => $e->getResponse()->getStatusCode(), + 'error' => $e->getMessage(), + 'response' => $e->getResponse()->getContent(false) + ]; + + $this->logger->error('[GetAnonymousReserve] API request failed', $exception); + + return $exception; + + } catch (\Exception $e) { + $exception = [ + 'query' => $message->getDto(), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]; + + $this->logger->critical('[GetAnonymousReserve] Unexpected error', $exception); + return $exception; + } + } +} \ No newline at end of file diff --git a/src/MessageHandler/GetScheduleMessageHandler.php b/src/MessageHandler/GetScheduleMessageHandler.php new file mode 100644 index 0000000..d891a1a --- /dev/null +++ b/src/MessageHandler/GetScheduleMessageHandler.php @@ -0,0 +1,95 @@ +performanceTracker->start(); + $queryString = $message->getQueryString(); + $isOnlineMode = $message->isOnlineMode(); + + try { + // Проверяем кэш + $cachedResult = $this->cacheService->getCachedSchedule($queryString, $isOnlineMode); + if ($cachedResult !== null) { + $this->performanceTracker->stop(); + return $this->createSuccessResponse($cachedResult, 'cached', $isOnlineMode); + } + + // Получаем данные из API + // Передаем флаг onlineMode в клиент для формирования правильного запроса + $apiResult = $this->clientService->getSchedule($queryString, $isOnlineMode); + + // Сохраняем в кэш + $this->cacheService->saveSchedule($apiResult, $queryString, $isOnlineMode); + + $this->performanceTracker->stop(); + return $this->createSuccessResponse($apiResult, 'api', $isOnlineMode); + + } catch (HttpExceptionInterface $e) { + $this->performanceTracker->stop(); + return $this->errorHandler->handleHttpException( + $e, + $queryString, + $this->performanceTracker->getDurationMs(), + $isOnlineMode + ); + + } catch (\Exception $e) { + $this->performanceTracker->stop(); + return $this->errorHandler->handleGeneralException( + $e, + $queryString, + $this->performanceTracker->getDurationMs(), + $isOnlineMode + ); + } + } + + private function createSuccessResponse(array $data, string $source, bool $isOnlineMode): array + { + // Подсчет статистики + $totalIntervals = 0; + $totalDays = 0; + $totalDepartments = 0; + + foreach ($data['schedule'] ?? [] as $department => $dates) { + $totalDepartments++; + foreach ($dates as $daySchedule) { + $totalDays++; + $totalIntervals += count($daySchedule['intervals'] ?? []); + } + } + + return array_merge($data, [ + '_meta' => [ + 'source' => $source, + 'online_mode' => $isOnlineMode, + 'duration_ms' => $this->performanceTracker->getDurationMs(), + 'departments_count' => $totalDepartments, + 'days_count' => $totalDays, + 'intervals_count' => $totalIntervals, + 'timestamp' => (new \DateTime())->format('c') + ] + ]); + } +} \ No newline at end of file diff --git a/src/MessageHandler/GetSpecialistPictureMessageHandler.php b/src/MessageHandler/GetSpecialistPictureMessageHandler.php new file mode 100644 index 0000000..70136a1 --- /dev/null +++ b/src/MessageHandler/GetSpecialistPictureMessageHandler.php @@ -0,0 +1,102 @@ +logger = $logger->withName('bitrix'); + } + + public function __invoke(GetSpecialistPictureMessage $message): string + { + $specialist = $this->em->getRepository(Specialist::class) + ->find($message->getSpesialistId()); + + $urlPicture = $specialist->getPreviewPicture(); + + if (!str_starts_with($urlPicture, '/upload/iblock/')) + return $urlPicture; + + $this->logger->info('[GetSpecialistPictureMessage] Starting processing request', [ + 'specialist_id' => $specialist->getId(), + 'preview_picture' => $specialist->getPreviewPicture(), + 'message_class' => GetSpecialistPictureMessage::class + ]); + + try { + $startTime = microtime(true); + + // Получаем содержимое изображения + $fileContent = $this->bitixClient->getSpecialistImage($urlPicture); + + // Создаем временный файл + $tempFilePath = sys_get_temp_dir() . '/' . uniqid('img_', true); + file_put_contents($tempFilePath, $fileContent); + + $uploadedFile = new UploadedFile( + $tempFilePath, + basename($urlPicture), + mime_content_type($tempFilePath), + null, + true + ); + + $this->fileUploaderService->setTargetDirectory('specialist'); + $fileName = $this->fileUploaderService->upload($uploadedFile); + + $duration = round((microtime(true) - $startTime) * 1000, 2); + + $this->logger->info('[GetSpecialistPictureMessage] Successfully processed request', [ + 'specialist_id' => $specialist->getId(), + 'duration_ms' => $duration, + 'file_name' => $fileName + ]); + + $specialist->setPreviewPicture('specialist/'. $fileName); + + $this->em->persist($specialist); + $this->em->flush(); + $this->em->clear(); + + @unlink($tempFilePath); + + return 'specialist/' . $fileName; + + } catch (HttpExceptionInterface $e) { + $this->logger->error('[GetSpecialistPictureMessage] API request failed', [ + 'specialist_id' => $specialist->getId(), + 'preview_picture' => $specialist->getPreviewPicture(), + 'status_code' => $e->getResponse()->getStatusCode(), + 'error' => $e->getMessage(), + 'response' => $e->getResponse()->getContent(false) + ]); + throw $e; + + } catch (\Exception $e) { + $this->logger->critical('[GetSpecialistPictureMessage] Unexpected error', [ + 'specialist_id' => $specialist->getId(), + 'preview_picture' => $specialist->getPreviewPicture(), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + throw $e; + } + } +} \ No newline at end of file diff --git a/src/Repository/.gitignore b/src/Repository/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Repository/AlertSmsRepository.php b/src/Repository/AlertSmsRepository.php new file mode 100644 index 0000000..1735161 --- /dev/null +++ b/src/Repository/AlertSmsRepository.php @@ -0,0 +1,21 @@ + + */ +class ArticleRepository extends ServiceEntityRepository +{ + use ContentFilterTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Article::class); + } + + /** + */ + public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder + { + $qb = $this->createQueryBuilder('a')->orderBy('a.id', 'DESC'); + + $this->applyCommonFilters($qb, 'a', $filters); + + return $qb; + } + + /** + * Поиск статьи по alias с учётом возможных вариантов написания (исторический функционал). + */ + public function findOneByAlias(string $alias): ?Article + { + $alias = trim($alias); + if ($alias === '') { + return null; + } + + $variants = [ + $alias, + $alias . '-', + '/' . ltrim($alias, '/'), + ]; + foreach ($variants as $v) { + $article = $this->findOneBy(['alias' => $v]); + if ($article !== null) { + return $article; + } + } + + // Фолбэк по 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], + ); + if ($id !== false) { + return $this->find($id); + } + + return null; + } +} diff --git a/src/Repository/BannerRepository.php b/src/Repository/BannerRepository.php new file mode 100644 index 0000000..908c7eb --- /dev/null +++ b/src/Repository/BannerRepository.php @@ -0,0 +1,21 @@ + 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/DepartmentRepository.php b/src/Repository/DepartmentRepository.php new file mode 100644 index 0000000..5f12238 --- /dev/null +++ b/src/Repository/DepartmentRepository.php @@ -0,0 +1,87 @@ +createQueryBuilder('d') + ->where('d.active = :active') + ->setParameter('active', true) + ->orderBy('d.did', 'ASC') + ->getQuery() + ->getResult(); + } + + public function createFilteredQueryBuilder(array $filters): QueryBuilder + { + $qb = $this->createQueryBuilder('d') + ->orderBy('d.id', 'ASC'); + + foreach ($filters as $filterType => $filterValue) { + if ($filterValue === null) { + continue; + } + + match ($filterType) { + 'id' => $this->applyDidFilter($qb, $filterValue), + 'name' => $this->applyNameFilter($qb, $filterValue), + 'alias' => $this->applyAliasFilter($qb, $filterValue), + 'active' => $this->applyActiveFilter($qb, $filterValue), + 'groupName' => $this->applyGroupNameFilter($qb, $filterValue), + default => null + }; + } + + return $qb; + } + + private function applyDidFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('d.did in (:did)') + ->setParameter('did', $value); + } + + private function applyNameFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere($qb->expr()->orX( + $qb->expr()->like('LOWER(d.name)', 'LOWER(:name)') + )) + ->setParameter('name', '%'.$value.'%'); + } + + private function applyAliasFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('d.alias = :alias') + ->setParameter('alias', $value); + } + + private function applyActiveFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('d.active = :active') + ->setParameter('active', $value); + } + + private function applyGroupNameFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('d.groupName = :groupName') + ->setParameter('groupName', $value); + } +} diff --git a/src/Repository/DiseaseRepository.php b/src/Repository/DiseaseRepository.php new file mode 100644 index 0000000..33dd6b1 --- /dev/null +++ b/src/Repository/DiseaseRepository.php @@ -0,0 +1,36 @@ +createQueryBuilder('d')->orderBy('d.id', 'ASC'); + + $this->applyCommonFilters($qb, 'd', $filters); + + return $qb; + } +} diff --git a/src/Repository/FilialRepository.php b/src/Repository/FilialRepository.php new file mode 100644 index 0000000..74f0be1 --- /dev/null +++ b/src/Repository/FilialRepository.php @@ -0,0 +1,70 @@ +createQueryBuilder('f') + ->orderBy('f.id', 'ASC'); + + foreach ($filters as $filterType => $filterValue) { + if ($filterValue === null) { + continue; + } + + match ($filterType) { + 'name' => $this->applyNameFilter($qb, $filterValue), + 'address' => $this->applyAddressFilter($qb, $filterValue), + 'active' => $this->applyActiveFilter($qb, $filterValue), + 'regionId' => $this->applyRegionIdFilter($qb, $filterValue), + default => null + }; + } + + return $qb; + } + + private function applyNameFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere($qb->expr()->orX( + $qb->expr()->like('LOWER(f.name)', 'LOWER(:name)') + )) + ->setParameter('name', '%'.$value.'%'); + } + + private function applyAddressFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('f.address = :address') + ->setParameter('address', $value); + } + + private function applyActiveFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('f.active = :active') + ->setParameter('active', $value); + } + + private function applyRegionIdFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('f.regionId = :regionId') + ->setParameter('regionId', $value); + } +} diff --git a/src/Repository/IdoctorRepository.php b/src/Repository/IdoctorRepository.php new file mode 100644 index 0000000..8325b9a --- /dev/null +++ b/src/Repository/IdoctorRepository.php @@ -0,0 +1,67 @@ + + */ +class IdoctorRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Idoctor::class); + } + + public function createFilteredQueryBuilder(array $filters): QueryBuilder + { + $qb = $this->createQueryBuilder('d') + ->orderBy('d.id', 'ASC'); + + foreach ($filters as $filterType => $filterValue) { + if ($filterValue === null) { + continue; + } + + match ($filterType) { + 'filial' => $this->applyFilialFilter($qb, $filterValue), + 'department' => $this->applyDepartmentFilter($qb, $filterValue), + 'dcode' => $this->applyDcodeFilter($qb, $filterValue), + 'search' => $this->applySearchFilter($qb, $filterValue), + default => null + }; + } + + return $qb; + } + + private function applyFilialFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('d.filial = :filial') + ->setParameter('filial', $value); + } + + private function applyDepartmentFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('d.department = :department') + ->setParameter('department', $value); + } + + private function applyDcodeFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('d.dcode = :dcode') + ->setParameter('dcode', $value); + } + + private function applySearchFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere($qb->expr()->orX( + $qb->expr()->like('LOWER(d.name)', 'LOWER(:search)') + )) + ->setParameter('search', '%'.$value.'%'); + } +} diff --git a/src/Repository/LocationRepository.php b/src/Repository/LocationRepository.php new file mode 100644 index 0000000..b749c10 --- /dev/null +++ b/src/Repository/LocationRepository.php @@ -0,0 +1,95 @@ +createQueryBuilder('l') + ->orderBy('l.id', 'ASC'); + + foreach ($filters as $filterType => $filterValue) { + if ($filterValue === null) { + continue; + } + + match ($filterType) { + 'filial' => $this->applyFilialFilter($qb, $filterValue), + 'filials' => $this->applyFilialsFilter($qb, $filterValue), + 'department' => $this->applyDepartmentFilter($qb, $filterValue), + 'dcode' => $this->applyDcodeFilter($qb, $filterValue), + 'onlineMode' => $this->applyOnlineModeFilter($qb, $filterValue), + 'active' => $this->applyActiveFilter($qb, $filterValue), + default => null + }; + } + + return $qb; + } + + private function applyFilialFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('l.filial = :filial') + ->setParameter('filial', $value); + } + + /** + * @param array $value список fid филиалов + */ + private function applyFilialsFilter(QueryBuilder $qb, mixed $value): void + { + if (!is_array($value) || $value === []) { + return; + } + $qb->andWhere('l.filial IN (:filials)') + ->setParameter('filials', $value); + } + + private function applyDepartmentFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('l.department = :department') + ->setParameter('department', $value); + } + + private function applyDcodeFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('l.dcode = :dcode') + ->setParameter('dcode', $value); + } + + private function applyOnlineModeFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('l.onlineMode = :onlineMode') + ->setParameter('onlineMode', $value); + } + + private function applyActiveFilter(QueryBuilder $qb, mixed $value): void + { + if ($value === true) { + $qb->andWhere('EXISTS ( + SELECT 1 FROM App\Entity\Specialist s + WHERE l.specialist = s.id and s.active = :sActive + )')->setParameter('sActive', $value); + } + + $qb->andWhere('l.active = :active') + ->setParameter('active', $value); + } +} diff --git a/src/Repository/MarkKioskRepository.php b/src/Repository/MarkKioskRepository.php new file mode 100644 index 0000000..817970e --- /dev/null +++ b/src/Repository/MarkKioskRepository.php @@ -0,0 +1,43 @@ + + */ +class MarkKioskRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, MarkKiosk::class); + } + + // /** + // * @return MarkKiosk[] Returns an array of MarkKiosk objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('m') + // ->andWhere('m.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('m.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?MarkKiosk + // { + // return $this->createQueryBuilder('m') + // ->andWhere('m.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/MedicalCenterRepository.php b/src/Repository/MedicalCenterRepository.php new file mode 100644 index 0000000..021af74 --- /dev/null +++ b/src/Repository/MedicalCenterRepository.php @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..4520283 --- /dev/null +++ b/src/Repository/NewsRepository.php @@ -0,0 +1,40 @@ +createQueryBuilder('n')->orderBy('n.id', 'DESC'); + + $this->applyCommonFilters($qb, 'n', $filters); + + return $qb; + } +} diff --git a/src/Repository/PriceDepartmentRepository.php b/src/Repository/PriceDepartmentRepository.php new file mode 100644 index 0000000..3976120 --- /dev/null +++ b/src/Repository/PriceDepartmentRepository.php @@ -0,0 +1,43 @@ + + */ +class PriceDepartmentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, PriceDepartment::class); + } + + // /** + // * @return PriceDepartment[] Returns an array of PriceDepartment objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('p') + // ->andWhere('p.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('p.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?PriceDepartment + // { + // return $this->createQueryBuilder('p') + // ->andWhere('p.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/PriceListRepository.php b/src/Repository/PriceListRepository.php new file mode 100644 index 0000000..89cbd9f --- /dev/null +++ b/src/Repository/PriceListRepository.php @@ -0,0 +1,108 @@ +createQueryBuilder('p') + ->addOrderBy('p.filial, p.schname', 'ASC'); + + foreach ($filters as $filterType => $filterValue) { + if (empty($filterValue)) { + continue; + } + + match ($filterType) { + 'search' => $this->applySearchFilter($qb, $filterValue), + 'kodoper' => $this->applyKodoperFilter($qb, $filterValue), + 'schname' => $this->applySchnameFilter($qb, $filterValue), + 'filial' => $this->applyFilialFilter($qb, $filterValue), + 'actual' => $this->applyActualFilter($qb, $filterValue), + 'groupId' => $this->applyGroupIdFilter($qb, $filterValue), + default => null + }; + } + + return $qb; + } + + private function applyKodoperFilter(QueryBuilder $qb, mixed $value): void + { + $kodoper = match (true) { + is_array($value) => array_filter($value, 'is_string'), + is_string($value) => [$value], + default => [] + }; + + if ($kodoper) { + $qb->andWhere('p.kodoper IN (:codes)') + ->setParameter('codes', $kodoper); + } + } + + private function applySearchFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere($qb->expr()->orX( + $qb->expr()->like('LOWER(p.schname)', 'LOWER(:search)'), + $qb->expr()->like('LOWER(p.kodoper)', 'LOWER(:search)') + )) + ->setParameter('search', '%'.$value.'%'); + } + + private function applySchnameFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere($qb->expr()->orX( + $qb->expr()->like('LOWER(p.schname)', 'LOWER(:schname)'), + )) + ->setParameter('schname', '%' . $value . '%'); + } + + private function applyFilialFilter(QueryBuilder $qb, mixed $value): void + { + $filialIds = match (true) { + is_array($value) => $value, + is_numeric($value) => [$value], + default => [] + }; + + if ($filialIds) { + $qb->andWhere('p.filial in (:filial)') + ->setParameter('filial', $filialIds); + } + } + + private function applyActualFilter(QueryBuilder $qb, mixed $value): void + { + if ($value) { + $qb->andWhere('p.dateUpdate >= :dateUpdate') + ->setParameter('dateUpdate', (new \DateTime()) + ->modify('-1 day') + ->format('Y-m-d 00:00:00') + ); + } + } + + private function applyGroupIdFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('p.groupId = :groupId') + ->setParameter('groupId', (int) $value); + } +} \ No newline at end of file diff --git a/src/Repository/PromoRepository.php b/src/Repository/PromoRepository.php new file mode 100644 index 0000000..3d73d2b --- /dev/null +++ b/src/Repository/PromoRepository.php @@ -0,0 +1,36 @@ +createQueryBuilder('p')->orderBy('p.id', 'DESC'); + + $this->applyCommonFilters($qb, 'p', $filters); + + return $qb; + } +} diff --git a/src/Repository/RecordRepository.php b/src/Repository/RecordRepository.php new file mode 100644 index 0000000..65da22b --- /dev/null +++ b/src/Repository/RecordRepository.php @@ -0,0 +1,21 @@ +createQueryBuilder('r') + ->distinct() + ->leftJoin('r.specialist', 's') + ->addSelect('s') + ->leftJoin('s.locations', 'l') + ->addSelect('l') + ->orderBy('r.id', 'ASC'); + + foreach ($filters as $filterType => $filterValue) { + if ($filterValue === null || $filterValue === '') { + continue; + } + + match ($filterType) { + 'active' => $this->applyActiveFilter($qb, $filterValue), + 'filial' => $this->applyFilialFilter($qb, $filterValue), + 'department' => $this->applyDepartmentFilter($qb, $filterValue), + default => null + }; + } + + return $qb; + } + + private function applyActiveFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('r.active = :active') + ->setParameter('active', $value); + } + + private function applyFilialFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('l.filial = :filial') + ->setParameter('filial', (int) $value); + } + + private function applyDepartmentFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere('l.department = :department') + ->setParameter('department', (int) $value); + } +} diff --git a/src/Repository/ScheduleRepository.php b/src/Repository/ScheduleRepository.php new file mode 100644 index 0000000..79d0053 --- /dev/null +++ b/src/Repository/ScheduleRepository.php @@ -0,0 +1,155 @@ + + */ +class ScheduleRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Schedule::class); + } + + public function findByQueryModeAndTime( + string $queryString, + bool $onlineMode, + \DateTimeInterface $createdAfter + ): array { + return $this->createQueryBuilder('s') + ->andWhere('s.queryString = :queryString') + ->andWhere('s.onlineMode = :onlineMode') + ->andWhere('s.createdAt >= :createdAfter') + ->setParameter('queryString', $queryString) + ->setParameter('onlineMode', $onlineMode) + ->setParameter('createdAfter', $createdAfter) + ->orderBy('s.department', 'ASC') + ->addOrderBy('s.workdate', 'ASC') + ->addOrderBy('s.time', 'ASC') + ->getQuery() + ->getResult(); + } + + public function removeByQueryStringAndMode(string $queryString, bool $onlineMode): int + { + return $this->createQueryBuilder('s') + ->delete() + ->where('s.queryString = :queryString') + ->andWhere('s.onlineMode = :onlineMode') + ->setParameter('queryString', $queryString) + ->setParameter('onlineMode', $onlineMode) + ->getQuery() + ->execute(); + } + + public function removeByQueryString(string $queryString): int + { + return $this->createQueryBuilder('s') + ->delete() + ->where('s.queryString = :queryString') + ->setParameter('queryString', $queryString) + ->getQuery() + ->execute(); + } + + public function removeOlderThan(\DateTimeInterface $olderThan): int + { + return $this->createQueryBuilder('s') + ->delete() + ->where('s.createdAt < :olderThan') + ->setParameter('olderThan', $olderThan) + ->getQuery() + ->execute(); + } + + public function getCacheStatistics(): array + { + $qb = $this->createQueryBuilder('s'); + + $stats = $qb + ->select([ + 'COUNT(s.id) as total_records', + 'COUNT(DISTINCT s.queryString) as unique_queries', + 'SUM(CASE WHEN s.onlineMode = true THEN 1 ELSE 0 END) as online_records', + 'SUM(CASE WHEN s.onlineMode = false THEN 1 ELSE 0 END) as offline_records', + 'MIN(s.createdAt) as oldest_record', + 'MAX(s.createdAt) as newest_record' + ]) + ->getQuery() + ->getSingleResult(); + + // Статистика по дням и типам + $dailyStats = $this->createQueryBuilder('s') + ->select([ + 'DATE(s.createdAt) as day', + 's.onlineMode as online_mode', + 'COUNT(s.id) as records_count', + 'COUNT(DISTINCT s.queryString) as queries_count' + ]) + ->groupBy('day, s.onlineMode') + ->orderBy('day', 'DESC') + ->setMaxResults(14) + ->getQuery() + ->getArrayResult(); + + return [ + 'total_records' => (int)$stats['total_records'], + 'unique_queries' => (int)$stats['unique_queries'], + 'online_records' => (int)$stats['online_records'], + 'offline_records' => (int)$stats['offline_records'], + 'oldest_record' => $stats['oldest_record'], + 'newest_record' => $stats['newest_record'], + 'last_days' => $dailyStats + ]; + } + + public function findSchedulesByCriteria(array $criteria, ?int $limit = null): array + { + $qb = $this->createQueryBuilder('s'); + + if (isset($criteria['onlineMode'])) { + $qb->andWhere('s.onlineMode = :onlineMode') + ->setParameter('onlineMode', $criteria['onlineMode']); + } + + if (!empty($criteria['department'])) { + $qb->andWhere('s.department = :department') + ->setParameter('department', $criteria['department']); + } + + if (!empty($criteria['filial'])) { + $qb->andWhere('s.filial = :filial') + ->setParameter('filial', $criteria['filial']); + } + + if (!empty($criteria['date_from'])) { + $qb->andWhere('s.workdate >= :date_from') + ->setParameter('date_from', $criteria['date_from']); + } + + if (!empty($criteria['date_to'])) { + $qb->andWhere('s.workdate <= :date_to') + ->setParameter('date_to', $criteria['date_to']); + } + + if (isset($criteria['isFree'])) { + $qb->andWhere('s.isFree = :isFree') + ->setParameter('isFree', $criteria['isFree']); + } + + $qb->orderBy('s.workdate', 'ASC') + ->addOrderBy('s.time', 'ASC'); + + if ($limit) { + $qb->setMaxResults($limit); + } + + return $qb->getQuery()->getResult(); + } +} \ No newline at end of file diff --git a/src/Repository/SiteServiceRepository.php b/src/Repository/SiteServiceRepository.php new file mode 100644 index 0000000..73d834a --- /dev/null +++ b/src/Repository/SiteServiceRepository.php @@ -0,0 +1,36 @@ +createQueryBuilder('s')->orderBy('s.id', 'ASC'); + + $this->applyCommonFilters($qb, 's', $filters); + + return $qb; + } +} diff --git a/src/Repository/SpecialistDcodeDescriptionRepository.php b/src/Repository/SpecialistDcodeDescriptionRepository.php new file mode 100644 index 0000000..0a47d8d --- /dev/null +++ b/src/Repository/SpecialistDcodeDescriptionRepository.php @@ -0,0 +1,90 @@ + + */ +class SpecialistDcodeDescriptionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SpecialistDcodeDescription::class); + } + + public function createFilteredQueryBuilder(array $filters): QueryBuilder + { + $qb = $this->createQueryBuilder('sdd') + ->leftJoin('sdd.specialist', 's') + ->addSelect('s') + ->orderBy('sdd.id', 'ASC'); + + if (!empty($filters['specialistId'])) { + $qb->andWhere('s.id = :specialistId') + ->setParameter('specialistId', (int) $filters['specialistId']); + } + + if (!empty($filters['dcode'])) { + $qb->andWhere('sdd.dcode = :dcode') + ->setParameter('dcode', (int) $filters['dcode']); + } + + if (array_key_exists('department', $filters) && $filters['department'] !== null && $filters['department'] !== '') { + $conn = $this->getEntityManager()->getConnection(); + $table = $this->getClassMetadata()->getTableName(); + $dept = (int) $filters['department']; + $ids = $conn->fetchFirstColumn( + "SELECT id FROM {$table} WHERE department = ?", + [$dept], + [ParameterType::INTEGER] + ); + if ($ids === []) { + $qb->andWhere('1 = 0'); + } else { + $qb->andWhere('sdd.id IN (:deptIds)') + ->setParameter('deptIds', $ids, ArrayParameterType::INTEGER); + } + } + + if (!empty($filters['search'])) { + $qb->andWhere('LOWER(sdd.content) LIKE LOWER(:search)') + ->setParameter('search', '%' . $filters['search'] . '%'); + } + + return $qb; + } + + public function findOneBySpecialistAndDcode(int $specialistId, int $dcode, ?int $department = null): ?SpecialistDcodeDescription + { + $conn = $this->getEntityManager()->getConnection(); + $table = $this->getClassMetadata()->getTableName(); + + $sql = "SELECT id FROM {$table} WHERE specialist_id = ? AND dcode = ?"; + $params = [$specialistId, $dcode]; + $types = [ParameterType::INTEGER, ParameterType::INTEGER]; + + if ($department === null) { + $sql .= ' AND department IS NULL'; + } else { + $sql .= ' AND (department = ? OR department IS NULL)'; + $params[] = $department; + $types[] = ParameterType::INTEGER; + } + + $sql .= ' LIMIT 1'; + + $id = $conn->fetchOne($sql, $params, $types); + if ($id === false || $id === null) { + return null; + } + + return $this->find((int) $id); + } +} diff --git a/src/Repository/SpecialistDocsRepository.php b/src/Repository/SpecialistDocsRepository.php new file mode 100644 index 0000000..fe3e2eb --- /dev/null +++ b/src/Repository/SpecialistDocsRepository.php @@ -0,0 +1,43 @@ + + */ +class SpecialistDocsRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SpecialistDocs::class); + } + + // /** + // * @return SpecialistDocs[] Returns an array of SpecialistDocs objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('s.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?SpecialistDocs + // { + // return $this->createQueryBuilder('s') + // ->andWhere('s.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/SpecialistRepository.php b/src/Repository/SpecialistRepository.php new file mode 100644 index 0000000..3e930d6 --- /dev/null +++ b/src/Repository/SpecialistRepository.php @@ -0,0 +1,227 @@ +createQueryBuilder('s') + ->leftJoin('s.locations', 'l') + ->addSelect('l') + ->addGroupBy('s.id, l.id'); + + // Применяем фильтры + $this->applyFilters($qb, $filters); + + // Применяем сортировку + $this->applyOrderBy($qb, $filters['orderBy'] ?? null); + + return $qb; + } + + private function applyFilters(QueryBuilder $qb, array $filters): void + { + // Активность + if (isset($filters['active'])) { + $qb->andWhere('s.active = :active') + ->setParameter('active', (bool) $filters['active']); + } + + // Тип специалиста + if (!empty($filters['sType'])) { + $qb->andWhere('s.sType IN (:sType)') + ->setParameter('sType', array_map('intval', $filters['sType'])); + } + + // Регион + if (!empty($filters['regionId'])) { + $qb->andWhere('s.regionId = :regionId') + ->setParameter('regionId', (int) $filters['regionId']); + } + + // Alias + if (!empty($filters['alias'])) { + $qb->andWhere('s.alias = :alias') + ->setParameter('alias', $filters['alias']); + } + + // Отображение расписания + if (isset($filters['displaySchedule'])) { + $qb->andWhere('s.displaySchedule = :displaySchedule') + ->setParameter('displaySchedule', (bool) $filters['displaySchedule']); + } + + // Киоск + if (isset($filters['kiosk'])) { + $qb->andWhere('s.kiosk = :kiosk') + ->setParameter('kiosk', (bool) $filters['kiosk']); + } + + // Категория + if (!empty($filters['category'])) { + $qb->andWhere('s.category = :category') + ->setParameter('category', $filters['category']); + } + + // Возраст пациентов + if (!empty($filters['patientAge'])) { + $qb->andWhere('s.patientAge = :patientAge') + ->setParameter('patientAge', $filters['patientAge']); + } + + // JSON фильтры - теги + if (!empty($filters['tags'])) { + foreach ($filters['tags'] as $index => $tag) { + $paramName = 'tag_' . $index; + $qb->andWhere("JSONB_EXISTS(s.tags, :{$paramName}) = true") + ->setParameter($paramName, $tag); + } + } + + // JSON фильтры - все филиалы + if (!empty($filters['sFilial'])) { + $orX = $qb->expr()->orX(); + + foreach ($filters['sFilial'] as $index => $filial) { + $paramName = 'filial_' . $index; + $orX->add("JSONB_EXISTS(s.filials, :{$paramName}) = true"); + $qb->setParameter($paramName, $filial); + } + + if ($orX->count() > 0) { + $qb->andWhere($orX) + ->andWhere($qb->expr()->neq('s.filials', $qb->expr()->literal('[]'))); + } + } + + // JSON фильтры - коды операций + if (!empty($filters['kodoper'])) { + foreach ($filters['kodoper'] as $index => $kodoper) { + $paramName = 'kodoper_' . $index; + $qb->andWhere("JSONB_EXISTS(s.kodoper, :{$paramName}) = true") + ->setParameter($paramName, $kodoper); + } + } + + // Фильтры по Location + $this->applyLocationFilters($qb, $filters); + + // Поиск по тексту + if (!empty($filters['search'])) { + $this->applySearchFilter($qb, $filters['search']); + } + } + + private function applyLocationFilters(QueryBuilder $qb, array $filters): void + { + // Отделение + if (!empty($filters['department'])) { + $qb->andWhere('l.department = :department') + ->setParameter('department', $filters['department']); + } + + // Филиал + if (!empty($filters['filial'])) { + $qb->andWhere('l.filial = :filial') + ->setParameter('filial', (int) $filters['filial']); + } + + // Dcodes + if (!empty($filters['dcode'])) { + $qb->andWhere('l.dcode IN (:dcodes)') + ->setParameter('dcodes', array_map('intval', $filters['dcode'])); + } + + // Фильтры по дате + if (!empty($filters['dateFrom'])) { + $dateFrom = \DateTime::createFromFormat('Ymd', $filters['dateFrom']); + if ($dateFrom) { + $qb->andWhere('l.nearestDate >= :dateFrom') + ->setParameter('dateFrom', $dateFrom->format('Y-m-d')); + } + } + + if (!empty($filters['dateTo'])) { + $dateTo = \DateTime::createFromFormat('Ymd', $filters['dateTo']); + if ($dateTo) { + $qb->andWhere('l.nearestDate <= :dateTo') + ->setParameter('dateTo', $dateTo->format('Y-m-d')); + } + } + + // Если есть фильтры по дате, показываем только с nearestDate + if (!empty($filters['dateFrom']) || !empty($filters['dateTo'])) { + $qb->andWhere('l.nearestDate IS NOT NULL'); + } + + // Для offline режима проверяем и в location + if (isset($filters['onlineMode'])) { + $qb->andWhere('l.onlineMode = :locationOnlineMode') + ->setParameter('locationOnlineMode', $filters['onlineMode']); + } + } + + private function applySearchFilter(QueryBuilder $qb, string $value): void + { + $qb->andWhere( + $qb->expr()->like('LOWER(s.name)', 'LOWER(:name)') + ) + ->setParameter('name', '%'.$value.'%'); + } + + private function applyOrderBy(QueryBuilder $qb, ?string $orderBy): void + { + if (empty($orderBy)) { + $qb->addOrderBy('s.id', 'ASC'); + return; + } + + switch ($orderBy) { + case 'sort-time.asc': + $qb->addOrderBy('l.nearestDate', 'ASC') + ->addOrderBy('s.id', 'ASC'); + break; + + case 'sort-time.desc': + $qb->addOrderBy('l.nearestDate', 'DESC') + ->addOrderBy('s.id', 'DESC'); + break; + + case 'name.asc': + $qb->addOrderBy('s.name', 'ASC') + ->addOrderBy('s.id', 'ASC'); + break; + + case 'name.desc': + $qb->addOrderBy('s.name', 'DESC') + ->addOrderBy('s.id', 'DESC'); + break; + + default: + $qb->addOrderBy('s.id', 'ASC'); + } + } + + public function countFiltered(array $filters): int + { + $qb = $this->createQueryBuilder('s') + ->select('COUNT(DISTINCT s.id)') + ->leftJoin('s.locations', 'l'); + + $this->applyFilters($qb, $filters); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } +} \ No newline at end of file diff --git a/src/Repository/StockRepository.php b/src/Repository/StockRepository.php new file mode 100644 index 0000000..aabfa5c --- /dev/null +++ b/src/Repository/StockRepository.php @@ -0,0 +1,48 @@ + + */ +class StockRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Stock::class); + } + + public function createFilteredQueryBuilder(array $filters): QueryBuilder + { + $qb = $this->createQueryBuilder('s') + ->orderBy('s.id', 'ASC'); + + foreach ($filters as $filterType => $filterValue) { + if ($filterValue === null || $filterValue === '') { + continue; + } + + match ($filterType) { + 'search' => $this->applySearchFilter($qb, $filterValue), + default => null + }; + } + + return $qb; + } + + public function applySearchFilter(QueryBuilder $qb, mixed $value): void + { + $qb->andWhere($qb->expr()->orX( + $qb->expr()->like('LOWER(s.name)', 'LOWER(:search)'), + $qb->expr()->like('LOWER(s.content)', 'LOWER(:search)'), + $qb->expr()->like('LOWER(s.anons)', 'LOWER(:search)'), + )) + ->setParameter('search', '%'.$filters['search'].'%'); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..139523d --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,39 @@ + + */ +class UserRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + public function findOneByUidAndBirthDate(int $uid, \DateTimeInterface $birthDate): ?User + { + // Нормализуем дату - оставляем только дату без времени + $normalizedDate = clone $birthDate; + $normalizedDate->setTime(0, 0, 0); + + $startOfDay = clone $normalizedDate; + $endOfDay = clone $normalizedDate; + $endOfDay->setTime(23, 59, 59); + + return $this->createQueryBuilder('u') + ->where('u.uid = :uid') + ->andWhere('u.birthDate >= :startDate') + ->andWhere('u.birthDate <= :endDate') + ->setParameter('uid', $uid) + ->setParameter('startDate', $startOfDay) + ->setParameter('endDate', $endOfDay) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/src/Repository/WebGetDocinfoRepository.php b/src/Repository/WebGetDocinfoRepository.php new file mode 100644 index 0000000..2a44dea --- /dev/null +++ b/src/Repository/WebGetDocinfoRepository.php @@ -0,0 +1,78 @@ +createQueryBuilder('w') + ->where('w.id = :id') + ->andWhere('w.sourceTable = :sourceTable') + ->setParameter('id', $id) + ->setParameter('sourceTable', $sourceTable) + ->getQuery() + ->getOneOrNullResult(); + } + + public function createFilteredQueryBuilder(array $filters = []): QueryBuilder + { + $qb = $this->createQueryBuilder('w'); + + if (!empty($filters['id'])) { + $qb->andWhere('w.id = :id') + ->setParameter('id', $filters['id']); + } + + if (!empty($filters['sourceTable'])) { + $qb->andWhere('w.sourceTable = :sourceTable') + ->setParameter('sourceTable', $filters['sourceTable']); + } + + if (!empty($filters['filial'])) { + $qb->andWhere('w.filial = :filial') + ->setParameter('filial', $filters['filial']); + } + + if (!empty($filters['viewinweb'])) { + $qb->andWhere('w.viewinweb = :viewinweb') + ->setParameter('viewinweb', $filters['viewinweb']); + } + + if (!empty($filters['search'])) { + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->like('LOWER(w.docName)', 'LOWER(:search)'), + $qb->expr()->like('LOWER(w.docPost)', 'LOWER(:search)') + ) + ) + ->setParameter('search', '%' . $filters['search'] . '%'); + } + + $qb->orderBy('w.id', 'ASC') + ->addOrderBy('w.sourceTable', 'ASC'); + + return $qb; + } + + public function findByFilialAndId(int $filial, int $id): ?WebGetDocinfo + { + return $this->createQueryBuilder('w') + ->where('w.filial = :filial') + ->andWhere('w.id = :id') + ->setParameter('filial', $filial) + ->setParameter('id', $id) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/src/Repository/WidgetFormInputRepository.php b/src/Repository/WidgetFormInputRepository.php new file mode 100644 index 0000000..57b66d2 --- /dev/null +++ b/src/Repository/WidgetFormInputRepository.php @@ -0,0 +1,22 @@ +stateful($this->cache) // ensure missed tasks are executed + ->processOnlyLastMissedRun(true) // ensure only last missed task is run + + // add your own tasks here + // see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule + ; + } +} diff --git a/src/Serializer/Normalizer/FilialNormalizer.php b/src/Serializer/Normalizer/FilialNormalizer.php new file mode 100644 index 0000000..b88aa95 --- /dev/null +++ b/src/Serializer/Normalizer/FilialNormalizer.php @@ -0,0 +1,42 @@ +normalizer->normalize($object, $format, $context); + + if (isset($data['picture'])) { + $data['pictureLink'] = $this->urlGenerator->generate( + 'filial_picture', + ['id' => $data['id']], + UrlGeneratorInterface::ABSOLUTE_PATH + ); + } + + return $data; + } + + public function supportsNormalization($data, ?string $format = null, array $context = []): bool + { + return $data instanceof Filial; + } + + public function getSupportedTypes(?string $format): array + { + return [Filial::class => true]; + } +} diff --git a/src/Serializer/Normalizer/SpecialistDocsNormalizer.php b/src/Serializer/Normalizer/SpecialistDocsNormalizer.php new file mode 100644 index 0000000..1d7e261 --- /dev/null +++ b/src/Serializer/Normalizer/SpecialistDocsNormalizer.php @@ -0,0 +1,42 @@ +normalizer->normalize($object, $format, $context); + + if (isset($data['picture'])) { + $data['pictureLink'] = $this->urlGenerator->generate( + 'specialist_docs_picture', + ['id' => $data['id']], + UrlGeneratorInterface::ABSOLUTE_PATH + ); + } + + return $data; + } + + public function supportsNormalization($data, ?string $format = null, array $context = []): bool + { + return $data instanceof SpecialistDocs; + } + + public function getSupportedTypes(?string $format): array + { + return [SpecialistDocs::class => true]; + } +} diff --git a/src/Serializer/Normalizer/SpecialistNormalizer.php b/src/Serializer/Normalizer/SpecialistNormalizer.php new file mode 100644 index 0000000..d0bd97d --- /dev/null +++ b/src/Serializer/Normalizer/SpecialistNormalizer.php @@ -0,0 +1,56 @@ +normalizer->normalize($object, $format, $context); + + if (isset($data['previewPicture'])) { + unset($data['previewPicture']); + + $data['pictureLink'] = $this->urlGenerator->generate( + 'specialist_picture', + ['id' => $data['id']], + UrlGeneratorInterface::ABSOLUTE_PATH + ); + } + + if (isset($data['experience'])) { + $exp = date('Y') - $data['experience']; + $data['experienceText'] = $this->helperService->textYear($exp, true); + } + + if (isset($data['patientAge']) && $data['sType'] == 1) { + $age = $data['patientAge']; + $data['patientAgeText'] = 'с ' . $this->helperService->textYear($age, false); + } + + return $data; + } + + public function supportsNormalization($data, ?string $format = null, array $context = []): bool + { + return $data instanceof Specialist; + } + + public function getSupportedTypes(?string $format): array + { + return [Specialist::class => true]; + } +} diff --git a/src/Serializer/Normalizer/StockNormalizer.php b/src/Serializer/Normalizer/StockNormalizer.php new file mode 100644 index 0000000..2bdad28 --- /dev/null +++ b/src/Serializer/Normalizer/StockNormalizer.php @@ -0,0 +1,43 @@ +normalizer->normalize($object, $format, $context); + + if (isset($data['picture'])) { + $data['pictureLink'] = $this->urlGenerator->generate( + 'stock_picture', + ['id' => $data['id']], + UrlGeneratorInterface::ABSOLUTE_PATH + ); + } + + return $data; + } + + public function supportsNormalization($data, ?string $format = null, array $context = []): bool + { + return $data instanceof Stock; + } + + public function getSupportedTypes(?string $format): array + { + return [Stock::class => true]; + } +} diff --git a/src/Service/Bitrix/BitrixService.php b/src/Service/Bitrix/BitrixService.php new file mode 100644 index 0000000..9bbe28e --- /dev/null +++ b/src/Service/Bitrix/BitrixService.php @@ -0,0 +1,205 @@ +connection->executeQuery("SET NAMES $name"); + + return $this; + } + + public function setRegionId(int $id): self + { + $this->regionId = $id; + return $this; + } + + public function getPropertyPrice(string $param = 'value'): mixed + { + $props = [ + '91' => [ + 'value' => 2980, + 'name' => 'Саратов' + ], + 'comfort' => [ + 'value' => 2980, + 'name' => 'Саратов Comfort' + ], + '93' => [ + 'value' => 2985, + 'name' => 'Воронеж' + ], + '92' => [ + 'value' => 2990, + 'name' => 'Волгоград' + ], + '94' => [ + 'value' => 4477, + 'name' => 'Краснодар' + ], + ]; + + return $props[$this->regionId][$param] ?? null; + } + + public function getBlockId(string $param = 'value'): mixed + { + $iblockIds = [ + 'sovenok' => [ + 'value' => 174, + 'uslugi' => 175, + 'name' => 'Саратов Совенок' + ], + 'comfort' => [ + 'value' => 145, + 'uslugi' => 146, + 'name' => 'Саратов Comfort' + ], + '91' => [ + 'value' => 91, + 'uslugi' => 165, + 'name' => 'Саратов' + ], + '92' => [ + 'value' => 92, + 'uslugi' => 167, + 'name' => 'Волгоград' + ], + '93' => [ + 'value' => 93, + 'uslugi' => 166, + 'name' => 'Воронеж' + ], + '94' => [ + 'value' => 219, + 'uslugi' => 229, + 'name' => 'Краснодар' + ], + ]; + + return $iblockIds[$this->regionId][$param] ?? null; + } + + + public function getReviews(int $doctorId): array + { + $specialist = $this->getSpecialist($doctorId); + + if (!$specialist) { + return []; + } + + $items = $this->connection->createQueryBuilder() + ->select('biep.IBLOCK_ELEMENT_ID as REVIEW_ID') + ->from('b_iblock_element_property', 'biep') + ->leftJoin('biep', 'b_iblock_property', 'bip', 'biep.IBLOCK_PROPERTY_ID = bip.ID') + ->where('biep.VALUE = :VALUE') + ->andWhere('bip.CODE REGEXP :CODE') + ->setParameter('VALUE', $specialist['ID']) + ->setParameter('CODE', 'MEDIC') + ->executeQuery() + ->fetchAllAssociative(); + + foreach ($this->getElementProperties($specialist['ID'], 'LINK_REVIEWS') as $item) { + $items[]['REVIEW_ID'] = $item['VALUE']; + } + + foreach ($items as $key => $item) { + $items[$key]['DATA'] = $this->getElementProperties($item['REVIEW_ID']); + + foreach ($items[$key]['DATA'] as $i => $props) { + if ($props['CODE'] == 'MESSAGE') { + $data = preg_replace_callback('!s:(\d+):"(.*?)";!s', function($m) { + $len = strlen($m[2]); + return "s:$len:\"{$m[2]}\";"; + }, $props['VALUE']); + + if (@unserialize($data) !== false) { + $items[$key]['DATA'][$i]['VALUE'] = unserialize($data)['TEXT']; + } + } + } + } + + return $items; + } + + public function getServiceCode(int $doctorId): ?array + { + $kodoper = null; + + // Получаем ID элементов цен + $listPriceLink = $this->getElementProperties($doctorId, 'LINK_PRICE_1', true); + + if (!empty($listPriceLink)) { + foreach ($listPriceLink as $key => $priceLink) { + $item = $this->getElementProperties($priceLink['VALUE'], 'KOD', false); + + if (!empty($item)) { + $kodoper[$key] = $item['VALUE']; + } + } + } + + if (!$kodoper) { + return null; + } + + return $kodoper; + } + + public function getElementProperties(int $elementId, ?string $code = null, bool $all = true): ?array + { + $qb = $this->connection->createQueryBuilder() + ->select('bie.NAME as BIE_NAME, biep.ID, bie.ACTIVE, bie.IBLOCK_SECTION_ID, bie.DATE_CREATE, biep.VALUE, bip.NAME, bip.CODE') + ->from('b_iblock_element', 'bie') + ->innerJoin('bie', 'b_iblock_element_property', 'biep', 'biep.IBLOCK_ELEMENT_ID = bie.ID') + ->leftJoin('biep', 'b_iblock_property', 'bip', 'biep.IBLOCK_PROPERTY_ID = bip.ID') + ->where('bie.ID = :ID') + ->setParameter('ID', $elementId); + + if ($code) { + $qb->andWhere('bip.CODE = :CODE') + ->setParameter('CODE', $code); + } + + $response = $all + ? $qb->executeQuery()->fetchAllAssociative() + : $qb->executeQuery()->fetchAssociative(); + + return $response === false ? null : $response; + } + + public function getSpecialist(int $id, bool $fromInfoclinica = false): ?array + { + $qb = $this->connection->createQueryBuilder() + ->select('*') + ->from('b_iblock_element', 'el'); + + if ($fromInfoclinica) { + $qb->where('el.XML_ID = :XML_ID') + ->setParameter('XML_ID', $id); + } else { + $qb->where('el.ID = :ID') + ->setParameter('ID', $id); + } + + $specialist = $qb->executeQuery()->fetchAssociative(); + + if ($specialist) { + $specialist['NAME'] = explode(' ', trim($specialist['NAME'])); + } + + return $specialist ?: null; + } +} \ No newline at end of file diff --git a/src/Service/Client/AbstractHttpClientService.php b/src/Service/Client/AbstractHttpClientService.php new file mode 100644 index 0000000..d58a446 --- /dev/null +++ b/src/Service/Client/AbstractHttpClientService.php @@ -0,0 +1,163 @@ +userAgent = $userAgent; + $this->baseUrl = $baseUrl; + $this->defaultOptions = [ + 'base_uri' => $this->baseUrl, + 'verify_peer' => false, + 'verify_host' => false, + 'headers' => [ + 'Content-Type' => 'application/json; charset=UTF-8', + 'User-Agent' => $this->userAgent, + 'Accept' => 'application/json, text/javascript, */*; q=0.01', + 'Accept-Language' => 'ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3', + 'Content-Type' => 'application/json; charset=UTF-8', + 'X-Requested-With' => 'XMLHttpRequest', + 'X-Integration-Type' => 'WEBSDK' + ] + ]; + + $this->client = HttpClient::create($this->defaultOptions); + } + + /** + * Установка куки + */ + public function setCookie(string $name, string $value): void + { + $this->cookies[$name] = $value; + $this->updateCookiesHeader(); + } + + /** + * Установка нескольких куки + */ + public function setCookies(array $cookies): void + { + $this->cookies = array_merge($this->cookies, $cookies); + $this->updateCookiesHeader(); + } + + /** + * Получение куки по имени + */ + public function getCookie(string $name): ?string + { + return $this->cookies[$name] ?? null; + } + + /** + * Получение всех куки + */ + public function getCookies(): array + { + return $this->cookies; + } + + /** + * Удаление куки + */ + public function removeCookie(string $name): void + { + unset($this->cookies[$name]); + $this->updateCookiesHeader(); + } + + /** + * Очистка всех куки + */ + public function clearCookies(): void + { + $this->cookies = []; + $this->updateCookiesHeader(); + } + + /** + * Обновление заголовка Cookie в defaultOptions + */ + protected function updateCookiesHeader(): void + { + if (!empty($this->cookies)) { + $cookieString = ''; + foreach ($this->cookies as $name => $value) { + $cookieString .= "{$name}={$value}; "; + } + $this->defaultOptions['headers']['Cookie'] = rtrim($cookieString, '; '); + } else { + unset($this->defaultOptions['headers']['Cookie']); + } + } + + /** + * Извлечение куки из ответа и сохранение их + */ + protected function extractCookiesFromResponse(ResponseInterface $response): void + { + $headers = $response->getHeaders(); + + if (isset($headers['set-cookie'])) { + foreach ($headers['set-cookie'] as $cookieHeader) { + $this->parseAndSetCookie($cookieHeader); + } + } + } + + /** + * Парсинг строки Set-Cookie и установка куки + */ + protected function parseAndSetCookie(string $cookieHeader): void + { + $parts = explode(';', $cookieHeader); + $cookiePart = trim($parts[0]); + + if (strpos($cookiePart, '=') !== false) { + list($name, $value) = explode('=', $cookiePart, 2); + $this->setCookie(trim($name), trim($value)); + } + } + + public function request(string $method, string $path, array $options = []): ResponseInterface + { + $validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']; + + if (!in_array(strtoupper($method), $validMethods)) { + throw new \InvalidArgumentException('Invalid HTTP method'); + } + + // Обновляем заголовки с учетом текущих куки + if (isset($options['headers']) && isset($this->defaultOptions['headers'])) { + $options['headers'] = array_merge($this->defaultOptions['headers'], $options['headers']); + unset($this->defaultOptions['headers']); + } + + $options = array_merge($this->defaultOptions, $options); + + try { + $response = $this->client->request($method, $path, $options); + + // Автоматически извлекаем куки из ответа + $this->extractCookiesFromResponse($response); + + return $response; + } catch (TransportExceptionInterface $e) { + throw new \RuntimeException('Failed to send request: ' . $e->getMessage()); + } + } +} \ No newline at end of file diff --git a/src/Service/Client/BitrixClientService.php b/src/Service/Client/BitrixClientService.php new file mode 100644 index 0000000..84be6e5 --- /dev/null +++ b/src/Service/Client/BitrixClientService.php @@ -0,0 +1,21 @@ +request('GET', $path); + + return $httpResponse->getContent(); + } +} diff --git a/src/Service/Client/CalltouchClientService.php b/src/Service/Client/CalltouchClientService.php new file mode 100644 index 0000000..e3d10f3 --- /dev/null +++ b/src/Service/Client/CalltouchClientService.php @@ -0,0 +1,65 @@ +baseUrl = $userAgent; + $this->baseUrl = $baseUrl; + + $this->toArrayParams($params); + } + + private function toArrayParams($params) : void + { + foreach (explode(',' , $params) as $val) { + + $params = explode(':', $val); + + $this->params[$params[0]]['siteId'] = $params[1]; + $this->params[$params[0]]['token'] = $params[2]; + } + } + + private function configureHeaders(int $regionId): void + { + if (empty($this->params[$regionId]['siteId']) || empty($this->params[$regionId]['token'])) { + throw new \InvalidArgumentException('Missing configuration for region'); + } + + $this->token = $this->params[$regionId]['token']; + $this->siteId = $this->params[$regionId]['siteId']; + } + + public function requestCreate(CalltouchCreateRequestDto $dto) : array + { + $this->configureHeaders($dto->regionId); + + $option = [ + 'headers' => [ + 'Access-Token' => $this->token, + 'SiteId' => $this->siteId, + ], + 'body' => json_encode(['requests' => $dto->toArray()]) + ]; + + $httpResponse = $this->request('POST', '/lead-service/v1/api/request/create', $option); + + return $httpResponse->toArray()['data'] ?: []; + } +} \ No newline at end of file diff --git a/src/Service/Client/InfoclinicaClientService.php b/src/Service/Client/InfoclinicaClientService.php new file mode 100644 index 0000000..6b607a0 --- /dev/null +++ b/src/Service/Client/InfoclinicaClientService.php @@ -0,0 +1,94 @@ + $schedule, + 'nearestDate' => $nearestDate + ]; + } + + public function getSchedule(string $queryString): array + { + $httpResponse = $this->request('GET', '/api/reservation/intervals?' . $queryString); + $responseArray = $httpResponse->toArray(); + + if ($responseArray['data']) { + return $this->normalizeSchedule($responseArray['data']); + } + + return []; + } + + public function getFilialsList(): array + { + $httpResponse = $this->request('GET', '/filials/list'); + return $httpResponse->toArray(); + } + + public function registration(RegistrationDto $dto): array + { + $httpResponse = $this->request('GET', '/api/reservation/intervals?' . $queryString); + $responseArray = $httpResponse->toArray(); + + if ($responseArray['data']) { + return $this->normalizeSchedule($responseArray['data']); + } + + return []; + } + + public function anonymousReserve(AnonymousReserveRequestDto $dto): array + { + $httpResponse = $this->request('POST', '/api/reservation/anonymous-reserve', [ + 'body' => json_encode($dto->toArray(), JSON_UNESCAPED_UNICODE) + ]); + + return $httpResponse->toArray(); + } + +} diff --git a/src/Service/Client/Interfaces/AbstractHttpClientServiceInterface.php b/src/Service/Client/Interfaces/AbstractHttpClientServiceInterface.php new file mode 100644 index 0000000..0832e7b --- /dev/null +++ b/src/Service/Client/Interfaces/AbstractHttpClientServiceInterface.php @@ -0,0 +1,10 @@ +secret = $secret; + } + + public function validate(string $token, string $clientIp): array + { + $options = [ + 'query' => [ + "secret" => $this->secret, + "token" => $token, + "ip" => $clientIp, + ] + ]; + + $httpResponse = $this->request('POST', '/validate', $options); + + return $httpResponse->toArray(); + } +} diff --git a/src/Service/Client/Sms4bClientService.php b/src/Service/Client/Sms4bClientService.php new file mode 100644 index 0000000..b5b5ed0 --- /dev/null +++ b/src/Service/Client/Sms4bClientService.php @@ -0,0 +1,72 @@ +token = $token; + $this->sender = $sender; + } + + public function send(string $to, string $msg): array + { + $options = [ + 'headers' => [ + 'Authorization' => $this->token, + 'accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'body' => json_encode([ + 'sender' => $this->sender, + 'messages' => [ + [ + 'number' => $to, + 'text' => $msg + ] + ] + ]) + ]; + + $httpResponse = $this->request('POST', '/v1/sms', $options); + + return $httpResponse->toArray(); + } + + public function senders(): array + { + $options = [ + 'headers' => [ + 'Authorization' => $this->token, + 'Content-Type' => 'application/json', + ] + ]; + + $httpResponse = $this->request('GET', '/v1/sms/senders', $options); + + return $httpResponse->toArray(); + } + + public function balance(): array + { + $options = [ + 'headers' => [ + 'Authorization' => $this->token, + 'Content-Type' => 'application/json', + ] + ]; + + $httpResponse = $this->request('GET', '/v1/balance', $options); + + return $httpResponse->toArray(); + } +} diff --git a/src/Service/Client/SmsruClientService.php b/src/Service/Client/SmsruClientService.php new file mode 100644 index 0000000..81899f9 --- /dev/null +++ b/src/Service/Client/SmsruClientService.php @@ -0,0 +1,66 @@ +token = $token; + $this->sender = $sender; + } + + public function send(string $to, string $msg): array + { + $options = [ + 'query' => [ + 'to' => $to, + 'msg' => $msg, + 'from' => $this->sender, + 'json' => 1, + 'api_id' => $this->token, + 'test' => 0 + ] + ]; + + $httpResponse = $this->request('GET', '/sms/send', $options); + + return $httpResponse->toArray(); + } + + public function senders(): array + { + $options = [ + 'query' => [ + 'json' => 1, + 'api_id' => $this->token + ] + ]; + + $httpResponse = $this->request('GET', '/my/senders', $options); + + return $httpResponse->toArray(); + } + + public function balance(): array + { + $options = [ + 'query' => [ + 'json' => 1, + 'api_id' => $this->token + ] + ]; + + $httpResponse = $this->request('GET', '/my/balance', $options); + + return $httpResponse->toArray(); + } +} diff --git a/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php b/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php new file mode 100644 index 0000000..9d3d9cd --- /dev/null +++ b/src/Service/Client/Stub/AlwaysValidSmartCaptchaClientService.php @@ -0,0 +1,23 @@ +logger->info('SmartCaptcha suppressed (noop stub)', [ + 'ip' => $clientIp, + ]); + + return ['status' => 'ok', 'message' => '', 'stub' => true]; + } +} diff --git a/src/Service/Client/Stub/NoopCalltouchClientService.php b/src/Service/Client/Stub/NoopCalltouchClientService.php new file mode 100644 index 0000000..809df62 --- /dev/null +++ b/src/Service/Client/Stub/NoopCalltouchClientService.php @@ -0,0 +1,24 @@ +logger->info('Calltouch lead suppressed (noop stub)', [ + 'regionId' => $requests->regionId ?? null, + ]); + + return ['leadId' => 'test-stub', 'stub' => true]; + } +} diff --git a/src/Service/Client/Stub/NoopSmsClientService.php b/src/Service/Client/Stub/NoopSmsClientService.php new file mode 100644 index 0000000..7956f42 --- /dev/null +++ b/src/Service/Client/Stub/NoopSmsClientService.php @@ -0,0 +1,35 @@ +logger->info('SMS suppressed (noop stub)', ['to' => $to]); + + return ['status' => 'ok', 'stub' => true]; + } + + public function senders(): array + { + $this->logger->info('SMS senders suppressed (noop stub)'); + + return ['status' => 'ok', 'stub' => true, 'senders' => []]; + } + + public function balance(): array + { + $this->logger->info('SMS balance suppressed (noop stub)'); + + return ['status' => 'ok', 'stub' => true, 'balance' => 0]; + } +} diff --git a/src/Service/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/Crypt/AESCryptService.php b/src/Service/Crypt/AESCryptService.php new file mode 100644 index 0000000..e3fd6db --- /dev/null +++ b/src/Service/Crypt/AESCryptService.php @@ -0,0 +1,66 @@ +cipher = $cipher; + $this->secretKey = $secretKey; + + if (!in_array($this->cipher, openssl_get_cipher_methods())) { + throw new \RuntimeException(sprintf('Cipher method "%s" is not available', $this->cipher)); + } + } + + public function encrypt(string $plaintext): string + { + $ivlen = openssl_cipher_iv_length($this->cipher); + $iv = openssl_random_pseudo_bytes($ivlen); + $ciphertext_raw = openssl_encrypt( + $plaintext, + $this->cipher, + $this->secretKey, + OPENSSL_RAW_DATA, + $iv + ); + $hmac = hash_hmac('sha256', $ciphertext_raw, $this->secretKey, true); + + return base64_encode($iv.$hmac.$ciphertext_raw); + } + + public function decrypt(string $ciphertext): ?string + { + $c = base64_decode($ciphertext); + + if ($c === false) { + return null; + } + + $ivlen = openssl_cipher_iv_length($this->cipher); + $iv = substr($c, 0, $ivlen); + $hmac = substr($c, $ivlen, 32); + $ciphertext_raw = substr($c, $ivlen + 32); + + $plaintext = openssl_decrypt( + $ciphertext_raw, + $this->cipher, + $this->secretKey, + OPENSSL_RAW_DATA, + $iv + ); + + if ($plaintext === false) { + return null; + } + + $calcmac = hash_hmac('sha256', $ciphertext_raw, $this->secretKey, true); + + return hash_equals($hmac, $calcmac) ? $plaintext : null; + } +} \ No newline at end of file diff --git a/src/Service/Crypt/Interfaces/AESCryptServiceInterface.php b/src/Service/Crypt/Interfaces/AESCryptServiceInterface.php new file mode 100644 index 0000000..3291cd1 --- /dev/null +++ b/src/Service/Crypt/Interfaces/AESCryptServiceInterface.php @@ -0,0 +1,9 @@ +userRepository = $userRepository; + $this->jwtManager = $jwtManager; + $this->tokenStorage = $tokenStorage; + } + + public function getUser(): ?User + { + $decodedJwtToken = $this->jwtManager->decode($this->tokenStorage->getToken()); + + if ($decodedJwtToken) { + return $this->userRepository->findOneBy(['email' => $decodedJwtToken['username']]); + } + + return NULL; + } +} diff --git a/src/Service/Department/DepartmentService.php b/src/Service/Department/DepartmentService.php new file mode 100644 index 0000000..e2d28c2 --- /dev/null +++ b/src/Service/Department/DepartmentService.php @@ -0,0 +1,34 @@ +departmentRepository + ->createFilteredQueryBuilder($params) + ->getQuery() + ->getResult() + ; + } + + public function getShow(array $params = []): ?PriceList + { + return $this + ->departmentRepository + ->createFilteredQueryBuilder($params) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } +} \ No newline at end of file diff --git a/src/Service/DiseaseCrudService.php b/src/Service/DiseaseCrudService.php new file mode 100644 index 0000000..ff42797 --- /dev/null +++ b/src/Service/DiseaseCrudService.php @@ -0,0 +1,107 @@ +em->getConnection()->executeStatement($sql); + } +} diff --git a/src/Service/ErrorHandler/ScheduleErrorHandlerService.php b/src/Service/ErrorHandler/ScheduleErrorHandlerService.php new file mode 100644 index 0000000..d990fac --- /dev/null +++ b/src/Service/ErrorHandler/ScheduleErrorHandlerService.php @@ -0,0 +1,54 @@ +logger = $logger->withName('infoclinica-error'); + } + + public function handleHttpException( + HttpExceptionInterface $e, + string $queryString, + ?int $duration = null, + ?bool $isOnlineMode = null + ): array { + $errorData = [ + 'query' => $queryString, + 'online_mode' => $isOnlineMode, + 'status_code' => $e->getResponse()->getStatusCode(), + 'duration_ms' => $duration, + 'error' => $e->getMessage(), + 'response' => $e->getResponse()->getContent(false) + ]; + + $this->logger->error('API request failed', $errorData); + + return $errorData; + } + + public function handleGeneralException( + \Exception $e, + string $queryString, + ?int $duration = null, + ?bool $isOnlineMode = null + ): array { + $errorData = [ + 'query' => $queryString, + 'online_mode' => $isOnlineMode, + 'duration_ms' => $duration, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]; + + $this->logger->critical('Unexpected error', $errorData); + + return $errorData; + } +} \ No newline at end of file diff --git a/src/Service/FileUploader/FileUploaderService.php b/src/Service/FileUploader/FileUploaderService.php new file mode 100644 index 0000000..3ae035e --- /dev/null +++ b/src/Service/FileUploader/FileUploaderService.php @@ -0,0 +1,59 @@ +getClientOriginalName(), PATHINFO_FILENAME); + $safeFilename = $this->slugger->slug($originalFilename); + $fileName = $safeFilename.'-'.uniqid().'.'.$file->guessExtension(); + + try { + $file->move($this->targetDirectory, $fileName); + } catch (FileException $e) { + throw new \RuntimeException('Ошибка при загрузке файла: ' . $e->getMessage()); + } + + return $fileName; + } + + public function remove($file): bool + { + try { + if ($file !== null && $file !== '') { + if (is_file($file) && file_exists($file)) { + @unlink($file); + + return true; + } + } + + return false; + } catch (Exception $e) { + throw new \RuntimeException('Ошибка при удалении файла: ' . $e->getMessage()); + } + } + + public function getTargetDirectory(): string + { + return $this->targetDirectory; + } + + public function setTargetDirectory(?string $dir): self + { + $this->targetDirectory = $this->targetDirectory . '/' . $dir; + return $this; + } +} diff --git a/src/Service/FileUploader/Interfaces/FileUploaderServiceInterface.php b/src/Service/FileUploader/Interfaces/FileUploaderServiceInterface.php new file mode 100644 index 0000000..650aca0 --- /dev/null +++ b/src/Service/FileUploader/Interfaces/FileUploaderServiceInterface.php @@ -0,0 +1,13 @@ +filialRepository + ->createFilteredQueryBuilder($params) + ->getQuery() + ->getResult() + ; + } + + public function getShow(array $params = []): ?Filial + { + return $this + ->filialRepository + ->createFilteredQueryBuilder($params) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } +} \ No newline at end of file diff --git a/src/Service/Helper/HelperService.php b/src/Service/Helper/HelperService.php new file mode 100644 index 0000000..3d8b9ca --- /dev/null +++ b/src/Service/Helper/HelperService.php @@ -0,0 +1,33 @@ + "год", + $lastDigit >= 2 && $lastDigit <= 4 && ($lastTwoDigits < 10 || $lastTwoDigits >= 20) => "года", + default => "лет" + }; + } + + return $year . ' ' . $response; + } +} \ No newline at end of file diff --git a/src/Service/Image/ImageService.php b/src/Service/Image/ImageService.php new file mode 100644 index 0000000..656e864 --- /dev/null +++ b/src/Service/Image/ImageService.php @@ -0,0 +1,164 @@ +createImageResource($filePath, $imageType); + if ($src === false) { + throw new FileException('Unsupported image type'); + } + + [$newWidth, $newHeight] = $this->calculateDimensions( + $originalWidth, + $originalHeight, + $width, + $height + ); + + $processedImage = $this->processImage( + $src, + $originalWidth, + $originalHeight, + $newWidth, + $newHeight, + $imageType + ); + + return $this->createResponse($processedImage, $imageType); + } catch (\Exception $e) { + return new Response( + $e->getMessage(), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + } + + private function createImageResource(string $filePath, int $imageType) + { + return match ($imageType) { + IMAGETYPE_GIF => @imagecreatefromgif($filePath), + IMAGETYPE_JPEG => @imagecreatefromjpeg($filePath), + IMAGETYPE_PNG => @imagecreatefrompng($filePath), + IMAGETYPE_WEBP => @imagecreatefromwebp($filePath), + default => false, + }; + } + + private function calculateDimensions( + int $originalWidth, + int $originalHeight, + int $targetWidth, + int $targetHeight + ): array { + if ($originalWidth <= $targetWidth && $originalHeight <= $targetHeight) { + return [$originalWidth, $originalHeight]; + } + + $widthRatio = $targetWidth / $originalWidth; + $heightRatio = $targetHeight / $originalHeight; + + $ratio = min($widthRatio, $heightRatio); + + return [ + (int) round($originalWidth * $ratio), + (int) round($originalHeight * $ratio) + ]; + } + + private function processImage( + $src, + int $originalWidth, + int $originalHeight, + int $newWidth, + int $newHeight, + int $imageType + ) { + $tmp = imagecreatetruecolor($newWidth, $newHeight); + + // Handle transparency for GIF and PNG + if ($imageType === IMAGETYPE_GIF || $imageType === IMAGETYPE_PNG) { + $this->preserveTransparency($tmp); + } + + imagecopyresampled( + $tmp, + $src, + 0, 0, 0, 0, + $newWidth, + $newHeight, + $originalWidth, + $originalHeight + ); + + imagedestroy($src); + + return $tmp; + } + + private function preserveTransparency($imageResource): void + { + imagealphablending($imageResource, false); + imagesavealpha($imageResource, true); + $transparent = imagecolorallocatealpha($imageResource, 255, 255, 255, 127); + imagefill($imageResource, 0, 0, $transparent); + } + + private function createResponse($imageResource, int $imageType): Response + { + ob_start(); + + switch ($imageType) { + case IMAGETYPE_GIF: + imagegif($imageResource); + $mimeType = 'image/gif'; + break; + case IMAGETYPE_JPEG: + imagejpeg($imageResource, null, self::DEFAULT_QUALITY); + $mimeType = 'image/jpeg'; + break; + case IMAGETYPE_PNG: + imagepng($imageResource, null, 0); + $mimeType = 'image/png'; + break; + case IMAGETYPE_WEBP: + imagewebp($imageResource, null, self::DEFAULT_QUALITY); + $mimeType = 'image/webp'; + break; + default: + $mimeType = self::DEFAULT_MIME_TYPE; + imagejpeg($imageResource, null, self::DEFAULT_QUALITY); + } + + $imageContent = ob_get_clean(); + imagedestroy($imageResource); + + return new Response( + $imageContent, + Response::HTTP_OK, + ['Content-Type' => $mimeType] + ); + } +} \ No newline at end of file diff --git a/src/Service/Image/Interfaces/ImageServiceInterface.php b/src/Service/Image/Interfaces/ImageServiceInterface.php new file mode 100644 index 0000000..03489f2 --- /dev/null +++ b/src/Service/Image/Interfaces/ImageServiceInterface.php @@ -0,0 +1,10 @@ +locationRepository + ->createFilteredQueryBuilder($params) + ->getQuery() + ->getResult(); + } + + public function getShow(array $params): ?Location + { + return $this + ->locationRepository + ->createFilteredQueryBuilder($params) + ->getQuery() + ->getOneOrNullResult(); + } +} \ No newline at end of file diff --git a/src/Service/Mail/SendMailConfig.php b/src/Service/Mail/SendMailConfig.php new file mode 100644 index 0000000..40d7a76 --- /dev/null +++ b/src/Service/Mail/SendMailConfig.php @@ -0,0 +1,18 @@ +accessToken; + } +} diff --git a/src/Service/Mail/SendMailService.php b/src/Service/Mail/SendMailService.php new file mode 100644 index 0000000..344d97a --- /dev/null +++ b/src/Service/Mail/SendMailService.php @@ -0,0 +1,29 @@ +from(sprintf('%s <%s>', $this->fromName, $this->fromEmail)) + ->to($mailto) + ->subject($subject) + ->text($message); + + $this->mailer->send($email); + } +} diff --git a/src/Service/MedicalCenterCrudService.php b/src/Service/MedicalCenterCrudService.php new file mode 100644 index 0000000..1dc1be7 --- /dev/null +++ b/src/Service/MedicalCenterCrudService.php @@ -0,0 +1,128 @@ +em->getConnection()->executeStatement($sql); + } +} diff --git a/src/Service/NewsCrudService.php b/src/Service/NewsCrudService.php new file mode 100644 index 0000000..c5a7fd0 --- /dev/null +++ b/src/Service/NewsCrudService.php @@ -0,0 +1,85 @@ +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/Performance/PerformanceTrackerService.php b/src/Service/Performance/PerformanceTrackerService.php new file mode 100644 index 0000000..9981aae --- /dev/null +++ b/src/Service/Performance/PerformanceTrackerService.php @@ -0,0 +1,36 @@ +startTime = microtime(true); + $this->endTime = null; + } + + public function stop(): void + { + $this->endTime = microtime(true); + } + + public function getDurationMs(): int + { + if (!$this->startTime) { + return 0; + } + + $endTime = $this->endTime ?? microtime(true); + return (int)round(($endTime - $this->startTime) * 1000); + } + + public function reset(): void + { + $this->startTime = null; + $this->endTime = null; + } +} \ No newline at end of file diff --git a/src/Service/PriceList/PriceListService.php b/src/Service/PriceList/PriceListService.php new file mode 100644 index 0000000..731e539 --- /dev/null +++ b/src/Service/PriceList/PriceListService.php @@ -0,0 +1,34 @@ +priceListRepository + ->createFilteredQueryBuilder($params) + ->getQuery() + ->getResult() + ; + } + + public function getShow($params): ?PriceList + { + return $this + ->priceListRepository + ->createFilteredQueryBuilder($params) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } +} \ No newline at end of file diff --git a/src/Service/PromoCrudService.php b/src/Service/PromoCrudService.php new file mode 100644 index 0000000..c6b21b6 --- /dev/null +++ b/src/Service/PromoCrudService.php @@ -0,0 +1,83 @@ +em->getConnection()->executeStatement($sql); + } +} diff --git a/src/Service/ScheduleCache/ScheduleCacheService.php b/src/Service/ScheduleCache/ScheduleCacheService.php new file mode 100644 index 0000000..404db7b --- /dev/null +++ b/src/Service/ScheduleCache/ScheduleCacheService.php @@ -0,0 +1,208 @@ +logger = $logger->withName('infoclinica-cache'); + } + + public function getCachedSchedule(string $queryString, bool $isOnlineMode): ?array + { + try { + $cacheTime = new \DateTime(sprintf('-%d minutes', self::CACHE_TTL_MINUTES)); + + $recentSchedules = $this->scheduleRepository->findByQueryModeAndTime( + $queryString, + $isOnlineMode, + $cacheTime + ); + + if (empty($recentSchedules)) { + return null; + } + + return $this->reconstructFromDatabase($recentSchedules, $isOnlineMode); + + } catch (\Exception $e) { + $this->logger->error('Error reading from cache', [ + 'error' => $e->getMessage(), + 'query' => $queryString, + 'onlineMode' => $isOnlineMode + ]); + return null; + } + } + + public function saveSchedule(array $scheduleData, string $queryString, bool $isOnlineMode): int + { + try { + // Удаляем старые записи для этого запроса и типа расписания + $this->scheduleRepository->removeByQueryStringAndMode($queryString, $isOnlineMode); + + $savedCount = 0; + $now = new \DateTime(); + + foreach ($scheduleData['schedule'] ?? [] as $department => $dates) { + foreach ($dates as $dateString => $daySchedule) { + $savedCount += $this->saveDaySchedule( + $department, + $dateString, + $daySchedule, + $queryString, + $isOnlineMode, + $now + ); + } + } + + $this->entityManager->flush(); + + $this->logger->info('Successfully saved schedule to cache', [ + 'query' => $queryString, + 'onlineMode' => $isOnlineMode, + 'saved_records' => $savedCount + ]); + + return $savedCount; + + } catch (\Exception $e) { + $this->logger->error('Error saving to cache', [ + 'error' => $e->getMessage(), + 'query' => $queryString, + 'onlineMode' => $isOnlineMode + ]); + throw $e; + } + } + + private function saveDaySchedule( + string $department, + string $dateString, + array $daySchedule, + string $queryString, + bool $isOnlineMode, + \DateTime $createdAt + ): int { + $count = 0; + + foreach ($daySchedule['intervals'] ?? [] as $interval) { + $schedule = new Schedule(); + + $workDate = \DateTime::createFromFormat('Ymd', $dateString); + if ($workDate === false) { + $workDate = new \DateTime(); + } + + // Базовые поля + $schedule + ->setDcode((string)($daySchedule['dcode'] ?? '')) + ->setDepartment((string)$department) + ->setFilial((int)($daySchedule['filial'] ?? 0)) + ->setSchedident((string)($daySchedule['schedident'] ?? '')) + ->setWorkdate($workDate) + ->setRnum((string)($daySchedule['rnum'] ?? '')) + ->setTime((string)($interval['time'] ?? '')) + ->setIsFree((bool)($daySchedule['isFree'] ?? false)) + ->setOnlineMode($isOnlineMode) + ->setIntervalIsFree((bool)($interval['isFree'] ?? false)) + ->setQueryString($queryString) + ->setCreatedAt($createdAt); + + // Поля только для офлайн расписания + if (!$isOnlineMode) { + $schedule + ->setRfloor($daySchedule['rfloor'] ?? null) + ->setRbuilding($daySchedule['rbuilding'] ?? null); + } + + // Поле priceInfo только для онлайн расписания + if ($isOnlineMode && isset($daySchedule['priceInfo'])) { + $schedule->setPriceInfo($daySchedule['priceInfo']); + } + + $this->entityManager->persist($schedule); + $count++; + } + + return $count; + } + + private function reconstructFromDatabase(array $schedules, bool $isOnlineMode): array + { + $result = [ + 'schedule' => [], + 'nearestDate' => [] + ]; + + foreach ($schedules as $schedule) { + $department = $schedule->getDepartment(); + $workDate = $schedule->getWorkdate()->format('Ymd'); + + if (!isset($result['schedule'][$department][$workDate])) { + $dayData = [ + 'schedident' => $schedule->getSchedident(), + 'rnum' => $schedule->getRnum(), + 'dcode' => $schedule->getDcode(), + 'filial' => $schedule->getFilial(), + 'intervals' => [], + 'depnum' => $department, + 'isFree' => $schedule->isFree() + ]; + + // Добавляем поля в зависимости от типа расписания + if (!$isOnlineMode) { + $dayData['rfloor'] = $schedule->getRfloor(); + $dayData['rbuilding'] = $schedule->getRbuilding(); + } + + if ($isOnlineMode && $schedule->getPriceInfo()) { + $dayData['priceInfo'] = $schedule->getPriceInfo(); + } + + $result['schedule'][$department][$workDate] = $dayData; + + // Сохраняем nearestDate + if (!isset($result['nearestDate'][$department])) { + $result['nearestDate'][$department] = (int)$workDate; + } + } + + $result['schedule'][$department][$workDate]['intervals'][] = [ + 'time' => $schedule->getTime(), + 'isFree' => $schedule->isIntervalIsFree() + ]; + } + + return $result; + } + + public function clearOldCache(\DateTimeInterface $olderThan): int + { + try { + return $this->scheduleRepository->removeOlderThan($olderThan); + } catch (\Exception $e) { + $this->logger->error('Error clearing old cache', [ + 'error' => $e->getMessage() + ]); + throw $e; + } + } + + public function getCacheStats(): array + { + return $this->scheduleRepository->getCacheStatistics(); + } +} \ No newline at end of file diff --git a/src/Service/Sequence/SequenceService.php b/src/Service/Sequence/SequenceService.php new file mode 100644 index 0000000..edaea27 --- /dev/null +++ b/src/Service/Sequence/SequenceService.php @@ -0,0 +1,64 @@ +em->getConnection(); + $metadata = $this->em->getClassMetadata($entityClass); + + $tableName = $metadata->getTableName(); + $sequenceName = $tableName . '_id_seq'; + + // Проверяем существует ли последовательность + $sequenceExists = $connection->executeQuery(" + SELECT 1 FROM pg_class WHERE relname = '$sequenceName' + ")->fetchOne(); + + if (!$sequenceExists) { + throw new \RuntimeException("Sequence $sequenceName does not exist"); + } + + // Получаем текущие значения + $maxId = (int) $connection->executeQuery("SELECT COALESCE(MAX(id), 0) FROM $tableName")->fetchOne(); + $currentSeq = (int) $connection->executeQuery("SELECT last_value FROM $sequenceName")->fetchOne(); + + // Если последовательность отстает, обновляем ее + if ($maxId >= $currentSeq) { + $nextVal = $maxId + 1; + $connection->executeStatement("SELECT setval('$sequenceName', $nextVal)"); + + // Проверяем результат + $newSeq = (int) $connection->executeQuery("SELECT last_value FROM $sequenceName")->fetchOne(); + + return $newSeq === $nextVal; + } + + return true; + } + + public function debugSequence(string $entityClass): array + { + $connection = $this->em->getConnection(); + $metadata = $this->em->getClassMetadata($entityClass); + + $tableName = $metadata->getTableName(); + $sequenceName = $tableName . '_id_seq'; + + return [ + 'table_name' => $tableName, + 'sequence_name' => $sequenceName, + 'max_id' => $connection->executeQuery("SELECT COALESCE(MAX(id), 0) FROM $tableName")->fetchOne(), + 'current_sequence' => $connection->executeQuery("SELECT last_value FROM $sequenceName")->fetchOne(), + 'sequence_exists' => (bool) $connection->executeQuery("SELECT 1 FROM pg_class WHERE relname = '$sequenceName'")->fetchOne(), + ]; + } +} \ No newline at end of file diff --git a/src/Service/SiteServiceCrudService.php b/src/Service/SiteServiceCrudService.php new file mode 100644 index 0000000..befae42 --- /dev/null +++ b/src/Service/SiteServiceCrudService.php @@ -0,0 +1,206 @@ +em->getConnection()->executeStatement($sql); + } +} diff --git a/src/Service/Specialist/Interfaces/SpecialistServiceInterface.php b/src/Service/Specialist/Interfaces/SpecialistServiceInterface.php new file mode 100644 index 0000000..3333f0e --- /dev/null +++ b/src/Service/Specialist/Interfaces/SpecialistServiceInterface.php @@ -0,0 +1,17 @@ +messageBus->dispatch($message); + $handledStamp = $envelope->last(HandledStamp::class); + + return $handledStamp->getResult(); + } + + /** + * Получить расписание специалиста + */ + public function getSchedule(ScheduleDto $dto): array + { + $message = new GetScheduleMessage($dto->toQueryString(), $dto->onlineMode); + $envelope = $this->messageBus->dispatch($message); + $handledStamp = $envelope->last(HandledStamp::class); + + return $handledStamp->getResult(); + } + + /** + * Создать анонимную запись + */ + public function createAnonymousReserve(AnonymousReserveRequestDto $dto): array + { + $message = new GetAnonymousReserveRequestMessage($dto); + $envelope = $this->messageBus->dispatch($message); + $handledStamp = $envelope->last(HandledStamp::class); + + return $handledStamp->getResult(); + } + + /** + * Получить специалиста по ID или alias + */ + public function getSpecialist(string $identifier, int $regionId = null): ?Specialist + { + if (is_numeric($identifier)) { + return $this->specialistRepository + ->createFilteredQueryBuilder([ + 'id' => $identifier + ]) + ->getQuery() + ->getOneOrNullResult(); + } + + return $this->specialistRepository + ->createFilteredQueryBuilder([ + 'alias' => $identifier, + 'regionId' => $regionId + ]) + ->getQuery() + ->getOneOrNullResult(); + } + + public function getList(SpecialistFilterDto $filter): array + { + $params = $filter->toArray(); + return $this + ->specialistRepository + ->createFilteredQueryBuilder($params) + ->getQuery() + ->getResult(); + } + + public function getFilteredCount(SpecialistFilterDto $filter): int + { + $params = $filter->toArray(); + return $this->specialistRepository->countFiltered($params); + } +} \ No newline at end of file diff --git a/src/Service/Translite/Interfaces/TransliteServiceInterface.php b/src/Service/Translite/Interfaces/TransliteServiceInterface.php new file mode 100644 index 0000000..6835db7 --- /dev/null +++ b/src/Service/Translite/Interfaces/TransliteServiceInterface.php @@ -0,0 +1,8 @@ + 'a', 'б' => 'b', 'в' => 'v', + 'г' => 'g', 'д' => 'd', 'е' => 'ye', + 'ё' => 'yo', 'ж' => 'zh', 'з' => 'z', + 'и' => 'i', 'й' => 'y', 'к' => 'k', + 'л' => 'l', 'м' => 'm', 'н' => 'n', + 'о' => 'o', 'п' => 'p', 'р' => 'r', + 'с' => 's', 'т' => 't', 'у' => 'u', + 'ф' => 'f', 'х' => 'kh', 'ц' => 'ts', + 'ч' => 'ch', 'ш' => 'sh', 'щ' => 'shch', + 'ь' => '', 'ы' => 'y', 'ъ' => '', + 'э' => 'e', 'ю' => 'yu', 'я' => 'ya' + ]; + + $string = mb_strtolower($string, 'UTF-8'); + $string = strtr($string, $converter); + $string = preg_replace('~[^-a-z0-9_]+~u', $replacement, $string); + $string = preg_replace('~' . preg_quote($replacement, '~') . '{2,}~', $replacement, $string); + return trim($string, $replacement); + } +} diff --git a/src/Service/User/AuthenticationService.php b/src/Service/User/AuthenticationService.php new file mode 100644 index 0000000..44a2f19 --- /dev/null +++ b/src/Service/User/AuthenticationService.php @@ -0,0 +1,56 @@ +userRepository->findOneBy(['uid' => $dto->uid]); + + if($user === null) + return [ + 'user' => NULL, + 'isPasswordValid' => NULL + ]; + + $isPasswordValid = $this->passwordHasher->isPasswordValid($user, $dto->password); + + return [ + 'user' => $user, + 'isPasswordValid' => $isPasswordValid + ]; + } + + public function jsonAuth(UserLoginDto $dto): array + { + $user = $this->userRepository->findOneBy(['email' => md5($dto->email)]); + + if($user === null) + return [ + 'user' => NULL, + 'isPasswordValid' => NULL + ]; + + $isPasswordValid = $this->passwordHasher->isPasswordValid($user, $dto->password); + + return [ + 'user' => $user, + 'isPasswordValid' => $isPasswordValid + ]; + } +} \ No newline at end of file diff --git a/src/Service/User/Interfaces/AuthenticationServiceInterface.php b/src/Service/User/Interfaces/AuthenticationServiceInterface.php new file mode 100644 index 0000000..387b178 --- /dev/null +++ b/src/Service/User/Interfaces/AuthenticationServiceInterface.php @@ -0,0 +1,12 @@ +setEmail(md5($dto->email)); + $user->setRegionId($dto->regionId); + $user->setRoles(['ROLE_USER']); + $user->setUid($dto->uid); + + $birthDate = \DateTime::createFromFormat('Ymd', $dto->birthDate); + + $user->setBirthDate($birthDate); + $user->setPassword( + $this->passwordHasher->hashPassword($user, $dto->password) + ); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } + + public function createByUidAndBirthDate(UserUidAuthDto $dto, int $defaultRegionId = 1): User + { + $user = new User(); + + // Генерируем email на основе uid + $user->setEmail(md5((string)$dto->uid)); + + // Устанавливаем дефолтный регион + $user->setRegionId($defaultRegionId); + + // Устанавливаем роль + $user->setRoles(['ROLE_USER']); + + // Устанавливаем uid + $user->setUid($dto->uid); + + // Парсим дату рождения + $birthDate = \DateTime::createFromFormat('Ymd', $dto->birthDate); + if (!$birthDate) { + // Пробуем другие форматы + $birthDate = \DateTime::createFromFormat('Y-m-d', $dto->birthDate); + } + if (!$birthDate) { + throw new \InvalidArgumentException('Неверный формат даты рождения'); + } + + $user->setBirthDate($birthDate); + + // Генерируем дефолтный пароль на основе uid и birthDate + $defaultPassword = md5($dto->uid . $dto->birthDate); + $user->setPassword( + $this->passwordHasher->hashPassword($user, $defaultPassword) + ); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } +} \ No newline at end of file diff --git a/src/Service/User/UserProfileService.php b/src/Service/User/UserProfileService.php new file mode 100644 index 0000000..da8d77a --- /dev/null +++ b/src/Service/User/UserProfileService.php @@ -0,0 +1,28 @@ +jwtDecoderService->getUser(); + $user->setRegionId($dto->regionId); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } +} \ No newline at end of file diff --git a/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php b/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php new file mode 100644 index 0000000..896db13 --- /dev/null +++ b/src/Service/XmlFeedGenerator/XmlFeedGeneratorService.php @@ -0,0 +1,623 @@ +dom = new DOMDocument('1.0', 'UTF-8'); + $this->dom->formatOutput = true; + } + + public function generateFeed(Filial $currentFilial, array $utmParams = []): string + { + $this->filial = $currentFilial; + $this->utmParams = $utmParams; + + $shop = $this->dom->createElement('shop'); + $shop->setAttribute('date', date('Y-m-d\TH:i:s\Z')); + $shop->setAttribute('version', '2.0'); + $this->dom->appendChild($shop); + + $this->addShopInfo($shop); + $this->addDoctors($shop); + $this->addClinics($shop); + $this->addServices($shop); + $this->addOffers($shop); + + return $this->dom->saveXML(); + } + + private function addServices(DOMElement $shop): void + { + $filter = new SpecialistFilterDto(); + $filter->active = true; + $filter->filial = $this->filial->getFid(); + + $doctors = $this->specialistService->getList($filter); + + $kodoperArray = array_map(function($doctor) { + return $doctor->getKodoper(); + }, $doctors); + $services = $this->priceListService->getList([ + 'filial' => $this->filial->getFid(), + 'kodoper' => $kodoperArray + ]); + $servicesElement = $this->dom->createElement('services'); + + foreach ($services as $service) { + $serviceElement = $this->dom->createElement('service'); + $serviceElement->setAttribute('id', $service->getId()); + + $this->addTextElement($serviceElement, 'name', $service->getSchname()); + $this->addTextElement($serviceElement, 'gov_id', $service->getKodoper()); + + $servicesElement->appendChild($serviceElement); + } + + $shop->appendChild($servicesElement); + } + + private function addShopInfo(DOMElement $shop): void + { + $shop->appendChild($this->dom->createElement('picture', $this->getShopPicture())); + $shop->appendChild($this->dom->createElement('name', $this->getShopName())); + $shop->appendChild($this->dom->createElement('company', $this->filial->getCompany())); + $shop->appendChild($this->dom->createElement('url', $this->filial->getOrigin())); + $shop->appendChild($this->dom->createElement('email', $this->filial->getEmail())); + } + + private function getShopName(): string + { + return match ($this->filial->getRegionId()) { + 94 => 'WMT', + default => match ($this->filial->getFid()) { + 9 => 'Совёнок', + default => 'Сова', + } + }; + } + + private function getShopPicture(): string + { + $picture = match ($this->filial->getRegionId()) { + 94 => 'wmtmed.png', + default => match ($this->filial->getFid()) { + 9 => 'sovenok.png', + 10 => 'comfort.jpg', + default => 'sovamed.png', + } + }; + + return rtrim($this->apiPublicUrl, '/') . "/images/logo/{$picture}"; + } + + private function getSpecialistLink(Specialist $specialist): string + { + $url = $this->filial->getOrigin(); + $url .= match ($this->filial->getId()) { + 8 => '/specialisty', + default => '/vrachi' + }; + $url .= match ($specialist->getStype()) { + 0 => '/vzroslyj-vrach/', + 1 => '/detskij-vrach/', + 2 => '/administraciya/', + default => '/vzroslyj-vrach/', + }; + $url .= $specialist->getAlias(); + $url .= '/'; + + // Формируем query string из UTM-параметров, если они есть + if (!empty($this->utmParams)) { + $queryParams = array_filter($this->utmParams, fn($value) => $value !== null && $value !== ''); + if (!empty($queryParams)) { + // Формируем query string вручную, гарантируя использование & + $parts = []; + foreach ($queryParams as $key => $value) { + if ($value !== null && $value !== '') { + $parts[] = rawurlencode($key) . '=' . rawurlencode($value); + } + } + if (!empty($parts)) { + $url .= '?' . implode('&', $parts); + } + } + } + + return $url; + } + + private function addSpecialist(Location $location): DOMElement + { + $doctor = $location->getSpecialist(); + + $doctorElement = $this->dom->createElement('doctor'); + $doctorElement->setAttribute('id', $doctor->getId() . '_' . $location->getId()); + + $url = $this->getSpecialistLink($doctor); + + $this->addTextElement($doctorElement, 'url', $url); + + $id = $doctor->getId(); + $doctorDescription = $this->getOfferDoctorDescription( + $id, + $location->getDcode(), + $location->getDepartment() + ); + $this->addTextElement($doctorElement, 'description', $doctorDescription ?? ''); + $picture = rtrim($this->apiPublicUrl, '/') . "/specialist/picture/{$id}"; + $this->addTextElement($doctorElement, 'picture', $picture); + $this->addTextElement($doctorElement, 'name', $doctor->getName() ?? ''); + $this->addTextElement($doctorElement, 'first_name', $doctor->getFullName()['firstName'] ?? ''); + $this->addTextElement($doctorElement, 'surname', $doctor->getFullName()['lastName'] ?? ''); + $this->addTextElement($doctorElement, 'patronymic', $doctor->getFullName()['middleName'] ?? ''); + + $experience = $doctor->getExperience(); + + if ($experience && is_numeric($experience) && $experience > 1900 && $experience <= date('Y')) { + $this->addTextElement($doctorElement, 'career_start_date', $experience . '-01-01'); + $this->addTextElement($doctorElement, 'experience_years', $experience ? date('Y') - $experience : ''); + } + + $this->addTextElement($doctorElement, 'degree', $doctor->getDegree() ?? ''); + $this->addTextElement($doctorElement, 'category', $doctor->getCategory() ?? ''); + $this->addTextElement($doctorElement, 'internal_id', 'doctor_' . $doctor->getId()); + + $jobElement = $this->dom->createElement('job'); + $this->addTextElement($jobElement, 'organization', $this->filial->getCompany() ?? ''); + $this->addTextElement($jobElement, 'position', $doctor->getPost() ?? ''); + $doctorElement->appendChild($jobElement); + + return $doctorElement; + } + + private function addDoctors(DOMElement $shop): void + { + $locations = $this->locationService->getList([ + 'filial' => $this->filial->getFid(), + 'active' => true, + ]); + + $doctorsElement = $this->dom->createElement('doctors'); + $seenSpecialistIds = []; + + foreach ($locations as $location) { + $specialist = $location->getSpecialist(); + if ($specialist === null) { + continue; + } + + $specialistId = $specialist->getId(); + if (isset($seenSpecialistIds[$specialistId])) { + continue; + } + $seenSpecialistIds[$specialistId] = true; + + $doctorElement = $this->addSpecialist($location); + $doctorsElement->appendChild($doctorElement); + } + + $shop->appendChild($doctorsElement); + } + + private function prepareXmlContent($content): string + { + $content = str_replace('&', '&', $content); + $validEntities = [ + '&nbsp;' => ' ', + '&amp;' => '&', + '&lt;' => '<', + '&gt;' => '>', + '&quot;' => '"', + '&apos;' => ''' + ]; + $content = str_replace(array_keys($validEntities), array_values($validEntities), $content); + + return htmlspecialchars($content, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } + + private function getClinicInteralId(): int + { + return match ($this->filial->getFid()) { + 1 => 233054332033, // симбирская + 3 => 25612492147, // ленина + 4 => 1766329112, // разина + 5 => 199291222081, // сакко + 7 => 103319931090, // никитинская + 8 => 1281270894, // академическая + 9 => 227335089844, // чапаева совенок + 10 => 196496853150, // б.казачия комфорт + 11 => 97526446099, // мая + 12 => 134757619374, // постовая + 13 => 82702166632, // московский + 14 => 168673809491, // петра метальникова + default => (int) $this->filial->getFid(), + }; + } + + private function addClinics(DOMElement $shop): void + { + $clinicsElement = $this->dom->createElement('clinics'); + + $clinicElement = $this->dom->createElement('clinic'); + $clinicElement->setAttribute('id', $this->filial->getFid()); + + $this->addTextElement($clinicElement, 'picture', $this->getShopPicture()); + $this->addTextElement($clinicElement, 'url', $this->filial->getOrigin()); + $this->addTextElement($clinicElement, 'name', $this->getShopName()); + + $city = match ((int) $this->filial->getRegionId()) { + 91 => 'г. Саратов', + 92 => 'г. Волгоград', + 93 => 'г. Воронеж', + 94 => 'г. Краснодар', + default => '', + }; + $this->addTextElement($clinicElement, 'city', $city); + $this->addTextElement($clinicElement, 'address', $this->filial->getShortName()); + $this->addTextElement($clinicElement, 'phone', $this->filial->getPhone()); + $this->addTextElement($clinicElement, 'email', $this->filial->getEmail()); + $this->addTextElement($clinicElement, 'internal_id', $this->getClinicInteralId()); + + $clinicsElement->appendChild($clinicElement); + + $shop->appendChild($clinicsElement); + } + + private function specialitet(): array + { + return [ + 'абдоминальный хирург', + 'акушер', + 'акушер-гинеколог', + 'аллерголог', + 'аллерголог-иммунолог', + 'андролог', + 'анестезиолог', + 'анестезиолог-реаниматолог', + 'аритмолог', + 'артролог', + 'бариатрический хирург', + 'вегетолог', + 'венеролог', + 'вертебролог', + 'вирусолог', + 'врач лабораторной диагностики', + 'врач лфк', + 'врач общей практики', + 'врач по медико-социальной экспертизе', + 'врач по паллиативной медицинской помощи', + 'врач по спортивной медицине', + 'врач по рентгенэндоваскулярным диагностике и лечению', + 'врач скорой помощи', + 'врач УЗИ', + 'врач функциональной диагностики', + 'врач эфферентной терапии', + 'гастроэнтеролог', + 'гематолог', + 'гемостазиолог', + 'генетик', + 'гепатолог', + 'гериатр (геронтолог)', + 'гинеколог', + 'гинеколог-эндокринолог', + 'гипнолог', + 'гирудотерапевт', + 'гнатолог', + 'гнойный хирург', + 'дезинфектолог', + 'дерматолог', + 'дерматовенеролог', + 'дефектолог', + 'диабетолог', + 'диетолог', + 'иммунолог', + 'инструктор лфк', + 'инфекционист', + 'кардиолог', + 'кардиохирург', + 'кинезиолог', + 'кистевой хирург', + 'клинический фармаколог', + 'колопроктолог (проктолог)', + 'косметолог', + 'лазерный хирург', + 'лимфолог', + 'логопед', + 'лор (отоларинголог)', + 'малоинвазивный хирург', + 'маммолог', + 'мануальный терапевт', + 'массажист', + 'миколог', + 'нарколог', + 'невролог', + 'нейропсихолог', + 'нейрофизиолог', + 'нейрохирург', + 'неонатолог', + 'нефролог', + 'нутрициолог', + 'ожоговый хирург (комбустиолог)', + 'онкогинеколог', + 'онкодерматолог', + 'онколог', + 'онколог-гематолог', + 'онкопроктолог', + 'онкоуролог', + 'оптометрист', + 'ортопед', + 'остеопат', + 'отоневролог', + 'офтальмолог (окулист)', + 'офтальмолог-протезист', + 'офтальмохирург', + 'паразитолог', + 'патологоанатом', + 'педиатр', + 'перинатолог', + 'пластический хирург', + 'подиатр', + 'подолог', + 'профпатолог', + 'психиатр', + 'психолог', + 'психотерапевт', + 'пульмонолог', + 'радиолог', + 'радиотерапевт', + 'реабилитолог', + 'реаниматолог', + 'ревматолог', + 'рентгенолог', + 'репродуктолог', + 'рефлексотерапевт', + 'сексолог', + 'семейный врач', + 'сердечно-сосудистый хирург', + 'сомнолог', + 'сосудистый хирург', + 'специалист по грудному вскармливанию', + 'спортивный врач', + 'стоматолог', + 'стоматолог-гигиенист', + 'стоматолог-имплантолог', + 'стоматолог-ортодонт', + 'стоматолог-ортопед', + 'стоматолог-пародонтолог', + 'стоматолог-терапевт', + 'стоматолог-хирург', + 'стоматолог-эндодонт', + 'судебно-медицинский эксперт', + 'сурдолог', + 'сурдолог-протезист', + 'терапевт', + 'токсиколог', + 'торакальный онколог', + 'торакальный хирург', + 'травматолог', + 'трансплантолог', + 'трансфузиолог', + 'трихолог', + 'уролог', + 'физиотерапевт', + 'фитотерапевт', + 'флеболог', + 'фониатр', + 'фтизиатр', + 'химиотерапевт', + 'хирург', + 'хирург-эндокринолог', + 'цитолог', + 'челюстно-лицевой хирург', + 'эмбриолог', + 'эндоваскулярный хирург', + 'эндокринолог', + 'эндоскопист', + 'эпидемиолог', + 'эпилептолог' + ]; + } + + private function getSpeciality(?string $spesiality): string + { + if (!$spesiality) return ''; + + // 1. Нормализуем строку: заменяем типы дефисов и удаляем "врач" + $normalized = str_replace(['—', 'Врач'], ['-', ' '], $spesiality); + $normalized = trim($normalized); + + $specialities = $this->specialitet(); + + // 2. Ищем точное совпадение с нормализованной строкой + if (in_array($normalized, $specialities, true)) { + return $normalized; + } + + // 3. Разбиваем на части и ищем каждую часть + $parts = preg_split('/[\s,]+/', $normalized); + + // Ищем каждую отдельную часть + foreach ($parts as $part) { + $part = trim($part); + if (in_array($part, $specialities, true)) { + return $part; + } + } + + // 4. Ищем комбинации из 2 слов + for ($i = 0; $i < count($parts) - 1; $i++) { + $combined = $parts[$i] . ' ' . $parts[$i + 1]; + if (in_array($combined, $specialities, true)) { + return $combined; + } + + // Также пробуем комбинацию с дефисом (на случай если в оригинале был пробел) + $combinedWithHyphen = $parts[$i] . '-' . $parts[$i + 1]; + if (in_array($combinedWithHyphen, $specialities, true)) { + return $combinedWithHyphen; + } + } + + // 5. Если не нашли, удаляем все дефисы и ищем снова + $withoutHyphens = str_replace('-', ' ', $normalized); + $withoutHyphens = trim($withoutHyphens); + + if ($withoutHyphens !== $normalized) { + $partsWithoutHyphens = preg_split('/[\s,]+/', $withoutHyphens); + + // Ищем каждую отдельную часть без дефисов + foreach ($partsWithoutHyphens as $part) { + $part = trim($part); + if (in_array($part, $specialities, true)) { + return $part; + } + } + + // Ищем комбинации из 2 слов без дефисов + for ($i = 0; $i < count($partsWithoutHyphens) - 1; $i++) { + $combined = $partsWithoutHyphens[$i] . ' ' . $partsWithoutHyphens[$i + 1]; + if (in_array($combined, $specialities, true)) { + return $combined; + } + } + } + + return ''; + } + + private function addOffers(DOMElement $shop): void + { + $offers = $this->locationService->getList([ + 'filial' => $this->filial->getFid(), + 'active' => true + ]); + + $offersElement = $this->dom->createElement('offers'); + + foreach ($offers as $offer) { + $specialist = $offer->getSpecialist(); + + if (empty($specialist)) { + continue; + } + + if (!$specialist->getKodoper()) { + continue; + } + + $priceList = $this->priceListService->getShow([ + 'kodoper' => $specialist->getKodoper(), + 'filial' => $offer->getFilial() + ]); + + if ($priceList) { + $priceInfo = $priceList->getPriceInfo(); + + if (is_array($priceInfo)) { + $offerElement = $this->dom->createElement('offer'); + $offerElement->setAttribute('id', $offer->getId()); + + $url = $this->getSpecialistLink($specialist); + + $this->addTextElement($offerElement, 'url', $url); + $this->addTextElement($offerElement, 'oms', 'false'); + $this->addTextElement($offerElement, 'appointment', 'true'); + $this->addTextElement($offerElement, 'online_schedule', 'true'); + + $serviceElement = $this->dom->createElement('service'); + $serviceElement->setAttribute('id', $priceList->getId()); + $offerElement->appendChild($serviceElement); + + $clinicElement = $this->dom->createElement('clinic'); + $clinicElement->setAttribute('id', $offer->getFilial()); + $doctorElement = $this->dom->createElement('doctor'); + $doctorElement->setAttribute('id', $specialist->getId() . '_' . $offer->getId()); + + $speciality = $this->getSpeciality($specialist->getPost()); + $this->addTextElement($doctorElement, 'speciality', $speciality); + $this->addTextElement($doctorElement, 'is_base_service', 'true'); + $this->addTextElement($doctorElement, 'children_appointment', ($specialist->getStype() == 1)? 'true': 'false'); + $this->addTextElement($doctorElement, 'adult_appointment', in_array($specialist->getStype(), [0,2])? 'true': 'false'); + $offerElement->appendChild($clinicElement); + $clinicElement->appendChild($doctorElement); + + $priceElement = $this->dom->createElement('price'); + $this->addTextElement($priceElement, 'base_price', $priceInfo['price']); + $this->addTextElement($priceElement, 'currency', 'RUB'); + $offerElement->appendChild($priceElement); + + $offersElement->appendChild($offerElement); + } + } + } + + $shop->appendChild($offersElement); + } + + private function getOfferDoctorDescription(int $specialistId, ?int $dcode, ?int $department): ?string + { + if ($dcode === null) { + return null; + } + + $cacheKey = sprintf('%d:%d:%s', $specialistId, $dcode, $department === null ? 'null' : (string) $department); + if (array_key_exists($cacheKey, $this->offerDescriptionCache)) { + return $this->offerDescriptionCache[$cacheKey]; + } + + $description = $this->specialistDcodeDescriptionRepository->findOneBySpecialistAndDcode( + $specialistId, + $dcode, + $department + ); + + if ($description === null) { + $this->offerDescriptionCache[$cacheKey] = null; + + return null; + } + + $content = $description->getContent(); + $this->offerDescriptionCache[$cacheKey] = $content; + + return $content; + } + + private function addTextElement(DOMElement $parent, string $name, $value): void + { + if ($value !== null && $value !== '') { + $element = $this->dom->createElement($name); + $text = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $element->appendChild($this->dom->createTextNode($text)); + $parent->appendChild($element); + } + } +} \ No newline at end of file diff --git a/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php b/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php new file mode 100644 index 0000000..d4502f2 --- /dev/null +++ b/src/Service/XmlFeedGenerator/XmlFeedGeneratorV1Service.php @@ -0,0 +1,264 @@ + */ + private array $filialsByFid = []; + + public function __construct( + private PriceListService $priceListService, + private SpecialistService $specialistService, + private HelperService $helperService, + private Connection $connection, + private string $apiPublicUrl, + private ?LoggerInterface $logger = null, + ) { + $this->dom = new DOMDocument('1.0', 'UTF-8'); + $this->dom->formatOutput = true; + } + + /** + * @param Filial|Filial[] $filials один филиал или массив филиалов (например, по региону) + */ + public function generateFeed(Filial|array $filials): string + { + $filialList = is_array($filials) ? $filials : [$filials]; + if ($filialList === []) { + $this->dom = new DOMDocument('1.0', 'UTF-8'); + $this->dom->formatOutput = true; + return $this->dom->saveXML(); + } + + $this->dom = new DOMDocument('1.0', 'UTF-8'); + $this->dom->formatOutput = true; + + $this->filial = $filialList[0]; + $this->filialsByFid = []; + foreach ($filialList as $f) { + $this->filialsByFid[$f->getFid()] = $f; + } + + $ymlCatalog = $this->dom->createElement('yml_catalog'); + $ymlCatalog->setAttribute('date', date('Y-m-d H:i')); + $this->dom->appendChild($ymlCatalog); + + $shop = $this->dom->createElement('shop'); + $ymlCatalog->appendChild($shop); + + $this->addShopInfo($shop); + $this->addCurrencies($shop); + $this->addCategories($shop); + $this->addSets($shop); + $this->addOffers($shop); + + return $this->dom->saveXML(); + } + + private function addShopInfo(DOMElement $shop): void + { + $this->addTextElement($shop, 'name', $this->getShopName()); + $this->addTextElement($shop, 'company', $this->filial->getCompany()); + $this->addTextElement($shop, 'url', $this->filial->getOrigin()); + $this->addTextElement($shop, 'email', $this->filial->getEmail()); + } + + private function addCurrencies(DOMElement $shop): void + { + $currencies = $this->dom->createElement('currencies'); + $currency = $this->dom->createElement('currency'); + $currency->setAttribute('id', 'RUR'); + $currency->setAttribute('rate', '1'); + $currencies->appendChild($currency); + $shop->appendChild($currencies); + } + + private function addCategories(DOMElement $shop): void + { + $categories = $this->dom->createElement('categories'); + $category = $this->dom->createElement('category', 'Врач'); + $category->setAttribute('id', '1'); + $categories->appendChild($category); + $shop->appendChild($categories); + } + + private function addSets(DOMElement $shop): void + { + $sets = $this->dom->createElement('sets'); + $departments = $this->connection->fetchAllAssociative( + 'SELECT did, name FROM departments WHERE active = :active ORDER BY id ASC', + ['active' => true] + ); + + foreach ($departments as $department) { + $set = $this->dom->createElement('set'); + $set->setAttribute('id', (string) $department['did']); + + $name = $this->dom->createElement('name', (string) $department['name']); + $set->appendChild($name); + + $sets->appendChild($set); + } + + $shop->appendChild($sets); + } + + private function getShopName(): string + { + return match ($this->filial->getRegionId()) { + 94 => 'Клиника ВМТ Сова', + default => match ($this->filial->getFid()) { + 9 => 'Совёнок', + default => 'Сова', + } + }; + } + + private function getShopPicture(): string + { + $picture = match ($this->filial->getRegionId()) { + 94 => 'wmtmed.png', + default => match ($this->filial->getFid()) { + 9 => 'sovenok.png', + 10 => 'comfort.jpg', + default => 'sovamed.png', + } + }; + + return rtrim($this->apiPublicUrl, '/') . "/images/logo/{$picture}"; + } + + private function getSpecialistLink(Specialist $specialist): string + { + $url = $this->filial->getOrigin(); + $url .= match ($this->filial->getId()) { + 9 => '/doctors/', + 14 => '/doctors/', + 10 => '/doctors/', + 8 => '/specialisty', + default => '/vrachi' + }; + + if (!in_array($this->filial->getId(), [9, 14, 10])) { + $sType = $specialist->getSType(); + if ($sType !== null) { + $url .= match ($sType) { + 0 => '/vzroslyj-vrach/', + 1 => '/detskij-vrach/', + 2 => '/administraciya/', + default => '/vzroslyj-vrach/', + }; + } else { + $this->logger?->info('Specialist type is not set', ['specialist' => $specialist->getId()]); + } + } + $url .= $specialist->getAlias(); + $url .= '/'; + + return $url; + } + + private function getCity(): string + { + return match ((int) $this->filial->getRegionId()) { + 91 => 'Саратов', + 92 => 'Волгоград', + 93 => 'Воронеж', + 94 => 'Краснодар', + default => '' + }; + } + + private function addOffers(DOMElement $shop): void + { + $offersElement = $this->dom->createElement('offers'); + + $fids = array_keys($this->filialsByFid); + if ($fids === []) { + $shop->appendChild($offersElement); + return; + } + + $filter = new SpecialistFilterDto(); + $filter->kiosk = true; + $filter->sFilial = array_map('strval', $fids); + $specialistList = $this->specialistService->getList($filter); + + foreach ($specialistList as $specialist) { + $specialistDescription = $specialist->getPost(); + + $offerElement = $this->dom->createElement('offer'); + $offerElement->setAttribute('id', (string) $specialist->getId()); + $url = $this->getSpecialistLink($specialist); + $experience = $specialist->getExperience(); + $experienceYears = $experience && is_numeric($experience) && $experience > 1900 && $experience <= date('Y') + ? date('Y') - (int) $experience + : null; + + if ($experienceYears !== null) { + $specialistDescription .= ' с опытом работы ' . $this->helperService->textYear($experienceYears, true); + } + $specialistDescription .= '. Запись к специалисту на сайте'; + + $this->addTextElement($offerElement, 'name', $specialist->getName() ?? ''); + $this->addTextElement($offerElement, 'description', $specialistDescription ?? ''); + $this->addTextElement($offerElement, 'url', $url); + $this->addTextElement($offerElement, 'set-ids', $specialist->getDcodes()); + + $picture = rtrim($this->apiPublicUrl, '/') . "/specialist/picture/{$specialist->getId()}"; + $this->addTextElement($offerElement, 'picture', $picture); + $this->addTextElement($offerElement, 'categoryId', '1'); + $this->addTextElement($offerElement, 'currencyId', 'RUR'); + $offersElement->appendChild($offerElement); + } + + $shop->appendChild($offersElement); + } + + private function addTextElement(DOMElement $parent, string $name, $value): void + { + if ($value !== null && $value !== '') { + $parent->appendChild($this->dom->createElement($name, $this->prepareXmlContent((string)$value))); + } + } + + private function addParamElement(DOMElement $parent, string $name, $value): void + { + if ($value !== null && $value !== '') { + $param = $this->dom->createElement('param', $this->prepareXmlContent((string)$value)); + $param->setAttribute('name', $name); + $parent->appendChild($param); + } + } + + private function prepareXmlContent($content): string + { + $content = str_replace('&', '&', $content); + $validEntities = [ + '&nbsp;' => ' ', + '&amp;' => '&', + '&lt;' => '<', + '&gt;' => '>', + '&quot;' => '"', + '&apos;' => ''' + ]; + $content = str_replace(array_keys($validEntities), array_values($validEntities), $content); + + return htmlspecialchars($content, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } +} \ No newline at end of file diff --git a/templates/.DS_Store b/templates/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..0671ed03ba13e3aa256ba4573631ef1d907e4b44 GIT binary patch literal 6148 zcmeHK!A`?441F0B3~||!V@}-n2W2W>updBeCqQcHU>fKC9sj_MKjGO96;)S>6GD?M zIWKkW#C?hC20&OJsui#Vu%HUgF4-Ir85bSNELmhQ=o~e+*w+uMP1%mT-S8h7kiEM= zfdigV;m7_pJ4^TcX6(_ld0y0Q!4ZisKR@1Yk9k$U@Y+4fY587F&LQ*J^Ble64NoYs zwe!?_yo^02^BK(3v=jLZyoUKi$;ZVRa0Z-#Q)7TNTV!!%=&dv03^)Tl1M+={RKYA^ zXQ-bJHhKjh&S_R*U;p8VQ4GW^VP{ASC0HoYLPNS@1PiAY^m0;bBlVst oB7U7=AHt57V){xczNV_s9;HLf5_X2PQ2a+gY4FAw_)`YH0BT=Lg8%>k literal 0 HcmV?d00001 diff --git a/templates/base.html.twig b/templates/base.html.twig new file mode 100644 index 0000000..039c2fc --- /dev/null +++ b/templates/base.html.twig @@ -0,0 +1,18 @@ + + + + + {% block title %}Welcome!{% endblock %} + + + {% block stylesheets %} + {% endblock %} + + {% block javascripts %} + {% block importmap %}{{ importmap('app') }}{% endblock %} + {% endblock %} + + + {% block body %}{% endblock %} + + diff --git a/templates/base_plain.html.twig b/templates/base_plain.html.twig new file mode 100644 index 0000000..335dace --- /dev/null +++ b/templates/base_plain.html.twig @@ -0,0 +1,19 @@ + + + + + {% block title %}Welcome!{% endblock %} + + + + + + {% block body %}{% endblock %} + + diff --git a/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig b/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig new file mode 100644 index 0000000..2df851f --- /dev/null +++ b/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig @@ -0,0 +1,46 @@ +{# This file is part of the API Platform project. + +(c) Kévin Dunglas + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. #} + + + + + {% block meta %} + + {% endblock meta %} + {% block title %}{{ swagger_data.spec.info.title }}{% endblock title %} + + {% block stylesheets %} + {{ nelmioAsset(assets_mode, 'swagger-ui/swagger-ui.css') }} + {{ nelmioAsset(assets_mode, 'style.css') }} + {% endblock stylesheets %} + + {% block swagger_data %} + {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} + + {% endblock swagger_data %} + + + {% block swagger_ui %} +
+ {% endblock %} + + {% block javascripts %} + {{ nelmioAsset(assets_mode, 'swagger-ui/swagger-ui-bundle.js') }} + {{ nelmioAsset(assets_mode, 'swagger-ui/swagger-ui-standalone-preset.js') }} + {% endblock javascripts %} + + {{ nelmioAsset(assets_mode, 'init-swagger-ui.js') }} + + {% block swagger_initialization %} + + {% endblock swagger_initialization %} + + diff --git a/templates/service/comingsoon.html.twig b/templates/service/comingsoon.html.twig new file mode 100644 index 0000000..5d89181 --- /dev/null +++ b/templates/service/comingsoon.html.twig @@ -0,0 +1,24 @@ +{% extends 'base_plain.html.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block body %} +
+ + + + + + + + + + + + + + + +
{{ message }}
+
+{% endblock %} diff --git a/tests/.DS_Store b/tests/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..11174f1a60bda1143355f24f94a9e87d5828791f GIT binary patch literal 6148 zcmeHKOHRW;47E!o3T(QA<*eCt!9J95f?NPKEgvZ*RRyeb1a8G0*l`n{KZ;1xNURVb zyYjrmW6wBmqPQj^oVL{lP~Km|uf81{(Fi}qyX7MWySV@owv_^oK$^A5vrWI)dD zn67YckMx97YtOH`n-yif-dNJ7w?lsGI?oqXy}-PzRerp`T@L#$zwNuZ|GK}XaY%)} zSugZJC9S#{UYujs9gl(BdR}|8+VSe<^NP(vf`MQl82Gacpk|9qP7GrV27-ZLpk+Y5 z4+$!mIW~s+>A;{Z0B{Vm3a+J>keK9{IW~rvfv|-FEtI{*U<-#mxnJhk7+N^7Hy`Xf zvv(-0yJP*N?!=j4jKM%K&}HDjZl_ZJuZR2pZcy9>1Hr(*Vt~hakrequest('GET', '/calltouch'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/CertificateControllerTest.php b/tests/Controller/CertificateControllerTest.php new file mode 100644 index 0000000..87d397f --- /dev/null +++ b/tests/Controller/CertificateControllerTest.php @@ -0,0 +1,16 @@ +request('GET', '/certificate'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/InfoclinicaControllerTest.php b/tests/Controller/InfoclinicaControllerTest.php new file mode 100644 index 0000000..a296798 --- /dev/null +++ b/tests/Controller/InfoclinicaControllerTest.php @@ -0,0 +1,88 @@ +request('GET', '/infoclinica/clvisitsovacheckpass'); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testClvisitsovacheckpassWithInvalidFilial(): void + { + $client = static::createClient(); + + // Тестируем GET запрос с невалидным filial (не число) - должен вернуть 404 + // requirements: ['filial' => '\d+'] требует только цифры + $client->request('GET', '/infoclinica/clvisitsovacheckpass/abc'); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testClvisitsovacheckpassWithPostMethod(): void + { + $client = static::createClient(); + + // Тестируем POST запрос - должен вернуть 405 Method Not Allowed + // так как эндпоинт принимает только GET + $client->request('POST', '/infoclinica/clvisitsovacheckpass/123'); + + self::assertResponseStatusCodeSame(Response::HTTP_METHOD_NOT_ALLOWED); + } + + public function testClvisitsovacheckpassWithValidFilial(): void + { + $client = static::createClient(); + + // Мокируем JWTDecoderService через контейнер + $container = static::getContainer(); + $jwtDecoderServiceMock = $this->createMock(JWTDecoderServiceInterface::class); + $user = $this->createMock(User::class); + $jwtDecoderServiceMock->method('getUser')->willReturn($user); + + // Заменяем сервис в контейнере + $container->set(JWTDecoderServiceInterface::class, $jwtDecoderServiceMock); + + // Тестируем GET запрос с валидным filial + $client->request('GET', '/infoclinica/clvisitsovacheckpass/123'); + + self::assertResponseIsSuccessful(); + self::assertResponseHeaderSame('content-type', 'application/json'); + + $responseData = json_decode($client->getResponse()->getContent(), true); + self::assertTrue($responseData); + } + + public function testClvisitsovacheckpassWithoutAuth(): void + { + $client = static::createClient(); + + // Мокируем JWTDecoderService чтобы вернуть null (нет пользователя) + $container = static::getContainer(); + $jwtDecoderServiceMock = $this->createMock(JWTDecoderServiceInterface::class); + $jwtDecoderServiceMock->method('getUser')->willReturn(null); + + // Заменяем сервис в контейнере + $container->set(JWTDecoderServiceInterface::class, $jwtDecoderServiceMock); + + // Тестируем GET запрос без авторизации + $client->request('GET', '/infoclinica/clvisitsovacheckpass/123'); + + self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED); + + $responseData = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('error', $responseData); + self::assertEquals('Пользователь не найден', $responseData['error']); + } +} diff --git a/tests/Controller/InfoclinicaDoctorControllerTest.php b/tests/Controller/InfoclinicaDoctorControllerTest.php new file mode 100644 index 0000000..a9a82d6 --- /dev/null +++ b/tests/Controller/InfoclinicaDoctorControllerTest.php @@ -0,0 +1,16 @@ +request('GET', '/infoclinica/doctor'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/LocationControllerTest.php b/tests/Controller/LocationControllerTest.php new file mode 100644 index 0000000..3d4148b --- /dev/null +++ b/tests/Controller/LocationControllerTest.php @@ -0,0 +1,16 @@ +request('GET', '/location'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/ServiceControllerTest.php b/tests/Controller/ServiceControllerTest.php new file mode 100644 index 0000000..cd944fd --- /dev/null +++ b/tests/Controller/ServiceControllerTest.php @@ -0,0 +1,16 @@ +request('GET', '/service'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Controller/StockControllerTest.php b/tests/Controller/StockControllerTest.php new file mode 100644 index 0000000..ebcd369 --- /dev/null +++ b/tests/Controller/StockControllerTest.php @@ -0,0 +1,16 @@ +request('GET', '/stock'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/tests/Service/AESCryptServiceTest.php b/tests/Service/AESCryptServiceTest.php new file mode 100644 index 0000000..c45216b --- /dev/null +++ b/tests/Service/AESCryptServiceTest.php @@ -0,0 +1,29 @@ +service = new AESCryptService($secret, $cipher); + } + + public function testEncryptDecrypt(): void + { + $plaintext = 'Hello, world!'; + $encrypted = $this->service->encrypt($plaintext); + $decrypted = $this->service->decrypt($encrypted); + + $this->assertNotEquals($plaintext, $encrypted); + $this->assertEquals($plaintext, $decrypted); + } +} diff --git a/tests/Service/BitrixServiceTest.php b/tests/Service/BitrixServiceTest.php new file mode 100644 index 0000000..2ec69df --- /dev/null +++ b/tests/Service/BitrixServiceTest.php @@ -0,0 +1,31 @@ +service = new BitrixService($databaseUrl); + } + + public function testConnect(): void + { + + $connection = $this->service->getConnection(); + + // $this->assertEquals($plaintext, $decrypted); + + // Проверяем, что подключение установлено + $this->assertInstanceOf(Connection::class, $connection); + } +} diff --git a/tests/Service/CalltouchClientServiceTest.php b/tests/Service/CalltouchClientServiceTest.php new file mode 100644 index 0000000..7571451 --- /dev/null +++ b/tests/Service/CalltouchClientServiceTest.php @@ -0,0 +1,57 @@ +dto = new CalltouchCreateRequestDto(); + + $this->dto->regionId = 91; + $this->dto->requestNumber = 'test_' . time(); + $this->dto->subject = 'Тестовая заявка'; + $this->dto->requestUrl = 'CalltouchClientServiceTest'; + $this->dto->sessionId = null; + $this->dto->phoneNumber = '79996243200'; + $this->dto->email = 'yahve1989@yandex.ru'; + $this->dto->fio = 'Тест Public API'; + $this->dto->addTags = ['public_api']; + $this->dto->requestDate = date('Y-m-d'); + $this->dto->customSources = [ + 'source' => '', + 'medium' => '', + 'campaign' => '', + 'term' => '' + ]; + + $ctUrl = $_ENV['CT_URL']; + $apiClient = $_ENV['API_CLIENT']; + $params = $_ENV['CT_PARAMS']; + + $this->service = new CalltouchClientService($apiClient, $ctUrl , $params); + } + + public function testSend(): void + { + # -- 17 числа узнать документы + $response = $this->service->requestCreate($this->dto); + + var_dump($response, $this->dto); + die(); + + // $plaintext = 'Hello, world!'; + // $encrypted = $this->service->encrypt($plaintext); + // $decrypted = $this->service->decrypt($encrypted); + + // $this->assertNotEquals($plaintext, $encrypted); + // $this->assertEquals($plaintext, $decrypted); + } +} diff --git a/tests/Service/ImageServiceTest.php b/tests/Service/ImageServiceTest.php new file mode 100644 index 0000000..72a736b --- /dev/null +++ b/tests/Service/ImageServiceTest.php @@ -0,0 +1,76 @@ +imageService = new ImageService(); + } + + /** + * @dataProvider imageDataProvider + */ + public function testGetPicture(string $filePath, int $width, int $height, string $expectedType): void + { + // Создаем тестовый файл изображения + $testImagePath = __DIR__ . '/test_image.jpg'; + $this->createTestImage($testImagePath); + + $response = $this->imageService->getPicture($testImagePath, $width, $height); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($expectedType, $response->headers->get('Content-Type')); + $this->assertNotEmpty($response->getContent()); + + // Удаляем тестовый файл + unlink($testImagePath); + } + + public function imageDataProvider(): array + { + return [ + ['test_image.jpg', 200, 200, 'image/jpeg'], + ['test_image.png', 100, 100, 'image/png'], + ['test_image.gif', 150, 150, 'image/gif'], + ]; + } + + public function testInvalidFile(): void + { + $response = $this->imageService->getPicture('non_existent_file.jpg'); + $this->assertInstanceOf(Response::class, $response); + $this->assertEmpty($response->getContent()); + } + + public function testDefaultSize(): void + { + $testImagePath = __DIR__ . '/test_image.jpg'; + $this->createTestImage($testImagePath); + + $response = $this->imageService->getPicture($testImagePath); + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals('image/jpeg', $response->headers->get('Content-Type')); + $this->assertNotEmpty($response->getContent()); + + unlink($testImagePath); + } + + private function createTestImage(string $path): void + { + $image = imagecreatetruecolor(800, 600); + $color = imagecolorallocate($image, 255, 0, 0); + imagefilledrectangle($image, 0, 0, 800, 600, $color); + imagejpeg($image, $path); + imagedestroy($image); + } +} diff --git a/tests/Service/InfoclinicaClientServiceTest.php b/tests/Service/InfoclinicaClientServiceTest.php new file mode 100644 index 0000000..95c2fc4 --- /dev/null +++ b/tests/Service/InfoclinicaClientServiceTest.php @@ -0,0 +1,88 @@ +prophet = new Prophet(); + $this->clientServiceMock = $this->prophet->prophesize(InfoclinicaClientServiceInterface::class); + } + + private function expectedResult() + { + return [ + 'schedule' => [ + '990000082' => [ + '20250520' => [ + 'schedident' => 30240229, + 'rnum' => '2.06', + 'dcode' => 30001414, + 'filial' => 3, + 'intervals' => [ + [ + 'time' => '14:30-15:00', + 'isFree' => false + ], + [ + 'time' => '15:00-15:30', + 'isFree' => false + ], + [ + 'time' => '15:30-16:00', + 'isFree' => false + ] + ], + 'depnum' => 990000082, + 'isFree' => false + ], + ] + ], + 'nearestDate' => [] + ]; + } + + public function testGetSchedule(): void + { + // Подготовка данных + $queryString = '?st=20250516&en=20250530&dcode=30001414&onlineMode=0&filial=3'; + + // Настройка мок-объекта + $this->clientServiceMock + ->getSchedule($queryString) + ->willReturn($this->expectedResult()); + + $service = $this->clientServiceMock->reveal(); + + $result = $service->getSchedule($queryString); + + $this->assertEquals($this->expectedResult(), $result); + } + + public function testGetScheduleWithEmptyQuery(): void + { + $queryString = ''; + $expectedExceptionMessage = 'Query string cannot be empty'; + + $this->clientServiceMock + ->getSchedule($queryString) + ->willThrow(new \InvalidArgumentException($expectedExceptionMessage)); + + $service = $this->clientServiceMock->reveal(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + $service->getSchedule($queryString); + } +} diff --git a/tests/Service/MessageSenderServiceTest.php b/tests/Service/MessageSenderServiceTest.php new file mode 100644 index 0000000..e2552b1 --- /dev/null +++ b/tests/Service/MessageSenderServiceTest.php @@ -0,0 +1,29 @@ +createMock(MessageBusInterface::class); + + // 2. Ожидаем, что dispatch() будет вызван с правильным сообщением + $expectedMessage = new RunCommandMessage('debug:container'); + $messageBus->expects($this->once()) + ->method('dispatch') + ->with($expectedMessage) + ->willReturn(new Envelope($expectedMessage)); + + // 3. Тестируемый сервис + $service = new MessageSenderService($messageBus); + $service->sendCommand('debug:container'); + } +} \ No newline at end of file diff --git a/tests/Service/SchedulerTransportTest.php b/tests/Service/SchedulerTransportTest.php new file mode 100644 index 0000000..45589eb --- /dev/null +++ b/tests/Service/SchedulerTransportTest.php @@ -0,0 +1,42 @@ +get(MessageBusInterface::class); + + // 2. Получаем транспорт scheduler_default + /** @var TransportInterface $transport */ + $transport = self::getContainer()->get('messenger.transport.scheduler_default'); + + // 3. Очищаем очередь перед тестом (если транспорт поддерживает) + if (method_exists($transport, 'reset')) { + $transport->reset(); + } + + // 4. Отправляем сообщение + $messageBus->dispatch(new RunCommandMessage('debug:container')); + + // 5. Проверяем очередь + $envelopes = $transport->get(); + $this->assertCount(1, $envelopes); + + $receivedMessage = $envelopes[0]->getMessage(); + $this->assertInstanceOf(RunCommandMessage::class, $receivedMessage); + $this->assertEquals('debug:container', $receivedMessage->getCommand()); + } +} \ No newline at end of file diff --git a/tests/Service/Sms4bClientServiceTest.php b/tests/Service/Sms4bClientServiceTest.php new file mode 100644 index 0000000..1d6c7fb --- /dev/null +++ b/tests/Service/Sms4bClientServiceTest.php @@ -0,0 +1,45 @@ +smsClient = new Sms4bClientService($userAgent, $baseUrl, $token, $sender); + } + + public function testBalance() + { + $result = $this->smsClient->balance(); + + $this->assertIsArray($result); + } + + public function testSenders() + { + $result = $this->smsClient->senders(); + + $this->assertIsArray($result); + } + + public function testSendSms() + { + $to = '79996243200'; + $msg = 'Тест sms4b интерфейса'; + + $result = $this->smsClient->send($to, $msg); + + $this->assertIsArray($result); + } +} diff --git a/tests/Service/SmsruClientServiceTest.php b/tests/Service/SmsruClientServiceTest.php new file mode 100644 index 0000000..5e76bee --- /dev/null +++ b/tests/Service/SmsruClientServiceTest.php @@ -0,0 +1,45 @@ +smsClient = new SmsruClientService($userAgent, $baseUrl, $token, $sender); + } + + public function testBalance() + { + $result = $this->smsClient->balance(); + + $this->assertIsArray($result); + } + + public function testSenders() + { + $result = $this->smsClient->senders(); + + $this->assertIsArray($result); + } + + public function testSendSms() + { + $to = '79996243200'; + $msg = 'Тест smsru интерфейса.'; + + $result = $this->smsClient->send($to, $msg); + + $this->assertIsArray($result); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..47a5855 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG']) { + umask(0000); +} diff --git a/translations/.gitignore b/translations/.gitignore new file mode 100644 index 0000000..e69de29