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/env.py b/api/alembic/env.py index 89ae547..65e43ee 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -87,7 +87,7 @@ def run_migrations_offline() -> None: target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, - version_table_schema="dkb", + version_table_schema=db_schema, include_schemas=True, ) diff --git a/api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py b/api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py new file mode 100644 index 0000000..97bba11 --- /dev/null +++ b/api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py @@ -0,0 +1,150 @@ +"""Add OSV entries for group with counterparty alias resolution. + +Revision ID: c94eb1503a26 +Revises: c89d082850bd +""" +from alembic import op +from src.core.config import settings + +revision = "c94eb1503a26" +down_revision = "c89d082850bd" +branch_labels = None +depends_on = None + + +def _quoted_db_schema() -> str: + return op.get_bind().dialect.identifier_preparer.quote_schema( + settings.OPENBAO.DATABASE.DATABASE_SCHEMA + ) + + +def upgrade() -> None: + schema = _quoted_db_schema() + op.execute(f""" + CREATE OR REPLACE FUNCTION {schema}.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, + counterparty_resolved_by_alias boolean, + 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, + COALESCE(oe.counterparty_id, counterparty_by_inn.id, counterparty_by_alias.legal_entity_id) AS counterparty_id, + (oe.counterparty_id IS NULL + AND counterparty_by_inn.id IS NULL + AND counterparty_by_alias.legal_entity_id IS NOT NULL) + AS counterparty_resolved_by_alias, + 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 {schema}.related_group_member AS member + JOIN {schema}.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 {schema}.osv_entry AS oe + LEFT JOIN {schema}.legal_entity AS counterparty_by_inn + ON oe.counterparty_id IS NULL + AND counterparty_by_inn.inn = NULLIF(BTRIM(oe.counterparty_inn), '') + LEFT JOIN {schema}.legal_entity_alias AS counterparty_by_alias + ON oe.counterparty_id IS NULL + AND counterparty_by_inn.id IS NULL + AND counterparty_by_alias.normalized_alias = NULLIF(BTRIM(oe.counterparty_name), '') + LEFT JOIN {schema}.chart_of_account AS coa ON coa.id = oe.account_id + JOIN {schema}.stored_file AS sf ON sf.id = oe.stored_file_id + JOIN {schema}.legal_entity AS le ON le.id = oe.legal_entity_id + JOIN {schema}.company_group AS cg ON cg.id = le.company_group_id + LEFT JOIN {schema}.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 {schema}.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: + schema = _quoted_db_schema() + op.execute( + f"DROP FUNCTION IF EXISTS {schema}.get_osv_entries_for_group(uuid, integer, text)" + ) diff --git a/api/alembic/versions/e28f709a6b31_add_osv_entries_for_consolidation_version.py b/api/alembic/versions/e28f709a6b31_add_osv_entries_for_consolidation_version.py new file mode 100644 index 0000000..589daaf --- /dev/null +++ b/api/alembic/versions/e28f709a6b31_add_osv_entries_for_consolidation_version.py @@ -0,0 +1,156 @@ +"""Add OSV entries scoped to a consolidation version's members and period. + +Revision ID: e28f709a6b31 +Revises: c94eb1503a26 +""" +from alembic import op +from src.core.config import settings + +revision = "e28f709a6b31" +down_revision = "c94eb1503a26" +branch_labels = None +depends_on = None + + +def _quoted_db_schema() -> str: + return op.get_bind().dialect.identifier_preparer.quote_schema( + settings.OPENBAO.DATABASE.DATABASE_SCHEMA + ) + + +def upgrade() -> None: + schema = _quoted_db_schema() + op.execute(f""" + CREATE OR REPLACE FUNCTION {schema}.get_osv_entries_for_consolidation_version( + p_consolidation_version_id uuid + ) + 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, + counterparty_resolved_by_alias boolean, + 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 consolidation_info AS ( + SELECT cv.id AS consolidation_version_id, + c.year AS period_year, + c.quarter AS period_quarter, + rg.company_group_id + FROM {schema}.consolidation_version AS cv + JOIN {schema}.consolidation AS c ON c.id = cv.consolidation_id + JOIN {schema}.related_group AS rg ON rg.id = c.related_group_id + WHERE cv.id = p_consolidation_version_id + ), 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, + COALESCE(oe.counterparty_id, counterparty_by_inn.id, counterparty_by_alias.legal_entity_id) AS counterparty_id, + (oe.counterparty_id IS NULL + AND counterparty_by_inn.id IS NULL + AND counterparty_by_alias.legal_entity_id IS NOT NULL) + AS counterparty_resolved_by_alias, + 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 {schema}.related_group_member AS member + JOIN {schema}.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 = ci.company_group_id + AND rg.kind = 'GSK' + ) AS is_gsk, + le.company_group_id = ci.company_group_id AS is_gk + FROM consolidation_info AS ci + JOIN {schema}.consolidation_member AS cm + ON cm.consolidation_version_id = ci.consolidation_version_id + JOIN {schema}.osv_entry AS oe ON oe.legal_entity_id = cm.legal_entity_id + LEFT JOIN {schema}.legal_entity AS counterparty_by_inn + ON oe.counterparty_id IS NULL + AND counterparty_by_inn.inn = NULLIF(BTRIM(oe.counterparty_inn), '') + LEFT JOIN {schema}.legal_entity_alias AS counterparty_by_alias + ON oe.counterparty_id IS NULL + AND counterparty_by_inn.id IS NULL + AND counterparty_by_alias.normalized_alias = NULLIF(BTRIM(oe.counterparty_name), '') + LEFT JOIN {schema}.chart_of_account AS coa ON coa.id = oe.account_id + JOIN {schema}.stored_file AS sf ON sf.id = oe.stored_file_id + JOIN {schema}.legal_entity AS le ON le.id = oe.legal_entity_id + JOIN {schema}.company_group AS cg ON cg.id = le.company_group_id + LEFT JOIN {schema}.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 = ci.period_year + AND sf.period_quarter = ci.period_quarter + ) + 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: + schema = _quoted_db_schema() + op.execute( + f"DROP FUNCTION {schema}.get_osv_entries_for_consolidation_version(uuid)" + ) diff --git a/api/src/api/endpoints/osv.py b/api/src/api/endpoints/osv.py index 8815961..ee7c35f 100644 --- a/api/src/api/endpoints/osv.py +++ b/api/src/api/endpoints/osv.py @@ -1,11 +1,43 @@ -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-consolidation-version', response_model=list[OsvGroupEntryResponse]) +async def get_osv_by_consolidation_version( + consolidation_version_id: uuid.UUID, + service: OsvService = Depends(get_osv_service), +): + """ОСВ за версию консолидации. + + Пустая выборка (в том числе неизвестная версия) возвращается как []. + """ + return await service.get_by_consolidation_version( + consolidation_version_id=consolidation_version_id + ) + + @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/core/PDFWorker/PDFTempPoller.py b/api/src/core/PDFWorker/PDFTempPoller.py index 4ad0738..996d546 100644 --- a/api/src/core/PDFWorker/PDFTempPoller.py +++ b/api/src/core/PDFWorker/PDFTempPoller.py @@ -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())) logger.info("В рамках job_id={job_id} файлов в SMB найдено: {files_count}", job_id=job.job_id, files_count=len(files)) s3_json_dict = { diff --git a/api/src/core/clients.py b/api/src/core/clients.py index 666a6db..ad2c86a 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -108,7 +108,8 @@ 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}/Input/{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) diff --git a/api/src/core/config.py b/api/src/core/config.py index c87a231..6e072de 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 @@ -6,6 +7,7 @@ class DBSettings(BaseSettings): DATABASE_SCHEMA: str class MinioSettings(BaseSettings): + MINIO_SECURE: bool = True MINIO_URL: str MINIO_ADMIN_LOGIN: str MINIO_ADMIN_PASSWORD: str @@ -17,6 +19,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 diff --git a/api/src/repositories/osv_repository.py b/api/src/repositories/osv_repository.py index 4f0cb0b..4524222 100644 --- a/api/src/repositories/osv_repository.py +++ b/api/src/repositories/osv_repository.py @@ -1,11 +1,37 @@ 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_consolidation_version(self, consolidation_version_id): + stmt = text(""" + SELECT * FROM dkb.get_osv_entries_for_consolidation_version( + CAST(:consolidation_version_id AS uuid) + ) + """) + result = await self.session.execute(stmt, { + "consolidation_version_id": consolidation_version_id, + }) + 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..a55af28 --- /dev/null +++ b/api/src/schemas/osv.py @@ -0,0 +1,43 @@ +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 + counterparty_resolved_by_alias: bool + 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..ed96cfd 100644 --- a/api/src/services/osv_service.py +++ b/api/src/services/osv_service.py @@ -1,15 +1,73 @@ import uuid from fastapi import UploadFile +from loguru import logger +from pydantic import ValidationError 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 + ) + + result: list[OsvGroupEntryResponse] = [] + for row_index, entry in enumerate(entries): + try: + result.append(OsvGroupEntryResponse.model_validate(entry)) + except ValidationError as exc: + logger.error( + "Некорректный результат get_osv_entries_for_group: " + "company_group_id={company_group_id}, year={year}, quarter={quarter}, " + "row_index={row_index}, osv_entry_id={osv_entry_id}, " + "validation_errors={validation_errors}", + company_group_id=company_group_id, + year=year, + quarter=quarter.value, + row_index=row_index, + osv_entry_id=entry.get("osv_entry_id"), + validation_errors=exc.errors(include_input=False), + ) + raise + + return result + + async def get_by_consolidation_version( + self, consolidation_version_id: uuid.UUID + ) -> list[OsvGroupEntryResponse]: + entries = await self.osv_repo.get_by_consolidation_version( + consolidation_version_id=consolidation_version_id + ) + + result: list[OsvGroupEntryResponse] = [] + for row_index, entry in enumerate(entries): + try: + result.append(OsvGroupEntryResponse.model_validate(entry)) + except ValidationError as exc: + logger.error( + "Некорректный результат get_osv_entries_for_consolidation_version: " + "consolidation_version_id={consolidation_version_id}, " + "row_index={row_index}, osv_entry_id={osv_entry_id}, " + "validation_errors={validation_errors}", + consolidation_version_id=consolidation_version_id, + row_index=row_index, + osv_entry_id=entry.get("osv_entry_id"), + validation_errors=exc.errors(include_input=False), + ) + raise + + return result + async def get_by_file_id(self, file_id: uuid.UUID): try: @@ -18,4 +76,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`