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

PUT /api/developer/crm/purchase-program — Настроить программу покупки

Менеджер застройщика задаёт общие настройки способов оплаты своей программы покупки: скидку за 100% оплату, параметры ипотечного калькулятора (ставка/срок) и выключатели опций «ипотека»/«100% оплата». Программа — один агрегат на застройщика (INV-PPR-1), поэтому операция построена как upsert по TenantId: первый вызов создаёт агрегат, последующие правят его. Пресеты рассрочки этой командой не трогаются — у них отдельные команды upsert/архива. Дом полей, инвариантов и формулы графика — purchase-program.md.

Назначение

Upsert общих настроек программы покупки в TenantId = DeveloperCompanyId: FullPaymentDiscountPercent, MortgageAnnualRatePercent, MortgageTermMonths, флаги IsMortgageEnabled / IsFullPaymentEnabled. Вызывает менеджер застройщика; первый вызов создаёт агрегат, последующие правят его. На выходе — карточка настроек программы.

Metadata

ПолеЗначение
Сервис/контекстcrm
API areadeveloper
Feature grouppurchase-program
Статусdraft
Документdocs/06-services/crm/api/developer/update-purchase-program.md

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

ПроверкаЗначение
Actor typeUser (Менеджер застройщика в рамках своего TenantId = DeveloperCompanyId; Менеджер ПИН / администратор платформы — platform-scope, тот же permission)
Auth policy / Permissionscrm.purchase-programs.manage
Scope (RBAC)организация (застройщик): менеджер настраивает свою программу покупки
Record-levelPurchaseProgramEntity.TenantId == actor.DeveloperCompanyId (агрегат принадлежит застройщику)
Tenant isolationTenantId = DeveloperCompanyId в каждом запросе (ADR-0052)
Auditcreate/update (CreatedByUserId/UpdatedByUserId = actor, инкремент Version)
Доступ

Record-level ограничивает доступ агрегатом своего застройщика: PurchaseProgramEntity.TenantId == actor.DeveloperCompanyId — менеджер видит и правит только программу своей компании. Менеджер ПИН и администратор платформы обходят record-level (platform-scope) с тем же permission crm.purchase-programs.manage.

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/developer/crm/purchase-program
Success status200 (настройки обновлены) / 201 (агрегат создан первым вызовом)
Idempotency headerне применяется — идемпотентность естественная (upsert по TenantId; повтор с тем же телом и RowVersion даёт тот же агрегат)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = DeveloperCompanyIdTenant isolation
ActorUserIdidentity claimsGuidДаJWT менеджера; пишется в CreatedByUserId/UpdatedByUserId
FullPaymentDiscountPercentbodydecimalДа>= 0 and <= 100, шкала (5,2) (INV-PPR-2)
MortgageAnnualRatePercentbodydecimalДа>= 0 and <= 100, шкала (5,2) (INV-PPR-2)
MortgageTermMonthsbodyintДа> 0 and <= 600 (INV-PPR-2)
IsMortgageEnabledbodyboolДавыключатель опции «ипотека»
IsFullPaymentEnabledbodyboolДавыключатель опции «100% оплата»
RowVersionbodyuint?Нетintent-токен: null — намерение создать; задан — намерение обновить существующий (токен из query), несовпадение → 409

RowVersion намеренно nullable: агрегат один на застройщика, поэтому вместо route-id клиент выражает намерение через наличие токена — null (первичная настройка) против конкретного токена (правка полученного из query агрегата). Разбор ветвей — шаги 3–5 алгоритма.

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

Проекция агрегата — дом полей purchase-program.md; ниже только настроечная дельта, которую пишет/возвращает эта команда. TenantId наружу не отдаётся (внутренняя изоляция); список пресетов рассрочки в ответ не входит — он проецируется отдельным query-методом программы (пресеты правятся своими командами).

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаPurchaseProgramEntity.IdИдентификатор программы застройщика.
FullPaymentDiscountPercentdecimalДаPurchaseProgramEntity.FullPaymentDiscountPercentСкидка при 100% оплате, %.
MortgageAnnualRatePercentdecimalДаPurchaseProgramEntity.MortgageAnnualRatePercentГодовая ставка ипотеки для калькулятора, %.
MortgageTermMonthsintДаPurchaseProgramEntity.MortgageTermMonthsСрок ипотеки, месяцы.
IsMortgageEnabledboolДаPurchaseProgramEntity.IsMortgageEnabledДоступна ли ипотека.
IsFullPaymentEnabledboolДаPurchaseProgramEntity.IsFullPaymentEnabledДоступна ли 100% оплата.
VersionlongДаPurchaseProgramEntity.VersionОптимистическая версия (== 1 после создания, иначе +1).
RowVersionuintДаPurchaseProgramEntity.RowVersionНовый токен оптимистической блокировки для следующей правки клиента.
UpdatedAtDateTimeOffset?НетPurchaseProgramEntity.UpdatedAtМомент последней правки (null сразу после создания).

