chore: вычищены остатки доменного кода, каркас проходит tsc/biome/rsbuild

This commit is contained in:
Andrey Epifancev 2026-10-02 23:34:58 +04:00
parent 29e5cc5bf4
commit 1bd97a3672
16 changed files with 372 additions and 1108 deletions

View File

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

View File

@ -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")

View File

@ -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()
]

View File

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

View File

@ -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
return handler

View File

@ -0,0 +1,4 @@
"""ORM-модели. Источник истины — docs/model/data-model-v5.13.dbml; заполняется в Срезе 0."""
from .base import Base, UUIDPrimaryMixin
__all__ = ["Base", "UUIDPrimaryMixin"]

14
api/src/models/base.py Normal file
View File

@ -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)

View File

@ -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 (
<Provider store={store}>
<BrowserRouter basename={Config.APP_ADDRESS}>
<Layout hasSider={true} style={{ height: "100vh" }}>
<Sidebar companyGroups={companyGroups} />
<Layout.Content>
<Routes>
<Route path="/groups/:group_id" element={<Main />} />
<Route path="/groups/monozaemchiki" element={<Mono />} />
<Route path="/catalogue/groups" element={<CatalogueGroup />} />
<Route path="/catalogue/jur_l" element={<CatalogueJur />} />
<Route path="/files/:file_id" element={<FileViewer />} />
<Route path="/docstorage" element={<DocStorage />} />
<Route path="/logging" element={<AuditLogging />} />
</Routes>
</Layout.Content>
</Layout>
</BrowserRouter>
</Provider>
);
return (
<Provider store={store}>
<BrowserRouter basename={Config.APP_ADDRESS}>
<Layout hasSider={true} style={{ height: '100vh' }}>
<Sidebar />
<Layout.Content>
<Routes>
<Route path='/' element={<Main />} />
</Routes>
</Layout.Content>
</Layout>
</BrowserRouter>
</Provider>
);
}
export default App;

View File

@ -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'}`
}
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,
};

1
web/src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="@rsbuild/core/types" />

View File

@ -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;
}

View File

@ -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(
// <StrictMode>
<ConfigProvider
theme={{
token: {
colorPrimary: "rgba(0, 255, 0, 0.5)",
colorPrimaryHover: "rgba(0, 255, 0, 1)",
colorBgLayout: "rgb(247, 248, 250)",
},
components: {
Layout: {
siderBg: "#fff",
},
Button: {
// textHoverBg: "green",
colorBorder: "rgba(0,0,0,0.5)",
defaultActiveBorderColor: "rgba(0,0,0,0.5)",
defaultBorderColor: "rgba(0,0,0,0.5)",
},
Input: {
activeBorderColor: "rgba(46, 166, 75, 0.35)",
activeShadow: "0 0 0 1px #2ea64b59,0 1px 2px #0000000f",
hoverBorderColor: "rgba(46, 166, 75, 0.35)",
},
},
}}
>
<App />
</ConfigProvider>,
// </StrictMode>,
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Элемент #root не найден');
}
createRoot(rootElement).render(
// <StrictMode>
<ConfigProvider
theme={{
token: {
colorPrimary: 'rgba(0, 255, 0, 0.5)',
colorPrimaryHover: 'rgba(0, 255, 0, 1)',
colorBgLayout: 'rgb(247, 248, 250)',
},
components: {
Layout: {
siderBg: '#fff',
},
Button: {
// textHoverBg: "green",
colorBorder: 'rgba(0,0,0,0.5)',
defaultActiveBorderColor: 'rgba(0,0,0,0.5)',
defaultBorderColor: 'rgba(0,0,0,0.5)',
},
Input: {
activeBorderColor: 'rgba(46, 166, 75, 0.35)',
activeShadow: '0 0 0 1px #2ea64b59,0 1px 2px #0000000f',
hoverBorderColor: 'rgba(46, 166, 75, 0.35)',
},
},
}}
>
<App />
</ConfigProvider>,
// </StrictMode>,
);

View File

@ -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 (
<>
<FileUploaderModal legal_entity_id={leid} trigger_sse_cb={() => {}} />
{drawerContent.map((f) => (
<FileItem key={f.id} file={f} />
))}
<button
onClick={() => {
onReloadCall(leid);
}}
>
Перезагрузить список файлов
</button>
</>
);
}
// Каркас: стартовый экран. Содержимое — по 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 (
<>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
justifyContent: "space-between",
height: "calc(100% - 20px)",
alignItems: "center",
margin: 10,
}}
>
<div
style={{
background: "white",
borderRadius: "10px",
width: "100%",
height: "100%",
}}
>
<h3>Консолидации</h3>
</div>
<ConsoleLegalEntities
handleLEChoice={handleLEChoice}
legalEntities={legalEntities}
/>
</div>
<Drawer
size="large"
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
}}
>
<DrawerContent
drawerContent={drawerContent}
leid={legEntId}
onReloadCall={onReloadCall}
/>
</Drawer>
</>
);
return (
<div
style={{
margin: 10,
padding: 16,
background: 'white',
borderRadius: 10,
height: 'calc(100% - 20px)',
}}>
<h3>АРМ КА</h3>
</div>
);
}

View File

@ -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<typeof store.getState>;

View File

