Compare commits

...

16 Commits

Author SHA1 Message Date
brusnitsyn
bb36ef3a40 Merge remote-tracking branch 'origin/main'
Some checks failed
Build and Push Docker Image / test (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
2025-12-29 17:08:46 +09:00
brusnitsyn
56be95caa4 Обновление 1.0 2025-12-29 17:08:26 +09:00
e263697f7d Добавить .github/workflows/build-docker.yml
Some checks failed
Build and Push Docker Image / test (push) Failing after 1m15s
Build and Push Docker Image / build (push) Has been cancelled
2025-12-26 20:26:02 +09:00
c7dcfe1a51 Удалить .github/workflows./docker_build.yml 2025-12-26 20:25:11 +09:00
d7d959ed53 Добавить .github/workflows./docker_build.yml 2025-12-26 20:24:07 +09:00
brusnitsyn
c5c1a2b3e1 Новая логика поисковой выдачи
И добавлена кнопка Добавить карту в архив
2025-12-26 17:41:25 +09:00
brusnitsyn
329304076d Правка выдачи карт 2025-12-26 14:29:55 +09:00
brusnitsyn
a5209f45c8 Много чего 2025-12-25 17:30:50 +09:00
brusnitsyn
c4bb7ec6f9 Правки в конфигах для docker 2025-12-22 17:23:09 +09:00
brusnitsyn
e6af72a778 Правки в конфигах для docker 2025-12-22 13:09:25 +09:00
brusnitsyn
eed6607296 Фиксы docker конфигов 2025-12-21 19:47:39 +09:00
brusnitsyn
255a117c20 сервис уведомлений 2025-12-19 17:02:15 +09:00
brusnitsyn
2bafa7f073 обновление конфигов nginx и docker 2025-12-19 17:01:47 +09:00
brusnitsyn
9057d3e8ad Добавлен сервис уведомлений 2025-12-19 09:19:41 +09:00
brusnitsyn
b86cdaec90 Добавлен лоадер при открытии модальных окон 2025-12-19 09:00:14 +09:00
brusnitsyn
0a1304e55b Правка отступа Divider 2025-12-19 00:43:41 +09:00
43 changed files with 2863 additions and 783 deletions

103
.github/workflows/build-docker.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: Build and Push Docker Image
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
branches: [main, master]
env:
REGISTRY: registry.brusoff.su
IMAGE_NAME: ${{ gitea.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, ctype, iconv, intl, pdo_mysql, pdo_pgsql, gd, redis, zip
coverage: none
- name: Validate composer.json
run: composer validate --strict
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-suggest
- name: Run PHP tests
run: vendor/bin/phpunit
build:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/'))
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
build-args: |
BUILDKIT_INLINE_CACHE=1
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v2
if: always()
with:
sarif_file: 'trivy-results.sarif'

View File

@@ -1,146 +1,207 @@
ARG NODE_VERSION=24.12.0 # Этап 1: PHP зависимости
FROM php:8.3-fpm-alpine AS phpbuild FROM dh-mirror.gitverse.ru/php:8.3-fpm AS phpbuild
# Install base dependencies # Установка системных зависимостей
RUN apk add --no-cache \ RUN apt-get update && apt-get install -y \
bash \ git \
curl \ curl \
git \
wget
# Установка Composer
COPY --from=composer:2.7.8 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
# Копирование необходимых файлов и изменение разрешений
COPY . .
RUN chown -R www:www /var/www \
&& chmod -R 775 /var/www/storage \
&& chmod -R 775 /var/www/bootstrap/cache
# Установка php и зависимостей
RUN composer install --no-dev --prefer-dist
RUN chown -R www:www /var/www/vendor \
&& chmod -R 775 /var/www/vendor
FROM node:${NODE_VERSION} AS jsbuild
WORKDIR /var/www
COPY . .
# Установка node.js зависимостей
RUN npm install \
&& npm run build
FROM php:8.3-fpm-alpine
# 1. Сначала обновляем и ставим минимальные зависимости
RUN apk add --no-cache \
git \
wget \ wget \
gnupg unzip \
# 2. Устанавливаем зависимости для самых важных расширений
RUN apk add --no-cache \
libzip-dev \ libzip-dev \
libxml2-dev \ libxml2-dev \
libonig-dev \
libicu-dev \ libicu-dev \
libcurl4-openssl-dev \ libonig-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libwebp-dev \
libpq-dev \
libxslt1-dev \
libexif-dev \
libffi-dev \
pkg-config \
libssl-dev \ libssl-dev \
zlib1g-dev && rm -rf /var/lib/apt/lists/*
# 3. Устанавливаем БАЗОВЫЕ расширения (которые точно работают) # Установка PHP расширений
RUN docker-php-ext-install \ RUN docker-php-ext-configure gd \
--with-freetype \
--with-jpeg \
--with-webp \
&& docker-php-ext-install -j$(nproc) \
bcmath \ bcmath \
intl \ intl \
mbstring \ mbstring \
zip \ zip \
opcache \ opcache \
pdo pdo \
# 4. Устанавливаем зависимости для GD
RUN apk add --no-cache \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libwebp-dev
# 5. Настраиваем и устанавливаем GD
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install gd
# 6. Устанавливаем зависимости для баз данных
RUN apk add --no-cache \
libpq-dev
# 7. Устанавливаем расширения для баз данных
RUN docker-php-ext-install \
mysqli \
pdo_mysql \ pdo_mysql \
pdo_pgsql \ pdo_pgsql \
pgsql gd \
# 8. Устанавливаем оставшиеся расширения
RUN docker-php-ext-install \
calendar \
ctype \
curl \
fileinfo \
ftp \
gettext \
iconv \
phar \
posix \
xml \
dom \
simplexml \
sockets
# 9. Устанавливаем зависимости для системных расширений
RUN apk add --no-cache \
libxslt-dev \
libexif-dev \
libffi-dev \
# 10. Устанавливаем системные расширения
RUN docker-php-ext-install \
xsl \
exif \ exif \
sockets \
xsl \
ffi \ ffi \
shmop pcntl
# 11. Установка Redis # Установка Redis расширения
RUN cd /tmp && \ RUN pecl install redis && docker-php-ext-enable redis
git clone --branch 6.3.0 --depth 1 https://github.com/phpredis/phpredis.git && \
cd phpredis && \
phpize && \
./configure && \
make -j$(nproc) && \
make install && \
docker-php-ext-enable redis && \
rm -rf /tmp/phpredis
# 12. Установка Composer # Настройка opcache для production
#RUN curl -sS https://getcomposer.org/installer | php -- \ RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini && \
# --install-dir=/usr/local/bin --filename=composer echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini && \
echo "opcache.interned_strings_buffer=32" >> /usr/local/etc/php/conf.d/opcache.ini && \
echo "opcache.max_accelerated_files=32531" >> /usr/local/etc/php/conf.d/opcache.ini && \
echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini
# 13. Создание пользователя # Установка Composer
RUN groupadd -g 1000 www && \ COPY --from=dh-mirror.gitverse.ru/composer:2.7 /usr/bin/composer /usr/bin/composer
useradd -u 1000 -ms /bin/ash -g www www
# 14. Копирование файлов проекта
COPY --chown=www:www --from=phpbuild /var/www /var/www
COPY --chown=www:www --from=jsbuild /var/www/node_modules /var/www/node_modules
WORKDIR /var/www WORKDIR /var/www
# 15. Смена пользователя # Копирование файлов для установки зависимостей
USER www COPY composer.json composer.lock ./
# 16. Expose port 9000 и запуск php-fpm # Установка PHP зависимостей
EXPOSE 9000 RUN composer install \
CMD ["php-fpm"] --no-progress \
--no-scripts \
--prefer-dist \
--optimize-autoloader \
--apcu-autoloader
# Копируем исходный код
COPY . .
# Установка прав
RUN chown -R www-data:www-data /var/www && \
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
# Этап 2: Сборка фронтенда (Inertia + Vue 3)
FROM dh-mirror.gitverse.ru/node:20 AS jsbuild
# Установка дополнительных зависимостей для сборки
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /var/www
# Копируем зависимости Node.js
COPY package.json package-lock.json* ./
# Установка зависимостей Node.js
RUN npm ci \
--no-audit \
--progress=false
# Копируем файлы для сборки фронтенда
COPY vite.config.js ./
COPY resources/js ./resources/js/
COPY resources/css ./resources/css/
# Сборка ассетов Vite
RUN npm run build
# Этап 3: Финальный образ
FROM dh-mirror.gitverse.ru/php:8.3-fpm
# Установка runtime зависимостей
RUN apt-get update && apt-get install -y \
libxml2 \
libonig5 \
libpng16-16 \
libjpeg62-turbo \
libfreetype6 \
libwebp7 \
libpq5 \
libxslt1.1 \
libexif12 \
libffi8 \
supervisor \
nginx \
cron \
&& rm -rf /var/lib/apt/lists/*
# Установка системных зависимостей
RUN apt-get update && apt-get install -y \
git \
curl \
wget \
unzip \
libzip-dev \
libxml2-dev \
libicu-dev \
libonig-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libwebp-dev \
libpq-dev \
libxslt1-dev \
libexif-dev \
libffi-dev \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Установка PHP расширений
RUN docker-php-ext-configure gd \
--with-freetype \
--with-jpeg \
--with-webp \
&& docker-php-ext-install -j$(nproc) \
bcmath \
intl \
mbstring \
zip \
opcache \
pdo \
pdo_mysql \
pdo_pgsql \
gd \
exif \
sockets \
xsl \
ffi \
pcntl
# Установка Redis расширения
RUN pecl install redis && docker-php-ext-enable redis
# Настройка opcache для production
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini && \
echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini && \
echo "opcache.interned_strings_buffer=32" >> /usr/local/etc/php/conf.d/opcache.ini && \
echo "opcache.max_accelerated_files=32531" >> /usr/local/etc/php/conf.d/opcache.ini && \
echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini
# Копируем PHP расширения из первого этапа
#COPY --from=phpbuild /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
#COPY --from=phpbuild /usr/local/lib/php/extensions/no-debug-non-zts-20230831/ /usr/local/lib/php/extensions/no-debug-non-zts-20230831/
#COPY --from=phpbuild /usr/local/bin/ /usr/local/bin/
# Копируем конфигурации
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/app.conf /etc/nginx/conf.d/default.conf
COPY docker/supervisord.conf /etc/supervisor/supervisord.conf
WORKDIR /var/www
# Копируем приложение
COPY --chown=www-data:www-data --from=phpbuild /var/www .
COPY --chown=www-data:www-data --from=jsbuild /var/www/public/build ./public/build
COPY --chown=www-data:www-data --from=jsbuild /var/www/node_modules ./node_modules
# Настройка прав и оптимизация Laravel
RUN mkdir -p /var/log/supervisor && \
chown -R www-data:www-data /var/www /var/log/supervisor && \
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
# Создание ссылки на Storage
RUN php artisan storage:link
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

View File

@@ -6,6 +6,7 @@ use App\Http\Resources\ArchiveHistoryResource;
use App\Http\Resources\ArchiveInfoResource; use App\Http\Resources\ArchiveInfoResource;
use App\Models\ArchiveHistory; use App\Models\ArchiveHistory;
use App\Models\ArchiveInfo; use App\Models\ArchiveInfo;
use App\Models\SI\SttMedicalHistory;
use App\Rules\DateTimeOrStringOrNumber; use App\Rules\DateTimeOrStringOrNumber;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -21,7 +22,10 @@ class ArchiveHistoryController extends Controller
{ {
$archiveHistory = ArchiveHistory::find($id); $archiveHistory = ArchiveHistory::find($id);
return response()->json($archiveHistory); return response()->json([
...$archiveHistory->toArray(),
'type' => $archiveHistory->historyType()
]);
} }
public function moveStore(Request $request) public function moveStore(Request $request)
@@ -34,36 +38,53 @@ class ArchiveHistoryController extends Controller
'employee_post' => 'nullable|string', 'employee_post' => 'nullable|string',
'comment' => 'nullable|string', 'comment' => 'nullable|string',
'has_lost' => 'boolean', 'has_lost' => 'boolean',
'historyable_id' => 'nullable|numeric', 'history_id' => 'required',
'historyable_type' => 'required|string', 'type' => 'required|string',
]); ]);
// Преобразуем timestamp в дату, если пришли числа // Преобразуем timestamp в дату, если пришли числа
if (isset($data['issue_at']) && is_numeric($data['issue_at'])) { if (isset($data['issue_at']) && is_numeric($data['issue_at'])) {
$data['issue_at'] = Carbon::createFromTimestampMs($data['issue_at'])->format('Y-m-d'); $data['issue_at'] = Carbon::createFromTimestampMs($data['issue_at'])
->setTimezone(config('app.timezone'))
->format('Y-m-d');
} }
if (isset($data['return_at']) && is_numeric($data['return_at'])) { if (isset($data['return_at']) && is_numeric($data['return_at'])) {
$data['return_at'] = Carbon::createFromTimestampMs($data['return_at'])->format('Y-m-d'); $data['return_at'] = Carbon::createFromTimestampMs($data['return_at'])
->setTimezone(config('app.timezone'))
->format('Y-m-d');
} }
$archiveHistory = ArchiveHistory::create($data); if ($data['type'] === 'mis') {
$patient = \App\Models\Mis\SttMedicalHistory::where('MedicalHistoryID', $data['history_id'])->first();
// Если переданы данные для полиморфной связи } else {
if ($request->filled('historyable_id') && $request->filled('historyable_type')) { $patient = \App\Models\Si\SttMedicalHistory::where('keykarta', $data['history_id'])->first();
// Найти связанную модель
$historyableClass = $request->input('historyable_type');
// Проверяем, существует ли класс модели
if (class_exists($historyableClass)) {
$historyableModel = $historyableClass::find($request->input('historyable_id'));
if ($historyableModel) {
// Связываем модели
$archiveHistory->historyable()->associate($historyableModel);
$archiveHistory->save();
}
} }
// $archiveInfo = ArchiveInfo::whereId($data['archive_info_id'])->first();
if ($data['type'] === 'mis') {
$archiveHistory = $patient->archiveHistory()->create([
'issue_at' => $data['issue_at'],
'return_at' => $data['return_at'],
'org_id' => $data['org_id'],
'employee_name' => $data['employee_name'],
'employee_post' => $data['employee_post'],
'comment' => $data['comment'],
'has_lost' => $data['has_lost'],
'mis_history_id' => $patient->MedicalHistoryID
]);
} else {
$archiveHistory = $patient->archiveHistory()->create([
'issue_at' => $data['issue_at'],
'return_at' => $data['return_at'],
'org_id' => $data['org_id'],
'employee_name' => $data['employee_name'],
'employee_post' => $data['employee_post'],
'comment' => $data['comment'],
'has_lost' => $data['has_lost'],
'foxpro_history_id' => $patient->keykarta,
]);
} }
return response()->json(ArchiveHistoryResource::make($archiveHistory)); return response()->json(ArchiveHistoryResource::make($archiveHistory));
@@ -79,17 +100,20 @@ class ArchiveHistoryController extends Controller
'employee_post' => 'nullable|string', 'employee_post' => 'nullable|string',
'comment' => 'nullable|string', 'comment' => 'nullable|string',
'has_lost' => 'boolean', 'has_lost' => 'boolean',
'historyable_id' => 'nullable|numeric', 'type' => 'required|string',
'historyable_type' => 'required|string',
]); ]);
// Преобразуем timestamp в дату, если пришли числа // Преобразуем timestamp в дату, если пришли числа
if (isset($data['issue_at']) && is_numeric($data['issue_at'])) { if (isset($data['issue_at']) && is_numeric($data['issue_at'])) {
$data['issue_at'] = Carbon::createFromTimestampMs($data['issue_at'])->format('Y-m-d'); $data['issue_at'] = Carbon::createFromTimestampMs($data['issue_at'])
->setTimezone(config('app.timezone'))
->format('Y-m-d');
} }
if (isset($data['return_at']) && is_numeric($data['return_at'])) { if (isset($data['return_at']) && is_numeric($data['return_at'])) {
$data['return_at'] = Carbon::createFromTimestampMs($data['return_at'])->format('Y-m-d'); $data['return_at'] = Carbon::createFromTimestampMs($data['return_at'])
->setTimezone(config('app.timezone'))
->format('Y-m-d');
} }
$archiveHistory = ArchiveHistory::find($id); $archiveHistory = ArchiveHistory::find($id);
@@ -105,7 +129,8 @@ class ArchiveHistoryController extends Controller
'id' => 'required|numeric', 'id' => 'required|numeric',
'num' => 'nullable|string', 'num' => 'nullable|string',
'post_in' => ['nullable', new DateTimeOrStringOrNumber], 'post_in' => ['nullable', new DateTimeOrStringOrNumber],
'historyable_type' => 'required|string', 'status' => 'nullable',
'type' => 'nullable'
]); ]);
// Преобразуем timestamp в дату, если пришли числа // Преобразуем timestamp в дату, если пришли числа
@@ -113,25 +138,15 @@ class ArchiveHistoryController extends Controller
$data['post_in'] = Carbon::createFromTimestampMs($data['post_in'])->format('Y-m-d'); $data['post_in'] = Carbon::createFromTimestampMs($data['post_in'])->format('Y-m-d');
} }
if ($patientId && $request->filled('historyable_type')) { $archiveInfo = ArchiveInfo::whereId($data['id'])->first();
// Найти связанную модель
$historyableClass = $request->input('historyable_type');
// Проверяем, существует ли класс модели $archiveInfo->update(
if (class_exists($historyableClass)) { [
$historyableModel = $historyableClass::find($patientId); 'archive_num' => $data['num'],
'post_in' => $data['post_in'],
]
);
if ($historyableModel) { return response()->json()->setStatusCode(200);
// Связываем модели
$historyableModel->archiveInfo()->updateOrCreate([
'historyable_type' => $historyableClass,
'historyable_id' => $patientId,
], $data);
return response()->json(ArchiveInfoResource::make($historyableModel->archiveInfo));
}
}
}
return response()->json()->setStatusCode(500);
} }
} }

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use App\Models\ArchiveInfo;
use App\Models\Mis\SttMedicalHistory;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class ArchiveInfoController extends Controller
{
public function store(Request $request)
{
$data = $request->validate([
'id' => 'required',
'num' => 'required',
'post_in' => 'required'
]);
// Преобразуем timestamp в дату, если пришли числа
if (isset($data['post_in']) && is_numeric($data['post_in'])) {
$data['post_in'] = Carbon::createFromTimestampMs($data['post_in'])
->setTimezone(config('app.timezone'))
->format('Y-m-d');
}
$history = SttMedicalHistory::where('MedicalHistoryID', $data['id'])->first();
$hasCreated = ArchiveInfo::create([
'mis_num' => $history->MedCardNum,
'mis_history_id' => $data['id'],
'archive_num' => $data['num'],
'post_in' => $data['post_in'],
]);
return $hasCreated;
}
}

View File

@@ -2,21 +2,25 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Resources\IndexSttMedicalHistoryResource;
use App\Http\Resources\SI\SttMedicalHistoryResource as SiSttMedicalHistoryResource; use App\Http\Resources\SI\SttMedicalHistoryResource as SiSttMedicalHistoryResource;
use App\Http\Resources\Mis\SttMedicalHistoryResource as MisSttMedicalHistoryResource; use App\Http\Resources\Mis\SttMedicalHistoryResource as MisSttMedicalHistoryResource;
use App\Models\ArchiveStatus; use App\Models\ArchiveStatus;
use App\Models\SI\SttMedicalHistory; use App\Models\SI\SttMedicalHistory;
use App\Repositories\MedicalHistoryRepository; use App\Repositories\MedicalHistoryRepository;
use App\Services\ArchiveCardService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
class IndexController extends Controller class IndexController extends Controller
{ {
private MedicalHistoryRepository $repository; private MedicalHistoryRepository $repository;
private ArchiveCardService $cardService;
public function __construct(MedicalHistoryRepository $repository) public function __construct(MedicalHistoryRepository $repository, ArchiveCardService $cardService)
{ {
$this->repository = $repository; $this->repository = $repository;
$this->cardService = $cardService;
} }
public function index(Request $request) public function index(Request $request)
@@ -25,58 +29,25 @@ class IndexController extends Controller
$searchText = $request->get('search', null); $searchText = $request->get('search', null);
$dateExtractFrom = $request->get('date_extract_from', null); $dateExtractFrom = $request->get('date_extract_from', null);
$dateExtractTo = $request->get('date_extract_to', null); $dateExtractTo = $request->get('date_extract_to', null);
$database = $request->get('database', 'separate'); // si, mis
$status = $request->get('status', null); $status = $request->get('status', null);
$data = []; // $data = $this->repository->unifiedSearch(
$databaseStats = $this->repository->getDatabaseStats(); // $searchText,
// $dateExtractFrom,
// $dateExtractTo,
// $status,
// $pageSize
// );
switch ($database) { $data = $this->cardService->get(
case 'si':
$paginator = $this->repository->searchInPostgres(
$searchText,
$dateExtractFrom,
$dateExtractTo,
$pageSize
);
$data['si'] = SiSttMedicalHistoryResource::collection($paginator);
break;
case 'mis':
$paginator = $this->repository->searchInMssql(
$searchText,
$dateExtractFrom,
$dateExtractTo,
$pageSize
);
$data['mis'] = MisSttMedicalHistoryResource::collection($paginator);
break;
case 'smart':
$paginator = $this->repository->smartSearch(
$searchText,
$dateExtractFrom,
$dateExtractTo,
$pageSize
);
$data['smart'] = SiSttMedicalHistoryResource::collection($paginator);
break;
case 'separate':
$separateResults = $this->repository->separateSearch(
$searchText, $searchText,
$dateExtractFrom, $dateExtractFrom,
$dateExtractTo, $dateExtractTo,
$status, $status,
$pageSize $pageSize
); );
$data = [
'si' => SiSttMedicalHistoryResource::collection($separateResults['si']), // dd($data);
'mis' => MisSttMedicalHistoryResource::collection($separateResults['mis']),
'stats' => $separateResults['stats'],
];
break;
}
$statuses = ArchiveStatus::all()->map(function ($status) { $statuses = ArchiveStatus::all()->map(function ($status) {
return [ return [
@@ -85,41 +56,14 @@ class IndexController extends Controller
]; ];
}); });
$statuses->push([
'value' => 0,
'label' => 'Нет в архиве',
]);
return Inertia::render('Home/Index', [ return Inertia::render('Home/Index', [
'cards' => $data, 'cards' => IndexSttMedicalHistoryResource::collection($data),
'statuses' => $statuses, 'statuses' => $statuses,
'databaseStats' => $databaseStats,
'filters' => array_merge($request->only([ 'filters' => array_merge($request->only([
'search', 'date_extract_from', 'date_extract_to', 'search', 'date_extract_from', 'date_extract_to',
'page_size', 'page', 'view_type', 'database', 'status' 'page_size', 'page', 'status'
])) ]))
]); ]);
// $cardsQuery = SttMedicalHistory::query();
//
// if (!empty($searchText)) {
// $cardsQuery = $cardsQuery->search($searchText);
// }
//
// if (!empty($dateExtractFrom)) {
// $cardsQuery = $cardsQuery->whereDate('dateextract', '>=', $dateExtractFrom);
// if (!empty($dateExtractTo)) {
// $cardsQuery = $cardsQuery->whereDate('dateextract', '<=', $dateExtractTo);
// }
// }
//
// $cards = SttMedicalHistoryResource::collection($cardsQuery->paginate($pageSize));
//
// return Inertia::render('Home/Index', [
// 'cards' => $cards,
// 'filters' => $request->only([
// 'search', 'date_extract_from', 'date_extract_to', 'page_size', 'page', 'view_type'
// ]),
// ]);
} }
} }

View File

@@ -5,6 +5,8 @@ namespace App\Http\Controllers;
use App\Http\Resources\ArchiveHistoryResource; use App\Http\Resources\ArchiveHistoryResource;
use App\Http\Resources\ArchiveInfoResource; use App\Http\Resources\ArchiveInfoResource;
use App\Http\Resources\PatientInfoResource; use App\Http\Resources\PatientInfoResource;
use App\Http\Resources\SI\SttMedicalHistoryResource;
use App\Models\ArchiveInfo;
use App\Models\SI\SttMedicalHistory as SiSttMedicalHistory; use App\Models\SI\SttMedicalHistory as SiSttMedicalHistory;
use App\Models\Mis\SttMedicalHistory as MisSttMedicalHistory; use App\Models\Mis\SttMedicalHistory as MisSttMedicalHistory;
use App\Repositories\MedicalHistoryRepository; use App\Repositories\MedicalHistoryRepository;
@@ -14,29 +16,36 @@ class MedicalHistoryController extends Controller
{ {
public function patient($id, Request $request) public function patient($id, Request $request)
{ {
$viewType = $request->get('view_type', 'si'); $viewType = $request->get('view_type', 'mis');
$patientId = $request->get('patient_id'); $patientId = $request->get('patient_id');
$patientInfo = null; if ($viewType == 'foxpro') $patient = SiSttMedicalHistory::where('keykarta', $id)->first();
if ($viewType == 'si') $patient = SiSttMedicalHistory::where('id', $id)->first();
else $patient = MisSttMedicalHistory::where('MedicalHistoryID', $id)->first(); else $patient = MisSttMedicalHistory::where('MedicalHistoryID', $id)->first();
$archiveJournal = $patient->archiveHistory ? ArchiveHistoryResource::collection($patient->archiveHistory) : null;
if (!empty($patient->archiveInfo)) { if($patient instanceof MisSttMedicalHistory) {
$archiveInfo = ArchiveInfoResource::make($patient->archiveInfo)->toArray(request()); if ($patient->archiveHistory->count() === 0) {
$foxproCardId = $patient->archiveInfo->foxpro_history_id;
$foxproPatient = SiSttMedicalHistory::where('keykarta', $foxproCardId)->first();
$journalHistory = $foxproPatient->archiveHistory;
} else { } else {
$archiveInfo = null; $journalHistory = $patient->archiveHistory;
}
} else {
$journalHistory = $patient->archiveHistory;
} }
$archiveInfo = $patient->archiveInfo ? $patient->archiveInfo : null;
$patientInfo = [ $patientInfo = [
'historyable_type' => $viewType == 'si' ? SiSttMedicalHistory::class : MisSttMedicalHistory::class, 'historyable_type' => $viewType == 'foxpro' ? SiSttMedicalHistory::class : MisSttMedicalHistory::class,
'info' => [ 'info' => [
'historyable_type' => $viewType == 'si' ? SiSttMedicalHistory::class : MisSttMedicalHistory::class, 'historyable_type' => $viewType == 'foxpro' ? SiSttMedicalHistory::class : MisSttMedicalHistory::class,
...PatientInfoResource::make($patient)->toArray(request()), ...PatientInfoResource::make($patient)->toArray(request()),
'can_be_issued' => $patient->canBeIssued() 'can_be_issued' => $patient->canBeIssued()
], ],
'journal' => $archiveJournal, 'journal' => ArchiveHistoryResource::collection($journalHistory),
'archiveInfo' => $archiveInfo 'archiveInfo' => $archiveInfo ? ArchiveInfoResource::make($archiveInfo) : null
]; ];
return response()->json($patientInfo); return response()->json($patientInfo);

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use App\Models\Mis\SttMedicalHistory;
use Illuminate\Http\Request;
class MisHistoryController extends Controller
{
public function search(Request $request)
{
$data = $request->validate([
'query' => 'required|string'
]);
$searchText = $data['query'];
$searchParts = preg_split('/\s+/', trim($searchText));
$query = SttMedicalHistory::query();
// Проверяем, начинается ли строка с цифры (вероятно, это номер карты)
$isCardNumberSearch = is_numeric($searchParts[0]);
if ($isCardNumberSearch) {
// Паттерн: № Ф И О
$query->where('MedCardNum', 'ILIKE', "$searchParts[0]%");
// ФИО начинается со второй части
$fioStartIndex = 1;
} else {
// Паттерн: Ф И О (без номера)
$fioStartIndex = 0;
}
// Ищем ФИО в зависимости от количества оставшихся частей
$fioPartsCount = count($searchParts) - $fioStartIndex;
if ($fioPartsCount === 1) {
// Одно слово - ищем в фамилии, имени или отчестве
$query->where(function ($q) use ($searchParts, $fioStartIndex) {
$q->where('FAMILY', 'ILIKE', "{$searchParts[$fioStartIndex]}%")
->orWhere('Name', 'ILIKE', "{$searchParts[$fioStartIndex]}%")
->orWhere('OT', 'ILIKE', "{$searchParts[$fioStartIndex]}%");
});
} elseif ($fioPartsCount === 2) {
// Два слова - фамилия и инициал имени
$query->where(function ($q) use ($searchParts, $fioStartIndex) {
$q->where('FAMILY', 'ILIKE', "{$searchParts[$fioStartIndex]}%")
->where('Name', 'ILIKE', "{$searchParts[$fioStartIndex + 1]}%");
});
} elseif ($fioPartsCount === 3) {
// Три слова - полное ФИО
$query->where(function ($q) use ($searchParts, $fioStartIndex) {
$q->where('FAMILY', 'ILIKE', "{$searchParts[$fioStartIndex]}%")
->where('Name', 'ILIKE', "{$searchParts[$fioStartIndex + 1]}%")
->where('OT', 'ILIKE', "{$searchParts[$fioStartIndex + 2]}%");
});
}
// Также ищем полную строку в объединенных полях
$query->orWhereRaw("CONCAT(\"MedCardNum\", ' ', \"FAMILY\", ' ', \"Name\", ' ', \"OT\") ILIKE ?", ["{$searchText}%"]);
$results = $query->select([
'MedicalHistoryID', 'MedCardNum', 'FAMILY', 'Name', 'OT'
])->get()->map(function ($item) {
return [
'label' => "$item->MedCardNum - $item->FAMILY $item->Name $item->OT",
'value' => $item->MedicalHistoryID
];
});
return response()->json($results);
}
}

View File

@@ -21,7 +21,7 @@ class ArchiveHistoryResource extends JsonResource
'return_at' => $this->return_at ? Carbon::parse($this->return_at)->format('d.m.Y') : null, 'return_at' => $this->return_at ? Carbon::parse($this->return_at)->format('d.m.Y') : null,
'comment' => $this->comment, 'comment' => $this->comment,
'org_id' => $this->org_id, 'org_id' => $this->org_id,
'org' => $this->org->name, 'org' => $this->org?->name,
'employee_name' => $this->employee_name, 'employee_name' => $this->employee_name,
'employee_post' => $this->employee_post, 'employee_post' => $this->employee_post,
'has_lost' => $this->has_lost, 'has_lost' => $this->has_lost,

View File

@@ -16,11 +16,11 @@ class ArchiveInfoResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'num' => $this->num, 'num' => $this->archive_num,
'post_in' => $this->post_in, 'post_in' => $this->post_in,
'status' => $this->status, 'status' => $this->status,
'historyable_id' => $this->historyable_id, 'foxpro_num' => $this->foxpro_num,
'historyable_type' => $this->historyable_type, 'mis_num' => $this->mis_num,
]; ];
} }
} }

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Resources;
use App\Models\ArchiveStatus;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IndexSttMedicalHistoryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
// Получаем оригинальную модель
$model = $this->resource;
$archiveInfo = $model->archiveInfo;
$historyType = $archiveInfo->historyType();
$inArchive = $archiveInfo->exists;
$hasMisType = $historyType === 'mis';
$family = $hasMisType ? $model->FAMILY : $model->fam;
$im = $hasMisType ? $this->Name : $this->im;
$ot = $hasMisType ? $this->OT : $this->ot;
$fio = "$family $im $ot";
$birthDate = $hasMisType ? $this->BD : $this->dr;
$dateRecipient = $hasMisType ? $this->DateRecipient : $this->mpostdate;
$dateExtract = $hasMisType ? $this->DateExtract : $this->menddate;
$postIn = $inArchive ? $archiveInfo->post_in : null;
$formattedBirthDate = $birthDate ? Carbon::parse($birthDate)->format('d.m.Y') : null;
$formattedDateRecipient = $dateRecipient ? Carbon::parse($dateRecipient)->format('d.m.Y') : null;
$formattedDateExtract = $dateExtract ? Carbon::parse($dateExtract)->format('d.m.Y') : null;
$formattedPostIn = $postIn ? Carbon::parse($postIn)->format('d.m.Y') : null;
$id = $historyType === 'mis' ? $this->MedicalHistoryID : $this->keykarta;
$archiveNum = $inArchive ? $archiveInfo->archive_num : null;
$status = $inArchive ? $archiveInfo->status : ArchiveStatus::find(5)->first();
$misCardNumber = $this->MedCardNum;
$foxproCardNumber = $this->nkarta;
$archiveInfoMisCardNumber = $inArchive ? $archiveInfo->mis_num : null;
$archiveInfoFoxproCardNumber = $inArchive ? $archiveInfo->foxpro_num : null;
$hasDividerCardNumber = isset($archiveInfoMisCardNumber) && isset($archiveInfoFoxproCardNumber);
$cardNumber = $hasDividerCardNumber
? "$archiveInfoMisCardNumber / $archiveInfoFoxproCardNumber"
: $archiveInfoMisCardNumber ?? $archiveInfoFoxproCardNumber;
return [
'id' => $id,
'history_type' => $historyType,
// Основные данные
'fullname' => $fio,
'family' => $family,
'name' => $im,
'ot' => $ot,
'dr' => $formattedBirthDate,
'daterecipient' => $formattedDateRecipient,
'dateextract' => $formattedDateExtract,
// Номера карт
'medcardnum' => $cardNumber, // MIS номер или FoxPro номер
'card_num' => $archiveNum, // Архивный номер
'datearhiv' => $formattedPostIn,
// Статус и возможности
'status' => $status,
'status_id' => $this->resource['status_id'] ?? 0,
'can_be_issued' => $this->resource['can_be_issued'] ?? false,
'can_add_to_archive' => $this->resource['can_add_to_archive'] ?? false,
];
}
}

View File

@@ -2,9 +2,9 @@
namespace App\Http\Resources\Mis; namespace App\Http\Resources\Mis;
use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
class SttMedicalHistoryResource extends JsonResource class SttMedicalHistoryResource extends JsonResource
{ {
@@ -15,17 +15,114 @@ class SttMedicalHistoryResource extends JsonResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
// Определяем источник данных
$isFromArchive = $this->resource['in_archive'] ?? false;
$isTemporary = $this->resource['is_temporary'] ?? false;
$historyType = $this->resource['history_type'] ?? 'mis';
// Формируем ФИО
$family = $this->resource['family'] ?? '';
$name = $this->resource['name'] ?? '';
$ot = $this->resource['ot'] ?? '';
$misCardNumber = $this->resource['mis_card_number'];
$foxproCardNumber = $this->resource['foxpro_card_number'];
$hasDividerCardNumber = isset($misCardNumber) && isset($foxproCardNumber);
$cardNumber = $hasDividerCardNumber ? "$misCardNumber / $foxproCardNumber" : $misCardNumber ?? $foxproCardNumber;
// Для временных записей (не в архиве) используем данные из MIS
if ($isTemporary) {
// Данные из stt_medicalhistory (не в архиве)
$fullName = trim("{$family} {$name} {$ot}");
$birthDate = $this->resource['birth_date'] ?? null;
$dateExtract = $this->resource['date_extract'] ?? null;
$dateRecipient = $this->resource['date_recipient'] ?? null;
$archiveNum = null;
$postIn = null;
$status = $this->resource['status_text'] ?? 'Не в архиве';
} else {
// Данные из archive_infos (в архиве)
$fullName = trim("{$family} {$name} {$ot}");
$birthDate = $this->resource['birth_date'] ?? null;
// Для архивных записей date_extract может быть из MIS или FoxPro
$dateExtract = $this->resource['date_extract'] ?? null;
$dateRecipient = $this->resource['date_recipient'] ?? null;
$archiveNum = $this->resource['archive_num'] ?? null;
$postIn = $this->resource['post_in'] ?? null;
$status = $this->resource['status_text'] ?? 'Неизвестно';
}
// Форматирование дат
$formattedBirthDate = $birthDate ? Carbon::parse($birthDate)->format('d.m.Y') : null;
$formattedDateRecipient = $dateRecipient ? Carbon::parse($dateRecipient)->format('d.m.Y') : null;
$formattedDateExtract = $dateExtract ? Carbon::parse($dateExtract)->format('d.m.Y') : null;
$formattedPostIn = $postIn ? Carbon::parse($postIn)->format('d.m.Y') : null;
$id = $historyType === 'mis' ? $this->resource['mis_history_id'] : $this->resource['foxpro_history_id'];
return [ return [
'id' => $this->MedicalHistoryID, 'id' => $id,
'fullname' => $this->getFullNameAttribute(), 'history_type' => $historyType,
'daterecipient' => Carbon::parse($this->DateRecipient)->format('d.m.Y'), 'in_archive' => $isFromArchive,
'dateextract' => Carbon::parse($this->DateExtract)->format('d.m.Y'), 'is_temporary' => $isTemporary,
'card_num' => $this->archiveInfo->num ?? null, 'source' => $this->resource['source'] ?? 'archive',
'status' => $this->archiveInfo->status ?? null,
'datearhiv' => $this->whenLoaded('archiveInfo') ? Carbon::parse($this->archiveInfo->post_in)->format('d.m.Y') : null, // Основные данные
'medcardnum' => $this->MedCardNum, 'fullname' => $fullName,
'dr' => Carbon::parse($this->BD)->format('d.m.Y'), 'family' => $family,
'can_be_issue' => $this->canBeIssued() 'name' => $name,
'ot' => $ot,
'dr' => $formattedBirthDate,
'daterecipient' => $formattedDateRecipient,
'dateextract' => $formattedDateExtract,
// Номера карт
'medcardnum' => $cardNumber, // MIS номер или FoxPro номер
'mis_card_number' => $this->resource['mis_card_number'] ?? null, // Оригинальный MIS номер
'foxpro_card_number' => $this->resource['foxpro_card_number'] ?? null, // Оригинальный FoxPro номер
'card_num' => $archiveNum, // Архивный номер
'datearhiv' => $formattedPostIn,
// Статус и возможности
'status' => $status,
'status_id' => $this->resource['status_id'] ?? 0,
'can_be_issued' => $this->resource['can_be_issued'] ?? false,
'can_add_to_archive' => $this->resource['can_add_to_archive'] ?? false,
// Дополнительные идентификаторы
'mis_history_id' => $this->resource['mis_history_id'] ?? null,
'foxpro_history_id' => $this->resource['foxpro_history_id'] ?? null,
// Дополнительные данные
'snils' => $this->resource['snils'] ?? null,
'enp' => $this->resource['enp'] ?? null,
// 'created_at' => $this->resource->created_at ? Carbon::parse($this->resource->created_at)->format('d.m.Y H:i') : null,
// 'updated_at' => $this->resource->updated_at ? Carbon::parse($this->resource->updated_at)->format('d.m.Y H:i') : null,
// Стили для фронта
'row_class' => $this->resource['row_class'] ?? '',
'status_color' => $this->getStatusColor($this->resource['status_id'] ?? 0, $isFromArchive),
]; ];
} }
/**
* Получение цвета статуса для фронта
*/
private function getStatusColor(int $statusId, bool $inArchive): string
{
if (!$inArchive) {
return 'warning'; // Желтый для не в архиве
}
return match($statusId) {
1 => 'success', // Зеленый для в архиве
2 => 'info', // Синий для выдано
3, 4 => 'danger', // Красный для утрачено/списано
default => 'secondary',
};
}
} }

View File

@@ -15,10 +15,10 @@ class PatientInfoResource extends JsonResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
'id' => $this->id ?? $this->MedicalHistoryID, 'id' => $this->keykarta ?? $this->MedicalHistoryID,
'medcardnum' => $this->medcardnum ?? $this->MedCardNum, 'medcardnum' => $this->nkarta ?? $this->MedCardNum,
'family' => $this->family ?? $this->FAMILY, 'family' => $this->fam ?? $this->FAMILY,
'name' => $this->name ?? $this->Name, 'name' => $this->im ?? $this->Name,
'ot' => $this->ot ?? $this->OT, 'ot' => $this->ot ?? $this->OT,
]; ];
} }

