POST /api/v1/developers/buildings/{buildingId}/property-objects/bulk — Массовый upsert объектов недвижимости
Назначение
Менеджер застройщика заполняет шахматку здания пачкой (импорт из Excel/выгрузки или массовая правка
паспортных полей десятков-сотен объектов за один вызов). Каждая строка — создание нового объекта
недвижимости или обновление существующего (сопоставление по Id, иначе по Number в пределах здания).
Одиночные аналоги — create-property-object.md,
update-property-object.md. Дом полей — сущность
Объект недвижимости.
Пакет применяется атомарно: одна невалидная строка → не применяется ничего, 422 c построчным отчётом
Failed[] (код + индекс на строку); успех → 200 c Created[]/Updated[]. Частичный commit валидного
подмножества отвергнут: застройщик правит шахматку итеративно и ждёт «весь пакет принят / исправь и повтори».
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | chessboard |
| API area | developers |
| Feature group | propertyObjects |
| Статус | draft |
| Документ | docs/06-services/chessboard/api/developers/bulk-upsert-property-objects.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (менеджер застройщика) |
| Auth policy / Permissions | chessboard.property-objects.manage |
| Scope (RBAC) | организация / группа проектов / проект (проект здания — в scope актора) |
| Tenant isolation | TenantId = CompanyId из JWT; Building/PropertyObject.TenantId наследуются от проекта (INV-CHS-004); чужое здание недостижимо (404, не 403) |
| Audit | create/update: CreatedByUserId/UpdatedByUserId, Version stamping |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/developers/buildings/{buildingId}/property-objects/bulk |
| Success status | 200 (пакет применён / идемпотентный повтор) |
| Idempotency header | Idempotency-Key (обязателен — защита от повторной заливки при ретрае клиента/сети) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
UserId | identity claims (sub) | Guid | Да | Required | — |
TenantId | identity claims (tenant_id) | Guid | Да | Required | Tenant isolation |
BuildingId | route | Guid | Да | существующее здание tenant-а, проект не Archived | 404 при чужом/отсутствии |
Rows | body | IReadOnlyList<BulkPropertyObjectRowModel> | Да | 1..500 элементов (лимит размера пакета); не пустой | — |
Модель строки BulkPropertyObjectRowModel
Дом полей и инвариантов — ../domain/property-object.md; ниже — только принимаемая дельта.
CurrencyId не принимается — выставляется из Project.CurrencyId (INV-PRJ-6); Status не принимается —
управляется событиями CRM (INV-CHS-001). Реплика-поля Talent через API не меняются (INV-CHS SyncOwned).
| Поле | Тип | Обязательность | Валидация / Ограничения |
|---|---|---|---|
Id | Guid? | Нет | задан → upsert по Id (объект недвижимости должен принадлежать BuildingId); пуст → сопоставление по Number |
Number | string | Да | 1..32, trim; уникален в здании (INV-CHS-006) и внутри пакета (case-insensitive lower) |
Floor | short | Да | -5..200; <= Building.FloorsTotal (INV-BLD-3) |
Section | short? | Нет | 1..50; <= Building.SectionsCount |
Rooms | short | Да | 0..20 (0 = студия) |
TotalArea | decimal | Да | > 0, precision (8,2) |
LivingArea | decimal? | Нет | > 0 и <= TotalArea, precision (8,2) |
Price | decimal | Да | >= 0, precision (14,2); смена цены Sold-объекта недвижимости запрещена (INV-CHS-008) |
LayoutId | Guid? | Нет | существующая планировка того же проекта (INV-CHS-007) |
WindowViews | IReadOnlyList<WindowViewKind>? | Нет | enum-string (reference-data.md); ≤ 4, без дублей |
Capacity | short? | Нет | 1..20 (апарт-отели) |
Модель ответа BulkUpsertDeveloperPropertyObjectsResult
Именованный отчёт: сводка + три построчных списка (эхо пакета команды, границы = размер запроса ≤ 500).
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
BuildingId | Guid | Да | route | Здание пакета. |
TotalCount | int | Да | Rows.Count | Строк в запросе. |
CreatedCount / UpdatedCount / FailedCount | int | Да | computed | Сводка (Created+Updated+Failed = Total). |
Created | IReadOnlyList<BulkPropertyObjectOutcomeItem> | Да (может быть пустым) | applied | Созданные объекты недвижимости. |
Updated | IReadOnlyList<BulkPropertyObjectOutcomeItem> | Да (может быть пустым) | applied | Обновлённые объекты недвижимости. |
Failed | IReadOnlyList<BulkPropertyObjectFailureItem> | Да (может быть пустым) | validation | Отклонённые строки (при непустом — пакет не применён). |
BulkPropertyObjectOutcomeItem
| Поле | Тип | Обязательность | Описание |
|---|---|---|---|
Index | int | Да | Индекс строки в Rows (0-based). |
PropertyObjectId | Guid | Да | Id созданного/обновлённого объекта недвижимости. |
Number | string | Да | Номер объекта недвижимости. |
Version | long | Да | Доменная версия после применения (1 при создании). |
BulkPropertyObjectFailureItem
| Поле | Тип | Обязательность | Описание |
|---|---|---|---|
Index | int | Да | Индекс отклонённой строки в Rows. |
Number | string? | Нет | Номер строки (если распознан). |
ErrorCode | string | Да | Код доменной ошибки строки (CHESSBOARD_PROPERTY_OBJECT_*, таблица ниже). |
Message | string | Да | Безопасное сообщение (без внутренних данных). |
Алгоритм
- Контекст и доступ. Операцию выполняет менеджер застройщика; tenant — его компания; требуется право
chessboard.property-objects.manageв scope проекта здания. Повтор с тем жеIdempotency-Keyвозвращает сохранённый отчёт; тот же ключ с иным телом →IDEMPOTENCY_KEY_MISMATCH. - Проверка входа (пакет).
- структура/формат:
Rowsне пуст и≤ 500; формат/диапазоны каждого поля строки — иначеValidationError(400); - здание-родитель существует в пределах tenant-а — иначе
CHESSBOARD_BUILDING_NOT_FOUND(404);project.Status == Archived→ тоже 404 (архивный/чужой контекст наружу не раскрывается).
- структура/формат:
- Чтение контекста (предзагрузка до цикла). Проект — источник
TenantId/CurrencyId(INV-CHS-004/005, INV-PRJ-6); текущие объекты недвижимости здания — для сопоставления поIdи поlower(Number); планировки строк — для проверкиLayout.ProjectId == Project.Id(INV-CHS-007). - Разбор строк (накопление отчёта). Для каждой строки:
- резолв цели:
Id != null→ взять существующий поId(иначеCHESSBOARD_PROPERTY_OBJECT_NOT_FOUND);Id == null→ matched поlower(Number)→ update, нет → create; - уникальность
Number: не встречался раньше в пакете (CHESSBOARD_PROPERTY_OBJECT_NUMBER_DUPLICATE_IN_BATCH) и, для create, отсутствует в здании (CHESSBOARD_PROPERTY_OBJECT_NUMBER_TAKEN, INV-CHS-006); - инварианты объекта недвижимости:
Floor <= Building.FloorsTotal(INV-BLD-3 →CHESSBOARD_PROPERTY_OBJECT_FLOOR_OUT_OF_BOUNDS);LayoutIdтого же проекта (INV-CHS-007 →CHESSBOARD_PROPERTY_OBJECT_LAYOUT_PROJECT_MISMATCH); сменаPriceприStatus == Soldзапрещена (INV-CHS-008 →CHESSBOARD_PROPERTY_OBJECT_PRICE_LOCKED); - create: новый объект со статусом
Free,SyncSource = Pin,CurrencyId = Project.CurrencyId,PricePerMeter = round(Price/TotalArea,2),Version = 1; - update: маппинг паспортных полей + пересчёт
PricePerMeter; приPrice != текущая— смена цены (INV-CHS-008); - неуспех строки → запись в
Failed[], объект недвижимости не мутируется.
- резолв цели:
- Барьер all-or-nothing. Если
Failed[]непусто — вернуть422(CHESSBOARD_PROPERTY_OBJECT_BULK_VALIDATION_FAILED) до любых записей. Иначе все создания/обновления применяются в одном согласованном изменении (родители уже существуют); гонка добивается unique indexux_property_objects_building_number. - Факты и события. На каждый затронутый объект недвижимости публикуется событие
pin.chessboard.property-object-upserted.v1(паспортный снапшот); при изменении цены дополнительно —pin.chessboard.property-object-price-changed.v1. Один на здание запрашивается фоновый пересчёт агрегатов проекта/здания/планировок (project-aggregates-recalc, debounce 30 с). - Результат. Отчёт со сводкой и списками
Created/Updated(Failedпуст) → HTTP200; сохраняется подIdempotency-Key.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | PropertyObjectUpsertedKafkaEvent → pin.chessboard.property-object-upserted.v1 (по одному на затронутый объект недвижимости); PropertyObjectPriceChangedKafkaEvent → pin.chessboard.property-object-price-changed.v1 (по объектам с изменённой ценой); потребители: catalog, dev-stats, rewards, invest-analytics |
| Проекции / пересчёт | ProjectAggregatesRecalcRequestedEvent — один на здание/проект (debounce 30 c) |
Ошибки и failure modes
Сквозные (ValidationError 400, UNAUTHORIZED 401, FORBIDDEN 403, CHESSBOARD_CONCURRENCY_CONFLICT 409) —
в реестре README.md, здесь не повторяются.
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
ValidationError | 400 | Пустой Rows / размер > 500 / формат-диапазоны полей строки | Список ошибок полей с индексами | no retry |
CHESSBOARD_BUILDING_NOT_FOUND | 404 | Здание не найдено / чужое / проект Archived | «Здание не найдено» | no retry |
CHESSBOARD_PROPERTY_OBJECT_BULK_VALIDATION_FAILED | 422 | ≥1 строка не прошла доменную валидацию (пакет не применён; per-row Failed[]) | «Пакет отклонён, см. отчёт по строкам» | no retry (исправить строки) |
CHESSBOARD_PROPERTY_OBJECT_NUMBER_TAKEN | — (в Failed[].ErrorCode) | Create: Number занят в здании (INV-CHS-006) | «Номер объекта недвижимости уже используется» | no retry |
CHESSBOARD_PROPERTY_OBJECT_NUMBER_DUPLICATE_IN_BATCH | — (в Failed[]) | Повтор Number внутри пакета | «Дубль номера в пакете» | no retry |
CHESSBOARD_PROPERTY_OBJECT_NOT_FOUND | — (в Failed[]) | Id задан, объект недвижимости не в здании | «Объект недвижимости не найден в здании» | no retry |
CHESSBOARD_PROPERTY_OBJECT_LAYOUT_PROJECT_MISMATCH | — (в Failed[]) | LayoutId чужого проекта (INV-CHS-007) | «Планировка другого проекта» | no retry |
CHESSBOARD_PROPERTY_OBJECT_FLOOR_OUT_OF_BOUNDS | — (в Failed[]) | Floor > Building.FloorsTotal (INV-BLD-3) | «Этаж превышает этажность здания» | no retry |
CHESSBOARD_PROPERTY_OBJECT_PRICE_LOCKED | — (в Failed[]) | Смена Price у Sold-объекта недвижимости (INV-CHS-008) | «Цена проданного объекта недвижимости не меняется» | no retry |
IDEMPOTENCY_KEY_MISMATCH | 409 | Тот же Idempotency-Key с иным телом | «Ключ идемпотентности переиспользован» | no retry |
Совместимость и наблюдаемость
- Новые optional поля строки — additive; лимит
500— параметр конфигурации (увеличение — additive). - Logs:
TenantId,BuildingId,total/created/updated/failed,Idempotency-Key(PII нет); metrics:chessboard_developers_property_objects_bulk_total{result},_rows(histogram размера); traces — OTel (traceparent); dashboardchessboard-overview.
Acceptance Criteria
- Happy path (весь пакет валиден). Дано: здание tenant-а,
Rows(1..500) все проходят доменную валидацию, часть поNumber/Idпопадает в update, часть — в create. Когда:POST …/bulk. Тогда: все строки применены в одном согласованном изменении;Failedпуст;200cCreated[]/Updated[]; на каждый затронутый объект —property-object-upserted.v1, на строки с изменённой ценой — дополнительноproperty-object-price-changed.v1; один запрос пересчёта агрегатов на здание. - Одна невалидная строка (all-or-nothing). Дано: ≥1 строка нарушает инвариант (дубль номера, чужая
планировка, этаж вне здания, смена цены
Sold-объекта). Когда: вызов. Тогда: не применяется НИЧЕГО;422(CHESSBOARD_PROPERTY_OBJECT_BULK_VALIDATION_FAILED) сFailed[](код + индекс на строку); события не публикуются. - Дубль номера внутри пакета. Дано: два элемента
Rowsс одинаковымlower(Number). Когда: вызов. Тогда: строка помеченаCHESSBOARD_PROPERTY_OBJECT_NUMBER_DUPLICATE_IN_BATCH, пакет отклонён (422). - Превышение размера пакета. Дано:
Rowsпуст или > 500. Когда: вызов. Тогда:ValidationError(400) до какой-либо обработки строк. - Здание чужое/архивный проект. Дано:
buildingIdчужого tenant-а или его проектArchived. Когда: вызов. Тогда:CHESSBOARD_BUILDING_NOT_FOUND(404, архивный/чужой контекст наружу не раскрывается). - Идемпотентный повтор. Дано: успешный пакет применён под
Idempotency-Key = K. Когда: повтор с тем жеKи телом → сохранённый отчёт без повторного применения и событий; при ином теле →IDEMPOTENCY_KEY_MISMATCH(409). - Гонка уникальности. Дано: два параллельных пакета создают объект с одним
Number. Когда: применение. Тогда: один добивается unique indexux_property_objects_building_number, второй получаетCHESSBOARD_CONCURRENCY_CONFLICTи повторяется.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): Chessboard API — реестр операций областей Developers и Admin
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность/инварианты: ../domain/property-object.md (INV-CHS-004..008), ../domain/building.md (INV-BLD-3), ../domain/project.md (INV-PRJ-6), ../domain/layout.md, ../domain/reference-data.md.
- События: ../events/property-object-upserted.md, ../events/property-object-price-changed.md.
- Соседи: create-property-object.md, update-property-object.md, change-property-object-price.md; реестр — README.md.