From 1bd97a367254f3389283b6aaabd5b710b8140ebf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 2 Oct 2026 23:34:58 +0400 Subject: [PATCH] =?UTF-8?q?chore:=20=D0=B2=D1=8B=D1=87=D0=B8=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0,=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20tsc/biome/rs?= =?UTF-8?q?build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic/env.py | 2 +- api/src/app.py | 185 ++++------- api/src/core/async_cache.py | 52 --- api/src/core/clients.py | 161 +-------- api/src/core/nats_callback.py | 131 +------- api/src/models/__init__.py | 4 + api/src/models/base.py | 14 + web/src/App.tsx | 78 ++--- web/src/conf/config.ts | 12 +- web/src/env.d.ts | 1 + web/src/index.css | 4 +- web/src/index.tsx | 74 +++-- web/src/layouts/Main.tsx | 142 +------- web/src/store/store.ts | 10 +- web/src/widgets/Sidebar/Sidebar.tsx | 383 +++++----------------- web/src/widgets/Sidebar/styles.module.css | 227 +++++++------ 16 files changed, 372 insertions(+), 1108 deletions(-) delete mode 100644 api/src/core/async_cache.py create mode 100644 api/src/models/base.py create mode 100644 web/src/env.d.ts diff --git a/api/alembic/env.py b/api/alembic/env.py index 520e78a..b89fbff 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -6,7 +6,7 @@ from sqlalchemy import pool from sqlalchemy.ext.asyncio import AsyncEngine from alembic import context from src.core.config import settings -from src.models import * +from src.models import Base # noqa: F401 — модели регистрируются в src/models/__init__.py # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config diff --git a/api/src/app.py b/api/src/app.py index 4f1edbb..845e7c0 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,65 +1,53 @@ -import os +"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода. -import smbclient -from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles +Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT), SMB (горячие папки ИС Сканирование), +middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck, раздача собранного SPA. +Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result +(docs/context/03-architecture.md), зависимость require(permission). +""" +import base64 +import os +from contextlib import asynccontextmanager 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 - +import smbclient +from fastapi import FastAPI +from fastapi.exceptions import HTTPException, StarletteHTTPException +from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from loguru import logger +from miniopy_async import Minio +from nats.aio.client import Client as NATS from starlette.middleware.gzip import GZipMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware +from src.api.router import router +from src.core.clients import S3Storage +from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers settings = get_settings() -VERSION = "1.0.0" +VERSION = settings.VERSION UP_TIME = datetime.now(timezone.utc) -kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode('utf-8')) +# --- NATS: аутентификация по nkeys/JWT --------------------------------------------------------- +kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) def jwt_cb(): - return settings.OPENBAO.NATS.NATS_JWT.encode('utf-8') + 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 + if isinstance(nonce, str): + nonce = nonce.encode("utf-8") + return base64.b64encode(kp.sign(nonce)) +# --- РАИСА: защищённый API (только вне DEBUG) --------------------------------------------------- protected_middleware_available = False if not settings.DEBUG: try: @@ -90,120 +78,81 @@ _APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH) @asynccontextmanager -async def lifespan(app): +async def lifespan(app: FastAPI): 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: + smbclient.register_session( + settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD + ) + except Exception: 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) + + 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_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() + app.state.storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) + 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) + 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}", + "Не удалось подключиться к NATS. 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) + # Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md - 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() + for sub in app.state.nats_subs: + await sub.unsubscribe() + if app.state.nats and not settings.DEBUG: + await app.state.nats.close() 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 -) +app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) + + +@app.get("/healthcheck", status_code=200) async def healthcheck(): - return { - "uptime": UP_TIME, - "version": VERSION - } + 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) - +app.include_router(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=["*"], @@ -213,17 +162,11 @@ app.add_middleware( ) - -#Создание каталога для web приложения -os.makedirs(os.path.join('web'), exist_ok=True) - -#Подключаем все ендпоинты и роуты до маунта статических файлов -# app.include_router(files_router) - -#Подключение собранного web приложения +# --- Раздача собранного SPA (web/) --------------------------------------------------------------- +os.makedirs("web", exist_ok=True) -def sep_static(path, stat_dir): +def sep_static(path: str, stat_dir: str) -> str: sep_path = path.split("/") index = sep_path.index(stat_dir) return "/".join(sep_path[index:]) @@ -240,11 +183,7 @@ class SPAStaticFiles(StaticFiles): 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 - + raise 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/core/async_cache.py b/api/src/core/async_cache.py deleted file mode 100644 index f191c64..0000000 --- a/api/src/core/async_cache.py +++ /dev/null @@ -1,52 +0,0 @@ -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 index a0092b0..8f07d24 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -1,29 +1,10 @@ -from datetime import datetime -import json - -import smbclient -from miniopy_async import Minio +"""Клиенты инфраструктуры. Каркас: оставлен S3Storage; outbox-паблишер (PollWorker) реализуется в Срезе 0 +по docs/context/03-architecture.md (референс — reference-old-arm/api/src/core/clients.py). +""" from io import BytesIO -import asyncio -from nats.aio.client import Client as NATS -from sqlalchemy import or_, select, update, and_ +from miniopy_async import Minio -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): @@ -44,137 +25,3 @@ class S3Storage: 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/nats_callback.py b/api/src/core/nats_callback.py index 39379e5..2f2e05b 100644 --- a/api/src/core/nats_callback.py +++ b/api/src/core/nats_callback.py @@ -1,123 +1,18 @@ -from .clients import S3Storage +"""Обработчики сообщений NATS. + +Каркас: доменный обработчик удалён. Consumer `recognition.result` (дедуп по recognition_job.result_msg_id, +маппинг JSON из S3 в document/osv_entry/financial_statement_*) реализуется в Срезе 0–2 по +docs/context/03-architecture.md. Для референса — reference-old-arm/api/src/core/nats_callback.py. +""" +from loguru import logger 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 + +from src.core.clients import S3Storage -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 - +def build_result_handler(nc: NATS, storage: S3Storage): 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() + logger.info("NATS message received. subject={subject}, size={size}", subject=msg.subject, size=len(msg.data)) + await msg.ack() - - 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 + return handler diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index e69de29..36fbcbd 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -0,0 +1,4 @@ +"""ORM-модели. Источник истины — docs/model/data-model-v5.13.dbml; заполняется в Срезе 0.""" +from .base import Base, UUIDPrimaryMixin + +__all__ = ["Base", "UUIDPrimaryMixin"] diff --git a/api/src/models/base.py b/api/src/models/base.py new file mode 100644 index 0000000..325b5bb --- /dev/null +++ b/api/src/models/base.py @@ -0,0 +1,14 @@ +import uuid + +from sqlalchemy import MetaData +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column + + +class Base(DeclarativeBase): + metadata = MetaData(schema="dkb") + + +@declarative_mixin +class UUIDPrimaryMixin: + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) diff --git a/web/src/App.tsx b/web/src/App.tsx index 2dd88ef..9e13510 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,60 +1,28 @@ -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"; - +import './App.css'; +import { Layout } from 'antd'; +import { Provider } from 'react-redux'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { Config } from './conf/config'; +import { Main } from './layouts/Main'; +import { store } from './store/store'; +import { Sidebar } from './widgets/Sidebar/Sidebar'; +// Каркас: экраны по docs/context/10-user-journey.md добавляются срезами. 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 ( - - - - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - ); + return ( + + + + + + + } /> + + + + + + ); } export default App; diff --git a/web/src/conf/config.ts b/web/src/conf/config.ts index 8ce0b55..acd3f47 100644 --- a/web/src/conf/config.ts +++ b/web/src/conf/config.ts @@ -1,7 +1,7 @@ -//@ts-ignore -const environment = process.env; +// biome-ignore lint/suspicious/noExplicitAny: process.env подставляется rsbuild на этапе сборки +const environment = (process as any).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 +export const Config = { + API_URL: (environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? '') as string, + APP_ADDRESS: `${environment.REACT_APP_ROOT_PATH || '/aurora/apps/fastapi-dkb-armka'}` as string, +}; diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..b0ac762 --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/src/index.css b/web/src/index.css index 980a5e2..9200763 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -111,6 +111,6 @@ code { } */ body { - font-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif; - margin: 0px; + 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 index 2746602..ae87a8b 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,37 +1,41 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import "./index.css"; -import App from "./App"; -import { ConfigProvider } from "antd"; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import { ConfigProvider } from 'antd'; +import App from './App'; -createRoot(document.getElementById("root")!).render( - // - - - , - // , +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error('Элемент #root не найден'); +} + +createRoot(rootElement).render( + // + + + , + // , ); diff --git a/web/src/layouts/Main.tsx b/web/src/layouts/Main.tsx index 42f66e1..95571bb 100644 --- a/web/src/layouts/Main.tsx +++ b/web/src/layouts/Main.tsx @@ -1,131 +1,15 @@ -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) => ( - - ))} - - - ); -} - +// Каркас: стартовый экран. Содержимое — по docs/context/10-user-journey.md. 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); - }} - > - - - - ); + return ( +
+

