From cf77d1d51e18c57d72c48809477161349af4f8e6 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Thu, 17 Sep 2026 17:18:15 +0300 Subject: [PATCH 1/4] =?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` -- 2.47.2 From 2d0d64f3ef82a73174ac3d21465c21ca15159327 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 23 Sep 2026 03:17:06 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D0=B0=20=D1=83=D1=87=D0=B5=D1=82=20=D0=B0=D0=BB=D0=B8=D0=B0?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=20=D0=B2=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=B5=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...1503a26_add_osv_counterparty_alias_flag.py | 259 ++++++++++++++++++ api/src/schemas/osv.py | 1 + 2 files changed, 260 insertions(+) create mode 100644 api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py 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..344be9e --- /dev/null +++ b/api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py @@ -0,0 +1,259 @@ +"""Expose whether an OSV counterparty was resolved by alias. + +Revision ID: c94eb1503a26 +Revises: d614b414a32f +""" +from alembic import op + +revision = "c94eb1503a26" +down_revision = "d614b414a32f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # PostgreSQL requires recreation when RETURNS TABLE changes. + # No CASCADE: dependent database objects must not be silently removed. + op.execute("DROP FUNCTION dkb.get_osv_entries_for_group(uuid, integer, text)") + 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, + 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 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.legal_entity AS counterparty_by_inn + ON oe.counterparty_id IS NULL + AND counterparty_by_inn.inn = NULLIF(BTRIM(oe.counterparty_inn), '') + LEFT JOIN dkb.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 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 dkb.get_osv_entries_for_group(uuid, integer, text)") + 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, + COALESCE(oe.counterparty_id, counterparty_by_inn.id, counterparty_by_alias.legal_entity_id) AS 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.legal_entity AS counterparty_by_inn + ON oe.counterparty_id IS NULL + AND counterparty_by_inn.inn = NULLIF(BTRIM(oe.counterparty_inn), '') + LEFT JOIN dkb.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 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$; + """) + diff --git a/api/src/schemas/osv.py b/api/src/schemas/osv.py index 05d636c..a55af28 100644 --- a/api/src/schemas/osv.py +++ b/api/src/schemas/osv.py @@ -21,6 +21,7 @@ class OsvGroupEntryResponse(BaseModel): counterparty_name: str counterparty_inn: str counterparty_id: UUID | None + counterparty_resolved_by_alias: bool opening_debit: Decimal opening_credit: Decimal turnover_debit: Decimal -- 2.47.2 From 0760168966054274a3ebc2a122ce7192a6e0461a Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 23 Sep 2026 15:16:07 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20=D1=85=D0=B0=D1=80=D0=B4=20=D0=BA=D0=BE=D0=B4=20?= =?UTF-8?q?=D1=81=D1=85=D0=B5=D0=BC=D1=8B,=20=D0=BF=D1=80=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=D1=85=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic/env.py | 2 +- ...1503a26_add_osv_counterparty_alias_flag.py | 66 +++++++++++-------- ...a32f_add_osv_entries_for_group_function.py | 37 +++++++---- api/src/services/osv_service.py | 24 ++++++- 4 files changed, 84 insertions(+), 45 deletions(-) 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 index 344be9e..7cf1813 100644 --- a/api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py +++ b/api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py @@ -4,6 +4,7 @@ Revision ID: c94eb1503a26 Revises: d614b414a32f """ from alembic import op +from src.core.config import settings revision = "c94eb1503a26" down_revision = "d614b414a32f" @@ -11,12 +12,19 @@ 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() # PostgreSQL requires recreation when RETURNS TABLE changes. # No CASCADE: dependent database objects must not be silently removed. - op.execute("DROP FUNCTION dkb.get_osv_entries_for_group(uuid, integer, text)") - op.execute(""" - CREATE OR REPLACE FUNCTION dkb.get_osv_entries_for_group( + op.execute(f"DROP FUNCTION {schema}.get_osv_entries_for_group(uuid, integer, text)") + 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 @@ -94,8 +102,8 @@ def upgrade() -> None: rj.finished_at, EXISTS ( SELECT 1 - FROM dkb.related_group_member AS member - JOIN dkb.related_group AS rg + 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 @@ -103,26 +111,26 @@ def upgrade() -> None: 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.legal_entity AS counterparty_by_inn + 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 dkb.legal_entity_alias AS counterparty_by_alias + 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 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 + 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 dkb.company_group AS selected_group + SELECT 1 FROM {schema}.company_group AS selected_group WHERE selected_group.id = p_company_group_id ) ) @@ -139,9 +147,10 @@ def upgrade() -> None: def downgrade() -> None: - op.execute("DROP FUNCTION dkb.get_osv_entries_for_group(uuid, integer, text)") - op.execute(""" - CREATE OR REPLACE FUNCTION dkb.get_osv_entries_for_group( + schema = _quoted_db_schema() + op.execute(f"DROP FUNCTION {schema}.get_osv_entries_for_group(uuid, integer, text)") + 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 @@ -214,8 +223,8 @@ def downgrade() -> None: rj.finished_at, EXISTS ( SELECT 1 - FROM dkb.related_group_member AS member - JOIN dkb.related_group AS rg + 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 @@ -223,26 +232,26 @@ def downgrade() -> None: 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.legal_entity AS counterparty_by_inn + 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 dkb.legal_entity_alias AS counterparty_by_alias + 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 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 + 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 dkb.company_group AS selected_group + SELECT 1 FROM {schema}.company_group AS selected_group WHERE selected_group.id = p_company_group_id ) ) @@ -256,4 +265,3 @@ def downgrade() -> None: FROM classified_entries AS ce; $function$; """) - 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 index 8c1c46b..e5382a9 100644 --- a/api/alembic/versions/d614b414a32f_add_osv_entries_for_group_function.py +++ b/api/alembic/versions/d614b414a32f_add_osv_entries_for_group_function.py @@ -9,6 +9,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +from src.core.config import settings # revision identifiers, used by Alembic. @@ -18,9 +19,16 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def _quoted_db_schema() -> str: + return op.get_bind().dialect.identifier_preparer.quote_schema( + settings.OPENBAO.DATABASE.DATABASE_SCHEMA + ) + + def upgrade() -> None: - op.execute(""" - CREATE OR REPLACE FUNCTION dkb.get_osv_entries_for_group( + 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 @@ -93,8 +101,8 @@ def upgrade() -> None: rj.finished_at, EXISTS ( SELECT 1 - FROM dkb.related_group_member AS member - JOIN dkb.related_group AS rg + 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 @@ -102,19 +110,19 @@ def upgrade() -> None: 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 + FROM {schema}.osv_entry AS oe + 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 dkb.company_group AS selected_group + SELECT 1 FROM {schema}.company_group AS selected_group WHERE selected_group.id = p_company_group_id ) ) @@ -131,7 +139,8 @@ def upgrade() -> None: def downgrade() -> None: - op.execute(""" + schema = _quoted_db_schema() + op.execute(f""" DROP FUNCTION IF EXISTS - dkb.get_osv_entries_for_group(uuid, integer, text) - """) \ No newline at end of file + {schema}.get_osv_entries_for_group(uuid, integer, text) + """) diff --git a/api/src/services/osv_service.py b/api/src/services/osv_service.py index ef5992c..1af1ba2 100644 --- a/api/src/services/osv_service.py +++ b/api/src/services/osv_service.py @@ -1,6 +1,8 @@ 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 @@ -18,7 +20,27 @@ class OsvService: 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] + + 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_file_id(self, file_id: uuid.UUID): -- 2.47.2 From c3bc214498937e01a004916850bcfde5f7225d29 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Mon, 28 Sep 2026 16:07:59 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BE=D1=81=D0=B2=20=D0=BF=D0=BE=20id=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...1503a26_add_osv_counterparty_alias_flag.py | 129 +----------------- ..._osv_entries_for_consolidation_version.py} | 76 ++++++----- api/src/api/endpoints/osv.py | 15 ++ api/src/repositories/osv_repository.py | 11 ++ api/src/services/osv_service.py | 28 +++- 5 files changed, 102 insertions(+), 157 deletions(-) rename api/alembic/versions/{d614b414a32f_add_osv_entries_for_group_function.py => e28f709a6b31_add_osv_entries_for_consolidation_version.py} (61%) diff --git a/api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py b/api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py index 7cf1813..97bba11 100644 --- a/api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py +++ b/api/alembic/versions/c94eb1503a26_add_osv_counterparty_alias_flag.py @@ -1,13 +1,13 @@ -"""Expose whether an OSV counterparty was resolved by alias. +"""Add OSV entries for group with counterparty alias resolution. Revision ID: c94eb1503a26 -Revises: d614b414a32f +Revises: c89d082850bd """ from alembic import op from src.core.config import settings revision = "c94eb1503a26" -down_revision = "d614b414a32f" +down_revision = "c89d082850bd" branch_labels = None depends_on = None @@ -20,9 +20,6 @@ def _quoted_db_schema() -> str: def upgrade() -> None: schema = _quoted_db_schema() - # PostgreSQL requires recreation when RETURNS TABLE changes. - # No CASCADE: dependent database objects must not be silently removed. - op.execute(f"DROP FUNCTION {schema}.get_osv_entries_for_group(uuid, integer, text)") op.execute(f""" CREATE OR REPLACE FUNCTION {schema}.get_osv_entries_for_group( p_company_group_id uuid, @@ -148,120 +145,6 @@ def upgrade() -> None: def downgrade() -> None: schema = _quoted_db_schema() - op.execute(f"DROP FUNCTION {schema}.get_osv_entries_for_group(uuid, integer, text)") - 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, - 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.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$; - """) + op.execute( + f"DROP FUNCTION IF EXISTS {schema}.get_osv_entries_for_group(uuid, integer, text)" + ) diff --git a/api/alembic/versions/d614b414a32f_add_osv_entries_for_group_function.py b/api/alembic/versions/e28f709a6b31_add_osv_entries_for_consolidation_version.py similarity index 61% rename from api/alembic/versions/d614b414a32f_add_osv_entries_for_group_function.py rename to api/alembic/versions/e28f709a6b31_add_osv_entries_for_consolidation_version.py index e5382a9..589daaf 100644 --- a/api/alembic/versions/d614b414a32f_add_osv_entries_for_group_function.py +++ b/api/alembic/versions/e28f709a6b31_add_osv_entries_for_consolidation_version.py @@ -1,22 +1,15 @@ -"""add osv entries for group function - -Revision ID: d614b414a32f -Revises: c89d082850bd -Create Date: 2026-09-17 17:15:16.421048 +"""Add OSV entries scoped to a consolidation version's members and period. +Revision ID: e28f709a6b31 +Revises: c94eb1503a26 """ -from typing import Sequence, Union - from alembic import op -import sqlalchemy as sa from src.core.config import settings - -# 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 +revision = "e28f709a6b31" +down_revision = "c94eb1503a26" +branch_labels = None +depends_on = None def _quoted_db_schema() -> str: @@ -28,10 +21,8 @@ def _quoted_db_schema() -> str: 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 + CREATE OR REPLACE FUNCTION {schema}.get_osv_entries_for_consolidation_version( + p_consolidation_version_id uuid ) RETURNS TABLE ( osv_entry_id uuid, @@ -44,6 +35,7 @@ def upgrade() -> None: counterparty_name varchar, counterparty_inn varchar, counterparty_id uuid, + counterparty_resolved_by_alias boolean, opening_debit numeric, opening_credit numeric, turnover_debit numeric, @@ -68,7 +60,16 @@ def upgrade() -> None: STABLE STRICT AS $function$ - WITH classified_entries AS ( + 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, @@ -82,7 +83,11 @@ def upgrade() -> None: oe.account_root, oe.counterparty_name, oe.counterparty_inn, - oe.counterparty_id, + 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, @@ -106,11 +111,21 @@ def upgrade() -> None: 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.company_group_id = ci.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 + 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 @@ -119,12 +134,8 @@ def upgrade() -> None: 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 - ) + AND sf.period_year = ci.period_year + AND sf.period_quarter = ci.period_quarter ) SELECT ce.*, @@ -140,7 +151,6 @@ def upgrade() -> None: def downgrade() -> None: schema = _quoted_db_schema() - op.execute(f""" - DROP FUNCTION IF EXISTS - {schema}.get_osv_entries_for_group(uuid, integer, text) - """) + 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 d101bd2..ee7c35f 100644 --- a/api/src/api/endpoints/osv.py +++ b/api/src/api/endpoints/osv.py @@ -23,6 +23,21 @@ async def get_osv_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/repositories/osv_repository.py b/api/src/repositories/osv_repository.py index 752cc18..4524222 100644 --- a/api/src/repositories/osv_repository.py +++ b/api/src/repositories/osv_repository.py @@ -20,6 +20,17 @@ class OsvRepository(BaseRepository): }) 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) diff --git a/api/src/services/osv_service.py b/api/src/services/osv_service.py index 1af1ba2..ed96cfd 100644 --- a/api/src/services/osv_service.py +++ b/api/src/services/osv_service.py @@ -41,7 +41,33 @@ class OsvService: 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: -- 2.47.2