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

PUT /api/developer/crm/purchase-program/presets/{presetId} — Редактировать пресет рассрочки

Менеджер застройщика правит параметры одного пресета рассрочки своей программы покупки: название, первоначальный взнос, ежемесячный платёж, срок, скидку и milestone-график. Операция сохраняет те же инварианты, что и создание пресета — INV-PPR-2/3/4 (проценты 0–100 и сроки > 0, правило единственного remainder, полнота графика). Дом полей, инвариантов, enum-ов и формулы расчёта — purchase-program.md.

Назначение

Обновить конфигурацию одного пресета рассрочки застройщика с оптимистической блокировкой по RowVersion (параллельная правка тем же/другим менеджером). Правка не ретроактивна: существующие сделки продолжают ссылаться на пресет по Deal.InstallmentPresetId (deal.md); нетерминальные сделки при следующем запросе графика пересчитаются по новым параметрам (расчёт серверный, дом формулы — purchase-program.md), а терминальные (Completed/Cancelled) сделки остаются read-only и уже согласованный график не пересчитывают.

Metadata

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

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

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

Пресет правит менеджер застройщика — владелец программы покупки в своём TenantId = DeveloperCompanyId, либо Менеджер ПИН / администратор платформы (platform-scope). Пресет чужого tenant → 403 CRM_INSTALLMENT_PRESET_FORBIDDEN; несуществующий или архивный пресет в своём tenant → 404 CRM_INSTALLMENT_PRESET_NOT_FOUND (правка архива запрещена).

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/developer/crm/purchase-program/presets/{presetId}
Success status200 (пресет обновлён; тело — обновлённая проекция InstallmentPresetModel)
Idempotency headerне применяется — идемпотентность естественная (повтор с тем же телом и RowVersion даёт тот же результат; устаревший RowVersion409)
Correlationсквозной trace через заголовок traceparent

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

Важно

PurchaseProgramId в теле не принимается: родитель пресета неизменен (перенос между программами не поддерживается). Enum-ы сериализуются строкой; неизвестное значение KindVALIDATION_FAILED (строгая типизация десериализатора).

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = DeveloperCompanyIdTenant isolation
ActorUserIdidentity claimsGuidДаJWT менеджера; пишется в UpdatedByUserId
PresetIdrouteGuidДа!= Guid.Empty; живой пресет в TenantId (шаг 2)
NamebodystringДаtrim, 1..128, не пустое; уникально по (PurchaseProgramId, lower(Name)) среди живых кроме себя
Codebodystring?Нет0..64; display-код, не доменный ключ
InitialPaymentPercentbodydecimalДаnumeric(5,2), >= 0 and <= 100 (INV-PPR-2)
MonthlyFixedPaymentbodydecimalДаnumeric(18,2), >= 0 (RUB, как цена объекта недвижимости)
TermMonthsbodyintДа> 0 and <= 600 (INV-PPR-2)
DiscountPercentbodydecimalДаnumeric(5,2), >= 0 and <= 100 (INV-PPR-2)
MilestonePaymentsbodyMilestonePaymentListДа (список может быть пустым)контейнер ниже; INV-PPR-3 (единственный remainder, последний по Month)
SortOrderbodyintДа>= 0
RowVersionbodyuintДаоптимистический токен из get/list; несовпадение → 409

Контейнер MilestonePaymentList (вход = проекция jsonb-модели агрегата)

Дом модели — purchase-program.md (раздел «Модели JSONB»). Контейнер: { ContractVersion: int (=1), Items: MilestonePayment[] }; элемент MilestonePayment:

ПолеТипОбязательностьВалидация
MonthintДа>= 1 and <= TermMonths запроса
Kindstring (enum MilestoneKind: percent / fixed / remainder)ДаEnum.IsDefined; не более одного remainder, он максимален по Month (INV-PPR-3)
ValuedecimalДаpercent: > 0 and <= 100; fixed: > 0 (RUB); remainder: игнорируется, нормализуется в 0

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

Проекция child-сущности — дом полей purchase-program.md; ниже дельта, значимая для правки. Та же модель отдаётся get/list пресетов застройщика. TenantId наружу не отдаётся.

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаInstallmentPresetEntity.IdИдентификатор пресета (на него ссылается Deal.InstallmentPresetId).
PurchaseProgramIdGuidДаInstallmentPresetEntity.PurchaseProgramIdРодительская программа (не изменилась операцией).
NamestringДаInstallmentPresetEntity.NameНовое название пресета.
Codestring?НетInstallmentPresetEntity.CodeDisplay-код.
InitialPaymentPercentdecimalДаInstallmentPresetEntity.InitialPaymentPercentПервоначальный взнос, %.
MonthlyFixedPaymentdecimalДаInstallmentPresetEntity.MonthlyFixedPaymentЕжемесячный платёж, RUB.
TermMonthsintДаInstallmentPresetEntity.TermMonthsСрок рассрочки, месяцы.
DiscountPercentdecimalДаInstallmentPresetEntity.DiscountPercentСкидка пресета, %.
MilestonePaymentsMilestonePaymentListДаInstallmentPresetEntity.MilestonePaymentsОбновлённый milestone-график.
SortOrderintДаInstallmentPresetEntity.SortOrderПорядок показа опции.
VersionlongДаInstallmentPresetEntity.VersionИнкрементирована на 1.
RowVersionuintДаInstallmentPresetEntity.RowVersionНовый токен оптимистической блокировки для следующей правки клиента.
UpdatedAtDateTimeOffsetДаInstallmentPresetEntity.UpdatedAtМомент правки.

