chore: каркас из шаблона команды (DKB_ARMKA@ba172f8, доменный код удалён)

This commit is contained in:
Andrey Epifancev 2026-10-02 23:15:38 +04:00
commit ad4f4559a9
57 changed files with 2935 additions and 0 deletions

330
.gitignore vendored Normal file
View 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
View 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
View File

@ -0,0 +1,163 @@
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](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
View 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
View File

@ -0,0 +1 @@
Generic single-database configuration.

102
api/alembic/env.py Normal file
View 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())

View 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"}

View File

21
api/pytest.ini Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
from .app import app
from .core.config import get_settings
__all__ = [
'app',
'get_settings',
]

View File

View File

3
api/src/api/router.py Normal file
View File

@ -0,0 +1,3 @@
from fastapi import APIRouter
router = APIRouter()

250
api/src/app.py Normal file
View 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")

View File

@ -0,0 +1,5 @@
class NotFoundError(Exception):
pass
class CouldntCreateError(Exception):
pass

0
api/src/core/__init__.py Normal file
View File

View 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
View 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
View 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()

View 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
View 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

View 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
View File

@ -0,0 +1 @@
from .db import get_async_session

16
api/src/db/db.py Normal file
View 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

View File

View File

View File

View File

0
api/tests/conftest.py Normal file
View File

8
raisa_config.yml Normal file
View 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
View File

@ -0,0 +1,2 @@
registry=http://nexus.dev.raisa.go.rshbank.ru/repository/npm/
strict-ssl=false

8
setup/pip.conf Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
}
}

0
web/public/.gitkeep Normal file
View File

13
web/public/index.html Normal file
View 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
View 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
View File

60
web/src/App.tsx Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

7
web/src/conf/config.ts Normal file
View 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
View 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
View 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
View 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>
</>
);
}

View File

14
web/src/store/store.ts Normal file
View 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
View File

View 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>
);
}

View 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
View 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
View 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"]
}