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

POST /api/v1/investor/calendar/appointments/{appointmentId}/reschedule — Перенос моей записи

Назначение

Инвестор переносит свою активную запись на другой свободный слот того же агента и того же вида встречи: выбирает новое время на витрине (list-available-slots.md) и вызывает перенос. Операция атомарна — «отмена текущей записи + бронирование нового слота» происходят вместе или не происходят вовсе: если новый слот успели занять, старая запись остаётся нетронутой. Пара записей связывается взаимными ссылками RescheduledFrom/RescheduledTo (INV-APT-5).

Metadata

ПолеЗначение
Сервис/контекстcalendar
API areainvestor
Feature groupappointments
Статусdraft
Документdocs/06-services/calendar/api/investor/reschedule-my-appointment.md

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

ПроверкаЗначение
Actor typeUser (Инвестор с JWT)
Auth policy / Permissionsnone (достаточно аутентификации инвестора)
Scope (RBAC)не применяется — инвестор вне tenant (ADR-0052)
Record-levelпереносить можно только свою запись: InvestorUserId = ActorUserId из токена; чужая → 404
Tenant isolationTenantId новой записи = компания агента (та же — агент не меняется)
Auditupdate + create: у старой записи CancelledBy = Investor и RescheduledToAppointmentId; у новой — RescheduledFromAppointmentId; актор фиксируется

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/investor/calendar/appointments/{appointmentId}/reschedule
Success status201 (создана новая запись; старая отменена)
Idempotency headerне применяется: повтор переноса уже отменённой записи → 409 APPOINTMENT_INVALID_STATE; повтор на тот же новый слот → слот уже Booked
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdтокен (sub)GuidДаRequiredrecord-level фильтр InvestorUserId
AppointmentIdrouteGuidДаRequired; переносимая (старая) запись
NewSlotIdbodyGuidДаRequired; ≠ слоту текущей записи (перенос «на то же время» бессмыслен → VALIDATION_FAILED)

Комментарий инвестора (Comment) отдельно не передаётся — он копируется из старой записи в новую.

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

Новая запись — модель по дому list-my-appointments.md: RescheduledFromAppointmentId = {AppointmentId} заполнен; Status — по AutoConfirm нового слота (Confirmed или Pending с PendingExpiresAt). Отменённая старая запись доступна через get-my-appointment.md (Status = Cancelled, RescheduledToAppointmentId — id новой).

Алгоритм

  1. Контекст и доступ. Актор — аутентифицированный инвестор; старая запись ищется с условием InvestorUserId = actor.
  2. Проверка входа. NewSlotId задан и отличается от слота текущей записи — иначе VALIDATION_FAILED.
  3. Guards старой половины. Запись не найдена/чужая → APPOINTMENT_NOT_FOUND. Статус ∉ {Pending, Confirmed} → APPOINTMENT_INVALID_STATE. Cutoff (INV-APT-3): now > StartAt − CancellationCutoffMinutesAPPOINTMENT_CUTOFF_PASSED.
  4. Guards новой половины. Новый слот не найден → CAL_SLOT_NOT_FOUND. Слот принадлежит другому агенту (AgentUserId ≠ агента записи) или имеет другой KindAPPOINTMENT_RESCHEDULE_SLOT_MISMATCH (перенос не меняет ни агента, ни вид встречи). Статус слота: BookedSLOT_ALREADY_BOOKED; Expired/ArchivedCAL_SLOT_NOT_AVAILABLE. Lead time нового слота: now > StartAt − MinLeadTimeMinutesCAL_SLOT_LEAD_TIME_VIOLATION (INV-SLT-5).
  5. Атомарная пара (INV-APT-5). В одном согласованном изменении — при отказе любой части не происходит ничего:
    • условный захват нового слота Available → Booked под построчной блокировкой (INV-SLT-3); гонка проиграна → SLOT_ALREADY_BOOKED, старая запись не тронута;
    • старая запись → Cancelled (CancelledBy = Investor, CancelReason = «Перенос записи», RescheduledToAppointmentId = id новой); её слот освобождается по правилу каскадов (Available, если now ≤ StartAt − MinLeadTimeMinutes, иначе Expired); её календарное событие → Cancelled, Pending-напоминания → Skipped;
    • создаётся новая запись со снапшотом нового слота (StartAt/EndAt/TimeZone/Kind), Comment копируется, RescheduledFromAppointmentId = id старой; статус — Confirmed (ConfirmedAt = now) при AutoConfirm нового слота, иначе Pending с PendingExpiresAt = min(now + 24 ч, StartAt − MinLeadTimeMinutes);
    • создаётся новое календарное событие Planned (Origin = Appointment) с напоминаниями;
    • публикуются события (см. Побочные эффекты) атомарно со сменой состояния. Антиспам INV-APT-2 переносом не нарушается по построению: пара (инвестор, агент, вид) освобождается отменой старой записи в том же изменении, суммарное число активных записей не растёт. Конкуренция с действиями агента/worker-а над старой записью — арбитраж версией: проигравший получает APPOINTMENT_INVALID_STATE.
  6. Результат. MyAppointmentModel новой записи → HTTP 201.

Диаграмма

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

