chore: initial import for test contour

This commit is contained in:
sova-bootstrap
2026-05-27 19:36:32 +03:00
commit 166cdb148e
282 changed files with 84872 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
.env
.env.*
config/secrets/
+17
View File
@@ -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
+23
View File
@@ -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 ###
+3
View File
@@ -0,0 +1,3 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
SYMFONY_DEPRECATIONS_HELPER=999999
+92
View File
@@ -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
+34
View File
@@ -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
+46
View File
@@ -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"]
BIN
View File
Binary file not shown.
+8
View File
@@ -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';
+5
View File
@@ -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);
+15
View File
@@ -0,0 +1,15 @@
{
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
}
},
"entrypoints": []
}
@@ -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';
+16
View File
@@ -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';
}
}
+5
View File
@@ -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);
+54
View File
@@ -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;
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
if (PHP_VERSION_ID >= 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';
}
+18
View File
@@ -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 ###
+25
View File
@@ -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 ###
+136
View File
@@ -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"
}
}
BIN
View File
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['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],
];
+11
View File
@@ -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
+10
View File
@@ -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
+11
View File
@@ -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
+5
View File
@@ -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)%"
+14
View File
@@ -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
+92
View File
@@ -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
+6
View File
@@ -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
+31
View File
@@ -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
@@ -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
+2
View File
@@ -0,0 +1,2 @@
framework:
lock: '%env(LOCK_DSN)%'
+3
View File
@@ -0,0 +1,3 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
+24
View File
@@ -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://'
+76
View File
@@ -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
+26
View File
@@ -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($|/)'
]
+12
View File
@@ -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:
'^/': ~
+12
View File
@@ -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 }
+3
View File
@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true
+11
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
framework:
scheduler:
enabled: false
+38
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
framework:
serializer:
enabled: true
default_context:
date_format: 'Y-m-d'
+5
View File
@@ -0,0 +1,5 @@
framework:
default_locale: ru
translator:
default_path: '%kernel.project_dir%/translations'
providers:
+6
View File
@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true
+4
View File
@@ -0,0 +1,4 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
csrf_protection:
check_header: true
+12
View File
@@ -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
+13
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}
+1891
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
+4
View File
@@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error
+12
View File
@@ -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 }
+3
View File
@@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service
+8
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
<?php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $container): void {
$stubMode = $_ENV['INTEGRATIONS_STUB_MODE'] ?? $_SERVER['INTEGRATIONS_STUB_MODE'] ?? 'false';
$appEnv = $_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? 'prod';
if (filter_var($stubMode, FILTER_VALIDATE_BOOL) || in_array($appEnv, ['dev', 'test'], true)) {
$container->import('./services_stub.yaml');
}
};
+232
View File
@@ -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 }
+13
View File
@@ -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
+11
View File
@@ -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
+32
View File
@@ -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;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Returns the importmap for this application.
*
* - "path" is a path inside the asset mapper system. Use the
* "debug:asset-map" command to see the full list of paths.
*
* - "entrypoint" (JavaScript only) set to true for any module that will
* be used as an "entrypoint" (and passed to the importmap() Twig function).
*
* The "importmap:require" command can be used to add new entries to this file.
*/
return [
'app' => [
'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',
],
];
+59347
View File
File diff suppressed because one or more lines are too long
View File
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260213132749 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create article table';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260213132759 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create view_article and populate article table';
}
public function up(Schema $schema): void
{
// Создаем представление view_article, если его еще нет
$this->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');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260311212936 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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)');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260417120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add department column to specialist_dcode_description';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260515142000 extends AbstractMigration
{
private const TABLES = [
'news',
'promo',
'disease',
'medical_center',
'site_services',
];
public function getDescription(): string
{
return 'Add generated id defaults for content CRUD entities';
}
public function up(Schema $schema): void
{
foreach (self::TABLES as $table) {
$sequence = $table . '_id_seq';
$this->addSql(sprintf('CREATE SEQUENCE IF NOT EXISTS %s OWNED BY %s.id', $sequence, $table));
$this->addSql(sprintf(
'SELECT setval(\'%s\', COALESCE((SELECT MAX(id) FROM %s), 0) + 1, false)',
$sequence,
$table,
));
$this->addSql(sprintf(
'ALTER TABLE %s ALTER COLUMN id SET DEFAULT nextval(\'%s\')',
$table,
$sequence,
));
}
}
public function down(Schema $schema): void
{
foreach (array_reverse(self::TABLES) as $table) {
$sequence = $table . '_id_seq';
$this->addSql(sprintf('ALTER TABLE %s ALTER COLUMN id DROP DEFAULT', $table));
$this->addSql(sprintf('DROP SEQUENCE IF EXISTS %s', $sequence));
}
}
}
+3255
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
<extensions>
</extensions>
</phpunit>
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+9
View File
@@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Disallow: /
+3
View File
@@ -0,0 +1,3 @@
{
"openapi": "3.0.0"
}
BIN
View File
Binary file not shown.
@@ -0,0 +1,84 @@
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Specialist;
use App\Service\Bitrix\BitrixService;
use Psr\Log\LoggerInterface;
#[AsCommand(
name: 'bitrix-update-doctors',
description: 'Обновления врачей из CMS Bitrix',
)]
class BitrixUpdateDoctorsCommand extends Command
{
public function __construct(
private LoggerInterface $logger,
private EntityManagerInterface $entityManager,
private BitrixService $bitrixService
)
{
parent::__construct();
}
protected function configure(): void
{
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->logger->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;
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Specialist;
use App\Entity\Review;
use App\Service\Bitrix\BitrixService;
use Psr\Log\LoggerInterface;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Symfony\Component\Console\Helper\ProgressBar;
#[AsCommand(
name: 'bitrix-update-reviews',
description: 'Обновления отзывов врачей из CMS Bitrix',
)]
final class BitrixUpdateReviewsCommand extends Command
{
public function __construct(
private LoggerInterface $logger,
private EntityManagerInterface $entityManager,
private BitrixService $bitrixService
)
{
parent::__construct();
}
protected function configure(): void
{
}
private $review = [
'source' => 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;
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Command;
use App\Service\ScheduleCache\ScheduleCacheService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:schedule:clear-cache',
description: 'Clear old schedule cache'
)]
class ClearScheduleCacheCommand extends Command
{
public function __construct(
private ScheduleCacheService $cacheService
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}
}
+306
View File
@@ -0,0 +1,306 @@
<?php
namespace App\Command;
use App\Entity\Department;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use App\Service\Client\Interfaces\InfoclinicaClientServiceInterface;
use App\Service\Translite\Interfaces\TransliteServiceInterface;
#[AsCommand(
name: 'upload:deps',
description: 'Пакетное обновление отделений для врачей',
)]
class UploadDepartmentsCommand extends Command
{
private const BATCH_SIZE = 100;
public function __construct(
private EntityManagerInterface $entityManager,
private InfoclinicaClientServiceInterface $client,
private TransliteServiceInterface $transliteService
) {
parent::__construct();
}
protected function configure(): void { }
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->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;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Command;
use App\Service\DiseaseCrudService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'upload:diseases',
description: 'Обновление таблицы disease из view_disease',
)]
final class UploadDiseasesCommand extends Command
{
public function __construct(
private LoggerInterface $logger,
private DiseaseCrudService $diseaseCrudService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}
}
+262
View File
@@ -0,0 +1,262 @@
<?php
namespace App\Command;
use App\Entity\Idoctor;
use App\Entity\Department;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use App\Service\Client\Interfaces\InfoclinicaClientServiceInterface;
#[AsCommand(
name: 'upload:doctors',
description: 'Пакетное обновление врачей из инфоклиники',
)]
class UploadDoctorsCommand extends Command
{
private const BATCH_SIZE = 150;
private const CHUNK_SIZE = 300;
public function __construct(
private EntityManagerInterface $entityManager,
private InfoclinicaClientServiceInterface $client
) {
parent::__construct();
}
protected function configure(): void
{
$this->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);
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace App\Command;
use App\Entity\Filial;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Doctrine\ORM\EntityManagerInterface;
use App\Service\Translite\Interfaces\TransliteServiceInterface;
use Psr\Log\LoggerInterface;
#[AsCommand(
name: 'upload:filials',
description: 'Обнвление филиалов',
)]
class UploadFilialsCommand extends Command
{
public function __construct(
private LoggerInterface $logger,
private EntityManagerInterface $entityManager,
private HttpClientInterface $client,
private TransliteServiceInterface $transliteService,
private string $widgetApiUrl,
)
{
parent::__construct();
}
protected function configure(): void { }
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->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;
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Command;
use App\Service\MedicalCenterCrudService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'upload:medical-centers',
description: 'Обновление таблицы medical_center из view_centers'
)]
final class UploadMedicalCentersCommand extends Command
{
public function __construct(
private LoggerInterface $logger,
private MedicalCenterCrudService $medicalCenterCrudService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Command;
use App\Service\NewsCrudService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'upload:news',
description: 'Обновление таблицы news из view_news'
)]
final class UploadNewsCommand extends Command
{
public function __construct(
private LoggerInterface $logger,
private NewsCrudService $newsCrudService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}
}
+181
View File
@@ -0,0 +1,181 @@
<?php
namespace App\Command;
use App\Entity\PriceDepartment;
use App\Entity\PriceList;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Doctrine\ORM\EntityManagerInterface;
#[AsCommand(
name: 'upload:price',
description: 'Обновление цен',
)]
class UploadPriceCommand extends Command
{
private $nosleep;
public function __construct(
private EntityManagerInterface $entityManager,
private HttpClientInterface $client,
private string $widgetApiUrl,
)
{
parent::__construct();
}
protected function configure(): void
{
$this
->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;
// Логирование ошибки можно добавить при необходимости
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Command;
use App\Entity\PriceDepartment;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Doctrine\ORM\EntityManagerInterface;
#[AsCommand(
name: 'upload:priceDep',
description: 'Обновление отделений для услуг',
)]
class UploadPriceDepCommand extends Command
{
public function __construct(
private EntityManagerInterface $entityManager,
private HttpClientInterface $client,
private string $widgetApiUrl,
)
{
parent::__construct();
}
protected function configure(): void { }
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$response = $this->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;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Command;
use App\Service\PromoCrudService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'upload:promo',
description: 'Обновление таблицы promo из view_promo'
)]
final class UploadPromoCommand extends Command
{
public function __construct(
private LoggerInterface $logger,
private PromoCrudService $promoCrudService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Command;
use App\Service\SiteServiceCrudService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'upload:site-services',
description: 'Обновление таблицы site_services из public.view_services'
)]
final class UploadSiteServicesCommand extends Command
{
public function __construct(
private LoggerInterface $logger,
private SiteServiceCrudService $siteServiceCrudService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}
}
View File
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Controller;
use App\Dto\Content\ContentFilterDto;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use App\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/article')]
final class ArticleController extends AbstractController
{
private const READ_GROUPS = ['article:read'];
private const WRITE_GROUPS = ['article:write'];
public function __construct(
private readonly CrudResponder $crud,
private readonly Paginator $paginator,
) {
}
#[OA\Tag(name: 'Статьи')]
#[OA\Parameter(name: 'page', in: 'query', schema: new OA\Schema(type: 'integer'))]
#[OA\Parameter(name: 'limit', in: 'query', schema: new OA\Schema(type: 'integer'))]
#[OA\Parameter(name: 'regionId', in: 'query', schema: new OA\Schema(type: 'integer'))]
#[OA\Parameter(name: 'active', in: 'query', schema: new OA\Schema(type: 'boolean'))]
#[OA\Parameter(name: 'alias', in: 'query', schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'search', in: 'query', schema: new OA\Schema(type: 'string'))]
#[Route('/list', name: 'article_list', methods: ['GET'])]
public function list(Request $request, ArticleRepository $repository): JsonResponse
{
$qb = $repository->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);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use App\Service\Client\Interfaces\CalltouchClientServiceInterface;
use App\Dto\CalltouchCreateRequestDto;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Serializer\SerializerInterface;
#[IsGranted('ROLE_ADMIN')]
#[Route('/calltouch')]
final class CalltouchController extends AbstractController
{
public function __construct (
private CalltouchClientServiceInterface $calltouchClientService,
private ValidatorInterface $validator,
private SerializerInterface $serializer
) {}
#[Route('/create-lead', name: 'app_calltouch_create-lead', methods: ['POST'])]
public function createLead(CalltouchCreateRequestDto $dto, Request $request): JsonResponse
{
$requestData = $request->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,
]);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class DefaultController extends AbstractController
{
#[Route('/', name: 'app_default_comingsoon', methods: ['GET'])]
public function comingsoon(): Response
{
return $this->render('service/comingsoon.html.twig', [
'title' => 'Account disabled by server administrator',
'message' => 'Account disabled by server administrator',
]);
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace App\Controller;
use App\Entity\Department;
use App\Repository\DepartmentRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use OpenApi\Attributes as OA;
#[Route('/department')]
final class DepartmentController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private ValidatorInterface $validator,
private SerializerInterface $serializer
) { }
#[OA\Tag(name: 'Список отделений')]
#[Route('/list', name: 'department_list', methods: ['GET'])]
public function list(DepartmentRepository $repository): JsonResponse
{
$departmentList = $repository->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']
]);
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Controller;
use App\Dto\Content\ContentFilterDto;
use App\Entity\Disease;
use App\Repository\DiseaseRepository;
use App\Service\Crud\CrudResponder;
use App\Service\Pagination\Paginator;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
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 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, DiseaseRepository $repository): JsonResponse
{
$qb = $repository->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);
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace App\Controller;
use App\Entity\Filial;
use App\Repository\FilialRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use App\Service\FileUploader\Interfaces\FileUploaderServiceInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use App\Service\Image\Interfaces\ImageServiceInterface;
use App\Dto\FileUploadDto;
use OpenApi\Attributes as OA;
use Exception;
#[Route('/filial')]
final class FilialController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private ValidatorInterface $validator,
private SerializerInterface $serializer,
private ImageServiceInterface $imageService
) { }
#[OA\Tag(name: 'Список филиалов')]
#[Route(path: '/list', name: 'filial_list', methods: ['GET'])]
public function list(Request $request, FilialRepository $repository): JsonResponse
{
$regionId = $request->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);
}
}

Some files were not shown because too many files have changed in this diff Show More