# Групповой выбор в ДП "Выбор нескольких задач из категории" (Multilookup) — SSRM

Документ описывает механизм группового выбора в модальном окне Multilookup: зачем он нужен, как устроен на клиенте и на сервере, какие инварианты поддерживаются и какие ограничения есть. Для фронтенд-разработчиков гридов, поддержки и интеграторов. Паттерн применяется только в режиме мультиселекта (`isMultiselect = true`).

## Зачем это нужно

Модальное окно Multilookup отображает реестр задач через **Server-Side Row Model (SSRM)** — ag-grid загружает строки постранично с сервера. В любой момент в браузере присутствует только текущая страница (по умолчанию 100 строк).

**Проблема:** пользователь раскрывает группу из 800 задач, ставит чекбокс на группе — и ожидает выбрать все 800. Но стандартный API грида видит только 100 загруженных строк и выбирает лишь их.

**Решение:** не пытаться загрузить все строки на клиент, а **зафиксировать намерение** пользователя и передать его серверу, который имеет доступ ко всему датасету.

Паттерн работает **только в режиме мультиселекта** (`isMultiselect = true`). Одиночный лукап использует стандартный API грида без этой логики.

## Архитектура: три слоя

Реализация разбита на три независимых слоя: клиентский кеш намерений, восстановление состояния интерфейса при скролле и серверное разрешение групп в ID задач.

```
┌─────────────────────────────────────────────┐
│            Кеш намерений выбора             │  ← состояние на клиенте
│   (группы, переопределения строк, выборы)   │
├─────────────────────────────────────────────┤
│      Восстановление состояния в UI          │  ← синхронизация кеша с гридом
│     (восстановление чекбоксов при скролле)  │
├─────────────────────────────────────────────┤
│         Разрешение на сервере               │  ← запрос → бэкенд при сохранении
│      (разворачивание групп в ID задач)      │
└─────────────────────────────────────────────┘
```

```mermaid
flowchart TD
    A["Пользователь ставит чекбокс на группе (например, 800 задач)"] --> B["Кеш намерений: запись о группе (groupKeys, filterModel, sortModel), а не список ID"]
    B --> C["При скролле — восстановление чекбоксов из кеша (setNodesSelected)"]
    B --> D["Сохранение → payload: selectedGroups, include/exclude rows, selectedTaskIds, clearExisting"]
    D --> E{"Есть серверный ID сущности?"}
    E -- "да" --> F["Сервер выполняет тот же SSRM-запрос по группе → разворачивает в ID задач"]
    E -- "нет, новая сущность" --> G["Клиент сам резолвит группы через data source API"]
    F --> H["Применить include/exclude/individual, сохранить; UI обновляется по SignalR"]
    G --> H
```

## Слой 1 — кеш намерений (что запомнил клиент)

Вместо списка выбранных ID кеш хранит **описание намерения**:

| Объект | Что содержит | Зачем |
|--------|-------------|-------|
| **Запись о группе** (Group Selection Entry) | `groupKeys` (путь в дереве), `rowGroupCols`, `filterModel`, `sortModel`, `childCount`, тип `include`/`exclude` | Описывает выбранную/снятую группу |
| **Переопределение строки** (Row Override) | `taskId`, `parentGroupKeys`, тип `include`/`exclude` | Индивидуальное переопределение строки внутри группы |
| **Отдельные выборы** (Individual Selections) | Список ID | Строки, выбранные вне контекста группировки |

### Семантика кеша: приоритет, инварианты, подсчёт

Правило выбора применяется по убыванию приоритета: строчное переопределение важнее группового, групповое важнее индивидуального, индивидуальное — важнее начального состояния.

```
Row Override > Group Selection > Individual Selection > Initial State
```

1. Есть row override для строки → применяем его
2. Нет override, но выбрана группа-предок → строка выбрана
3. Нет группового контекста → смотрим individual selections и начальное состояние

Инварианты работы с кешем:

- Выбор группы **очищает** все row overrides внутри неё (сброс к чистому состоянию)
- Снятие группы происходит только по **явному клику пользователя** — каскадные события грида игнорируются (см. раздел «Различение событий» ниже)
- Если выбрана родительская группа, дочерние записи не дублируются

