Compare commits

..

6 Commits

Author SHA1 Message Date
PotapovaA
0ac121af1c Merge branch 'main' into summary-osv 2026-09-17 17:24:50 +03:00
PotapovaA
cf77d1d51e эндпоинт для получения сводной osv для группы компаний из всех osv 2026-09-17 17:18:15 +03:00
1667e11add Merge pull request 'test-init' (#1) from test-init into main
Reviewed-on: #1
2026-09-16 16:19:44 +03:00
PotapovaA
8b83f155c2 правки для настройки локальных nats smb minio 2026-09-16 16:11:36 +03:00
PotapovaA
72b32d790f test 2026-09-15 18:21:05 +03:00
PotapovaA
7decc3378e test 2026-09-15 17:49:12 +03:00
16 changed files with 3455 additions and 109 deletions

1
.gitignore vendored
View File

@ -330,6 +330,7 @@ docker-compose.yml
.vscode
my_project_env
.cursorignore
/test-scripts
# Graphify: generated graphs, caches, skill and local integration
graphify-out/

View File

@ -1,12 +1,27 @@
import asyncio
from logging.config import fileConfig
from pathlib import Path
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import AsyncEngine
from alembic import context
from src.core.config import settings
from src.models import *
class MigrationSettings(BaseSettings):
database_url: str = Field(validation_alias=AliasChoices(
"OPENBAO__DATABASE__DATABASE_URL",
"OPENBAO__SETTINGS__DATABASE_URL",
))
model_config = SettingsConfigDict(
env_file=Path(__file__).resolve().parents[1] / ".env",
extra="ignore",
)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
@ -16,7 +31,7 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", settings.OPENBAO.DATABASE.DATABASE_URL)
config.set_main_option("sqlalchemy.url", MigrationSettings().database_url.replace("%", "%%"))
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
@ -45,6 +60,7 @@ def do_run_migrations(connection) -> None:
)
with context.begin_transaction():
context.execute('CREATE SCHEMA IF NOT EXISTS "dkb"')
context.run_migrations()
def run_migrations_offline() -> None:
@ -71,6 +87,7 @@ def run_migrations_offline() -> None:
)
with context.begin_transaction():
context.execute('CREATE SCHEMA IF NOT EXISTS "dkb"')
context.run_migrations()
@ -96,7 +113,9 @@ async def run_migrations_online() -> None:
await connectable.dispose()
if context.is_offline_mode():
if config.attributes.get("connection") is not None:
do_run_migrations(config.attributes["connection"])
elif context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@ -0,0 +1,137 @@
"""add osv entries for group function
Revision ID: d614b414a32f
Revises: c89d082850bd
Create Date: 2026-09-17 17:15:16.421048
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd614b414a32f'
down_revision: Union[str, Sequence[str], None] = 'c89d082850bd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
CREATE OR REPLACE FUNCTION dkb.get_osv_entries_for_group(
p_company_group_id uuid,
p_period_year integer,
p_period_quarter text
)
RETURNS TABLE (
osv_entry_id uuid,
recognition_job_id uuid,
legal_entity_id uuid,
stored_file_id uuid,
account_code varchar,
account_id uuid,
account_root varchar,
counterparty_name varchar,
counterparty_inn varchar,
counterparty_id uuid,
opening_debit numeric,
opening_credit numeric,
turnover_debit numeric,
turnover_credit numeric,
closing_debit numeric,
closing_credit numeric,
legal_entity_name varchar,
company_group_id uuid,
company_group_name varchar,
original_name varchar,
file_status varchar,
period_year smallint,
period_quarter varchar,
uploaded_at timestamptz,
uploaded_by varchar,
finished_at timestamptz,
is_gsk boolean,
is_gk boolean,
entity_group_flag text
)
LANGUAGE sql
STABLE
STRICT
AS $function$
WITH classified_entries AS (
SELECT
oe.id AS osv_entry_id,
oe.recognition_job_id,
oe.legal_entity_id,
oe.stored_file_id,
CASE
WHEN oe.account_id IS NOT NULL THEN coa.code
ELSE oe.account_code
END AS account_code,
oe.account_id,
oe.account_root,
oe.counterparty_name,
oe.counterparty_inn,
oe.counterparty_id,
oe.opening_debit,
oe.opening_credit,
oe.turnover_debit,
oe.turnover_credit,
oe.closing_debit,
oe.closing_credit,
le.name AS legal_entity_name,
le.company_group_id,
cg.name AS company_group_name,
sf.original_name,
sf.status AS file_status,
sf.period_year,
sf.period_quarter,
sf.uploaded_at,
sf.uploaded_by,
rj.finished_at,
EXISTS (
SELECT 1
FROM dkb.related_group_member AS member
JOIN dkb.related_group AS rg
ON rg.id = member.related_group_id
WHERE member.legal_entity_id = oe.legal_entity_id
AND member.is_current IS TRUE
AND rg.company_group_id = p_company_group_id
AND rg.kind = 'GSK'
) AS is_gsk,
le.company_group_id = p_company_group_id AS is_gk
FROM dkb.osv_entry AS oe
LEFT JOIN dkb.chart_of_account AS coa ON coa.id = oe.account_id
JOIN dkb.stored_file AS sf ON sf.id = oe.stored_file_id
JOIN dkb.legal_entity AS le ON le.id = oe.legal_entity_id
JOIN dkb.company_group AS cg ON cg.id = le.company_group_id
LEFT JOIN dkb.recognition_job AS rj
ON rj.id = oe.recognition_job_id
AND rj.file_id = sf.id
WHERE sf.status = 'UPLOADED'
AND sf.period_year = p_period_year
AND sf.period_quarter = p_period_quarter
AND EXISTS (
SELECT 1 FROM dkb.company_group AS selected_group
WHERE selected_group.id = p_company_group_id
)
)
SELECT
ce.*,
CASE
WHEN ce.is_gsk THEN 'ГСК'
WHEN ce.is_gk THEN 'ГК'
ELSE 'Внешний'
END AS entity_group_flag
FROM classified_entries AS ce;
$function$;
""")
def downgrade() -> None:
op.execute("""
DROP FUNCTION IF EXISTS
dkb.get_osv_entries_for_group(uuid, integer, text)
""")