@ -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<MenuProps>["items"][number];
function CollapsedIcon({ icon, onClick, title, to, cn = "" }) {
if (to === null) {
return (
<Tooltip placement="right" title={title}>
<button
type="button"
onClick={onClick}
className={cn || `${styles.iconBtn} ${styles.iconBtnNav}`}
>
{icon}
</button>
</Tooltip>
);
}
return (
<Tooltip placement="right" title={title}>
<NavLink
to={to}
className={({ isActive }) =>
cn ||
`${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`
}
>
{icon}
</NavLink>
</Tooltip>
);
}
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: <TeamOutlined />,
label: "ГК и организации",
},
{
link: "/catalogue/jur_l",
icon: <SolutionOutlined />,
label: "ЮЛ вне ГК",
},
],
},
{
title: "Документы",
key: "docs",
children: [
{
link: "/docstorage",
icon: <InboxOutlined />,
label: "Единое хранилище",
},
],
},
{
title: "Система",
key: "system",
children: [
{
link: "/logging",
icon: <ContainerOutlined />,
label: "Журнал действий",
},
],
},
{
title: 'Главная',
key: 'home',
children: [{ link: '/', icon: <HomeOutlined />, label: 'Главная' }],
},
];
function NavLinkStyled({ to, children }) {
return (
<NavLink
className={({ isActive }) =>
`${styles.linkBase} ${isActive ? styles.linkActive : ""}`
}
to={to}
>
{children}
</NavLink>
);
function CollapsedIcon({ icon, title, to }: { icon: ReactNode; title: string; to: string }) {
return (
<Tooltip placement='right' title={title}>
<NavLink to={to} className={({ isActive }) => `${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`}>
{icon}
</NavLink>
</Tooltip>
);
}
const Section = memo(function Section({
isCollapsed,
group,
}: {
isCollapsed: boolean;
group: Group;
}) {
return !isCollapsed ? (
<div className={styles.section}>
<div className={styles.sectionTitle}>{group.title}</div>
{group.children.map((i) => {
return (
<NavLinkStyled key={i.link} to={i.link}>
{i.icon}
{i.label}
</NavLinkStyled>
);
})}
<Divider size="small" />
</div>
) : (
<>
{group.children.map((i) => (
<CollapsedIcon
key={i.link}
icon={i.icon}
// onClick={() => navigate(i.link)}
onClick={() => {}}
// isActive={isGroupActive(i.link)}
// isActive={false}
to={i.link}
title="Единое хранилище"
/>
))}
</>
);
function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) {
return (
<NavLink className={({ isActive }) => `${styles.linkBase} ${isActive ? styles.linkActive : ''}`} to={to}>
{children}
</NavLink>
);
}
const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) {
return !isCollapsed ? (
<div className={styles.section}>
<div className={styles.sectionTitle}>{group.title}</div>
{group.children.map((i) => (
<NavLinkStyled key={i.link} to={i.link}>
{i.icon}
{i.label}
</NavLinkStyled>
))}
<Divider size='small' />
</div>
) : (
<>
{group.children.map((i) => (
<CollapsedIcon key={i.link} icon={i.icon} to={i.link} title={i.label} />
))}
</>
);
});
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 (
<Layout.Sider
collapsed={isCollapsed}
style={{ height: "auto", margin: "10px", borderRadius: 10 }}
width={280}
collapsedWidth={64}
collapsible
trigger={null}
>
<div
className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ""}`}
>
<div className={styles.topbar}>
<button
className={styles.collapseBtn}
onClick={() => setIsCollapsed(!isCollapsed)}
>
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
</button>
</div>
{isCollapsed ? (
<div>
<CollapsedIcon
icon={<PlusOutlined />}
cn={`${styles.iconBtn} ${styles.iconBtnAction}`}
to={null}
// isActive={false}
title="Новая консолидация"
onClick={() => {
setModalOpen(true);
}}
/>
<CollapsedIcon
icon={<SearchOutlined />}
to={null}
title="Поиск ГК"
onClick={onCollapsedSearchClick}
/>
<CollapsedIcon
icon={<BankOutlined />}
onClick={() => navigate(groupsBaseRef)}
to={groupsBaseRef}
title="Группы компаний"
/>
<CollapsedIcon
icon={<UserOutlined />}
onClick={() => navigate("/groups/monozaemchiki")}
to="/groups/monozaemchiki"
title="Монозаёмщики"
/>
</div>
) : (
<>
<div className={styles.section}>
<div className={styles.sectionTitle}>Отчётность</div>
<Input
allowClear
ref={inputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
prefix={<SearchOutlined style={{ color: "#8c8c8c" }} />}
placeholder="Поиск ГК"
className={styles.search}
/>
<button
type="button"
onClick={() => setSubgroupOpen((v) => !v)}
className={styles.subgroupToggle}
>
<span>Группы компаний</span>
{subgroupOpen ? <DownOutlined /> : <RightOutlined />}
</button>
{subgroupOpen && (
<div className={styles.subgroup}>
{filteredSubgroups.length === 0 ? (
<div className={styles.emptyWrap}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="Не найдено"
/>
</div>
) : (
filteredSubgroups.map((item) => (
<NavLinkStyled key={item.id} to={`/groups/${item.id}`}>
{item.name}
</NavLinkStyled>
))
)}
</div>
)}
<Divider size="small" style={{ margin: 10 }} />
<NavLinkStyled to="/groups/monozaemchiki">
Монозаёмщики
</NavLinkStyled>
</div>
</>
)}
{config.map((g) => (
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
))}
</div>
<Modal
open={modalOpen}
title="Форма создания консолидации"
onCancel={() => {
setModalOpen(false);
}}
/>
</Layout.Sider>
);
return (
<Layout.Sider
collapsed={isCollapsed}
style={{ height: 'auto', margin: '10px', borderRadius: 10 }}
width={280}
collapsedWidth={64}
collapsible
trigger={null}>
<div className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ''}`}>
<div className={styles.topbar}>
<button type='button' className={styles.collapseBtn} onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
</button>
</div>
{config.map((g) => (
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
))}
</div>
</Layout.Sider>
);
}

View File

@ -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;
}
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;
}