Алгоритм

Upsert-семантика (INV-PPR-1)

Программа — один агрегат на застройщика: намерение клиента (создать/обновить) выражается парой «есть ли живой агрегат» × RowVersion. Отсутствующий агрегат + RowVersion == null → создание; существующий агрегат

  • совпадающий RowVersion → обновление; любое другое сочетание — конфликт (CRM_PURCHASE_PROGRAM_EXISTS либо CRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT). Разбор веток — шаг 3.
  1. Контекст и доступ. Выполняет менеджер застройщика (User, permission crm.purchase-programs.manage; менеджер ПИН / администратор платформы — platform-scope); tenant — TenantId = DeveloperCompanyId. Операция идемпотентна по природе upsert; защита от параллельной правки — RowVersion (шаги 3–5).
  2. Проверка входа и предусловий.
    • INV-PPR-2: FullPaymentDiscountPercent ∈ [0..100], MortgageAnnualRatePercent ∈ [0..100], MortgageTermMonths ∈ (0..600] — нарушение → CRM_PURCHASE_PROGRAM_RANGE; структурно-невалидное тело (не число/нет обязательного поля) → VALIDATION_FAILED;
    • существование/состояние: агрегата по id нет (upsert по tenant); ветвление create/update — на шаге 3;
    • scope: только tenant isolation (TenantId = DeveloperCompanyId); родительских агрегатов и FK-ссылок у настроек нет; отсутствие permission → CRM_PURCHASE_PROGRAM_FORBIDDEN.
  3. Что читаем. Живой агрегат программы покупки застройщика (nullable-путь, под блокировкой строки — сериализует параллельные правки настроек одного застройщика). Пресеты рассрочки не читаются (команда их не трогает). Разбор намерения по паре (program, RowVersion):
    • program == null и RowVersion == nullcreate (шаг 4a);
    • program != null и RowVersion == program.RowVersionupdate (шаг 4b);
    • program != null и RowVersion == nullCRM_PURCHASE_PROGRAM_EXISTS (INV-PPR-1: не затирать существующий агрегат «слепым» создающим вызовом);
    • program == null и RowVersion != null, либо program != null и RowVersion != program.RowVersionCRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT (агрегат исчез/изменён параллельно).
  4. Бизнес-правила и переходы. Маппинг поле-в-поле из тела; INV-PPR-2 повторно закрепляется guard-ами.
    • 4a (create): создаётся программа с проверкой диапазонов (INV-PPR-2 → CRM_PURCHASE_PROGRAM_RANGE); Id — новый идентификатор, Version = 1, UpdatedAt = null;
    • 4b (update): обновляются настройки (FullPaymentDiscountPercent, MortgageAnnualRatePercent, MortgageTermMonths, IsMortgageEnabled, IsFullPaymentEnabled), UpdatedAt = now, Version += 1; CreatedBy* и коллекция пресетов не трогаются;
    • обе ветви: фиксируется доменный факт PurchaseProgramChanged.
  5. Что меняется. Программа создаётся (create) или её настройки обновляются (update). Гонку двух первичных upsert ловит partial-unique ux_purchase_programs_tenantCRM_PURCHASE_PROGRAM_EXISTS (INV-PPR-1). В update оптимистическая блокировка по RowVersion: рассинхрон → CRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT.
  6. События. Интеграционных Kafka-событий нет: конфигурация читается синхронно, топика pin.crm.purchase-program не существует. Внутренний доменный факт PurchaseProgramChanged инвалидирует кэш публичной проекции конфигурации (график/условия витрины).
  7. Согласованность. Вставка/обновление программы и audit/Version фиксируются в одном согласованном изменении (атомарно). Нарушение ux_purchase_programs_tenant откатывает изменение → CRM_PURCHASE_PROGRAM_EXISTS (шаг 5).
  8. Результат. Карточка PurchaseProgramModel (настройки, Version, свежий RowVersion, UpdatedAt) → HTTP 201 для create-ветви, 200 для update-ветви.

Диаграмма

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

ТипДетали
Integration eventsnone (конфигурация читается синхронно; kafka-топика программы покупки нет)
External callsnone
Cache / projections / searchдоменное PurchaseProgramChanged → инвалидация кэша публичной проекции конфигурации (график/условия витрины)
Notificationsnone (настроечная операция; участникам сделок ничего не шлётся)