View File

@ -1,5 +1,6 @@
import uvicorn
from src import app, get_settings
from src.app import app
from src.core.config import get_settings
if __name__ == "__main__":
settings = get_settings()
@ -9,4 +10,3 @@ if __name__ == "__main__":
port=settings.server.PORT,
lifespan="on",
)

View File

@ -1,7 +1 @@
from .app import app
from .core.config import get_settings
__all__ = [
'app',
'get_settings',
]
"""API package. Import application entry points explicitly from src.app."""

View File

@ -1,11 +1,28 @@
from fastapi import APIRouter, File, HTTPException, UploadFile, Form, Depends
from fastapi import APIRouter, File, HTTPException, UploadFile, Form, Depends, Query
from fastapi.responses import FileResponse, StreamingResponse
from src.api.dependencies.services import get_osv_service, OsvService
from src.schemas.enums import PeriodQuarter
from src.schemas.osv import OsvGroupEntryResponse
import uuid
router = APIRouter(prefix='/osv', tags=['osv'])
@router.get('/by-company-group', response_model=list[OsvGroupEntryResponse])
async def get_osv_by_company_group(
company_group_id: uuid.UUID,
quarter: PeriodQuarter,
year: int = Query(..., ge=1, le=9999),
service: OsvService = Depends(get_osv_service),
):
"""Все ОСВ за период из UPLOADED-файлов с флагом владельца относительно ГК.
Пустая выборка (в том числе неизвестная ГК) возвращается как [].
"""
return await service.get_by_company_group(
company_group_id=company_group_id, year=year, quarter=quarter
)
@router.get('/by-file-id')
async def get_osv_by_file(file_id: uuid.UUID, service: OsvService = Depends(get_osv_service)):
result = await service.get_by_file_id(file_id)

View File

