КПП юридического лица: legal_entity.kpp, миграция 0005, API, импорт (DBML v5.17, #45) #52
@ -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.
|
||||
|
||||
2
Makefile
2
Makefile
@ -3,7 +3,7 @@ VENV := api/.venv
|
||||
BIN := $(CURDIR)/$(VENV)/bin
|
||||
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
|
||||
# DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/
|
||||
DBML ?= ../../docs/model/data-model-v5.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
|
||||
|
||||
|
||||
22
README.md
22
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) — план срезов;
|
||||
|
||||
32
api/alembic/versions/0005_dbml_v5_17.py
Normal file
32
api/alembic/versions/0005_dbml_v5_17.py
Normal file
@ -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")
|
||||
@ -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`.
|
||||
|
||||
|
||||
@ -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 = "нет"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -116,6 +116,7 @@ class LegalEntitySeed:
|
||||
name: str
|
||||
entity_type: EntityType
|
||||
aliases: tuple[str, ...]
|
||||
kpp: str | None = None # v5.17: КПП, если известен (в dev-данных пока не задан — значений не выдумываем)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@ -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 внутри ГК, алиас — нормализованный текст, член — пара
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
33
api/src/domain/kpp.py
Normal file
33
api/src/domain/kpp.py
Normal file
@ -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
|
||||
@ -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",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 02 DBML v5.16 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`)."""
|
||||
"""Часть 02 DBML v5.17 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`)."""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 03 DBML v5.16 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные)."""
|
||||
"""Часть 03 DBML v5.17 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные)."""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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` давали одни и те же
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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` создаёт
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 08 DBML v5.16 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
|
||||
"""Часть 08 DBML v5.17 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
|
||||
|
||||
Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО
|
||||
замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`.
|
||||
|
||||
@ -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)).
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Часть 06 DBML v5.16 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
|
||||
"""Часть 06 DBML v5.17 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
|
||||
|
||||
Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением.
|
||||
Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
54
api/tests/test_domain_kpp.py
Normal file
54
api/tests/test_domain_kpp.py
Normal file
@ -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"
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user