Срез 2c: загрузка файлов ОСВ, цикл маппинга результата в document/osv_entry, API документов, DBML v5.15 #15

Merged
aep merged 35 commits from feat/14-slice-2c-osv-ingest into main 2026-10-06 00:56:15 +03:00
34 changed files with 274 additions and 68 deletions
Showing only changes of commit ab606dc59a - Show all commits

View File

@ -89,7 +89,7 @@
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
## Правила репозитория
- ORM 1:1 с `../docs/model/data-model-v5.14.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- ORM 1:1 с `../docs/model/data-model-v5.15.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц.
- Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR.

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.14.dbml
DBML ?= ../../docs/model/data-model-v5.15.dbml
.PHONY: venv lint test test-integration dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev

View File

@ -500,7 +500,7 @@ email без учёта регистра проверяет только сер
| `make lint` | `ruff check`, `ruff format --check`, `mypy src scripts` |
| `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен |
| `make test-integration` | интеграционные тесты: `pytest -m integration`, нужны PostgreSQL (и NATS) |
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.14.dbml` |
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.15.dbml` |
| `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate |
| `make check` | быстрая проверка без Docker: `lint` + `test` |
| `make docker-build` | сборка образа: `docker build -t arm-core .` |
@ -523,7 +523,7 @@ email без учёта регистра проверяет только сер
### Проверка ORM и DBML
Модель данных — `../docs/model/data-model-v5.14.dbml`. ORM обязана совпадать с ней один в один:
Модель данных — `../docs/model/data-model-v5.15.dbml`. ORM обязана совпадать с ней один в один:
```bash
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
@ -546,7 +546,7 @@ make migration-cycle # проверить цикл upgr
```
api/
run_server.py точка входа (uvicorn; хост и порт — из настроек)
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14)
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15)
seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
seed/dev/ dev-данные: филиалы и пользователи по ролям
scripts/ check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников)
@ -619,7 +619,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.14.dbml`](../docs/model/data-model-v5.14.dbml) — модель данных;
[`../docs/model/data-model-v5.15.dbml`](../docs/model/data-model-v5.15.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

@ -0,0 +1,42 @@
"""DBML v5.15: osv_data_type += NU, app_user.email в нижнем регистре (design D-13, решение №13).
`ALTER TYPE … ADD VALUE` необратим — downgrade пересоздаёт тип osv_data_type со значениями v5.14 (строк `osv_entry`
с `data_type = 'NU'` быть не должно; иначе downgrade падает на USING — это осознанно, как в 0002).
Приведение email к нижнему регистру — данные, не структура: при дублях по регистру `UPDATE` нарушит UNIQUE и миграция
упадёт (осознанно — слияние учёток решает человек); downgrade регистр не возвращает.
Revision ID: 0003_dbml_v5_15
Revises: 0002_dbml_v5_14
Create Date: 2026-10-05
"""
from collections.abc import Sequence
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "0003_dbml_v5_15"
down_revision: str | Sequence[str] | None = "0002_dbml_v5_14"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
SCHEMA = "dkb"
OSV_DATA_TYPE_V5_14 = ("BU", "CURRENCY", "QUANTITY", "SUM")
def upgrade() -> None:
# порядок значений — как в DBML v5.15 (сверка enum'ов сравнивает упорядоченные списки)
op.execute(f"ALTER TYPE {SCHEMA}.osv_data_type ADD VALUE IF NOT EXISTS 'NU' AFTER 'BU'")
op.execute(f"UPDATE {SCHEMA}.app_user SET email = lower(email) WHERE email <> lower(email)")
def downgrade() -> None:
op.execute(f"ALTER TYPE {SCHEMA}.osv_data_type RENAME TO osv_data_type_old")
postgresql.ENUM(*OSV_DATA_TYPE_V5_14, name="osv_data_type", schema=SCHEMA).create(op.get_bind(), checkfirst=False)
op.execute(
f"ALTER TABLE {SCHEMA}.osv_entry ALTER COLUMN data_type TYPE {SCHEMA}.osv_data_type "
f"USING data_type::text::{SCHEMA}.osv_data_type"
)
op.execute(f"DROP TYPE {SCHEMA}.osv_data_type_old")

View File

@ -2,7 +2,7 @@
Запуск из `api/`:
python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.14.dbml
python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.15.dbml
Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`.

View File

@ -56,7 +56,7 @@ from sqlalchemy.sql.elements import TextClause
from sqlalchemy.sql.functions import FunctionElement
SCHEMA = "dkb"
DBML_FILE_NAME = "data-model-v5.14.dbml"
DBML_FILE_NAME = "data-model-v5.15.dbml"
ORM = "ORM"
DBML = "DBML"
ABSENT = "нет"

View File

@ -164,6 +164,7 @@ async def _apply_chart_of_accounts(session: AsyncSession, data: SeedData, stat:
async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableStat, link_stat: TableStat) -> None:
"""Dev-пользователи: upsert `app_user` по `email`, затем `user_role` по (user_id, role_id).
Email из файла нормализуется (`strip().lower()`, DBML v5.15) — и при сверке с БД, и при вставке.
`created_at` ставится при вставке, `updated_at` — при вставке и при изменении данных (триггера в БД нет);
неизменившиеся строки не трогаются. Назначения ролей только добавляются: роль, убранная из файла, остаётся
назначенной (удалений нет). `external_id` не задаётся (SSO-субъект dev-пользователя неизвестен).
@ -175,7 +176,7 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt
}
role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()}
stored = {
row.email: row
(row.email or "").lower(): row
for row in (
await session.execute(
select(
@ -192,6 +193,7 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt
}
user_ids: dict[str, Any] = {}
for user in data.users:
email = user.email.strip().lower()
values = {
"full_name": user.full_name,
"employee_no": user.employee_no,
@ -199,17 +201,17 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt
"business_segment": user.business_segment,
"is_active": user.is_active,
}
row = stored.get(user.email)
row = stored.get(email)
if row is None:
user_stat.inserted += 1
user_ids[user.email] = uuid.uuid4()
user_ids[email] = uuid.uuid4()
await session.execute(
pg_insert(AppUser).values(
id=user_ids[user.email], email=user.email, **values, created_at=func.now(), updated_at=func.now()
id=user_ids[email], email=email, **values, created_at=func.now(), updated_at=func.now()
)
)
continue
user_ids[user.email] = row.id
user_ids[email] = row.id
if any(getattr(row, name) != value for name, value in values.items()):
user_stat.updated += 1
await session.execute(update(AppUser).where(AppUser.id == row.id).values(**values, updated_at=func.now()))
@ -219,7 +221,7 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt
new_rows = []
for user in data.users:
for role_code in user.role_codes:
pair = (user_ids[user.email], role_ids[role_code])
pair = (user_ids[user.email.strip().lower()], role_ids[role_code])
if pair in existing:
link_stat.unchanged += 1
else:

View File

@ -6,7 +6,7 @@
Код роли (`role.code`) здесь не читается. Право (`require`) отвечает «может ли вообще», policy — «какие именно
записи»; объект вне видимости вызывающий превращает в 404 с тем же телом, что у несуществующего.
ASSUMPTION(OQ-86): ЗО (`app_user.business_segment`) в Срезе 1 не фильтрует — у ГК и ЮЛ нет поля сегмента (DBML v5.14).
ASSUMPTION(OQ-86): ЗО (`app_user.business_segment`) в Срезе 1 не фильтрует — у ГК и ЮЛ нет поля сегмента (DBML v5.15).
"""
import uuid

View File

