chore: вычищены остатки доменного кода, каркас проходит tsc/biome/rsbuild
This commit is contained in:
parent
29e5cc5bf4
commit
1bd97a3672
@ -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
|
||||
|
||||
185
api/src/app.py
185
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")
|
||||
@ -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()
|
||||
]
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
@ -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
14
api/src/models/base.py
Normal 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)
|
||||
@ -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;
|
||||
|
||||
@ -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
1
web/src/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user