Подсчёт выбранных строк выполняется по формуле, учитывающей все группы, индивидуальные переопределения и одиночные выборы:

```
count = Σ(childCount для include-групп)
      + количество include row overrides
      - количество exclude row overrides
      + количество individual selections
```

`childCount` берётся из `node.allChildrenCount` ag-grid — серверное значение, доступное на group node.

## Слой 2 — восстановление состояния (синхронизация интерфейса)

При каждом скролле, фильтрации или раскрытии группы SSRM подгружает новые строки. Их `isSelected` по умолчанию `false`. Необходимо **восстановить чекбоксы** по состоянию из intent cache.

### Алгоритм восстановления, подавление и различение событий

Алгоритм после каждого fetch:

```
для каждого node в загруженных строках:
  override = cache.getRowOverride(node.taskId)
  если override.exclude → снять выделение
  если override.include → выделить
  иначе:
    если ancestor-группа выбрана → выделить
    если node в individualSelections → выделить
    если node в initialState → выделить
    иначе → снять выделение (если ошибочно выделен)
```

Восстановление выполняется через `setNodesSelected()` API грида.

`setNodesSelected()` генерирует `onRowSelected` события **асинхронно** (через microtask queue). Без подавления эти события трактуются как пользовательский ввод и искажают intent cache. Guard-флаг с **асинхронным сбросом**:

```typescript
isRestoringSelection = true
setNodesSelected(...)
setTimeout(() => isRestoringSelection = false)
// Синхронный сброс не работает — события ещё не доставлены в момент сброса
```

ag-grid при снятии чекбокса с дочерней строки каскадно снимает выделение с родительской группы (`onRowSelected` с `isSelected = false`). Это **не должно** очищать группу из intent cache.

Отличие каскадного события от пользовательского клика: каскадное событие не содержит нативного DOM-события `event`.

```typescript
если node.group И node.deselected:
  если event присутствует → пользователь явно снял группу → deselectGroup()
  если event отсутствует → каскад от потомка → игнорировать
```

## Слой 3 — разрешение на сервере (резолв на бэкенде)

### Состав payload, алгоритм резолва и запасной вариант

Состав запроса (payload) при сохранении:

```json
{
  "selectedGroups": [
    {
      "groupKeys": ["Отдел A"],
      "rowGroupCols": [{"id": "dep", "field": "department"}],
      "filterModel": {"status": {"type": "equals", "filter": "active"}},
      "sortModel": []
    }
  ],
  "deselectedGroups": [],
  "includeRows": [{"taskId": 42}],
  "excludeRows": [{"taskId": 17}],
  "selectedTaskIds": [100, 200],
  "clearExisting": true
}
```

Алгоритм резолва на сервере:

```
result = clearExisting ? {} : текущее состояние

для каждой selectedGroup:
  ids = executeQuery(groupKeys, filterModel, sortModel)  // тот же SSRM-запрос
  result += ids

для каждой deselectedGroup:
  ids = executeQuery(...)
  result -= ids

result += includeRows
result -= excludeRows
result += selectedTaskIds

save(result)
```

Сервер выполняет **тот же запрос**, что и грид при отображении этой группы — с теми же фильтрами и группировкой. Это гарантирует согласованность между тем, что видел пользователь, и тем, что записывается.

Запасной вариант — разрешение на клиенте. При создании новой сущности (нет серверного ID) server-side resolution недоступен, и клиент резолвит выбор самостоятельно. Через data source API запрашиваются данные выбранных групп и, отдельным запросом по списку идентификаторов, задачи, отмеченные по одной (`selectedTaskIds` вместе с `includeRows`). Результаты объединяются с отбрасыванием дублей по задаче, после чего применяются `excludeRows`. Поэтому снятие галочки с отдельной строки убирает из выборки только эту строку — даже когда ни одна группа целиком не выбрана и весь выбор состоит из индивидуально отмеченных задач.

#### Где живёт клиентский резолв

Клиентское разрешение выборки описано одним методом — `resolveGroupSelectionLocally` сервиса `ExtParamMultiselectSubcatTasksLogicService` (файл `ext-param-multiselect-subcat-tasks-logic.service.ts` в папке компонента `components/task/ui/ext-fields/ext-param-multiselect-subcat-tasks/`). Это единственная реализация: и обычный мультилукап, и его табличный вид вызывают её, а не держат свои копии.