@ -24,8 +24,8 @@ 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
import nkeys
from loguru import logger
import os
@ -42,20 +42,6 @@ VERSION = "1.0.0"
UP_TIME = datetime.now(timezone.utc)
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode('utf-8'))
def jwt_cb():
return settings.OPENBAO.NATS.NATS_JWT.encode('utf-8')
def sig_cb(nonce):
if (isinstance(nonce, str)):
nonce = nonce.encode('utf-8')
signed = kp.sign(nonce)
return base64.b64encode(signed)
async def sub_cb(msg):
return None
@ -94,80 +80,75 @@ async def lifespan(app):
app.state.nats_subs = []
app.state.storage = None
app.state.nats = None
app.state.poller = None
app.state.pdf_poller = None
s3_client = None
smb_registered = False
try:
smbclient.register_session(settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD)
except:
logger.exception("Ошибка при регистрации сессии SMB")
s3_client = Minio(settings.OPENBAO.MINIO.MINIO_URL, access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, cert_check=False)
try:
bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET)
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists)
except Exception as exc:
logger.exception(
"Не удалось проверить наличие бакетa. error_category=s3_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
return
storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET)
app.state.storage = storage
cache = AsyncInMemoryCache()
nc = NATS()
if not settings.DEBUG:
try:
await nc.connect(settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb,
max_reconnect_attempts=2)
logger.info("Подключение к NATS произошло")
except Exception as exc:
logger.exception(
"Не удалось проверить наличие бакетa. error_category=nats_error, error_type={error_type}, error={error}",
error_type=type(exc).__name__,
error=repr(exc),
minio = settings.OPENBAO.MINIO
if minio is not None:
s3_client = Minio(
minio.MINIO_URL,
access_key=minio.MINIO_ADMIN_LOGIN,
secret_key=minio.MINIO_ADMIN_PASSWORD,
secure=minio.MINIO_SECURE,
)
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)
if not await s3_client.bucket_exists(minio.MINIO_BUCKET):
raise RuntimeError(f"Бакет MinIO {minio.MINIO_BUCKET!r} не создан")
app.state.storage = S3Storage(s3_client, minio.MINIO_BUCKET)
logger.info("MinIO подключён, бакет: {}", minio.MINIO_BUCKET)
smb = settings.OPENBAO.SMB
if smb is not None:
smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD,
port=smb.SMB_PORT)
smb_registered = True
logger.info("SMB подключён")
nats_settings = settings.OPENBAO.NATS
if nats_settings is not None:
nc = NATS()
app.state.nats = nc
if not settings.DEBUG:
keypair = nkeys.from_seed(nats_settings.NATS_SEED.encode("utf-8"))
def user_jwt_cb():
return nats_settings.NATS_JWT.encode("utf-8")
def signature_cb(nonce):
return base64.b64encode(keypair.sign(nonce.encode("utf-8")))
await nc.connect(nats_settings.NATS_URL, user_jwt_cb=user_jwt_cb,
signature_cb=signature_cb, max_reconnect_attempts=2)
logger.info("NATS подключён")
missing = [name for name in ("MINIO", "NATS", "SMB")
if getattr(settings.OPENBAO, name) is None]
if missing:
logger.info("Распознавание файлов отключено: не настроены {}", ", ".join(missing))
else:
cache = AsyncInMemoryCache()
app.state.poller = PollWorker(nc, cache, app.state.storage)
await app.state.poller.start()
app.state.pdf_poller = PDFPollWorker(cache, app.state.storage)
await app.state.pdf_poller.start()
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()
for worker in (app.state.poller, app.state.pdf_poller):
if worker is not None:
await worker.stop()
if not settings.DEBUG:
for sub in app.state.nats_subs:
logger.info("Отписываемся от топика")
await sub.unsubscribe()
if app.state.nats:
if app.state.nats is not None:
await app.state.nats.close()
if app.state.poller:
await app.state.poller.stop()
if app.state.storage:
await app.state.storage.client.close_session()
if app.state.pdf_poller:
await app.state.pdf_poller.stop()
logger.info("CLOSING APP AT ALL")
if s3_client is not None:
await s3_client.close_session()
if smb_registered:
smbclient.reset_connection_cache()
logger.info("Приложение закрывается")
app = FastAPI(
title="Fast API",
@ -247,4 +228,4 @@ class SPAStaticFiles(StaticFiles):
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
app.mount("/", StaticFiles(directory="web", html=True), name="web")
app.mount("/", StaticFiles(directory="web", html=True), name="web")

View File

@ -65,7 +65,7 @@ class PDFPollWorker:
logger.exception("Не получилось достать данные из кэша")
jobs = []
all_files = smbclient.listdir(self.smb_path)
all_files = smbclient.listdir(self.smb_path, port=settings.OPENBAO.SMB.SMB_PORT)
for job in jobs:
search_fname = f"{job.file_id}"
@ -85,8 +85,8 @@ class PDFPollWorker:
logger.info("Начинаю обработку файла по пути smb_dir={smb_dir}", smb_dir=new_dir)
files = []
for fname in smbclient.listdir(new_dir):
with smbclient.open_file(f'{new_dir}/{fname}', 'r') as f:
for fname in smbclient.listdir(new_dir, port=settings.OPENBAO.SMB.SMB_PORT):
with smbclient.open_file(f'{new_dir}/{fname}', 'r', port=settings.OPENBAO.SMB.SMB_PORT) as f:
files.append(json.loads(f.read()))
s3_json_dict = {
'file_id': job.file_id,

View File

@ -108,7 +108,7 @@ class PollWorker:
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:
with smbclient.open_file(f'{settings.OPENBAO.SMB.SMB_PATH}/Файлы/{last_part}', 'wb', port=settings.OPENBAO.SMB.SMB_PORT) as f:
data = await self.storage.get_object(row['row_payload']['s3_key'])
content = await data.read()
f.write(content)
@ -177,4 +177,5 @@ class PollWorker:
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()
await session.commit()

View File

@ -1,31 +1,37 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class DBSettings(BaseSettings):
DATABASE_URL: str
class MinioSettings(BaseSettings):
MINIO_SECURE: bool = True
MINIO_URL: str
MINIO_ADMIN_LOGIN: str
MINIO_ADMIN_PASSWORD: str
MINIO_BUCKET: str
MINIO_SECURE: bool = True
class NATSSettings(BaseSettings):
NATS_URL: str
NATS_SEED: str
NATS_JWT: str
NATS_JWT: str = Field(min_length=1)
NATS_SEED: str = Field(min_length=1)
class SMBSettings(BaseSettings):
SMB_PORT: int = Field(default=445, ge=1, le=65535)
SMB_HOST: str
SMB_PORT: int = 445
SMB_PATH: str
SMB_LOGIN: str
SMB_PASSWORD: str
class OpenBAOSettings(BaseSettings):
DATABASE: DBSettings
MINIO: MinioSettings
NATS: NATSSettings
SMB: SMBSettings
MINIO: MinioSettings | None = None
NATS: NATSSettings | None = None
SMB: SMBSettings | None = None
class ServerSettings(BaseSettings):
HOST: str = "0.0.0.0"
@ -39,6 +45,7 @@ class Settings(BaseSettings):
VERSION: str = '1.0.0'
class Config:
case_sensitive = True
extra = "ignore"
env_file = ".env"
env_nested_delimiter = "__"
@ -47,4 +54,4 @@ class Settings(BaseSettings):
def get_settings() -> Settings:
return Settings()
settings = get_settings()
settings = get_settings()

View File

@ -1,11 +1,26 @@
from .base_repository import BaseRepository
from src.models import OsvEntry
from sqlalchemy import insert, select
from sqlalchemy import insert, select, text
class OsvRepository(BaseRepository):
model = OsvEntry
async def get_by_company_group(self, company_group_id, year, quarter):
stmt = text("""
SELECT * FROM dkb.get_osv_entries_for_group(
CAST(:company_group_id AS uuid),
CAST(:year AS integer),
CAST(:quarter AS text)
)
""")
result = await self.session.execute(stmt, {
"company_group_id": company_group_id,
"year": year,
"quarter": quarter,
})
return [dict(row) for row in result.mappings().all()]
async def get_by_file_id(self, file_id):
stmt = select(self.model).where(self.model.stored_file_id == file_id)
result = await self.session.execute(stmt)
return result.scalars().all()
return result.scalars().all()

42
api/src/schemas/osv.py Normal file
View File

@ -0,0 +1,42 @@
from datetime import datetime
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import BaseModel
from src.schemas.enums import PeriodQuarter
class OsvGroupEntryResponse(BaseModel):
"""Строка get_osv_entries_for_group; денежные суммы в JSON — точные строки."""
osv_entry_id: UUID
recognition_job_id: UUID
legal_entity_id: UUID
stored_file_id: UUID
account_code: str | None
account_id: UUID | None
account_root: str
counterparty_name: str
counterparty_inn: str
counterparty_id: UUID | None
opening_debit: Decimal
opening_credit: Decimal
turnover_debit: Decimal
turnover_credit: Decimal
closing_debit: Decimal
closing_credit: Decimal
legal_entity_name: str
company_group_id: UUID
company_group_name: str
original_name: str
file_status: str
period_year: int
period_quarter: PeriodQuarter
uploaded_at: datetime | None
uploaded_by: str | None
finished_at: datetime | None
is_gsk: bool
is_gk: bool
entity_group_flag: Literal['ГСК', 'ГК', 'Внешний']

View File

@ -1,7 +1,7 @@
import json
import uuid
from fastapi import UploadFile
from fastapi import HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.inspection import inspect
from loguru import logger
@ -21,6 +21,8 @@ class FileService:
self.session = session
async def save_file(self, file: UploadFile, legal_entity_id: uuid.UUID, quarter: str, year: int):
if self.storage is None:
raise HTTPException(status_code=503, detail="Обработка файлов не настроена")
try:
db_payload = {
'original_name': file.filename,
@ -70,6 +72,8 @@ class FileService:
return [self._get_transformed_file_job_info(new_file) for new_file in result]
async def get_file_bytes(self, file_id):
if self.storage is None:
raise HTTPException(status_code=503, detail="Файловое хранилище не настроено")
result = await self.file_repo.get_by_id(file_id)
return (await self.storage.get_object(result.s3_key))
@ -82,4 +86,3 @@ class FileService:
'original_name': stored_file_db_obj.original_name,
'document_type': stored_file_db_obj.document_type
}

View File

@ -4,11 +4,21 @@ from fastapi import UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import NotFoundError
from src.repositories.osv_repository import OsvEntry, OsvRepository
from src.schemas.enums import PeriodQuarter
from src.schemas.osv import OsvGroupEntryResponse
class OsvService:
def __init__(self, session: AsyncSession, osv_repo: OsvRepository):
self.osv_repo = osv_repo
self.session = session
async def get_by_company_group(
self, company_group_id: uuid.UUID, year: int, quarter: PeriodQuarter
) -> list[OsvGroupEntryResponse]:
entries = await self.osv_repo.get_by_company_group(
company_group_id=company_group_id, year=year, quarter=quarter.value
)
return [OsvGroupEntryResponse.model_validate(entry) for entry in entries]
async def get_by_file_id(self, file_id: uuid.UUID):
@ -18,4 +28,4 @@ class OsvService:
raise NotFoundError('No osv entries found')
return osv_entries
except Exception as e:
raise
raise

View File

@ -0,0 +1,287 @@
# ER-диаграмма схемы `dkb`
Диаграмма построена по SQLAlchemy-моделям и Alembic-миграциям. `PK` — первичный ключ, `FK` — внешний ключ, `UK` — уникальное поле. Все таблицы находятся в PostgreSQL-схеме `dkb`.
```mermaid
erDiagram
CHART_OF_ACCOUNT {
uuid id PK
string code UK
uuid parent_id FK "nullable"
string name
enum kind "A | P | AP"
}
REGIONAL_OFFICE {
uuid id PK
string name
boolean is_active
}
COMPANY_GROUP {
uuid id PK
string crm_id UK
string name
uuid base_regional_office_id FK
datetime valid_from
string updated_by
datetime created_at
}
COMPANY_GROUP_HISTORY {
uuid id PK
uuid company_group_id FK
string crm_id UK
string name
uuid base_regional_office_id "snapshot, not FK"
datetime valid_from
datetime valid_to
string updated_by
}
LEGAL_ENTITY {
uuid id PK
string inn UK
enum entity_type "IP | UL"
string name
uuid company_group_id FK
uuid regional_office_id FK
date close_date
string updated_by
datetime created_at
}
LEGAL_ENTITY_HISTORY {
uuid id PK
uuid legal_entity_id FK
string inn UK
enum entity_type "IP | UL"
string name
uuid company_group_id FK
uuid regional_office_id FK
date close_date
datetime valid_from
datetime valid_to
string changed_by
}
LEGAL_ENTITY_ALIAS {
uuid id PK
uuid legal_entity_id FK
string alias
string normalized_alias
string created_by
datetime created_at
}
RELATED_GROUP {
uuid id PK
string code UK
uuid company_group_id FK
enum kind "GSK | GSZ"
string name
date created_date
}
RELATED_GROUP_MEMBER {
uuid id PK
uuid related_group_id FK
uuid legal_entity_id FK
enum kind "GSK | GSZ"
datetime valid_from
datetime valid_to
boolean is_current
string created_by
}
CONSOLIDATION {
uuid id PK
uuid related_group_id FK
smallint year
enum quarter "Q1 | Q2 | Q3 | Q4"
datetime created_at
}
CONSOLIDATION_VERSION {
uuid id PK
uuid consolidation_id FK
string version_no
date consolidation_date
string initiator
string approved_in_rf
string verified_in_go
boolean kfo_confirmation_enabled
boolean is_in_work
datetime created_at
string created_by
}
CONSOLIDATION_MEMBER {
uuid id PK
uuid consolidation_version_id FK
uuid legal_entity_id FK
}
CORRECTION {
uuid id PK
uuid consolidation_version_id FK
uuid correction_id FK "nullable; parent correction"
uuid legal_entity_id FK "nullable"
string debit_line_code
string debit_account_code
numeric debit_adjustment
string credit_line_code
string credit_account_code
numeric credit_adjustment
numeric deviation_amount
numeric deviation_percent
text comment
string created_by
datetime created_at
string uploaded_by
datetime uploaded_at
}
STORED_FILE {
uuid id PK
uuid legal_entity_id FK
enum document_type "nullable"
enum file_type "XLSX | XLS | PDF | PNG | JPG"
string original_name
string s3_key UK "nullable"
string content_hash "nullable"
bigint size_bytes
smallint period_year
enum period_quarter "nullable"
enum status "nullable"
uuid current_recognition_job_id FK "nullable"
string uploaded_by "nullable"
datetime uploaded_at "nullable"
}
RECOGNITION_JOB {
uuid id PK
uuid file_id FK
uuid legal_entity_id FK
enum pipeline "nullable"
string service_version "nullable"
enum status "nullable"
smallint attempt
string queue_msg_id "nullable"
datetime requested_at "nullable"
datetime started_at "nullable"
datetime finished_at "nullable"
text error_message "nullable"
string raw_result_s3_key "nullable"
}
FINANCIAL_STATEMENT_META {
uuid id PK
uuid stored_file_id FK
enum statement_type "BALANCE | INCOME"
string inn
boolean inn_verified
date report_date "nullable"
boolean report_date_verified
string units
boolean units_verified
}
FINANCIAL_STATEMENT_LINE {
uuid id PK
uuid recognition_job_id FK
uuid meta_id FK "nullable"
uuid legal_entity_id FK
string line_code
boolean line_code_recognized
string line_name
boolean line_name_recognized
enum row_kind
bigint ordinal
}
FINANCIAL_STATEMENT_VALUE {
uuid id PK
uuid line_id FK
numeric amount "nullable"
string string_value "nullable"
smallint column_index
boolean is_recognized
}
OSV_ENTRY {
uuid id PK
uuid recognition_job_id FK
uuid legal_entity_id FK
uuid stored_file_id FK
string account_code
uuid account_id FK
string account_root
string counterparty_name
string counterparty_inn
uuid counterparty_id FK "nullable"
numeric opening_debit
numeric opening_credit
numeric turnover_debit
numeric turnover_credit
numeric closing_debit
numeric closing_credit
}
CHART_OF_ACCOUNT o|--o{ CHART_OF_ACCOUNT : "parent_id"
REGIONAL_OFFICE ||--o{ COMPANY_GROUP : "base office"
COMPANY_GROUP ||--o{ COMPANY_GROUP_HISTORY : "history"
COMPANY_GROUP ||--o{ LEGAL_ENTITY : "contains"
REGIONAL_OFFICE ||--o{ LEGAL_ENTITY : "serves"
LEGAL_ENTITY ||--o{ LEGAL_ENTITY_ALIAS : "aliases"
LEGAL_ENTITY ||--o{ LEGAL_ENTITY_HISTORY : "history"
COMPANY_GROUP ||--o{ LEGAL_ENTITY_HISTORY : "group snapshot"
REGIONAL_OFFICE ||--o{ LEGAL_ENTITY_HISTORY : "office snapshot"
COMPANY_GROUP ||--o{ RELATED_GROUP : "owns"
RELATED_GROUP ||--o{ RELATED_GROUP_MEMBER : "memberships"
LEGAL_ENTITY ||--o{ RELATED_GROUP_MEMBER : "participates"
RELATED_GROUP ||--o{ CONSOLIDATION : "periods"
CONSOLIDATION ||--o{ CONSOLIDATION_VERSION : "versions"
CONSOLIDATION_VERSION ||--o{ CONSOLIDATION_MEMBER : "members"
LEGAL_ENTITY ||--o{ CONSOLIDATION_MEMBER : "included"
CONSOLIDATION_VERSION ||--o{ CORRECTION : "corrections"
LEGAL_ENTITY o|--o{ CORRECTION : "entity correction"
CORRECTION o|--o{ CORRECTION : "parent correction"
LEGAL_ENTITY ||--o{ STORED_FILE : "files"
STORED_FILE ||--o{ RECOGNITION_JOB : "recognition attempts"
LEGAL_ENTITY ||--o{ RECOGNITION_JOB : "recognized for"
RECOGNITION_JOB o|--o{ STORED_FILE : "current job"
STORED_FILE ||--o{ FINANCIAL_STATEMENT_META : "metadata"
FINANCIAL_STATEMENT_META o|--o{ FINANCIAL_STATEMENT_LINE : "lines"
RECOGNITION_JOB ||--o{ FINANCIAL_STATEMENT_LINE : "produces"
LEGAL_ENTITY ||--o{ FINANCIAL_STATEMENT_LINE : "owns"
FINANCIAL_STATEMENT_LINE ||--o{ FINANCIAL_STATEMENT_VALUE : "values"
RECOGNITION_JOB ||--o{ OSV_ENTRY : "produces"
STORED_FILE ||--o{ OSV_ENTRY : "contains"
LEGAL_ENTITY ||--o{ OSV_ENTRY : "owner"
LEGAL_ENTITY o|--o{ OSV_ENTRY : "counterparty"
CHART_OF_ACCOUNT ||--o{ OSV_ENTRY : "account"
```
## Важные ограничения
- `consolidation`: уникальна комбинация (`related_group_id`, `year`, `quarter`).
- `consolidation_version`: уникальна пара (`consolidation_id`, `version_no`).
- `consolidation_member`: уникальна пара (`consolidation_version_id`, `legal_entity_id`).
- `legal_entity_history`: уникальна пара (`legal_entity_id`, `valid_from`).
- `related_group_member`: действуют частичные уникальные индексы для текущих участников.
- `stored_file` и `recognition_job` связаны циклически: файл хранит текущую попытку, а каждая попытка ссылается на файл.
## Представление
`dkb.v_osv_entry_with_file_flags` объединяет `osv_entry`, `stored_file`, `recognition_job`, `legal_entity`, `related_group_member` и `related_group`. Оно добавляет вычисляемый тип контрагента: `ГСК`, `ГК` или `Внешний`.
## Источники
- `api/src/models/*.py`
- `api/alembic/versions/1c4e0a7d9612_first_migration.py`
- `api/alembic/versions/c89d082850bd_add_default_and_nullability.py`
- `api/scripts/create_osv_entry_view.sql`

2832
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff