Merge pull request 'summary-osv' (#2) from summary-osv into main

Reviewed-on: #2
This commit is contained in:
PotapovaA 2026-10-06 14:25:16 +03:00
commit 55ac236e41
12 changed files with 766 additions and 9 deletions

1
.gitignore vendored
View File

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

View File

@ -87,7 +87,7 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
version_table_schema="dkb", version_table_schema=db_schema,
include_schemas=True, include_schemas=True,
) )

View File

@ -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)"
)

View File

@ -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)"
)

View File

@ -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 fastapi.responses import FileResponse, StreamingResponse
from src.api.dependencies.services import get_osv_service, OsvService from src.api.dependencies.services import get_osv_service, OsvService
from src.schemas.enums import PeriodQuarter from src.schemas.enums import PeriodQuarter
from src.schemas.osv import OsvGroupEntryResponse
import uuid import uuid
router = APIRouter(prefix='/osv', tags=['osv']) 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') @router.get('/by-file-id')
async def get_osv_by_file(file_id: uuid.UUID, service: OsvService = Depends(get_osv_service)): 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) result = await service.get_by_file_id(file_id)

View File

@ -85,8 +85,8 @@ class PDFPollWorker:
logger.info("Начинаю обработку файла по пути smb_dir={smb_dir}", smb_dir=new_dir) logger.info("Начинаю обработку файла по пути smb_dir={smb_dir}", smb_dir=new_dir)
files = [] files = []
for fname in smbclient.listdir(new_dir): for fname in smbclient.listdir(new_dir, port=settings.OPENBAO.SMB.SMB_PORT):
with smbclient.open_file(f'{new_dir}/{fname}', 'r') as f: with smbclient.open_file(f'{new_dir}/{fname}', 'r', port=settings.OPENBAO.SMB.SMB_PORT) as f:
files.append(json.loads(f.read())) files.append(json.loads(f.read()))
logger.info("В рамках job_id={job_id} файлов в SMB найдено: {files_count}", job_id=job.job_id, files_count=len(files)) logger.info("В рамках job_id={job_id} файлов в SMB найдено: {files_count}", job_id=job.job_id, files_count=len(files))
s3_json_dict = { s3_json_dict = {

View File

@ -108,7 +108,8 @@ class PollWorker:
if row['subject'] == 'file.img_uploaded': if row['subject'] == 'file.img_uploaded':
last_part = row['row_payload']['s3_key'].split('/')[-1] 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']) data = await self.storage.get_object(row['row_payload']['s3_key'])
content = await data.read() content = await data.read()
f.write(content) f.write(content)

View File

@ -1,3 +1,4 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@ -6,6 +7,7 @@ class DBSettings(BaseSettings):
DATABASE_SCHEMA: str DATABASE_SCHEMA: str
class MinioSettings(BaseSettings): class MinioSettings(BaseSettings):
MINIO_SECURE: bool = True
MINIO_URL: str MINIO_URL: str
MINIO_ADMIN_LOGIN: str MINIO_ADMIN_LOGIN: str
MINIO_ADMIN_PASSWORD: str MINIO_ADMIN_PASSWORD: str
@ -17,6 +19,7 @@ class NATSSettings(BaseSettings):
NATS_JWT: str NATS_JWT: str
class SMBSettings(BaseSettings): class SMBSettings(BaseSettings):
SMB_PORT: int = Field(default=445, ge=1, le=65535)
SMB_HOST: str SMB_HOST: str
SMB_PATH: str SMB_PATH: str
SMB_LOGIN: str SMB_LOGIN: str

View File

@ -1,10 +1,36 @@
from .base_repository import BaseRepository from .base_repository import BaseRepository
from src.models import OsvEntry from src.models import OsvEntry
from sqlalchemy import insert, select from sqlalchemy import insert, select, text
class OsvRepository(BaseRepository): class OsvRepository(BaseRepository):
model = OsvEntry 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): async def get_by_file_id(self, file_id):
stmt = select(self.model).where(self.model.stored_file_id == file_id) stmt = select(self.model).where(self.model.stored_file_id == file_id)
result = await self.session.execute(stmt) result = await self.session.execute(stmt)

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

@ -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['ГСК', 'ГК', 'Внешний']

View File

@ -1,15 +1,73 @@
import uuid import uuid
from fastapi import UploadFile from fastapi import UploadFile
from loguru import logger
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.common.exceptions import NotFoundError from src.common.exceptions import NotFoundError
from src.repositories.osv_repository import OsvEntry, OsvRepository from src.repositories.osv_repository import OsvEntry, OsvRepository
from src.schemas.enums import PeriodQuarter
from src.schemas.osv import OsvGroupEntryResponse
class OsvService: class OsvService:
def __init__(self, session: AsyncSession, osv_repo: OsvRepository): def __init__(self, session: AsyncSession, osv_repo: OsvRepository):
self.osv_repo = osv_repo self.osv_repo = osv_repo
self.session = session 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): async def get_by_file_id(self, file_id: uuid.UUID):
try: try:

View File

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