Предметная областьМодулиПредложенные правки в ревью

Предложенные правки в ревью

Актуально на: 2026-08-11

Ревьюер не только описывает проблему словами, но и показывает решение: правит план прямо в интерфейсе, а изменения уходят автору списком. Владелец принимает их по одной — принятая правка меняет план, отклонённая остаётся в истории пометки.

Продуктовая часть

Кто что может

РольЧто доступно
Ревьюер (команда сервиса)Включить режим правок, изменить значения в плане, отправить набор вместе с пометкой
Владелец планаПринять или отклонить каждую правку
Другой участник проектаВидит правки и решения, кнопок нет

Правки живут только у заявок, которые занимают план (PLAN, LISTING). Проверка витрины (LISTING_CARD) идёт параллельно и к содержимому плана отношения не имеет.

Правка без пометки не отправляется

Набор правок всегда приходит вместе с текстом: увидев изменение значения без объяснения, автор не поймёт мотива и либо примет вслепую, либо отклонит. Пометка и набор создаются одним запросом.

Что можно предлагать

Только данные модели — то, что перечислено в PLAN_REVIEW_DATA_KEYS: продукты, календарный план, материалы, персонал, издержки, налоги, займы, акционерный капитал, настройки расчёта, заголовок проекта. Настройки отображения, тема, текст бизнес-плана и приложенные документы правками не покрываются.

Решение построчно

Статус живёт на строке, а не на наборе: половину правок можно взять, половину отклонить. Набор не атомарен — принятая правка применяется сразу, независимо от соседних.

Границы во времени

Принимать правки можно, пока заявка активна. После завершения ревью они остаются историей: применять их к уже изменившейся модели через месяц — не то, о чём договаривались. Поэтому завершение ревью при нерассмотренных правках требует подтверждения (force) — так же, как одобрение листинга с незакрытыми пометками.

Правка и отметка «проверено»

Принятие правки — обычное изменение плана, поэтому действующая отметка переходит в STALE (см. plan-verification.md). Это осознанное поведение: план после правки отличается от одобренного снимка. Практический вывод — правки принимают до завершения ревью, тогда отметка выдаётся уже с их учётом.

Техническая часть

Адресация

Правка описывает место и операцию над ним:

path: ["loansPlan", "instruments", { "id": "cml..." }, "rateAnnual"]
op:   SET | INSERT | REMOVE

Элементы списков адресуются по id, а не по позиции: порядок строк в плане меняется свободно и сам по себе правку не отменяет. У списков без id сегментом становится индекс — такие правки хрупче, их спасает проверка исходного значения.

Путь обязан начинаться с содержательного ключа состояния (isAllowedPlanPath); __proto__, constructor и ключи с __ отклоняются при разборе. Значение правки проходит sanitizePlanValue: только простые JSON-данные, до 32 КБ и 12 уровней вложенности.

Значения правки хранятся обёрткой { v: … }: пустая колонка означает «значения нет», { v: null } — «поле нужно обнулить». Без обёртки эти случаи читаются из базы одинаково (Prisma отдаёт null и для SQL NULL, и для JSON null), и правка, обнуляющая поле, не применилась бы никогда. Той же обёрткой принятая правка возвращается клиенту.

Разбор различий

diffSnapshots (lib/plan-review/diff.ts) выдаёт каждое изменение сразу в двух видах: читаемом (path, «Займы → Кредит ВТБ → Ставка, годовых») и машинном (keyPath, op, index). Ряды примитивов — помесячные объёмы, суммы по периодам — сравниваются целиком, но показываются с перечнем изменившихся позиций («#13: 100 · #14: 120»): одна правка вместо сотни строк «#13», и при этом видно, что именно поменялось.

Вглубь разбор идёт без ограничения — им пользуются и блок отметки «проверено», и «что изменилось после пометки», где сравнивается в том числе текст бизнес-плана. Предел MAX_PLAN_PATH_DEPTH касается только адреса: изменение глубже него остаётся видимым, но приходит с пустым keyPath и правкой не предлагается.

Инвариант «было + правки = стало» проверяется скриптом:

pnpm exec tsx scripts/test-plan-patch.ts

Конфликты

У каждой правки хранится before — значение на момент подготовки. Перед применением оно сверяется с текущим (planPatchMatchesBase):

  • совпало — правка применяется;
  • не совпало — ответ 409 с текущим значением, в панели правка помечена «место изменилось», принять её можно только подтверждением (force);
  • места больше нет (элемент удалён) — принять нельзя, только отклонить.

Правка, заполняющая пустое место (новый ключ), ожидает, что место и сейчас пустует — иначе это тоже конфликт.

Порядок принятия

Состояние плана в воркспейсе разложено по десяткам отдельных useState, а автосохранение отправляет его целиком. Поэтому:

  1. клиент придерживает автосохранение (pauseAutosave) и догоняет базу текущим состоянием (saveWorkspaceStateNow);
  2. сервер сверяет before, захватывает строку (PENDING → ACCEPTED одним updateMany — два клика подряд не применят правку дважды), применяет патч и пишет план через persistPlanState;
  3. клиент раскладывает ту же правку по своим состояниям (useSuggestionApply) и отпускает автосохранение.

Если запись не удалась, захват снимается и строка возвращается в PENDING.

Задержка автосохранения — состояние, а не флаг: на время паузы таймеры не ставятся и взводятся заново, когда она снята, поэтому правки, набранные в этот момент, не теряются. Отдельное состояние blocked — задержка без снятия: если принятая правка легла в базу, но в открытом плане её показать нечем, писать из этой вкладки больше нельзя (следующее сохранение вернуло бы состояние без правки), и панель уводит пользователя в перезагрузку.

Принятая правка, которой не оказалось в плане (её затёрло сохранение из другой вкладки или значение вернули руками), приходит в панель с признаком reverted: владелец видит, что стоит в плане сейчас, и применяет правку заново одной кнопкой — статус решения при этом не меняется.

Запись состояния идёт единственным путём — persistPlanState (lib/persist-plan-state.ts): вместе с состоянием он пересчитывает название, прогресс и отметку «проверено». Мимо него план не пишут — иначе заверенный план изменился бы незаметно и ушёл бы в маркетплейс без модерации.

Режим правок у ревьюера

Ревьюер смотрит чужой план в режиме наблюдения: поля редактируются, но сохранение отключено. Режим правок этим и пользуется — при включении запоминается срез плана (baseline), дальше ревьюер работает как обычно, а панель с задержкой сравнивает срез с текущим состоянием и показывает список. Лишнее (производные пересчёты) ревьюер снимает галочками перед отправкой.

После отправки план возвращается к baseline: иначе ревьюер продолжил бы смотреть на «свою» версию и следующий набор собирал бы поверх собственных изменений.

Модель данных

PlanReviewSuggestion — одна строка на одно принимаемое изменение, noteId обязателен. Хранит путь (path), читаемую подпись (label), операцию, before/after, позицию вставки, статус и решение владельца.

Миграция: prisma/manual-migrations/2026-08-11-review-suggestions.sql.

Ограничения

  • До 40 правок в одном наборе; больше — разбить на несколько пометок.
  • Две открытые вкладки одного плана по-прежнему затирают друг друга: автосейв пишет состояние целиком и без проверки версии. Паузу держит только та вкладка, в которой принимают правку, поэтому чужая вкладка может вернуть план к состоянию без неё. Потеря не молчаливая — правка вернётся с признаком reverted и применяется заново, — но защиту от перезаписи между вкладками нужно делать в самом автосохранении, а не здесь.