View File

@@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Models\Mis\SttMedicalHistory as MisMedicalHistory;
use App\Models\SI\SttMedicalHistory as SiMedicalHistory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class ArchiveHistory extends Model class ArchiveHistory extends Model
@@ -21,16 +23,12 @@ class ArchiveHistory extends Model
public function updateArchiveInfoStatus() public function updateArchiveInfoStatus()
{ {
// Получаем связанную модель через морф if ($this->mis_history_id) {
$historyable = $this->historyable; $this->misHistory->archiveInfo->update([
'status_id' => $this->determineStatusId()
if (!$historyable) { ]);
return; } else {
} $this->foxproHistory->archiveInfo->update([
// Проверяем, есть ли у модели архивная информация
if (method_exists($historyable, 'archiveInfo') && $historyable->archiveInfo) {
$historyable->archiveInfo->update([
'status_id' => $this->determineStatusId() 'status_id' => $this->determineStatusId()
]); ]);
} }
@@ -54,8 +52,9 @@ class ArchiveHistory extends Model
} }
protected $fillable = [ protected $fillable = [
'historyable_type', 'keyarhiv',
'historyable_id', 'foxpro_history_id',
'mis_history_id',
'issue_at', 'issue_at',
'return_at', 'return_at',
'comment', 'comment',
@@ -65,9 +64,19 @@ class ArchiveHistory extends Model
'has_lost', 'has_lost',
]; ];
public function historyable() public function foxproHistory()
{ {
return $this->morphTo(); return $this->belongsTo(SiMedicalHistory::class, 'foxpro_history_id', 'keykarta');
}
public function misHistory()
{
return $this->belongsTo(MisMedicalHistory::class, 'mis_history_id', 'MedicalHistoryID');
}
public function historyType()
{
return $this->mis_history_id !== null ? 'mis' : 'foxpro';
} }
public function org(): \Illuminate\Database\Eloquent\Relations\BelongsTo public function org(): \Illuminate\Database\Eloquent\Relations\BelongsTo

