commit ad4f4559a9065f301c42b934ec96a5a4ab1509fa Author: Andrey Epifancev Date: Fri Oct 2 23:15:38 2026 +0400 chore: каркас из шаблона команды (DKB_ARMKA@ba172f8, доменный код удалён) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c09eafc --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..083aee3 --- /dev/null +++ b/CLAUDE.md @@ -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`). + +## Соглашения шаблона + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b8cbb1 --- /dev/null +++ b/README.md @@ -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 + diff --git a/api/alembic.ini b/api/alembic.ini new file mode 100644 index 0000000..807ded2 --- /dev/null +++ b/api/alembic.ini @@ -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 /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 diff --git a/api/alembic/README b/api/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/api/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/api/alembic/env.py b/api/alembic/env.py new file mode 100644 index 0000000..520e78a --- /dev/null +++ b/api/alembic/env.py @@ -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()) diff --git a/api/alembic/script.py.mako b/api/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/api/alembic/script.py.mako @@ -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"} diff --git a/api/alembic/versions/.gitkeep b/api/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/api/pytest.ini b/api/pytest.ini new file mode 100644 index 0000000..2dc434a --- /dev/null +++ b/api/pytest.ini @@ -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 diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt new file mode 100644 index 0000000..e4d6841 --- /dev/null +++ b/api/requirements-dev.txt @@ -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 \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..f26f6d9 --- /dev/null +++ b/api/requirements.txt @@ -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 diff --git a/api/run_server.py b/api/run_server.py new file mode 100644 index 0000000..f2449e1 --- /dev/null +++ b/api/run_server.py @@ -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", + ) + diff --git a/api/src/__init__.py b/api/src/__init__.py new file mode 100644 index 0000000..61c3faa --- /dev/null +++ b/api/src/__init__.py @@ -0,0 +1,7 @@ +from .app import app +from .core.config import get_settings + +__all__ = [ + 'app', + 'get_settings', +] \ No newline at end of file diff --git a/api/src/api/dependencies/__init__.py b/api/src/api/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/api/endpoints/__init__.py b/api/src/api/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/api/router.py b/api/src/api/router.py new file mode 100644 index 0000000..af9233c --- /dev/null +++ b/api/src/api/router.py @@ -0,0 +1,3 @@ +from fastapi import APIRouter + +router = APIRouter() diff --git a/api/src/app.py b/api/src/app.py new file mode 100644 index 0000000..4f1edbb --- /dev/null +++ b/api/src/app.py @@ -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") \ No newline at end of file diff --git a/api/src/common/exceptions.py b/api/src/common/exceptions.py new file mode 100644 index 0000000..2eca689 --- /dev/null +++ b/api/src/common/exceptions.py @@ -0,0 +1,5 @@ +class NotFoundError(Exception): + pass + +class CouldntCreateError(Exception): + pass \ No newline at end of file diff --git a/api/src/core/__init__.py b/api/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/core/async_cache.py b/api/src/core/async_cache.py new file mode 100644 index 0000000..f191c64 --- /dev/null +++ b/api/src/core/async_cache.py @@ -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() + ] \ No newline at end of file diff --git a/api/src/core/clients.py b/api/src/core/clients.py new file mode 100644 index 0000000..a0092b0 --- /dev/null +++ b/api/src/core/clients.py @@ -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() \ No newline at end of file diff --git a/api/src/core/config.py b/api/src/core/config.py new file mode 100644 index 0000000..d57c41f --- /dev/null +++ b/api/src/core/config.py @@ -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() \ No newline at end of file diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py new file mode 100644 index 0000000..f7643c2 --- /dev/null +++ b/api/src/core/exception_handlers.py @@ -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": "внутренняя ошибка сервера"}, + ) diff --git a/api/src/core/logger.py b/api/src/core/logger.py new file mode 100644 index 0000000..2f99033 --- /dev/null +++ b/api/src/core/logger.py @@ -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 diff --git a/api/src/core/nats_callback.py b/api/src/core/nats_callback.py new file mode 100644 index 0000000..39379e5 --- /dev/null +++ b/api/src/core/nats_callback.py @@ -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 \ No newline at end of file diff --git a/api/src/db/__init__.py b/api/src/db/__init__.py new file mode 100644 index 0000000..49ce0c1 --- /dev/null +++ b/api/src/db/__init__.py @@ -0,0 +1 @@ +from .db import get_async_session \ No newline at end of file diff --git a/api/src/db/db.py b/api/src/db/db.py new file mode 100644 index 0000000..be73f6b --- /dev/null +++ b/api/src/db/db.py @@ -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 + diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/repositories/__init__.py b/api/src/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/schemas/__init__.py b/api/src/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/src/services/__init__.py b/api/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/conftest.py b/api/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/raisa_config.yml b/raisa_config.yml new file mode 100644 index 0000000..ee17081 --- /dev/null +++ b/raisa_config.yml @@ -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 \ No newline at end of file diff --git a/setup/.npmrc b/setup/.npmrc new file mode 100644 index 0000000..fca0c11 --- /dev/null +++ b/setup/.npmrc @@ -0,0 +1,2 @@ +registry=http://nexus.dev.raisa.go.rshbank.ru/repository/npm/ +strict-ssl=false \ No newline at end of file diff --git a/setup/pip.conf b/setup/pip.conf new file mode 100644 index 0000000..0fea868 --- /dev/null +++ b/setup/pip.conf @@ -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 \ No newline at end of file diff --git a/setup/setup.sh b/setup/setup.sh new file mode 100755 index 0000000..01f21ac --- /dev/null +++ b/setup/setup.sh @@ -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! :)" \ No newline at end of file diff --git a/web/biome.json b/web/biome.json new file mode 100644 index 0000000..fb06a60 --- /dev/null +++ b/web/biome.json @@ -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 + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..df8ff5d --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs new file mode 100644 index 0000000..e48f63b --- /dev/null +++ b/web/postcss.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + tailwindcss: {}, + } +} \ No newline at end of file diff --git a/web/public/.gitkeep b/web/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/public/index.html b/web/public/index.html new file mode 100644 index 0000000..75b67e5 --- /dev/null +++ b/web/public/index.html @@ -0,0 +1,13 @@ + + + + + + + Пример React-приложения + + + +
+ + \ No newline at end of file diff --git a/web/rsbuild.config.ts b/web/rsbuild.config.ts new file mode 100644 index 0000000..08f6224 --- /dev/null +++ b/web/rsbuild.config.ts @@ -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()], +}); diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..2dd88ef --- /dev/null +++ b/web/src/App.tsx @@ -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 ( + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + ); +} + +export default App; diff --git a/web/src/assets/favicon.ico b/web/src/assets/favicon.ico new file mode 100755 index 0000000..00067be Binary files /dev/null and b/web/src/assets/favicon.ico differ diff --git a/web/src/components/.gitkeep b/web/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/src/conf/config.ts b/web/src/conf/config.ts new file mode 100644 index 0000000..8ce0b55 --- /dev/null +++ b/web/src/conf/config.ts @@ -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'}` +} \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..980a5e2 --- /dev/null +++ b/web/src/index.css @@ -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; +} diff --git a/web/src/index.tsx b/web/src/index.tsx new file mode 100644 index 0000000..2746602 --- /dev/null +++ b/web/src/index.tsx @@ -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( + // + + + , + // , +); diff --git a/web/src/layouts/Main.tsx b/web/src/layouts/Main.tsx new file mode 100644 index 0000000..42f66e1 --- /dev/null +++ b/web/src/layouts/Main.tsx @@ -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 ( + <> + {}} /> + {drawerContent.map((f) => ( + + ))} + + + ); +} + +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 ( + <> +
+
+

Консолидации

+
+ +
+ { + setDrawerOpen(false); + }} + > + + + + ); +} diff --git a/web/src/store/slices/.gitkeep b/web/src/store/slices/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/src/store/store.ts b/web/src/store/store.ts new file mode 100644 index 0000000..4f44df8 --- /dev/null +++ b/web/src/store/store.ts @@ -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; +export type AppDispatch = typeof store.dispatch; +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/web/src/utils.ts b/web/src/utils.ts new file mode 100644 index 0000000..e69de29 diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx new file mode 100644 index 0000000..fa1b5c9 --- /dev/null +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -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["items"][number]; + +function CollapsedIcon({ icon, onClick, title, to, cn = "" }) { + if (to === null) { + return ( + + + + ); + } + return ( + + + cn || + `${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}` + } + > + {icon} + + + ); +} + +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: , + label: "ГК и организации", + }, + { + link: "/catalogue/jur_l", + icon: , + label: "ЮЛ вне ГК", + }, + ], + }, + { + title: "Документы", + key: "docs", + children: [ + { + link: "/docstorage", + icon: , + label: "Единое хранилище", + }, + ], + }, + { + title: "Система", + key: "system", + children: [ + { + link: "/logging", + icon: , + label: "Журнал действий", + }, + ], + }, +]; + +function NavLinkStyled({ to, children }) { + return ( + + `${styles.linkBase} ${isActive ? styles.linkActive : ""}` + } + to={to} + > + {children} + + ); +} + +const Section = memo(function Section({ + isCollapsed, + group, +}: { + isCollapsed: boolean; + group: Group; +}) { + return !isCollapsed ? ( +
+
{group.title}
+ {group.children.map((i) => { + return ( + + {i.icon} + {i.label} + + ); + })} + +
+ ) : ( + <> + {group.children.map((i) => ( + 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 ( + +
+
+ +
+ {isCollapsed ? ( +
+ } + cn={`${styles.iconBtn} ${styles.iconBtnAction}`} + to={null} + // isActive={false} + title="Новая консолидация" + onClick={() => { + setModalOpen(true); + }} + /> + } + to={null} + title="Поиск ГК" + onClick={onCollapsedSearchClick} + /> + } + onClick={() => navigate(groupsBaseRef)} + to={groupsBaseRef} + title="Группы компаний" + /> + } + onClick={() => navigate("/groups/monozaemchiki")} + to="/groups/monozaemchiki" + title="Монозаёмщики" + /> +
+ ) : ( + <> +
+
Отчётность
+ setSearch(e.target.value)} + prefix={} + placeholder="Поиск ГК" + className={styles.search} + /> + + + + {subgroupOpen && ( +
+ {filteredSubgroups.length === 0 ? ( +
+ +
+ ) : ( + filteredSubgroups.map((item) => ( + + {item.name} + + )) + )} +
+ )} + + + Монозаёмщики + +
+ + )} + {config.map((g) => ( +
+ ))} +
+ { + setModalOpen(false); + }} + /> +
+ ); +} diff --git a/web/src/widgets/Sidebar/styles.module.css b/web/src/widgets/Sidebar/styles.module.css new file mode 100644 index 0000000..6080a22 --- /dev/null +++ b/web/src/widgets/Sidebar/styles.module.css @@ -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; +} \ No newline at end of file diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..9ef3fea --- /dev/null +++ b/web/tailwind.config.js @@ -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", + } + } + ] + } +} \ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..9b1f256 --- /dev/null +++ b/web/tsconfig.json @@ -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"] +}