Ошибки и failure modes

Ошибки

Повторять запрос имеет смысл только при CRM_PURCHASE_PROGRAM_EXISTS и CRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT — оба требуют сначала перечитать актуальный RowVersion и повторить как update. Остальные коды (VALIDATION_FAILED, CRM_PURCHASE_PROGRAM_RANGE, CRM_PURCHASE_PROGRAM_FORBIDDEN) требуют сначала исправить тело запроса или права доступа.

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400структурно-невалидное тело (не число/нет обязательного поля/флага)«Проверьте корректность полей программы покупки»no retry
CRM_PURCHASE_PROGRAM_RANGE400INV-PPR-2: процент вне [0..100] или MortgageTermMonths вне (0..600] (валидатор либо check-констрейнт)«Значения ставок, скидок и срока вне допустимого диапазона»no retry
CRM_PURCHASE_PROGRAM_FORBIDDEN403нет crm.purchase-programs.manage / чужой tenant«Нет доступа к программе покупки»no retry
CRM_PURCHASE_PROGRAM_EXISTS409INV-PPR-1: create-намерение (RowVersion == null) при уже существующем живом агрегате либо гонка первичных upsert (ux_purchase_programs_tenant)«Программа покупки уже настроена — обновите её»retry (перечитать RowVersion и повторить как update)
CRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT409RowVersion устарел/агрегат изменён параллельно (update-намерение)«Программа изменена параллельно, обновите данные»retry (перечитать RowVersion)

Инварианты пресетов (CRM_INSTALLMENT_REMAINDER_INVALID, CRM_INSTALLMENT_SCHEDULE_INCOMPLETE, CRM_INSTALLMENT_PRESET_IN_USE, INV-PPR-3..5) к этой команде не относятся — см. purchase-program.md и команды upsert/архива пресетов.

Acceptance Criteria

  • Given у застройщика ещё нет живого агрегата программы и RowVersion == null, When upsert валидных настроек, Then создаётся агрегат, Version = 1, UpdatedAt = null, ответ 201 (INV-PPR-1: один агрегат на застройщика).
  • Given агрегат существует и RowVersion == program.RowVersion, When upsert, Then настройки обновлены, Version += 1, UpdatedAt = now, ответ 200; CreatedBy* и коллекция пресетов не тронуты.
  • Given агрегат уже есть, но RowVersion == null, When upsert, Then CRM_PURCHASE_PROGRAM_EXISTS (409) — существующий агрегат не затирается (INV-PPR-1).
  • Given RowVersion != program.RowVersion либо задан RowVersion при отсутствующем агрегате, When upsert, Then CRM_PURCHASE_PROGRAM_CONCURRENCY_CONFLICT (409).
  • Given FullPaymentDiscountPercent/MortgageAnnualRatePercent вне [0..100] или MortgageTermMonths ∉ (0..600], When upsert, Then CRM_PURCHASE_PROGRAM_RANGE (400) по INV-PPR-2; структурно-невалидное тело → VALIDATION_FAILED (400).
  • Given два параллельных первичных create одного застройщика, When оба коммитятся, Then один создаёт агрегат, второй нарушает ux_purchase_programs_tenantCRM_PURCHASE_PROGRAM_EXISTS (409).
  • Given нет crm.purchase-programs.manage либо чужой tenant, When upsert, Then CRM_PURCHASE_PROGRAM_FORBIDDEN (403).

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

  • Route/тело стабильны; добавление optional-полей в PurchaseProgramModel — additive (без breaking); удаление/переименование/смена типа — breaking (migration + rollout). Проценты сериализуются decimal (не float); нарушение диапазона — CRM_PURCHASE_PROGRAM_RANGE, а не молчаливое округление.
  • Logs: purchase-program upsert requested/created/updated/conflict (TenantId, ProgramId, Version; PII отсутствует); metrics: crm_purchase_program_upsert_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

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

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

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

  • Сущность, инварианты, индексы и формула графика: purchase-program.md (INV-PPR-1 один агрегат на застройщика, INV-PPR-2 диапазоны, RowVersion/soft-delete, доменное PurchaseProgramChanged, ux_purchase_programs_tenant); потребитель настроек — deal.md (PaymentMethod, InstallmentPresetId).
  • Область developer и карта событий: ../../README.md; публичное чтение конфигурации и график платежей — API-документ ../get-payment-schedule.md (будущий путь по конвенции).
  • ADR: 0052 tenancy, 0056 simplicity-first.