- 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
289 lines
13 KiB
Python
289 lines
13 KiB
Python
"""Фаза 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
|