"""Фаза 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: """Код права `_`; при совпадении объекта и действия — одно слово (`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)