2026-10-03 21:41:07 +04:00

322 lines
16 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.

"""Фаза 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,
PermissionSeed,
RegionalOfficeSeed,
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.models.enums import AccountKind, AccountRowType, BusinessSegment, PermissionAction, PermissionObject
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 — нет)
_ACCOUNT_CODE = re.compile(r"^\d{2,3}(\.\d{2}(\.[1-9]\d?)?)?$")
_ROW_TYPE_BY_DEPTH = (AccountRowType.account, AccountRowType.subaccount, AccountRowType.analytic)
_OFF_BALANCE_ROOTS = {f"{n:03d}" for n in range(1, 12)}
_OFF_BALANCE_CODE_LENGTH = 3 # забалансовые счета — трёхзначные (001–011), балансовые синтетические — двузначные
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")
root = code.split(".", maxsplit=1)[0]
off_balance_root = len(root) == _OFF_BALANCE_CODE_LENGTH
if off_balance_root and root not in _OFF_BALANCE_ROOTS:
raise SeedError(f"{where}: трёхзначные коды допустимы только для 001–011")
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–011")
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 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 должен быть пустым")
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) -> SeedData:
"""Фаза 1: прочитать и проверить все файлы. Бросает `SeedError`; к БД не обращается."""
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)
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
def _load_dev(data: SeedData, dev_dir: Path, seed_dir: Path) -> None:
"""Dev-данные (задача 15): филиалы, затем пользователи — они ссылаются на филиалы и роли набора."""
_load_dev_regional_offices(data, dev_dir, seed_dir)
_load_dev_users(data, dev_dir, seed_dir)