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 area | admin (администратор платформы, ADR-0053, audience pin.chessboard.admin) |
| Feature group | hoteliers |
| Статус | draft |
| Документ | docs/06-services/chessboard/api/admin/hoteliers-crud.md |
Актор и доступ (общий для op#38–43)
| Проверка | Значение |
|---|---|
| Actor type | Администратор платформы через admin API (m2m-токен, audience pin.chessboard.admin) |
| Auth policy / Permissions | chessboard.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 header | X-Admin-User-Id (Guid, обязателен на POST/PUT/DELETE) |
| # | Операция | Method | Route | Success |
|---|---|---|---|---|
| 38 | createHotelier | POST | /api/v1/admin/hoteliers | 201 (Location …/hoteliers/{id}) |
| 39 | searchHoteliers | GET | /api/v1/admin/hoteliers | 200 |
| 40 | getHotelier | GET | /api/v1/admin/hoteliers/{hotelierId} | 200 |
| 41 | updateHotelier | PUT | /api/v1/admin/hoteliers/{hotelierId} | 200 |
| 42 | deleteHotelier (архив) | DELETE | /api/v1/admin/hoteliers/{hotelierId} | 200 |
| 43 | lookupHoteliers / lookupHotelierById | GET | /api/v1/admin/lookup/hoteliers (+/{hotelierId}) | 200 |
Входные данные / параметры запроса
Ограничения полей — из дома полей HotelierEntity (здесь не дублируются).
Команды записи (op#38/op#41) принимают тело HotelierWriteModel; id — из route; аудит — из header
X-Admin-User-Id.
CreateHotelierCommand / UpdateHotelierCommand (тело + route + header)
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения |
|---|---|---|---|---|
AdminUserId | header X-Admin-User-Id | Guid | Да | непустой; используется для аудита |
HotelierId | route | Guid | Да (только update) | непустой; существующий отельер (иначе 404) |
Name | body (HotelierWriteModel) | string | Да | trim, 1..256, не пусто; уникально среди активных (INV-HTL-1) |
LogoFileId | body (HotelierWriteModel) | Guid? | Нет | FileId storage/CDN (без FK; существование — граница storage) |
Version | body | long | Да (только update) | >= 1; сверка с текущей версией отельера (optimistic) |
SearchHoteliersQuery (query)
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения |
|---|---|---|---|---|
Page / PageSize | query | int | Нет (1 / 20) | Page >= 1; PageSize 1..100 |
Search | query | string? | Нет | trim, <= 256; регистронезависимый поиск подстроки по названию |
IncludeArchived | query | bool | Нет (default false) | false → только активные (не архивные) |
SortBy | query | HotelierSortBy | Нет (default NameAsc) | enum-string: NameAsc / CreatedAtDesc |
GetHotelierQuery / DeleteHotelierCommand / LookupHotelierByIdQuery (route)
HotelierId (route, Guid, непустой, существующий → иначе 404). DeleteHotelierCommand дополнительно
берёт AdminUserId из header (audit). LookupHoteliersQuery — Search (<= 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)
| Поле | Тип | Обяз. | Источник | Описание |
|---|---|---|---|---|
Id | Guid | Да | HotelierEntity.Id | Идентификатор отельера. |
Name | string | Да | Name | Название оператора. |
LogoFileId | Guid? | Нет | LogoFileId | Лого (пресайн — storage/CDN). |
IsArchived | bool | Да | computed (DeletedAt != null) | Архивный флаг. |
Version | long | Да | Version | Доменная версия (для последующего PUT/DELETE). |
CreatedAt / UpdatedAt | DateTimeOffset | Да | одноимённые | Даты создания / последнего изменения. |
HotelierListItem — та же проекция (постраничный список). HotelierModel дополнительно несёт
CreatedByUserId/UpdatedByUserId (Guid?, аудит) и DeletedAt (DateTimeOffset?).
HotelierLookupItem (op#43), CreateHotelierResult (op#38), DeleteHotelierResult (op#42)
| Модель | Поля |
|---|---|
HotelierLookupItem | Id : Guid, Name : string, LogoFileId : Guid? — минимальная проекция для форм (только активные). |
CreateHotelierResult | Id : Guid, CreatedAt : DateTimeOffset, Version : long (= 1). |
DeleteHotelierResult | Id : 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)
- Проверить вход:
Nameпосле trim1..256, не пусто;LogoFileId— необязательный;AdminUserIdзадан. Нарушение →ValidationError. - Проверить занятость имени среди активных отельеров (INV-HTL-1). Если название уже занято активным
отельером →
CHESSBOARD_HOTELIER_NAME_TAKEN. - Создать отельера с новым идентификатором, названием (trim), логотипом; версия = 1, не архивный;
проставить аудит (создатель и последний изменивший =
AdminUserId). При гонке на уникальном имени →CHESSBOARD_HOTELIER_NAME_TAKEN. - Результат:
CreateHotelierResult { Id, CreatedAt, Version },201+Location: /api/v1/admin/hoteliers/{Id}.
updateHotelier (op#41)
- Проверить вход:
Name1..256,Version >= 1,HotelierIdзадан. Нарушение →ValidationError. - Найти отельера по
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). - Применить новое название (trim) и лого; последний изменивший =
AdminUserId; версия увеличивается. При конкурентной правке →CHESSBOARD_CONCURRENCY_CONFLICT; при гонке уникального имени →CHESSBOARD_HOTELIER_NAME_TAKEN. - Результат:
HotelierModel,200.
deleteHotelier = архив (op#42)
- Проверить вход:
HotelierIdзадан. - Найти отельера. Не найден →
CHESSBOARD_HOTELIER_NOT_FOUND(404). Идемпотентность: уже архивный (DeletedAt != null) → вернуть200с текущимDeleteHotelierResult(изменений нет). - ArchiveGuard (INV-HTL-2). Если есть хотя бы один не архивированный проект, ссылающийся на этого
отельера (
Project.HotelierId) →CHESSBOARD_HOTELIER_HAS_PROJECTS(409): архив запрещён, пока есть активные проекты-ссылки. Иначе пометить отельера архивным (DeletedAt = now,DeletedByUserId = AdminUserId, версия увеличивается) — строка физически не удаляется (soft-delete). При конкурентной правке →CHESSBOARD_CONCURRENCY_CONFLICT. - Результат:
DeleteHotelierResult { Id, IsArchived = true, DeletedAt, Version },200.
searchHoteliers (op#39) / getHotelier (op#40) / lookupHoteliers / lookupHotelierById (op#43) — read-only
- Проверить вход:
Page/PageSizeдиапазоны,Search <= 256,SortBy ∈ enum(op#39/op#43); для get и lookup-by-id —HotelierIdзадан. Нарушение →ValidationError. - Загрузка и выдача:
- 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).
- searchHoteliers — постраничный список отельеров; при
- Результат: 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_TAKEN | 409 | Name занят активным отельером (INV-HTL-1) / гонка уникального имени; op#38/op#41 | «Отельер с таким названием уже существует» | no retry (сменить имя) |
CHESSBOARD_HOTELIER_NOT_FOUND | 404 | hotelierId не найден (get/update/delete/lookup-by-id); архивный при update | «Отельер не найден» | no retry |
CHESSBOARD_HOTELIER_HAS_PROJECTS | 409 | Архив при активных проектах-ссылках (INV-HTL-2); op#42 | «Нельзя архивировать: есть проекты оператора» | no retry (сначала переназначить/архивировать проекты) |
Совместимость и наблюдаемость
- Новые optional поля
HotelierWriteModel/проекций/фильтры search — additive; переименование/смена типа/удалениеName/LogoFileId— breaking (migration + rollout). EnumHotelierSortBy— строкой; неизвестное значение →ValidationError. Событий справочник не публикует;catalogрезолвит имя и лого через lookup-API. Потребность в реплике уcatalog→HotelierUpsertedKafkaEventотдельным изменением контракта (дом полейHotelierEntity). - Logs:
hotelier created/updated/archived(HotelierId,AdminUserId,op; без значенийSearch); metricschessboard_admin_hoteliers_ops_total{op,result}, latency p95/p99; traces — сквозной trace черезtraceparent; dashboardchessboard-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++),200cDeleteHotelierResult { 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заполнен.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: HotelierEntity
- API (3): Admin CRUD банков — создание / список / карточка / правка / архив / lookup, Chessboard API — реестр операций областей Developers и Admin, GET /api/v1/developers/lookup/{banks,hoteliers} — Lookup справочников банков/отельеров
- Use Cases (1): UC-CHS-007. Администратор ведёт справочники банков и отельеров
- registries (1): Реестр API-методов PIN
Связанные документы
- Дом полей: дом полей
HotelierEntity(INV-HTL-1/2, поля, индексы); guard-связь: дом полейProjectEntity(HotelierId, INV-PRJ-4). - Реестр операций: реестр операций Chessboard API; парный справочник (та же форма): Admin CRUD банков; lookup для developers/agents: Lookup справочников банков/отельеров.
- ADR: ADR-0052 (tenancy), ADR-0053 (админ-доступ), ADR-0056 (simplicity-first).