322 lines
16 KiB
Python
322 lines
16 KiB
Python
"""Фаза 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)
|