Перейти к основному содержимому

Admin CRUD отельеров (создание / список / карточка / правка / архив / lookup) — область admin

CRUD-группа платформенного справочника Отельер (HotelierEntity, реестр операций op#38–43, реестр операций Chessboard API) области admin контекста chessboard. Отельер — оператор апарт-отеля; на него ссылается Project.HotelierId (INV-PRJ-4). Справочник без бизнес-логики и событий (ADR-0056); дом полей — дом полей HotelierEntity. Шесть операций одной формы описаны в одном файле; полный алгоритм — на каждую операцию ниже.

Назначение

Дать администратору платформы (admin API) вести реестр отельеров: завести оператора, найти в списке/поиске, открыть карточку, переименовать или сменить лого, отправить в архив (soft-delete под guard-ом INV-HTL-2) и получить lookup-срез для форм привязки проекта к отельеру. Наполненный справочник застройщики и агенты читают отдельным lookup (Lookup справочников банков/отельеров). Owning-контекст — chessboard; feature group — hoteliers.

Metadata

ПолеЗначение
Сервис/контекстchessboard
API areaadmin (администратор платформы, ADR-0053, audience pin.chessboard.admin)
Feature grouphoteliers
Статусdraft
Документdocs/06-services/chessboard/api/admin/hoteliers-crud.md

Актор и доступ (общий для op#38–43)

ПроверкаЗначение
Actor typeАдминистратор платформы через admin API (m2m-токен, audience pin.chessboard.admin)
Auth policy / Permissionschessboard.hoteliers.manage (каталог permissions)
Scope (RBAC)Платформа — справочник глобальный, не сужается
Record-levelне применяется
Tenant isolationНе применяется — справочник платформенный, не привязан к застройщику (ADR-0052 п.3)
AuditОбязателен на мутациях: Created/Updated/DeletedByUserId = X-Admin-User-Id (header, Guid)

HTTP-контракт (общие правила)

ПолеЗначение
Route prefix/api/v1/admin/
Idempotency headerне применяется (Create дедуп по имени INV-HTL-1; PUT — сверка версии; DELETE — идемпотентный soft-delete)
Correlationсквозной trace через заголовок traceparent
Audit headerX-Admin-User-Id (Guid, обязателен на POST/PUT/DELETE)
#ОперацияMethodRouteSuccess
38createHotelierPOST/api/v1/admin/hoteliers201 (Location …/hoteliers/{id})
39searchHoteliersGET/api/v1/admin/hoteliers200
40getHotelierGET/api/v1/admin/hoteliers/{hotelierId}200
41updateHotelierPUT/api/v1/admin/hoteliers/{hotelierId}200
42deleteHotelier (архив)DELETE/api/v1/admin/hoteliers/{hotelierId}200
43lookupHoteliers / lookupHotelierByIdGET/api/v1/admin/lookup/hoteliers (+/{hotelierId})200

Входные данные / параметры запроса

Ограничения полей — из дома полей HotelierEntity (здесь не дублируются). Команды записи (op#38/op#41) принимают тело HotelierWriteModel; id — из route; аудит — из header X-Admin-User-Id.

CreateHotelierCommand / UpdateHotelierCommand (тело + route + header)

ПолеИсточникТипОбязательностьВалидация / Ограничения
AdminUserIdheader X-Admin-User-IdGuidДанепустой; используется для аудита
HotelierIdrouteGuidДа (только update)непустой; существующий отельер (иначе 404)
Namebody (HotelierWriteModel)stringДаtrim, 1..256, не пусто; уникально среди активных (INV-HTL-1)
LogoFileIdbody (HotelierWriteModel)Guid?НетFileId storage/CDN (без FK; существование — граница storage)
VersionbodylongДа (только update)>= 1; сверка с текущей версией отельера (optimistic)

SearchHoteliersQuery (query)

ПолеИсточникТипОбязательностьВалидация / Ограничения
Page / PageSizequeryintНет (1 / 20)Page >= 1; PageSize 1..100
Searchquerystring?Нетtrim, <= 256; регистронезависимый поиск подстроки по названию
IncludeArchivedqueryboolНет (default false)false → только активные (не архивные)
SortByqueryHotelierSortByНет (default NameAsc)enum-string: NameAsc / CreatedAtDesc

GetHotelierQuery / DeleteHotelierCommand / LookupHotelierByIdQuery (route)

HotelierId (route, Guid, непустой, существующий → иначе 404). DeleteHotelierCommand дополнительно берёт AdminUserId из header (audit). LookupHoteliersQuerySearch (<= 256) + paging (PageSize 1..50), без IncludeArchived (lookup всегда активные, INV-HTL-2).

Модели ответа

Поля — проекция дома полей HotelierEntity; ниже только состав проекций. Списки op#39/op#43 — постраничный результат (поля Items, Page, PageSize, TotalCount), не сырой массив.

HotelierModel (get op#40, результат update op#41) и HotelierListItem (search op#39)

ПолеТипОбяз.ИсточникОписание
IdGuidДаHotelierEntity.IdИдентификатор отельера.
NamestringДаNameНазвание оператора.
LogoFileIdGuid?НетLogoFileIdЛого (пресайн — storage/CDN).
IsArchivedboolДаcomputed (DeletedAt != null)Архивный флаг.
VersionlongДаVersionДоменная версия (для последующего PUT/DELETE).
CreatedAt / UpdatedAtDateTimeOffsetДаодноимённыеДаты создания / последнего изменения.

HotelierListItem — та же проекция (постраничный список). HotelierModel дополнительно несёт CreatedByUserId/UpdatedByUserId (Guid?, аудит) и DeletedAt (DateTimeOffset?).

HotelierLookupItem (op#43), CreateHotelierResult (op#38), DeleteHotelierResult (op#42)

МодельПоля
HotelierLookupItemId : Guid, Name : string, LogoFileId : Guid? — минимальная проекция для форм (только активные).
CreateHotelierResultId : Guid, CreatedAt : DateTimeOffset, Version : long (= 1).
DeleteHotelierResultId : Guid, IsArchived : bool (= true), DeletedAt : DateTimeOffset, Version : long.

Алгоритм

Все операции выполняет администратор платформы через admin API (m2m-токен, audience pin.chessboard.admin, permission chessboard.hoteliers.manage); мутации проставляют аудит по AdminUserId из X-Admin-User-Id.

Ограничения

Tenant и idempotency-заголовок к операциям не применяются. Справочник не публикует события (ADR-0056).

createHotelier (op#38)

  1. Проверить вход: Name после trim 1..256, не пусто; LogoFileId — необязательный; AdminUserId задан. Нарушение → ValidationError.
  2. Проверить занятость имени среди активных отельеров (INV-HTL-1). Если название уже занято активным отельером → CHESSBOARD_HOTELIER_NAME_TAKEN.
  3. Создать отельера с новым идентификатором, названием (trim), логотипом; версия = 1, не архивный; проставить аудит (создатель и последний изменивший = AdminUserId). При гонке на уникальном имени → CHESSBOARD_HOTELIER_NAME_TAKEN.
  4. Результат: CreateHotelierResult { Id, CreatedAt, Version }, 201 + Location: /api/v1/admin/hoteliers/{Id}.

updateHotelier (op#41)

  1. Проверить вход: Name 1..256, Version >= 1, HotelierId задан. Нарушение → ValidationError.
  2. Найти отельера по HotelierId. Не найден → CHESSBOARD_HOTELIER_NOT_FOUND (404); архивный (DeletedAt != null, архивный не редактируется) → CHESSBOARD_HOTELIER_NOT_FOUND (404). При смене имени — проверить уникальность среди активных, исключая самого отельера (INV-HTL-1); занято → CHESSBOARD_HOTELIER_NAME_TAKEN. Если переданная Version не совпадает с текущей версией отельера → CHESSBOARD_CONCURRENCY_CONFLICT (409).
  3. Применить новое название (trim) и лого; последний изменивший = AdminUserId; версия увеличивается. При конкурентной правке → CHESSBOARD_CONCURRENCY_CONFLICT; при гонке уникального имени → CHESSBOARD_HOTELIER_NAME_TAKEN.
  4. Результат: HotelierModel, 200.

deleteHotelier = архив (op#42)

  1. Проверить вход: HotelierId задан.
  2. Найти отельера. Не найден → CHESSBOARD_HOTELIER_NOT_FOUND (404). Идемпотентность: уже архивный (DeletedAt != null) → вернуть 200 с текущим DeleteHotelierResult (изменений нет).
  3. ArchiveGuard (INV-HTL-2). Если есть хотя бы один не архивированный проект, ссылающийся на этого отельера (Project.HotelierId) → CHESSBOARD_HOTELIER_HAS_PROJECTS (409): архив запрещён, пока есть активные проекты-ссылки. Иначе пометить отельера архивным (DeletedAt = now, DeletedByUserId = AdminUserId, версия увеличивается) — строка физически не удаляется (soft-delete). При конкурентной правке → CHESSBOARD_CONCURRENCY_CONFLICT.
  4. Результат: DeleteHotelierResult { Id, IsArchived = true, DeletedAt, Version }, 200.

searchHoteliers (op#39) / getHotelier (op#40) / lookupHoteliers / lookupHotelierById (op#43) — read-only

  1. Проверить вход: Page/PageSize диапазоны, Search <= 256, SortBy ∈ enum (op#39/op#43); для get и lookup-by-id — HotelierId задан. Нарушение → ValidationError.
  2. Загрузка и выдача:
    • searchHoteliers — постраничный список отельеров; при IncludeArchived == false — только активные; Search — регистронезависимый поиск подстроки по названию; сортировка по SortBy (NameAsc — по названию; CreatedAtDesc — по дате создания). Возвращает HotelierListItem с TotalCount.
    • getHotelier — карточка отельера по HotelierId (архивные видны админу, IsArchived/DeletedAt в проекции); не найден → CHESSBOARD_HOTELIER_NOT_FOUND (404).
    • lookupHoteliers — только активные всегда (INV-HTL-2), PageSize 1..50, поиск по названию, сортировка по названию; lookupHotelierById — отельер по id, в т.ч. архивный (резолв уже привязанного оператора) → не найден → CHESSBOARD_HOTELIER_NOT_FOUND (404).
  3. Результат: search — постраничный HotelierListItem; get — HotelierModel; lookup — постраничный HotelierLookupItem; lookup-by-id — HotelierLookupItem; 200. Маскирование не требуется (PII нет).

Ошибки и failure modes

Сквозные ValidationError (400), UNAUTHORIZED (401), FORBIDDEN (403), CHESSBOARD_CONCURRENCY_CONFLICT (409) — в реестре операций Chessboard API, здесь не повторяются.

КодHTTPУсловиеСообщениеRetry
CHESSBOARD_HOTELIER_NAME_TAKEN409Name занят активным отельером (INV-HTL-1) / гонка уникального имени; op#38/op#41«Отельер с таким названием уже существует»no retry (сменить имя)
CHESSBOARD_HOTELIER_NOT_FOUND404hotelierId не найден (get/update/delete/lookup-by-id); архивный при update«Отельер не найден»no retry
CHESSBOARD_HOTELIER_HAS_PROJECTS409Архив при активных проектах-ссылках (INV-HTL-2); op#42«Нельзя архивировать: есть проекты оператора»no retry (сначала переназначить/архивировать проекты)

Совместимость и наблюдаемость

  • Новые optional поля HotelierWriteModel/проекций/фильтры search — additive; переименование/смена типа/удаление Name/LogoFileId — breaking (migration + rollout). Enum HotelierSortBy — строкой; неизвестное значение → ValidationError. Событий справочник не публикует; catalog резолвит имя и лого через lookup-API. Потребность в реплике у catalogHotelierUpsertedKafkaEvent отдельным изменением контракта (дом полей HotelierEntity).
  • Logs: hotelier created/updated/archived (HotelierId, AdminUserId, op; без значений Search); metrics chessboard_admin_hoteliers_ops_total{op,result}, latency p95/p99; traces — сквозной trace через traceparent; dashboard chessboard-overview; runbook — docs/06-services/chessboard/runbooks/ (будущий путь).

Acceptance Criteria

  • Create happy path (op#38). Дано: администратор с chessboard.hoteliers.manage, X-Admin-User-Id задан, Name свободен среди активных. Когда: POST /hoteliers. Тогда: отельер создан (Version = 1, не архивный, аудит = AdminUserId); 201 + Location; CreateHotelierResult { Id, CreatedAt, Version }; событий нет (ADR-0056).
  • Create дубль имени (op#38). Дано: активный отельер с тем же Name уже есть (в т.ч. гонка). Когда: создание. Тогда: CHESSBOARD_HOTELIER_NAME_TAKEN (409, INV-HTL-1).
  • Update concurrency (op#41). Дано: переданный Version не совпал с текущей версией. Когда: правка. Тогда: CHESSBOARD_CONCURRENCY_CONFLICT (409). Правка архивного → CHESSBOARD_HOTELIER_NOT_FOUND (404).
  • ArchiveGuard блокирует при проектах (op#42). Дано: есть хотя бы один не архивированный проект с Project.HotelierId = id. Когда: DELETE /hoteliers/{id}. Тогда: CHESSBOARD_HOTELIER_HAS_PROJECTS (409, INV-HTL-2); отельер не архивирован.
  • Archive happy path + идемпотентность (op#42). Дано: на отельера нет активных проектов-ссылок. Когда: архивация. Тогда: soft-delete (DeletedAt, Version++), 200 c DeleteHotelierResult { IsArchived = true, DeletedAt, Version }. Повтор на уже архивном → 200 с текущим результатом (изменений нет).
  • Lookup: list активные, by-id включая архив (op#43). Дано: отельер архивирован, но на него ссылается проект. Когда: GET /lookup/hoteliers (list) — его нет (только активные, INV-HTL-2); GET /lookup/hoteliers/{id} (by-id) — возвращается (резолв уже привязанного оператора). Неизвестный id → CHESSBOARD_HOTELIER_NOT_FOUND (404).
  • Search-фильтры (op#39). Дано: IncludeArchived = false, Search, SortBy. Когда: GET /hoteliers. Тогда: постраничный HotelierListItem только активных, регистронезависимый поиск подстроки, сортировка по SortBy, TotalCount заполнен.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы