diff --git a/CLAUDE.md b/CLAUDE.md index 4eacc03..7114dfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,7 +136,7 @@ (`get_request_audit`: пользователь, IP, user-agent). - **Справочники и права (Срез 1)** — видимость только через `domain/policy.py` (`VisibilityScope`: ГО — всё, филиал — свои ГК/ЮЛ/ГСК/ГСЗ и монозаёмщики), код роли в логике не читается; `domain/inn.py` — ИНН строкой (ЮЛ 10, ИП 12 цифр, - без контрольных разрядов, OQ-88); `domain/aliases.py` — `normalized_alias` по §3; `domain/directory_import.py` — импорт + без контрольных разрядов, OQ-88); `domain/kpp.py` — КПП (9 символов, необязателен, у ИП запрещён, `INVALID_KPP`, v5.17); `domain/aliases.py` — `normalized_alias` по §3; `domain/directory_import.py` — импорт ЕЦП (§13, зависимость `openpyxl` для xlsx; допущения `ASSUMPTION(OQ-87…91)`). Администрирование — `services/users_admin.py` (`LAST_ADMIN` с блокировкой строки права). Порядок: маршрут (`require(code)`) → policy → сервис → аудит. - **Аутентификация** — `core/auth.py` (проверка JWT), `src/api/dependencies/auth.py` (`current_user`, `require(code)`); @@ -156,7 +156,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.16.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. +- ORM 1:1 с `../docs/model/data-model-v5.17.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17`); `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. diff --git a/Makefile b/Makefile index 02222ac..be5b416 100644 --- a/Makefile +++ b/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.16.dbml +DBML ?= ../../docs/model/data-model-v5.17.dbml .PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev diff --git a/README.md b/README.md index bc0bff7..c4f7adf 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ PostgreSQL 16, core NATS без JetStream), `web/` — фронтенд (React + Спецификация — `openspec/changes/archive/2026-10-07-slice-3b-statements-api/`; разделы ниже: «API файлов и документов», «Формы ББ/ОФР (Срез 3b)», «Ошибки API». -Срез 4 («консолидация»): схема по DBML v5.16, версии консолидации ГК за период, периметр, блокировка «взять в работу», +Срез 4 («консолидация»): схема по DBML v5.17, версии консолидации ГК за период, периметр, блокировка «взять в работу», статусы, привязка подтверждённых документов к версии, чек-лист, сводная ОСВ, ручное сопоставление контрагента, экспорт xlsx. Спецификация — `openspec/changes/slice-4-consolidation/`; раздел ниже: «Консолидация (Срез 4)». @@ -241,9 +241,9 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `DELETE /directories/related-groups/{group_id}` | `DIRECTORY_DELETE` | удалить **пустую** ГСК/ГСЗ (`GROUP_NOT_EMPTY`) | | `POST /directories/related-groups/{group_id}/members` | `DIRECTORY_EDIT` | добавить ЮЛ (только из той же ГК) | | `DELETE /directories/related-groups/{group_id}/members/{legal_entity_id}` | `DIRECTORY_EDIT` | исключить ЮЛ (членство закрывается, не удаляется) | -| `GET /directories/legal-entities` | `DIRECTORY_VIEW` | видимые ЮЛ/ИП: `company_group_id`, `outside_group`, `inn`, `q`, `closed`, `limit`/`offset`; в каждой записи `documents_count` | -| `POST /directories/legal-entities` | `DIRECTORY_EDIT` | создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12) | -| `GET`, `PATCH /directories/legal-entities/{entity_id}` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | карточка (с членствами, алиасами и `documents_count`) / изменить | +| `GET /directories/legal-entities` | `DIRECTORY_VIEW` | видимые ЮЛ/ИП: `company_group_id`, `outside_group`, `inn`, `q` (подстрока имени, ИНН или КПП), `closed`, `limit`/`offset`; в каждой записи `documents_count` | +| `POST /directories/legal-entities` | `DIRECTORY_EDIT` | создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12; `kpp` необязателен, 9 символов, у ИП запрещён — `INVALID_KPP`) | +| `GET`, `PATCH /directories/legal-entities/{entity_id}` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | карточка (с членствами, алиасами и `documents_count`) / изменить (в т.ч. `kpp`; `null` очищает; прежнее значение уходит в историю) | | `POST /directories/legal-entities/{entity_id}/move` | `DIRECTORY_EDIT` | перевести в другую ГК с датой `effective_date` ≥ сегодня (дата по Москве, Europe/Moscow); членства ГСК/ГСЗ закрываются | | `POST /directories/legal-entities/{entity_id}/withdraw` | `DIRECTORY_EDIT` | вывести из ГК: `CLOSE` (с `close_date`) или `REMOVE_FROM_GROUP` | | `GET /directories/legal-entities/{entity_id}/history` | `DIRECTORY_VIEW` | история ЮЛ/ИП, новые первыми | @@ -303,16 +303,17 @@ curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bear | `Группа компаний` | да | имя ГК | | `Организация` | да | имя ЮЛ/ИП | | `Тип` | да | `ЮЛ` или `ИП` (без регистра) | +| `КПП` | нет | v5.17: 9 символов (4 цифры, 2 цифры/латинские A–Z, 3 цифры); пусто допустимо; у ИП непустой КПП — `INVALID_KPP`, строка пропускается; колонки нет — не ошибка | | `Базовый РФ ГК в CRM` | нет | филиал по точному имени | | `ГСК`, `ГСЗ` | нет | флаги «входит в ГСК» / «входит в ГСЗ» | Нет любой обязательной колонки — файл отвергается целиком (422 `MISSING_COLUMNS` с перечнем в поле `missing`). **Параметр `on_existing`.** `skip` (по умолчанию) — существующие ГК/ЮЛ не меняются; `update` — обновляются имя и -филиал ГК и имя/тип ЮЛ (со снимками в историю и аудитом). +филиал ГК и имя/тип/КПП ЮЛ (непустой КПП из файла; пустая ячейка существующий КПП не стирает; со снимками в историю и аудитом). **Коды отчёта (§13.2).** У каждой строки ровно один итоговый код, плюс сопутствующие события той же строки. -Пропуск строки (учитывается в `errors`): `UNSUPPORTED_TYPE`, `INVALID_INN`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`, +Пропуск строки (учитывается в `errors`): `UNSUPPORTED_TYPE`, `INVALID_INN`, `INVALID_KPP`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`, `GK_NAME_CONFLICT` (разные имена одной ГК в файле или пустой `iD ГК`). Итоговые: `LE_CREATED`, `LE_UPDATED`, `LE_EXISTS`. Сопутствующие: `RF_CREATED`, `GK_CREATED`, `GK_UPDATED`, `GK_EXISTS`, `GSK_CREATED`, `GSZ_CREATED`, `MEMBER_ADDED`, `UNKNOWN_FLAG`. @@ -809,6 +810,7 @@ email без учёта регистра проверяет только сер | `OFFICE_NOT_FOUND` | 422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) | | `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом | | `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр; в правке формы (`PATCH …/statement`) — не из 10 или 12 цифр | +| `INVALID_KPP` | 422 | КПП не из 9 символов формата `NNNN[0-9A-Z]{2}NNN` или КПП у ИП (DBML v5.17, OQ-31) | | `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней (по Москве) | | `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) | | `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ | @@ -932,7 +934,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.16.dbml` | +| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.17.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | @@ -984,7 +986,7 @@ queue group `arm-core`, подписчики группы получают со ### Проверка ORM и DBML -Модель данных — `../docs/model/data-model-v5.16.dbml`. ORM обязана совпадать с ней один в один: +Модель данных — `../docs/model/data-model-v5.17.dbml`. ORM обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно @@ -1007,7 +1009,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, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16) + alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17) rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/dev/ dev-данные: филиалы и пользователи по ролям @@ -1091,7 +1093,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.16.dbml`](../docs/model/data-model-v5.16.dbml) — модель данных; + [`../docs/model/data-model-v5.17.dbml`](../docs/model/data-model-v5.17.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) — план срезов; diff --git a/api/alembic/versions/0005_dbml_v5_17.py b/api/alembic/versions/0005_dbml_v5_17.py new file mode 100644 index 0000000..5a04c80 --- /dev/null +++ b/api/alembic/versions/0005_dbml_v5_17.py @@ -0,0 +1,32 @@ +"""DBML v5.17: legal_entity.kpp и legal_entity_history.kpp (КПП юридического лица; OQ-31, RSHB/arm-core#45). + +Колонки nullable (КПП у ЮЛ необязателен, у ИП запрещён — проверяет ядро), без индексов и уникальности: уникален +только `inn`. Колонки добавляются последними (в ORM и DBML они тоже последние); существующие записи остаются с +`kpp IS NULL`, backfill не нужен. + +Revision ID: 0005_dbml_v5_17 +Revises: 0004_dbml_v5_16 +Create Date: 2026-10-07 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0005_dbml_v5_17" +down_revision: str | Sequence[str] | None = "0004_dbml_v5_16" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "dkb" +TABLES = ("legal_entity", "legal_entity_history") + + +def upgrade() -> None: + for table in TABLES: + op.execute(f"ALTER TABLE {SCHEMA}.{table} ADD COLUMN kpp varchar") + + +def downgrade() -> None: + for table in TABLES: + op.execute(f"ALTER TABLE {SCHEMA}.{table} DROP COLUMN kpp") diff --git a/api/scripts/check_dbml_vs_orm.py b/api/scripts/check_dbml_vs_orm.py index 01edc31..1ef8150 100644 --- a/api/scripts/check_dbml_vs_orm.py +++ b/api/scripts/check_dbml_vs_orm.py @@ -2,7 +2,7 @@ Запуск из `api/`: - python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.16.dbml + python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.17.dbml Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`. diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index 6450506..88f5755 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -56,7 +56,7 @@ from sqlalchemy.sql.elements import TextClause from sqlalchemy.sql.functions import FunctionElement SCHEMA = "dkb" -DBML_FILE_NAME = "data-model-v5.16.dbml" +DBML_FILE_NAME = "data-model-v5.17.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index 9fcaaff..fab81a4 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -280,6 +280,7 @@ async def _apply_company_groups(session: AsyncSession, data: SeedData, report: S LegalEntity.entity_type, LegalEntity.company_group_id, LegalEntity.regional_office_id, + LegalEntity.kpp, ) ) ).all() @@ -342,6 +343,8 @@ async def _apply_company_groups(session: AsyncSession, data: SeedData, report: S "company_group_id": group_id, "regional_office_id": office_id, } + if entity.kpp is not None: # seed КПП не стирает: пустое значение в данных = «не задано» + values["kpp"] = entity.kpp stored_entity = entities.get(entity.inn) if stored_entity is None: entity_id = uuid.uuid4() diff --git a/api/scripts/seed_lib/loaders.py b/api/scripts/seed_lib/loaders.py index 545f15f..186f16e 100644 --- a/api/scripts/seed_lib/loaders.py +++ b/api/scripts/seed_lib/loaders.py @@ -23,8 +23,10 @@ from scripts.seed_lib.model import ( account_codes, ) from scripts.seed_lib.readers import bool_field, checked_records, ensure_unique, place, read_yaml, str_field +from src.common.exceptions import DomainValidationError from src.domain.aliases import normalize_alias from src.domain.inn import INN_LENGTH, normalize_inn +from src.domain.kpp import validate_kpp from src.models.enums import ( AccountKind, AccountRowType, @@ -354,7 +356,7 @@ def _load_dev_users(data: SeedData, dev_dir: Path, seed_dir: Path) -> None: data.users = users -_ENTITY_KEYS = {"inn", "name", "entity_type", "aliases"} +_ENTITY_KEYS = {"inn", "name", "entity_type", "aliases", "kpp"} _RELATED_KEYS = {"kind", "name", "members"} @@ -371,7 +373,7 @@ def _dev_legal_entities(record: dict[str, Any], where: str) -> list[LegalEntityS entities: list[LegalEntitySeed] = [] for item in items: if not isinstance(item, dict) or not {"inn", "name", "entity_type"} <= set(item) or set(item) - _ENTITY_KEYS: - raise SeedError(f"{where}: ЮЛ описывается полями inn, name, entity_type и необязательным aliases") + raise SeedError(f"{where}: ЮЛ описывается полями inn, name, entity_type и необязательными aliases, kpp") inn = normalize_inn(str(item["inn"])) try: entity_type = EntityType(str(item["entity_type"])) @@ -380,6 +382,10 @@ def _dev_legal_entities(record: dict[str, Any], where: str) -> list[LegalEntityS length = INN_LENGTH[entity_type] if len(inn) != length or not inn.isdigit(): raise SeedError(f"{where}: ИНН «{inn}» {entity_type.value} — ровно {length} цифр") + try: + kpp = validate_kpp(None if item.get("kpp") is None else str(item["kpp"]), entity_type) + except DomainValidationError as error: + raise SeedError(f"{where}: ИНН {inn}: КПП: {error.message}") from None aliases = _str_list(item.get("aliases", []), "aliases", f"{where}: ИНН {inn}") normalized = [normalize_alias(alias) for alias in aliases] if "" in normalized: @@ -387,7 +393,7 @@ def _dev_legal_entities(record: dict[str, Any], where: str) -> list[LegalEntityS if len(set(normalized)) != len(normalized): raise SeedError(f"{where}: ИНН {inn}: алиасы повторяются после нормализации") entities.append( - LegalEntitySeed(inn=inn, name=str(item["name"]), entity_type=entity_type, aliases=tuple(aliases)) + LegalEntitySeed(inn=inn, name=str(item["name"]), entity_type=entity_type, aliases=tuple(aliases), kpp=kpp) ) return entities diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py index ee59cac..175a847 100644 --- a/api/scripts/seed_lib/model.py +++ b/api/scripts/seed_lib/model.py @@ -116,6 +116,7 @@ class LegalEntitySeed: name: str entity_type: EntityType aliases: tuple[str, ...] + kpp: str | None = None # v5.17: КПП, если известен (в dev-данных пока не задан — значений не выдумываем) @dataclass(frozen=True) diff --git a/api/seed/dev/company_groups.yaml b/api/seed/dev/company_groups.yaml index 6176be7..40a3ab9 100644 --- a/api/seed/dev/company_groups.yaml +++ b/api/seed/dev/company_groups.yaml @@ -6,6 +6,8 @@ # Счастливочная. Повторный импорт выгрузки после seed даёт GK_EXISTS/LE_EXISTS. # - владельцы форм пакета ДКБ (fixtures/statements-xlsx): Унагранде Компани и Групп, НПЗ, Межениновская. ОСВ-организации # ДКБ без ИНН в шапках (Цимлянский, НКЗ, Новоросметалл, Приманыческий, Агрокомплекс) не заводятся — OQ-136. +# КПП: необязательный ключ `kpp` у ЮЛ (9 символов; у ИП нельзя). В данных ЕЦП и fixtures КПП ЮЛ нет, поэтому не задан ни у кого — +# значений не выдумываем; seed ранее записанный КПП не стирает. # Алиасы — как контрагент записан в ОСВ другой компании группы (06-test-data.md §1) и как организация названа в шапке. # Филиал ГК, которого нет в core/dev regional_office(s).yaml, создаётся как региональный филиал (is_head_office: false). # Натуральные ключи upsert: ГК — name, ЮЛ — inn, ГСК/ГСЗ — name внутри ГК, алиас — нормализованный текст, член — пара diff --git a/api/src/api/endpoints/directories/legal_entities.py b/api/src/api/endpoints/directories/legal_entities.py index 4cf3952..901c17c 100644 --- a/api/src/api/endpoints/directories/legal_entities.py +++ b/api/src/api/endpoints/directories/legal_entities.py @@ -41,7 +41,7 @@ async def list_legal_entities( service: LegalEntityServiceDep, query: Annotated[LegalEntityListQuery, Query()], ) -> Page[LegalEntityOut]: - """Видимые ЮЛ: фильтры `company_group_id`, `outside_group`, `inn` (точно), `q` (name/ИНН), `closed`; по имени.""" + """Видимые ЮЛ: фильтры `company_group_id`, `outside_group`, `inn` (точно), `q` (имя/ИНН/КПП), `closed`.""" page = PageParams(limit=query.limit, offset=query.offset) flt = LegalEntityFilter.model_validate(query.model_dump(include=set(LegalEntityFilter.model_fields))) items, total = await service.page(flt, page) @@ -59,7 +59,7 @@ async def list_legal_entities( "", response_model=LegalEntityOut, status_code=status.HTTP_201_CREATED, dependencies=EDIT, summary="Создать ЮЛ/ИП" ) async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut: - """Создаёт ЮЛ/ИП; без `company_group_id` — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12.""" + """Создаёт ЮЛ/ИП; без ГК — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12; КПП необязателен, у ИП запрещён.""" entity = await service.create(data) return await service.to_out(entity, 0) @@ -76,7 +76,7 @@ async def get_legal_entity(entity_id: uuid.UUID, service: LegalEntityServiceDep) async def update_legal_entity( entity_id: uuid.UUID, data: LegalEntityUpdate, service: LegalEntityServiceDep ) -> LegalEntityOut: - """Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`).""" + """Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`/`kpp`; прежние значения — в историю (`UPDATE`).""" entity = await service.update(entity_id, data) return await service.to_out(entity, await service.documents_count(entity_id)) diff --git a/api/src/domain/directory_import.py b/api/src/domain/directory_import.py index 58ae3c9..4b1c7f9 100644 --- a/api/src/domain/directory_import.py +++ b/api/src/domain/directory_import.py @@ -27,14 +27,16 @@ from openpyxl.utils.exceptions import InvalidFileException from src.common.exceptions import DomainValidationError from src.domain.inn import normalize_inn, validate_inn +from src.domain.kpp import validate_kpp from src.models.enums import EntityType, RelatedGroupKind class ImportCode(enum.StrEnum): - """Коды строк отчёта (§13.2). Итоговые: пять кодов пропуска и `LE_*`; остальные — сопутствующие события.""" + """Коды строк отчёта (§13.2). Итоговые: шесть кодов пропуска и `LE_*`; остальные — сопутствующие события.""" UNSUPPORTED_TYPE = "UNSUPPORTED_TYPE" INVALID_INN = "INVALID_INN" + INVALID_KPP = "INVALID_KPP" DUPLICATE_IN_FILE = "DUPLICATE_IN_FILE" LE_IN_OTHER_GROUP = "LE_IN_OTHER_GROUP" GK_NAME_CONFLICT = "GK_NAME_CONFLICT" @@ -61,10 +63,11 @@ class OnExisting(enum.StrEnum): # --------------------------------------------------------------------------- формат файла (§13.1) REQUIRED_COLUMNS = ("инн", "idгк", "группакомпаний", "организация", "тип") -OPTIONAL_COLUMNS = ("базовыйрфгквcrm", "гск", "гсз") +OPTIONAL_COLUMNS = ("базовыйрфгквcrm", "гск", "гсз", "кпп") # Исходные имена колонок — для перечня в `MISSING_COLUMNS` COLUMN_TITLES: dict[str, str] = { "инн": "ИНН", + "кпп": "КПП", "idгк": "iD ГК", "базовыйрфгквcrm": "Базовый РФ ГК в CRM", "группакомпаний": "Группа компаний", @@ -73,6 +76,8 @@ COLUMN_TITLES: dict[str, str] = { "гсз": "ГСЗ", "тип": "Тип", } +# Синонимы заголовков (нормализованные) → каноническое имя колонки: данные, не код (v5.17, OQ-31) +HEADER_SYNONYMS: dict[str, str] = {"kpp": "кпп"} FLAG_GSK = "входитвгск" FLAG_GSZ = "входитвгсз" @@ -139,6 +144,7 @@ class ImportRow: gsk_flag: str gsz_flag: str type_raw: str + kpp: str = "" # v5.17: необязательная колонка «КПП»; пусто — колонки нет или ячейка пуста def read_rows(filename: str, content: bytes) -> list[ImportRow]: @@ -237,6 +243,7 @@ def _import_row(number: int, cells: list[str], columns: dict[str, int]) -> Impor gsk_flag=cell("гск"), gsz_flag=cell("гсз"), type_raw=cell("тип"), + kpp=cell("кпп"), ) @@ -244,7 +251,7 @@ def _header_columns(cells: list[str]) -> dict[str, int]: """Индексы известных колонок (первое вхождение); нет обязательной → `MISSING_COLUMNS` с исходными именами.""" columns: dict[str, int] = {} for index, value in enumerate(cells): - name = normalize_header(value) + name = HEADER_SYNONYMS.get(normalize_header(value), normalize_header(value)) if name in COLUMN_TITLES and name not in columns: columns[name] = index missing = [COLUMN_TITLES[name] for name in REQUIRED_COLUMNS if name not in columns] @@ -283,6 +290,7 @@ class EntitySnap: group_id: uuid.UUID | None gsk_group_id: uuid.UUID | None gsz_group_id: uuid.UUID | None + kpp: str | None = None # v5.17 @dataclass @@ -326,15 +334,17 @@ class CreateEntity: entity_type: EntityType group_id: uuid.UUID office_id: uuid.UUID | None + kpp: str | None = None # v5.17: нормализованный КПП из файла @dataclass(frozen=True) class UpdateEntity: - """`None` в поле — без изменений.""" + """`None` в поле — без изменений; КПП меняется только непустым значением из файла (пустая ячейка не стирает).""" id: uuid.UUID name: str | None entity_type: EntityType | None + kpp: str | None = None @dataclass(frozen=True) @@ -449,13 +459,18 @@ class _Planner: inn = validate_inn(inn, entity_type) except DomainValidationError as error: raise self.skip(row, inn, ImportCode.INVALID_INN, error.message) from error + try: + # v5.17, OQ-31: формат 9 символов; у ИП КПП запрещён (строка пропускается, как и с прочими ошибками строки) + kpp = validate_kpp(row.kpp, entity_type) + except DomainValidationError as error: + raise self.skip(row, inn, ImportCode.INVALID_KPP, error.message) from error if inn in self.seen_inns: raise self.skip( row, inn, ImportCode.DUPLICATE_IN_FILE, f"ИНН уже встречался в строке {self.seen_inns[inn]}" ) self.seen_inns[inn] = row.row group = self.plan_group(row, inn) - entity_id, current = self.plan_entity(row, inn, entity_type, group) + entity_id, current = self.plan_entity(row, inn, entity_type, kpp, group) self.plan_flags(row, inn, group, entity_id, current) def plan_office(self, row: ImportRow, inn: str) -> uuid.UUID | None: @@ -512,8 +527,8 @@ class _Planner: related = {RelatedGroupKind.gsk: snap.gsk_id, RelatedGroupKind.gsz: snap.gsz_id} return _GroupState(snap.id, row.group_name, row.row, effective_office, related) - def plan_entity( - self, row: ImportRow, inn: str, entity_type: EntityType, group: _GroupState + def plan_entity( # noqa: PLR0913 - строка, ИНН, тип, КПП и ГК: все значимы для решения по ЮЛ + self, row: ImportRow, inn: str, entity_type: EntityType, kpp: str | None, group: _GroupState ) -> tuple[uuid.UUID, EntitySnap | None]: snap = self.snapshot.entities_by_inn.get(inn) if snap is None: @@ -526,6 +541,7 @@ class _Planner: entity_type=entity_type, group_id=group.id, office_id=group.office_id, + kpp=kpp, ) ) self.report.entities_created += 1 @@ -536,8 +552,11 @@ class _Planner: raise self.skip(row, inn, ImportCode.LE_IN_OTHER_GROUP, f"«{snap.name}» уже состоит в другой ГК") new_name = row.entity_name if row.entity_name != snap.name else None new_type = entity_type if entity_type != snap.entity_type else None - if self.on_existing is OnExisting.update and (new_name is not None or new_type is not None): - self.operations.append(UpdateEntity(id=snap.id, name=new_name, entity_type=new_type)) + new_kpp = kpp if kpp is not None and kpp != snap.kpp else None # пустая ячейка существующий КПП не стирает + if self.on_existing is OnExisting.update and ( + new_name is not None or new_type is not None or new_kpp is not None + ): + self.operations.append(UpdateEntity(id=snap.id, name=new_name, entity_type=new_type, kpp=new_kpp)) self.report.entities_updated += 1 self.line(row, inn, ImportCode.LE_UPDATED, f"обновлено {entity_type.value} «{row.entity_name}»") else: diff --git a/api/src/domain/kpp.py b/api/src/domain/kpp.py new file mode 100644 index 0000000..1d4a200 --- /dev/null +++ b/api/src/domain/kpp.py @@ -0,0 +1,33 @@ +"""КПП ЮЛ: нормализация и проверка формата (`05-domain-rules.md` §13.5, DBML v5.17, OQ-31). + +КПП — строка из ровно 9 символов: 4 цифры, 2 символа (цифра или латинская A–Z), 3 цифры (ведущие нули значимы). У ИП +КПП не бывает. Пустое значение — «КПП нет» (`None`): для ЮЛ он необязателен. Контрольных разрядов у КПП нет. +""" + +import re + +from src.common.exceptions import DomainValidationError +from src.models.enums import EntityType + +KPP_RE = re.compile(r"[0-9]{4}[0-9A-Z]{2}[0-9]{3}") +_WHITESPACE_RE = re.compile(r"\s+") + + +def normalize_kpp(raw: str | None) -> str | None: + """Без пробельных символов, в верхнем регистре; пустое значение (в т.ч. `None`) → `None`.""" + if raw is None: + return None + normalized = _WHITESPACE_RE.sub("", raw).upper() + return normalized or None + + +def validate_kpp(raw: str | None, entity_type: EntityType | None) -> str | None: + """Нормализованный КПП или `None`; непустой КПП у ИП или неверный формат — 422 `INVALID_KPP`.""" + kpp = normalize_kpp(raw) + if kpp is None: + return None + if entity_type is EntityType.ИП: + raise DomainValidationError("INVALID_KPP", "у ИП КПП не бывает") + if KPP_RE.fullmatch(kpp) is None: + raise DomainValidationError("INVALID_KPP", "КПП — 9 символов: 4 цифры, 2 цифры или латинские буквы, 3 цифры") + return kpp diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index f951554..75a9c84 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,4 +1,4 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.16.dbml; заполняется в Срезе 0. +"""ORM-модели. Источник истины — docs/model/data-model-v5.17.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.16" +DBML_VERSION = "5.17" __all__ = [ "DBML_VERSION", diff --git a/api/src/models/access.py b/api/src/models/access.py index 52ba38a..4bb2414 100644 --- a/api/src/models/access.py +++ b/api/src/models/access.py @@ -1,4 +1,4 @@ -"""Часть 02 DBML v5.16 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" +"""Часть 02 DBML v5.17 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" import datetime import uuid diff --git a/api/src/models/accounting.py b/api/src/models/accounting.py index 153266d..ee12402 100644 --- a/api/src/models/accounting.py +++ b/api/src/models/accounting.py @@ -1,4 +1,4 @@ -"""Часть 03 DBML v5.16 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" +"""Часть 03 DBML v5.17 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" import datetime import uuid diff --git a/api/src/models/audit_ex.py b/api/src/models/audit_ex.py index 27ce4ee..f9de8a7 100644 --- a/api/src/models/audit_ex.py +++ b/api/src/models/audit_ex.py @@ -1,4 +1,4 @@ -"""Часть 09 DBML v5.16 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. +"""Часть 09 DBML v5.17 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. `audit_event` — append-only, высокообъёмная (before/after — jsonb). Срезы ЕХ самодостаточны (v5.12): FK на консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `ex_snapshot.source_version_id` diff --git a/api/src/models/base.py b/api/src/models/base.py index 5f768c4..5840c71 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -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.16). +#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.17). SCHEMA = "dkb" #: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же diff --git a/api/src/models/consolidation.py b/api/src/models/consolidation.py index 9a990a1..45941ac 100644 --- a/api/src/models/consolidation.py +++ b/api/src/models/consolidation.py @@ -1,4 +1,4 @@ -"""Часть 07 DBML v5.16 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. +"""Часть 07 DBML v5.17 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из `osv_entry` × `consolidation_member` × `elimination_rule`. Версия после VERIFIED — read-only (ТЗ 6.10). diff --git a/api/src/models/directories.py b/api/src/models/directories.py index cab6181..64e18a1 100644 --- a/api/src/models/directories.py +++ b/api/src/models/directories.py @@ -1,4 +1,4 @@ -"""Часть 01 DBML v5.16 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" +"""Часть 01 DBML v5.17 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" import datetime import uuid @@ -79,6 +79,8 @@ class LegalEntity(UUIDPrimaryMixin, Base): updated_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + # v5.17, последней колонкой (как её добавляет миграция 0005): КПП, nullable; у ИП запрещён — проверяет ядро + kpp: Mapped[str | None] = mapped_column(String) class LegalEntityHistory(UUIDPrimaryMixin, Base): @@ -104,6 +106,7 @@ class LegalEntityHistory(UUIDPrimaryMixin, Base): valid_from: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) valid_to: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) changed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) + kpp: Mapped[str | None] = mapped_column(String) # v5.17, последней колонкой: снимок КПП class LegalEntityAlias(UUIDPrimaryMixin, Base): diff --git a/api/src/models/enums.py b/api/src/models/enums.py index 7bb613b..a7d4521 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -1,6 +1,6 @@ -"""Enum'ы модели данных DBML v5.16 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок. +"""Enum'ы модели данных DBML v5.17 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок. -Источник истины — docs/model/data-model-v5.16.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке +Источник истины — docs/model/data-model-v5.17.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима). Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`. diff --git a/api/src/models/files.py b/api/src/models/files.py index 4139b31..00362ef 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -1,4 +1,4 @@ -"""Часть 04 DBML v5.16 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. +"""Часть 04 DBML v5.17 «Файлы / распознавание»: реестр 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` создаёт diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py index cfaee70..a425e66 100644 --- a/api/src/models/kfo.py +++ b/api/src/models/kfo.py @@ -1,4 +1,4 @@ -"""Часть 08 DBML v5.16 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. +"""Часть 08 DBML v5.17 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`. diff --git a/api/src/models/osv.py b/api/src/models/osv.py index 5276811..2fc38f0 100644 --- a/api/src/models/osv.py +++ b/api/src/models/osv.py @@ -1,4 +1,4 @@ -"""Часть 05 DBML v5.16 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). +"""Часть 05 DBML v5.17 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). Первичка не мутируется: сырые значения хранятся как пришли, правка аналитика — рядом (`is_modified`, `original_values`, `modified_by/at` + audit_event(RECOGNIZED_DATA_EDIT)). diff --git a/api/src/models/statements.py b/api/src/models/statements.py index f2f756f..b82870f 100644 --- a/api/src/models/statements.py +++ b/api/src/models/statements.py @@ -1,4 +1,4 @@ -"""Часть 06 DBML v5.16 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). +"""Часть 06 DBML v5.17 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением. Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету. diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index 98bbd26..2f57836 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -138,7 +138,11 @@ def _filtered_entities( # noqa: PLR0913 - фильтры списка ЮЛ пе if q is not None and q.strip(): pattern = f"%{escape_like(q.strip())}%" stmt = stmt.where( - or_(LegalEntity.name.ilike(pattern, escape=LIKE_ESCAPE), LegalEntity.inn.ilike(pattern, escape=LIKE_ESCAPE)) + or_( + LegalEntity.name.ilike(pattern, escape=LIKE_ESCAPE), + LegalEntity.inn.ilike(pattern, escape=LIKE_ESCAPE), + LegalEntity.kpp.ilike(pattern, escape=LIKE_ESCAPE), + ) ) if closed is not None: stmt = stmt.where(LegalEntity.close_date.is_not(None) if closed else LegalEntity.close_date.is_(None)) @@ -159,7 +163,7 @@ async def list_legal_entities( # noqa: PLR0913 - фильтры и пагина ) -> tuple[list[LegalEntity], int]: """Страница видимых ЮЛ (по `name`, `id`) и общее число записей по фильтру. - `inn` — точное равенство (вызывающий уже сделал `strip`); `q` — подстрока `name`/`inn` без учёта регистра (R-4). + `inn` — точное равенство (вызывающий уже сделал `strip`); `q` — подстрока `name`/`inn`/`kpp` без регистра (R-4). """ filters: dict[str, Any] = { "company_group_id": company_group_id, @@ -475,13 +479,16 @@ async def load_snapshot(session: AsyncSession) -> DirectorySnapshot: LegalEntity.name, LegalEntity.entity_type, LegalEntity.company_group_id, + LegalEntity.kpp, RelatedGroupMember.related_group_id, RelatedGroupMember.kind, ).outerjoin( RelatedGroupMember, and_(RelatedGroupMember.legal_entity_id == LegalEntity.id, RelatedGroupMember.is_current.is_(True)), ) - for entity_id, inn, name, entity_type, group_id, related_id, kind in (await session.execute(entity_stmt)).all(): + for entity_id, inn, name, entity_type, group_id, kpp, related_id, kind in ( + await session.execute(entity_stmt) + ).all(): entity = entities.get(inn) if entity is None: entity = EntitySnap( @@ -492,6 +499,7 @@ async def load_snapshot(session: AsyncSession) -> DirectorySnapshot: group_id=group_id, gsk_group_id=None, gsz_group_id=None, + kpp=kpp, ) entities[inn] = entity if kind is RelatedGroupKind.gsk: diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index 49ac07e..30b387f 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -142,11 +142,13 @@ def _strip_required_name(value: str | None) -> str | None: class LegalEntityCreate(BaseModel): - """Создание ЮЛ/ИП: `inn` проверяется по `entity_type` в сервисе (`INVALID_INN`); без ГК — монозаёмщик.""" + """Создание ЮЛ/ИП: `inn` и `kpp` проверяются по `entity_type` в сервисе (`INVALID_INN`, `INVALID_KPP`); без ГК — + монозаёмщик. `kpp` необязателен, у ИП запрещён (DBML v5.17).""" model_config = ConfigDict(extra="forbid") inn: str + kpp: str | None = None entity_type: EntityType name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) company_group_id: uuid.UUID | None = None @@ -173,6 +175,7 @@ class LegalEntityUpdate(BaseModel): entity_type: EntityType | None = None crm_id: str | None = None regional_office_id: uuid.UUID | None = None + kpp: str | None = None # null очищает КПП; формат и запрет для ИП проверяет сервис (INVALID_KPP) @field_validator("name") @classmethod @@ -221,6 +224,7 @@ class LegalEntityOut(BaseModel): id: uuid.UUID inn: str + kpp: str | None crm_id: str | None entity_type: EntityType | None name: str @@ -285,6 +289,7 @@ class LegalEntityHistoryOut(BaseModel): id: uuid.UUID inn: str | None + kpp: str | None crm_id: str | None entity_type: EntityType | None name: str | None @@ -300,7 +305,7 @@ class LegalEntityHistoryOut(BaseModel): class LegalEntityFilter(BaseModel): - """Фильтры списка ЮЛ: `inn` — точно (после `strip`), `q` — подстрока `name`/`inn`, `closed` — по `close_date`.""" + """Фильтры списка ЮЛ: `inn` — точно (после `strip`), `q` — подстрока `name`/`inn`/`kpp`, `closed` — `close_date`.""" company_group_id: uuid.UUID | None = None outside_group: bool | None = None diff --git a/api/src/services/directory_import.py b/api/src/services/directory_import.py index 2e25d77..4c20073 100644 --- a/api/src/services/directory_import.py +++ b/api/src/services/directory_import.py @@ -133,6 +133,7 @@ class DirectoryImportService: LegalEntity( id=op.id, inn=op.inn, + kpp=op.kpp, entity_type=op.entity_type, name=op.name, company_group_id=op.group_id, @@ -204,6 +205,8 @@ class DirectoryImportService: changes["name"] = op.name if op.entity_type is not None: changes["entity_type"] = op.entity_type + if op.kpp is not None: + changes["kpp"] = op.kpp await apply_entity_update( self._session, self._audit, entities[op.id], changes, now=now, description=IMPORT_DESCRIPTION ) diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index e5bab0b..5d64d96 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -17,6 +17,7 @@ from src.api.dependencies.audit import RequestAudit from src.common.clock import moscow_date, utc_now from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError from src.domain.inn import validate_inn +from src.domain.kpp import validate_kpp from src.domain.policy import VisibilityScope, office_for_create from src.models.directories import CompanyGroup, LegalEntity, LegalEntityHistory from src.models.enums import AuditAction, AuditSection, LeHistoryEvent @@ -40,7 +41,7 @@ from src.services.company_groups import COMPANY_GROUP_NOT_FOUND LEGAL_ENTITY_NOT_FOUND = "ЮЛ не найдено" # одинаково для чужого и несуществующего (spec directory-policy) OBJECT_TYPE = "legal_entity" MEMBER_OBJECT_TYPE = "related_group_member" -TRACKED_FIELDS = ("name", "entity_type", "crm_id", "regional_office_id") # поля ЮЛ, изменяемые PATCH +TRACKED_FIELDS = ("name", "entity_type", "crm_id", "regional_office_id", "kpp") # поля ЮЛ, изменяемые PATCH def _today() -> datetime.date: @@ -61,6 +62,7 @@ def snapshot_entity( # noqa: PLR0913 - снимок ЮЛ: сессия, авт snapshot = LegalEntityHistory( legal_entity_id=entity.id, inn=entity.inn, + kpp=entity.kpp, crm_id=entity.crm_id, entity_type=entity.entity_type, name=entity.name, @@ -124,6 +126,7 @@ class LegalEntityService: async def create(self, data: LegalEntityCreate) -> LegalEntity: """Новое ЮЛ/ИП; ГК — только видимая (иначе 404); филиал по умолчанию — филиал ГК; аудит LEGAL_ENTITY_CHANGE.""" inn = validate_inn(data.inn, data.entity_type) + kpp = validate_kpp(data.kpp, data.entity_type) group = await self._visible_group(data.company_group_id) if data.company_group_id is not None else None if await repo.get_legal_entity_by_inn(self._session, inn) is not None: raise ConflictError("INN_EXISTS", "ЮЛ с таким ИНН уже существует") @@ -138,6 +141,7 @@ class LegalEntityService: now = utc_now() entity = LegalEntity( inn=inn, + kpp=kpp, crm_id=data.crm_id, entity_type=data.entity_type, name=data.name, @@ -157,6 +161,7 @@ class LegalEntityService: object_id=entity.id, after_value={ "inn": entity.inn, + "kpp": entity.kpp, "entity_type": entity.entity_type, "name": entity.name, "crm_id": entity.crm_id, @@ -167,7 +172,7 @@ class LegalEntityService: return entity async def update(self, entity_id: uuid.UUID, data: LegalEntityUpdate) -> LegalEntity: - """Изменение `name`/`entity_type`/`crm_id`/`regional_office_id`; без фактических изменений ничего не пишется.""" + """Изменение `name`/`entity_type`/`crm_id`/`regional_office_id`/`kpp`; без фактических изменений — ничего.""" entity = await self._get_entity(entity_id) changes = await self._changes(entity, data) if not changes: @@ -356,6 +361,11 @@ class LegalEntityService: if field not in data.model_fields_set: continue value = getattr(data, field) + if field == "kpp": + # итоговая пара (тип, КПП): у ИП КПП запрещён; значение нормализуется до сравнения с хранимым + value = validate_kpp( + value, data.entity_type if "entity_type" in data.model_fields_set else entity.entity_type + ) if value == getattr(entity, field): continue # то же значение — не изменение (Review Focus 1), даже если филиал ЮЛ чужой для пользователя if field == "regional_office_id": @@ -364,6 +374,7 @@ class LegalEntityService: changes[field] = value if "entity_type" in changes: validate_inn(entity.inn, changes["entity_type"]) # длина ИНН должна подходить новому типу + validate_kpp(changes.get("kpp", entity.kpp), changes["entity_type"]) # и КПП — новому типу if changes.get("regional_office_id") is not None: await self._check_office_exists(changes["regional_office_id"]) if changes.get("crm_id") is not None: diff --git a/api/tests/test_api_directory_import.py b/api/tests/test_api_directory_import.py index e477184..e3cabfe 100644 --- a/api/tests/test_api_directory_import.py +++ b/api/tests/test_api_directory_import.py @@ -70,6 +70,7 @@ COUNTER_KEYS = tuple(RB_COUNTERS) FINAL_CODES = { "UNSUPPORTED_TYPE", "INVALID_INN", + "INVALID_KPP", "DUPLICATE_IN_FILE", "LE_IN_OTHER_GROUP", "GK_NAME_CONFLICT", @@ -595,3 +596,82 @@ async def test_hostile_filename_does_not_produce_500( assert event.description.endswith(".csv") (key,) = storage.objects assert len(key.encode()) <= 1024 # noqa: PLR2004 - предел длины ключа S3 + + +@pytest.mark.integration +async def test_kpp_column_is_imported_updated_and_invalid_rows_skipped( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """КПП (DBML v5.17, §13.5): колонка по заголовку; ИП с КПП и неверный формат — `INVALID_KPP`, строка пропущена; + `update` обновляет КПП существующего ЮЛ со снимком и аудитом, пустая ячейка его не стирает.""" + client = await auth_client(ADMIN) + header = [*HEADER, "КПП"] + + def row(inn: str, entity: str, kpp: str, type_: str = "ЮЛ") -> list[str]: + return [inn, "1-KPP01", KRASNODAR, "ГК КПП", entity, "", "", type_, kpp] + + first = _csv( + row("7701000001", "ООО Альфа", "770101001"), + row("7701000002", "ООО Бета", ""), + row("352819549855", "ИП Иванов", "770101001", "ИП"), + row("7701000003", "ООО Гамма", "77010100"), + header=header, + ) + report = await _report(client, "kpp.csv", first, dry_run=False) + assert _finals(report) == {2: "LE_CREATED", 3: "LE_CREATED", 4: "INVALID_KPP", 5: "INVALID_KPP"} + assert (report["entities_created"], report["errors"]) == (2, 2) + stored = { + e.inn: e.kpp + for e in (await seeded.execute(select(LegalEntity).where(LegalEntity.inn.like("77010%")))).scalars() + } + assert stored == {"7701000001": "770101001", "7701000002": None} + assert ( + await seeded.execute(select(func.count()).select_from(LegalEntity).where(LegalEntity.inn == "352819549855")) + ).scalar_one() == 0 + + second = _csv( + row("7701000001", "ООО Альфа", ""), # пустая ячейка не стирает КПП + row("7701000002", "ООО Бета", "7701AB002"), + header=header, + ) + updated = await _report(client, "kpp2.csv", second, dry_run=False, on_existing="update") + assert _finals(updated) == {2: "LE_EXISTS", 3: "LE_UPDATED"} + beta = (await seeded.execute(select(LegalEntity).where(LegalEntity.inn == "7701000002"))).scalar_one() + alpha = (await seeded.execute(select(LegalEntity).where(LegalEntity.inn == "7701000001"))).scalar_one() + await seeded.refresh(alpha) + assert (alpha.kpp, beta.kpp) == ("770101001", "7701AB002") + (snapshot,) = ( + (await seeded.execute(select(LegalEntityHistory).where(LegalEntityHistory.legal_entity_id == beta.id))) + .scalars() + .all() + ) + assert (snapshot.event_type, snapshot.kpp) == (LeHistoryEvent.UPDATE, None) + (change,) = ( + ( + await seeded.execute( + select(AuditEvent).where( + AuditEvent.action == AuditAction.LEGAL_ENTITY_CHANGE, AuditEvent.object_id == beta.id + ) + ) + ) + .scalars() + .all() + ) + assert (change.changed_field, change.before_value, change.after_value) == ( + "kpp", + {"kpp": None}, + {"kpp": "7701AB002"}, + ) + + +@pytest.mark.integration +async def test_import_without_kpp_column_is_not_an_error( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + client = await auth_client(ADMIN) + report = await _report( + client, "nokpp.csv", _csv(_row("7701000001", "1-IMP01", "ГК Импорт", "ООО Альфа")), dry_run=False + ) + assert _finals(report) == {2: "LE_CREATED"} + entity = (await seeded.execute(select(LegalEntity).where(LegalEntity.inn == "7701000001"))).scalar_one() + assert entity.kpp is None diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py index d8f2899..d852b4d 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -119,6 +119,7 @@ async def test_create_audit_and_default_office_from_group(auth_client: AuthClien ) assert event.after_value == { "inn": "0123456789", + "kpp": None, "entity_type": "ЮЛ", "name": "ООО Ноль", "crm_id": "1-AB", @@ -382,6 +383,7 @@ async def test_patch_writes_update_snapshot_and_audit(auth_client: AuthClientFac assert set(history[0]) == { "id", "inn", + "kpp", "crm_id", "entity_type", "name", @@ -675,3 +677,99 @@ async def test_documents_count_in_move_and_withdraw_responses( ) assert withdraw.status_code == HTTP_OK, withdraw.text assert withdraw.json()["documents_count"] == 3 # noqa: PLR2004 - три документа ЮЛ + + +# --- КПП (DBML v5.17, OQ-31, 05-domain-rules §13.5) -------------------------------------------------------------- + + +@pytest.mark.integration +async def test_create_with_kpp_returns_it_in_out_detail_and_audit( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + client = await auth_client("ka_go@armka.local") + created = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО С КПП", kpp=" 3528 ab 001 ") + assert created["kpp"] == "3528AB001" # нормализован: без пробелов, верхний регистр + assert (await client.get(f"{ENTITIES}/{created['id']}")).json()["kpp"] == "3528AB001" + listed = (await client.get(ENTITIES)).json()["items"] + assert [item["kpp"] for item in listed] == ["3528AB001"] + (event,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) + assert event.after_value is not None + assert event.after_value["kpp"] == "3528AB001" + + +@pytest.mark.integration +async def test_kpp_is_optional_for_legal_entity(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + without = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Без КПП") + assert without["kpp"] is None + blank = await _create(client, inn="3528052719", entity_type="ЮЛ", name="ООО Пустой КПП", kpp=" ") + assert blank["kpp"] is None + + +@pytest.mark.integration +@pytest.mark.parametrize("kpp", ["35280100", "3528010011", "A52801001", "3528010A1", "3528я1001"]) +async def test_invalid_kpp_format_is_unprocessable( + auth_client: AuthClientFactory, seeded: AsyncSession, kpp: str +) -> None: + client = await auth_client("ka_go@armka.local") + response = await client.post(ENTITIES, json={"inn": "3528052718", "entity_type": "ЮЛ", "name": "ООО", "kpp": kpp}) + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "INVALID_KPP" + assert (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one() == 0 + + +@pytest.mark.integration +async def test_kpp_is_forbidden_for_sole_proprietor(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + response = await client.post( + ENTITIES, json={"inn": "352805271812", "entity_type": "ИП", "name": "ИП Иванов", "kpp": "352801001"} + ) + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "INVALID_KPP" + ok = await _create(client, inn="352805271812", entity_type="ИП", name="ИП Иванов") + assert ok["kpp"] is None + patch = await client.patch(f"{ENTITIES}/{ok['id']}", json={"kpp": "352801001"}) + assert patch.status_code == HTTP_UNPROCESSABLE + assert patch.json()["detail"]["code"] == "INVALID_KPP" + + +@pytest.mark.integration +async def test_inn_is_unique_kpp_is_not(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Уникален только `inn`: два ЮЛ с одним КПП допустимы.""" + client = await auth_client("ka_go@armka.local") + await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Первое", kpp="352801001") + await _create(client, inn="3528052719", entity_type="ЮЛ", name="ООО Второе", kpp="352801001") + + +@pytest.mark.integration +async def test_patch_kpp_writes_snapshot_and_audit_before_after( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + client = await auth_client("ka_go@armka.local") + created = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО", kpp="352801001") + response = await client.patch(f"{ENTITIES}/{created['id']}", json={"kpp": "352845002"}) + assert response.status_code == HTTP_OK, response.text + assert response.json()["kpp"] == "352845002" + history = (await client.get(f"{ENTITIES}/{created['id']}/history")).json() + assert [(row["event_type"], row["kpp"]) for row in history] == [("UPDATE", "352801001")] # снимок ПРЕЖНЕГО КПП + events = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) + assert (events[-1].before_value, events[-1].after_value) == ({"kpp": "352801001"}, {"kpp": "352845002"}) + + same = await client.patch(f"{ENTITIES}/{created['id']}", json={"kpp": "3528 45 002"}) + assert same.status_code == HTTP_OK + assert len((await client.get(f"{ENTITIES}/{created['id']}/history")).json()) == 1 # то же значение — не изменение + + cleared = await client.patch(f"{ENTITIES}/{created['id']}", json={"kpp": None}) + assert cleared.status_code == HTTP_OK + assert cleared.json()["kpp"] is None + omitted = await client.patch(f"{ENTITIES}/{created['id']}", json={"name": "ООО Новое"}) + assert omitted.json()["kpp"] is None # поле не передано — не затрагивается + + +@pytest.mark.integration +async def test_search_q_finds_by_kpp(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + target = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Первое", kpp="352845001") + await _create(client, inn="3528052719", entity_type="ЮЛ", name="ООО Второе", kpp="770101001") + found = (await client.get(ENTITIES, params={"q": "352845001"})).json() + assert [item["id"] for item in found["items"]] == [target["id"]] diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index 887b74e..1d249c7 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -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.17.dbml" - # шапка 5.16 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора - newer.write_text(lines[0].replace("v5.16", "v5.17") + "\nTable broken {{{\n", encoding="utf-8") - assert run_check(newer) == (1, ["версия DBML 5.17 не совпадает с версией ORM 5.16: обновите модели"]) + newer = tmp_path / "data-model-v5.18.dbml" + # шапка 5.17 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора + newer.write_text(lines[0].replace("v5.17", "v5.18") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.18 не совпадает с версией ORM 5.17: обновите модели"]) def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: - newer = tmp_path / "data-model-v5.17.dbml" - newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.16", "v5.17", 1), encoding="utf-8") + newer = tmp_path / "data-model-v5.18.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.17", "v5.18", 1), encoding="utf-8") result = _run_cli(str(newer)) assert result.returncode == 1 - assert result.stdout == "версия DBML 5.17 не совпадает с версией ORM 5.16: обновите модели\n" + assert result.stdout == "версия DBML 5.18 не совпадает с версией ORM 5.17: обновите модели\n" def test_header_without_version_is_usage_error(tmp_path: Path) -> None: diff --git a/api/tests/test_domain_directory_import.py b/api/tests/test_domain_directory_import.py index 87a86fb..bbebb53 100644 --- a/api/tests/test_domain_directory_import.py +++ b/api/tests/test_domain_directory_import.py @@ -43,6 +43,7 @@ HEADERS = ["ИНН", "iD ГК", "Базовый РФ ГК в CRM", "Группа FINAL_CODES = { ImportCode.UNSUPPORTED_TYPE, ImportCode.INVALID_INN, + ImportCode.INVALID_KPP, ImportCode.DUPLICATE_IN_FILE, ImportCode.LE_IN_OTHER_GROUP, ImportCode.GK_NAME_CONFLICT, @@ -342,6 +343,7 @@ def _row( # noqa: PLR0913 - строка выгрузки: все поля зн entity_name: str = "РУССКИЙ БИСКВИТ, АО", gsk_flag: str = "", gsz_flag: str = "", + kpp: str = "", ) -> ImportRow: return ImportRow( row=row, @@ -353,6 +355,7 @@ def _row( # noqa: PLR0913 - строка выгрузки: все поля зн gsk_flag=gsk_flag, gsz_flag=gsz_flag, type_raw=type_raw, + kpp=kpp, ) @@ -760,3 +763,80 @@ def test_russkiy_biskvit_export_on_empty_directory(fixtures_dir: Path) -> None: assert sorted(entities[m.entity_id] for m in members) == RB_GSK_MEMBERS assert report.memberships_created == len(RB_GSK_MEMBERS) assert all(m.kind is RelatedGroupKind.gsk for m in members) + + +# ---------------------------------------------------------------- КПП (DBML v5.17, §13.1, §13.2, §13.5) + + +def test_read_csv_kpp_column_is_optional_and_found_by_header() -> None: + without = read_rows("export.csv", _csv_bytes([HEADERS, _data_row()])) + assert [r.kpp for r in without] == [""] # колонки нет — не ошибка + headers = ["ИНН", "КПП", *HEADERS[1:]] + row = _data_row() + with_kpp = read_rows("export.csv", _csv_bytes([headers, [row[0], "352801001", *row[1:]]])) + assert [(r.inn, r.kpp) for r in with_kpp] == [("3528052718", "352801001")] + + +def test_read_csv_kpp_header_synonym_and_case() -> None: + headers = [" кпп ", *HEADERS] + content = _csv_bytes([headers, ["352801001", *_data_row()]]) + assert [r.kpp for r in read_rows("export.csv", content)] == ["352801001"] + latin = _csv_bytes([["KPP", *HEADERS], ["352801001", *_data_row()]]) + assert [r.kpp for r in read_rows("export.csv", latin)] == ["352801001"] + + +def test_create_entity_carries_normalized_kpp() -> None: + plan = plan_import([_row(2, kpp=" 3528 ab 001 ")], _empty(), OnExisting.skip) + (entity,) = _ops(plan, CreateEntity) + assert entity.kpp == "3528AB001" + assert _final_codes(plan) == {2: ImportCode.LE_CREATED} + + +def test_create_entity_without_kpp_has_none() -> None: + plan = plan_import([_row(2)], _empty(), OnExisting.skip) + (entity,) = _ops(plan, CreateEntity) + assert entity.kpp is None + + +@pytest.mark.parametrize("kpp", ["35280100", "3528010011", "A52801001", "35280100я"]) +def test_invalid_kpp_skips_row(kpp: str) -> None: + plan = plan_import([_row(2, kpp=kpp)], _empty(), OnExisting.skip) + assert _final_codes(plan) == {2: ImportCode.INVALID_KPP} + assert plan.operations == [] + assert plan.report.errors == 1 + + +def test_kpp_for_sole_proprietor_skips_row_with_warning_line() -> None: + rows = [_row(2, inn="352819549855", type_raw="ИП", entity_name="ИП Юганов", kpp="352801001")] + plan = plan_import(rows, _empty(), OnExisting.skip) + assert _final_codes(plan) == {2: ImportCode.INVALID_KPP} + (line,) = plan.report.lines + assert line.message == "у ИП КПП не бывает" + assert plan.operations == [] + + +def test_invalid_kpp_does_not_mark_inn_as_seen() -> None: + """Как с ИНН: строка с ошибкой КПП не «занимает» ИНН — следующая корректная строка не дубль.""" + rows = [_row(2, kpp="bad"), _row(3)] + plan = plan_import(rows, _empty(), OnExisting.skip) + assert _final_codes(plan) == {2: ImportCode.INVALID_KPP, 3: ImportCode.LE_CREATED} + + +def test_update_sets_kpp_of_existing_entity() -> None: + plan = plan_import([_row(2, kpp="352801001")], _existing(), OnExisting.update) + assert plan.operations == [UpdateEntity(id=ENTITY_ID, name=None, entity_type=None, kpp="352801001")] + assert _final_codes(plan) == {2: ImportCode.LE_UPDATED} + + +def test_update_with_empty_kpp_cell_keeps_existing_kpp() -> None: + snapshot = _existing() + snapshot.entities_by_inn["3528052718"].kpp = "352801001" + plan = plan_import([_row(2, kpp="")], snapshot, OnExisting.update) + assert plan.operations == [] + assert _final_codes(plan) == {2: ImportCode.LE_EXISTS} + + +def test_skip_strategy_leaves_kpp_of_existing_entity() -> None: + plan = plan_import([_row(2, kpp="352801001")], _existing(), OnExisting.skip) + assert plan.operations == [] + assert _final_codes(plan) == {2: ImportCode.LE_EXISTS} diff --git a/api/tests/test_domain_kpp.py b/api/tests/test_domain_kpp.py new file mode 100644 index 0000000..5b49a9c --- /dev/null +++ b/api/tests/test_domain_kpp.py @@ -0,0 +1,54 @@ +"""КПП: нормализация и проверка формата (05-domain-rules §13.5, DBML v5.17, OQ-31).""" + +import pytest + +from src.common.exceptions import DomainValidationError +from src.domain.kpp import normalize_kpp, validate_kpp +from src.models.enums import EntityType + + +@pytest.mark.parametrize("raw", ["352801001", "352845001", "3528AB001", "35280A001", "3528A1001", "000000000"]) +def test_valid_kpp_for_legal_entity(raw: str) -> None: + assert validate_kpp(raw, EntityType.ЮЛ) == raw + + +def test_kpp_is_normalized_whitespace_removed_and_uppercased() -> None: + assert validate_kpp(" 3528 ab 001 ", EntityType.ЮЛ) == "3528AB001" + + +@pytest.mark.parametrize("raw", [None, "", " "]) +def test_empty_kpp_is_none(raw: str | None) -> None: + assert normalize_kpp(raw) is None + assert validate_kpp(raw, EntityType.ЮЛ) is None + assert validate_kpp(raw, EntityType.ИП) is None # у ИП пустой КПП — норма + + +@pytest.mark.parametrize( + "raw", + [ + "35280100", # 8 символов + "3528010011", # 10 символов + "A52801001", # буква в первых четырёх + "3528010A1", # буква в последних трёх + "35280100A", + "3528ab00", # короткий + "3528я1001", # кириллица + "٣٥٢٨٠١٠٠١", # не ASCII-цифры + "3528-1001", + ], +) +def test_wrong_format_is_invalid_kpp(raw: str) -> None: + with pytest.raises(DomainValidationError) as error: + validate_kpp(raw, EntityType.ЮЛ) + assert error.value.code == "INVALID_KPP" + + +def test_kpp_is_forbidden_for_sole_proprietor() -> None: + with pytest.raises(DomainValidationError) as error: + validate_kpp("352801001", EntityType.ИП) + assert error.value.code == "INVALID_KPP" + assert error.value.message == "у ИП КПП не бывает" + + +def test_unknown_entity_type_is_treated_as_legal_entity() -> None: + assert validate_kpp("352801001", None) == "352801001" diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index 0586ba6..b3271c1 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -1,4 +1,4 @@ -"""Enum'ы ORM против блоков `Enum {}` DBML v5.16: имена, значения и их порядок (без БД).""" +"""Enum'ы ORM против блоков `Enum {}` DBML v5.17: имена, значения и их порядок (без БД).""" import enum import re @@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 28 def test_dbml_version_format() -> None: assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) - assert DBML_VERSION == "5.16" + assert DBML_VERSION == "5.17" assert f"v{DBML_VERSION}" in dbml_path().name diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 1510c42..5e26103 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -1,5 +1,5 @@ -"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16) на PostgreSQL 16: полный цикл, откат на шаг, -пустой autogenerate, схема = схеме ORM. +"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16), 0005 (v5.17) на PostgreSQL 16: полный цикл, +откат на шаг, пустой autogenerate, схема = схеме ORM. Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из @@ -36,7 +36,8 @@ from tests.test_models_part_07_09 import ( _check_active_lock_is_unique, ) -HEAD = "0004_dbml_v5_16" +HEAD = "0005_dbml_v5_17" +V5_16 = "0004_dbml_v5_16" V5_15 = "0003_dbml_v5_15" V5_14 = "0002_dbml_v5_14" PREVIOUS = "0001_dbml_v5_13" @@ -437,6 +438,66 @@ async def test_upgrade_0004_backfills_counterparty_match_kind(scratch_db_url: st await engine.dispose() +async def _kpp_columns(conn: AsyncConnection) -> list[Any]: + return list( + ( + await conn.execute( + text( + "SELECT table_name, is_nullable, data_type FROM information_schema.columns " + "WHERE table_schema = 'dkb' AND column_name = 'kpp' ORDER BY table_name" + ) + ) + ).all() + ) + + +@pytest.mark.integration +async def test_upgrade_0005_adds_nullable_kpp_columns(scratch_db_url: str) -> None: + """0005: `kpp varchar` nullable у `legal_entity` и `legal_entity_history`, существующие ЮЛ — с `kpp IS NULL`; + `downgrade 0004` убирает обе колонки, повторный upgrade возвращает.""" + engine = create_async_engine(scratch_db_url) + now = datetime.datetime.now(datetime.UTC) + entity_id = uuid.uuid4() + try: + await alembic(scratch_db_url, "upgrade", V5_16) + async with engine.begin() as conn: + assert await _kpp_columns(conn) == [] + await conn.execute( + text( + "INSERT INTO dkb.legal_entity (id, inn, name, valid_from) VALUES (:id, '7701000001', 'ООО', :now)" + ), + {"id": entity_id, "now": now}, + ) + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.begin() as conn: + assert await _kpp_columns(conn) == [ + ("legal_entity", "YES", "character varying"), + ("legal_entity_history", "YES", "character varying"), + ] + assert await _scalars(conn, "SELECT kpp FROM dkb.legal_entity") == [None] + await conn.execute(text("UPDATE dkb.legal_entity SET kpp = '770101001'")) # уникальности нет, тип varchar + order = await _scalars( + conn, + "SELECT column_name FROM information_schema.columns WHERE table_schema = 'dkb' " + "AND table_name = 'legal_entity' ORDER BY ordinal_position DESC LIMIT 1", + ) + assert order == ["kpp"] # последней колонкой — как в ORM и DBML (порядок сверяет check_dbml) + await engine.dispose() + + await alembic(scratch_db_url, "downgrade", V5_16) + async with engine.connect() as conn: + assert await _kpp_columns(conn) == [] + assert await _scalars(conn, "SELECT inn FROM dkb.legal_entity") == ["7701000001"] + 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_upgrade_0002_keeps_membership_and_fills_kind(scratch_db_url: str) -> None: """0002 пересоздаёт `related_group_member`: строки v5.13 сохраняются, `kind` = `related_group.kind` группы.""" @@ -653,8 +714,8 @@ 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 → 0002 → 0003 → 0004 — офлайн-SQL доходит до head - for new_version, old_version in ((V5_14, PREVIOUS), (V5_15, V5_14), (HEAD, V5_15)): + # версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 → 0004 → 0005 — офлайн-SQL доходит до head + for new_version, old_version in ((V5_14, PREVIOUS), (V5_15, V5_14), (V5_16, V5_15), (HEAD, V5_16)): assert ( f"UPDATE dkb.alembic_version SET version_num='{new_version}' " f"WHERE dkb.alembic_version.version_num = '{old_version}'" in output diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index f0f751d..d3892a8 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -1,4 +1,4 @@ -"""ORM частей 07–09 DBML v5.16 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц.""" +"""ORM частей 07–09 DBML v5.17 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 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.16 (имена буквально), всего 43, все в схеме dkb.""" + """Состав metadata == таблицы DBML v5.17 (имена буквально), всего 43, все в схеме dkb.""" tables = list(Base.metadata.sorted_tables) assert len(tables) == EXPECTED_TABLE_COUNT assert all(t.schema == "dkb" for t in tables) diff --git a/api/tests/test_seed_dev.py b/api/tests/test_seed_dev.py index 81de2a8..fe70e81 100644 --- a/api/tests/test_seed_dev.py +++ b/api/tests/test_seed_dev.py @@ -448,3 +448,50 @@ def test_validator_rejects_malformed_dev_company_groups(tmp_path: Path, old: str with pytest.raises(SeedError, match=expected): load_seed(target, dev=True) + + +def test_dev_seed_carries_no_invented_kpp() -> None: + """В данных seed КПП нет ни у одного ЮЛ (в выгрузке ЕЦП и фикстурах его нет) — значений не выдумываем.""" + data = load_seed(dev=True) + assert all(entity.kpp is None for group in data.company_groups for entity in group.legal_entities) + + +def test_loader_accepts_optional_kpp_and_normalizes_it(tmp_path: Path) -> None: + target = _copy_seed(tmp_path) + groups_file = target / "dev" / "company_groups.yaml" + text = groups_file.read_text(encoding="utf-8") + old = '{inn: "2315995868"' + assert text.count(old) == 1 + groups_file.write_text(text.replace(old, '{kpp: " 2315 ab 001 ", inn: "2315995868"'), encoding="utf-8") + + entities = {e.inn: e for g in load_seed(target, dev=True).company_groups for e in g.legal_entities} + assert entities["2315995868"].kpp == "2315AB001" + + +@pytest.mark.parametrize("kpp", ["23159500", "A31599500", "231599500Z"]) +def test_validator_rejects_malformed_kpp(tmp_path: Path, kpp: str) -> None: + target = _copy_seed(tmp_path) + groups_file = target / "dev" / "company_groups.yaml" + text = groups_file.read_text(encoding="utf-8") + old = '{inn: "2315995868"' + groups_file.write_text(text.replace(old, '{kpp: "' + kpp + '", inn: "2315995868"'), encoding="utf-8") + + with pytest.raises(SeedError, match="КПП"): + load_seed(target, dev=True) + + +@pytest.mark.integration +async def test_seed_writes_kpp_and_keeps_it_when_data_has_none(db_session: AsyncSession, tmp_path: Path) -> None: + target = _copy_seed(tmp_path) + groups_file = target / "dev" / "company_groups.yaml" + text = groups_file.read_text(encoding="utf-8") + old = '{inn: "2315995868"' + groups_file.write_text(text.replace(old, '{kpp: "231501001", inn: "2315995868"'), encoding="utf-8") + await run_seed(db_session, dev=True, seed_dir=target) + entity = (await db_session.scalars(select(LegalEntity).where(LegalEntity.inn == "2315995868"))).one() + assert entity.kpp == "231501001" + + groups_file.write_text(text, encoding="utf-8") # в данных КПП убран — seed ранее записанный КПП не стирает + await run_seed(db_session, dev=True, seed_dir=target) + await db_session.refresh(entity) + assert entity.kpp == "231501001" diff --git a/openspec/config.yaml b/openspec/config.yaml index f7f5c7e..6f05017 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -3,7 +3,7 @@ schema: spec-driven context: | Репозиторий arm-core — часть системы АРМ КА (ДКБ, Россельхозбанк): ядро (FastAPI + React), единственный писатель в PostgreSQL: справочники, файлы/документы, маппинг результатов распознавания, вычисления консолидации, согласование, ЕХ, аудит, права. Workspace из нескольких репозиториев; общий контекст — ../CLAUDE.md и ../docs/ (репозиторий arm-ka-docs). - Источники истины по приоритету: ../docs/model/data-model-v5.16.dbml → ../docs/model/entities-v5.16.md → + Источники истины по приоритету: ../docs/model/data-model-v5.17.dbml → ../docs/model/entities-v5.17.md → ../docs/context/05-domain-rules.md → страницы ТЗ в ../docs/source/tz (черновик, старые имена v4). Открытые вопросы — ../docs/context/07-open-questions.md; план срезов — ../docs/context/09-delivery-plan.md. Фикстуры — ../fixtures (реальные данные, не спецификация формата). @@ -14,7 +14,7 @@ context: | rules: proposal: - Указывать номер среза из 09-delivery-plan.md и затронутые репозитории; сквозные изменения — отдельный change в ../docs - - Перечислять таблицы DBML v5.16, которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML + - Перечислять таблицы DBML v5.17, которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML - Ссылаться на параграфы 05-domain-rules.md для каждого алгоритма; правило, которого там нет, — в 07-open-questions.md - Раздел «Non-goals» обязателен - Любое допущение по открытому вопросу помечать ASSUMPTION(OQ-nn)