Алгоритм

  1. Контекст и доступ. Выполняет менеджер застройщика (User, permission crm.purchase-programs.manage; менеджер ПИН / администратор платформы — platform-scope); tenant — TenantId = DeveloperCompanyId. Идемпотентность естественная — защита от параллельной правки строится на RowVersion (шаги 2, 5).
  2. Проверка входа и предусловий.
    • PresetId != Guid.Empty; Name после trim 1..128; Code 0..64; SortOrder >= 0; RowVersion задан — иначе VALIDATION_FAILED;
    • диапазоны (INV-PPR-2): InitialPaymentPercent/DiscountPercent ∈ [0..100], MonthlyFixedPayment >= 0, 0 < TermMonths <= 600, MilestonePayment.Value по Kind — нарушение → CRM_PURCHASE_PROGRAM_RANGE;
    • milestone-правило (INV-PPR-3): каждый Month ∈ [1..TermMonths]; в Items не более одного remainder, и его Month максимален — иначе CRM_INSTALLMENT_REMAINDER_INVALID;
    • полнота графика (INV-PPR-4): прогон формулы расчёта (purchase-program.md, «Серверный расчёт графика платежей») на эталонной цене; остаток после последнего месяца ≠ 0 (за вычетом округления) → CRM_INSTALLMENT_SCHEDULE_INCOMPLETE (канонично гарантируется хвостовым remainder, но допустима и точная раскладка без него);
    • существование: пресет есть среди живых в TenantId (архивный невидим) — иначе CRM_INSTALLMENT_PRESET_NOT_FOUND (правка архива запрещена);
    • scope: preset.TenantId == TenantId — иначе CRM_INSTALLMENT_PRESET_FORBIDDEN (чужая программа);
    • уникальность имени: живой пресет той же программы с таким Name (ci), кроме себя — если есть → CRM_INSTALLMENT_PRESET_NAME_DUPLICATE.
  3. Что читаем. Сам пресет и корень-агрегат — программу покупки: правка идёт через корень (инварианты и доменный факт живут на программе, дом — purchase-program.md). Проверка использования в сделках не делается: в отличие от архива (INV-PPR-5, CRM_INSTALLMENT_PRESET_IN_USE), правка допускается при живых ссылках deals.installment_preset_id — она не ретроактивна (см. Назначение). Строчная блокировка не берётся — конкурентность точечной правки покрывает оптимистический RowVersion.
  4. Бизнес-правила и переходы. Обновление переносит поле-в-поле: Name = trim(Name), Code, InitialPaymentPercent, MonthlyFixedPayment, TermMonths, DiscountPercent, MilestonePayments = MilestonePaymentList(ContractVersion = 1, Items) (нормализация remainder.Value → 0), SortOrder, UpdatedByUserId = ActorUserId, UpdatedAt = now, Version += 1; Id/PurchaseProgramId/TenantId/ CreatedBy* неизменны. Статусной машины нет (конфигурация: живой ↔ архив). Фиксируется доменный факт PurchaseProgramChanged.
  5. Что меняется. Обновляются параметры пресета. Оптимистическая блокировка по RowVersion: если между чтением клиента и записью пресет был изменён — конфликт CRM_INSTALLMENT_PRESET_CONCURRENCY_CONFLICT; гонку одинакового имени ловит unique-индекс ux_installment_presets_program_nameCRM_INSTALLMENT_PRESET_NAME_DUPLICATE. Пресет не денормализован в deals — нетерминальные сделки пересчитают график по новым параметрам при следующем запросе, терминальные (Completed/Cancelled) остаются read-only.
  6. События. Интеграционных Kafka-событий нет: конфигурация покупки читается синхронно, топика pin.crm.purchase-program не существует (карта событий — README). Внутренний доменный факт PurchaseProgramChanged инвалидирует кэш калькулятора графика.
  7. Согласованность. Обновление пресета и audit/Version фиксируются в одном согласованном изменении (атомарно).
  8. Результат. Карточка InstallmentPresetModel обновлённого пресета (новые параметры, инкрементированный Version, свежий RowVersion) → HTTP 200.

