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

POST /api/v1/developers/buildings/{buildingId}/property-objects/bulk — Массовый upsert объектов недвижимости

Назначение

Менеджер застройщика заполняет шахматку здания пачкой (импорт из Excel/выгрузки или массовая правка паспортных полей десятков-сотен объектов за один вызов). Каждая строка — создание нового объекта недвижимости или обновление существующего (сопоставление по Id, иначе по Number в пределах здания). Одиночные аналоги — create-property-object.md, update-property-object.md. Дом полей — сущность Объект недвижимости.

Политика частичного отказа: all-or-nothing

Пакет применяется атомарно: одна невалидная строка → не применяется ничего, 422 c построчным отчётом Failed[] (код + индекс на строку); успех → 200 c Created[]/Updated[]. Частичный commit валидного подмножества отвергнут: застройщик правит шахматку итеративно и ждёт «весь пакет принят / исправь и повтори».

Metadata

ПолеЗначение
Сервис/контекстchessboard
API areadevelopers
Feature grouppropertyObjects
Статусdraft
Документdocs/06-services/chessboard/api/developers/bulk-upsert-property-objects.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (менеджер застройщика)
Auth policy / Permissionschessboard.property-objects.manage
Scope (RBAC)организация / группа проектов / проект (проект здания — в scope актора)
Tenant isolationTenantId = CompanyId из JWT; Building/PropertyObject.TenantId наследуются от проекта (INV-CHS-004); чужое здание недостижимо (404, не 403)
Auditcreate/update: CreatedByUserId/UpdatedByUserId, Version stamping

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/developers/buildings/{buildingId}/property-objects/bulk
Success status200 (пакет применён / идемпотентный повтор)
Idempotency headerIdempotency-Key (обязателен — защита от повторной заливки при ретрае клиента/сети)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdidentity claims (sub)GuidДаRequired
TenantIdidentity claims (tenant_id)GuidДаRequiredTenant isolation
BuildingIdrouteGuidДасуществующее здание tenant-а, проект не Archived404 при чужом/отсутствии
RowsbodyIReadOnlyList<BulkPropertyObjectRowModel>Да1..500 элементов (лимит размера пакета); не пустой

Модель строки BulkPropertyObjectRowModel

Дом полей и инвариантов — ../domain/property-object.md; ниже — только принимаемая дельта.

Поля, не принимаемые в запросе

CurrencyId не принимается — выставляется из Project.CurrencyId (INV-PRJ-6); Status не принимается — управляется событиями CRM (INV-CHS-001). Реплика-поля Talent через API не меняются (INV-CHS SyncOwned).

ПолеТипОбязательностьВалидация / Ограничения
IdGuid?Нетзадан → upsert по Id (объект недвижимости должен принадлежать BuildingId); пуст → сопоставление по Number
NumberstringДа1..32, trim; уникален в здании (INV-CHS-006) и внутри пакета (case-insensitive lower)
FloorshortДа-5..200; <= Building.FloorsTotal (INV-BLD-3)
Sectionshort?Нет1..50; <= Building.SectionsCount
RoomsshortДа0..20 (0 = студия)
TotalAreadecimalДа> 0, precision (8,2)
LivingAreadecimal?Нет> 0 и <= TotalArea, precision (8,2)
PricedecimalДа>= 0, precision (14,2); смена цены Sold-объекта недвижимости запрещена (INV-CHS-008)
LayoutIdGuid?Нетсуществующая планировка того же проекта (INV-CHS-007)
WindowViewsIReadOnlyList<WindowViewKind>?Нетenum-string (reference-data.md); ≤ 4, без дублей
Capacityshort?Нет1..20 (апарт-отели)

Модель ответа BulkUpsertDeveloperPropertyObjectsResult

Именованный отчёт: сводка + три построчных списка (эхо пакета команды, границы = размер запроса ≤ 500).

ПолеТипОбязательностьИсточникОписание
BuildingIdGuidДаrouteЗдание пакета.
TotalCountintДаRows.CountСтрок в запросе.
CreatedCount / UpdatedCount / FailedCountintДаcomputedСводка (Created+Updated+Failed = Total).
CreatedIReadOnlyList<BulkPropertyObjectOutcomeItem>Да (может быть пустым)appliedСозданные объекты недвижимости.
UpdatedIReadOnlyList<BulkPropertyObjectOutcomeItem>Да (может быть пустым)appliedОбновлённые объекты недвижимости.
FailedIReadOnlyList<BulkPropertyObjectFailureItem>Да (может быть пустым)validationОтклонённые строки (при непустом — пакет не применён).

