chore: каркас из шаблона команды (DKB_ARMKA@ba172f8, доменный код удалён)
This commit is contained in:
commit
ad4f4559a9
330
.gitignore
vendored
Normal file
330
.gitignore
vendored
Normal file
@ -0,0 +1,330 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
api/.env
|
||||
api/.venv
|
||||
api/env/
|
||||
api/venv/
|
||||
api/ENV/
|
||||
api/env.bak/
|
||||
api/venv.bak/
|
||||
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Custom ignore
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.vscode
|
||||
my_project_env
|
||||
.cursorignore
|
||||
18
CLAUDE.md
Normal file
18
CLAUDE.md
Normal file
@ -0,0 +1,18 @@
|
||||
# CLAUDE.md — scan-gateway
|
||||
|
||||
Общий контекст системы — `../CLAUDE.md` и `../docs/`. Этот файл — только про этот сервис.
|
||||
|
||||
## Что это
|
||||
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: переименовать файл `UUID_имя`, положить в «Входящие»,
|
||||
забрать `Исходящие/UUID/UUID.json`, сохранить сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется),
|
||||
нормализовать в `../docs/contracts/recognition-result.schema.json` (`statementDocument`), опубликовать
|
||||
`recognition.result`. Таймаут 5 мин → `timeout`. **Нет доступа к БД.**
|
||||
|
||||
## Правила
|
||||
- Нормализация по фактическим ответам `../fixtures/ocr/aep_*.json`, а не по ТЗ (OQ-13, OQ-14): confidence → 0–100,
|
||||
поля организации/даты — по тому, что реально приходит.
|
||||
- Один PDF может содержать ББ и ОФР → два документа в результате.
|
||||
- Dev-режим: эмулятор ИС Сканирование, возвращающий `aep_N.json` по имени файла (`SCAN_EMULATOR=1`).
|
||||
|
||||
## Соглашения шаблона
|
||||
<!-- заполняет агент -->
|
||||
163
README.md
Normal file
163
README.md
Normal file
@ -0,0 +1,163 @@
|
||||
[](https://github.com/psf/black) [](https://pycqa.github.io/isort/) [](https://github.com/pylint-dev/pylint)
|
||||
__________________________________________________________________________________________________________
|
||||
# dkb_armka, v 0.0.1
|
||||
____
|
||||
|
||||
| | Исполнитель | Заказчик |
|
||||
|-----------|:----------------------------------------:|:----------------------------------------------:|
|
||||
| Сотрудник | raykov-mse | raykov-mse |
|
||||
| Отдел | aurora | aurora |
|
||||
| Контакты | raykovmse@rshb.ru | raykovmse@rshb.ru |
|
||||
-----
|
||||
# Шаблон FastAPI
|
||||
|
||||
## Описание
|
||||
|
||||
Данное шаблон предоставляет проект, в котором приложение состоит из API и UI. При использовании данного типа проекта, Вы можете разрабатывать, как отдельные API сервисы, так и WEB приложения с интерфейсом. Для построения WEB интерфейса используется React. Далее описаны основные иснтрументы предустановленные данным шаблоном:
|
||||
|
||||
### Подготовка к разработке
|
||||
|
||||
Для удобной настройки проекта под разработку. Перейдите в каталог setup и выполните слудеющие команды:
|
||||
|
||||
```bash
|
||||
cd setup
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
По итогу успешного выполнения Вы получите сообщение следующего
|
||||
|
||||
```bash
|
||||
complete setup web application
|
||||
To start api, cd to api directory, and use venv, start run_server.py
|
||||
To start api, cd to web directory, and use command npm run start!
|
||||
Happy Hacking! :)
|
||||
```
|
||||
|
||||
Далее просто запускайте проекты и приступайте к разработке
|
||||
|
||||
### Backend
|
||||
|
||||
Исходный код API находится в каталоге api. После клонирования репозитория требуется создать виртуальное окружение и установить зависимости, это достигается следующими командами:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
python -m venv ./.venv
|
||||
cp ./../setup/pip.conf ./.venv/pip.conf
|
||||
source ./.venv/bin/python
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Запуск приложения осуществляется через файл: run_server.py
|
||||
|
||||
В качестве фреймворка ля api используется FastAPI. (Как понятно из названия шаблона) Подробнее ознакомится с возможностями Fast API вы можете в официальной документации: [Fast API документация](https://fastapi.tiangolo.com/)
|
||||
|
||||
Вы можете дополнительно устанавливать библиотеки необходимые для разработки. Настоятельно не рекомендуется изменять базовые предустановки из файла requirements.txt
|
||||
|
||||
Базовый шаблон предоставляет минимальный функционал по работе с данными. Далее описаны основные моменты влияющие на работу приложения.
|
||||
|
||||
#### Основная структура проекта
|
||||
|
||||
```bash
|
||||
api
|
||||
├── requirements.txt
|
||||
├── run_server.py
|
||||
├── src
|
||||
│ ├── app.py
|
||||
│ ├── __init__.py
|
||||
│ ├── settings
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── models.py
|
||||
│ │ └── settings.py
|
||||
└── web
|
||||
```
|
||||
|
||||
Вы можете придерживатся любой структуры проекта, главное, чтобы у Вас присутствовал файл run_server.py. Который запускает web сервер uvicorn или gunicorn. Также обязательно Ваш сервер должен подниматся на определенном хосте и порте. Требуемый хост и порт передаются внутрь через переменные окружения SERVER_HOST, SERVER_PORT
|
||||
|
||||
```python
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=settings.server.HOST,
|
||||
port=settings.server.PORT,
|
||||
lifespan="on"
|
||||
)
|
||||
```
|
||||
|
||||
#### Отказ от ui (react)
|
||||
|
||||
Если внутри вашего приложения не требуется добавлять визуальный интерфейс, а Вам требуется исключительно разработать API, то в таком случае не стоит удалять директорию web находящуюся на уровне с директорией api. ТРебуется внести небольшие правки в конфигурацию приложения, в частности в файл: /api/src/app.py, удалив следующее содержимое. В таком случае Fast API сервис не будет обрабатывать статические файлы, от web приложения.
|
||||
|
||||
```python
|
||||
#Подключение собранного web приложения
|
||||
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
Исходный код UI находится в каталоге web. После клонирования репозитория требуется установить зависимости, это достигается следующими командами:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
```
|
||||
|
||||
Запуск приложения осуществляется через файл команду:
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
В качестве фреймворка для ui используется React. Подробнее ознакомится с возможностями React вы можете в официальной документации: [React документация](https://react.dev/reference/react)
|
||||
|
||||
Вы можете дополнительно устанавливать библиотеки необходимые для разработки. Настоятельно не рекомендуется изменять базовые предустановки из файла package.json
|
||||
|
||||
Базовый шаблон предоставляет минимальный интерфейс по работе с данными. Далее описаны основные моменты влияющие на работу web приложения.
|
||||
|
||||
#### Основная структура проекта
|
||||
|
||||
```bash
|
||||
web
|
||||
├── biome.json
|
||||
├── .npmrc
|
||||
├── dist
|
||||
├── package.json
|
||||
├── postcss.config.cjs
|
||||
├── public
|
||||
│ └── index.html
|
||||
├── README.md
|
||||
├── rsbuild.config.ts
|
||||
├── src
|
||||
│ ├── App.tsx
|
||||
│ ├── config.ts
|
||||
│ ├── env.d.ts
|
||||
│ ├── index.css
|
||||
│ └── index.tsx
|
||||
├── tailwind.config.js
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
Вы можете придерживатся любой структуры проекта, внутри src. Но на верхнем уровне находятся файлы конфигурации и сборки их требуется изменять только в крайнем случае. Для сборки приложения используется [RSBuild](https://rsbuild.dev/)
|
||||
|
||||
Для создания UI настоятельно рекомендуется использовать предустановленную библиотеку компонентови стилей:
|
||||
|
||||
- tailwindcss
|
||||
- daisy-ui
|
||||
- react-daisy-ui
|
||||
- raisa-ui
|
||||
|
||||
!!! Важно, если внутри web приложения Вы хотите обращатся к API данного проекта, используйте следующую конструкцию:
|
||||
|
||||
```javascript
|
||||
|
||||
fetch(`${Config.API_URL}/api`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
Речь идет об вызываемом эндпоинте ${Config.API_URL}/api, Обязательно используйте конструкцию данного вида т.к. при сборке приложения статический сайт обращается относительно api и вместо
|
||||
запросов вида http://localhost:8000/api должно быть так: /api
|
||||
|
||||
149
api/alembic.ini
Normal file
149
api/alembic.ini
Normal file
@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
api/alembic/README
Normal file
1
api/alembic/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
102
api/alembic/env.py
Normal file
102
api/alembic/env.py
Normal file
@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from alembic import context
|
||||
from src.core.config import settings
|
||||
from src.models import *
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.OPENBAO.DATABASE.DATABASE_URL)
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
def include_name(name, type_, parent_names):
|
||||
if type_ == 'schema':
|
||||
return name == 'dkb'
|
||||
return True
|
||||
|
||||
|
||||
def do_run_migrations(connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
version_table_schema="dkb",
|
||||
include_schemas=True,
|
||||
include_name=include_name
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
version_table_schema="dkb",
|
||||
include_schemas=True,
|
||||
include_name=include_name
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = AsyncEngine(
|
||||
engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
future=True
|
||||
)
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
28
api/alembic/script.py.mako
Normal file
28
api/alembic/script.py.mako
Normal file
@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
api/alembic/versions/.gitkeep
Normal file
0
api/alembic/versions/.gitkeep
Normal file
21
api/pytest.ini
Normal file
21
api/pytest.ini
Normal file
@ -0,0 +1,21 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths =
|
||||
tests
|
||||
python_files =
|
||||
test_*.py
|
||||
tests*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = session
|
||||
asyncio_default_test_loop_scope = session
|
||||
addopts =
|
||||
--tb=short
|
||||
--strict-markers
|
||||
--verbose
|
||||
--capture=no
|
||||
env =
|
||||
APP_ENV=test
|
||||
ENV_FILE=.env.test
|
||||
norecursedirs = .git __pycache__ .venv env build dist
|
||||
50
api/requirements-dev.txt
Normal file
50
api/requirements-dev.txt
Normal file
@ -0,0 +1,50 @@
|
||||
aiosqlite
|
||||
alembic
|
||||
annotated-types==0.7.0
|
||||
anyio==4.8.0
|
||||
argon2-cffi
|
||||
asyncpg
|
||||
certifi==2024.12.14
|
||||
click==8.1.8
|
||||
colorama==0.4.6
|
||||
dnspython==2.7.0
|
||||
email_validator==2.2.0
|
||||
et_xmlfile==2.0.0
|
||||
fastapi==0.115.7
|
||||
fastapi-cli==0.0.7
|
||||
h11==0.14.0
|
||||
httpcore==1.0.7
|
||||
httptools==0.6.4
|
||||
httpx==0.27.2
|
||||
idna==3.10
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.5
|
||||
markdown-it-py==3.0.0
|
||||
MarkupSafe==3.0.2
|
||||
mdurl==0.1.2
|
||||
openpyxl==3.1.5
|
||||
orjson==3.10.15
|
||||
pydantic==2.10.6
|
||||
pydantic-extra-types==2.10.2
|
||||
pydantic-settings==2.7.1
|
||||
pydantic_core==2.27.2
|
||||
Pygments==2.19.1
|
||||
python-dotenv==1.0.1
|
||||
python-jose
|
||||
python-multipart==0.0.20
|
||||
PyYAML==6.0.2
|
||||
rich==13.9.4
|
||||
rich-toolkit==0.13.2
|
||||
shellingham==1.5.4
|
||||
sniffio==1.3.1
|
||||
sqlalchemy
|
||||
starlette==0.45.3
|
||||
typer==0.15.1
|
||||
typing_extensions==4.12.2
|
||||
ujson==5.10.0
|
||||
uvicorn==0.34.0
|
||||
watchfiles==1.0.4
|
||||
websockets==14.2
|
||||
pytest-asyncio
|
||||
pytest-env
|
||||
cosmic-ray
|
||||
68
api/requirements.txt
Normal file
68
api/requirements.txt
Normal file
@ -0,0 +1,68 @@
|
||||
aiohappyeyeballs==2.7.1
|
||||
aiohttp==3.14.3
|
||||
aiohttp-retry==2.9.1
|
||||
aiosignal==1.4.0
|
||||
alembic==1.14.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.8.0
|
||||
anyio==4.14.2
|
||||
argon2-cffi==23.1.0
|
||||
argon2-cffi-bindings==25.1.0
|
||||
asyncpg==0.30.0
|
||||
attrs==26.1.0
|
||||
certifi==2025.11.12
|
||||
cffi==2.1.0
|
||||
click==8.4.2
|
||||
dnspython==2.8.0
|
||||
email-validator==2.3.0
|
||||
fastapi==0.135.1
|
||||
fastapi-cli==0.0.24
|
||||
fastapi-cloud-cli==0.14.1
|
||||
fastar==0.8.0
|
||||
frozenlist==1.8.0
|
||||
greenlet==3.4.0
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.7.1
|
||||
httpx==0.28.1
|
||||
idna==3.18
|
||||
Jinja2==3.1.6
|
||||
loguru==0.7.3
|
||||
Mako==1.3.10
|
||||
markdown-it-py==4.2.0
|
||||
MarkupSafe==3.0.3
|
||||
mdurl==0.1.2
|
||||
miniopy-async==1.23.4
|
||||
multidict==6.7.1
|
||||
nats-py==2.14.0
|
||||
nkeys==0.2.1
|
||||
propcache==0.5.2
|
||||
pycparser==3.0
|
||||
pycryptodome==3.23.0
|
||||
pydantic==2.13.4
|
||||
pydantic-extra-types==2.11.0
|
||||
pydantic-settings==2.14.2
|
||||
pydantic_core==2.46.4
|
||||
Pygments==2.20.0
|
||||
PyNaCl==1.5.0
|
||||
python-dotenv==1.2.2
|
||||
python-multipart==0.0.32
|
||||
PyYAML==6.0.3
|
||||
raisa_fastapi_protected_api==1.0.0
|
||||
rich==15.0.0
|
||||
rich-toolkit==0.19.7
|
||||
rignore==0.7.6
|
||||
sentry-sdk==2.66.1
|
||||
shellingham==1.5.4
|
||||
SQLAlchemy==2.0.36
|
||||
smbprotocol==1.15.0
|
||||
starlette==1.3.1
|
||||
typer==0.27.0
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.16.0
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.46.0
|
||||
uvloop==0.22.1
|
||||
watchfiles==1.2.0
|
||||
websockets==16.1.1
|
||||
yarl==1.24.5
|
||||
12
api/run_server.py
Normal file
12
api/run_server.py
Normal file
@ -0,0 +1,12 @@
|
||||
import uvicorn
|
||||
from src import app, get_settings
|
||||
|
||||
if __name__ == "__main__":
|
||||
settings = get_settings()
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=settings.server.HOST,
|
||||
port=settings.server.PORT,
|
||||
lifespan="on",
|
||||
)
|
||||
|
||||
7
api/src/__init__.py
Normal file
7
api/src/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from .app import app
|
||||
from .core.config import get_settings
|
||||
|
||||
__all__ = [
|
||||
'app',
|
||||
'get_settings',
|
||||
]
|
||||
0
api/src/api/dependencies/__init__.py
Normal file
0
api/src/api/dependencies/__init__.py
Normal file
0
api/src/api/endpoints/__init__.py
Normal file
0
api/src/api/endpoints/__init__.py
Normal file
3
api/src/api/router.py
Normal file
3
api/src/api/router.py
Normal file
@ -0,0 +1,3 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
250
api/src/app.py
Normal file
250
api/src/app.py
Normal file
@ -0,0 +1,250 @@
|
||||
import os
|
||||
|
||||
import smbclient
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.core.PDFWorker.PDFTempPoller import PDFPollWorker
|
||||
from .core.config import get_settings
|
||||
# from .api.router import files_router
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from src.core.nats_callback import build_file_handler
|
||||
from fastapi.exceptions import HTTPException, StarletteHTTPException
|
||||
from src.api.router import le_router, files_router, cg_router, rf_office_router, osv_router, fsl_router
|
||||
from miniopy_async import Minio
|
||||
from src.core.config import settings
|
||||
from src.core.clients import S3Storage, PollWorker
|
||||
from src.core.async_cache import AsyncInMemoryCache
|
||||
# from src.core.logger import setup_logger
|
||||
from nats.aio.client import Client as NATS
|
||||
import nkeys
|
||||
import base64
|
||||
from loguru import logger
|
||||
|
||||
import os
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
from src.core.exception_handlers import register_exception_handlers
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
VERSION = "1.0.0"
|
||||
UP_TIME = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode('utf-8'))
|
||||
|
||||
|
||||
def jwt_cb():
|
||||
return settings.OPENBAO.NATS.NATS_JWT.encode('utf-8')
|
||||
|
||||
|
||||
def sig_cb(nonce):
|
||||
if (isinstance(nonce, str)):
|
||||
nonce = nonce.encode('utf-8')
|
||||
signed = kp.sign(nonce)
|
||||
return base64.b64encode(signed)
|
||||
|
||||
|
||||
async def sub_cb(msg):
|
||||
return None
|
||||
|
||||
|
||||
protected_middleware_available = False
|
||||
if not settings.DEBUG:
|
||||
try:
|
||||
from raisa_fastapi_protected_api import ( # pyright: ignore[reportMissingImports]
|
||||
AuthorizationMiddleware,
|
||||
OpenEndpoint,
|
||||
ProtectedOAuthSettings,
|
||||
ProtectedRolesSettings,
|
||||
ProtectedSettings,
|
||||
SearchType,
|
||||
)
|
||||
|
||||
protected_middleware_available = True
|
||||
except ImportError:
|
||||
protected_middleware_available = False
|
||||
|
||||
|
||||
def _normalize_root_path(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
value = value.strip()
|
||||
if value == "/":
|
||||
return ""
|
||||
return "/" + value.strip("/")
|
||||
|
||||
|
||||
_APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
app.state.nats_subs = []
|
||||
app.state.storage = None
|
||||
app.state.nats = None
|
||||
try:
|
||||
smbclient.register_session(settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD)
|
||||
except:
|
||||
logger.exception("Ошибка при регистрации сессии SMB")
|
||||
s3_client = Minio(settings.OPENBAO.MINIO.MINIO_URL, access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, cert_check=False)
|
||||
try:
|
||||
bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET)
|
||||
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Не удалось проверить наличие бакетa. error_category=s3_error, error_type={error_type}, error={error}",
|
||||
error_type=type(exc).__name__,
|
||||
error=repr(exc),
|
||||
)
|
||||
return
|
||||
storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
|
||||
app.state.storage = storage
|
||||
cache = AsyncInMemoryCache()
|
||||
nc = NATS()
|
||||
if not settings.DEBUG:
|
||||
try:
|
||||
await nc.connect(settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb,
|
||||
max_reconnect_attempts=2)
|
||||
logger.info("Подключение к NATS произошло")
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Не удалось проверить наличие бакетa. error_category=nats_error, error_type={error_type}, error={error}",
|
||||
error_type=type(exc).__name__,
|
||||
error=repr(exc),
|
||||
)
|
||||
return
|
||||
poller = PollWorker(nc, cache, storage)
|
||||
await poller.start()
|
||||
pdf_poller = PDFPollWorker(cache, storage)
|
||||
await pdf_poller.start()
|
||||
app.state.nats = nc
|
||||
app.state.poller = poller
|
||||
app.state.pdf_poller = pdf_poller
|
||||
if not settings.DEBUG:
|
||||
handler_cb = build_file_handler(nc, storage)
|
||||
|
||||
try:
|
||||
# sub_xls = await nc.subscribe('files.tabular_parsed', cb=handler_cb)
|
||||
# sub_img = await nc.subscribe('files.img_parsed', cb=handler_cb)
|
||||
#
|
||||
# app.state.nats_subs = [sub_xls, sub_img]
|
||||
logger.info("Подписка на топики завершена")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Не удалось подписаться на тему. error_category=nats_error, error_type={error_type}, error={error}",
|
||||
error_type=type(exc).__name__,
|
||||
error=repr(exc),
|
||||
)
|
||||
try:
|
||||
logger.info("Приложение запущено")
|
||||
yield
|
||||
except Exception as exc:
|
||||
logger.exception("ОШИБКА ПРИ РАБОТЕ С СЕКРЕТАМИ error={error}", error=repr(exc))
|
||||
finally:
|
||||
logger.info("Приложение закрывается")
|
||||
smbclient.reset_connection_cache()
|
||||
if not settings.DEBUG:
|
||||
for sub in app.state.nats_subs:
|
||||
logger.info("Отписываемся от топика")
|
||||
await sub.unsubscribe()
|
||||
if app.state.nats:
|
||||
await app.state.nats.close()
|
||||
if app.state.poller:
|
||||
await app.state.poller.stop()
|
||||
if app.state.storage:
|
||||
await app.state.storage.client.close_session()
|
||||
if app.state.pdf_poller:
|
||||
await app.state.pdf_poller.stop()
|
||||
logger.info("CLOSING APP AT ALL")
|
||||
|
||||
app = FastAPI(
|
||||
title="Fast API",
|
||||
version=VERSION,
|
||||
root_path=settings.ROOT_PATH,
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/healthcheck",
|
||||
status_code=200
|
||||
)
|
||||
async def healthcheck():
|
||||
return {
|
||||
"uptime": UP_TIME,
|
||||
"version": VERSION
|
||||
}
|
||||
|
||||
|
||||
app.include_router(le_router)
|
||||
app.include_router(files_router)
|
||||
app.include_router(cg_router)
|
||||
app.include_router(rf_office_router)
|
||||
app.include_router(osv_router)
|
||||
app.include_router(fsl_router)
|
||||
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
|
||||
|
||||
|
||||
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
#Создание каталога для web приложения
|
||||
os.makedirs(os.path.join('web'), exist_ok=True)
|
||||
|
||||
#Подключаем все ендпоинты и роуты до маунта статических файлов
|
||||
# app.include_router(files_router)
|
||||
|
||||
#Подключение собранного web приложения
|
||||
|
||||
|
||||
def sep_static(path, stat_dir):
|
||||
sep_path = path.split("/")
|
||||
index = sep_path.index(stat_dir)
|
||||
return "/".join(sep_path[index:])
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
try:
|
||||
return await super().get_response(path, scope)
|
||||
except (HTTPException, StarletteHTTPException) as ex:
|
||||
if ex.status_code == 404:
|
||||
if "images" in path:
|
||||
return await super().get_response(sep_static(path, "images"), scope)
|
||||
if "static" in path:
|
||||
return await super().get_response(sep_static(path, "static"), scope)
|
||||
return await super().get_response("index.html", scope)
|
||||
else:
|
||||
raise ex
|
||||
|
||||
|
||||
|
||||
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
|
||||
|
||||
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
||||
5
api/src/common/exceptions.py
Normal file
5
api/src/common/exceptions.py
Normal file
@ -0,0 +1,5 @@
|
||||
class NotFoundError(Exception):
|
||||
pass
|
||||
|
||||
class CouldntCreateError(Exception):
|
||||
pass
|
||||
0
api/src/core/__init__.py
Normal file
0
api/src/core/__init__.py
Normal file
52
api/src/core/async_cache.py
Normal file
52
api/src/core/async_cache.py
Normal file
@ -0,0 +1,52 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
@dataclass
|
||||
class FileDictItem:
|
||||
started_at: datetime
|
||||
expires_at: datetime
|
||||
file_id: str
|
||||
job_id: str
|
||||
s3_key: str
|
||||
legal_entity_id: str
|
||||
|
||||
class AsyncInMemoryCache:
|
||||
def __init__(self):
|
||||
self.mapping: dict[str, FileDictItem] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def add(self, job_id: str, file_id: str, s3_key: str, legal_entity_id: str) -> FileDictItem:
|
||||
now = datetime.now()
|
||||
expiration_date = datetime.now() + timedelta(minutes=5)
|
||||
|
||||
new_item = FileDictItem(
|
||||
started_at= now,
|
||||
expires_at= expiration_date,
|
||||
file_id= str(file_id),
|
||||
s3_key=s3_key,
|
||||
job_id= str(job_id),
|
||||
legal_entity_id = legal_entity_id
|
||||
)
|
||||
async with self._lock:
|
||||
self.mapping[str(job_id)] = new_item
|
||||
return new_item
|
||||
|
||||
async def get(self, job_id: uuid.UUID):
|
||||
async with self._lock:
|
||||
return self.mapping.get(str(job_id))
|
||||
|
||||
async def remove(self, source_link: str) -> None:
|
||||
async with self._lock:
|
||||
self.mapping.pop(source_link, None)
|
||||
|
||||
|
||||
async def get_active_items(self) -> list[FileDictItem]:
|
||||
async with self._lock:
|
||||
return [
|
||||
item
|
||||
for item in self.mapping.values()
|
||||
]
|
||||
180
api/src/core/clients.py
Normal file
180
api/src/core/clients.py
Normal file
@ -0,0 +1,180 @@
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
import smbclient
|
||||
from miniopy_async import Minio
|
||||
from io import BytesIO
|
||||
import asyncio
|
||||
from nats.aio.client import Client as NATS
|
||||
|
||||
from sqlalchemy import or_, select, update, and_
|
||||
|
||||
from src.core.async_cache import AsyncInMemoryCache
|
||||
from src.models import RecognitionJob, StoredFile
|
||||
from src.schemas.enums import RecognitionStatus, FileStatus
|
||||
from src.db.db import async_session_maker
|
||||
from src.core.config import settings
|
||||
|
||||
from loguru import logger
|
||||
|
||||
ext_to_subject = {
|
||||
"XLS": "file.tabular_uploaded",
|
||||
"XLSX": "file.tabular_uploaded",
|
||||
"PNG": "file.img_uploaded",
|
||||
"PDF": "file.img_uploaded",
|
||||
"JPG": "file.img_uploaded",
|
||||
}
|
||||
|
||||
class S3Storage:
|
||||
def __init__(self, client: Minio, bucket_name: str):
|
||||
self.client = client
|
||||
self.bucket = bucket_name
|
||||
|
||||
async def put_object(self, obj_name, file_data):
|
||||
try:
|
||||
stream = BytesIO(file_data)
|
||||
await self.client.put_object(self.bucket, obj_name, stream, length=len(file_data))
|
||||
except:
|
||||
raise
|
||||
|
||||
async def get_object(self, obj_name):
|
||||
try:
|
||||
response = await self.client.get_object(self.bucket, obj_name)
|
||||
# file_bytes = await response.content.read()
|
||||
return response
|
||||
except:
|
||||
raise
|
||||
|
||||
|
||||
|
||||
class PollWorker:
|
||||
def __init__(self, nats_client: NATS, cache: AsyncInMemoryCache, s3_client: S3Storage):
|
||||
self.task: asyncio.Task | None = None
|
||||
self.stop_event = asyncio.Event()
|
||||
self.nats_client = nats_client
|
||||
self.cache = cache
|
||||
self.storage = s3_client
|
||||
|
||||
|
||||
async def start(self):
|
||||
self.stop_event.clear()
|
||||
self.task = asyncio.create_task(self.run())
|
||||
|
||||
async def stop(self):
|
||||
self.stop_event.set()
|
||||
if self.task:
|
||||
self.task.cancel()
|
||||
try:
|
||||
await self.task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def run(self):
|
||||
logger.info("Начат процесс опроса БД по RecognitionJob")
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
await self.poll_once()
|
||||
except Exception:
|
||||
# logger.exception("Poll iteration failed")
|
||||
pass
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.stop_event.wait(),
|
||||
timeout=7,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
logger.info("PollWorker остановлен")
|
||||
|
||||
|
||||
async def poll_once(self):
|
||||
try:
|
||||
rows = await self.claim_pending_rows()
|
||||
logger.info("Получил rows. Количество: {rlen}", rlen=len(rows))
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Проблема при получении строк из БД по RecognitionJob. error_category=pollworker, error_type={error_type}, error={error}",
|
||||
error_type=type(exc).__name__,
|
||||
error=repr(exc),
|
||||
)
|
||||
rows=[]
|
||||
|
||||
for row in rows:
|
||||
row_id = row["id"]
|
||||
try:
|
||||
# await self.publish_to_nats(row["subject"], row["row_payload"])
|
||||
|
||||
if row['subject'] == 'file.img_uploaded':
|
||||
last_part = row['row_payload']['s3_key'].split('/')[-1]
|
||||
with smbclient.open_file(f'{settings.OPENBAO.SMB.SMB_PATH}/Файлы/{last_part}', 'wb') as f:
|
||||
data = await self.storage.get_object(row['row_payload']['s3_key'])
|
||||
content = await data.read()
|
||||
f.write(content)
|
||||
await self.cache.add(row['row_payload']['job_id'], row['row_payload']['file_id'],
|
||||
row['row_payload']['s3_key'], row['row_payload']['legal_entity_id'])
|
||||
|
||||
|
||||
except Exception as exc:
|
||||
# logger.exception(
|
||||
# "Failed to publish row id={row_id}. error_category=pollworker, error_type={error_type}, error={error}",
|
||||
# row_id=row_id,
|
||||
# error_type=type(exc).__name__,
|
||||
# error=repr(exc),
|
||||
# )
|
||||
logger.exception(
|
||||
"Ошибка при попытке положить сообщение в кэш. error_category=pdfpollworker, error_type={error_type}, error={error}",
|
||||
row_id=row_id,
|
||||
error_type=type(exc).__name__,
|
||||
error=repr(exc),
|
||||
)
|
||||
|
||||
await self.mark_queued(row_id)
|
||||
|
||||
async def claim_pending_rows(self):
|
||||
async with async_session_maker() as session:
|
||||
stmt = (
|
||||
select(RecognitionJob, StoredFile)
|
||||
.join(StoredFile, StoredFile.id == RecognitionJob.file_id)
|
||||
.where(and_(RecognitionJob.status == RecognitionStatus.QUEUED, StoredFile.status.is_(None)))
|
||||
.with_for_update(skip_locked=True, read=True)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
result_scalars = result.all()
|
||||
|
||||
for (job, file) in result_scalars:
|
||||
job.status = RecognitionStatus.PROCESSING
|
||||
file.status = FileStatus.PROCESSING
|
||||
job.requested_at = datetime.now()
|
||||
|
||||
|
||||
await session.commit()
|
||||
|
||||
return [{
|
||||
"id": job.id,
|
||||
"subject": ext_to_subject[file.file_type],
|
||||
"row_payload": {
|
||||
"legal_entity_id": str(job.legal_entity_id),
|
||||
"file_id": str(file.id),
|
||||
"s3_key": str(file.s3_key),
|
||||
"job_id": str(job.id)
|
||||
}
|
||||
|
||||
} for (job, file) in result_scalars]
|
||||
|
||||
async def publish_to_nats(self, subject, payload):
|
||||
if not settings.DEBUG:
|
||||
# logger.info("Попытка отправить сообщение в subject={subject}", subject=subject)
|
||||
await self.nats_client.publish(subject, json.dumps(payload).encode("utf-8"))
|
||||
else:
|
||||
print("PUBLISHING TO NATS", subject, payload) #print достаточен, поскеольку работает локально
|
||||
print()
|
||||
|
||||
async def mark_queued(self, row_id):
|
||||
async with async_session_maker() as session:
|
||||
stmt = update(RecognitionJob).where(RecognitionJob.id == row_id).values(status=RecognitionStatus.QUEUED)
|
||||
file_stmt = update(StoredFile).where(StoredFile.current_recognition_job_id == row_id).values(status=None)
|
||||
await session.execute(stmt)
|
||||
await session.execute(file_stmt)
|
||||
await session.commit()
|
||||
50
api/src/core/config.py
Normal file
50
api/src/core/config.py
Normal file
@ -0,0 +1,50 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class DBSettings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
|
||||
class MinioSettings(BaseSettings):
|
||||
MINIO_URL: str
|
||||
MINIO_ADMIN_LOGIN: str
|
||||
MINIO_ADMIN_PASSWORD: str
|
||||
MINIO_BUCKET: str
|
||||
|
||||
class NATSSettings(BaseSettings):
|
||||
NATS_URL: str
|
||||
NATS_SEED: str
|
||||
NATS_JWT: str
|
||||
|
||||
class SMBSettings(BaseSettings):
|
||||
SMB_HOST: str
|
||||
SMB_PATH: str
|
||||
SMB_LOGIN: str
|
||||
SMB_PASSWORD: str
|
||||
|
||||
class OpenBAOSettings(BaseSettings):
|
||||
DATABASE: DBSettings
|
||||
MINIO: MinioSettings
|
||||
NATS: NATSSettings
|
||||
SMB: SMBSettings
|
||||
|
||||
class ServerSettings(BaseSettings):
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
class Settings(BaseSettings):
|
||||
OPENBAO: OpenBAOSettings
|
||||
DEBUG: bool = False
|
||||
server: ServerSettings = ServerSettings()
|
||||
ROOT_PATH: str = '/'
|
||||
VERSION: str = '1.0.0'
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
env_file = ".env"
|
||||
env_nested_delimiter = "__"
|
||||
nested_model_default_partial_update = False
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
settings = get_settings()
|
||||
43
api/src/core/exception_handlers.py
Normal file
43
api/src/core/exception_handlers.py
Normal file
@ -0,0 +1,43 @@
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def request_validation_exception_handler(_: Request, exc: RequestValidationError):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={"detail": exc.errors()},
|
||||
)
|
||||
|
||||
@app.exception_handler(IntegrityError)
|
||||
async def integrity_error_handler(_: Request, exc: IntegrityError):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={"detail": "нарушение целостности данных в БД"},
|
||||
)
|
||||
|
||||
@app.exception_handler(SQLAlchemyError)
|
||||
async def sqlalchemy_error_handler(_: Request, exc: SQLAlchemyError):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "ошибка базы данных"},
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(_: Request, exc: HTTPException):
|
||||
# Не ломаем существующие ответы endpoint-ов и прокидываем исходный detail.
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(_: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "внутренняя ошибка сервера"},
|
||||
)
|
||||
98
api/src/core/logger.py
Normal file
98
api/src/core/logger.py
Normal file
@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
# Периодические пробы мониторинга/оркестратора и служебные doc-эндпоинты.
|
||||
NOISE_PATHS: tuple[str, ...] = (
|
||||
"/healthcheck",
|
||||
"/uptime",
|
||||
"/metrics",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/favicon.ico",
|
||||
)
|
||||
|
||||
# Маршруты статики (mount'ы StaticFiles) — контейнеры любой статики.
|
||||
_NOISE_MARKERS: tuple[str, ...] = ("/static/", "/static-swagger/")
|
||||
|
||||
# Расширения статических ассетов (картинки, стили, скрипты, шрифты).
|
||||
_STATIC_SUFFIXES: tuple[str, ...] = (
|
||||
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp",
|
||||
".css", ".js", ".map", ".woff", ".woff2", ".ttf", ".eot",
|
||||
)
|
||||
|
||||
|
||||
def is_noise_path(path: str) -> bool:
|
||||
"""True для служебных путей, чьи УСПЕШНЫЕ обращения не логируем.
|
||||
|
||||
Пробы (healthcheck/uptime/metrics), doc-эндпоинты и статика — чистый шум.
|
||||
Ошибки (>=400) по этим путям логируются: это решает RequestLoggingMiddleware.
|
||||
"""
|
||||
if any(path.endswith(p) for p in NOISE_PATHS):
|
||||
return True
|
||||
if any(marker in path for marker in _NOISE_MARKERS):
|
||||
return True
|
||||
if any(path.endswith(suffix) for suffix in _STATIC_SUFFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def setup_logger(
|
||||
*,
|
||||
level: str | int = "INFO",
|
||||
fmt: str | None = None,
|
||||
intercept_std_logging: bool = True,
|
||||
) -> None:
|
||||
logger.remove()
|
||||
logger.configure(extra={"request_id": "-"})
|
||||
|
||||
if fmt is None:
|
||||
fmt = (
|
||||
"{time:YYYY-MM-DD HH:mm:ss.SSS} | "
|
||||
"{level:<8} | "
|
||||
"req={extra[request_id]} | "
|
||||
"{name}:{function}:{line} - {message}"
|
||||
)
|
||||
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level=level,
|
||||
format=fmt,
|
||||
enqueue=True,
|
||||
backtrace=False,
|
||||
diagnose=False,
|
||||
)
|
||||
|
||||
if not intercept_std_logging:
|
||||
return
|
||||
|
||||
class InterceptHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
lvl = logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
lvl = record.levelno
|
||||
|
||||
logger.opt(
|
||||
depth=6,
|
||||
exception=record.exc_info,
|
||||
).log(lvl, record.getMessage())
|
||||
|
||||
logging.root.handlers = [InterceptHandler()]
|
||||
logging.root.setLevel(0)
|
||||
|
||||
for name in ("uvicorn", "uvicorn.error"):
|
||||
lg = logging.getLogger(name)
|
||||
lg.handlers = [InterceptHandler()]
|
||||
lg.setLevel(0)
|
||||
lg.propagate = False
|
||||
|
||||
access = logging.getLogger("uvicorn.access")
|
||||
access.handlers = []
|
||||
access.propagate = False
|
||||
access.disabled = True
|
||||
123
api/src/core/nats_callback.py
Normal file
123
api/src/core/nats_callback.py
Normal file
@ -0,0 +1,123 @@
|
||||
from .clients import S3Storage
|
||||
from nats.aio.client import Client as NATS
|
||||
from src.db.db import async_session_maker
|
||||
from sqlalchemy import select, update, insert
|
||||
from src.models import StoredFile, RecognitionJob, ChartOfAccount, OsvEntry
|
||||
from src.schemas.enums import RecognitionStatus, FileStatus, DocumentType
|
||||
import json
|
||||
import copy
|
||||
|
||||
|
||||
def json_to_db_rows(json_dict: dict, codes_mapping: dict) -> list:
|
||||
# {
|
||||
# recognition_job_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("recognition_job.id"), nullable=False)
|
||||
# legal_entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("legal_entity.id"))
|
||||
# account_code: Mapped[str] = mapped_column(String)
|
||||
# account_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('chart_of_account.id'))
|
||||
# account_root: Mapped[str] = mapped_column(String)
|
||||
# counterparty_name: Mapped[str] = mapped_column(String)
|
||||
# counterparty_inn: Mapped[str] = mapped_column(String)
|
||||
# counterparty_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("legal_entity.id"), nullable=True)
|
||||
# opening_debit: Mapped[Decimal] = mapped_column(Numeric)
|
||||
# opening_credit: Mapped[Decimal] = mapped_column(Numeric)
|
||||
# turnover_debit: Mapped[Decimal] = mapped_column(Numeric)
|
||||
# turnover_credit: Mapped[Decimal] = mapped_column(Numeric)
|
||||
# closing_debit: Mapped[Decimal] = mapped_column(Numeric)
|
||||
# closing_credit: Mapped[Decimal] = mapped_column(Numeric)
|
||||
# }
|
||||
rows = copy.deepcopy(json_dict['rows'])
|
||||
for row in rows:
|
||||
row['recognition_job_id'] = json_dict['recognition_job_id']
|
||||
row['legal_entity_id'] = json_dict['legal_entity_id']
|
||||
normalized_account_code = normalize_account_code(row['account_code'])
|
||||
row['account_id'] = codes_mapping[normalized_account_code]
|
||||
row['stored_file_id'] = json_dict['stored_file_id']
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def normalize_account_code(acc_code: str):
|
||||
split_parts = acc_code.split('.')
|
||||
for i in range(len(split_parts)):
|
||||
if len(split_parts[i]) == 1:
|
||||
split_parts[i] = '0' + split_parts[i]
|
||||
|
||||
return '.'.join(split_parts)
|
||||
|
||||
|
||||
async def get_account_codes_mapping(session):
|
||||
stmt = select(ChartOfAccount)
|
||||
scalars: list[ChartOfAccount] = (await session.execute(stmt)).scalars().all()
|
||||
result = {}
|
||||
for i in range(len(scalars)):
|
||||
result[scalars[i].code] = scalars[i].id
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def build_file_handler(nc: NATS, storage: S3Storage):
|
||||
session_factory = async_session_maker
|
||||
|
||||
async def handler(msg):
|
||||
msg_json = json.loads(msg.data.decode('utf-8'))
|
||||
payload = msg_json['data']
|
||||
job_id = payload['job_id']
|
||||
file_id = payload['file_id']
|
||||
legal_entity_id = payload['legal_entity_id']
|
||||
status = msg_json['status']
|
||||
if status != 'success':
|
||||
async with session_factory() as session:
|
||||
stmt = update(RecognitionJob).where(RecognitionJob.id == job_id).values(status = RecognitionStatus.ERROR)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
result_s3_path = payload.get('result_s3_path')
|
||||
source_file_ext = payload['s3_key'].split(".")[-1].lower()
|
||||
|
||||
|
||||
async with session_factory() as session:
|
||||
file_upd_values = {}
|
||||
if result_s3_path is None:
|
||||
file_upd_values = {
|
||||
"status": FileStatus.UPLOADED,
|
||||
"document_type": DocumentType.OTHER
|
||||
}
|
||||
|
||||
else:
|
||||
file_response = await storage.get_object(result_s3_path)
|
||||
file = await file_response.content.read()
|
||||
|
||||
json_data = json.loads(file)
|
||||
period_year = json_data.get('period_year')
|
||||
period_quarter = json_data.get('period_quarter')
|
||||
file_upd_values = {
|
||||
"status": FileStatus.UPLOADED,
|
||||
"period_quarter": period_quarter,
|
||||
"period_year": period_year,
|
||||
}
|
||||
|
||||
if (source_file_ext in ["xlsx", "xls"]):
|
||||
codes_mapping = await get_account_codes_mapping(session)
|
||||
db_rows = json_to_db_rows(json_data, codes_mapping)
|
||||
await session.execute(insert(OsvEntry).values(db_rows))
|
||||
|
||||
file_upd_values['document_type'] = DocumentType.OSV
|
||||
|
||||
else:
|
||||
|
||||
file_upd_values['document_type'] = DocumentType.BUH_OT
|
||||
|
||||
|
||||
|
||||
stmt = update(StoredFile).where(StoredFile.id == file_id).values(**file_upd_values).returning(StoredFile)
|
||||
job_stmt = update(RecognitionJob).where(RecognitionJob.id == job_id).values(status=RecognitionStatus.DONE, raw_result_s3_key = result_s3_path).returning(RecognitionJob)
|
||||
|
||||
first_res = await session.execute(stmt)
|
||||
sec_res = await session.execute(job_stmt)
|
||||
await session.commit()
|
||||
|
||||
|
||||
|
||||
|
||||
return handler
|
||||
1
api/src/db/__init__.py
Normal file
1
api/src/db/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .db import get_async_session
|
||||
16
api/src/db/db.py
Normal file
16
api/src/db/db.py
Normal file
@ -0,0 +1,16 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession
|
||||
from fastapi import Depends
|
||||
# from fastapi_users.db import SQLAlchemyUserDatabase, SQLAlchemyBaseUserTableUUID
|
||||
from src.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.OPENBAO.DATABASE.DATABASE_URL, connect_args={"server_settings": {"search_path": 'dkb'}})
|
||||
async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
# @asynccontextmanager
|
||||
async def get_async_session():
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
|
||||
0
api/src/models/__init__.py
Normal file
0
api/src/models/__init__.py
Normal file
0
api/src/repositories/__init__.py
Normal file
0
api/src/repositories/__init__.py
Normal file
0
api/src/schemas/__init__.py
Normal file
0
api/src/schemas/__init__.py
Normal file
0
api/src/services/__init__.py
Normal file
0
api/src/services/__init__.py
Normal file
0
api/tests/conftest.py
Normal file
0
api/tests/conftest.py
Normal file
8
raisa_config.yml
Normal file
8
raisa_config.yml
Normal file
@ -0,0 +1,8 @@
|
||||
# Конфигурация ресурсов приложения
|
||||
# Доступные профили: Малый (0.25CPU/1GB), Средний (0.5CPU/2GB), Большой (1CPU/4GB), custom
|
||||
# CPU Cores указывается, только с шагом 0.25
|
||||
# RAM GB указывается, только с шагом 1
|
||||
profile:
|
||||
# Последние данные по наименованию профиля: Малый
|
||||
cpu: 0.25 # значение Cores
|
||||
ram: 1.0 # значение GB
|
||||
2
setup/.npmrc
Normal file
2
setup/.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
registry=http://nexus.dev.raisa.go.rshbank.ru/repository/npm/
|
||||
strict-ssl=false
|
||||
8
setup/pip.conf
Normal file
8
setup/pip.conf
Normal file
@ -0,0 +1,8 @@
|
||||
[global]
|
||||
disable-pip-version-check = True
|
||||
trusted-host = nexus.dev.raisa.go.rshbank.ru
|
||||
index = http://nexus.dev.raisa.go.rshbank.ru/repository/pypi/pypi
|
||||
index-url = http://nexus.dev.raisa.go.rshbank.ru/repository/pypi/simple
|
||||
|
||||
[easy_install]
|
||||
index-url = http://nexus.dev.raisa.go.rshbank.ru/repository/pypi/simple
|
||||
23
setup/setup.sh
Executable file
23
setup/setup.sh
Executable file
@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
echo "setup project to development"
|
||||
|
||||
echo "create virtual environment to api"
|
||||
|
||||
python -m venv ./../api/.venv
|
||||
cp ./pip.conf ./../api/.venv/pip.conf
|
||||
source ./../api/.venv/bin/activate
|
||||
pip install -r ./../api/requirements.txt
|
||||
|
||||
echo "complete setup development api"
|
||||
|
||||
echo "setup web application"
|
||||
|
||||
cp ./.npmrc ./../web/.npmrc
|
||||
cd ./../web
|
||||
|
||||
npm i
|
||||
|
||||
echo "complete setup web application"
|
||||
echo "To start api, cd to api directory, and use venv, start run_server.py"
|
||||
echo "To start api, cd to web directory, and use command npm run start!"
|
||||
echo "Happy Hacking! :)"
|
||||
91
web/biome.json
Normal file
91
web/biome.json
Normal file
@ -0,0 +1,91 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"ignore": ["node_modules"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"lineWidth": 140,
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"ignore": ["*.json"],
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"useButtonType": "off",
|
||||
"noSvgWithoutTitle": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noUselessTypeConstraint": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"noUnusedImports": "error",
|
||||
"useExhaustiveDependencies": "info"
|
||||
},
|
||||
"nursery": {
|
||||
"noCommonJs": "error",
|
||||
"noDuplicateElseIf": "error",
|
||||
"noDuplicateProperties": "error",
|
||||
"noNestedTernary": "error",
|
||||
"useAtIndex": "error",
|
||||
"useCollapsedIf": "error",
|
||||
"useConsistentCurlyBraces": "error"
|
||||
},
|
||||
"performance": {
|
||||
"noDelete": "off",
|
||||
"noAccumulatingSpread": "off"
|
||||
},
|
||||
"style": {
|
||||
"useDefaultSwitchClause": "error",
|
||||
"useForOf": "error",
|
||||
"useFragmentSyntax": "error",
|
||||
"useConsistentArrayType": "error",
|
||||
"useShorthandAssign": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noConsole": "warn",
|
||||
"noEmptyBlockStatements": "error",
|
||||
"noEvolvingTypes": "error",
|
||||
"noExplicitAny": "warn",
|
||||
"noSkippedTests": "warn",
|
||||
"useAwait": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "single",
|
||||
"bracketSameLine": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"parser": {
|
||||
"allowComments": true
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"formatter": {
|
||||
"enabled": true
|
||||
},
|
||||
"parser": {
|
||||
"cssModules": true
|
||||
}
|
||||
}
|
||||
}
|
||||
36
web/package.json
Normal file
36
web/package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"start": "rsbuild dev --open",
|
||||
"preview": "rsbuild preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"antd": "^6.5.0",
|
||||
"markdown-to-jsx": "^9.1.2",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"raisa-fastapi-protected-web": "^1.0.0",
|
||||
"raisa-ui": "^1.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-daisyui": "^5.0.5",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@biomejs/cli-linux-x64-musl": "^1.9.4",
|
||||
"@rsbuild/core": "1.6.7",
|
||||
"@rsbuild/plugin-react": "^1.1.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"daisyui": "^4.12.10",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
5
web/postcss.config.cjs
Normal file
5
web/postcss.config.cjs
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
}
|
||||
}
|
||||
0
web/public/.gitkeep
Normal file
0
web/public/.gitkeep
Normal file
13
web/public/index.html
Normal file
13
web/public/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru-RU">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>Пример React-приложения</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
32
web/rsbuild.config.ts
Normal file
32
web/rsbuild.config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { defineConfig, loadEnv } from '@rsbuild/core';
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
|
||||
const { publicVars, rawPublicVars } = loadEnv({prefixes: ['REACT_APP_']});
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
base: (rawPublicVars['REACT_APP_ROOT_PATH'] !== undefined) ? rawPublicVars['REACT_APP_ROOT_PATH'] : '/'
|
||||
},
|
||||
output: {
|
||||
filename: {
|
||||
js: '[name]_script.[contenthash:8].js',
|
||||
css: '[name]_style.[contenthash:8].css',
|
||||
},
|
||||
distPath: {
|
||||
favicon: 'static/image',
|
||||
},
|
||||
assetPrefix: (rawPublicVars['REACT_APP_ROOT_PATH'] !== undefined) ? rawPublicVars['REACT_APP_ROOT_PATH'] : './'
|
||||
},
|
||||
html: {
|
||||
template: './public/index.html',
|
||||
favicon: './src/assets/favicon.ico',
|
||||
},
|
||||
source: {
|
||||
include: [{ not: /[\\/]core-js[\\/]/ }],
|
||||
define: {
|
||||
...publicVars,
|
||||
'process.env': JSON.stringify(rawPublicVars),
|
||||
}
|
||||
},
|
||||
plugins: [pluginReact()],
|
||||
});
|
||||
0
web/src/App.css
Normal file
0
web/src/App.css
Normal file
60
web/src/App.tsx
Normal file
60
web/src/App.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import "./App.css";
|
||||
import RfView from "./components/RfView";
|
||||
import { Button, Layout } from "antd";
|
||||
// import FileUploaderModal from "./components/FileUploaderModel";
|
||||
// import { FileItem } from "./components/FilesList";
|
||||
import { Sidebar } from "./widgets/Sidebar/Sidebar";
|
||||
import { Main } from "./layouts/Main";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { Mono } from "./layouts/Monozaemchiki";
|
||||
import { Catalogue } from "./layouts/Catalogue";
|
||||
import { FileViewer } from "./layouts/FileViewer";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "./store/store";
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
||||
import { AuditLogging } from "./layouts/AuditLogging";
|
||||
import { CatalogueGroup } from "./layouts/CatalogueGroups";
|
||||
import { DocStorage } from "./layouts/DocStorage";
|
||||
import { CatalogueJur } from "./layouts/CatalogueJur";
|
||||
import { Config } from "./conf/config";
|
||||
|
||||
|
||||
function App() {
|
||||
const [companyGroups, setCompanyGroups] = useState([]);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchGroups() {
|
||||
const res = await fetch(`${Config.API_URL}/company-groups`);
|
||||
const json = await res.json();
|
||||
setCompanyGroups(() =>
|
||||
json.map((c) => ({ id: c.id, name: c.name, isActive: false })),
|
||||
);
|
||||
}
|
||||
fetchGroups();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<BrowserRouter basename={Config.APP_ADDRESS}>
|
||||
<Layout hasSider={true} style={{ height: "100vh" }}>
|
||||
<Sidebar companyGroups={companyGroups} />
|
||||
<Layout.Content>
|
||||
<Routes>
|
||||
<Route path="/groups/:group_id" element={<Main />} />
|
||||
<Route path="/groups/monozaemchiki" element={<Mono />} />
|
||||
<Route path="/catalogue/groups" element={<CatalogueGroup />} />
|
||||
<Route path="/catalogue/jur_l" element={<CatalogueJur />} />
|
||||
<Route path="/files/:file_id" element={<FileViewer />} />
|
||||
<Route path="/docstorage" element={<DocStorage />} />
|
||||
<Route path="/logging" element={<AuditLogging />} />
|
||||
</Routes>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
web/src/assets/favicon.ico
Executable file
BIN
web/src/assets/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
0
web/src/components/.gitkeep
Normal file
0
web/src/components/.gitkeep
Normal file
7
web/src/conf/config.ts
Normal file
7
web/src/conf/config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
//@ts-ignore
|
||||
const environment = process.env;
|
||||
|
||||
export class Config {
|
||||
static API_URL: string = environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? '';
|
||||
static APP_ADDRESS: string = `${environment.REACT_APP_ROOT_PATH || '/aurora/apps/fastapi-dkb-armka'}`
|
||||
}
|
||||
116
web/src/index.css
Normal file
116
web/src/index.css
Normal file
@ -0,0 +1,116 @@
|
||||
/* :root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
} */
|
||||
|
||||
body {
|
||||
font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
|
||||
margin: 0px;
|
||||
}
|
||||
37
web/src/index.tsx
Normal file
37
web/src/index.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { ConfigProvider } from "antd";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
// <StrictMode>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: "rgba(0, 255, 0, 0.5)",
|
||||
colorPrimaryHover: "rgba(0, 255, 0, 1)",
|
||||
colorBgLayout: "rgb(247, 248, 250)",
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
siderBg: "#fff",
|
||||
},
|
||||
Button: {
|
||||
// textHoverBg: "green",
|
||||
colorBorder: "rgba(0,0,0,0.5)",
|
||||
defaultActiveBorderColor: "rgba(0,0,0,0.5)",
|
||||
defaultBorderColor: "rgba(0,0,0,0.5)",
|
||||
},
|
||||
Input: {
|
||||
activeBorderColor: "rgba(46, 166, 75, 0.35)",
|
||||
activeShadow: "0 0 0 1px #2ea64b59,0 1px 2px #0000000f",
|
||||
hoverBorderColor: "rgba(46, 166, 75, 0.35)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</ConfigProvider>,
|
||||
// </StrictMode>,
|
||||
);
|
||||
131
web/src/layouts/Main.tsx
Normal file
131
web/src/layouts/Main.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Splitter,
|
||||
Table,
|
||||
Tooltip,
|
||||
type TableProps,
|
||||
} from "antd";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FileItem } from "../components/FilesList";
|
||||
import { useParams } from "react-router-dom";
|
||||
import FileUploaderModal from "../components/FileUploaderModel";
|
||||
import { useAppDispatch, useAppSelector } from "../store/store";
|
||||
import { getFilesThunk } from "../store/slices/fileSlice";
|
||||
import { FileOutlined } from "@ant-design/icons";
|
||||
import { ConsoleLegalEntities } from "../widgets/ConsoleLegalEntities/ConsoleLegalEntities";
|
||||
import { Config } from "../conf/config";
|
||||
|
||||
async function getByLEId(legalEntityId: string) {
|
||||
const filesRes = await fetch(`${Config.API_URL}/files/${legalEntityId}`);
|
||||
const json = await filesRes.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
function DrawerContent({ drawerContent, leid, onReloadCall }) {
|
||||
// if (!drawerContent.length) return;
|
||||
return (
|
||||
<>
|
||||
<FileUploaderModal legal_entity_id={leid} trigger_sse_cb={() => {}} />
|
||||
{drawerContent.map((f) => (
|
||||
<FileItem key={f.id} file={f} />
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
onReloadCall(leid);
|
||||
}}
|
||||
>
|
||||
Перезагрузить список файлов
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Main() {
|
||||
const [legalEntities, setLegalEntities] = useState([]);
|
||||
const [legEntId, setLegEntId] = useState(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
// const [drawerContent, setDrawerContent] = useState([]);
|
||||
const { group_id } = useParams();
|
||||
const dispatch = useAppDispatch();
|
||||
const drawerContent = useAppSelector((state) => state.files.currentOpenFiles);
|
||||
|
||||
useEffect(() => {
|
||||
async function getLegEntitites() {
|
||||
const res = await fetch(
|
||||
`${Config.API_URL}/legal-entities/by-company-group?company_group_id=${group_id}`,
|
||||
);
|
||||
let data;
|
||||
if (!res.ok) {
|
||||
data = [];
|
||||
} else {
|
||||
data = await res.json();
|
||||
}
|
||||
|
||||
setLegalEntities(data);
|
||||
}
|
||||
getLegEntitites();
|
||||
}, [group_id]);
|
||||
|
||||
const onReloadCall = useCallback(
|
||||
async (id) => {
|
||||
// const files = await getByLEId(id);
|
||||
|
||||
// setDrawerContent(files);
|
||||
await dispatch(getFilesThunk(id));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const handleLEChoice = useCallback(
|
||||
async (id) => {
|
||||
await dispatch(getFilesThunk(id));
|
||||
|
||||
setDrawerOpen(true);
|
||||
setLegEntId(id);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "10px",
|
||||
justifyContent: "space-between",
|
||||
height: "calc(100% - 20px)",
|
||||
alignItems: "center",
|
||||
margin: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
borderRadius: "10px",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<h3>Консолидации</h3>
|
||||
</div>
|
||||
<ConsoleLegalEntities
|
||||
handleLEChoice={handleLEChoice}
|
||||
legalEntities={legalEntities}
|
||||
/>
|
||||
</div>
|
||||
<Drawer
|
||||
size="large"
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
drawerContent={drawerContent}
|
||||
leid={legEntId}
|
||||
onReloadCall={onReloadCall}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
0
web/src/store/slices/.gitkeep
Normal file
0
web/src/store/slices/.gitkeep
Normal file
14
web/src/store/store.ts
Normal file
14
web/src/store/store.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import filesReducer from "./slices/fileSlice";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
files: filesReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
||||
0
web/src/utils.ts
Normal file
0
web/src/utils.ts
Normal file
325
web/src/widgets/Sidebar/Sidebar.tsx
Normal file
325
web/src/widgets/Sidebar/Sidebar.tsx
Normal file
@ -0,0 +1,325 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import styles from "./styles.module.css";
|
||||
import {
|
||||
Divider,
|
||||
Empty,
|
||||
Input,
|
||||
Layout,
|
||||
Tooltip,
|
||||
type MenuProps,
|
||||
Modal,
|
||||
} from "antd";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
BankOutlined,
|
||||
ContainerOutlined,
|
||||
DownOutlined,
|
||||
InboxOutlined,
|
||||
LeftOutlined,
|
||||
PlusOutlined,
|
||||
RightOutlined,
|
||||
SearchOutlined,
|
||||
SolutionOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
type CompanyGroupType = {
|
||||
id: str;
|
||||
name: str;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
|
||||
function CollapsedIcon({ icon, onClick, title, to, cn = "" }) {
|
||||
if (to === null) {
|
||||
return (
|
||||
<Tooltip placement="right" title={title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn || `${styles.iconBtn} ${styles.iconBtnNav}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip placement="right" title={title}>
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn ||
|
||||
`${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
link: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
};
|
||||
|
||||
type Group = {
|
||||
title: string;
|
||||
key: string;
|
||||
children: NavItem[];
|
||||
};
|
||||
|
||||
const config: Group[] = [
|
||||
{
|
||||
title: "Справочники",
|
||||
key: "catalogues",
|
||||
children: [
|
||||
{
|
||||
link: "/catalogue/groups",
|
||||
icon: <TeamOutlined />,
|
||||
label: "ГК и организации",
|
||||
},
|
||||
{
|
||||
link: "/catalogue/jur_l",
|
||||
icon: <SolutionOutlined />,
|
||||
label: "ЮЛ вне ГК",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Документы",
|
||||
key: "docs",
|
||||
children: [
|
||||
{
|
||||
link: "/docstorage",
|
||||
icon: <InboxOutlined />,
|
||||
label: "Единое хранилище",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Система",
|
||||
key: "system",
|
||||
children: [
|
||||
{
|
||||
link: "/logging",
|
||||
icon: <ContainerOutlined />,
|
||||
label: "Журнал действий",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function NavLinkStyled({ to, children }) {
|
||||
return (
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
`${styles.linkBase} ${isActive ? styles.linkActive : ""}`
|
||||
}
|
||||
to={to}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
const Section = memo(function Section({
|
||||
isCollapsed,
|
||||
group,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
group: Group;
|
||||
}) {
|
||||
return !isCollapsed ? (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{group.title}</div>
|
||||
{group.children.map((i) => {
|
||||
return (
|
||||
<NavLinkStyled key={i.link} to={i.link}>
|
||||
{i.icon}
|
||||
{i.label}
|
||||
</NavLinkStyled>
|
||||
);
|
||||
})}
|
||||
<Divider size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{group.children.map((i) => (
|
||||
<CollapsedIcon
|
||||
key={i.link}
|
||||
icon={i.icon}
|
||||
// onClick={() => navigate(i.link)}
|
||||
onClick={() => {}}
|
||||
// isActive={isGroupActive(i.link)}
|
||||
// isActive={false}
|
||||
to={i.link}
|
||||
title="Единое хранилище"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function Sidebar({
|
||||
companyGroups,
|
||||
}: {
|
||||
companyGroups: CompanyGroupType[];
|
||||
}) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [subgroupOpen, setSubgroupOpen] = useState(true);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const onCollapsedSearchClick = useCallback(() => {
|
||||
setIsCollapsed(false);
|
||||
const cb = requestIdleCallback(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
} else {
|
||||
console.log("NO INPUT REF");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filteredSubgroups = useMemo(() => {
|
||||
const query = search.toLowerCase().trim();
|
||||
if (!query) return companyGroups;
|
||||
return companyGroups.filter((cg) => {
|
||||
return cg.name.toLowerCase().includes(query);
|
||||
});
|
||||
}, [search, companyGroups]);
|
||||
|
||||
const isGroupCompanyGroup = useCallback(() => {
|
||||
if (
|
||||
location.pathname.startsWith("/groups/") &&
|
||||
!location.pathname.endsWith("monozaemchiki")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [location.pathname]);
|
||||
|
||||
const groupsBaseRef =
|
||||
companyGroups.length > 0 ? `/groups/${companyGroups[0].id}` : "/groups";
|
||||
|
||||
return (
|
||||
<Layout.Sider
|
||||
collapsed={isCollapsed}
|
||||
style={{ height: "auto", margin: "10px", borderRadius: 10 }}
|
||||
width={280}
|
||||
collapsedWidth={64}
|
||||
collapsible
|
||||
trigger={null}
|
||||
>
|
||||
<div
|
||||
className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ""}`}
|
||||
>
|
||||
<div className={styles.topbar}>
|
||||
<button
|
||||
className={styles.collapseBtn}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<div>
|
||||
<CollapsedIcon
|
||||
icon={<PlusOutlined />}
|
||||
cn={`${styles.iconBtn} ${styles.iconBtnAction}`}
|
||||
to={null}
|
||||
// isActive={false}
|
||||
title="Новая консолидация"
|
||||
onClick={() => {
|
||||
setModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<CollapsedIcon
|
||||
icon={<SearchOutlined />}
|
||||
to={null}
|
||||
title="Поиск ГК"
|
||||
onClick={onCollapsedSearchClick}
|
||||
/>
|
||||
<CollapsedIcon
|
||||
icon={<BankOutlined />}
|
||||
onClick={() => navigate(groupsBaseRef)}
|
||||
to={groupsBaseRef}
|
||||
title="Группы компаний"
|
||||
/>
|
||||
<CollapsedIcon
|
||||
icon={<UserOutlined />}
|
||||
onClick={() => navigate("/groups/monozaemchiki")}
|
||||
to="/groups/monozaemchiki"
|
||||
title="Монозаёмщики"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>Отчётность</div>
|
||||
<Input
|
||||
allowClear
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
prefix={<SearchOutlined style={{ color: "#8c8c8c" }} />}
|
||||
placeholder="Поиск ГК"
|
||||
className={styles.search}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubgroupOpen((v) => !v)}
|
||||
className={styles.subgroupToggle}
|
||||
>
|
||||
<span>Группы компаний</span>
|
||||
{subgroupOpen ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
|
||||
{subgroupOpen && (
|
||||
<div className={styles.subgroup}>
|
||||
{filteredSubgroups.length === 0 ? (
|
||||
<div className={styles.emptyWrap}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="Не найдено"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
filteredSubgroups.map((item) => (
|
||||
<NavLinkStyled key={item.id} to={`/groups/${item.id}`}>
|
||||
{item.name}
|
||||
</NavLinkStyled>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Divider size="small" style={{ margin: 10 }} />
|
||||
<NavLinkStyled to="/groups/monozaemchiki">
|
||||
Монозаёмщики
|
||||
</NavLinkStyled>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{config.map((g) => (
|
||||
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</div>
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title="Форма создания консолидации"
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
);
|
||||
}
|
||||
191
web/src/widgets/Sidebar/styles.module.css
Normal file
191
web/src/widgets/Sidebar/styles.module.css
Normal file
@ -0,0 +1,191 @@
|
||||
.company_group_item {
|
||||
border: 2px solid grey;
|
||||
border-radius: 2px;
|
||||
height: 50px;
|
||||
width: 90%;
|
||||
cursor: pointer;
|
||||
background-color: lightgray;
|
||||
}
|
||||
.active_cg {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collapsedList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
||||
.section {
|
||||
margin-bottom: 0px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #8c8c8c;
|
||||
text-transform: uppercase ;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subgroupToggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subgroup {
|
||||
/* padding-left: 8px; */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* gap: 10px; */
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.linkBase {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
color: rgba(55, 65, 81, 0.88);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* .linkActive:hover {
|
||||
color: rgb(31, 114, 55);
|
||||
} */
|
||||
|
||||
.linkBase:hover {
|
||||
color: rgba(55, 65, 81, 0.88);
|
||||
background: rgb(249, 250, 251);
|
||||
}
|
||||
|
||||
.linkBase.linkActive {
|
||||
color: rgb(31, 114, 55);
|
||||
background: #edf8ee;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.emptyWrap {
|
||||
padding-top: 8;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.wrapperCollapsed {
|
||||
padding: 12px 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapsedList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
.iconBtnActive,
|
||||
.iconBtnActive:hover {
|
||||
background: rgba(46, 166, 75, 0.16);
|
||||
color: rgb(31, 114, 55);
|
||||
}
|
||||
|
||||
.iconBtnNav:hover {
|
||||
color: #101828;
|
||||
background: #f2f4f7;
|
||||
}
|
||||
|
||||
.iconBtnAction {
|
||||
background: linear-gradient(160deg,#2ea64b,#1f7237);
|
||||
font-size: small;
|
||||
box-shadow: 0 1px 3px #1f723740;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.iconBtnAction:hover {
|
||||
background: linear-gradient(160deg,#268f40,#185c2b);
|
||||
}
|
||||
|
||||
/* .iconBtn. */
|
||||
|
||||
|
||||
.collapseBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
43
web/tailwind.config.js
Normal file
43
web/tailwind.config.js
Normal file
@ -0,0 +1,43 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{html,js,jsx,ts,tsx}",
|
||||
"node_modules/daisyui/dist/**/*.js",
|
||||
"node_modules/react-daisyui/dist/**/*.js",
|
||||
"node_modules/raisa-ui/dist/**/*.js"
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require('daisyui')],
|
||||
daisyui: {
|
||||
themes: [
|
||||
{
|
||||
rshb: {
|
||||
...require('daisyui/src/theming/themes')['light'],
|
||||
|
||||
"primary": "#171A1C",
|
||||
"primary-content": "#fcfcfd",
|
||||
"secondary": "#ECEDEF",
|
||||
"secondary-content": "#687178",
|
||||
"accent": "#8ecaff",
|
||||
"accent-content": "#fcfcfd",
|
||||
"neutral": "#ecedef",
|
||||
"neutral-content": "#171A1C",
|
||||
"base-100": "#FCFCFD",
|
||||
"base-200": "#F6F7F8",
|
||||
"base-300": "#ECEDEF",
|
||||
"base-content": "#171A1C",
|
||||
"info": "#0894FF",
|
||||
"info-content": "#E3E5E8",
|
||||
"success": "#39B33D",
|
||||
"success-content": "#E3E5E8",
|
||||
"warning": "#E6B200",
|
||||
"warning-content": "#fcfcfd",
|
||||
"error": "#FF371A",
|
||||
"error-content": "#fcfcfd",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
23
web/tsconfig.json
Normal file
23
web/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "ES2020"],
|
||||
"jsx": "react-jsx",
|
||||
"target": "ES2020",
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": true,
|
||||
|
||||
/* modules */
|
||||
"module": "ESNext",
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
|
||||
/* type checking */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user