- модели 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
478 lines
25 KiB
Python
478 lines
25 KiB
Python
"""Фаза 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)
|