seed/dev/company_groups.yaml: ГК «Русский Бисквит» ровно как в fixtures/directories/ecp-export-russkiy-biskvit.xlsx (20 ЮЛ/ИП, crm_id, филиал «Санкт-Петербургский РФ», ГСК из РБ и Счастливочной), ГК Унагранде/НПЗ/Межениновская по ИНН из форм ББ/ОФР; алиасы под имена контрагентов и шапок ОСВ («Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ», ...), чтобы документы из fixtures ложились на «своих» ЮЛ и резолвились. Загрузчик проверяет ИНН, виды, ссылки членов и уникальность «ЮЛ в одной ГСК/ГСЗ» до записи; apply — upsert по name/inn/(ГК, name), коды из последовательностей API, без удалений. `--no-dev-groups` / `run_seed(dev_groups=False)` — dev-данные без ГК; фикстуры тестов API используют его, чтобы начинать с пустых справочников. make check: 1132 passed; интеграция seed/import: 23 passed. Два теста падают и на main из-за обновлённых golden (test_mapping_golden 88 → 113, test_consolidation_fixtures UNMAPPED_TOTAL) — отдельная issue. Closes #31 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
194 lines
6.6 KiB
Python
194 lines
6.6 KiB
Python
"""Модель проверенного набора 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, ...]
|
||
|
||
|
||
@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)
|