Andrey Epifancev 75feab6015 test: гигиена тестов Среза 2c и нормализация email seed в одном месте
- test_api_documents: тавтология по counterparty_name строки 18 ОСВ_66 заменена
  значениями снимка (T9.3).
- test_mapping_golden: план счетов не пуст и есть строки с account_id (T8.6).
- e2e: код ответа DELETE проверяется после try/finally — не маскирует причину
  падения (T11.2).
- test_counterparty_resolve: единственное точное имя побеждает неоднозначность
  нормализованного (T6.1).
- test_domain_osv_mapping: шапка «ООО» без ИНН → UNKNOWN (T5.1).
- seed_lib/apply: email пользователя нормализуется один раз для upsert и ролей,
  поведение не меняется (T1.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 00:39:40 +04:00

289 lines
13 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.

"""Фаза 2: применение проверенного набора к БД — upsert по натуральному ключу, без удалений и без commit."""
import uuid
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed_lib.apply_rules import apply_elimination_rules, apply_required_document_rules
from scripts.seed_lib.model import SeedData, SeedReport, TableStat
from src.models.access import AppUser, Permission, Role, RolePermission, UserRole
from src.models.accounting import ChartOfAccount
from src.models.directories import RegionalOffice
async def _upsert(
session: AsyncSession,
model: Any,
key: str,
rows: list[dict[str, Any]],
stat: TableStat,
) -> set[Any]:
"""Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет.
Возвращает ключи строк, ВСТАВЛЕННЫХ этим прогоном (для матрицы прав: связи новых ролей и прав).
"""
if not rows:
return set()
key_column = getattr(model, key)
value_names = [name for name in rows[0] if name != key]
existing: dict[Any, tuple[Any, ...]] = {
row[0]: tuple(row[1:])
for row in (await session.execute(select(key_column, *(getattr(model, name) for name in value_names)))).all()
}
changed = []
inserted: set[Any] = set()
for row in rows:
current = existing.get(row[key])
if current is None:
stat.inserted += 1
inserted.add(row[key])
changed.append(row)
elif current != tuple(row[name] for name in value_names):
stat.updated += 1
changed.append(row)
else:
stat.unchanged += 1
if changed:
statement = pg_insert(model)
statement = statement.on_conflict_do_update(
index_elements=[key_column], set_={name: statement.excluded[name] for name in value_names}
)
await session.execute(statement, changed)
return inserted
MATRIX_SKIPPED = (
"матрица прав уже заполнена; seed не возвращает права, снятые администратором, и добавляет только связи ролей "
"и прав, впервые созданных этим запуском (правка — PUT /admin/roles/{code}/permissions)"
)
async def _apply_role_permissions( # noqa: PLR0913 - набор, статистика и коды впервые вставленных ролей и прав
session: AsyncSession,
data: SeedData,
stat: TableStat,
*,
new_roles: set[str],
new_permissions: set[str],
) -> None:
"""Матрица «роль × право» из файла (issue #9 п.7, ревью R-3).
Пустая `role_permission` (первичная загрузка, в том числе после того, как администратор снял все связи) —
вставляется вся матрица файла. Непустая — матрицей владеет администратор: вставляются только пары, у которых
роль ИЛИ право вставлены этим же прогоном (`new_roles`/`new_permissions` — их администратор снять не мог);
остальные пары не трогаются (`stat.skipped_reason`): снятые права не возвращаются, добавленные не отзываются.
"""
matrix_filled = await session.scalar(select(RolePermission.role_id).limit(1)) is not None
if matrix_filled:
stat.skipped_reason = MATRIX_SKIPPED
role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()}
permission_ids = {code: id_ for id_, code in (await session.execute(select(Permission.id, Permission.code))).all()}
new_rows = [
{
"role_id": role_ids[role_code],
"permission_id": permission_ids[code],
"granted_by": None,
"granted_at": func.now(),
}
for role_code, codes in data.role_permissions.items()
for code in codes
if not matrix_filled or role_code in new_roles or code in new_permissions
]
if new_rows:
stat.inserted += len(new_rows)
# .values(): func.now() допустим только во встроенном VALUES, не в executemany
statement = (
pg_insert(RolePermission)
.values(new_rows)
.on_conflict_do_nothing(index_elements=["role_id", "permission_id"])
)
await session.execute(statement)
async def _apply_chart_of_accounts(session: AsyncSession, data: SeedData, stat: TableStat) -> None:
"""План счетов: upsert по `code` в порядке глубины (родитель раньше потомков), одним `run_seed` и одной транзакцией.
Идентификаторы новых строк генерирует приложение заранее, поэтому `parent_id` известен при вставке и второй проход
не нужен; существующим строкам при расхождении с файлом обновляются поля и `parent_id`. Удалений нет.
"""
if not data.accounts:
return
stored = (
await session.execute(
select(
ChartOfAccount.code,
ChartOfAccount.id,
ChartOfAccount.parent_id,
ChartOfAccount.name,
ChartOfAccount.kind,
ChartOfAccount.row_type,
ChartOfAccount.is_off_balance,
)
)
).all()
ids = {row.code: row.id for row in stored}
existing = {row.code: (row.parent_id, row.name, row.kind, row.row_type, row.is_off_balance) for row in stored}
for code in data.accounts:
ids.setdefault(code, uuid.uuid4())
rows = []
for code, account in sorted(data.accounts.items(), key=lambda item: item[0].count(".")):
parent_id = ids[account.parent_code] if account.parent_code is not None else None
values = (parent_id, account.name, account.kind, account.row_type, account.is_off_balance)
if code not in existing:
stat.inserted += 1
elif existing[code] != values:
stat.updated += 1
else:
stat.unchanged += 1
continue
rows.append(
{
"id": ids[code],
"code": code,
"parent_id": parent_id,
"name": account.name,
"kind": account.kind,
"row_type": account.row_type,
"is_off_balance": account.is_off_balance,
}
)
if rows:
statement = pg_insert(ChartOfAccount)
statement = statement.on_conflict_do_update(
index_elements=[ChartOfAccount.code],
set_={
name: statement.excluded[name] for name in ("parent_id", "name", "kind", "row_type", "is_off_balance")
},
)
await session.execute(statement, rows)
async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableStat, link_stat: TableStat) -> None:
"""Dev-пользователи: upsert `app_user` по `email`, затем `user_role` по (user_id, role_id).
Email из файла нормализуется (`strip().lower()`, DBML v5.15) — и при сверке с БД, и при вставке.
`created_at` ставится при вставке, `updated_at` — при вставке и при изменении данных (триггера в БД нет);
неизменившиеся строки не трогаются. Назначения ролей только добавляются: роль, убранная из файла, остаётся
назначенной (удалений нет). `external_id` не задаётся (SSO-субъект dev-пользователя неизвестен).
"""
if not data.users:
return
office_ids = {
name: id_ for id_, name in (await session.execute(select(RegionalOffice.id, RegionalOffice.name))).all()
}
role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()}
stored = {
(row.email or "").lower(): row
for row in (
await session.execute(
select(
AppUser.id,
AppUser.email,
AppUser.full_name,
AppUser.employee_no,
AppUser.regional_office_id,
AppUser.business_segment,
AppUser.is_active,
)
)
).all()
}
user_ids: dict[str, Any] = {}
users = [(user.email.strip().lower(), user) for user in data.users] # email нормализуется один раз
for email, user in users:
values = {
"full_name": user.full_name,
"employee_no": user.employee_no,
"regional_office_id": office_ids[user.regional_office],
"business_segment": user.business_segment,
"is_active": user.is_active,
}
row = stored.get(email)
if row is None:
user_stat.inserted += 1
user_ids[email] = uuid.uuid4()
await session.execute(
pg_insert(AppUser).values(
id=user_ids[email], email=email, **values, created_at=func.now(), updated_at=func.now()
)
)
continue
user_ids[email] = row.id
if any(getattr(row, name) != value for name, value in values.items()):
user_stat.updated += 1
await session.execute(update(AppUser).where(AppUser.id == row.id).values(**values, updated_at=func.now()))
else:
user_stat.unchanged += 1
existing = set((await session.execute(select(UserRole.user_id, UserRole.role_id))).all())
new_rows = []
for email, user in users:
for role_code in user.role_codes:
pair = (user_ids[email], role_ids[role_code])
if pair in existing:
link_stat.unchanged += 1
else:
link_stat.inserted += 1
new_rows.append({"id": uuid.uuid4(), "user_id": pair[0], "role_id": pair[1]})
if new_rows:
await session.execute(
pg_insert(UserRole).on_conflict_do_nothing(index_elements=["user_id", "role_id"]), new_rows
)
async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
"""Фаза 2: upsert набора в порядке зависимостей. Без commit — транзакцией управляет вызывающий."""
report = SeedReport()
await _upsert(
session,
RegionalOffice,
"name",
[{"name": o.name, "is_head_office": o.is_head_office, "is_active": o.is_active} for o in data.regional_offices],
report.stat("regional_office"),
)
new_roles = await _upsert(
session,
Role,
"code",
[
{
"code": r.code,
"name": r.name,
"description": r.description,
"is_system": r.is_system,
"is_active": r.is_active,
}
for r in data.roles
],
report.stat("role"),
)
new_permissions = await _upsert(
session,
Permission,
"code",
[
{
"code": p.code,
"object_type": p.object_type,
"action": p.action,
"name": p.name,
"description": p.description,
}
for p in data.permissions
],
report.stat("permission"),
)
await _apply_role_permissions(
session, data, report.stat("role_permission"), new_roles=new_roles, new_permissions=new_permissions
)
await _apply_chart_of_accounts(session, data, report.stat("chart_of_account"))
await apply_elimination_rules(
session, data, report.stat("elimination_rule"), report.stat("elimination_rule_account")
)
await apply_required_document_rules(session, data, report.stat("required_document_rule"))
# dev-пользователи — после ролей и филиалов (в том числе dev-филиалов, они входят в `regional_offices`)
await _apply_users(session, data, report.stat("app_user"), report.stat("user_role"))
return report