| Место вызова | Что идёт первым аргументом |
|---|---|
| `ext-param-multiselect-subcat-tasks.component.ts` — обычный мультилукап | `this.epId` |
| `multilookup-table/multilookup-table.component.ts` — табличный вид | `this.extParamId` |

Оба вызова стоят в ветке «задача ещё не создана»: пока у задачи нет идентификатора на сервере, сохранить выбор и развернуть его сервером нельзя, поэтому окно собирает строки до создания задачи. Идентификатор параметра передаётся аргументом — поэтому разница в названии поля (`epId` против `extParamId`) остаётся на стороне компонента и в сервис не попадает.

Контракт метода типизирован:

```typescript
resolveGroupSelectionLocally(
  extParamId: number,
  payload: IMultiselectApplyPayload
): Observable<ITasksDataSourceResponse[]>
```

Что происходит внутри, по шагам:

1. по каждой записи из `selectedGroups` выполняется запрос данных источника (`dataSourceRows('lookup', extParamId, …)`) с её `groupKeys`, `rowGroupCols`, `filterModel` и `sortModel` — тем же запросом, что и грид при раскрытии группы;
2. `selectedTaskIds` и строки из `includeRows` сводятся в один набор; если набор непуст, к запросам добавляется ещё один — по этому списку, фильтром `taskId` типа `set`;
3. ответы всех запросов склеиваются в карту по `taskId`, поэтому дублей не остаётся; строки без `taskId` и строки из `excludeRows` отбрасываются;
4. если в payload нет ни групп, ни отмеченных задач, метод возвращает пустой массив и не делает ни одного запроса.

Проверки лежат рядом, в `ext-param-multiselect-subcat-tasks-logic.service.spec.ts`: пустой payload без запросов; склейка групп и отдельных отметок с дедупликацией и отбрасыванием `excludeRows`; загрузка только `selectedTaskIds`, когда групп нет. При изменении этой логики правка вносится в сервис и его тест — обе точки вызова остаются как были.

**Ограничение для доработок.** На одно разрешение выборки уходит столько запросов к источнику, сколько групп в payload, плюс один запрос на отдельные отметки; переиспользования потоков и кеша нет. Стоит ли это оптимизировать — открытый вопрос, отдельной задачей пока не заведён.

## Серверная группировка по полю-маркеру «Сохраненные»

Строки окна выбора можно сгруппировать по синтетическому полю `Сохраненные`. Такого поля в источнике данных нет: его обрабатывает отдельная ветка серверной группировки, которая делит строки на две группы — «Сохраненные значения» и «Доступно для выбора». Первая группа всегда идёт выше.

Признак считается по фактически сохранённому в параметре набору: сервер читает связи `ExtParamValueSelectedTask` по дополнительному параметру и задаче из контекста грида, а не по текущим отметкам в окне. Поэтому группы не переставляются вслед за галками — строка переходит в «Сохраненные значения» только после сохранения параметра.

Граничные случаи. Если сохранённых значений нет, условие строится как заведомо ложное сравнение по колонке задачи, а не как константа: на MS SQL выражение группировки обязано ссылаться на колонку. На PostgreSQL результат приводится к `varchar`, потому что выражение со строковыми литералами получает тип `text`, а процедура ожидает `varchar`.

В окне выбора поле представлено колонкой «Выбор» — она скрыта по умолчанию и доступна в выборе столбцов. Сортировка и фильтр по ней не поддерживаются: поле работает только как группировка.

#### Поведение на форме создания новой задачи

При открытии окна выбора на форме создания задачи, когда идентификатора задачи ещё нет в контексте грида, сервер определяет контекст синтетической группировки по типу грида и идентификатору параметра, не требуя обязательного наличия `TaskId`. Набор ранее сохранённых задач считается пустым без обращения к базе данных, поэтому все доступные для выбора строки попадают в группу «Доступно для выбора». Синтетическое поле в этом случае не прорастает в фильтрацию как физическая колонка — иначе запрос падал бы на отсутствующей колонке.

