feat(#190): метрики качества первички (AURORA-1759) #229
@ -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`, как и
|
||||
|
||||
2
Makefile
2
Makefile
@ -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
|
||||
|
||||
|
||||
@ -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) — план срезов;
|
||||
|
||||
@ -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 -> значение по умолчанию. Неизвестное имя — ошибка при старте.
|
||||
|
||||
@ -6,6 +6,7 @@ DEBUG=true
|
||||
|
||||
# Схема БД: как в .env.example (тесты используют ту же настройку, что и приложение)
|
||||
DB_SCHEMA=dkb
|
||||
QUALITY_METRICS_SCHEMA=dkb_metrics
|
||||
|
||||
# Как в .env.example (тесты сервер не запускают)
|
||||
SERVER__HOST=127.0.0.1
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
282
api/alembic/versions/0012_dbml_v5_25.py
Normal file
282
api/alembic/versions/0012_dbml_v5_25.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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__`.
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
139
api/src/models/quality_facts.py
Normal file
139
api/src/models/quality_facts.py
Normal 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)
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}' "
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
1097
docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md
Normal file
1097
docs/superpowers/plans/2026-11-05-quality-metrics-arm-core.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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` красными — хуже, чем более широкий, но согласованный коммит |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user