АРМ КА

+
+ ); } diff --git a/web/src/store/store.ts b/web/src/store/store.ts index 4f44df8..e3364ed 100644 --- a/web/src/store/store.ts +++ b/web/src/store/store.ts @@ -1,11 +1,9 @@ -import { configureStore } from "@reduxjs/toolkit"; -import filesReducer from "./slices/fileSlice"; -import { useDispatch, useSelector } from "react-redux"; +import { configureStore } from '@reduxjs/toolkit'; +import { useDispatch, useSelector } from 'react-redux'; +// Каркас: слайсы добавляются срезами в ./slices. export const store = configureStore({ - reducer: { - files: filesReducer, - }, + reducer: {}, }); export type RootState = ReturnType; diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index fa1b5c9..fd32ea5 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -1,325 +1,90 @@ -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} - - - ); -} +import { HomeOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; +import { Divider, Layout, Tooltip } from 'antd'; +import { type ReactNode, memo, useState } from 'react'; +import { NavLink } from 'react-router-dom'; +import styles from './styles.module.css'; type NavItem = { - link: string; - label: string; - icon: any; + link: string; + label: string; + icon: ReactNode; }; type Group = { - title: string; - key: string; - children: NavItem[]; + title: string; + key: string; + children: NavItem[]; }; +// Каркас: разделы меню — по карте экранов docs/context/10-user-journey.md, добавляются срезами. 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: "Журнал действий", - }, - ], - }, + { + title: 'Главная', + key: 'home', + children: [{ link: '/', icon: , label: 'Главная' }], + }, ]; -function NavLinkStyled({ to, children }) { - return ( - - `${styles.linkBase} ${isActive ? styles.linkActive : ""}` - } - to={to} - > - {children} - - ); +function CollapsedIcon({ icon, title, to }: { icon: ReactNode; title: string; to: string }) { + return ( + + `${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`}> + {icon} + + + ); } -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="Единое хранилище" - /> - ))} - - ); +function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) { + 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) => ( + + {i.icon} + {i.label} + + ))} + +
+ ) : ( + <> + {group.children.map((i) => ( + + ))} + + ); }); -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); +export function Sidebar() { + const [isCollapsed, setIsCollapsed] = useState(false); - 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); - }} - /> -
- ); + return ( + +
+
+ +
+ {config.map((g) => ( +
+ ))} +
+
+ ); } diff --git a/web/src/widgets/Sidebar/styles.module.css b/web/src/widgets/Sidebar/styles.module.css index 6080a22..7f90c29 100644 --- a/web/src/widgets/Sidebar/styles.module.css +++ b/web/src/widgets/Sidebar/styles.module.css @@ -1,87 +1,86 @@ .company_group_item { - border: 2px solid grey; - border-radius: 2px; - height: 50px; - width: 90%; - cursor: pointer; - background-color: lightgray; + border: 2px solid grey; + border-radius: 2px; + height: 50px; + width: 90%; + cursor: pointer; + background-color: lightgray; } .active_cg { - background-color: white; + background-color: white; } .topbar { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; } .brand { - font-weight: 700; - white-space: nowrap; + font-weight: 700; + white-space: nowrap; } .collapsedList { - display: flex; - flex-direction: column; - gap: 8px; - align-items: center; - margin-top: 8px; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + margin-top: 8px; } - .section { - margin-bottom: 0px; - padding: 12px; - border-radius: 10px; + 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; + font-size: 12px; + font-weight: 700; + color: #8c8c8c; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 10px; } .search { - margin-bottom: 10px; + 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; + 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; + /* 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; + 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 { @@ -89,103 +88,101 @@ } */ .linkBase:hover { - color: rgba(55, 65, 81, 0.88); - background: rgb(249, 250, 251); + color: rgba(55, 65, 81, 0.88); + background: rgb(249, 250, 251); } .linkBase.linkActive { - color: rgb(31, 114, 55); - background: #edf8ee; - font-weight: 600; + color: rgb(31, 114, 55); + background: #edf8ee; + font-weight: 600; } .state { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; } .emptyWrap { - padding-top: 8; + padding-top: 8; } .wrapper { - height: 100%; - display: flex; - flex-direction: column; - padding: 12px; - overflow: hidden; - transition: width 0.2s ease; + height: 100%; + display: flex; + flex-direction: column; + padding: 12px; + overflow: hidden; + transition: width 0.2s ease; } .wrapperCollapsed { - padding: 12px 0; - align-items: center; + padding: 12px 0; + align-items: center; } .collapsedList { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - align-items: center; - margin-top: 8px; + 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; + 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); + background: rgba(46, 166, 75, 0.16); + color: rgb(31, 114, 55); } .iconBtnNav:hover { - color: #101828; - background: #f2f4f7; + color: #101828; + background: #f2f4f7; } .iconBtnAction { - background: linear-gradient(160deg,#2ea64b,#1f7237); - font-size: small; - box-shadow: 0 1px 3px #1f723740; - color: white; + 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); + 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 + 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; +}