Andrey Epifancev 4cc926c033 КПП юридического лица: legal_entity.kpp, миграция 0005, API, импорт (DBML v5.17, #45)
- модели LegalEntity.kpp / LegalEntityHistory.kpp (последней колонкой), миграция 0005_dbml_v5_17 (upgrade/downgrade);
- domain/kpp.py: 9 символов (4 цифры, 2 цифры/латинские A-Z, 3 цифры), у ИП запрещён, иначе 422 INVALID_KPP;
- схемы Create/Update/Out/Detail/HistoryOut, PATCH пишет снимок history и аудит before/after с kpp, поиск q по КПП;
- импорт справочника: необязательная колонка КПП (синоним kpp), код INVALID_KPP, update обновляет непустой КПП;
- dev-seed: необязательный ключ kpp (значений в данных нет, не выдумываем);
- сверка с DBML, Makefile, README, CLAUDE.md, openspec/config.yaml — на v5.17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-07 18:51:50 +04:00

478 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Фаза 1: чтение файлов `seed/` в память и проверка (обязательные поля, enum'ы, ссылки внутри наборов).
База данных здесь не используется; любая ошибка — `SeedError` с файлом и записью.
"""
import csv
import re
from pathlib import Path
from typing import Any
from scripts.seed_lib.loaders_rules import load_elimination_rules, load_required_document_rules
from scripts.seed_lib.model import (
AccountSeed,
CompanyGroupSeed,
LegalEntitySeed,
PermissionSeed,
RegionalOfficeSeed,
RelatedGroupSeed,
RoleSeed,
SeedData,
SeedError,
UserSeed,
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,
BusinessSegment,
EntityType,
PermissionAction,
PermissionObject,
RelatedGroupKind,
)
DEFAULT_SEED_DIR = Path(__file__).resolve().parent.parent.parent / "seed"
def _load_regional_offices(
directory: Path, seed_dir: Path, filename: str = "regional_office.yaml"
) -> list[RegionalOfficeSeed]:
path = directory / filename
records = checked_records(
read_yaml(path, seed_dir), {"name", "is_head_office", "is_active"}, {"name"}, path, seed_dir
)
result = []
for index, record in enumerate(records):
where = place(path, seed_dir, index)
result.append(
RegionalOfficeSeed(
name=str(str_field(record, "name", where)),
is_head_office=bool_field(record, "is_head_office", where, default=False),
is_active=bool_field(record, "is_active", where, default=True),
)
)
ensure_unique([o.name for o in result], "филиал", path, seed_dir)
return result
def _load_roles(core: Path, seed_dir: Path) -> list[RoleSeed]:
path = core / "roles.yaml"
records = checked_records(
read_yaml(path, seed_dir),
{"code", "name", "description", "is_system", "is_active"},
{"code", "name"},
path,
seed_dir,
)
result = []
for index, record in enumerate(records):
where = place(path, seed_dir, index, str(record.get("code")))
result.append(
RoleSeed(
code=str(str_field(record, "code", where)),
name=str(str_field(record, "name", where)),
description=str_field(record, "description", where, optional=True),
is_system=bool_field(record, "is_system", where, default=False),
is_active=bool_field(record, "is_active", where, default=True),
)
)
ensure_unique([r.code for r in result], "роль", path, seed_dir)
return result
def _expected_permission_code(object_type: PermissionObject, action: PermissionAction) -> str:
"""Код права `<OBJECT>_<ACTION>`; при совпадении объекта и действия — одно слово (`EXPORT`)."""
if object_type.value == action.value:
return action.value
return f"{object_type.value}_{action.value}"
def _load_permissions(core: Path, seed_dir: Path) -> list[PermissionSeed]:
path = core / "permissions.yaml"
records = checked_records(
read_yaml(path, seed_dir),
{"code", "object", "action", "name", "description"},
{"code", "object", "action", "name"},
path,
seed_dir,
)
result = []
for index, record in enumerate(records):
where = place(path, seed_dir, index, str(record.get("code")))
try:
object_type = PermissionObject(record["object"])
except ValueError:
raise SeedError(f"{where}: неизвестный объект права «{record['object']}»") from None
try:
action = PermissionAction(record["action"])
except ValueError:
raise SeedError(f"{where}: неизвестное действие права «{record['action']}»") from None
code = str(str_field(record, "code", where))
if code != _expected_permission_code(object_type, action):
raise SeedError(
f"{where}: код права «{code}» не соответствует объекту и действию, "
f"ожидался «{_expected_permission_code(object_type, action)}»"
)
result.append(
PermissionSeed(
code=code,
object_type=object_type,
action=action,
name=str(str_field(record, "name", where)),
description=str_field(record, "description", where, optional=True),
)
)
ensure_unique([p.code for p in result], "право", path, seed_dir)
pairs = [f"{p.object_type.value}.{p.action.value}" for p in result]
ensure_unique(pairs, "сочетание объект.действие", path, seed_dir)
return result
def _load_role_permissions(
core: Path, seed_dir: Path, role_codes: set[str], permission_codes: set[str]
) -> dict[str, list[str]]:
path = core / "role_permissions.yaml"
data = read_yaml(path, seed_dir)
if not isinstance(data, dict):
raise SeedError(f"{place(path, seed_dir)}: ожидается словарь «код роли -> список кодов прав»")
result: dict[str, list[str]] = {}
for role_code, codes in data.items():
where = place(path, seed_dir, key=str(role_code))
if role_code not in role_codes:
raise SeedError(f"{where}: роль «{role_code}» не описана в roles.yaml")
if not isinstance(codes, list) or not all(isinstance(c, str) for c in codes):
raise SeedError(f"{where}: ожидается список кодов прав")
for code in codes:
if code not in permission_codes:
raise SeedError(f"{where}: право «{code}» не описано в permissions.yaml")
duplicates = sorted({c for c in codes if codes.count(c) > 1})
if duplicates:
raise SeedError(f"{where}: право «{duplicates[0]}» указано больше одного раза")
result[role_code] = list(codes)
return result
CHART_COLUMNS = ["code", "parent_code", "name", "kind", "row_type", "is_off_balance"]
#: нормализованный код. Цифровые: NN, NNN, NN.MM, NN.MM.K (K без ведущего нуля: 76.07.1, как в ТЗ; 60.1 — нет),
#: буквенные субсчета: 01.К, 76.АВ, 62.ОТ.1, 99.02.И, 004.К. Буквенные корни плана 1С: МЦ, НЕ, ГТД, РВ.1, Я68.01 —
#: буквы кириллицей, как в выгрузке 1С (issue #23, ASSUMPTION(OQ-113): выгрузка полна).
_LETTER = "[А-ЯЁ]"
_ANALYTIC = rf"(?:\.[1-9]\d?|\.{_LETTER})"
_ACCOUNT_CODE = re.compile(
rf"^(?:\d{{2,3}}(?:\.(?:\d{{2}}|{_LETTER}{{1,2}}){_ANALYTIC}?)?"
rf"|(?:{_LETTER}{{2,3}}|Я\d{{2}})(?:\.(?:\d{{1,2}}|{_LETTER}{{1,2}}){_ANALYTIC}?)?)$"
)
_ROW_TYPE_BY_DEPTH = (AccountRowType.account, AccountRowType.subaccount, AccountRowType.analytic)
#: забалансовые счета 1С: 001–012 и буквенные корни (МЦ, НЕ, ГТД, ИНВ, КВ, ОТ, РВ, УСН); 000 и «Я…» — балансовые
_OFF_BALANCE_DIGIT_ROOTS = {f"{n:03d}" for n in range(1, 13)}
_LETTER_ROOT = re.compile(rf"^{_LETTER}{{2,3}}$")
_THREE_DIGIT_ROOTS = {f"{n:03d}" for n in range(0, 13)}
_OFF_BALANCE_CODE_LENGTH = 3 # трёхзначные корни — 000–012, балансовые синтетические — двузначные
def _is_off_balance_root(root: str) -> bool:
return root in _OFF_BALANCE_DIGIT_ROOTS or _LETTER_ROOT.match(root) is not None
def _chart_row(row: dict[str | None, Any], where: str, code: str) -> AccountSeed:
"""Проверка одной строки плана счетов, не требующая других строк."""
where = f"{where} ({code})"
if not _ACCOUNT_CODE.match(code):
raise SeedError(f"{where}: код не нормализован, ожидается вид 60, 001, 60.01, 76.07.1, 01.К или МЦ.04")
root = code.split(".", maxsplit=1)[0]
if len(root) == _OFF_BALANCE_CODE_LENGTH and root.isdigit() and root not in _THREE_DIGIT_ROOTS:
raise SeedError(f"{where}: трёхзначные коды допустимы только для 000–012")
off_balance_root = _is_off_balance_root(root)
if root == "00":
raise SeedError(f"{where}: счёта 00 не существует")
kind_text = str(row["kind"]).strip()
try:
kind = AccountKind(kind_text) if kind_text else None
except ValueError:
raise SeedError(f"{where}: неизвестный kind «{kind_text}» (допустимы A, P, AP)") from None
row_type_text = str(row["row_type"]).strip()
try:
row_type = AccountRowType(row_type_text)
except ValueError:
raise SeedError(
f"{where}: неизвестный row_type «{row_type_text}» (допустимы account, subaccount, analytic)"
) from None
flag_text = str(row["is_off_balance"]).strip()
if flag_text not in ("true", "false"):
raise SeedError(f"{where}: is_off_balance должен быть true или false, получено «{flag_text}»")
is_off_balance = flag_text == "true"
expected_type = _ROW_TYPE_BY_DEPTH[code.count(".")]
if row_type is not expected_type:
raise SeedError(
f"{where}: row_type «{row_type.value}» не соответствует глубине кода (ожидался «{expected_type.value}»)"
)
if off_balance_root and not is_off_balance:
raise SeedError(f"{where}: забалансовый счёт должен иметь is_off_balance=true")
if not off_balance_root and is_off_balance:
raise SeedError(f"{where}: is_off_balance=true допустим только для счетов 001–012 и буквенных (МЦ, НЕ, ГТД…)")
if row_type is AccountRowType.account and kind is None:
raise SeedError(f"{where}: у синтетического счёта должен быть задан kind")
name = str(row["name"]).strip()
if not name:
raise SeedError(f"{where}: пустое название счёта")
parent_text = str(row["parent_code"]).strip()
return AccountSeed(code, parent_text or None, name, kind, row_type, is_off_balance)
def _has_ancestor(code: str, accounts: dict[str, AccountSeed]) -> bool:
"""Есть ли в плане хотя бы один код-предок (по префиксу сегментов)."""
parts = code.split(".")
return any(".".join(parts[:n]) in accounts for n in range(1, len(parts)))
def load_chart_of_accounts(core: Path, seed_dir: Path) -> dict[str, AccountSeed]:
"""План счетов из `chart_of_accounts.csv` (UTF-8): проверка до записи, порядок файла сохраняется."""
path = core / "chart_of_accounts.csv"
if not path.is_file():
raise SeedError(f"{place(path, seed_dir)}: файл не найден")
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError as error:
raise SeedError(f"{place(path, seed_dir)}: не удалось прочитать файл как UTF-8: {error}") from error
reader = csv.DictReader(text.splitlines())
if reader.fieldnames != CHART_COLUMNS:
raise SeedError(f"{place(path, seed_dir)}: заголовок должен быть «{','.join(CHART_COLUMNS)}»")
accounts: dict[str, AccountSeed] = {}
lines: dict[str, int] = {}
for line_no, row in enumerate(reader, start=2):
where = f"{place(path, seed_dir)}, строка {line_no}"
if None in row or any(value is None for value in row.values()):
raise SeedError(f"{where}: число полей не совпадает с заголовком")
code = str(row["code"]).strip()
account = _chart_row(row, where, code)
if code in accounts:
raise SeedError(f"{where} ({code}): код уже встречался в строке {lines[code]}")
accounts[code] = account
lines[code] = line_no
for code, account in accounts.items():
where = f"{place(path, seed_dir)}, строка {lines[code]} ({code})"
expected_parent = code.rsplit(".", 1)[0] if "." in code else None
if account.parent_code != expected_parent:
if expected_parent is None:
raise SeedError(f"{where}: у синтетического счёта parent_code должен быть пустым")
if account.parent_code is None and not _has_ancestor(code, accounts):
continue # сирота выгрузки 1С («Я68.01» без «Я68»): родителей не выдумываем
raise SeedError(f"{where}: родитель должен быть «{expected_parent}», указан «{account.parent_code or ''}»")
if account.parent_code is not None and account.parent_code not in accounts:
raise SeedError(f"{where}: родитель «{account.parent_code}» не найден в файле")
return accounts
def load_seed(seed_dir: Path | None = None, *, dev: bool = False, dev_groups: bool | None = None) -> SeedData:
"""Фаза 1: прочитать и проверить все файлы. Бросает `SeedError`; к БД не обращается.
`dev_groups` — грузить ли dev-ГК (`dev/company_groups.yaml`) при `dev`; по умолчанию да. Тесты API засевают
справочники без ГК (`dev_groups=False`), чтобы начинать с пустых справочников.
"""
seed_dir = seed_dir or DEFAULT_SEED_DIR
core = seed_dir / "core"
data = SeedData()
data.roles = _load_roles(core, seed_dir)
data.permissions = _load_permissions(core, seed_dir)
data.role_permissions = _load_role_permissions(
core, seed_dir, {r.code for r in data.roles}, {p.code for p in data.permissions}
)
data.regional_offices = _load_regional_offices(core, seed_dir)
data.accounts = load_chart_of_accounts(core, seed_dir)
# правила и чек-лист проверяют свои ссылки на счета по плану счетов набора — до любой записи в БД
codes = account_codes(data)
data.elimination_rules = load_elimination_rules(core, seed_dir, codes)
data.required_document_rules = load_required_document_rules(core, seed_dir, codes)
if dev:
_load_dev(data, seed_dir / "dev", seed_dir, groups=dev if dev_groups is None else dev_groups)
return data
def _load_dev_regional_offices(data: SeedData, dev_dir: Path, seed_dir: Path) -> None:
"""Dev-филиалы (`dev/regional_offices.yaml`) дописываются к филиалам набора; имена уникальны во всём наборе."""
path = dev_dir / "regional_offices.yaml"
offices = _load_regional_offices(dev_dir, seed_dir, "regional_offices.yaml")
for office in offices:
if office.is_head_office:
raise SeedError(
f"{place(path, seed_dir)}: филиал «{office.name}» объявлен головным офисом, в dev — только РФ"
)
data.regional_offices.extend(offices)
ensure_unique([o.name for o in data.regional_offices], "филиал", path, seed_dir)
def _load_dev_users(data: SeedData, dev_dir: Path, seed_dir: Path) -> None:
"""Dev-пользователи (`dev/users.yaml`): ссылки на роли и филиалы проверяются по набору до любой записи в БД."""
path = dev_dir / "users.yaml"
records = checked_records(
read_yaml(path, seed_dir),
{"email", "full_name", "employee_no", "regional_office", "business_segment", "is_active", "roles"},
{"email", "full_name", "regional_office", "roles"},
path,
seed_dir,
)
role_codes = {r.code for r in data.roles}
office_names = {o.name for o in data.regional_offices}
users = []
for index, record in enumerate(records):
where = place(path, seed_dir, index, str(record.get("email")))
office = str(str_field(record, "regional_office", where))
if office not in office_names:
raise SeedError(
f"{where}: филиал «{office}» не описан ни в core/regional_office.yaml, ни в dev/regional_offices.yaml"
)
roles = record["roles"]
if not isinstance(roles, list) or not all(isinstance(code, str) for code in roles):
raise SeedError(f"{where}: поле «roles» должно быть списком кодов ролей")
if len(roles) != 1:
raise SeedError(f"{where}: у dev-пользователя должна быть ровно одна роль, указано {len(roles)}")
if roles[0] not in role_codes:
raise SeedError(f"{where}: роль «{roles[0]}» не описана в roles.yaml")
segment_text = str_field(record, "business_segment", where, optional=True)
try:
segment = BusinessSegment(segment_text) if segment_text is not None else None
except ValueError:
raise SeedError(
f"{where}: неизвестный business_segment «{segment_text}» (допустимы DKB, DMSB, DM)"
) from None
users.append(
UserSeed(
email=str(str_field(record, "email", where)),
full_name=str(str_field(record, "full_name", where)),
employee_no=str_field(record, "employee_no", where, optional=True),
regional_office=office,
business_segment=segment,
is_active=bool_field(record, "is_active", where, default=True),
role_codes=(roles[0],),
)
)
ensure_unique([u.email for u in users], "email", path, seed_dir)
data.users = users
_ENTITY_KEYS = {"inn", "name", "entity_type", "aliases", "kpp"}
_RELATED_KEYS = {"kind", "name", "members"}
def _str_list(value: Any, what: str, where: str) -> list[str]:
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
raise SeedError(f"{where}: поле «{what}» должно быть списком строк")
return value
def _dev_legal_entities(record: dict[str, Any], where: str) -> list[LegalEntitySeed]:
items = record.get("legal_entities")
if not isinstance(items, list) or not items:
raise SeedError(f"{where}: поле «legal_entities» должно быть непустым списком")
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, kpp")
inn = normalize_inn(str(item["inn"]))
try:
entity_type = EntityType(str(item["entity_type"]))
except ValueError:
raise SeedError(f"{where}: ИНН {inn}: entity_type «{item['entity_type']}» (допустимы ЮЛ, ИП)") from None
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:
raise SeedError(f"{where}: ИНН {inn}: алиас из одних ОПФ и пунктуации")
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), kpp=kpp)
)
return entities
def _dev_related_groups(record: dict[str, Any], where: str, inns: set[str]) -> list[RelatedGroupSeed]:
items = record.get("related_groups", [])
if not isinstance(items, list):
raise SeedError(f"{where}: поле «related_groups» должно быть списком")
groups: list[RelatedGroupSeed] = []
for item in items:
if not isinstance(item, dict) or set(item) != _RELATED_KEYS:
raise SeedError(f"{where}: ГСК/ГСЗ описывается полями kind, name, members")
try:
kind = RelatedGroupKind(str(item["kind"]))
except ValueError:
raise SeedError(f"{where}: kind «{item['kind']}» (допустимы gsk, gsz)") from None
members = [normalize_inn(inn) for inn in _str_list(item["members"], "members", where)]
if not members:
raise SeedError(f"{where}: группа «{item['name']}» без членов")
unknown = [inn for inn in members if inn not in inns]
if unknown:
raise SeedError(f"{where}: группа «{item['name']}»: ИНН {unknown} не описаны в legal_entities этой ГК")
if len(set(members)) != len(members):
raise SeedError(f"{where}: группа «{item['name']}»: ИНН членов повторяются")
groups.append(RelatedGroupSeed(kind=kind, name=str(item["name"]), member_inns=tuple(members)))
names = [group.name for group in groups]
if len(set(names)) != len(names):
raise SeedError(f"{where}: имена ГСК/ГСЗ внутри ГК повторяются")
return groups
def _load_dev_company_groups(data: SeedData, dev_dir: Path, seed_dir: Path) -> None:
"""Dev-ГК (`dev/company_groups.yaml`): ЮЛ/ИП, алиасы, ГСК/ГСЗ; ссылки на филиалы и между собой — до записи в БД."""
path = dev_dir / "company_groups.yaml"
records = checked_records(
read_yaml(path, seed_dir),
{"name", "crm_id", "regional_office", "legal_entities", "related_groups"},
{"name", "regional_office", "legal_entities"},
path,
seed_dir,
)
office_names = {o.name for o in data.regional_offices}
groups: list[CompanyGroupSeed] = []
all_inns: list[str] = []
members_by_kind: dict[RelatedGroupKind, list[str]] = {kind: [] for kind in RelatedGroupKind}
for index, record in enumerate(records):
where = place(path, seed_dir, index, str(record.get("name")))
office = str(str_field(record, "regional_office", where))
if office not in office_names:
# филиал из выгрузки ЕЦП («Санкт-Петербургский РФ») заводится вместе с dev-ГК как РФ; без dev-ГК
# (`dev_groups=False`, тесты API) его нет, и импорт выгрузки создаёт его сам
data.regional_offices.append(RegionalOfficeSeed(name=office, is_head_office=False, is_active=True))
office_names.add(office)
entities = _dev_legal_entities(record, where)
related = _dev_related_groups(record, where, {entity.inn for entity in entities})
for group in related:
members_by_kind[group.kind].extend(group.member_inns)
all_inns.extend(entity.inn for entity in entities)
groups.append(
CompanyGroupSeed(
name=str(str_field(record, "name", where)),
crm_id=str_field(record, "crm_id", where, optional=True),
regional_office=office,
legal_entities=tuple(entities),
related_groups=tuple(related),
)
)
ensure_unique([group.name for group in groups], "ГК", path, seed_dir)
ensure_unique(all_inns, "ИНН", path, seed_dir)
for kind, inns in members_by_kind.items():
# ЮЛ входит не более чем в одну группу каждого вида (уникальный индекс related_group_member по (ЮЛ, вид))
ensure_unique(inns, f"ЮЛ в группах вида {kind.value}", path, seed_dir)
data.company_groups = groups
def _load_dev(data: SeedData, dev_dir: Path, seed_dir: Path, *, groups: bool = True) -> None:
"""Dev-данные: филиалы, затем пользователи и (если `groups`) ГК — они ссылаются на филиалы (и роли) набора."""
_load_dev_regional_offices(data, dev_dir, seed_dir)
_load_dev_users(data, dev_dir, seed_dir)
if groups:
_load_dev_company_groups(data, dev_dir, seed_dir)