From cf77d1d51e18c57d72c48809477161349af4f8e6 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Thu, 17 Sep 2026 17:18:15 +0300 Subject: [PATCH] =?UTF-8?q?=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20osv=20=D0=B4=D0=BB=D1=8F=20=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=D1=8B=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=B2=D1=81=D0=B5=D1=85=20osv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + ...a32f_add_osv_entries_for_group_function.py | 137 +++++++++ api/src/api/endpoints/osv.py | 19 +- api/src/app.py | 6 +- api/src/core/PDFWorker/PDFTempPoller.py | 6 +- api/src/core/clients.py | 4 +- api/src/core/config.py | 5 +- api/src/repositories/osv_repository.py | 19 +- api/src/schemas/osv.py | 42 +++ api/src/services/osv_service.py | 12 +- docs/database-er-diagram-17-09-2026.md | 287 ++++++++++++++++++ 11 files changed, 525 insertions(+), 13 deletions(-) create mode 100644 api/alembic/versions/d614b414a32f_add_osv_entries_for_group_function.py create mode 100644 api/src/schemas/osv.py create mode 100644 docs/database-er-diagram-17-09-2026.md diff --git a/.gitignore b/.gitignore index dea0407..82c0de7 100644 --- a/.gitignore +++ b/.gitignore @@ -330,6 +330,7 @@ docker-compose.yml .vscode my_project_env .cursorignore +/test-scripts # Graphify: generated graphs, caches, skill and local integration graphify-out/ diff --git a/api/alembic/versions/d614b414a32f_add_osv_entries_for_group_function.py b/api/alembic/versions/d614b414a32f_add_osv_entries_for_group_function.py new file mode 100644 index 0000000..8c1c46b --- /dev/null +++ b/api/alembic/versions/d614b414a32f_add_osv_entries_for_group_function.py @@ -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) + """) \ No newline at end of file diff --git a/api/src/api/endpoints/osv.py b/api/src/api/endpoints/osv.py index 8815961..d101bd2 100644 --- a/api/src/api/endpoints/osv.py +++ b/api/src/api/endpoints/osv.py @@ -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) diff --git a/api/src/app.py b/api/src/app.py index 4f1edbb..57340bd 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -95,10 +95,10 @@ async def lifespan(app): 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) + smbclient.register_session(settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD, port=settings.OPENBAO.SMB.SMB_PORT) 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) + s3_client = Minio(settings.OPENBAO.MINIO.MINIO_URL, access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, secure=settings.OPENBAO.MINIO.MINIO_SECURE, 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) @@ -247,4 +247,4 @@ class SPAStaticFiles(StaticFiles): app.mount("/", SPAStaticFiles(directory="web", html=True), name="web") -app.mount("/", StaticFiles(directory="web", html=True), name="web") \ No newline at end of file +app.mount("/", StaticFiles(directory="web", html=True), name="web") diff --git a/api/src/core/PDFWorker/PDFTempPoller.py b/api/src/core/PDFWorker/PDFTempPoller.py index f231424..8259f05 100644 --- a/api/src/core/PDFWorker/PDFTempPoller.py +++ b/api/src/core/PDFWorker/PDFTempPoller.py @@ -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, diff --git a/api/src/core/clients.py b/api/src/core/clients.py index a0092b0..c590554 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -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,4 @@ 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() \ No newline at end of file + await session.commit() diff --git a/api/src/core/config.py b/api/src/core/config.py index d57c41f..dc33d29 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,3 +1,4 @@ +from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -5,6 +6,7 @@ class DBSettings(BaseSettings): DATABASE_URL: str class MinioSettings(BaseSettings): + MINIO_SECURE: bool = True MINIO_URL: str MINIO_ADMIN_LOGIN: str MINIO_ADMIN_PASSWORD: str @@ -16,6 +18,7 @@ class NATSSettings(BaseSettings): NATS_JWT: str class SMBSettings(BaseSettings): + SMB_PORT: int = Field(default=445, ge=1, le=65535) SMB_HOST: str SMB_PATH: str SMB_LOGIN: str @@ -47,4 +50,4 @@ class Settings(BaseSettings): def get_settings() -> Settings: return Settings() -settings = get_settings() \ No newline at end of file +settings = get_settings() diff --git a/api/src/repositories/osv_repository.py b/api/src/repositories/osv_repository.py index 4f0cb0b..752cc18 100644 --- a/api/src/repositories/osv_repository.py +++ b/api/src/repositories/osv_repository.py @@ -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() \ No newline at end of file + return result.scalars().all() diff --git a/api/src/schemas/osv.py b/api/src/schemas/osv.py new file mode 100644 index 0000000..05d636c --- /dev/null +++ b/api/src/schemas/osv.py @@ -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['ГСК', 'ГК', 'Внешний'] diff --git a/api/src/services/osv_service.py b/api/src/services/osv_service.py index 09100bd..ef5992c 100644 --- a/api/src/services/osv_service.py +++ b/api/src/services/osv_service.py @@ -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 \ No newline at end of file + raise diff --git a/docs/database-er-diagram-17-09-2026.md b/docs/database-er-diagram-17-09-2026.md new file mode 100644 index 0000000..491f12f --- /dev/null +++ b/docs/database-er-diagram-17-09-2026.md @@ -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`