"""Модель проверенного набора seed и отчёта о применении (без доступа к БД).""" import datetime from dataclasses import dataclass, field from src.models.enums import ( AccountKind, AccountRowType, BusinessSegment, ConsolidationApproach, DocumentType, EntityType, PermissionAction, PermissionObject, RelatedGroupKind, StatementType, ) class SeedError(Exception): """Ошибка файлов seed (отсутствие, формат, ссылочная целостность); сообщение — по-русски, с файлом и записью.""" @dataclass(frozen=True) class RegionalOfficeSeed: name: str is_head_office: bool is_active: bool @dataclass(frozen=True) class RoleSeed: code: str name: str description: str | None is_system: bool is_active: bool @dataclass(frozen=True) class PermissionSeed: code: str object_type: PermissionObject action: PermissionAction name: str description: str | None @dataclass(frozen=True) class AccountSeed: """Строка плана счетов; `code` нормализован (60.01, 76.07.1), `parent_code` — код родителя или None.""" code: str parent_code: str | None name: str kind: AccountKind | None row_type: AccountRowType is_off_balance: bool @dataclass(frozen=True) class RuleSideSeed: """Состав счетов одной стороны правила: `accounts` — включённые, `excluded` — исключения внутри маски.""" accounts: tuple[str, ...] excluded: tuple[str, ...] @dataclass(frozen=True) class EliminationRuleSeed: """Правило элиминации версии `version_no` (§4); `sides` — company_no (1, 2) -> состав счетов.""" statement_type: StatementType rule_no: int version_no: int article_1_line: str article_2_line: str side_1: str measure: str elimination_mode: str vat_handling: str | None excise_adjust: bool comment: str | None valid_from: datetime.date sides: dict[int, RuleSideSeed] @dataclass(frozen=True) class RequiredDocumentRuleSeed: """Строка чек-листа; `account_code` задан только для OSV.""" approach: ConsolidationApproach document_type: DocumentType account_code: str | None is_mandatory: bool @dataclass(frozen=True) class UserSeed: """Dev-пользователь; `regional_office` — имя филиала, `role_codes` — коды ролей (в dev-наборе ровно одна).""" email: str full_name: str employee_no: str | None regional_office: str business_segment: BusinessSegment | None is_active: bool role_codes: tuple[str, ...] @dataclass(frozen=True) class LegalEntitySeed: """ЮЛ/ИП dev-ГК: ИНН — натуральный ключ; алиасы — как контрагент записан в ОСВ или в шапке документа.""" inn: str name: str entity_type: EntityType aliases: tuple[str, ...] kpp: str | None = None # v5.17: КПП, если известен (в dev-данных пока не задан — значений не выдумываем) @dataclass(frozen=True) class RelatedGroupSeed: """ГСК/ГСЗ dev-ГК: имя уникально внутри ГК, члены — ИНН ЮЛ той же ГК.""" kind: RelatedGroupKind name: str member_inns: tuple[str, ...] @dataclass(frozen=True) class CompanyGroupSeed: """Dev-ГК (`dev/company_groups.yaml`): имя — натуральный ключ; филиал — по имени из набора.""" name: str crm_id: str | None regional_office: str legal_entities: tuple[LegalEntitySeed, ...] related_groups: tuple[RelatedGroupSeed, ...] @dataclass class SeedData: """Проверенный набор seed; ничего не знает о БД.""" regional_offices: list[RegionalOfficeSeed] = field(default_factory=list) roles: list[RoleSeed] = field(default_factory=list) permissions: list[PermissionSeed] = field(default_factory=list) #: код роли -> коды прав (порядок как в файле) role_permissions: dict[str, list[str]] = field(default_factory=dict) #: код счёта -> счёт; порядок файла (родитель раньше потомков). Реестр кодов для загрузчика правил: `account_codes` accounts: dict[str, AccountSeed] = field(default_factory=dict) elimination_rules: list[EliminationRuleSeed] = field(default_factory=list) required_document_rules: list[RequiredDocumentRuleSeed] = field(default_factory=list) #: только при `--dev` (seed/dev/users.yaml); при `--dev` dev-филиалы дописываются в `regional_offices` users: list[UserSeed] = field(default_factory=list) #: только при `--dev` (seed/dev/company_groups.yaml): ГК с ЮЛ/ИП, алиасами и ГСК/ГСЗ company_groups: list[CompanyGroupSeed] = field(default_factory=list) def account_codes(data: SeedData) -> frozenset[str]: """Коды плана счетов набора: правила элиминации и чек-лист проверяют свои ссылки на счета по этому реестру.""" return frozenset(data.accounts) @dataclass class TableStat: inserted: int = 0 updated: int = 0 unchanged: int = 0 #: шаг выполнен не полностью или пропущен (матрица прав уже заполнена); текст — для отчёта CLI skipped_reason: str | None = None @dataclass class SeedReport: """Итог применения: по таблицам — сколько строк вставлено / обновлено / не изменилось (или почему шаг пропущен).""" tables: dict[str, TableStat] = field(default_factory=dict) def stat(self, table: str) -> TableStat: return self.tables.setdefault(table, TableStat()) def format(self) -> str: lines = ["Seed применён (вставлено / обновлено / без изменений):"] for name, stat in self.tables.items(): counts = f"{stat.inserted} / {stat.updated} / {stat.unchanged}" if stat.skipped_reason is None: lines.append(f" {name}: {counts}") elif stat.inserted: lines.append(f" {name}: {counts} (только связи новых ролей и прав; {stat.skipped_reason})") else: lines.append(f" {name}: пропущено — {stat.skipped_reason}") return "\n".join(lines)