View File

@@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Models\Mis\SttMedicalHistory as MisMedicalHistory;
use App\Models\SI\SttMedicalHistory as SiMedicalHistory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class ArchiveInfo extends Model class ArchiveInfo extends Model
@@ -16,17 +18,30 @@ class ArchiveInfo extends Model
protected $connection = 'pgsql'; protected $connection = 'pgsql';
protected $table = 'archive_infos'; protected $table = 'archive_infos';
protected $fillable = [ protected $fillable = [
'historyable_type', 'foxpro_history_id',
'historyable_id', 'mis_history_id',
'num', 'foxpro_num',
'mis_num',
'archive_num',
'post_in', 'post_in',
'status_id' 'status_id'
]; ];
public function historyable() public function foxproHistory()
{ {
return $this->morphTo(); return $this->belongsTo(SiMedicalHistory::class, 'foxpro_history_id', 'keykarta');
}
public function misHistory()
{
return $this->belongsTo(MisMedicalHistory::class, 'mis_history_id', 'MedicalHistoryID');
}
public function historyType()
{
return $this->mis_history_id !== null ? 'mis' : 'foxpro';
} }
public function status() public function status()

View File

@@ -11,6 +11,11 @@ class SttMedicalHistory extends Model
{ {
protected $primaryKey = 'MedicalHistoryID'; protected $primaryKey = 'MedicalHistoryID';
protected $table = 'stt_medicalhistory'; protected $table = 'stt_medicalhistory';
protected $keyType = 'string';
protected $casts = [
'MedicalHistoryID' => 'string',
];
public function getFullNameAttribute() public function getFullNameAttribute()
{ {
@@ -19,12 +24,12 @@ class SttMedicalHistory extends Model
public function archiveHistory() public function archiveHistory()
{ {
return $this->morphMany(ArchiveHistory::class, 'historyable'); return $this->hasMany(ArchiveHistory::class, 'mis_history_id', 'MedicalHistoryID');
} }
public function archiveInfo() public function archiveInfo()
{ {
return $this->morphOne(ArchiveInfo::class, 'historyable'); return $this->hasOne(ArchiveInfo::class, 'mis_history_id', 'MedicalHistoryID');
} }
/** /**
@@ -41,7 +46,6 @@ class SttMedicalHistory extends Model
$hasNotBadStatus = $this->archiveInfo() $hasNotBadStatus = $this->archiveInfo()
->whereNotNull('status_id') ->whereNotNull('status_id')
->whereNot('status_id', 1)
->whereNot('status_id', 3) ->whereNot('status_id', 3)
->whereNot('status_id', 4) ->whereNot('status_id', 4)
->exists(); ->exists();

View File

@@ -8,36 +8,43 @@ use Illuminate\Database\Eloquent\Model;
class SttMedicalHistory extends Model class SttMedicalHistory extends Model
{ {
protected $table = 'si_stt_patients'; protected $table = 'foxpro_original_patient';
protected $primaryKey = 'keykarta';
protected $connection = 'pgsql'; protected $connection = 'pgsql';
protected $keyType = 'string';
public $incrementing = false;
protected $casts = [
'keykarta' => 'string',
];
protected $fillable = [ protected $fillable = [
'family', // Фамилия 'fam', // Фамилия
'name', // Имя 'im', // Имя
'ot', // Отчество 'ot', // Отчество
'daterecipient', // Д. пост. (mpostdate) 'mpostdate', // Д. пост. (mpostdate)
'dateextract', // Д. вып. (menddate) 'menddate', // Д. вып. (menddate)
'narhiv', // № в архиве 'narhiv', // № в архиве
'datearhiv', // Д. архив 'datearhiv', // Д. архив
'statgod', // Год нахождения в стационаре 'snils', // Год нахождения в стационаре
'enp', // ЕНП 'enp', // ЕНП
'medcardnum', // № карты 'nkarta', // № карты
'dr', // ДР 'dr', // ДР
]; ];
public function getFullNameAttribute() public function getFullNameAttribute()
{ {
return "$this->family $this->name $this->ot"; return "$this->fam $this->im $this->ot";
} }
public function archiveHistory() public function archiveHistory()
{ {
return $this->morphMany(ArchiveHistory::class, 'historyable'); return $this->hasMany(ArchiveHistory::class, 'foxpro_history_id', 'keykarta');
} }
public function archiveInfo() public function archiveInfo()
{ {
return $this->morphOne(ArchiveInfo::class, 'historyable'); return $this->hasOne(ArchiveInfo::class, 'foxpro_history_id', 'keykarta');
} }
/** /**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Services;
use App\Models\ArchiveInfo;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ArchiveCardService
{
public function get(?string $searchText,
?string $dateExtractFrom,
?string $dateExtractTo,
?int $status,
string $pageSize
) : LengthAwarePaginator
{
$query = ArchiveInfo::with(['misHistory', 'foxproHistory'])
->orderBy('post_in', 'desc');
// Поиск по тексту (если передан)
if (!empty($searchText)) {
$query->where(function ($q) use ($searchText) {
// Разбиваем строку поиска на части (по пробелам)
$searchParts = preg_split('/\s+/', trim($searchText));
$q->where(function ($subQuery) use ($searchParts, $searchText) {
// Сначала проверяем MIS историю
$subQuery->whereHas('misHistory', function ($misQuery) use ($searchParts, $searchText) {
$this->applySearchToHistory($misQuery, $searchParts, $searchText, 'mis');
});
// ИЛИ проверяем Foxpro историю (только если нет MIS истории)
$subQuery->orWhere(function ($orWhereSubQuery) use ($searchParts, $searchText) {
$orWhereSubQuery->whereNull('mis_history_id') // Нет MIS истории
->whereHas('foxproHistory', function ($foxproQuery) use ($searchParts, $searchText) {
$this->applySearchToHistory($foxproQuery, $searchParts, $searchText, 'foxpro');
});
});
})
// Ищем по архивным номерам
->orWhere('archive_num', 'ILIKE', "{$searchText}%")
->orWhere('mis_num', 'ILIKE', "{$searchText}%")
->orWhere('foxpro_num', 'ILIKE', "{$searchText}%");
});
}
// Фильтрация по дате выписки
if (!empty($dateExtractFrom)) {
$query->where(function ($q) use ($dateExtractFrom) {
$q->whereHas('misHistory', function ($misQuery) use ($dateExtractFrom) {
$misQuery->whereDate('DateExtract', '>=', $dateExtractFrom);
})->orWhereHas('foxproHistory', function ($foxproQuery) use ($dateExtractFrom) {
$foxproQuery->whereDate('menddate', '>=', $dateExtractFrom);
});
});
}
if (!empty($dateExtractTo)) {
$query->where(function ($q) use ($dateExtractTo) {
$q->whereHas('misHistory', function ($misQuery) use ($dateExtractTo) {
$misQuery->whereDate('DateExtract', '<=', $dateExtractTo);
})->orWhereHas('foxproHistory', function ($foxproQuery) use ($dateExtractTo) {
$foxproQuery->whereDate('menddate', '<=', $dateExtractTo);
});
});
}
// Фильтрация по статусу (если передан)
if (!empty($status)) {
$query->where('status_id', $status);
}
// Получаем результаты с пагинацией
$results = $query->paginate($pageSize)
->through(function ($archiveInfo) {
// Приоритет MIS истории
if ($archiveInfo->misHistory) {
$history = $archiveInfo->misHistory;
$history->history_type = 'mis';
} else {
$history = $archiveInfo->foxproHistory;
$history->history_type = 'foxpro';
}
return $history;
});
return $results;
}
private function applySearchToHistory($query, $searchParts, $searchText, $type)
{
if ($type === 'mis') {
$query->where('MedCardNum', 'ILIKE', "{$searchText}%");
// Ищем ФИО в зависимости от количества частей
if (count($searchParts) === 1) {
$query->orWhere('FAMILY', 'ILIKE', "{$searchParts[0]}%")
->orWhere('Name', 'ILIKE', "{$searchParts[0]}%")
->orWhere('OT', 'ILIKE', "{$searchParts[0]}%");
} elseif (count($searchParts) === 2) {
$query->orWhere(function ($subQuery) use ($searchParts) {
$subQuery->where('FAMILY', 'ILIKE', "{$searchParts[0]}%")
->where('Name', 'ILIKE', "{$searchParts[1]}%");
});
} elseif (count($searchParts) === 3) {
$query->orWhere(function ($subQuery) use ($searchParts) {
$subQuery->where('FAMILY', 'ILIKE', "{$searchParts[0]}%")
->where('Name', 'LIKE', "{$searchParts[1]}%")
->where('OT', 'ILIKE', "{$searchParts[2]}%");
});
}
$query->orWhereRaw("CONCAT(\"FAMILY\", ' ', \"Name\", ' ', \"OT\") ILIKE ?", ["{$searchText}%"]);
} else { // foxpro
$query->where('nkarta', 'ILIKE', "{$searchText}%");
if (count($searchParts) === 1) {
$query->orWhere('fam', 'ILIKE', "{$searchParts[0]}%")
->orWhere('im', 'ILIKE', "{$searchParts[0]}%")
->orWhere('ot', 'ILIKE', "{$searchParts[0]}%");
} elseif (count($searchParts) === 2) {
$query->orWhere(function ($subQuery) use ($searchParts) {
$subQuery->where('fam', 'ILIKE', "{$searchParts[0]}%")
->where('im', 'ILIKE', "{$searchParts[1]}%");
});
} elseif (count($searchParts) === 3) {
$query->orWhere(function ($subQuery) use ($searchParts) {
$subQuery->where('fam', 'ILIKE', "{$searchParts[0]}%")
->where('im', 'ILIKE', "{$searchParts[1]}%")
->where('ot', 'ILIKE', "{$searchParts[2]}%");
});
}
$query->orWhereRaw("CONCAT(fam, ' ', im, ' ', ot) ILIKE ?", ["{$searchText}%"]);
}
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Services;
use App\Models\ArchiveInfo;
use App\Models\Mis\SttMedicalHistory as MisMedicalHistory;
use App\Models\SI\SttMedicalHistory as SiMedicalHistory;
class ArchiveSearchService
{
/**
* Поиск карты по номеру
*/
public function searchByNumber(string $searchText): ?ArchiveInfo
{
if (!is_numeric($searchText)) {
return null;
}
// 1. Проверяем, есть ли запись в archive_infos
$archiveInfo = $this->searchInArchiveInfos($searchText);
if ($archiveInfo) {
return $archiveInfo;
}
// 2. Если нет в archive_infos, ищем в MIS
$misHistory = $this->searchInMis($searchText);
if ($misHistory) {
// Создаем запись в archive_infos для MIS карты
return $this->createArchiveInfoForMis($misHistory);
}
// 3. Если нет в MIS, ищем в FoxPro
$foxproHistory = $this->searchInFoxPro($searchText);
if ($foxproHistory) {
// Создаем запись в archive_infos для FoxPro карты
return $this->createArchiveInfoForFoxPro($foxproHistory);
}
return null;
}
/**
* Поиск по ФИО или другим критериям
*/
public function searchByName(string $searchText): array
{
// Поиск в MIS
$misResults = MisMedicalHistory::query()
->where(function($query) use ($searchText) {
$query->whereRaw("CONCAT(\"FAMILY\", ' ', \"Name\", ' ', COALESCE(\"OT\", '')) ILIKE ?", ["$searchText%"])
->orWhere('FAMILY', 'ILIKE', "$searchText%")
->orWhere('Name', 'ILIKE', "$searchText%")
->orWhere('OT', 'ILIKE', "$searchText%");
})
->get();
return $misResults;
}
/**
* Поиск в archive_infos
*/
private function searchInArchiveInfos(string $cardNumber): ?ArchiveInfo
{
return ArchiveInfo::query()
->where('mis_num', $cardNumber)
->orWhere('foxpro_num', $cardNumber)
->with(['misHistory', 'foxproHistory'])
->first();
}
/**
* Поиск в MIS
*/
private function searchInMis(string $cardNumber): ?MisMedicalHistory
{
return MisMedicalHistory::where('MedCardNum', $cardNumber)->first();
}
/**
* Поиск в FoxPro
*/
private function searchInFoxPro(string $cardNumber): ?SiMedicalHistory
{
return SiMedicalHistory::where('nkarta', $cardNumber)->first();
}
/**
* Создание записи в archive_infos для MIS карты
*/
private function createArchiveInfoForMis(MisMedicalHistory $misHistory): ArchiveInfo
{
// Проверяем, нет ли уже записи
$existingArchiveInfo = ArchiveInfo::where('mis_history_id', $misHistory->MedicalHistoryID)->first();
if ($existingArchiveInfo) {
return $existingArchiveInfo;
}
// Ищем связанную запись в FoxPro
$foxproHistory = SiMedicalHistory::where('snils', $misHistory->SS)
->where('menddate', $misHistory->DateExtract)
->where('dr', '!=', $misHistory->BD)
->first();
// Создаем запись
return ArchiveInfo::create([
'mis_history_id' => $misHistory->MedicalHistoryID,
'mis_num' => $misHistory->MedCardNum,
'foxpro_history_id' => $foxproHistory?->keykarta,
'foxpro_num' => $foxproHistory?->nkarta,
'archive_num' => $foxproHistory?->narhiv,
'post_in' => $foxproHistory?->datearhiv,
'status_id' => 2, // Статус по умолчанию
]);
}
/**
* Создание записи в archive_infos для FoxPro карты
*/
private function createArchiveInfoForFoxPro(SiMedicalHistory $foxproHistory): ArchiveInfo
{
// Проверяем, нет ли уже записи
$existingArchiveInfo = ArchiveInfo::where('foxpro_history_id', $foxproHistory->keykarta)->first();
if ($existingArchiveInfo) {
return $existingArchiveInfo;
}
// Ищем связанную запись в MIS
$misHistory = MisMedicalHistory::where('SS', $foxproHistory->snils)
->where('DateExtract', $foxproHistory->menddate)
->where('BD', '!=', $foxproHistory->dr)
->first();
// Создаем запись
return ArchiveInfo::create([
'foxpro_history_id' => $foxproHistory->keykarta,
'foxpro_num' => $foxproHistory->nkarta,
'archive_num' => $foxproHistory->narhiv,
'post_in' => $foxproHistory->datearhiv,
'mis_history_id' => $misHistory?->MedicalHistoryID,
'mis_num' => $misHistory?->MedCardNum,
'status_id' => 2, // Статус по умолчанию
]);
}
}

View File

@@ -13,6 +13,7 @@
"laravel/tinker": "^2.10.1" "laravel/tinker": "^2.10.1"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.16",
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.24", "laravel/pint": "^1.24",

161
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "4ecf76c8e987c4f4186a17e150248317", "content-hash": "d35c2af4354943a6ab7e9b9ca3c4fed0",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -6136,6 +6136,91 @@
} }
], ],
"packages-dev": [ "packages-dev": [
{
"name": "barryvdh/laravel-debugbar",
"version": "v3.16.2",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-debugbar.git",
"reference": "730dbf8bf41f5691e026dd771e64dd54ad1b10b3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/730dbf8bf41f5691e026dd771e64dd54ad1b10b3",
"reference": "730dbf8bf41f5691e026dd771e64dd54ad1b10b3",
"shasum": ""
},
"require": {
"illuminate/routing": "^10|^11|^12",
"illuminate/session": "^10|^11|^12",
"illuminate/support": "^10|^11|^12",
"php": "^8.1",
"php-debugbar/php-debugbar": "^2.2.4",
"symfony/finder": "^6|^7"
},
"require-dev": {
"mockery/mockery": "^1.3.3",
"orchestra/testbench-dusk": "^7|^8|^9|^10",
"phpunit/phpunit": "^9.5.10|^10|^11",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Debugbar": "Barryvdh\\Debugbar\\Facades\\Debugbar"
},
"providers": [
"Barryvdh\\Debugbar\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.16-dev"
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Barryvdh\\Debugbar\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "PHP Debugbar integration for Laravel",
"keywords": [
"debug",
"debugbar",
"dev",
"laravel",
"profiler",
"webprofiler"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-debugbar/issues",
"source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.16.2"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2025-12-03T14:52:46+00:00"
},
{ {
"name": "brianium/paratest", "name": "brianium/paratest",
"version": "v7.14.2", "version": "v7.14.2",
@@ -7614,6 +7699,80 @@
}, },
"time": "2022-02-21T01:04:05+00:00" "time": "2022-02-21T01:04:05+00:00"
}, },
{
"name": "php-debugbar/php-debugbar",
"version": "v2.2.5",
"source": {
"type": "git",
"url": "https://github.com/php-debugbar/php-debugbar.git",
"reference": "c5dce08e98dd101c771e55949fd89124b216271d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/c5dce08e98dd101c771e55949fd89124b216271d",
"reference": "c5dce08e98dd101c771e55949fd89124b216271d",
"shasum": ""
},
"require": {
"php": "^8.1",
"psr/log": "^1|^2|^3",
"symfony/var-dumper": "^5.4|^6.4|^7.3|^8.0"
},
"replace": {
"maximebf/debugbar": "self.version"
},
"require-dev": {
"dbrekelmans/bdi": "^1",
"phpunit/phpunit": "^10",
"symfony/browser-kit": "^6.0|7.0",
"symfony/panther": "^1|^2.1",
"twig/twig": "^3.11.2"
},
"suggest": {
"kriswallsmith/assetic": "The best way to manage assets",
"monolog/monolog": "Log using Monolog",
"predis/predis": "Redis storage"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.2-dev"
}
},
"autoload": {
"psr-4": {
"DebugBar\\": "src/DebugBar/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maxime Bouroumeau-Fuseau",
"email": "maxime.bouroumeau@gmail.com",
"homepage": "http://maximebf.com"
},
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "Debug bar in the browser for php application",
"homepage": "https://github.com/php-debugbar/php-debugbar",
"keywords": [
"debug",
"debug bar",
"debugbar",
"dev"
],
"support": {
"issues": "https://github.com/php-debugbar/php-debugbar/issues",
"source": "https://github.com/php-debugbar/php-debugbar/tree/v2.2.5"
},
"time": "2025-12-21T08:50:08+00:00"
},
{ {
"name": "phpdocumentor/reflection-common", "name": "phpdocumentor/reflection-common",
"version": "2.2.0", "version": "2.2.0",

View File

@@ -65,7 +65,7 @@ return [
| |
*/ */
'timezone' => 'UTC', 'timezone' => 'Asia/Yakutsk',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -13,7 +13,9 @@ return new class extends Migration
{ {
Schema::create('archive_histories', function (Blueprint $table) { Schema::create('archive_histories', function (Blueprint $table) {
$table->id(); $table->id();
$table->morphs('historyable'); $table->string('keyarhiv')->nullable();
$table->string('foxpro_history_id')->nullable();
$table->bigInteger('mis_history_id')->nullable();
$table->timestamp('issue_at'); $table->timestamp('issue_at');
$table->timestamp('return_at')->nullable(); $table->timestamp('return_at')->nullable();
$table->text('comment')->nullable(); $table->text('comment')->nullable();

View File

@@ -13,8 +13,11 @@ return new class extends Migration
{ {
Schema::create('archive_infos', function (Blueprint $table) { Schema::create('archive_infos', function (Blueprint $table) {
$table->id(); $table->id();
$table->morphs('historyable'); $table->string('foxpro_history_id')->nullable();
$table->string('num'); $table->bigInteger('mis_history_id')->nullable();
$table->string('foxpro_num')->nullable();
$table->string('mis_num')->nullable();
$table->string('archive_num');
$table->date('post_in'); $table->date('post_in');
$table->foreignIdFor(\App\Models\ArchiveStatus::class, 'status_id')->default(1); $table->foreignIdFor(\App\Models\ArchiveStatus::class, 'status_id')->default(1);
$table->timestamps(); $table->timestamps();

View File

@@ -0,0 +1,21 @@
<?php
namespace Database\Seeders;
use App\Models\Org;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class OrgSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
Org::create([
'code' => '0',
'name' => 'Неизвестно'
]);
}
}

View File

@@ -1,34 +1,18 @@
services: services:
#PHP Service #PHP Service
app: app:
build: image: registry.brusoff.su/kartoteka:1.0
context: . build: .
dockerfile: Dockerfile
image: kartoteka:latest
container_name: kartoteka_app container_name: kartoteka_app
restart: unless-stopped restart: unless-stopped
tty: true ports:
environment: - "8090:80"
SERVICE_NAME: app
SERVICE_TAGS: dev
working_dir: /var/www working_dir: /var/www
volumes: volumes:
- ./.env:/var/www/.env - ./.env:/var/www/.env
- ./docker/php.ini:/usr/local/etc/php/conf.d/app.ini - ./docker/php.ini:/usr/local/etc/php/conf.d/app.ini
networks: - ./docker/blocked_ips.map:/etc/nginx/blocked_ips.map
- app-network - ./storage/logs:/var/www/storage/logs
#Nginx Service
webserver:
image: nginx:alpine
container_name: kartoteka_nginx
restart: unless-stopped
tty: true
ports:
- "8090:80"
volumes:
- ./:/var/www
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
networks: networks:
- app-network - app-network

204
docker/app.conf Normal file
View File

@@ -0,0 +1,204 @@
# Определяем переменную для блокировки
map $remote_addr $blocked_ip {
default 0;
include /etc/nginx/blocked_ips.map;
}
# Определяем типы запросов
map $request_uri $request_type {
default "general";
# API endpoints
~*^/api/ "api";
# Auth endpoints
~*^/(login|register|password-reset|auth|forgot-password|verify-email) "auth";
# Admin endpoints
~*^/(admin|dashboard|cp|control-panel|manager) "admin";
# Static files by extension
~*\.(jpg|jpeg|png|gif|ico|css|js|woff2?|ttf|eot|svg|pdf|zip|mp4|webm)$ "static";
# Static files by directory
~*^/(storage|uploads|media|images|css|js|fonts)/ "static";
}
server {
listen 80;
server_name _;
root /var/www/public;
index index.php index.html;
# ========== ОСНОВНЫЕ НАСТРОЙКИ БЕЗОПАСНОСТИ ==========
# Защитные заголовки
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ========== ПРЕДВАРИТЕЛЬНЫЕ ПРОВЕРКИ ==========
# Если IP в черном списке
if ($blocked_ip) {
return 444;
}
# Блокировка пустых User-Agent
if ($http_user_agent = "") {
return 444;
}
# Блокировка нестандартных методов
if ($request_method !~ ^(GET|HEAD|POST|OPTIONS)$) {
return 444;
}
# ========== ГЛОБАЛЬНЫЕ ОГРАНИЧЕНИЯ ==========
limit_conn conn_limit_per_ip 25;
limit_req zone=req_limit_per_ip burst=30 delay=15;
# ========== СТАТИЧЕСКИЕ ФАЙЛЫ ==========
location ~* \.(jpg|jpeg|png|gif|ico|webp|avif)$ {
limit_req zone=static_limit burst=100 nodelay;
limit_conn conn_limit_per_ip 100;
expires 1y;
add_header Cache-Control "public, immutable";
add_header Pragma "public";
try_files $uri =404;
access_log off;
log_not_found off;
}
location ~* \.(css|js)$ {
limit_req zone=static_limit burst=80 nodelay;
limit_conn conn_limit_per_ip 80;
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff";
try_files $uri =404;
access_log off;
}
location ~* \.(woff2?|ttf|eot|svg)$ {
limit_req zone=static_limit burst=60 nodelay;
limit_conn conn_limit_per_ip 60;
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
try_files $uri =404;
access_log off;
}
# ========== API ENDPOINTS ==========
location ~* ^/api/ {
limit_req zone=api_limit burst=30 delay=15;
limit_conn conn_limit_per_ip 30;
access_log /var/log/nginx/api_access.log main;
try_files $uri $uri/ /index.php?$query_string;
}
# ========== АУТЕНТИФИКАЦИЯ ==========
location ~* ^/(login|register|password-reset|auth|forgot-password|verify-email) {
limit_req zone=auth_limit burst=5 delay=3;
limit_conn conn_limit_per_ip 5;
access_log /var/log/nginx/auth_access.log main;
try_files $uri $uri/ /index.php?$query_string;
}
# ========== ОБРАБОТКА PHP ==========
location ~ \.php$ {
# Дефолтные лимиты для всех PHP запросов
limit_req zone=req_limit_per_ip burst=30 delay=15;
limit_conn conn_limit_per_ip 30;
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
# Оптимизация из основного конфига
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_busy_buffers_size 240k;
fastcgi_temp_file_write_size 256k;
# Таймауты
fastcgi_connect_timeout 60s;
fastcgi_send_timeout 600s;
fastcgi_read_timeout 600s;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
# ========== ОСНОВНОЙ LOCATION ==========
location / {
if ($query_string ~* "(?:^|[^a-z])(?:union|select|insert|update|delete|drop|create|alter|exec)(?:[^a-z]|$)") {
return 444;
}
if ($request_uri ~ "//") {
return 444;
}
try_files $uri $uri/ /index.php?$query_string;
gzip_static on;
gzip_vary on;
}
# ========== HEALTH CHECK ==========
location = /health {
access_log off;
# Прямой прокси в Laravel
try_files $uri /index.php?$query_string;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# ========== ЗАЩИТА ФАЙЛОВОЙ СИСТЕМЫ ==========
location ~ /\. {
deny all;
access_log off;
log_not_found off;
return 404;
}
location ~* (\.env|\.git|\.svn|\.htaccess|composer\.json|composer\.lock) {
deny all;
return 404;
}
location ~* (eval|base64_encode|system\(|shell_exec|passthru|exec|phpinfo) {
deny all;
return 444;
}
}

0
docker/blocked_ips.map Normal file
View File

View File

@@ -1,20 +1,81 @@
server { worker_processes auto;
listen 80; worker_rlimit_nofile 65535;
index index.php index.html;
error_log /var/log/nginx/error.log; error_log /var/log/nginx/error.log warn;
access_log /var/log/nginx/access.log; pid /var/run/nginx.pid;
root /var/www/public;
location ~ \.php$ { events {
try_files $uri =404; worker_connections 4096;
fastcgi_split_path_info ^(.+\.php)(/.+)$; use epoll;
fastcgi_pass app:9000; multi_accept on;
fastcgi_index index.php; accept_mutex_delay 100ms;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
} }
location / {
try_files $uri $uri/ /index.php?$query_string; http {
gzip_static on; limit_conn_zone $binary_remote_addr zone=conn_limit_per_ip:10m;
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=6r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=3r/s;
limit_req_zone $binary_remote_addr zone=bot_limit:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=static_limit:10m rate=100r/s;
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
map $http_user_agent $limit_bots {
default "";
~*(googlebot|bingbot|yandex|baiduspider) $binary_remote_addr;
} }
limit_req_zone $limit_bots zone=bots:10m rate=20r/m;
limit_req_zone $request_method zone=post_limit:10m rate=5r/s;
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:10m max_size=1g inactive=40m use_temp_path=off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 75;
keepalive_requests 100;
types_hash_max_size 2048;
server_tokens off;
client_max_body_size 10m;
client_body_buffer_size 128k;
client_body_timeout 12;
client_header_timeout 12;
send_timeout 30;
reset_timedout_connection on;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss
application/atom+xml image/svg+xml;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct="$upstream_connect_time" uht="$upstream_header_time" urt="$upstream_response_time"';
log_format ddos_log '$remote_addr - [$time_local] "$request" $status '
'rate=$limit_req_status conn=$limit_conn_status '
'user_agent="$http_user_agent"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
limit_req_status 429;
limit_conn_status 429;
include /etc/nginx/conf.d/*.conf;
} }

25
docker/supervisord.conf Normal file
View File

@@ -0,0 +1,25 @@
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisor.log
logfile_maxbytes=50MB
logfile_backups=3
[program:php-fpm]
command=/usr/local/sbin/php-fpm -F
autostart=true
autorestart=true
priority=5
stdout_logfile=/var/log/supervisor/php-fpm.log
stderr_logfile=/var/log/supervisor/php-fpm_err.log
logfile_maxbytes=50MB
logfile_backups=3
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
priority=10
stdout_logfile=/var/log/supervisor/nginx.log
stderr_logfile=/var/log/supervisor/nginx_err.log
logfile_maxbytes=50MB
logfile_backups=3

View File

@@ -16,51 +16,15 @@ export const useMedicalHistoryFilter = (initialFilters = {}) => {
page_size: initialFilters?.page_size || 50, page_size: initialFilters?.page_size || 50,
sort_by: initialFilters?.sort_by || 'dateextract', sort_by: initialFilters?.sort_by || 'dateextract',
sort_order: initialFilters?.sort_order || 'desc', sort_order: initialFilters?.sort_order || 'desc',
view_type: initialFilters?.view_type || 'si',
status: initialFilters?.status || null, status: initialFilters?.status || null,
database: initialFilters?.database || 'separate', // НОВЫЙ ПАРАМЕТР: postgresql, mssql, smart, separate
}) })
// Метаданные для разных типов данных // Метаданные для разных типов данных
const meta = computed(() => { const meta = computed(() => {
const cards = page.props.cards return page.props.cards.meta
// Для раздельного поиска
if (filtersRef.value.database === 'separate') {
return {
si: cards.si.meta || {},
mis: cards.mis.meta || {},
stats: cards.stats || {},
}
} else if (filtersRef.value.database === 'mis') {
return {
mis: cards.mis?.meta
}
} else {
return {
si: cards.si?.meta
}
}
}) })
const isLoading = ref(false) const isLoading = ref(false)
const databaseStats = computed(() => page.props.databaseStats || {})
// Статистика по базам
const databaseInfo = computed(() => ({
postgresql: {
count: databaseStats.value.postgresql?.total || 0,
label: 'PostgreSQL',
color: 'blue',
description: 'Основной архив'
},
mssql: {
count: databaseStats.value.mssql?.total || 0,
label: 'MSSQL',
color: 'purple',
description: 'Исторический архив'
}
}))
// Форматирование даты для URL // Форматирование даты для URL
const formatDateForUrl = (date) => { const formatDateForUrl = (date) => {
@@ -81,8 +45,6 @@ export const useMedicalHistoryFilter = (initialFilters = {}) => {
page_size: filtersRef.value.page_size, page_size: filtersRef.value.page_size,
sort_by: filtersRef.value.sort_by, sort_by: filtersRef.value.sort_by,
sort_order: filtersRef.value.sort_order, sort_order: filtersRef.value.sort_order,
view_type: filtersRef.value.view_type,
database: filtersRef.value.database,
status: filtersRef.value.status status: filtersRef.value.status
} }
@@ -128,10 +90,6 @@ export const useMedicalHistoryFilter = (initialFilters = {}) => {
debouncedSearch(value) debouncedSearch(value)
} }
const handleDatabaseChange = (database) => {
applyFilters({ database, page: 1 }, false)
}
// Конвертация строки даты в timestamp для NaiveUI // Конвертация строки даты в timestamp для NaiveUI
const convertToTimestamp = (dateString) => { const convertToTimestamp = (dateString) => {
if (!dateString) return null if (!dateString) return null
@@ -166,6 +124,11 @@ export const useMedicalHistoryFilter = (initialFilters = {}) => {
const searchValue = ref(filtersRef.value.search ?? null) const searchValue = ref(filtersRef.value.search ?? null)
const statusValue = computed(() => {
if (filtersRef.value.status !== null) return Number(filtersRef.value.status)
else return filtersRef.value.status
})
const handleDateRangeChange = (timestamps) => { const handleDateRangeChange = (timestamps) => {
dateRange.value = timestamps || [null, null] dateRange.value = timestamps || [null, null]
@@ -186,10 +149,6 @@ export const useMedicalHistoryFilter = (initialFilters = {}) => {
applyFilters({ page_size: size, page: 1 }) applyFilters({ page_size: size, page: 1 })
} }
const handleViewTypeChange = (view_type) => {
applyFilters({ view_type, page: 1 })
}
const handleSortChange = (sorter) => { const handleSortChange = (sorter) => {
applyFilters({ applyFilters({
sort_by: sorter.columnKey, sort_by: sorter.columnKey,
@@ -213,8 +172,6 @@ export const useMedicalHistoryFilter = (initialFilters = {}) => {
page_size: 15, page_size: 15,
sort_by: 'dateextract', sort_by: 'dateextract',
sort_order: 'desc', sort_order: 'desc',
view_type: 'archive',
database: 'postgresql',
} }
dateRange.value = [null, null] dateRange.value = [null, null]
applyFilters({}, true) applyFilters({}, true)
@@ -270,22 +227,6 @@ export const useMedicalHistoryFilter = (initialFilters = {}) => {
}) })
} }
// Фильтр по базе данных
if (filtersRef.value.database) {
const dbLabel = {
postgresql: 'PostgreSQL',
mssql: 'MSSQL',
smart: 'Умный поиск',
separate: 'Раздельно'
}[filtersRef.value.database] || filtersRef.value.database
active.push({
key: 'database',
label: `База: ${dbLabel}`,
icon: 'database'
})
}
return active return active
}) })
@@ -302,9 +243,6 @@ export const useMedicalHistoryFilter = (initialFilters = {}) => {
updates.date_extract_to = null updates.date_extract_to = null
dateRange.value = [null, null] dateRange.value = [null, null]
break break
case 'database':
updates.database = 'postgresql'
break
} }
Object.assign(filtersRef.value, updates) Object.assign(filtersRef.value, updates)
@@ -341,12 +279,9 @@ export const useMedicalHistoryFilter = (initialFilters = {}) => {
activeFilters, activeFilters,
dateRange, dateRange,
searchValue, searchValue,
databaseInfo, statusValue,
databaseStats,
// Обработчики // Обработчики
handleDatabaseChange,
handleViewTypeChange,
handleSearch, handleSearch,
handleDateRangeChange, handleDateRangeChange,
handlePageChange, handlePageChange,

View File

@@ -0,0 +1,43 @@
import {h} from "vue";
import {NCode, NLog} from "naive-ui"
export function useNotification() {
const showNotification = (options) => {
if (window.$notification) {
return window.$notification.create(options)
}
return null
}
const success = () => {
}
const errorApi = (data, content = null, options = {}) => {
return showNotification(
{
title: 'Произошла ошибка',
description: data
? `Код: ${data.code}\nURL: ${data.response.config.url}\nМетод: ${data.response.config.method.toUpperCase()}`
: null,
content: () => {
return content ? content : h(
NLog,
{
rows: 4,
log: data.response.config.data,
}
)
},
meta: options.meta || new Date().toLocaleDateString(),
...options,
type: 'error'
}
)
}
return {
errorApi
}
}

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { NLayout, NH1, NLayoutSider, NFlex, NButton, NConfigProvider, ruRU, dateRuRU } from "naive-ui"; import {NLayout, NH1, NLayoutSider, NFlex, NButton, NConfigProvider, ruRU, dateRuRU, darkTheme} from "naive-ui";
import SideMenu from "./Components/SideMenu.vue"; import SideMenu from "./Components/SideMenu.vue";
import { generate } from '@arco-design/color' import { generate } from '@arco-design/color'
@@ -7,17 +7,27 @@ const colors = generate('#EC6608', {
list: true, list: true,
}) })
const themeOverrides = { const themeOverrides = {
common: { // common: {
primaryColor: colors[5], // primaryColor: colors[5],
primaryColorHover: colors[4], // primaryColorHover: colors[4],
primaryColorSuppl: colors[4], // primaryColorSuppl: colors[4],
primaryColorPressed: colors[6], // primaryColorPressed: colors[6],
// },
Modal: {
peers: {
Dialog: {
borderRadius: '8px'
},
Card: {
borderRadius: '8px'
},
}
} }
} }
</script> </script>
<template> <template>
<NConfigProvider :theme-overrides="themeOverrides" :locale="ruRU" :date-locale="dateRuRU"> <NConfigProvider :theme="darkTheme" :theme-overrides="themeOverrides" :locale="ruRU" :date-locale="dateRuRU">
<NLayout class="h-screen"> <NLayout class="h-screen">
<NLayout position="absolute" content-class="p-6 relative" :native-scrollbar="false"> <NLayout position="absolute" content-class="p-6 relative" :native-scrollbar="false">
<!-- <NLayoutSider--> <!-- <NLayoutSider-->

View File

@@ -0,0 +1,166 @@
<script setup>
import { NModal, NSelect, NSpace, NFlex, NButton, NForm, NFormItem, NInput, NDatePicker, NDivider, NSpin, NTag } from 'naive-ui'
import ArchiveHistoryMoveModal from '../ArchiveHistoryMoveModal/Index.vue'
import {computed, ref, watch} from "vue";
import {useMedicalHistoryFilter} from "../../../Composables/useMedicalHistoryFilter.js";
import {useNotification} from "../../../Composables/useNotification.js";
import {useDebounceFn} from "@vueuse/core";
const open = defineModel('open')
const {errorApi} = useNotification()
const loading = ref(false)
const archiveInfo = ref({
id: null,
num: null,
post_in: null,
})
const archiveInfoRules = {
id: [
{
required: true,
validator(rule, value) {
console.log(value)
if (!value) {
return new Error('Это поле необходимо заполнить')
}
else if (!Number(value)) {
return new Error('Ошибка при получении ID')
}
return true
},
trigger: ['input', 'blur']
}
],
num: [
{
required: true,
message: 'Это поле необходимо заполнить',
trigger: 'blur'
}
],
post_in: [
{
required: true,
message: 'Это поле необходимо заполнить',
trigger: 'blur'
}
]
}
const loadingFilterPatients = ref(false)
const formRef = ref(null)
const emits = defineEmits(['historyUpdated', 'closeWithoutSave'])
const onResetData = () => {
archiveInfo.value = {
id: null,
num: null,
post_in: null,
}
}
const onCloseWithoutSave = () => {
emits('closeWithoutSave')
open.value = false
onResetData()
}
const filteredPatients = ref([])
const debounceSearchCard = useDebounceFn(async (query) => {
loadingFilterPatients.value = true
await axios.post('/api/mis/patients/search', {
query
}).then(res => {
filteredPatients.value = res.data
}).finally(() => {
loadingFilterPatients.value = false
})
}, 1000)
const handleSearchCard = (query) => {
debounceSearchCard(query)
}
const onSubmit = async (e) => {
e.preventDefault()
formRef.value?.validate(async (errors) => {
if (!errors) {
loading.value = true
try {
await axios.post(`/api/archive/create`, archiveInfo.value).then(res => {
open.value = false
})
} catch (error) {
errorApi(error)
console.error(error)
} finally {
loading.value = false
}
}
else {
console.log(errors)
errorApi(null, 'Проверьте заполненность формы')
}
})
}
</script>
<template>
<NModal v-model:show="open"
preset="card"
class="max-w-xl relative"
:mask-closable="false"
closable
title="Добавить карту в архив"
@close="onCloseWithoutSave">
<NFlex class="w-full" :wrap="false">
<NForm ref="formRef" id="formAddToArchive" class="w-full" :model="archiveInfo" :rules="archiveInfoRules">
<NFormItem label="Карта пациента" path="id">
<NSelect placeholder="№ карты фамилия имя отчество ИЛИ фамилия имя отчество"
size="large"
:remote
filterable
:loading="loadingFilterPatients"
:options="filteredPatients"
v-model:value="archiveInfo.id"
@search="handleSearchCard"
/>
</NFormItem>
<NFormItem size="large" label="Дата поступления карты в архив" path="post_in">
<NDatePicker class="w-full"
v-model:value="archiveInfo.post_in"
format="dd.MM.yyyy"
/>
</NFormItem>
<NFormItem size="large" label="№ в архиве" path="num">
<NInput v-model:value="archiveInfo.num" />
</NFormItem>
</NForm>
</NFlex>
<template #action>
<NFlex justify="end" align="center">
<NSpace align="center" :size="0">
<NButton secondary
type="primary"
attr-type="submit"
form="formAddToArchive"
@click="onSubmit">
Добавить карту
</NButton>
</NSpace>
</NFlex>
</template>
<div v-show="loading"
class="absolute inset-0 z-10 backdrop-blur flex items-center justify-center rounded-[8px]">
<NSpin :show="true" />
</div>
</NModal>
</template>
<style scoped>
</style>

View File

@@ -1,25 +1,27 @@
<script setup> <script setup>
import { NModal, NDataTable, NSpace, NFlex, NButton, NForm, NFormItem, NInput, NDatePicker, NDivider, NSwitch, NTag } from 'naive-ui' import { NModal, NDataTable, NSpace, NFlex, NButton, NForm, NFormItem, NInput, NDatePicker, NDivider, NSpin, NTag } from 'naive-ui'
import ArchiveHistoryMoveModal from '../ArchiveHistoryMoveModal/Index.vue' import ArchiveHistoryMoveModal from '../ArchiveHistoryMoveModal/Index.vue'
import {computed, ref, watch} from "vue"; import {computed, ref, watch} from "vue";
import {useMedicalHistoryFilter} from "../../../Composables/useMedicalHistoryFilter.js"; import {useMedicalHistoryFilter} from "../../../Composables/useMedicalHistoryFilter.js";
import {useNotification} from "../../../Composables/useNotification.js";
const open = defineModel('open') const open = defineModel('open')
const props = defineProps({ const props = defineProps({
patientId: { patientInfo: {
type: Number, type: Number,
} }
}) })
const {filtersRef} = useMedicalHistoryFilter() const {filtersRef} = useMedicalHistoryFilter()
const {errorApi} = useNotification()
const loading = ref(true) const loading = ref(true)
const patient = ref({}) const patient = ref({})
const showArchiveHistoryModal = ref(false) const showArchiveHistoryModal = ref(false)
const selectedArchiveHistoryId = ref(null) const selectedArchiveHistoryId = ref(null)
const selectedArchiveHistoryType = ref(null) const selectedArchiveHistoryType = ref(props.patientInfo.type)
const isCreateNewArchiveHistoryModal = ref(false) const isCreateNewArchiveHistoryModal = ref(false)
const archiveInfo = ref({ const archiveInfo = ref({
id: props.patientId, id: props.patientInfo.id,
num: null, num: null,
post_in: null, post_in: null,
status: null status: null
@@ -28,7 +30,7 @@ const emits = defineEmits(['historyUpdated', 'closeWithoutSave'])
const onResetData = () => { const onResetData = () => {
archiveInfo.value = { archiveInfo.value = {
id: props.patientId, id: props.patientInfo.id,
num: null, num: null,
post_in: null, post_in: null,
status: null status: null
@@ -45,23 +47,23 @@ const patientData = ref({
}) })
const loadPatientData = async () => { const loadPatientData = async () => {
if (!props.patientId) return if (!props.patientInfo.id) return
loading.value = true loading.value = true
try { try {
await axios.get(`/api/si/patients/${props.patientId}?view_type=${filtersRef.value.view_type}`).then(res => { await axios.get(`/api/si/patients/${props.patientInfo.id}?view_type=${props.patientInfo.type}`).then(res => {
patient.value = res.data patient.value = res.data
patientData.value = res.data.info patientData.value = res.data.info
archiveInfo.value = res.data.archiveInfo ?? { archiveInfo.value = res.data.archiveInfo ?? {
historyable_type: res.data.historyable_type, // historyable_type: res.data.historyable_type,
id: props.patientId, id: props.patientInfo.id,
num: null, num: null,
post_in: null, post_in: null,
status: null, status: null,
} }
}) })
} catch (error) { } catch (error) {
// message.error('Ошибка при загрузке данных пациента') errorApi(error)
console.error(error) console.error(error)
} finally { } finally {
loading.value = false loading.value = false
@@ -71,12 +73,12 @@ const loadPatientData = async () => {
const onShowArchiveHistoryModal = (id) => { const onShowArchiveHistoryModal = (id) => {
if (id === null) { if (id === null) {
isCreateNewArchiveHistoryModal.value = true isCreateNewArchiveHistoryModal.value = true
selectedArchiveHistoryId.value = props.patientId selectedArchiveHistoryId.value = Number(props.patientInfo.id)
} else { } else {
isCreateNewArchiveHistoryModal.value = false isCreateNewArchiveHistoryModal.value = false
selectedArchiveHistoryId.value = id selectedArchiveHistoryId.value = Number(id)
} }
selectedArchiveHistoryType.value = archiveInfo.value.historyable_type selectedArchiveHistoryType.value = props.patientInfo.type
showArchiveHistoryModal.value = true showArchiveHistoryModal.value = true
} }
@@ -120,7 +122,7 @@ const onUpdateHistory = async ({data}) => {
} }
emits('historyUpdated', { emits('historyUpdated', {
data: updatedData, data: updatedData,
patientId: props.patientId patientId: props.patientInfo.id
}) })
} }
@@ -128,19 +130,20 @@ const hasCreateNew = computed(() => archiveInfo.value === null)
const onSubmit = async () => { const onSubmit = async () => {
try { try {
await axios.post(`/api/archive/histories/info/${props.patientId}`, archiveInfo.value).then(res => { await axios.post(`/api/archive/histories/info/${props.patientInfo.id}`, archiveInfo.value).then(res => {
// onCloseWithoutSave() // onCloseWithoutSave()
const updatedData = { const updatedData = {
status: archiveInfo.value.status, status: archiveInfo.value.status,
} }
emits('historyUpdated', { emits('historyUpdated', {
data: updatedData, data: updatedData,
patientId: props.patientId patientId: props.patientInfo.id
}) })
open.value = false open.value = false
}) })
} catch (error) { } catch (error) {
errorApi(error)
console.error(error) console.error(error)
} finally { } finally {
loading.value = false loading.value = false
@@ -148,7 +151,7 @@ const onSubmit = async () => {
} }
// Наблюдаем за изменением patientId // Наблюдаем за изменением patientId
watch(() => props.patientId, async (newId) => { watch(() => props.patientInfo, async (newId) => {
if (newId) { if (newId) {
await loadPatientData() await loadPatientData()
} }
@@ -156,7 +159,7 @@ watch(() => props.patientId, async (newId) => {
</script> </script>
<template> <template>
<NModal v-model:show="open" preset="card" class="max-w-4xl" closable @close="onCloseWithoutSave"> <NModal v-model:show="open" preset="card" class="max-w-4xl relative overflow-clip" closable @close="onCloseWithoutSave">
<template #header> <template #header>
{{ patient.info?.medcardnum }} {{ patient.info?.family }} {{ patient.info?.name }} {{ patient.info?.ot }} {{ patient.info?.medcardnum }} {{ patient.info?.family }} {{ patient.info?.name }} {{ patient.info?.ot }}
</template> </template>
@@ -174,6 +177,11 @@ watch(() => props.patientId, async (newId) => {
<NFormItem label="Дата поступления карты в архив"> <NFormItem label="Дата поступления карты в архив">
<NDatePicker v-model:value="archiveInfo.post_in" format="dd.MM.yyyy" /> <NDatePicker v-model:value="archiveInfo.post_in" format="dd.MM.yyyy" />
</NFormItem> </NFormItem>
<NFormItem label="№ карты в МИС / СофтИнфо">
<NTag :bordered="false" round class="w-full justify-center">
{{ archiveInfo.mis_num }} / {{ archiveInfo.foxpro_num }}
</NTag>
</NFormItem>
</NForm> </NForm>
</NFlex> </NFlex>
<NButton @click="onShowArchiveHistoryModal(null)" :disabled="!patientData.can_be_issued"> <NButton @click="onShowArchiveHistoryModal(null)" :disabled="!patientData.can_be_issued">
@@ -194,8 +202,12 @@ watch(() => props.patientId, async (newId) => {
</NSpace> </NSpace>
</NFlex> </NFlex>
</template> </template>
<div v-show="loading" class="absolute inset-0 z-10 backdrop-blur flex items-center justify-center">
<NSpin :show="true" />
</div>
</NModal> </NModal>
<ArchiveHistoryMoveModal @history-updated="onUpdateHistory" @close-without-save="selectedArchiveHistoryId = null" :is-create-new="isCreateNewArchiveHistoryModal" v-model:open="showArchiveHistoryModal" :active-history-type="selectedArchiveHistoryType" :archive-history-id="selectedArchiveHistoryId" /> <ArchiveHistoryMoveModal @history-updated="onUpdateHistory" @close-without-save="selectedArchiveHistoryId = null" :is-create-new="isCreateNewArchiveHistoryModal" v-model:open="showArchiveHistoryModal" :type="selectedArchiveHistoryType" :archive-history-id="selectedArchiveHistoryId" />
</template> </template>
<style scoped> <style scoped>

View File

@@ -1,15 +1,28 @@
<script setup> <script setup>
import {NButton, NDatePicker, NDivider, NFlex, NForm, NFormItem, NInput, NModal, NSpace, NSwitch, NSelect} from "naive-ui"; import {
NButton,
NDatePicker,
NDivider,
NFlex,
NForm,
NFormItem,
NInput,
NModal,
NSpace,
NSwitch,
NSelect,
NSpin
} from "naive-ui";
import {ref, watch} from "vue"; import {ref, watch} from "vue";
import {router} from "@inertiajs/vue3"; import {router} from "@inertiajs/vue3";
const open = defineModel('open') const open = defineModel('open')
const props = defineProps({ const props = defineProps({
type: {
type: String
},
archiveHistoryId: { archiveHistoryId: {
type: Number type: Number
}, },
activeHistoryType: {
type: String
},
isCreateNew: { isCreateNew: {
type: Boolean type: Boolean
} }
@@ -18,8 +31,7 @@ const emits = defineEmits(['closeWithoutSave', 'historyUpdated'])
const loading = ref(false) const loading = ref(false)
const archiveHistory = ref({ const archiveHistory = ref({
historyable_id: props.archiveHistoryId, type: props.type,
historyable_type: props.activeHistoryType,
issue_at: null, issue_at: null,
org_id: null, org_id: null,
return_at: null, return_at: null,
@@ -32,8 +44,8 @@ const orgs = ref([])
const onResetData = () => { const onResetData = () => {
archiveHistory.value = { archiveHistory.value = {
historyable_id: props.archiveHistoryId, history_id: props.archiveHistoryId,
historyable_type: props.activeHistoryType, type: props.type,
issue_at: null, issue_at: null,
org_id: null, org_id: null,
return_at: null, return_at: null,
@@ -99,9 +111,9 @@ watch(() => props.archiveHistoryId, async (newId) => {
</script> </script>
<template> <template>
<NModal v-model:show="open" preset="card" class="max-w-2xl" closable @close="onCloseWithoutSave"> <NModal v-model:show="open" preset="card" class="max-w-2xl relative overflow-clip" closable @close="onCloseWithoutSave">
<template #header> <template #header>
{{ archiveHistoryId === null ? 'Добавить' : 'Редактировать' }} запись выдачи {{ isCreateNew ? 'Добавить' : 'Редактировать' }} запись выдачи
</template> </template>
<NForm> <NForm>
<NFormItem label="Дата выдачи"> <NFormItem label="Дата выдачи">
@@ -140,6 +152,10 @@ watch(() => props.archiveHistoryId, async (newId) => {
</NSpace> </NSpace>
</NFlex> </NFlex>
</template> </template>
<div v-show="loading" class="absolute inset-0 z-10 backdrop-blur flex items-center justify-center">
<NSpin :show="true" />
</div>
</NModal> </NModal>
</template> </template>

View File

@@ -29,13 +29,12 @@ const dataTable = ref(props.data ?? [])
const archiveStatusColumn = (status) => { const archiveStatusColumn = (status) => {
const tagType = status?.variant ?? 'error' const tagType = status?.variant ?? 'error'
const tagText = status?.text ?? 'Нет в архиве' const tagText = status?.text ?? 'Нет в архиве'
console.log(tagType)
return h( return h(
NEllipsis, NEllipsis,
null, null,
{ {
default: () => h(NTag, {type: tagType, round: true, size: 'small'}, tagText) default: () => h(NTag, {type: tagType, bordered: false, round: true, size: 'small'}, tagText)
}) })
} }
@@ -90,56 +89,37 @@ const columns = ref([
} }
]) ])
const showArchiveHistoryModal = ref(false) const showArchiveHistoryModal = ref(false)
const selectedPatientId = ref(null) const selectedPatientInfo = ref({})
const rowProps = (row) => ({ const rowProps = (row) => ({
onDblclick: () => { onDblclick: () => {
selectedPatientId.value = row.id selectedPatientInfo.value = {id: row.id, type: row.history_type}
showArchiveHistoryModal.value = true showArchiveHistoryModal.value = true
} }
}) })
const pagination = computed(() => { const pagination = computed(() => {
if (filtersRef.value.view_type === 'si') {
return { return {
page: meta.value.si.current_page || 1, page: meta.value.current_page || 1,
pageCount: meta.value.si.last_page || 1, pageCount: meta.value.last_page || 1,
pageSize: meta.value.si.per_page || 15, pageSize: meta.value.per_page || 15,
itemCount: meta.value.si.total || 0, itemCount: meta.value.total || 0,
pageSizes: [15, 50, 100], pageSizes: [15, 50, 100],
showSizePicker: true, showSizePicker: true,
prefix({ itemCount }) { prefix({ itemCount }) {
return `Всего карт ${itemCount}.` return `Всего карт ${itemCount}`
}, },
onUpdatePage: (page) => { onUpdatePage: (page) => {
handlePageChange(page) handlePageChange(page)
}, },
onUpdatePageSize: handlePageSizeChange onUpdatePageSize: handlePageSizeChange
} }
} else {
return {
page: meta.value.mis.current_page || 1,
pageCount: meta.value.mis.last_page || 1,
pageSize: meta.value.mis.per_page || 15,
itemCount: meta.value.mis.total || 0,
pageSizes: [15, 50, 100],
showSizePicker: true,
prefix({ itemCount }) {
return `Всего карт ${itemCount}.`
},
onUpdatePage: (page) => {
handlePageChange(page)
},
onUpdatePageSize: handlePageSizeChange
}
}
}) })
const onCloseWithoutSave = () => { const onCloseWithoutSave = () => {
selectedPatientId.value = null selectedPatientInfo.value = null
} }
const onUpdateHistory = ({data, patientId}) => { const onUpdateHistory = ({data, patientId}) => {
console.log(data)
if (dataTable.value.length > 0) { if (dataTable.value.length > 0) {
let needUpdateItem = dataTable.value.findIndex(itm => itm.id === patientId) let needUpdateItem = dataTable.value.findIndex(itm => itm.id === patientId)
dataTable.value[needUpdateItem] = { dataTable.value[needUpdateItem] = {
@@ -155,8 +135,22 @@ watch(() => props.data, (newData) => {
</script> </script>
<template> <template>
<NDataTable remote striped :loading="isLoading" :row-props="rowProps" :columns="columns" :pagination="pagination" :max-height="maxHeight" size="small" :min-height="minHeight" :data="dataTable" /> <NDataTable remote
<ArchiveHistoryModal v-model:open="showArchiveHistoryModal" :patient-id="selectedPatientId" @history-updated="onUpdateHistory" @close-without-save="onCloseWithoutSave" /> striped
:loading="isLoading"
:row-props="rowProps"
:columns="columns"
:pagination="pagination"
:max-height="maxHeight"
size="small"
:min-height="minHeight"
:data="dataTable"
/>
<ArchiveHistoryModal v-model:open="showArchiveHistoryModal"
:patient-info="selectedPatientInfo"
@history-updated="onUpdateHistory"
@close-without-save="onCloseWithoutSave"
/>
</template> </template>
<style scoped> <style scoped>

View File

@@ -1,11 +1,10 @@
<script setup> <script setup>
import AppLayout from "../../Layouts/AppLayout.vue" import AppLayout from "../../Layouts/AppLayout.vue"
import TableCards from './DataTable/Index.vue' import TableCards from './DataTable/Index.vue'
import { NInput, NFlex, NDivider, NDatePicker, NSpace, NFormItem, NRadioButton, NH1, NTabs, NTabPane, NSelect } from 'naive-ui' import ArchiveHistoryCreateModal from './ArchiveHistoryCreateModal/Index.vue'
import {useMedicalHistory} from "../../Composables/useMedicalHistory.js"; import { NInput, NFlex, NDivider, NDatePicker, NSpace, NFormItem, NRadioButton, NH1, NTabs, NButton, NSelect } from 'naive-ui'
import {useDebounceFn} from "@vueuse/core";
import {useMedicalHistoryFilter} from "../../Composables/useMedicalHistoryFilter.js"; import {useMedicalHistoryFilter} from "../../Composables/useMedicalHistoryFilter.js";
import {computed, ref} from "vue"; import {ref} from "vue";
const props = defineProps({ const props = defineProps({
cards: { cards: {
@@ -22,12 +21,18 @@ const props = defineProps({
} }
}) })
const ArchiveHistoryCreateModalShow = ref(false)
const { const {
isLoading, applyFilters, filtersRef, handleSearch, dateRange, searchValue, isLoading, applyFilters, filtersRef, handleSearch, dateRange, searchValue,
handleDateRangeChange, handleViewTypeChange, handleStatusChange statusValue, handleDateRangeChange, handleViewTypeChange, handleStatusChange
} = useMedicalHistoryFilter(props.filters) } = useMedicalHistoryFilter(props.filters)
const viewType = ref('archive') const viewType = ref('archive')
const searchRef = ref()
const onHandleSearch = (search) => {
handleSearch(search)
}
const handleBeforeLeave = (tabName) => { const handleBeforeLeave = (tabName) => {
handleViewTypeChange(tabName) handleViewTypeChange(tabName)
@@ -39,36 +44,68 @@ const handleBeforeLeave = (tabName) => {
<AppLayout> <AppLayout>
<template #header> <template #header>
<NSpace vertical> <NSpace vertical>
<!-- <NH1 class="!my-0 mb-5">-->
<!-- Архив-->
<!-- </NH1>-->
<!-- <NRadioGroup v-model:value="viewType" @update:value="val => handleViewTypeChange(val)">-->
<!-- <NRadioButton value="archive">Архив</NRadioButton>-->
<!-- <NRadioButton value="mis">МИС</NRadioButton>-->
<!-- <NRadioButton value="softinfo">СофтИнфо</NRadioButton>-->
<!-- </NRadioGroup>-->
<NFlex class="pb-4" align="center" :wrap="false"> <NFlex class="pb-4" align="center" :wrap="false">
<NFormItem class="w-[720px]" label="Поиск" :show-feedback="false"> <NFormItem class="w-[720px]" label="Поиск" :show-feedback="false">
<NInput placeholder="Поиск по ФИО, № карты" v-model:value="searchValue" @update:value="val => handleSearch(val)" size="large" /> <NInput placeholder="Поиск по ФИО, № карты"
autofocus
ref="searchRef"
clearable
v-model:value="searchValue"
@update:value="val => onHandleSearch(val)"
size="large"
:loading="isLoading"
:disabled="isLoading"
/>
</NFormItem> </NFormItem>
<div class="mt-6">
<NDivider vertical /> <NDivider vertical />
</div>
<NFormItem class="w-[340px]" label="Дата выписки" :show-feedback="false"> <NFormItem class="w-[340px]" label="Дата выписки" :show-feedback="false">
<NDatePicker v-model:value="dateRange" @update:value="handleDateRangeChange" type="daterange" clearable format="dd.MM.yyyy" start-placeholder="Дата выписки с" end-placeholder="по" size="large" /> <NDatePicker v-model:value="dateRange"
@update:value="handleDateRangeChange"
type="daterange"
clearable
format="dd.MM.yyyy"
start-placeholder="Дата выписки с"
end-placeholder="по"
size="large"
:disabled="isLoading"
/>
</NFormItem> </NFormItem>
<NFormItem class="w-[340px]" label="Статус карты" :show-feedback="false"> <NFormItem class="w-[340px]" label="Статус карты" :show-feedback="false">
<NSelect :options="statuses" @update:value="val => handleStatusChange(val)" clearable size="large" /> <NSelect :options="statuses"
:value="statusValue"
@update:value="val => handleStatusChange(val)"
clearable
size="large"
:loading="isLoading"
:disabled="isLoading"
/>
</NFormItem>
<div class="mt-6">
<NDivider vertical />
</div>
<NFormItem class="w-[340px]" :show-feedback="false">
<NButton type="primary"
@click="ArchiveHistoryCreateModalShow = true"
secondary
size="large"
:loading="isLoading"
:disabled="isLoading"
>
Добавить карту в архив
</NButton>
</NFormItem> </NFormItem>
</NFlex> </NFlex>
</NSpace> </NSpace>
</template> </template>
<NTabs :value="filtersRef.view_type" type="segment" animated @before-leave="handleBeforeLeave"> <TableCards :filters="filters"
<NTabPane name="si" :tab="`СофтИнфо (${cards.si?.meta.total})`"> :data="cards.data"
<TableCards :filters="filters" :data="cards.si?.data" :meta="cards.si?.meta" min-height="calc(100vh - 286px)" max-height="calc(100vh - 320px)" /> :meta="cards.meta"
</NTabPane> min-height="calc(100vh - 212px)"
<NTabPane name="mis" :tab="`МИС (${cards.mis?.meta.total})`"> max-height="calc(100vh - 320px)"
<TableCards :filters="filters" :data="cards.mis?.data" :meta="cards.mis?.meta" min-height="calc(100vh - 286px)" max-height="calc(100vh - 320px)" /> />
</NTabPane> <ArchiveHistoryCreateModal v-model:open="ArchiveHistoryCreateModalShow" />
</NTabs>
</AppLayout> </AppLayout>
</template> </template>

View File

@@ -0,0 +1,22 @@
import {createDiscreteApi, darkTheme, lightTheme} from "naive-ui";
export function setupNaiveDiscreteApi(app) {
const {message, notification, dialog, loadingBar} = createDiscreteApi(
['message', 'dialog', 'notification', 'loadingBar'],
{
configProviderProps: {
theme: window.matchMedia('(prefers-color-scheme: dark)').matches ? darkTheme : lightTheme
}
}
)
window.$notification = notification
window.$message = message
window.$dialog = dialog
window.$loadingBar = loadingBar
app.config.globalProperties.$notification = notification
app.config.globalProperties.$message = message
app.config.globalProperties.$dialog = dialog
app.config.globalProperties.$loadingBar = loadingBar
}

View File

@@ -3,6 +3,7 @@ import '../css/app.css';
import { createApp, h } from 'vue' import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3' import { createInertiaApp } from '@inertiajs/vue3'
import {createPinia} from "pinia"; import {createPinia} from "pinia";
import {setupNaiveDiscreteApi} from "./Plugins/naiveUI.js";
createInertiaApp({ createInertiaApp({
id: 'kartoteka', id: 'kartoteka',
@@ -11,11 +12,17 @@ createInertiaApp({
return pages[`./Pages/${name}.vue`] return pages[`./Pages/${name}.vue`]
}, },
setup({el, App, props, plugin}) { setup({el, App, props, plugin}) {
const vueApp = createApp({render: () => h(App, props)})
const pinia = createPinia() const pinia = createPinia()
createApp({ render: () => h(App, props) }) vueApp.use(plugin)
.use(plugin) vueApp.use(pinia)
.use(pinia)
.mount(el) setupNaiveDiscreteApi(vueApp)
vueApp.mount(el)
}, },
}).then(r => {
console.log('Инициализация прошла успешно')
}) })

View File

@@ -13,6 +13,12 @@ Route::prefix('si')->group(function () {
}); });
}); });
Route::prefix('mis')->group(function () {
Route::prefix('patients')->group(function () {
Route::post('search', [\App\Http\Controllers\MisHistoryController::class, 'search']);
});
});
Route::prefix('archive')->group(function () { Route::prefix('archive')->group(function () {
Route::prefix('histories')->group(function () { Route::prefix('histories')->group(function () {
Route::get('{id}', [\App\Http\Controllers\ArchiveHistoryController::class, 'show']); Route::get('{id}', [\App\Http\Controllers\ArchiveHistoryController::class, 'show']);
@@ -24,6 +30,8 @@ Route::prefix('archive')->group(function () {
Route::post('{patientId}', [\App\Http\Controllers\ArchiveHistoryController::class, 'infoUpdate']); Route::post('{patientId}', [\App\Http\Controllers\ArchiveHistoryController::class, 'infoUpdate']);
}); });
}); });
Route::post('create', [\App\Http\Controllers\ArchiveInfoController::class, 'store']);
}); });
Route::prefix('orgs')->group(function () { Route::prefix('orgs')->group(function () {

2
storage/debugbar/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

0
storage/logs/.gitignore vendored Normal file → Executable file
View File