Предложенные правки в ревью
Актуально на: 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, а
автосохранение отправляет его целиком. Поэтому:
- клиент придерживает автосохранение (
pauseAutosave) и догоняет базу текущим состоянием (saveWorkspaceStateNow); - сервер сверяет
before, захватывает строку (PENDING → ACCEPTEDоднимupdateMany— два клика подряд не применят правку дважды), применяет патч и пишет план черезpersistPlanState; - клиент раскладывает ту же правку по своим состояниям
(
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и применяется заново, — но защиту от перезаписи между вкладками нужно делать в самом автосохранении, а не здесь.