feat(#190): метрики качества первички (AURORA-1759) #229

Merged
aep merged 45 commits from feat/190-ocr-metrics into main 2026-11-07 07:50:00 +03:00
25 changed files with 1832 additions and 78 deletions
Showing only changes of commit 251c30d5ca - Show all commits

View File

@ -175,7 +175,7 @@
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
## Правила репозитория
- ORM 1:1 с `../docs/model/data-model-v5.24.dbml` (текущая версия; v5.24 — назначения ВГО; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21` + `0010_dbml_v5_23` + `0011_dbml_v5_24`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- ORM 1:1 с `../docs/model/data-model-v5.25.dbml` (текущая версия; v5.25 — метрики качества первички, отдельная схема `dkb_metrics`; v5.24 — назначения ВГО; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21` + `0010_dbml_v5_23` + `0011_dbml_v5_24` + `0012_dbml_v5_25`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц.
- Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и

View File

@ -3,7 +3,7 @@ VENV := api/.venv
BIN := $(CURDIR)/$(VENV)/bin
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
# DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/
DBML ?= ../../docs/model/data-model-v5.24.dbml
DBML ?= ../../docs/model/data-model-v5.25.dbml
.PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check statements-check check-full up migrate seed seed-dev

View File

@ -1142,7 +1142,7 @@ email без учёта регистра проверяет только сер
| `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен |
| `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) |
| `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) |
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.24.dbml` |
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.25.dbml` |
| `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate |
| `make check` | быстрая проверка без Docker: `lint` + `test` |
| `make docker-build` | сборка образа: `docker build -t arm-core .` |
@ -1227,7 +1227,7 @@ GET требует `CONSOLIDATION_VIEW` и не меняет данные. Им
### Проверка ORM и DBML
Модель данных — `../docs/model/data-model-v5.24.dbml`. ORM обязана совпадать с ней один в один:
Модель данных — `../docs/model/data-model-v5.25.dbml`. ORM обязана совпадать с ней один в один:
```bash
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
@ -1334,7 +1334,7 @@ Dockerfile только для локального compose
- [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы;
- [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3;
- [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и
[`../docs/model/data-model-v5.24.dbml`](../docs/model/data-model-v5.24.dbml) — модель данных;
[`../docs/model/data-model-v5.25.dbml`](../docs/model/data-model-v5.25.dbml) — модель данных;
- [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области;
- [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы;
- [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов;

View File

@ -10,6 +10,9 @@
# Схема PostgreSQL модели данных (по умолчанию dkb). Одно значение для приложения и Alembic; идентификатор
# [a-z_][a-z0-9_]{0,62}, не pg_*. В контуре платформы: OPENBAO__APP__DB_SCHEMA
DB_SCHEMA=dkb
# Отдельная схема фактов качества распознавания первички (по умолчанию dkb_metrics), не совпадает с DB_SCHEMA.
# В контуре платформы: OPENBAO__APP__QUALITY_METRICS_SCHEMA
QUALITY_METRICS_SCHEMA=dkb_metrics
# OPENBAO__APP__AUTH_DEV_ISSUER=true
# OPENBAO__APP__FORWARDED_ALLOW_IPS=10.0.0.1,10.0.0.2
# Приоритет: обычный env -> OPENBAO__APP__* -> .env -> значение по умолчанию. Неизвестное имя — ошибка при старте.

View File

@ -6,6 +6,7 @@ DEBUG=true
# Схема БД: как в .env.example (тесты используют ту же настройку, что и приложение)
DB_SCHEMA=dkb
QUALITY_METRICS_SCHEMA=dkb_metrics
# Как в .env.example (тесты сервер не запускают)
SERVER__HOST=127.0.0.1

View File

@ -8,6 +8,10 @@ URL БД (по убыванию приоритета):
Схема создаётся до таблицы версий `<схема>.alembic_version` и до миграций — только если её нет: PostgreSQL проверяет
право CREATE на базу раньше, чем `IF NOT EXISTS`, а платформа может выдать роли миграций готовую схему без этого права.
v5.25: вторая схема `settings.QUALITY_METRICS_SCHEMA` (факты качества, `METRICS_SCHEMA`) — та же цепочка миграций и
`alembic_version` (одна схема версий, `version_table_schema=SCHEMA`), но её саму создаёт сама миграция 0012
(`CREATE SCHEMA IF NOT EXISTS`), а не `env.py`: в отличие от `SCHEMA`, она не нужна до первой миграции.
"""
import asyncio
@ -19,7 +23,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from alembic import context
from src.core.config import settings
from src.models import Base # модели регистрируются в src/models/__init__.py
from src.models.base import SCHEMA
from src.models.base import METRICS_SCHEMA, SCHEMA
#: Значение `sqlalchemy.url` в шаблонном `alembic.ini` — признак «URL не задан вызывающим кодом».
INI_PLACEHOLDER_PREFIX = "driver://"
@ -49,9 +53,10 @@ def database_url() -> str:
def include_name(name: str | None, type_: str, parent_names: object) -> bool:
"""Autogenerate видит только схему модели (`SCHEMA`) (схема по умолчанию `public` и прочие — не наши)."""
"""Autogenerate видит схему модели (`SCHEMA`) и схему фактов качества (`METRICS_SCHEMA`, v5.25) — схема по
умолчанию `public` и прочие не наши."""
if type_ == "schema":
return name == SCHEMA
return name in (SCHEMA, METRICS_SCHEMA)
return True

View File

@ -0,0 +1,282 @@
"""DBML v5.25: метрики качества первички (AURORA-1759, openspec/changes/quality-metrics).
Три новых enum-типа (`quality_outcome`, `quality_cause`, `quality_failure_kind`) и два новых значения существующих
(`s3_bucket_role.raw`, `permission_object.QUALITY_METRICS`) — в схеме модели (`SCHEMA`, по умолчанию `dkb`), как все
прочие enum'ы. Две новые колонки там же: `stored_file.is_scan`, `recognition_job.raw_s3_object_id` (FK -> `s3_object`).
Три новые таблицы фактов (`quality_intake_fact`, `quality_outcome_fact`, `quality_failure_fact`) — в ОТДЕЛЬНОЙ схеме
`METRICS_SCHEMA` (`settings.QUALITY_METRICS_SCHEMA`, по умолчанию `dkb_metrics`): одна цепочка Alembic с `SCHEMA`
(`alembic_version` остаётся только в `SCHEMA`), но данные в своей схеме — append-only, без FK на рабочие таблицы
(design §4). Схему создаёт эта миграция — условно, только если её нет (как `SCHEMA` в `env.py`/0001: платформа может
выдать роли миграций готовую схему без права `CREATE` на базу, а оно проверяется раньше `IF NOT EXISTS`).
Откат: таблицы и колонки отбрасываются без условий (новые, данных по ним с момента прошлого релиза не было).
Откат двух enum-значений — только если значение не использовано (append-only журнал `s3_object`/`permission` не
трогаем): пересборка типа без значения останавливается `RAISE EXCEPTION`, как в 0002/0009. Схему `METRICS_SCHEMA`
откат не трогает — симметрично тому, что `SCHEMA` никогда не удаляется (та же причина: роль может не иметь права
`DROP SCHEMA`, если схему ей выдала платформа, а не эта миграция).
Revision ID: 0012_dbml_v5_25
Revises: 0011_dbml_v5_24
Create Date: 2026-11-05
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
from src.models.base import METRICS_SCHEMA, SCHEMA
revision: str = "0012_dbml_v5_25"
down_revision: str | Sequence[str] | None = "0011_dbml_v5_24"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
#: Три новых enum-типа v5.25, значения — в порядке DBML; живут в SCHEMA, хотя используют их таблицы METRICS_SCHEMA.
NEW_ENUMS: dict[str, tuple[str, ...]] = {
"quality_outcome": (
"AUTO_CONFIRMED",
"CONFIRMED",
"DELETED",
"SUPERSEDED",
"DELETED_AFTER_CONFIRM",
"SUPERSEDED_AFTER_CONFIRM",
),
"quality_cause": ("SERVICE_ERROR", "OUR_ERROR", "CLIENT_DOCUMENT", "WITHOUT_PARTICIPATION", "AS_IS"),
"quality_failure_kind": ("SERVICE_ERROR", "TIMEOUT", "NO_ACK", "MAPPING"),
}
RAW_S3_OBJECT_FK = "fk_recognition_job_raw_s3_object_id_s3_object"
#: Как в env.py/0001: `CREATE SCHEMA IF NOT EXISTS` сам по себе проверяет право `CREATE` на базу раньше, чем
#: отрабатывает `IF NOT EXISTS` — платформа может выдать схему роли миграций без этого права.
CREATE_METRICS_SCHEMA_IF_MISSING_SQL = (
f"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '{METRICS_SCHEMA}') "
f"THEN CREATE SCHEMA {METRICS_SCHEMA}; END IF; END $$"
)
def _enum_ref(name: str) -> postgresql.ENUM:
"""Ссылка на уже существующий (или только что созданный этой миграцией) PG-enum `SCHEMA.<name>`."""
return postgresql.ENUM(name=name, schema=SCHEMA, create_type=False)
def _create_new_enum_types() -> None:
for name, values in NEW_ENUMS.items():
literal_values = ", ".join(f"'{value}'" for value in values)
op.execute(f"CREATE TYPE {SCHEMA}.{name} AS ENUM ({literal_values})")
def _drop_new_enum_types() -> None:
for name in reversed(NEW_ENUMS):
op.execute(f"DROP TYPE {SCHEMA}.{name}")
def _revert_s3_bucket_role() -> None:
"""Откат `s3_bucket_role += raw`: только если `raw` ещё не использован хотя бы одним `s3_object`."""
op.execute(f"""DO $$ DECLARE n bigint; labels text; BEGIN
SELECT count(*) INTO n FROM {SCHEMA}.s3_object WHERE bucket_role::text = 'raw';
IF n > 0 THEN RAISE EXCEPTION 'откат v5.25 невозможен: s3_object с bucket_role=raw: %', n; END IF;
SELECT string_agg(quote_literal(e.enumlabel), ',' ORDER BY e.enumsortorder) INTO labels
FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid JOIN pg_namespace ns ON ns.oid = t.typnamespace
WHERE ns.nspname = '{SCHEMA}' AND t.typname = 's3_bucket_role' AND e.enumlabel <> 'raw';
ALTER TYPE {SCHEMA}.s3_bucket_role RENAME TO s3_bucket_role_v5_25;
EXECUTE 'CREATE TYPE {SCHEMA}.s3_bucket_role AS ENUM (' || labels || ')';
ALTER TABLE {SCHEMA}.s3_object ALTER COLUMN bucket_role TYPE {SCHEMA}.s3_bucket_role
USING bucket_role::text::{SCHEMA}.s3_bucket_role;
DROP TYPE {SCHEMA}.s3_bucket_role_v5_25;
END $$""")
def _revert_permission_object() -> None:
"""Откат `permission_object += QUALITY_METRICS`: только если право ещё не выдано (нет записи `permission`)."""
op.execute(f"""DO $$ DECLARE n bigint; labels text; BEGIN
SELECT count(*) INTO n FROM {SCHEMA}.permission WHERE object_type::text = 'QUALITY_METRICS';
IF n > 0 THEN RAISE EXCEPTION 'откат v5.25 невозможен: permission с object_type=QUALITY_METRICS: %', n; END IF;
SELECT string_agg(quote_literal(e.enumlabel), ',' ORDER BY e.enumsortorder) INTO labels
FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid JOIN pg_namespace ns ON ns.oid = t.typnamespace
WHERE ns.nspname = '{SCHEMA}' AND t.typname = 'permission_object' AND e.enumlabel <> 'QUALITY_METRICS';
ALTER TYPE {SCHEMA}.permission_object RENAME TO permission_object_v5_25;
EXECUTE 'CREATE TYPE {SCHEMA}.permission_object AS ENUM (' || labels || ')';
ALTER TABLE {SCHEMA}.permission ALTER COLUMN object_type TYPE {SCHEMA}.permission_object
USING object_type::text::{SCHEMA}.permission_object;
DROP TYPE {SCHEMA}.permission_object_v5_25;
END $$""")
def _create_quality_intake_fact() -> None:
op.create_table(
"quality_intake_fact",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("document_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("file_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("job_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("attempt", sa.SmallInteger(), nullable=False),
sa.Column("legal_entity_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("legal_entity_name", sa.String(), nullable=False),
sa.Column("group_id", postgresql.UUID(as_uuid=True)),
sa.Column("group_name", sa.String()),
sa.Column("document_type", _enum_ref("document_type"), nullable=False),
sa.Column("pipeline", _enum_ref("recognition_pipeline"), nullable=False),
sa.Column("is_scan", sa.Boolean()),
sa.Column("service_name", sa.String()),
sa.Column("service_version", sa.String()),
sa.Column("statements_version", sa.String()),
sa.Column("result_schema_version", sa.String()),
sa.Column("report_date", sa.Date()),
sa.Column("intake_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("intake_status", _enum_ref("document_status"), nullable=False),
sa.Column("line_count", sa.Integer()),
sa.Column("value_count", sa.Integer()),
sa.Column("value_needs_review_count", sa.Integer()),
sa.Column("line_unreadable_count", sa.Integer()),
sa.Column("line_missing_count", sa.Integer()),
sa.Column("issue_relation_count", sa.Integer()),
sa.Column("issue_soft_count", sa.Integer()),
sa.Column("warning_count", sa.Integer()),
sa.Column("raw_s3_key", sa.String()),
sa.Column("result_s3_key", sa.String()),
sa.Column("upload_s3_key", sa.String()),
sa.Column("payload", postgresql.JSONB(), nullable=False),
sa.Column("payload_schema_version", sa.String()),
schema=METRICS_SCHEMA,
)
op.create_index(
"uq_quality_intake_fact_document_id",
"quality_intake_fact",
["document_id"],
unique=True,
schema=METRICS_SCHEMA,
)
op.create_index(
"ix_quality_intake_fact_legal_entity_id_intake_at",
"quality_intake_fact",
["legal_entity_id", "intake_at"],
schema=METRICS_SCHEMA,
)
op.create_index(
"ix_quality_intake_fact_group_id_intake_at",
"quality_intake_fact",
["group_id", "intake_at"],
schema=METRICS_SCHEMA,
)
def _create_quality_outcome_fact() -> None:
op.create_table(
"quality_outcome_fact",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("document_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("outcome", _enum_ref("quality_outcome"), nullable=False),
sa.Column("outcome_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("primary_cause", _enum_ref("quality_cause")),
sa.Column("fixes_value_service", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("fixes_value_ours", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("fixes_code_service", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("fixes_code_ours", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("fixes_added_row_service", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("fixes_added_row_ours", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("fixes_header_service", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("fixes_header_ours", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("fixes_osv_period_account", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("checks_accepted", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("issue_count_at_outcome", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("report_date", sa.Date()),
sa.Column("confirmed_by", postgresql.UUID(as_uuid=True)),
sa.Column("payload", postgresql.JSONB(), nullable=False),
sa.Column("payload_schema_version", sa.String()),
schema=METRICS_SCHEMA,
)
op.create_index(
"ix_quality_outcome_fact_document_id_outcome_at",
"quality_outcome_fact",
["document_id", "outcome_at"],
schema=METRICS_SCHEMA,
)
op.create_index(
"ix_quality_outcome_fact_outcome_outcome_at",
"quality_outcome_fact",
["outcome", "outcome_at"],
schema=METRICS_SCHEMA,
)
def _create_quality_failure_fact() -> None:
op.create_table(
"quality_failure_fact",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("job_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("file_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("attempt", sa.SmallInteger(), nullable=False),
sa.Column("legal_entity_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("legal_entity_name", sa.String(), nullable=False),
sa.Column("group_id", postgresql.UUID(as_uuid=True)),
sa.Column("group_name", sa.String()),
sa.Column("pipeline", _enum_ref("recognition_pipeline"), nullable=False),
sa.Column("is_scan", sa.Boolean()),
sa.Column("service_name", sa.String()),
sa.Column("failure_kind", _enum_ref("quality_failure_kind"), nullable=False),
sa.Column("error_code", sa.String()),
sa.Column("failed_at", sa.DateTime(timezone=True), nullable=False),
schema=METRICS_SCHEMA,
)
op.create_index(
"uq_quality_failure_fact_job_id", "quality_failure_fact", ["job_id"], unique=True, schema=METRICS_SCHEMA
)
op.create_index(
"ix_quality_failure_fact_file_id_attempt",
"quality_failure_fact",
["file_id", "attempt"],
schema=METRICS_SCHEMA,
)
def upgrade() -> None:
_create_new_enum_types()
op.execute(f"ALTER TYPE {SCHEMA}.s3_bucket_role ADD VALUE IF NOT EXISTS 'raw' AFTER 'archive'")
op.execute(f"ALTER TYPE {SCHEMA}.permission_object ADD VALUE IF NOT EXISTS 'QUALITY_METRICS' AFTER 'AUDIT'")
op.add_column("stored_file", sa.Column("is_scan", sa.Boolean()), schema=SCHEMA)
op.add_column("recognition_job", sa.Column("raw_s3_object_id", postgresql.UUID(as_uuid=True)), schema=SCHEMA)
op.create_foreign_key(
RAW_S3_OBJECT_FK,
"recognition_job",
"s3_object",
["raw_s3_object_id"],
["id"],
source_schema=SCHEMA,
referent_schema=SCHEMA,
)
op.execute(CREATE_METRICS_SCHEMA_IF_MISSING_SQL)
_create_quality_intake_fact()
_create_quality_outcome_fact()
_create_quality_failure_fact()
def downgrade() -> None:
op.drop_index("ix_quality_failure_fact_file_id_attempt", table_name="quality_failure_fact", schema=METRICS_SCHEMA)
op.drop_index("uq_quality_failure_fact_job_id", table_name="quality_failure_fact", schema=METRICS_SCHEMA)
op.drop_table("quality_failure_fact", schema=METRICS_SCHEMA)
op.drop_index(
"ix_quality_outcome_fact_outcome_outcome_at", table_name="quality_outcome_fact", schema=METRICS_SCHEMA
)
op.drop_index(
"ix_quality_outcome_fact_document_id_outcome_at", table_name="quality_outcome_fact", schema=METRICS_SCHEMA
)
op.drop_table("quality_outcome_fact", schema=METRICS_SCHEMA)
op.drop_index("ix_quality_intake_fact_group_id_intake_at", table_name="quality_intake_fact", schema=METRICS_SCHEMA)
op.drop_index(
"ix_quality_intake_fact_legal_entity_id_intake_at", table_name="quality_intake_fact", schema=METRICS_SCHEMA
)
op.drop_index("uq_quality_intake_fact_document_id", table_name="quality_intake_fact", schema=METRICS_SCHEMA)
op.drop_table("quality_intake_fact", schema=METRICS_SCHEMA)
# METRICS_SCHEMA (как и SCHEMA) остаётся — см. docstring модуля
op.drop_constraint(RAW_S3_OBJECT_FK, "recognition_job", type_="foreignkey", schema=SCHEMA)
op.drop_column("recognition_job", "raw_s3_object_id", schema=SCHEMA)
op.drop_column("stored_file", "is_scan", schema=SCHEMA)
_revert_permission_object()
_revert_s3_bucket_role()
_drop_new_enum_types()

View File

@ -33,9 +33,11 @@ if str(_API_DIR) not in sys.path:
sys.path.insert(0, str(_API_DIR))
if "ENV_FILE" not in os.environ and not Path(".env").is_file():
os.environ["ENV_FILE"] = str(_API_DIR / ".env.example")
# Сверка идёт в эталонной схеме `dkb`: имя схемы в БД конфигурируемо (`DB_SCHEMA`), на совпадение имён, типов и
# ограничений с DBML оно не влияет. Обычная переменная окружения приоритетнее `OPENBAO__APP__DB_SCHEMA` и `.env`.
# Сверка идёт в эталонной схеме `dkb`/`dkb_metrics`: имена схем в БД конфигурируемы (`DB_SCHEMA`,
# `QUALITY_METRICS_SCHEMA`), на совпадение имён, типов и ограничений с DBML они не влияют. Обычная переменная
# окружения приоритетнее `OPENBAO__APP__*` и `.env`.
os.environ["DB_SCHEMA"] = "dkb"
os.environ["QUALITY_METRICS_SCHEMA"] = "dkb_metrics"
from sqlalchemy import MetaData # noqa: E402

View File

@ -57,12 +57,23 @@ from sqlalchemy.sql.functions import FunctionElement
#: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением.
SCHEMA = "dkb"
DBML_FILE_NAME = "data-model-v5.24.dbml"
#: v5.25: три таблицы фактов качества живут в отдельной схеме (`QUALITY_METRICS_SCHEMA`, по умолчанию `dkb_metrics`)
#: — единственное исключение из «всё в SCHEMA». DBML не кодирует схему Postgres, поэтому список таблиц, для которых
#: ожидается не SCHEMA, явный (аналог `_check_problems`/`vgo_assignment` ниже).
METRICS_SCHEMA = "dkb_metrics"
METRICS_TABLES = frozenset({"quality_intake_fact", "quality_outcome_fact", "quality_failure_fact"})
DBML_FILE_NAME = "data-model-v5.25.dbml"
ORM = "ORM"
DBML = "DBML"
ABSENT = "нет"
NO_ACTION = "NO ACTION"
def expected_schema(table_name: str) -> str:
"""Схема, в которой таблица `table_name` обязана жить по DBML: `SCHEMA`, кроме `METRICS_TABLES` (v5.25)."""
return METRICS_SCHEMA if table_name in METRICS_TABLES else SCHEMA
_NUMERIC_RE = re.compile(r"numeric\((\d+),\s*(\d+)\)")
_VERSION_RE = re.compile(r"v(\d+\.\d+)")
_PARTIAL_RE = re.compile(r"partial\s+UNIQUE\s+WHERE\s+(.+?)(?:\s+—.*)?$", re.IGNORECASE | re.DOTALL)
@ -424,8 +435,9 @@ def find_problems(metadata: MetaData, tables: Collection[str], dbml: PyDBML) ->
elif name not in orm_tables:
problems.append(missing(name, ORM))
else:
if orm_tables[name].schema != SCHEMA:
problems.append(mismatch(f"{name}: схема", SCHEMA, orm_tables[name].schema))
schema = expected_schema(name)
if orm_tables[name].schema != schema:
problems.append(mismatch(f"{name}: схема", schema, orm_tables[name].schema))
problems += _column_problems(orm_tables[name], dbml_tables[name])
problems += _index_problems(orm_tables[name], dbml_tables[name])
problems += _check_problems(orm_tables[name])

View File

@ -127,8 +127,12 @@ class Settings(BaseSettings):
server: ServerSettings = ServerSettings()
ROOT_PATH: str = "/"
# Схема PostgreSQL модели данных (MetaData ORM, search_path движка, Alembic). В контуре — OPENBAO__APP__DB_SCHEMA.
# Имя подставляется в DDL, поэтому валидируется как простой идентификатор (см. `_check_db_schema`)
# Имя подставляется в DDL, поэтому валидируется как простой идентификатор (см. `_check_schema_identifier`)
DB_SCHEMA: str = "dkb"
# v5.25: отдельная схема PostgreSQL фактов качества распознавания первички (quality_intake_fact/outcome_fact/
# failure_fact) — одна цепочка Alembic с DB_SCHEMA, но другая схема (Superset подключается сюда read-only ролью,
# openspec/changes/quality-metrics §4, §8). В контуре — OPENBAO__APP__QUALITY_METRICS_SCHEMA
QUALITY_METRICS_SCHEMA: str = "dkb_metrics"
VERSION: str = "1.0.0"
# CORS при DEBUG=false: разрешённые источники (JSON-список или строка через запятую); пусто — кросс-доменные
# запросы запрещены. При DEBUG=true — всегда `*` (OQ-65)
@ -189,17 +193,25 @@ class Settings(BaseSettings):
file_secret_settings,
)
@field_validator("DB_SCHEMA")
@field_validator("DB_SCHEMA", "QUALITY_METRICS_SCHEMA")
@classmethod
def _check_db_schema(cls, value: str) -> str:
"""Имя схемы — строчный SQL-идентификатор до 63 символов, не `pg_*` (зарезервировано PostgreSQL): безопасно
для подстановки в DDL и `search_path` без кавычек."""
def _check_schema_identifier(cls, value: str, info: ValidationInfo) -> str:
"""Имя схемы (основной модели или метрик качества) — строчный SQL-идентификатор до 63 символов, не `pg_*`
(зарезервировано PostgreSQL): безопасно для подстановки в DDL и `search_path` без кавычек."""
if not DB_SCHEMA_RE.fullmatch(value) or value.startswith("pg_"):
raise ValueError(
f"DB_SCHEMA={value!r}: нужен идентификатор [a-z_][a-z0-9_]{{0,62}}, не начинающийся с 'pg_'"
f"{info.field_name}={value!r}: нужен идентификатор [a-z_][a-z0-9_]{{0,62}}, не начинающийся с 'pg_'"
)
return value
@model_validator(mode="after")
def _check_schemas_distinct(self) -> "Settings":
"""`DB_SCHEMA` и `QUALITY_METRICS_SCHEMA` обязаны различаться: иначе таблицы модели и факты качества
оказались бы в одной схеме и имена конфликтовали бы."""
if self.DB_SCHEMA == self.QUALITY_METRICS_SCHEMA:
raise ValueError(f"DB_SCHEMA и QUALITY_METRICS_SCHEMA совпадают ({self.DB_SCHEMA!r}): нужны разные схемы")
return self
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def _split_origins(cls, value: Any) -> Any:

View File

@ -1,13 +1,13 @@
"""ORM-модели. Источник истины — docs/model/data-model-v5.24.dbml; заполняется в Срезе 0.
"""ORM-модели. Источник истины — docs/model/data-model-v5.25.dbml; заполняется в Срезе 0.
Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic).
"""
from . import access, accounting, audit_ex, consolidation, directories, files, kfo, osv, statements
from . import access, accounting, audit_ex, consolidation, directories, files, kfo, osv, quality_facts, statements
from .base import Base, UUIDPrimaryMixin
#: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm).
DBML_VERSION = "5.24"
DBML_VERSION = "5.25"
__all__ = [
"DBML_VERSION",
@ -21,5 +21,6 @@ __all__ = [
"files",
"kfo",
"osv",
"quality_facts",
"statements",
]

View File

@ -12,6 +12,12 @@ from src.core.config import settings
#: Читается при импорте: MetaData, enum и sequence привязываются к схеме сразу (см. R-1 PR #38).
SCHEMA = settings.DB_SCHEMA
#: Отдельная схема PostgreSQL фактов качества распознавания первички (DBML v5.25, `quality_*_fact`) — единственный
#: источник `settings.QUALITY_METRICS_SCHEMA`. Таблицы фактов (`src/models/quality_facts.py`) задают её явно через
#: `__table_args__ = {"schema": METRICS_SCHEMA}` — `Base.metadata` при этом остаётся одной на обе схемы (как и
#: `ENUMS`/enum-типы, которые по-прежнему живут в `SCHEMA`, см. `.enums`).
METRICS_SCHEMA = settings.QUALITY_METRICS_SCHEMA
#: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же
#: имена ограничений и `downgrade` был детерминирован. Для составных/partial-индексов, где конвенция даёт
#: неоднозначное или слишком длинное имя (лимит PostgreSQL — 63 символа), имена задаются явно в `__table_args__`.

View File

@ -1,9 +1,13 @@
"""Enum'ы модели данных DBML v5.19 (33 штуки) и фабрика `pg_enum` для PostgreSQL-колонок.
"""Enum'ы модели данных DBML v5.25 (36 штук) и фабрика `pg_enum` для PostgreSQL-колонок.
Источник истины — docs/model/data-model-v5.19.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке
Источник истины — docs/model/data-model-v5.25.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке
DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются.
Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима).
Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`.
v5.25: `quality_outcome`/`quality_cause`/`quality_failure_kind` — новые enum'ы метрик качества первички
(openspec/changes/quality-metrics §4, §6). Их значения живут в той же схеме `SCHEMA`, что все прочие enum'ы, хотя
таблицы, которые их используют (`quality_*_fact`), — в отдельной схеме `settings.QUALITY_METRICS_SCHEMA` (см. `.base`).
"""
import enum
@ -114,6 +118,7 @@ class S3BucketRole(enum.StrEnum):
results = "results"
exports = "exports"
archive = "archive"
raw = "raw" # v5.25: сырой ответ ИС Сканирование, raw/{job_id}.json — не экспирится с файлом (OQ-179)
class RecognitionStatus(enum.StrEnum):
@ -283,6 +288,7 @@ class PermissionObject(enum.StrEnum):
EXPORT = "EXPORT"
USERS = "USERS"
AUDIT = "AUDIT"
QUALITY_METRICS = "QUALITY_METRICS" # v5.25: метрики качества распознавания первички (право QUALITY_METRICS_VIEW)
class PermissionAction(enum.StrEnum):
@ -347,6 +353,46 @@ class AuditAction(enum.StrEnum):
ROLE_PERMISSION_CHANGE = "ROLE_PERMISSION_CHANGE"
class QualityOutcome(enum.StrEnum):
"""DBML `quality_outcome` (v5.25): исход документа — `quality_outcome_fact.outcome` (design §4.2)."""
AUTO_CONFIRMED = "AUTO_CONFIRMED"
CONFIRMED = "CONFIRMED"
DELETED = "DELETED"
SUPERSEDED = "SUPERSEDED"
DELETED_AFTER_CONFIRM = "DELETED_AFTER_CONFIRM"
SUPERSEDED_AFTER_CONFIRM = "SUPERSEDED_AFTER_CONFIRM"
class QualityCause(enum.StrEnum):
"""DBML `quality_cause` (v5.25): причина документа — ячейка матрицы «источник × причина» (design §5, §6).
`SERVICE_ERROR` — есть правка, не найденная в сыром тексте сервиса; `OUR_ERROR` — есть правка, найденная в сыром
тексте (наш разбор ошибся); `CLIENT_DOCUMENT` — подтверждён с акцептом нарушенных жёстких проверок, без
несписанных правок на это нарушение; `WITHOUT_PARTICIPATION` — `AUTO_CONFIRMED`; `AS_IS` — `CONFIRMED` без
правок и без акцепта.
"""
SERVICE_ERROR = "SERVICE_ERROR"
OUR_ERROR = "OUR_ERROR"
CLIENT_DOCUMENT = "CLIENT_DOCUMENT"
WITHOUT_PARTICIPATION = "WITHOUT_PARTICIPATION"
AS_IS = "AS_IS"
class QualityFailureKind(enum.StrEnum):
"""DBML `quality_failure_kind` (v5.25): вид технического отказа — `quality_failure_fact.failure_kind`.
`SERVICE_ERROR` — `recognition_job.status = error`; `NO_ACK` — `dlq` (redelivery после `max_deliver`);
`MAPPING` — `MappingFatal`, файл → `ERROR`.
"""
SERVICE_ERROR = "SERVICE_ERROR"
TIMEOUT = "TIMEOUT"
NO_ACK = "NO_ACK"
MAPPING = "MAPPING"
#: Реестр: имя enum в DBML (= имя типа в PostgreSQL) → класс.
ENUMS: dict[str, type[enum.Enum]] = {
"entity_type": EntityType,
@ -382,6 +428,9 @@ ENUMS: dict[str, type[enum.Enum]] = {
"permission_action": PermissionAction,
"audit_section": AuditSection,
"audit_action": AuditAction,
"quality_outcome": QualityOutcome,
"quality_cause": QualityCause,
"quality_failure_kind": QualityFailureKind,
}

View File

@ -1,4 +1,4 @@
"""Часть 04 DBML v5.19 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания.
"""Часть 04 DBML v5.25 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания.
Цикл FK `stored_file.current_recognition_job_id` -> `recognition_job` и `recognition_job.file_id` -> `stored_file`
разорван через `use_alter=True` на nullable-стороне (`stored_file.current_recognition_job_id`): `create_all` создаёт
@ -70,6 +70,9 @@ class StoredFile(UUIDPrimaryMixin, Base):
status: Mapped[FileStatus] = mapped_column(pg_enum("file_status"), nullable=False)
version_no: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1"))
superseded_by_file_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("stored_file.id"))
# v5.25: только PDF — признак скана по отсутствию текстового слоя при загрузке; true = скан, false = цифровой
# (в т.ч. скрытый OCR-слой — допущение §14); NULL для не-PDF (05-domain-rules.md §14.6)
is_scan: Mapped[bool | None] = mapped_column(Boolean)
current_recognition_job_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey(
@ -169,6 +172,9 @@ class RecognitionJob(UUIDPrimaryMixin, Base):
error_code: Mapped[str | None] = mapped_column(String)
error_message: Mapped[str | None] = mapped_column(Text)
result_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id"))
# v5.25: сырой ответ ИС Сканирование (роль raw), заполняется при приёме результата image pipeline — отменяет
# решение v5.13 «в БД не регистрируется» (openspec/changes/quality-metrics §9)
raw_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id"))
result_schema_version: Mapped[str | None] = mapped_column(String)
group_schema: Mapped[Any | None] = mapped_column(nullable_jsonb())
metrics: Mapped[Any | None] = mapped_column(nullable_jsonb())

View File

@ -0,0 +1,139 @@
"""DBML v5.25 «Метрики качества»: факты качества распознавания первички (AURORA-1759, openspec/changes/quality-metrics).
Три append-only таблицы в отдельной схеме PostgreSQL `settings.QUALITY_METRICS_SCHEMA` (по умолчанию `dkb_metrics`,
`.base.METRICS_SCHEMA`) — не в `SCHEMA` (`dkb`), где живёт остальная модель. Задаётся явно через `__table_args__ =
{"schema": METRICS_SCHEMA}`: `Base`/`Base.metadata` общие для обеих схем (как и enum-типы `pg_enum`, которые
остаются в `SCHEMA`) — второй `DeclarativeBase` не нужен.
Без FK на рабочие таблицы (`document`, `stored_file`, `recognition_job`, `legal_entity`, `company_group`): факт
обязан переживать удаление/перераспознавание документа и физическое удаление первички (design §4). Идентификаторы —
простой `uuid`, имена ЮЛ/ГК — копия на момент факта.
"""
import datetime
import uuid
from typing import Any
from sqlalchemy import Boolean, Date, DateTime, Index, Integer, SmallInteger, String, text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from .base import METRICS_SCHEMA, Base, UUIDPrimaryMixin
from .enums import (
DocumentStatus,
DocumentType,
QualityCause,
QualityFailureKind,
QualityOutcome,
RecognitionPipeline,
pg_enum,
)
class QualityIntakeFact(UUIDPrimaryMixin, Base):
"""Документ «как распознано» (design §4.1): один факт на созданный документ BALANCE/INCOME/OSV (DISCLOSURE/OTHER
факт не пишут). Пишется в транзакции маппинга, после `map_file`, до `auto_confirm`."""
__tablename__ = "quality_intake_fact"
__table_args__ = (
Index("uq_quality_intake_fact_document_id", "document_id", unique=True),
Index("ix_quality_intake_fact_legal_entity_id_intake_at", "legal_entity_id", "intake_at"),
Index("ix_quality_intake_fact_group_id_intake_at", "group_id", "intake_at"),
{"schema": METRICS_SCHEMA},
)
document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
job_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
attempt: Mapped[int] = mapped_column(SmallInteger, nullable=False)
legal_entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
legal_entity_name: Mapped[str] = mapped_column(String, nullable=False)
group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
group_name: Mapped[str | None] = mapped_column(String)
document_type: Mapped[DocumentType] = mapped_column(pg_enum("document_type"), nullable=False)
pipeline: Mapped[RecognitionPipeline] = mapped_column(pg_enum("recognition_pipeline"), nullable=False)
is_scan: Mapped[bool | None] = mapped_column(Boolean)
service_name: Mapped[str | None] = mapped_column(String)
service_version: Mapped[str | None] = mapped_column(String)
statements_version: Mapped[str | None] = mapped_column(String)
result_schema_version: Mapped[str | None] = mapped_column(String)
report_date: Mapped[datetime.date | None] = mapped_column(Date)
intake_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
intake_status: Mapped[DocumentStatus] = mapped_column(pg_enum("document_status"), nullable=False)
line_count: Mapped[int | None] = mapped_column(Integer)
value_count: Mapped[int | None] = mapped_column(Integer)
value_needs_review_count: Mapped[int | None] = mapped_column(Integer)
line_unreadable_count: Mapped[int | None] = mapped_column(Integer)
line_missing_count: Mapped[int | None] = mapped_column(Integer)
issue_relation_count: Mapped[int | None] = mapped_column(Integer)
issue_soft_count: Mapped[int | None] = mapped_column(Integer)
warning_count: Mapped[int | None] = mapped_column(Integer)
raw_s3_key: Mapped[str | None] = mapped_column(String)
result_s3_key: Mapped[str | None] = mapped_column(String)
upload_s3_key: Mapped[str | None] = mapped_column(String)
payload: Mapped[Any] = mapped_column(JSONB, nullable=False)
payload_schema_version: Mapped[str | None] = mapped_column(String)
class QualityOutcomeFact(UUIDPrimaryMixin, Base):
"""Исход документа (design §4.2): подтверждение (ручное/`auto_confirm`), удаление до подтверждения или
автозамена. НЕ unique на `document_id` — повторное подтверждение ОСВ и `DELETED_AFTER_CONFIRM`/
`SUPERSEDED_AFTER_CONFIRM` пишут новый факт поверх прежнего; метрики берут последний по `outcome_at`."""
__tablename__ = "quality_outcome_fact"
__table_args__ = (
Index("ix_quality_outcome_fact_document_id_outcome_at", "document_id", "outcome_at"),
Index("ix_quality_outcome_fact_outcome_outcome_at", "outcome", "outcome_at"),
{"schema": METRICS_SCHEMA},
)
document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
outcome: Mapped[QualityOutcome] = mapped_column(pg_enum("quality_outcome"), nullable=False)
outcome_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# NULL для DELETED*/SUPERSEDED* — они не входят в матрицу причин (design §6)
primary_cause: Mapped[QualityCause | None] = mapped_column(pg_enum("quality_cause"))
fixes_value_service: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
fixes_value_ours: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
fixes_code_service: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
fixes_code_ours: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
fixes_added_row_service: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
fixes_added_row_ours: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
fixes_header_service: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
fixes_header_ours: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
# всегда «наш разбор» (Excel) — не делится по источнику (design §5, последняя строка таблицы)
fixes_osv_period_account: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
# подтверждён с акцептом жёстких нарушений §7 — условие «документ клиента» (design §5, §14 05-domain-rules.md)
checks_accepted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
issue_count_at_outcome: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
report_date: Mapped[datetime.date | None] = mapped_column(Date)
confirmed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) # для агрегатов не используется
payload: Mapped[Any] = mapped_column(JSONB, nullable=False)
payload_schema_version: Mapped[str | None] = mapped_column(String)
class QualityFailureFact(UUIDPrimaryMixin, Base):
"""Технический отказ задания распознавания (`recognition_job.status` error/timeout/dlq) или неповторяемая
ошибка маппинга (`MappingFatal`, файл -> `ERROR`; design §4.3). Считается по файлам, не по документам: у
отказавшего файла нет документов и типа. `FILE_REPROCESS` создаёт новое задание — отказ первой попытки остаётся
фактом, документы удачной попытки получают свои `QualityIntakeFact`/`QualityOutcomeFact`."""
__tablename__ = "quality_failure_fact"
__table_args__ = (
Index("uq_quality_failure_fact_job_id", "job_id", unique=True),
Index("ix_quality_failure_fact_file_id_attempt", "file_id", "attempt"),
{"schema": METRICS_SCHEMA},
)
job_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
attempt: Mapped[int] = mapped_column(SmallInteger, nullable=False)
legal_entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
legal_entity_name: Mapped[str] = mapped_column(String, nullable=False)
group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
group_name: Mapped[str | None] = mapped_column(String)
pipeline: Mapped[RecognitionPipeline] = mapped_column(pg_enum("recognition_pipeline"), nullable=False)
is_scan: Mapped[bool | None] = mapped_column(Boolean)
service_name: Mapped[str | None] = mapped_column(String)
failure_kind: Mapped[QualityFailureKind] = mapped_column(pg_enum("quality_failure_kind"), nullable=False)
error_code: Mapped[str | None] = mapped_column(String)
failed_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False)

View File

@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None:
def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None:
lines = dbml_file.read_text(encoding="utf-8").splitlines()
newer = tmp_path / "data-model-v5.25.dbml"
# шапка 5.24 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.24", "v5.25") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.25 не совпадает с версией ORM 5.24: обновите модели"])
newer = tmp_path / "data-model-v5.26.dbml"
# шапка 5.25 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.25", "v5.26") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.26 не совпадает с версией ORM 5.25: обновите модели"])
def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None:
newer = tmp_path / "data-model-v5.25.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.24", "v5.25", 1), encoding="utf-8")
newer = tmp_path / "data-model-v5.26.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.25", "v5.26", 1), encoding="utf-8")
result = _run_cli(str(newer))
assert result.returncode == 1
assert result.stdout == "версия DBML 5.25 не совпадает с версией ORM 5.24: обновите модели\n"
assert result.stdout == "версия DBML 5.26 не совпадает с версией ORM 5.25: обновите модели\n"
def test_header_without_version_is_usage_error(tmp_path: Path) -> None:

View File

@ -30,6 +30,7 @@ FLAT_KEYS = {
"CORS_ORIGINS",
"OUTBOX_POLL_SECONDS",
"DB_SCHEMA",
"QUALITY_METRICS_SCHEMA",
}
@ -73,6 +74,30 @@ def test_db_schema_rejects_unsafe_identifier(env: pytest.MonkeyPatch, bad: str)
_settings()
def test_quality_metrics_schema_default_and_app(env: pytest.MonkeyPatch) -> None:
"""v5.25: `QUALITY_METRICS_SCHEMA` — по умолчанию `dkb_metrics`; в контуре —
`OPENBAO__APP__QUALITY_METRICS_SCHEMA`."""
assert _settings().QUALITY_METRICS_SCHEMA == "dkb_metrics"
env.setenv("OPENBAO__APP__QUALITY_METRICS_SCHEMA", "dkb_metrics_alt")
assert _settings().QUALITY_METRICS_SCHEMA == "dkb_metrics_alt"
@pytest.mark.parametrize("bad", ["", "Dkb", "1dkb", "dkb-alt", 'dkb"; DROP SCHEMA x; --', "pg_dkb", "a" * 64, "схема"])
def test_quality_metrics_schema_rejects_unsafe_identifier(env: pytest.MonkeyPatch, bad: str) -> None:
"""Та же проверка идентификатора, что у `DB_SCHEMA` (общий валидатор `_check_schema_identifier`)."""
env.setenv("QUALITY_METRICS_SCHEMA", bad)
with pytest.raises(ValidationError):
_settings()
def test_db_schema_and_quality_metrics_schema_must_differ(env: pytest.MonkeyPatch) -> None:
"""v5.25: совпадающие схемы — ошибка при старте (иначе таблицы модели и факты качества столкнутся по имени)."""
env.setenv("DB_SCHEMA", "dkb")
env.setenv("QUALITY_METRICS_SCHEMA", "dkb")
with pytest.raises(ValidationError, match="DB_SCHEMA и QUALITY_METRICS_SCHEMA совпадают"):
_settings()
def test_default_without_app(env: pytest.MonkeyPatch) -> None:
assert _settings().NATS_ENABLED is False

View File

@ -1,7 +1,9 @@
"""Схема БД конфигурируема (`DB_SCHEMA`, issue #38): миграции и приложение работают в схеме с нестандартным именем.
"""Схема БД конфигурируема (`DB_SCHEMA`, issue #38; `QUALITY_METRICS_SCHEMA`, v5.25): миграции и приложение работают
в схемах с нестандартными именами.
`SCHEMA` читается при импорте моделей, поэтому и Alembic, и «приложение» запускаются отдельными процессами с
`DB_SCHEMA=dkb_alt` в окружении (обычная переменная приоритетнее `.env.test`).
`SCHEMA`/`METRICS_SCHEMA` читаются при импорте моделей, поэтому и Alembic, и «приложение» запускаются отдельными
процессами с `DB_SCHEMA=dkb_alt`/`QUALITY_METRICS_SCHEMA=dkb_metrics_alt` в окружении (обычная переменная
приоритетнее `.env.test`).
"""
import os
@ -14,10 +16,11 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from tests.pg_helpers import API_DIR, alembic_async
from tests.test_models_part_07_09 import ALL_TABLES, EXPECTED_ENUM_COUNT
from tests.test_models_part_07_09 import ALL_TABLES, EXPECTED_ENUM_COUNT, METRICS_TABLES
ALT = "dkb_alt"
ALT_ENV = {"DB_SCHEMA": ALT}
METRICS_ALT = "dkb_metrics_alt"
ALT_ENV = {"DB_SCHEMA": ALT, "QUALITY_METRICS_SCHEMA": METRICS_ALT}
#: «Приложение» в схеме ALT: движок и сессия из `src.db.db` (search_path из настроек), запрос ORM без указания схемы.
APP_PROBE = """
@ -26,12 +29,14 @@ from sqlalchemy import func, select, text
from src.db.db import session_transaction
from src.models import Base
from src.models.access import AppUser
from src.models.base import METRICS_SCHEMA
async def main() -> None:
async with session_transaction() as session:
assert (await session.execute(text("SHOW search_path"))).scalar() == "dkb_alt"
assert (await session.execute(select(func.count()).select_from(AppUser))).scalar() == 0
assert Base.metadata.schema == "dkb_alt"
assert METRICS_SCHEMA == "dkb_metrics_alt"
print("PROBE_OK")
asyncio.run(main())
@ -44,7 +49,8 @@ async def _scalar(conn: AsyncConnection, sql: str, **params: str) -> Any:
@pytest.mark.integration
async def test_migrations_and_app_work_in_custom_schema(scratch_db_url: str) -> None:
"""upgrade head -> приложение читает таблицу через сессию -> downgrade base; схема `dkb` не создаётся."""
"""upgrade head -> приложение читает таблицу через сессию -> downgrade base; схемы `dkb`/`dkb_metrics` не создаются
(обе настройки — DB_SCHEMA и QUALITY_METRICS_SCHEMA, v5.25 — переопределены именами ALT/METRICS_ALT)."""
engine = create_async_engine(scratch_db_url)
try:
await alembic_async(scratch_db_url, "upgrade", "head", env=ALT_ENV)
@ -56,16 +62,27 @@ async def test_migrations_and_app_work_in_custom_schema(scratch_db_url: str) ->
)
).scalars()
)
assert tables == ALL_TABLES | {"alembic_version"}
assert tables == (ALL_TABLES - METRICS_TABLES) | {"alembic_version"}
metrics_tables = set(
(
await conn.execute(
text("SELECT table_name FROM information_schema.tables WHERE table_schema = :s"),
{"s": METRICS_ALT},
)
).scalars()
)
assert metrics_tables == METRICS_TABLES
namespaces = set((await conn.execute(text("SELECT nspname FROM pg_namespace"))).scalars())
assert "dkb" not in namespaces
assert "dkb_metrics" not in namespaces # имя по умолчанию не прошито — переопределено METRICS_ALT
enums = await _scalar(
conn,
"SELECT count(*) FROM pg_type WHERE typnamespace = CAST(:s AS regnamespace) AND typtype = 'e'",
s=ALT,
)
assert enums == EXPECTED_ENUM_COUNT
# все внешние ключи ведут в таблицы схемы ALT (строковые FK миграций тоже берут схему из настроек)
assert enums == EXPECTED_ENUM_COUNT # все enum'ы (вкл. v5.25) — в SCHEMA/ALT, не в METRICS_SCHEMA
# все внешние ключи ведут в таблицы схемы ALT (строковые FK миграций тоже берут схему из настроек);
# у таблиц METRICS_ALT FK нет вовсе (design §4), поэтому это не сужает проверку
foreign_elsewhere = await _scalar(
conn,
"SELECT count(*) FROM pg_constraint WHERE contype = 'f' AND confrelid::regclass::text NOT LIKE :p",
@ -92,5 +109,10 @@ async def test_migrations_and_app_work_in_custom_schema(scratch_db_url: str) ->
s=ALT,
)
assert left == 0
# METRICS_ALT остаётся (пустой) после отката — симметрично тому, что ALT сам не удаляется (0012/0001)
metrics_left = await _scalar(
conn, "SELECT count(*) FROM pg_class WHERE relnamespace = CAST(:s AS regnamespace)", s=METRICS_ALT
)
assert metrics_left == 0
finally:
await engine.dispose()

View File

@ -1,4 +1,4 @@
"""Enum'ы ORM против блоков `Enum {}` DBML v5.24: имена, значения и их порядок (без БД)."""
"""Enum'ы ORM против блоков `Enum {}` DBML v5.25: имена, значения и их порядок (без БД)."""
import enum
import re
@ -10,12 +10,12 @@ from src.models import DBML_VERSION
from src.models.enums import ENUMS, pg_enum
from tests.dbml_helpers import dbml_path, load_dbml
EXPECTED_ENUM_COUNT = 33
EXPECTED_ENUM_COUNT = 36
def test_dbml_version_format() -> None:
assert re.fullmatch(r"\d+\.\d+", DBML_VERSION)
assert DBML_VERSION == "5.24"
assert DBML_VERSION == "5.25"
assert f"v{DBML_VERSION}" in dbml_path().name
@ -29,6 +29,26 @@ def test_counterparty_match_kind_values() -> None:
assert [m.value for m in ENUMS["counterparty_match_kind"]] == ["INN", "NAME", "ALIAS", "MANUAL", "PROPAGATED"]
def test_quality_enums_values() -> None:
"""v5.25: три новых enum'а метрик качества — значения и порядок как в DBML."""
assert [m.value for m in ENUMS["quality_outcome"]] == [
"AUTO_CONFIRMED",
"CONFIRMED",
"DELETED",
"SUPERSEDED",
"DELETED_AFTER_CONFIRM",
"SUPERSEDED_AFTER_CONFIRM",
]
assert [m.value for m in ENUMS["quality_cause"]] == [
"SERVICE_ERROR",
"OUR_ERROR",
"CLIENT_DOCUMENT",
"WITHOUT_PARTICIPATION",
"AS_IS",
]
assert [m.value for m in ENUMS["quality_failure_kind"]] == ["SERVICE_ERROR", "TIMEOUT", "NO_ACK", "MAPPING"]
def test_enums_count_and_names_match_dbml() -> None:
dbml_enums = {e.name for e in load_dbml().enums}
assert len(dbml_enums) == EXPECTED_ENUM_COUNT

View File

@ -20,6 +20,7 @@ from sqlalchemy.exc import IntegrityError, ProgrammingError
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine
from src.models import Base
from src.models.base import METRICS_SCHEMA
from src.models.enums import ENUMS, OsvDataType
from tests.pg_helpers import alembic_async as alembic
from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db, run_alembic, service_url
@ -34,6 +35,7 @@ from tests.test_models_part_07_09 import (
ALL_TABLES,
EXPECTED_ENUM_COUNT,
EXPECTED_TABLE_COUNT,
METRICS_TABLES,
_check_active_lock_is_unique,
)
@ -41,7 +43,8 @@ V5_19 = "0007_dbml_v5_19"
V5_20 = "0008_dbml_v5_20"
V5_21 = "0009_dbml_v5_21"
V5_23 = "0010_dbml_v5_23"
HEAD = "0011_dbml_v5_24"
V5_24 = "0011_dbml_v5_24"
HEAD = "0012_dbml_v5_25"
V5_18 = "0006_dbml_v5_18"
V5_17 = "0005_dbml_v5_17"
V5_16 = "0004_dbml_v5_16"
@ -62,15 +65,19 @@ async def _scalars(conn: AsyncConnection, sql: str) -> list[Any]:
return list((await conn.execute(text(sql))).scalars())
async def tables_in_dkb(conn: AsyncConnection) -> set[str]:
async def tables_in_schema(conn: AsyncConnection, schema: str) -> set[str]:
return set(
await _scalars(
conn,
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'dkb'",
f"SELECT table_name FROM information_schema.tables WHERE table_schema = '{schema}'",
)
)
async def tables_in_dkb(conn: AsyncConnection) -> set[str]:
return await tables_in_schema(conn, "dkb")
async def enums_in_dkb(conn: AsyncConnection) -> set[str]:
return set(
await _scalars(
@ -81,31 +88,35 @@ async def enums_in_dkb(conn: AsyncConnection) -> set[str]:
)
async def schema_snapshot(conn: AsyncConnection) -> dict[str, Any]:
"""Каталог схемы dkb без служебной таблицы alembic: колонки, ограничения, индексы, enum'ы (с порядком значений)."""
async def schema_snapshot(conn: AsyncConnection, schema: str = "dkb") -> dict[str, Any]:
"""Каталог схемы `schema` без служебной таблицы alembic: колонки, ограничения, индексы, enum'ы (порядок значений).
v5.25: параметризовано схемой — вызывается и для `dkb`, и для `METRICS_SCHEMA` (своих enum'ов там нет, см. вызов
в `test_migrated_schema_equals_orm_create_all`)."""
columns = (
await conn.execute(
text(
"SELECT table_name, ordinal_position, column_name, data_type, udt_schema, udt_name, is_nullable, "
"column_default, numeric_precision, numeric_scale FROM information_schema.columns "
"WHERE table_schema = 'dkb' AND table_name <> :vt ORDER BY table_name, ordinal_position"
"WHERE table_schema = :schema AND table_name <> :vt ORDER BY table_name, ordinal_position"
),
{"vt": VERSION_TABLE},
{"vt": VERSION_TABLE, "schema": schema},
)
).all()
constraints = (
await conn.execute(
text(
"SELECT c.conname, c.conrelid::regclass::text, pg_get_constraintdef(c.oid) FROM pg_constraint c "
"WHERE c.connamespace = 'dkb'::regnamespace AND c.conrelid::regclass::text <> 'dkb.alembic_version' "
"AND c.contype <> 'n'"
)
"WHERE c.connamespace = CAST(:schema AS regnamespace) "
"AND c.conrelid::regclass::text <> :version_table AND c.contype <> 'n'"
),
{"schema": schema, "version_table": f"{schema}.alembic_version"},
)
).all()
indexes = (
await conn.execute(
text("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'dkb' AND tablename <> :vt"),
{"vt": VERSION_TABLE},
text("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = :schema AND tablename <> :vt"),
{"vt": VERSION_TABLE, "schema": schema},
)
).all()
enums = (
@ -113,8 +124,9 @@ async def schema_snapshot(conn: AsyncConnection) -> dict[str, Any]:
text(
"SELECT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) FROM pg_type t "
"JOIN pg_namespace n ON n.oid = t.typnamespace JOIN pg_enum e ON e.enumtypid = t.oid "
"WHERE n.nspname = 'dkb' GROUP BY t.typname"
)
"WHERE n.nspname = :schema GROUP BY t.typname"
),
{"schema": schema},
)
).all()
return {
@ -127,10 +139,12 @@ async def schema_snapshot(conn: AsyncConnection) -> dict[str, Any]:
async def _assert_full_model(conn: AsyncConnection) -> None:
tables = await tables_in_dkb(conn)
assert tables - {VERSION_TABLE} == ALL_TABLES
assert len(tables - {VERSION_TABLE}) == EXPECTED_TABLE_COUNT
assert tables - {VERSION_TABLE} == ALL_TABLES - METRICS_TABLES # v5.25: METRICS_TABLES живут в METRICS_SCHEMA
assert len(tables - {VERSION_TABLE}) == EXPECTED_TABLE_COUNT - len(METRICS_TABLES)
metrics_tables = await tables_in_schema(conn, METRICS_SCHEMA)
assert metrics_tables == METRICS_TABLES
enums = await enums_in_dkb(conn)
assert enums == set(ENUMS)
assert enums == set(ENUMS) # v5.25: enum'ы фактов качества тоже в dkb (METRICS_SCHEMA своих типов не имеет)
assert len(enums) == EXPECTED_ENUM_COUNT
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [HEAD]
@ -152,8 +166,11 @@ async def _insert_office(conn: AsyncConnection) -> uuid.UUID:
async def _truncate_model_tables(engine: AsyncEngine) -> None:
# v5.25: METRICS_TABLES исключены — они в METRICS_SCHEMA (не dkb), и в этом файле их никто не заполняет
async with engine.begin() as conn:
await conn.execute(text("TRUNCATE " + ", ".join(f"dkb.{table}" for table in sorted(ALL_TABLES)) + " CASCADE"))
await conn.execute(
text("TRUNCATE " + ", ".join(f"dkb.{table}" for table in sorted(ALL_TABLES - METRICS_TABLES)) + " CASCADE")
)
@pytest.mark.integration
@ -1149,7 +1166,9 @@ async def test_autogenerate_finds_nothing_after_upgrade(scratch_db_url: str) ->
"compare_type": True,
"compare_server_default": True,
"include_schemas": True,
"include_name": lambda name, type_, _parents: name == "dkb" if type_ == "schema" else True,
"include_name": lambda name, type_, _parents: (
name in ("dkb", METRICS_SCHEMA) if type_ == "schema" else True
),
"version_table_schema": "dkb",
},
)
@ -1168,7 +1187,10 @@ async def test_autogenerate_finds_nothing_after_upgrade(scratch_db_url: str) ->
@pytest.mark.integration
async def test_migrated_schema_equals_orm_create_all(scratch_db_url: str) -> None:
"""Каталог после миграции = каталог `create_all` ORM: колонки, default, ограничения, индексы, enum (имена и DDL)."""
"""Каталог после миграции = каталог `create_all` ORM: колонки, default, ограничения, индексы, enum (имена и DDL).
v5.25: `create_all` сам схему METRICS_SCHEMA не создаёт (в отличие от CREATE SCHEMA-условного кода миграции) —
в референсной БД её нужно завести явно, иначе create_all падает на первой таблице фактов."""
await alembic(scratch_db_url, "upgrade", "head")
reference_name = f"armcore_scratch_{uuid.uuid4().hex[:12]}"
reference_url = await create_scratch_db(reference_name)
@ -1177,15 +1199,21 @@ async def test_migrated_schema_equals_orm_create_all(scratch_db_url: str) -> Non
try:
async with reference.begin() as conn:
await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb"))
await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {METRICS_SCHEMA}"))
await conn.run_sync(Base.metadata.create_all)
async with migrated.connect() as conn:
got = await schema_snapshot(conn)
got_metrics = await schema_snapshot(conn, METRICS_SCHEMA)
async with reference.connect() as conn:
expected = await schema_snapshot(conn)
expected_metrics = await schema_snapshot(conn, METRICS_SCHEMA)
assert got["enums"] == expected["enums"]
assert got["columns"] == expected["columns"]
assert got["constraints"] == expected["constraints"]
assert got["indexes"] == expected["indexes"]
assert got_metrics["columns"] == expected_metrics["columns"]
assert got_metrics["constraints"] == expected_metrics["constraints"]
assert got_metrics["indexes"] == expected_metrics["indexes"]
finally:
await migrated.dispose()
await reference.dispose()
@ -1242,7 +1270,9 @@ async def test_migrated_schema_behaviour(scratch_db_url: str) -> None:
@pytest.mark.integration
async def test_upgrade_into_preprovisioned_schema_without_database_create(scratch_db_url: str) -> None:
"""Схему dkb выдала платформа во владение роли миграций, права CREATE на БД у роли нет: цикл миграций проходит.
"""Схемы dkb/dkb_metrics выдала платформа во владение роли миграций, права CREATE на БД у роли нет: цикл миграций
проходит (v5.25: METRICS_SCHEMA нужна той же роли, которая создаёт её таблицы в 0012 — платформа должна выдать
обе схемы, если своего права CREATE у роли нет).
PostgreSQL проверяет право CREATE на БД раньше, чем IF NOT EXISTS, поэтому схема создаётся только при отсутствии.
"""
@ -1260,6 +1290,7 @@ async def test_upgrade_into_preprovisioned_schema_without_database_create(scratc
async with scratch.connect() as conn:
await conn.execute(text(f'REVOKE CREATE ON DATABASE "{scratch_url.database}" FROM PUBLIC'))
await conn.execute(text(f'CREATE SCHEMA dkb AUTHORIZATION "{role}"'))
await conn.execute(text(f'CREATE SCHEMA {METRICS_SCHEMA} AUTHORIZATION "{role}"'))
can_create = await conn.scalar(
text("SELECT has_database_privilege(:role, :db, 'CREATE')"),
{"role": role, "db": scratch_url.database},
@ -1274,6 +1305,10 @@ async def test_upgrade_into_preprovisioned_schema_without_database_create(scratc
await _assert_full_model(conn)
owner = await conn.scalar(text("SELECT nspowner::regrole::text FROM pg_namespace WHERE nspname = 'dkb'"))
assert owner == role
metrics_owner = await conn.scalar(
text("SELECT nspowner::regrole::text FROM pg_namespace WHERE nspname = :s"), {"s": METRICS_SCHEMA}
)
assert metrics_owner == role
finally:
if role_created:
async with scratch.connect() as conn:
@ -1285,7 +1320,11 @@ async def test_upgrade_into_preprovisioned_schema_without_database_create(scratc
def test_offline_sql_creates_schema_before_version_table() -> None:
"""Офлайн-режим (`upgrade head --sql`): условный CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов."""
"""Офлайн-режим (`upgrade head --sql`): условный CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов.
v5.25: 0012 тем же условным способом создаёт METRICS_SCHEMA — до своих таблиц фактов, но после dkb/alembic_version
(METRICS_SCHEMA не нужна для бутстрапа версий, см. docstring 0012/alembic/env.py).
"""
output = run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql")
schema_at = output.index(
"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'dkb') "
@ -1295,6 +1334,14 @@ def test_offline_sql_creates_schema_before_version_table() -> None:
assert schema_at < output.index("CREATE TABLE dkb.alembic_version")
assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')")
assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output
metrics_schema_at = output.index(
f"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '{METRICS_SCHEMA}') "
f"THEN CREATE SCHEMA {METRICS_SCHEMA}; END IF; END $$"
)
assert metrics_schema_at > schema_at # METRICS_SCHEMA — в 0012, последней; dkb создаётся до первой миграции
assert metrics_schema_at < output.index(f"CREATE TABLE {METRICS_SCHEMA}.quality_intake_fact")
# Версия head пишется UPDATE'ами по полной цепочке миграций.
for new_version, old_version in (
(V5_14, PREVIOUS),
@ -1306,7 +1353,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None:
(V5_20, V5_19),
(V5_21, V5_20),
(V5_23, V5_21),
(HEAD, V5_23),
(V5_24, V5_23),
(HEAD, V5_24),
):
assert (
f"UPDATE dkb.alembic_version SET version_num='{new_version}' "

View File

@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.schema import CreateIndex, CreateTable
from src.models import Base
from src.models.base import METRICS_SCHEMA, SCHEMA
from tests.dbml_parity import find_problems
PART_01_03_TABLES = {
@ -43,7 +44,9 @@ def orm_tables() -> dict[str, Table]:
def test_part_01_03_tables_registered_in_metadata() -> None:
assert PART_01_03_TABLES <= set(orm_tables()) # общий состав metadata — в тестах последней по номеру части
assert all(t.schema == "dkb" for t in Base.metadata.sorted_tables)
# v5.25: кроме таблиц фактов качества (своя схема METRICS_SCHEMA, не SCHEMA) — см. per-таблицу проверку схемы в
# find_problems/test_all_48_tables_registered_and_match_dbml_names
assert all(t.schema in (SCHEMA, METRICS_SCHEMA) for t in Base.metadata.sorted_tables)
def test_every_table_and_index_compiles_for_postgresql() -> None:
@ -82,6 +85,7 @@ async def test_create_all_on_scratch_db(scratch_db_url: str) -> None:
try:
async with engine.begin() as conn:
await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb"))
await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {METRICS_SCHEMA}")) # v5.25: quality_*_fact
await conn.run_sync(Base.metadata.create_all)
async with engine.connect() as conn:

View File

@ -13,6 +13,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from src.models import Base
from src.models.base import METRICS_SCHEMA, SCHEMA
from src.models.directories import LegalEntity
from src.models.enums import (
DocumentStatus,
@ -52,7 +53,8 @@ def orm_tables() -> dict[str, Table]:
def test_part_04_06_tables_registered_in_metadata() -> None:
assert PART_01_03_TABLES | PART_04_06_TABLES <= set(orm_tables()) # полный состав и счёт — в тесте части 07–09
assert all(t.schema == "dkb" for t in Base.metadata.sorted_tables)
# v5.25: кроме таблиц фактов качества (своя схема METRICS_SCHEMA) — см. test_models_part_01_03
assert all(t.schema in (SCHEMA, METRICS_SCHEMA) for t in Base.metadata.sorted_tables)
def test_orm_matches_dbml() -> None:
@ -99,6 +101,7 @@ async def test_create_all_and_behavior_on_scratch_db(scratch_db_url: str) -> Non
try:
async with engine.begin() as conn:
await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb"))
await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {METRICS_SCHEMA}")) # v5.25: quality_*_fact
await conn.run_sync(Base.metadata.create_all)
async with engine.connect() as conn:

View File

@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from src.models import Base
from src.models.access import AppUser
from src.models.base import METRICS_SCHEMA, SCHEMA
from src.models.consolidation import Consolidation, ConsolidationLock, ConsolidationVersion
from src.models.directories import CompanyGroup, RegionalOffice
from src.models.enums import (
@ -47,10 +48,16 @@ PART_07_09_TABLES = {
"ex_snapshot_member",
"ex_snapshot_item",
"required_document_rule",
# quality_facts (v5.25, схема METRICS_SCHEMA — не SCHEMA, см. test_all_48_tables_registered_and_match_dbml_names)
"quality_intake_fact",
"quality_outcome_fact",
"quality_failure_fact",
}
ALL_TABLES = PART_01_03_TABLES | PART_04_06_TABLES | PART_07_09_TABLES
EXPECTED_TABLE_COUNT = 45
EXPECTED_ENUM_COUNT = 33
EXPECTED_TABLE_COUNT = 48
EXPECTED_ENUM_COUNT = 36
#: Таблицы в METRICS_SCHEMA — единственное исключение из «все таблицы в SCHEMA» (v5.25).
METRICS_TABLES = {"quality_intake_fact", "quality_outcome_fact", "quality_failure_fact"}
def test_part_07_09_tables_are_new() -> None:
@ -112,18 +119,18 @@ def test_ex_snapshot_has_no_fk_to_consolidation() -> None:
# ---------------------------------------------------------------- итоговая полнота моделей (43 таблицы)
def test_all_43_tables_registered_and_match_dbml_names() -> None:
"""Состав metadata == таблицы DBML v5.17 (имена буквально), всего 43, все в схеме dkb."""
def test_all_48_tables_registered_and_match_dbml_names() -> None:
"""Состав metadata == таблицы DBML v5.25 (имена буквально), всего 48; все в SCHEMA, кроме METRICS_TABLES (v5.25)."""
tables = list(Base.metadata.sorted_tables)
assert len(tables) == EXPECTED_TABLE_COUNT
assert all(t.schema == "dkb" for t in tables)
assert all(t.schema == (METRICS_SCHEMA if t.name in METRICS_TABLES else SCHEMA) for t in tables)
dbml_names = {t.name for t in load_dbml().tables}
assert len(dbml_names) == EXPECTED_TABLE_COUNT
assert {t.name for t in tables} == dbml_names
assert ALL_TABLES == dbml_names # разбиение по частям покрывает модель без пропусков и дублей
def test_all_28_enums_defined() -> None:
def test_all_36_enums_defined() -> None:
assert len(ENUMS) == EXPECTED_ENUM_COUNT
assert set(ENUMS) == {e.name for e in load_dbml().enums}
@ -138,17 +145,21 @@ def test_orm_matches_dbml_for_all_43_tables() -> None:
@pytest.mark.integration
async def test_create_all_43_tables_on_scratch_db(scratch_db_url: str) -> None:
"""`create_all` всех 43 таблиц; 28 enum-типов в dkb; partial unique `consolidation_lock` работает поведением."""
"""`create_all` всех 48 таблиц (45 в dkb + 3 в METRICS_SCHEMA); 36 enum-типов в dkb; partial unique
`consolidation_lock` работает поведением."""
engine = create_async_engine(scratch_db_url)
try:
async with engine.begin() as conn:
await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb"))
await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {METRICS_SCHEMA}")) # v5.25: quality_*_fact
await conn.run_sync(Base.metadata.create_all)
async with engine.connect() as conn:
tables = await conn.run_sync(lambda c: set(inspect(c).get_table_names(schema="dkb")))
assert len(tables) == EXPECTED_TABLE_COUNT
assert tables == ALL_TABLES
assert len(tables) == EXPECTED_TABLE_COUNT - len(METRICS_TABLES)
assert tables == ALL_TABLES - METRICS_TABLES
metrics_tables = await conn.run_sync(lambda c: set(inspect(c).get_table_names(schema=METRICS_SCHEMA)))
assert metrics_tables == METRICS_TABLES
enums = {
row[0]
for row in await conn.execute(

File diff suppressed because it is too large Load Diff

View File

@ -222,3 +222,9 @@ PDF, точный вид выгрузки, разбиение на задачи.
| 27.10.2026 | Код добавленной строки ищется среди четырёхзначных токенов сырого ответа ИС, извлечённых при приёме, без переноса адаптера шага 1 в ядро (builder check) | адаптер живёт в scan-gateway; совпадение с суммой даёт «наш разбор» — по правилу «спорное — нам» | метрика сервиса чуть лучше реальной на добавленных строках |
| 27.10.2026 | Технические отказы — по файлам, своей долей; фильтр по типу документа на них не действует; «уникальные отчёты» — счётчик, не режим (builder check) | у отказавшего файла нет документов и типа; смешивать файлы и документы в одном знаменателе нельзя | доля отказов не складывается с долями матрицы в 100 % |
| 27.10.2026 | ОСВ — без построчного снимка, только статусы, предупреждения, отказы, правки периода/счёта | правок сумм ОСВ в ядре нет | при появлении правок ОСВ — доработка снимка |
| 05.11.2026 | Task 1: `api/scripts/dbml_parity.py` (схема таблицы — через явный allowlist `METRICS_TABLES`, не единственная константа `SCHEMA`), `api/scripts/check_dbml_vs_orm.py` (фиксирует `QUALITY_METRICS_SCHEMA=dkb_metrics` в окружении проверки, как уже делает для `DB_SCHEMA`) и `api/alembic/env.py` (`include_name` признаёт обе схемы) — доработаны под вторую схему; план задачи 1 предполагал, что `check_dbml_vs_orm.py` «скорее всего» не потребует правок | без этого `make dbml-check` безусловно падал бы на `quality_intake_fact: схема: ожидалось dkb / фактически dkb_metrics» для всех трёх таблиц фактов; это ровно тот структурный сюрприз, о котором бриф просил не обходить тихо, а чинить или сообщать — чинить оказалось дешевле и находится в рамках задачи (`make dbml-check` — приёмный критерий шага 7) | без правки вторая схема логически невозможна — любой PR с `dkb_metrics` падает на CI/проверке |
| 05.11.2026 | Три новых ORM-класса (`QualityIntakeFact/OutcomeFact/FailureFact`) — на ОБЩЕМ `Base`/`Base.metadata` (как и всё остальное), схема переопределена точечно через `__table_args__ = {"schema": METRICS_SCHEMA}`; второй `DeclarativeBase` НЕ заводился | ровно второй вариант, предложенный самим бри­фом («…или аналог существующего паттерна»); так `Base.metadata` остаётся единой точкой для `check_dbml_vs_orm.py`/`alembic/env.py`/`create_all` — отдельная metadata потребовала бы протащить её через все три места отдельно | при введении третьей схемы в будущем придётся повторить тот же приём, а не скопировать общий механизм |
| 05.11.2026 | Три новых enum-типа (`quality_outcome`, `quality_cause`, `quality_failure_kind`) — значения живут в схеме `SCHEMA` (`dkb`), НЕ в `METRICS_SCHEMA`, хотя используют их только таблицы `dkb_metrics` | `pg_enum()` (src/models/enums.py) без правок — единственный источник схемы enum'а; разводить enum'ы по двум схемам потребовало бы параметра схемы в фабрике без текущей пользы (Superset читает только таблицы, не типы) | таблица `dkb_metrics` зависит от типов в `dkb`; перенос схемы метрик на другой сервер БД без `dkb` невозможен без правки (сейчас design §10 этого не требует) |
| 05.11.2026 | Откат миграции 0012 НЕ удаляет схему `dkb_metrics` (оставляет её пустой), симметрично тому, что `SCHEMA` (`dkb`) никогда не удаляется в `env.py`/0001 | тест `test_upgrade_into_preprovisioned_schema_without_database_create` (api/tests/test_migrations.py) показал конкретный отказ: роль без `CREATE` на базу не могла бы создать `dkb_metrics` заново при повторном `upgrade head`, если бы платформа выдавала схему готовой, а откат её удалял; симметрия с `dkb` — тот же риск, то же решение | если отладочный откат до base оставляет пустую схему `dkb_metrics` — незначительный мусор, не ошибка |
| 05.11.2026 | В план (`docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md`) добавлена строка `**Ветка:**` (`feat/190-ocr-metrics`, worktree, issue, Jira) — без неё хук `require-plan.sh` блокировал любую правку в `api/src/**`, так как не находил имя ветки ни в одном файле `docs/superpowers/plans/*.md` | во всех остальных активных планах эта строка есть (сверено на `2026-10-28-corrections-table-ui.md`); её отсутствие — пропуск на этапе написания плана, не содержательное решение | минимальный — чисто метаданные плана |
| 05.11.2026 | Коммит задачи 1 включает больше файлов, чем перечислено в брифе (`Makefile`, `README.md`, `api/.env.example`, `api/.env.test`, `api/alembic/env.py`, `api/scripts/check_dbml_vs_orm.py`, `api/scripts/dbml_parity.py`, `api/src/models/base.py`, `api/src/models/__init__.py`, восемь файлов `api/tests/test_*.py`) | все они требуют правки для согласованности версии v5.25 (имя DBML-файла в `Makefile`/README, `HEAD`-ревизия и допущение «всё в dkb» в тестах) или для поддержки второй схемы; список `git add` в брифе соответствовал более узкому ожидаемому дифу до того, как выяснилось, что `check_dbml_vs_orm.py` и тесты миграций не знают о второй схеме | коммит по списку брифа оставил бы `make test`/`make lint`/`make dbml-check` красными — хуже, чем более широкий, но согласованный коммит |