ТипДетали
Интеграционные событияAppointmentCancelledEventpin.calendar.appointment-cancelled.v1 (Reason = CancelledByInvestor, RescheduledToAppointmentId заполнен — потребители различают перенос и «чистую» отмену); AppointmentCreatedEventpin.calendar.appointment-created.v1 (RescheduledFromAppointmentId заполнен); при auto-confirm — AppointmentConfirmedEvent; по событиям календаря — event-status-changed (старое → Cancelled) и event-created (новое)
Проекции / поискстарый слот возвращается на витрину (Available) либо списывается (Expired); напоминания старого события → Skipped, у нового — материализованы
Уведомленияопосредованно: notifications — агенту «запись перенесена»; CRM — лента лида (../../../crm/consumers/calendar-appointments.md)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400NewSlotId не задан / совпадает со слотом текущей записи / невалидные Guid«Проверьте корректность полей»no retry
UNAUTHORIZED401нет/просрочен JWTno retry
APPOINTMENT_NOT_FOUND404старая запись не существует или принадлежит другому инвестору«Запись не найдена»no retry
CAL_SLOT_NOT_FOUND404новый слот по NewSlotId не существует«Слот не найден»no retry
APPOINTMENT_INVALID_STATE409старая запись терминальна (в т.ч. проигранная гонка)«Запись уже не активна»no retry (перечитать карточку)
APPOINTMENT_CUTOFF_PASSED409now > StartAt − CancellationCutoffMinutes старой записи (INV-APT-3)«Срок переноса истёк — свяжитесь с агентом»no retry
APPOINTMENT_RESCHEDULE_SLOT_MISMATCH409новый слот принадлежит другому агенту или имеет другой вид встречи«Слот не подходит для переноса этой записи»no retry
SLOT_ALREADY_BOOKED409новый слот уже занят (гонка); старая запись не изменена«Это время уже занято, выберите другой слот»no retry (выбрать другой слот)
CAL_SLOT_NOT_AVAILABLE409новый слот Expired/Archived«Это время больше недоступно»no retry
CAL_SLOT_LEAD_TIME_VIOLATION409до начала нового слота меньше его MinLeadTimeMinutes (INV-SLT-5)«До начала осталось слишком мало времени»no retry

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

  • Код APPOINTMENT_RESCHEDULE_SLOT_MISMATCH объявлен этой операцией (в домене записи не используется — специфичен для переноса). Новые optional поля — additive; смена семантики атомарной пары — breaking.
  • Logs: reschedule received/old-guard-rejected/new-slot-rejected/done (AppointmentId старой и новой, NewSlotId; без Comment); metrics: calendar_appointment_reschedules_total{result}; traces — сквозной traceparent.

Постусловия

После 201 гарантированно: старая запись Cancelled (CancelledBy = Investor, RescheduledToAppointmentId = новая), её слот освобождён/списан, её событие Cancelled; новая запись активна (Pending/Confirmed) на новом слоте (Booked) с новым событием Planned и напоминаниями; ссылки RescheduledFrom/RescheduledTo взаимны. Суммарное число активных записей инвестора не изменилось. При любом отказе — состояние в точности до вызова.

Acceptance Criteria

  • AC-1 (happy, auto-confirm). Дано: Confirmed-запись в пределах cutoff; свободный слот того же агента/вида с AutoConfirm = true. Когда: перенос. Тогда: 201; старая — Cancelled с RescheduledToAppointmentId; новая — Confirmed с RescheduledFromAppointmentId; старый слот снова на витрине; опубликованы appointment-cancelled + appointment-created + appointment-confirmed.
  • AC-2 (happy, ручное подтверждение). Дано: новый слот с AutoConfirm = false. Тогда: новая запись Pending с PendingExpiresAt; агент решает заново.
  • AC-3 (новый слот занят). Дано: гонка — новый слот заняли первым. Тогда: 409 SLOT_ALREADY_BOOKED; старая запись и её слот не изменились.
  • AC-4 (cutoff). Дано: до начала старой записи меньше cutoff. Тогда: 409 APPOINTMENT_CUTOFF_PASSED; ничего не изменилось.
  • AC-5 (чужой агент). Дано: NewSlotId слота другого агента. Тогда: 409 APPOINTMENT_RESCHEDULE_SLOT_MISMATCH.
  • AC-6 (другой вид). Дано: запись Consultation, новый слот Showing того же агента. Тогда: 409 APPOINTMENT_RESCHEDULE_SLOT_MISMATCH.
  • AC-7 (комментарий сохраняется). Дано: у старой записи Comment. Тогда: у новой записи тот же Comment; в payload appointment-created он присутствует.

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

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

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

  • Сущность, атомарная пара переноса и каскады: appointment.md (INV-APT-2/3/5/6); слот и гонка: availability-slot.md (INV-SLT-3/5); события календаря: calendar-event.md; напоминания: event-reminder.md.
  • Витрина слотов для выбора нового времени: list-available-slots.md; отмена без переноса: cancel-my-appointment.md; дом модели ответа: list-my-appointments.md.
  • Контракты событий: ../../events/appointment-cancelled.md, ../../events/appointment-created.md, ../../events/appointment-confirmed.md.
  • ADR-0064 п.3/4 — условный захват слота, запись как отдельный агрегат.