@ -1,4 +1,4 @@
"""ORM-модели. Источник истины — docs/model/data-model-v5.14.dbml; заполняется в Срезе 0.
"""ORM-модели. Источник истины — docs/model/data-model-v5.15.dbml; заполняется в Срезе 0.
Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic).
"""
@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k
from .base import Base, UUIDPrimaryMixin
#: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm).
DBML_VERSION = "5.14"
DBML_VERSION = "5.15"
__all__ = [
"DBML_VERSION",

View File

@ -1,4 +1,4 @@
"""Часть 02 DBML v5.14 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`)."""
"""Часть 02 DBML v5.15 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`)."""
import datetime
import uuid

View File

@ -1,4 +1,4 @@
"""Часть 03 DBML v5.14 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные)."""
"""Часть 03 DBML v5.15 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные)."""
import datetime
import uuid

View File

@ -1,4 +1,4 @@
"""Часть 09 DBML v5.14 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов.
"""Часть 09 DBML v5.15 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов.
`audit_event` — append-only, высокообъёмная (before/after — jsonb). Срезы ЕХ самодостаточны (v5.12): FK на
консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `ex_snapshot.source_version_id`

View File

@ -6,7 +6,7 @@ from sqlalchemy import MetaData
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column
#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.14).
#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.15).
SCHEMA = "dkb"
#: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же

View File

@ -1,4 +1,4 @@
"""Часть 07 DBML v5.14 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы.
"""Часть 07 DBML v5.15 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы.
Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из
`osv_entry` × `consolidation_member` × `elimination_rule`. Версия после VERIFIED — read-only (ТЗ 6.10).

View File

@ -1,4 +1,4 @@
"""Часть 01 DBML v5.14 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
"""Часть 01 DBML v5.15 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
import datetime
import uuid

View File

@ -1,6 +1,6 @@
"""Enum'ы модели данных DBML v5.14 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок.
"""Enum'ы модели данных DBML v5.15 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок.
Источник истины — docs/model/data-model-v5.14.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке
Источник истины — docs/model/data-model-v5.15.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке
DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются.
Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима).
Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`.
@ -150,6 +150,7 @@ class OsvDataType(enum.StrEnum):
"""DBML `osv_data_type`."""
BU = "BU"
NU = "NU"
CURRENCY = "CURRENCY"
QUANTITY = "QUANTITY"
SUM = "SUM"

View File

@ -1,4 +1,4 @@
"""Часть 04 DBML v5.14 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания.
"""Часть 04 DBML v5.15 «Файлы / распознавание»: реестр 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` создаёт

View File

@ -1,4 +1,4 @@
"""Часть 08 DBML v5.14 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
"""Часть 08 DBML v5.15 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО
замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`.

View File

@ -1,4 +1,4 @@
"""Часть 05 DBML v5.14 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5).
"""Часть 05 DBML v5.15 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5).
Первичка не мутируется: сырые значения хранятся как пришли, правка аналитика — рядом
(`is_modified`, `original_values`, `modified_by/at` + audit_event(RECOGNIZED_DATA_EDIT)).

View File

@ -1,4 +1,4 @@
"""Часть 06 DBML v5.14 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
"""Часть 06 DBML v5.15 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением.
Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету.

View File

@ -1,6 +1,6 @@
"""Схемы API администрирования `/admin/*` (spec users-admin, design D-8): пользователи, роли, матрица прав.
Поля — как в DBML v5.14 (`app_user`, `role`, `permission`); `external_id` через API не задаётся (появится при первом
Поля — как в DBML v5.15 (`app_user`, `role`, `permission`); `external_id` через API не задаётся (появится при первом
входе через РАИСА), поэтому тела запросов запрещают лишние поля.
"""

View File

@ -1,4 +1,4 @@
"""Схемы API справочников (spec directories-api): филиалы, ГК, ЮЛ/ИП; поля — как в DBML v5.14."""
"""Схемы API справочников (spec directories-api): филиалы, ГК, ЮЛ/ИП; поля — как в DBML v5.15."""
import datetime
import uuid

View File

@ -82,9 +82,9 @@ class UsersAdminService:
return user, office, roles[user.id]
async def create_user(self, data: UserCreate) -> AppUser:
"""Новый пользователь с ролями; email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён."""
email = str(data.email).strip()
if await repo.email_taken(self._session, email.lower()):
"""Новый пользователь с ролями; email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится в нижнем."""
email = str(data.email).strip().lower()
if await repo.email_taken(self._session, email):
raise ConflictError("EMAIL_EXISTS", "Пользователь с таким email уже существует")
await self._check_office(data.regional_office_id)
codes = _unique(data.roles)

View File

@ -91,7 +91,7 @@ async def test_create_user_with_role_gives_role_permissions_in_me(
business_segment="DKB",
roles=["KA_RF"],
)
assert created["email"] == "Ivan.Petrov@example.com" # регистр локальной части сохранён (EmailStr: домен строчными)
assert created["email"] == "ivan.petrov@example.com" # v5.15: email хранится в нижнем регистре
assert created["roles"] == ["KA_RF"]
assert created["is_active"] is True
assert created["regional_office"] == {
@ -123,7 +123,7 @@ async def test_create_user_with_role_gives_role_permissions_in_me(
assert (event.section, event.object_type, event.object_id) == (AuditSection.USERS, "app_user", user_id)
assert event.user_id == await _user_id(seeded, ADMIN)
assert event.after_value == {
"email": "Ivan.Petrov@example.com",
"email": "ivan.petrov@example.com",
"full_name": "Иван Петров",
"employee_no": "E-1",
"regional_office_id": str(rostov.id),
@ -138,6 +138,23 @@ async def test_create_user_with_role_gives_role_permissions_in_me(
assert me.json()["permissions"] == reference["permissions"]
@pytest.mark.integration
async def test_create_user_stores_email_in_lower_case(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""v5.15: `Qa1@Example.com` хранится как `qa1@example.com`; `/me` находит пользователя по любому регистру."""
admin = await auth_client(ADMIN)
office_id = str((await _office(seeded, ROSTOV)).id)
created = await _create(
admin, email="Qa1@Example.com", full_name="Ку", regional_office_id=office_id, roles=["KA_RF"]
)
assert created["email"] == "qa1@example.com"
stored = (await seeded.execute(select(AppUser.email).where(AppUser.id == uuid.UUID(created["id"])))).scalar_one()
assert stored == "qa1@example.com"
for variant in ("Qa1@Example.com", "qa1@example.com", "QA1@EXAMPLE.COM"):
response = await (await auth_client(variant)).get("/me")
assert response.status_code == HTTP_OK
assert response.json()["email"] == "qa1@example.com"
@pytest.mark.integration
async def test_create_user_email_in_other_case_conflicts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
admin = await auth_client(ADMIN)

View File

@ -138,10 +138,10 @@ async def test_user_email_race(
@pytest.mark.integration
async def test_user_email_race_with_other_case_is_not_caught(
async def test_user_email_race_with_other_case_is_caught(
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Известное ограничение: DB-ограничение чувствительно к регистру — дубликат в другом регистре гонку не ловит."""
"""v5.15: email хранится строчным, поэтому UNIQUE ловит гонку и с дублем в другом регистре."""
office = (await seeded.execute(select(RegionalOffice).limit(1))).scalar_one()
admin = await auth_client(ADMIN)
body = {"email": "Race.Case@example.com", "full_name": "Гонка Регистр", "regional_office_id": str(office.id)}
@ -150,7 +150,9 @@ async def test_user_email_race_with_other_case_is_not_caught(
response = await admin.post(USERS, json={**body, "email": "race.case@example.com"})
assert response.status_code == HTTP_CREATED # индекс по точному значению: дубль в другом регистре проходит
_assert_coded(
response, "EMAIL_EXISTS", "Пользователь с таким email уже существует", "race.case@example.com", "Гонка"
)
@pytest.mark.integration

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.15.dbml"
# шапка 5.15 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.14", "v5.15") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.15 не совпадает с версией ORM 5.14: обновите модели"])
newer = tmp_path / "data-model-v5.16.dbml"
# шапка 5.16 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.15", "v5.16") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.16 не совпадает с версией ORM 5.15: обновите модели"])
def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None:
newer = tmp_path / "data-model-v5.15.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.14", "v5.15", 1), encoding="utf-8")
newer = tmp_path / "data-model-v5.16.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.15", "v5.16", 1), encoding="utf-8")
result = _run_cli(str(newer))
assert result.returncode == 1
assert result.stdout == "версия DBML 5.15 не совпадает с версией ORM 5.14: обновите модели\n"
assert result.stdout == "версия DBML 5.16 не совпадает с версией ORM 5.15: обновите модели\n"
def test_header_without_version_is_usage_error(tmp_path: Path) -> None:

View File

@ -1,4 +1,4 @@
"""Enum'ы ORM против блоков `Enum {}` DBML v5.14: имена, значения и их порядок (без БД)."""
"""Enum'ы ORM против блоков `Enum {}` DBML v5.15: имена, значения и их порядок (без БД)."""
import enum
import re
@ -15,10 +15,15 @@ EXPECTED_ENUM_COUNT = 27
def test_dbml_version_format() -> None:
assert re.fullmatch(r"\d+\.\d+", DBML_VERSION)
assert DBML_VERSION == "5.14"
assert DBML_VERSION == "5.15"
assert f"v{DBML_VERSION}" in dbml_path().name
def test_osv_data_type_has_nu_after_bu() -> None:
"""v5.15: налоговый учёт (`NU`) идёт сразу после `BU` — порядок DBML."""
assert [m.value for m in ENUMS["osv_data_type"]] == ["BU", "NU", "CURRENCY", "QUANTITY", "SUM"]
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

@ -1,5 +1,5 @@
"""Миграции 0001 (DBML v5.13) и 0002 (v5.14) на PostgreSQL 16: полный цикл, откат на шаг, пустой autogenerate,
схема = схеме ORM.
"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15) на PostgreSQL 16: полный цикл, откат на шаг, пустой
autogenerate, схема = схеме ORM.
Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация
ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из
@ -18,11 +18,16 @@ from sqlalchemy import Connection, make_url, text
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine
from src.models import Base
from src.models.enums import ENUMS
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
from tests.test_models_part_01_03 import _check_related_group_member_uniqueness
from tests.test_models_part_04_06 import _check_nulls_not_distinct, _make_file_with_job
from tests.test_models_part_04_06 import (
_add_osv_entry,
_check_nu_entry_inserts,
_check_nulls_not_distinct,
_make_file_with_job,
)
from tests.test_models_part_07_09 import (
ALL_TABLES,
EXPECTED_ENUM_COUNT,
@ -30,7 +35,8 @@ from tests.test_models_part_07_09 import (
_check_active_lock_is_unique,
)
HEAD = "0002_dbml_v5_14"
HEAD = "0003_dbml_v5_15"
V5_14 = "0002_dbml_v5_14"
PREVIOUS = "0001_dbml_v5_13"
VERSION_TABLE = "alembic_version"
FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job"
@ -113,6 +119,22 @@ async def _assert_full_model(conn: AsyncConnection) -> None:
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [HEAD]
async def _insert_user(conn: AsyncConnection, office_id: uuid.UUID, email: str | None) -> uuid.UUID:
"""Сырой INSERT пользователя (на ревизии 0002 ORM v5.15 не подходит); NOT NULL — `full_name`, филиал."""
user_id = uuid.uuid4()
await conn.execute(
text("INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) VALUES (:id, :email, 'Тест', :ro)"),
{"id": user_id, "email": email, "ro": office_id},
)
return user_id
async def _insert_office(conn: AsyncConnection) -> uuid.UUID:
office_id = uuid.uuid4()
await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:id, 'ГО')"), {"id": office_id})
return office_id
async def _truncate_model_tables(engine: AsyncEngine) -> None:
async with engine.begin() as conn:
await conn.execute(text("TRUNCATE " + ", ".join(f"dkb.{table}" for table in sorted(ALL_TABLES)) + " CASCADE"))
@ -154,8 +176,8 @@ async def test_full_cycle_upgrade_downgrade_upgrade(scratch_db_url: str) -> None
@pytest.mark.integration
async def test_downgrade_one_step(scratch_db_url: str) -> None:
"""Сценарий «Откат на один шаг»: head → 0001 возвращает схему v5.13 (без kind, старый индекс и audit_action)."""
async def test_downgrade_to_v5_13(scratch_db_url: str) -> None:
"""Откат head → 0001 (два шага) возвращает схему v5.13 (без kind, старый индекс и audit_action)."""
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", "head")
@ -199,6 +221,73 @@ async def test_downgrade_one_step(scratch_db_url: str) -> None:
await engine.dispose()
@pytest.mark.integration
async def test_downgrade_one_step(scratch_db_url: str) -> None:
"""Сценарий «Откат на один шаг»: head → 0002 убирает `NU` из `osv_data_type`; регистр email не возвращается."""
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", "head")
async with engine.begin() as conn:
[labels] = await _scalars(conn, "SELECT enum_range(NULL::dkb.osv_data_type)::text[]")
assert labels == ["BU", "NU", "CURRENCY", "QUANTITY", "SUM"]
await _insert_user(conn, await _insert_office(conn), "lower@x.ru")
await engine.dispose() # пул asyncpg кэширует OID типов; downgrade пересоздаёт enum
await alembic(scratch_db_url, "downgrade", V5_14)
async with engine.connect() as conn:
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [V5_14]
[labels] = await _scalars(conn, "SELECT enum_range(NULL::dkb.osv_data_type)::text[]")
assert labels == ["BU", "CURRENCY", "QUANTITY", "SUM"]
assert await _scalars(conn, "SELECT email FROM dkb.app_user") == ["lower@x.ru"]
# колонка осталась типизированной новым типом: старые значения читаются, остальные схемы не сломаны
assert await _scalars(
conn,
"SELECT udt_name FROM information_schema.columns WHERE table_schema = 'dkb' "
"AND table_name = 'osv_entry' AND column_name = 'data_type'",
) == ["osv_data_type"]
await engine.dispose()
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
await _assert_full_model(conn)
finally:
await engine.dispose()
@pytest.mark.integration
async def test_downgrade_one_step_fails_while_nu_rows_exist(scratch_db_url: str) -> None:
"""Строка `osv_entry` с `data_type = 'NU'` делает откат невозможным (осознанно, как USING в 0002)."""
await alembic(scratch_db_url, "upgrade", "head")
engine = create_async_engine(scratch_db_url)
try:
async with AsyncSession(engine, expire_on_commit=False) as session:
chain = await _make_file_with_job(session)
await _add_osv_entry(session, chain, OsvDataType.NU, "91")
await session.commit()
with pytest.raises(AssertionError):
await alembic(scratch_db_url, "downgrade", V5_14)
finally:
await engine.dispose()
@pytest.mark.integration
async def test_upgrade_0003_lowercases_existing_emails(scratch_db_url: str) -> None:
"""0003 приводит `app_user.email` к нижнему регистру; уже строчные и NULL не затрагиваются."""
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", V5_14)
async with engine.begin() as conn:
office_id = await _insert_office(conn)
ids = [await _insert_user(conn, office_id, email) for email in ("Email@X.RU", "already@x.ru", None)]
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
rows = (await conn.execute(text("SELECT id, email FROM dkb.app_user"))).all()
assert {tuple(r) for r in rows} == {(ids[0], "email@x.ru"), (ids[1], "already@x.ru"), (ids[2], None)}
finally:
await engine.dispose()
@pytest.mark.integration
async def test_upgrade_0002_keeps_membership_and_fills_kind(scratch_db_url: str) -> None:
"""0002 пересоздаёт `related_group_member`: строки v5.13 сохраняются, `kind` = `related_group.kind` группы."""
@ -352,6 +441,7 @@ async def test_migrated_schema_behaviour(scratch_db_url: str) -> None:
chain = await _make_file_with_job(session) # замыкает FK-цикл, проверяет server_default
await session.commit()
await _check_nulls_not_distinct(session, chain)
await _check_nu_entry_inserts(session, chain) # v5.15: osv_data_type содержит NU
await _truncate_model_tables(engine)
async with AsyncSession(engine, expire_on_commit=False) as session:
await _check_active_lock_is_unique(session)
@ -414,11 +504,12 @@ 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
# с двумя ревизиями версия head пишется UPDATE'ом после 0001 — офлайн-SQL доходит до head
assert (
f"UPDATE dkb.alembic_version SET version_num='{HEAD}' WHERE dkb.alembic_version.version_num = '{PREVIOUS}'"
in output
)
# версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 — офлайн-SQL доходит до head
for new_version, old_version in ((V5_14, PREVIOUS), (HEAD, V5_14)):
assert (
f"UPDATE dkb.alembic_version SET version_num='{new_version}' "
f"WHERE dkb.alembic_version.version_num = '{old_version}'" in output
)
def test_cluster_commands_use_service_database() -> None:

View File

@ -1,4 +1,4 @@
"""ORM частей 01–03 DBML v5.14 (справочники, доступ, учёт): чётность с DBML без БД и create_all на PostgreSQL."""
"""ORM частей 01–03 DBML v5.15 (справочники, доступ, учёт): чётность с DBML без БД и create_all на PostgreSQL."""
import datetime
import uuid

View File

@ -1,4 +1,4 @@
"""ORM частей 04–06 DBML v5.14 (файлы, ОСВ, отчётность): чётность с DBML без БД и create_all на PostgreSQL."""
"""ORM частей 04–06 DBML v5.15 (файлы, ОСВ, отчётность): чётность с DBML без БД и create_all на PostgreSQL."""
import datetime
import decimal
@ -18,6 +18,7 @@ from src.models.enums import (
DocumentType,
FileStatus,
FileType,
OsvDataType,
RecognitionPipeline,
RecognitionStatus,
S3BucketRole,
@ -124,6 +125,7 @@ async def test_create_all_and_behavior_on_scratch_db(scratch_db_url: str) -> Non
await session.commit()
await _check_nulls_not_distinct(session, chain)
await _check_decimal_roundtrip(session, chain)
await _check_nu_entry_inserts(session, chain)
finally:
await engine.dispose()
@ -195,6 +197,31 @@ async def _check_nulls_not_distinct(session: AsyncSession, chain: Chain) -> None
await session.rollback()
async def _add_osv_entry(session: AsyncSession, chain: Chain, data_type: OsvDataType, account: str) -> uuid.UUID:
"""Документ ОСВ по счёту `account` и одна строка `osv_entry` с заданным `data_type`; flush без commit."""
document = _document(chain, DocumentType.OSV, account)
session.add(document)
await session.flush()
entry = OsvEntry(
recognition_job_id=chain.job_id,
document_id=document.id,
owner_legal_entity_id=chain.entity_id,
row_number=1,
data_type=data_type,
)
session.add(entry)
await session.flush()
return entry.id
async def _check_nu_entry_inserts(session: AsyncSession, chain: Chain) -> None:
"""v5.15, сценарий «Строка налогового учёта»: `data_type = 'NU'` вставляется и читается обратно."""
entry_id = await _add_osv_entry(session, chain, OsvDataType.NU, "91")
await session.commit()
stored = (await session.execute(select(OsvEntry.data_type).where(OsvEntry.id == entry_id))).scalar_one()
assert stored is OsvDataType.NU
async def _check_decimal_roundtrip(session: AsyncSession, chain: Chain) -> None:
"""Сумма numeric(18,2) читается обратно как `Decimal` без потери точности (деньги — не float)."""
document = _document(chain, DocumentType.OSV, "62")

View File

@ -1,4 +1,4 @@
"""ORM частей 07–09 DBML v5.14 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц."""
"""ORM частей 07–09 DBML v5.15 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц."""
import datetime
@ -112,7 +112,7 @@ def test_ex_snapshot_has_no_fk_to_consolidation() -> None:
def test_all_43_tables_registered_and_match_dbml_names() -> None:
"""Состав metadata == таблицы DBML v5.14 (имена буквально), всего 43, все в схеме dkb."""
"""Состав metadata == таблицы DBML v5.15 (имена буквально), всего 43, все в схеме dkb."""
tables = list(Base.metadata.sorted_tables)
assert len(tables) == EXPECTED_TABLE_COUNT
assert all(t.schema == "dkb" for t in tables)

View File

@ -74,6 +74,7 @@ async def test_dev_flag_loads_branches_and_one_user_per_role(db_session: AsyncSe
users = (await db_session.scalars(select(AppUser))).all()
assert {u.email for u in users} == DEV_EMAILS
assert all(u.email == u.email.lower() for u in users) # v5.15: email в нижнем регистре
assert all(u.is_active and u.external_id is None and u.full_name for u in users)
assert all(u.business_segment is BusinessSegment.DKB for u in users)
assert all(u.created_at is not None and u.updated_at is not None for u in users)
@ -140,6 +141,24 @@ async def test_changed_user_data_updates_row_and_keeps_old_role_link(db_session:
assert await _count(db_session, UserRole) == USER_COUNT + 1
@pytest.mark.integration
async def test_seed_stores_email_in_lower_case_and_stays_idempotent(db_session: AsyncSession, tmp_path: Path) -> None:
"""v5.15: email из файла seed хранится как `strip().lower()`; повторный прогон не плодит строк."""
seed_dir = _copy_seed(tmp_path)
users_file = seed_dir / "dev" / "users.yaml"
text = users_file.read_text(encoding="utf-8")
assert "email: expert@armka.local" in text
users_file.write_text(text.replace("email: expert@armka.local", "email: Expert@Armka.LOCAL"), encoding="utf-8")
await run_seed(db_session, dev=True, seed_dir=seed_dir)
emails = set((await db_session.scalars(select(AppUser.email))).all())
assert emails == DEV_EMAILS
report = await run_seed(db_session, dev=True, seed_dir=seed_dir)
assert (report.tables["app_user"].inserted, report.tables["app_user"].updated) == (0, 0)
assert await _count(db_session, AppUser) == USER_COUNT
@pytest.mark.integration
@pytest.mark.parametrize(
("old", "new", "expected"),

View File

@ -50,9 +50,9 @@
- Миграция (стиль 0002): `upgrade`: `ALTER TYPE dkb.osv_data_type ADD VALUE IF NOT EXISTS 'NU' AFTER 'BU'`; `UPDATE dkb.app_user SET email = lower(email) WHERE email <> lower(email)`; `downgrade`: пересоздание `osv_data_type` со значениями v5.14 (`RENAME TYPE` → `CREATE TYPE` → `ALTER COLUMN osv_entry.data_type TYPE … USING data_type::text::dkb.osv_data_type` → `DROP TYPE`), email не возвращается.
- `OsvDataType`: `BU, NU, CURRENCY, QUANTITY, SUM` (порядок DBML).
- [ ] **Step 1: Тесты.** `test_enums` ожидает `"5.15"` и 5 значений `osv_data_type`; `test_migrations`: `HEAD`, `test_downgrade_one_step` → после `downgrade 0002_dbml_v5_14` `enum_range(NULL::dkb.osv_data_type)` без `NU`; тест данных: вставить `app_user` с `Email@X.RU` на ревизии 0002 → после `upgrade head` email `email@x.ru`; `test_seed_dev`: email dev-пользователей в нижнем регистре; `test_api_admin`: создание с `Qa1@Example.com` → хранится `qa1@example.com`, `/me` по любому регистру.
- [ ] **Step 2:** падает → **Step 3:** реализация + docs-правки → **Step 4:** `make dbml-check` пуст; `make migration-cycle`; `make check`; `pytest -m integration tests/test_seed_dev.py tests/test_api_admin.py -q` → PASS.
- [ ] **Step 5: Commit** `feat(model): DBML v5.15 — osv_data_type NU, email в нижнем регистре, миграция 0003` (+ коммит в docs `docs: entities-v5.15, guide, ссылки на DBML v5.15`).
- [x] **Step 1: Тесты.** `test_enums` ожидает `"5.15"` и 5 значений `osv_data_type`; `test_migrations`: `HEAD`, `test_downgrade_one_step` → после `downgrade 0002_dbml_v5_14` `enum_range(NULL::dkb.osv_data_type)` без `NU`; тест данных: вставить `app_user` с `Email@X.RU` на ревизии 0002 → после `upgrade head` email `email@x.ru`; `test_seed_dev`: email dev-пользователей в нижнем регистре; `test_api_admin`: создание с `Qa1@Example.com` → хранится `qa1@example.com`, `/me` по любому регистру.
- [x] **Step 2:** падает → **Step 3:** реализация + docs-правки → **Step 4:** `make dbml-check` пуст; `make migration-cycle`; `make check`; `pytest -m integration tests/test_seed_dev.py tests/test_api_admin.py -q` → PASS.
- [x] **Step 5: Commit** `feat(model): DBML v5.15 — osv_data_type NU, email в нижнем регистре, миграция 0003` (+ коммит в docs `docs: entities-v5.15, guide, ссылки на DBML v5.15`).
---

View File

@ -6,7 +6,7 @@
## 1. Модель и инфраструктура
- [ ] 1.1 **DBML v5.15, миграция 0003.** `OsvDataType.NU` (после BU), миграция `0003_dbml_v5_15` (`ALTER TYPE … ADD VALUE 'NU'
- [x] 1.1 **DBML v5.15, миграция 0003.** `OsvDataType.NU` (после BU), миграция `0003_dbml_v5_15` (`ALTER TYPE … ADD VALUE 'NU'
AFTER 'BU'`, `UPDATE app_user SET email = lower(email)`; downgrade пересоздаёт enum); `DBML_VERSION = "5.15"`, `Makefile`,
`dbml_parity.DBML_FILE_NAME`, `HEAD` в `test_migrations`, `test_enums`, `test_check_dbml_script` (фиктивная 5.16); нормализация
email в нижний регистр в seed (`_apply_users`) и в `users_admin.create_user/update_user`; тесты seed/admin на регистр.