BulkPropertyObjectOutcomeItem

ПолеТипОбязательностьОписание
IndexintДаИндекс строки в Rows (0-based).
PropertyObjectIdGuidДаId созданного/обновлённого объекта недвижимости.
NumberstringДаНомер объекта недвижимости.
VersionlongДаДоменная версия после применения (1 при создании).

BulkPropertyObjectFailureItem

ПолеТипОбязательностьОписание
IndexintДаИндекс отклонённой строки в Rows.
Numberstring?НетНомер строки (если распознан).
ErrorCodestringДаКод доменной ошибки строки (CHESSBOARD_PROPERTY_OBJECT_*, таблица ниже).
MessagestringДаБезопасное сообщение (без внутренних данных).

Алгоритм

  1. Контекст и доступ. Операцию выполняет менеджер застройщика; tenant — его компания; требуется право chessboard.property-objects.manage в scope проекта здания. Повтор с тем же Idempotency-Key возвращает сохранённый отчёт; тот же ключ с иным телом → IDEMPOTENCY_KEY_MISMATCH.
  2. Проверка входа (пакет).
    • структура/формат: Rows не пуст и ≤ 500; формат/диапазоны каждого поля строки — иначе ValidationError (400);
    • здание-родитель существует в пределах tenant-а — иначе CHESSBOARD_BUILDING_NOT_FOUND (404); project.Status == Archived → тоже 404 (архивный/чужой контекст наружу не раскрывается).
  3. Чтение контекста (предзагрузка до цикла). Проект — источник TenantId/CurrencyId (INV-CHS-004/005, INV-PRJ-6); текущие объекты недвижимости здания — для сопоставления по Id и по lower(Number); планировки строк — для проверки Layout.ProjectId == Project.Id (INV-CHS-007).
  4. Разбор строк (накопление отчёта). Для каждой строки:
    • резолв цели: 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[], объект недвижимости не мутируется.
  5. Барьер all-or-nothing. Если Failed[] непусто — вернуть 422 (CHESSBOARD_PROPERTY_OBJECT_BULK_VALIDATION_FAILED) до любых записей. Иначе все создания/обновления применяются в одном согласованном изменении (родители уже существуют); гонка добивается unique index ux_property_objects_building_number.
  6. Факты и события. На каждый затронутый объект недвижимости публикуется событие pin.chessboard.property-object-upserted.v1 (паспортный снапшот); при изменении цены дополнительно — pin.chessboard.property-object-price-changed.v1. Один на здание запрашивается фоновый пересчёт агрегатов проекта/здания/планировок (project-aggregates-recalc, debounce 30 с).
  7. Результат. Отчёт со сводкой и списками Created/Updated (Failed пуст) → HTTP 200; сохраняется под Idempotency-Key.

Диаграмма

Побочные эффекты

ТипДетали
Интеграционные событияPropertyObjectUpsertedKafkaEventpin.chessboard.property-object-upserted.v1 (по одному на затронутый объект недвижимости); PropertyObjectPriceChangedKafkaEventpin.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
ValidationError400Пустой Rows / размер > 500 / формат-диапазоны полей строкиСписок ошибок полей с индексамиno retry
CHESSBOARD_BUILDING_NOT_FOUND404Здание не найдено / чужое / проект Archived«Здание не найдено»no retry
CHESSBOARD_PROPERTY_OBJECT_BULK_VALIDATION_FAILED422≥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_MISMATCH409Тот же 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); dashboard chessboard-overview.

Acceptance Criteria

  • Happy path (весь пакет валиден). Дано: здание tenant-а, Rows (1..500) все проходят доменную валидацию, часть по Number/Id попадает в update, часть — в create. Когда: POST …/bulk. Тогда: все строки применены в одном согласованном изменении; Failed пуст; 200 c Created[]/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 index ux_property_objects_building_number, второй получает CHESSBOARD_CONCURRENCY_CONFLICT и повторяется.

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

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

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