### Фильтр раскрытой группы

Раскрытие группы — отдельный запрос строк: клиент присылает ключ группы (`groupKeys`) и колонки группировки (`rowGroupCols`). Для обычной колонки сервер строит текстовое условие `equals` по её имени. Для поля-маркера «Сохраненные» так делать нельзя: колонки с таким именем в выборке нет, и условие по ней роняет запрос строк.

Поэтому фильтр раскрытой группы по полю-маркеру строится по номеру задачи — тем же набором, которым построена сама группа:

| Ключ группы | Условие на строки |
|---|---|
| «Сохраненные значения» | номера задач входят в сохранённый набор |
| «Доступно для выбора» | номера задач не входят в сохранённый набор |
| любой другой ключ | пустая группа |

Что из этого следует при отладке:

- **Счётчик группы и состав её строк совпадают по построению.** Набор читает один и тот же загрузчик, поэтому группа и её раскрытие не могут разойтись: после сохранения параметра и повторного открытия окна обе части смотрят на новый набор.
- **Пустой сохранённый набор запрос не ломает.** Для него условие строится как заведомо ложное сравнение по колонке задачи (`is null` / `is not null`), а не как пустой список номеров.
- **Предел размера набора (базово 1000) к серверному набору не применяется.** Он ограничивает только клиентский отбор по номерам задач; серверный набор уже целиком стоит в выражении группы, и повторная проверка отказывала бы ровно там, где группа построена. Признак «набор загружен сервером» из тела запроса не подделывается — его выставляет только сервер.
- **Чужой ключ даёт пустую группу, а не ошибку.** Такой запрос раньше отвечал отказом.
- **Число ключей группы не может превысить число колонок группировки.** Лишние ключи отбрасываются: раньше на этом был выход за границу списка.

**Условие контекста.** Ветка работает только для грида, открытого из карточки задачи: тип источника — «Лукап», в контексте есть идентификатор задачи и идентификатор параметра. В форме создания задачи задачи ещё нет, поле-маркер не распознаётся, и группировка по «Сохранённые» там не поддержана.

## Два источника истины в мультиселекте

В режиме мультиселекта одновременно работают два независимых хранилища:

| Источник | Что хранит | Используется |
|----------|-----------|--------------|
| `selectedItems` (список) | Индивидуально выбранные строки вне групп | Плоский выбор |
| `selectionCache` (кеш намерений) | Группы + переопределения строк | Выбор с учётом групп |

Итоговый count = `selectedItems.length` + `selectionCache.selectedCount`.

При сохранении оба источника сериализуются в единый payload: `selectedTaskIds` из списка, остальное из кеша.

Отдельный Set хранит ID записей, выбранных на момент открытия диалога (загружены с сервера как `isSelected: true`). Этот **Initial State Set** используется для восстановления чекбоксов при скролле (если запись не в группе и не в individual selections) и для корректного подсчёта при снятии галки с изначально выбранной записи.

Снятие галки с записи из initial state **удаляет** её из Set и добавляет exclude override в кеш — предотвращает повторное выделение при следующем fetch.

## Известные ограничения

Следующие ограничения существуют в текущей реализации и необходимо учитывать при разработке и отладке.

1. **childCount приблизительный** — `allChildrenCount` на group node может быть неточным при глубокой вложенности. Точный count доступен только после server-side resolution.

2. **groupKeys содержат display values** — ключи группировки берутся из `node.key` (отформатированное значение). Если бэкенд ожидает raw-значения, необходим маппинг.

3. **FilterModel — снимок на момент выбора** — filterModel фиксируется при выборе группы. Если пользователь изменит фильтры после, группа резолвится по **оригинальным** фильтрам. Это намеренное поведение: пользователь выбрал конкретный срез данных.

4. **Фильтр раскрытой группы по «Сохранённые» — только из карточки задачи.** В форме создания задачи в контексте нет идентификатора задачи, поле-маркер не распознаётся, и запрос строк отвечает ошибкой.

4. **Обновление родительского компонента** — после server-side resolution UI обновляется через SignalR/WebSocket. При отсутствии соединения обновление происходит только при перезагрузке страницы.