Диаграмма

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

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

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400PresetId = Guid.Empty, пустое/длинное Name/Code, SortOrder < 0, нет RowVersion, неизвестный Kind«Проверьте корректность полей пресета»no retry
CRM_PURCHASE_PROGRAM_RANGE400проценты вне [0..100], TermMonths <= 0/> 600, MonthlyFixedPayment < 0, Value не по Kind (INV-PPR-2)«Значения процентов и сроков вне допустимого диапазона»no retry
CRM_INSTALLMENT_REMAINDER_INVALID422более одного remainder либо remainder не последний по Month, либо Month вне [1..TermMonths] (INV-PPR-3)«Правило платежа-остатка нарушено»no retry
CRM_INSTALLMENT_SCHEDULE_INCOMPLETE422график не погашает 100% эталонной цены (INV-PPR-4)«График рассрочки не сходится к полной стоимости»no retry
CRM_INSTALLMENT_PRESET_NOT_FOUND404пресет не найден в TenantId либо архивирован«Пресет рассрочки не найден»no retry
CRM_INSTALLMENT_PRESET_FORBIDDEN403чужой tenant / нет crm.purchase-programs.manage«Нет доступа к пресету»no retry
CRM_INSTALLMENT_PRESET_NAME_DUPLICATE409имя занято живым пресетом программы (pre-check либо ux_installment_presets_program_name)«Пресет с таким названием уже есть»no retry (сменить имя)
CRM_INSTALLMENT_PRESET_CONCURRENCY_CONFLICT409RowVersion устарел (параллельная правка)«Пресет изменён параллельно, обновите данные»retry (перечитать RowVersion)

Acceptance Criteria

  • Given живой пресет в TenantId с актуальным RowVersion, When валидная правка параметров и milestone-графика, Then параметры обновлены, remainder.Value нормализован в 0, Version += 1, свежий RowVersion, ответ 200 с InstallmentPresetModel.
  • Given InitialPaymentPercent/DiscountPercent вне [0..100], TermMonths ∉ (0..600] или MonthlyFixedPayment < 0, When update, Then CRM_PURCHASE_PROGRAM_RANGE (400) по INV-PPR-2.
  • Given более одного remainder либо remainder не максимален по Month (или Month ∉ [1..TermMonths]), When update, Then CRM_INSTALLMENT_REMAINDER_INVALID (422) по INV-PPR-3.
  • Given график не гасит 100% эталонной цены (остаток ≠ 0 сверх округления), When update, Then CRM_INSTALLMENT_SCHEDULE_INCOMPLETE (422) по INV-PPR-4.
  • Given имя занято другим живым пресетом той же программы (ci), When update, Then CRM_INSTALLMENT_PRESET_NAME_DUPLICATE (409; pre-check или ux_installment_presets_program_name).
  • Given RowVersion устарел (параллельная правка), When update, Then CRM_INSTALLMENT_PRESET_CONCURRENCY_CONFLICT (409); клиент перечитывает RowVersion и повторяет.
  • Given на пресет ссылаются нетерминальные и терминальные сделки, When параметры изменены, Then правка проходит (в отличие от архива INV-PPR-5 не блокируется живыми ссылками), нетерминальные сделки пересчитают график по новым параметрам при следующем запросе, а Completed/Cancelled остаются read-only с уже согласованным графиком.
  • Given пресет архивирован либо не существует в TenantId, When update, Then CRM_INSTALLMENT_PRESET_NOT_FOUND (404); чужой tenant → CRM_INSTALLMENT_PRESET_FORBIDDEN (403).

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

  • Route/тело стабильны; добавление optional-полей в InstallmentPresetModel / MilestonePayment — additive (без breaking); удаление/переименование/смена типа — breaking (migration + rollout). Enum-ы MilestoneKind/PaymentMethod — строкой; unknown → VALIDATION_FAILED. MilestonePaymentList versioned (ContractVersion = 1) — новые версии добавляются с upcast (дом — purchase-program.md).
  • Logs: installment-preset-update requested/succeeded/conflict (PresetId, PurchaseProgramId, TenantId; PII нет); metrics: crm_installment_preset_update_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь).

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

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

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

  • Сущность, инварианты, формула и индексы: purchase-program.md (INV-PPR-2/3/4, enum MilestoneKind, RowVersion/soft-delete, ux_installment_presets_program_name, доменное PurchaseProgramChanged); потребитель пресета — deal.md (InstallmentPresetId, PaymentMethod = Installment).
  • Карта событий и область developer — ../../README.md (пресеты интеграционных событий не публикуют; конфигурация читается синхронно).
  • ADR: 0052 tenancy, 0056 simplicity-first.