Andrey Epifancev 93cd1f8aa0 feat(seed): dev-ГК под документы fixtures — «Русский Бисквит» из выгрузки ЕЦП и владельцы форм ДКБ
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
2026-10-06 17:05:46 +04:00

194 lines
6.6 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.

"""Модель проверенного набора 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)