summary-osv #2

Merged
PotapovaA merged 6 commits from summary-osv into main 2026-10-06 14:25:18 +03:00
12 changed files with 766 additions and 9 deletions

1
.gitignore vendored
View File

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

View File

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

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
Outdated
Review

Надо избавиться от указания схемы в создании функции и доступа к таблицам (в alembic/env.py в миграции теперь сначала происходит set search_path to "{db_schema}";, поэтому в целом должно прокатить просто удаление 'dkb.' из всей миграции, при этом в ENV-переменных будем прописывать схему. Та же схема используется в src/db/db/engine (search_path=db_schema)

Надо избавиться от указания схемы в создании функции и доступа к таблицам (в alembic/env.py в миграции теперь сначала происходит `set search_path to "{db_schema}";`, поэтому в целом должно прокатить просто удаление 'dkb.' из всей миграции, при этом в ENV-переменных будем прописывать схему. Та же схема используется в src/db/db/engine (search_path=db_schema)

исправила везде хард код схемы на settings.OPENBAO.DATABASE.DATABASE_SCHEMA

исправила везде хард код схемы на settings.OPENBAO.DATABASE.DATABASE_SCHEMA
Outdated
Review

А зачем здесь вообще схему оставлять? У нас в db.engine проставляется search_path из env, что эквивалентно, тут как будто не нужна схема вообще. Или без нее не работает?

А зачем здесь вообще схему оставлять? У нас в db.engine проставляется search_path из env, что эквивалентно, тут как будто не нужна схема вообще. Или без нее не работает?
)
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 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)

View File

@ -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 = {

View File

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

View File

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

View File

@ -1,10 +1,36 @@
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)

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
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
Review

.model_validate может упасть с ошибкой, возможно, стоит как-то обработать

.model_validate может упасть с ошибкой, возможно, стоит как-то обработать
) -> 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:

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`