110 lines
22 KiB
Markdown
110 lines
22 KiB
Markdown
# Design — AURORA-1883: просмотр закрытых ВСП
|
||
|
||
## Context
|
||
|
||
Исходный дизайн — описание Jira AURORA-1883 (28.10.2026). Он сверен с кодом ветки `feat/327-aurora-1885-vsp-get-and-close` (744b71f, поверх `test` ac68699, 12.11.2026). Описание и код совпадают; где фронт с 28.10 изменился, это отмечено ниже (D7, D8).
|
||
|
||
Ветка 1883 (`feat/330-aurora-1883-closed-vsp`) построена поверх AURORA-1885 (PR #328): после 1885 прошедшая дата закрытия сразу даёт `is_active=false`, поэтому «закрытый» = `is_active=false` без оговорок. AURORA-1886 (PR после #329, миграция 0062) правит `VSPService.update` и `VSPRepository.update` — с 1883 пересечений по коду нет (1883 трогает `get_list`, `get_dropdown`, `export_xlsx`, роутер и фронт). Миграции в 1883 нет.
|
||
|
||
Как сейчас (бэк):
|
||
- `api/src/api/v1/vsp.py` `get_vsp` — `service.get_list(..., is_active=True, with_count=True, load_org_unit=True)`, статус зашит. `get_vsp_dropdown(ssp_id, form_id)` → `service.get_dropdown`. `export_info(body: VSPExportRequest)` → `service.export_xlsx`.
|
||
- `api/src/domain/schemas.py` `VSPExportRequest` — `registration_number`, `address`, `ssp_ids`, `open_date_start/end`, `placement_type`, `staff_count_min/max`; статуса нет. `VSPInDB` уже отдаёт `is_active` и `close_date` (alias `closed_at`).
|
||
- `VSPService.get_list` — синхронизация закрытия (`_sync_closed_records`), пересечение ССП с ССП исполнителя, вызов `vsp_repo.get_list(... is_active=is_active ...)`. `get_dropdown` — ССП формы/`ssp_id` ∩ ССП исполнителя, `vsp_repo.get_list(ssp_ids, is_active=True, with_count)`. `export_xlsx` — `get_list(..., is_active=True, load_org_unit=True)`.
|
||
- `VSPRepository.get_list` — `is_deleted = false` всегда; фильтры через `match`; `is_active` (по умолчанию `True`); `close_date_is_null` — никем не передаётся; `order_by(Vsp.id)`; в конце мёртвый `return` после `if/else`.
|
||
|
||
Как сейчас (фронт):
|
||
- `web/src/api/dict-vsp.js` — `get(params)`, `getById`, `export`, `getDropdownVsp(params)`.
|
||
- `pages/DictVspPage/DictVspPage.jsx` — `loadData` зовёт `DictVspApi.get()` без параметров; `handleEdit` обновляет строку в списке.
|
||
- `components/DictVspFilters.jsx` — только Autocomplete ССП (`ssps`, `selectedSsp`, `onSspChange`); фильтр по ССП — на клиенте в `TableDictVsp.jsx`.
|
||
- `components/Modals/ModalExport.jsx` — `DictVspApi.export({ ssp_ids: selectedSsps })`.
|
||
- `pages/AdminPanel/SppsPage/SspTable.jsx:130-150` — образец бейджа статуса (синий «Активен» / серый «Архив», текст `#99a1af`).
|
||
- `components/RealtimeTable/RealtimeTable.jsx` ~:304-318 — `DictVspApi.getDropdownVsp({ form_id })` → `setVspOptions` (контекст `RealtimeVspOptionsContext`, `contexts/RealtimeContext.js`); ~:450 `vspRegistrationById` для поиска/фильтра по колонке ВСП.
|
||
- `Cell/Cell/Cell.jsx` ~:165-184 — `isNum` считается до проверки `isVspDropdown`; если ВСП найден в `vspOptions` — показывается `registration_number`, иначе — id, отформатированный как сумма с `displayFactor`.
|
||
- `Cell/EditCell/VspDropdownEditCell.jsx` — список выбора из `useVspOptions()`.
|
||
- `Modals/SelectVspModal.jsx` — **свой** запрос `getDropdownVsp({ form_id })` в собственный state (не контекст).
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals**
|
||
- Найти в справочнике закрытый (в том числе давно) ВСП по статусу и периоду закрытия; видеть статус строки.
|
||
- Выгрузка по выбранному статусу и периоду.
|
||
- Подпись закрытого ВСП в листах форм — рег. номер с пометкой, не число.
|
||
- Поведение по умолчанию (без параметров) — прежнее везде.
|
||
|
||
**Non-Goals**
|
||
- Снятие даты закрытия (AURORA-1886), удаление ВСП (AURORA-1884).
|
||
- Загрузка архива закрытых ВСП из внешнего источника.
|
||
- Серверная пагинация справочника, перенос фильтра ССП на сервер.
|
||
- Изменение прав: кто видит какие ВСП, кто их редактирует.
|
||
|
||
## Decisions
|
||
|
||
### D1. `status` — параметр роутера, в сервис идёт `is_active`
|
||
`get_vsp`: `status: Literal["active", "closed", "all"] = "active"` (FastAPI сам отвечает 422 на иное значение). Перевод — одна чистая функция `vsp_status_to_is_active(status) -> bool | None` (`active` → `True`, `closed` → `False`, `all` → `None`) в `api/src/services/vsp_service.py` (модульный уровень), чтобы её покрыть unit-тестом и переиспользовать в выгрузке. Сервис и репозиторий по-прежнему принимают `is_active`. `VSPStatus = Literal["active", "closed", "all"]` объявляется в `schemas.py` и используется и в роутере, и в `VSPExportRequest`.
|
||
|
||
### D2. Период закрытия — `close_date_start` / `close_date_end` до репозитория
|
||
Добавить в `get_vsp`, `VSPService.get_list`, `VSPService.export_xlsx`, `VSPRepository.get_list`: `close_date_start: date | None`, `close_date_end: date | None` → `Vsp.closed_at >= start`, `Vsp.closed_at <= end` (в тот же `match`). `NULL >= …` в SQL ложно — ВСП без даты закрытия при заданной границе не попадает, отдельного условия не нужно. Фильтр применяется при любом `status` (у действующего ВСП может быть будущая дата закрытия; UI показывает поля периода только при «Закрытые» и «Все», но API не запрещает).
|
||
|
||
### D3. Сортировка закрытых
|
||
`VSPRepository.get_list`: при `is_active is False` — `order_by(Vsp.closed_at.desc().nulls_last(), Vsp.id)`, иначе — `order_by(Vsp.id)`. `nulls_last` — страховка: закрытый ВСП без даты возможен только при ручной правке БД. Мёртвый `return` в конце метода удалить. Параметр `close_date_is_null` удалить (никто не передаёт — проверено grep'ом по `api/`).
|
||
|
||
### D4. Выгрузка
|
||
`VSPExportRequest`: `status: VSPStatus = "active"`, `close_date_start: date | None = None`, `close_date_end: date | None = None`. `export_info` передаёт их в `export_xlsx`, тот — `is_active=vsp_status_to_is_active(status)` и даты в `get_list` вместо зашитого `is_active=True`. Набор колонок файла не меняется; колонка `close_date` в выгрузке уже есть (`vsp_service.py` ~:198, :225).
|
||
|
||
### D5. Dropdown с закрытыми
|
||
`get_vsp_dropdown`: `include_inactive: bool = False` → `service.get_dropdown(..., include_inactive=...)` → `vsp_repo.get_list(ssp_ids, is_active=None if include_inactive else True, with_count)`. Удалённые отсекает `is_deleted = false` в репозитории, как раньше.
|
||
|
||
### D6. Фронт — справочник
|
||
- Чистая утилита `web/src/pages/DictVspPage/utils/vspListParams.js`: `buildVspListParams({ status, closeDateStart, closeDateEnd })` → объект query-параметров: `status` всегда; даты (`YYYY-MM-DD`) только при `status !== 'active'` и если заданы. Её же использует выгрузка. Плюс `vspMatchesStatus(vsp, status)` — для `handleEdit`: `active` → `vsp.is_active`, `closed` → `!vsp.is_active`, `all` → `true`. Покрыть `node --test` (`vspListParams.test.js` рядом).
|
||
- `DictVspFilters.jsx`: новые пропсы `status`, `onStatusChange`, `closeDateStart`, `closeDateEnd`, `onCloseDateChange`. Переключатель — MUI `ToggleButtonGroup` (exclusive) «Действующие» / «Закрытые» / «Все» рядом с ССП. Поля дат — `TextField type="date"` «Закрыт с» / «по» (как в остальном коде справочника; `@mui/x-date-pickers` не объявлен в package.json — не использовать), видимы при `status !== 'active'`.
|
||
- `DictVspPage.jsx`: state `status` (`'active'`), `closeDateStart`, `closeDateEnd` (`''`); `loadData` — `DictVspApi.get(buildVspListParams(...))`; перезагрузка в `useEffect` по этим трём значениям. Остальные запросы `loadData` (ССП и т.п.) не трогать — перезагружать только список ВСП, если `loadData` грузит несколько вещей, вынести загрузку ВСП в отдельную функцию. `handleEdit`: после ответа сервера, если `!vspMatchesStatus(updated, status)` — убрать строку из списка, иначе заменить как сейчас. Переключение статуса сохраняет выбранный ССП.
|
||
- `TableDictVsp.jsx`: колонка «Статус» (после «Дата закрытия ВСП»), бейдж «Действует» (синий, как «Активен» в `SspTable`) / «Закрыт» (серый, как «Архив»). Текст ячеек строки закрытого ВСП — `#99a1af` (через `muiTableBodyRowProps`/`muiTableBodyCellProps` по `row.original.is_active`).
|
||
- `ModalExport.jsx`: принимает `status`, `closeDateStart`, `closeDateEnd` пропсами от страницы и передаёт `{ ssp_ids, ...buildVspListParams(...) }` в `DictVspApi.export`.
|
||
|
||
### D7. Фронт — подпись в листах форм
|
||
- Чистая утилита `web/src/components/RealtimeTable/utils/vspLabel.js`: `getVspLabel(value, vspOptions)` → `null` для пустого значения (`null`/`undefined`/`''`); иначе ищет по `Number(value)`; действующий → `registration_number`; закрытый (`is_active === false`) → `` `${registration_number} (закрыт)` ``; не найден → `` `ВСП #${value}` ``. Тест `vspLabel.test.js`: действующий, закрытый, отсутствующий id, строковый id `"123"`, пустое значение.
|
||
- `Cell.jsx`: для `isVspDropdown` ветка идёт **до** числового форматирования: `display = getVspLabel(value, vspOptions) ?? ''`, `isNumeric = false`, без `displayFactor`. (Отличие от Jira: на 28.10 в Cell не было поиска по `vspOptions` вовсе — сейчас он есть, но срабатывает только для найденных; меняем порядок, чтобы ненайденный не форматировался как сумма.)
|
||
- `RealtimeTable.jsx`: `getDropdownVsp({ form_id: formId, include_inactive: true })` — в контексте полный список (действующие + закрытые). `vspRegistrationById` строится из того же списка — закрытые находятся поиском по рег. номеру без изменений кода.
|
||
- `VspDropdownEditCell.jsx`: в список выбора — только `vspOptions.filter((v) => v.is_active !== false)`. Если текущее значение ячейки — закрытый ВСП, оно не теряется: Select показывает подпись из `getVspLabel` для текущего значения (не пустое поле), но выбрать закрытый снова нельзя.
|
||
- `SelectVspModal.jsx` — **не меняется**: делает собственный запрос без `include_inactive` и уже получает только действующие (отличие от Jira, где предлагалось фильтровать).
|
||
|
||
### D8. Тесты
|
||
- Интеграционные (`api/tests/integration/test_vsp.py`, фикстуры `admin_tokens` / `isp_tokens`; ВСП с прошлой датой закрытия создаётся админом через `POST /dict/info`, после 1885 он сразу закрыт): `status` по умолчанию / `closed` / `all`; период внутри/вне; исполнитель с `status=closed` — только свои ССП (если в фикстуре нет второго ССП, доступного админу и недоступного исполнителю, — создать ВСП на ССП вне его привязок); `status=bogus` → 422; удалённый не возвращается ни при каком статусе; порядок `closed` — по дате закрытия убыв.; `dropdown?form_id=…&include_inactive=true` содержит закрытый ВСП ССП формы, без параметра — нет; `POST /dict/info/export` с `status=closed` — в xlsx (openpyxl) есть закрытый ВСП, без статуса — нет.
|
||
- Unit (`api/tests/unit/`): `vsp_status_to_is_active` (три значения); `VSPService.export_xlsx`/`get_dropdown` с моком репозитория — какой `is_active` уходит в `get_list`.
|
||
- Фронт: `node --test` для `vspLabel.test.js`, `vspListParams.test.js`; `npx biome check` по изменённым файлам без новых ошибок; `npm run build`.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- Справочник грузится целиком без пагинации; «Все» на большом справочнике тяжелее «Действующих». Объём справочника — тысячи строк, приемлемо; пагинация — вне задачи.
|
||
- `include_inactive=true` увеличивает список ВСП, который получает каждая открытая форма 2 (на закрытые ВСП её ССП). Ограничено ССП формы — несущественно.
|
||
- Исполнитель видит закрытые ВСП своих ССП — так же, как видел их до закрытия; новых данных ему не открывается.
|
||
|
||
## Оставлено исполнителю
|
||
|
||
- Точная вёрстка переключателя и полей дат в `DictVspFilters` (в стиле существующего Autocomplete).
|
||
- Как именно протянуть `status`/даты в `ModalExport` (пропсы или контекст страницы) — без изменения UX модалки.
|
||
- Формат даты в фикстурах тестов, имена тестов.
|
||
|
||
## Decisions made without discussion
|
||
|
||
Человек сказал «делай по Jira» (12.11.2026) — дизайн взят из описания Jira; ниже решения, которых в Jira нет или которые от него отличаются.
|
||
|
||
1. **2026-11-12 — «давно закрытые» = то, что уже есть в `v3.vsp`, без загрузки архива.** Обоснование: вопрос к заказчику в Jira; по умолчанию там же предложен этот вариант, человек его подтвердил выбором «делай по Jira». Цена ошибки: если заказчику нужен архив извне — отдельная задача на импорт, текущая работа не теряется.
|
||
2. **2026-11-12 — ветка поверх AURORA-1885 (PR #328), не поверх 1886.** Обоснование: 1883 зависит только от немедленного закрытия (1885); 1886 с миграцией 0062 ждёт стека #315–#323, ставить 1883 за ним — задержать без нужды. Цена: если 1886 и 1883 сольются в другом порядке — возможен текстовый конфликт в `vsp_service.py` (разные методы, маловероятен).
|
||
3. **2026-11-12 — `status`→`is_active` чистой функцией `vsp_status_to_is_active`, `VSPStatus` в schemas.py** (D1). Обоснование: один перевод для списка и выгрузки, покрывается unit-тестом. Цена: нет.
|
||
4. **2026-11-12 — фильтр периода закрытия применяется при любом статусе на API, UI показывает поля только при «Закрытые»/«Все»** (D2). Обоснование: действующий ВСП может иметь будущую дату закрытия; запрещать комбинацию на API незачем. Цена: нет.
|
||
5. **2026-11-12 — `nulls_last` в сортировке закрытых, удаление мёртвого `return` в `get_list`** (D3). Обоснование: страховка и чистка в том же методе. Цена: нет.
|
||
6. **2026-11-12 — `SelectVspModal` не меняется** (D7). Обоснование: он делает собственный запрос dropdown без `include_inactive` и уже получает только действующие; Jira предлагала фильтровать по `is_active`, что избыточно. Цена: если кто-то переключит модалку на контекст — закрытые попадут в выбор; закрыто тем, что фильтр стоит в `VspDropdownEditCell`, но не в модалке — отметить комментарием в коде модалки.
|
||
7. **2026-11-12 — в `VspDropdownEditCell` текущий закрытый ВСП показывается подписью, но не выбирается** (D7). Обоснование: иначе поле открылось бы пустым и создало впечатление потери значения. Цена: нет.
|
||
8. **2026-11-12 — поля дат — `TextField type="date"`, переключатель — `ToggleButtonGroup`** (D6). Обоснование: `@mui/x-date-pickers` не объявлен в package.json (ARCHITECTURE.md, «Открытые вопросы»). Цена: вид поля даты — нативный браузерный.
|
||
9. **2026-11-12 — колонка «Статус» в таблице справочника — после «Дата закрытия ВСП»; подписи бейджей «Действует» / «Закрыт»** (как в Jira; цвета — как в `SspTable`). Цена: косметика.
|
||
|
||
Решения при исполнении (12.11.2026, executing-plans; маркеры R-n — в ledger и PR):
|
||
|
||
10. **2026-11-12 — R-1: переключатель статуса виден всем ролям, включая EXECUTOR_RF** (у него скрыт только выбор ССП). Обоснование: спека требует переключатель без оговорок по ролям; бэк ограничивает ССП исполнителя. Цена: если РФ не должен видеть закрытые — скрыть одну кнопку.
|
||
11. **2026-11-12 — R-2: загрузка списка ВСП отделена от загрузки ССП** (`loadVspList`, свой индикатор, устаревшие ответы отбрасываются счётчиком). Обоснование: иначе смена статуса перерисовывала страницу спиннером вместе с фильтрами. Цена: нет.
|
||
12. **2026-11-12 — R-3: созданный ВСП, не подходящий под текущий статус, не добавляется в список** (как при правке). Цена: нет.
|
||
13. **2026-11-12 — R-4: закрытый текущий ВСП в редакторе ячейки — скрытый disabled пункт меню**, чтобы Select показал подпись и MUI не ругался на значение вне списка. Цена: нет (оговорка по клавиатуре — M-5, отложено).
|
||
14. **2026-11-12 — R-5: `InputLabelProps` → `slotProps.inputLabel`** (MUI 9.1.1 убрал `InputLabelProps`; найдено ручной проверкой). Цена: нет.
|
||
15. **2026-11-12 — R-6 (финальное ревью I-1): админ грузит все ССП; в «Действующих» архивные ССП скрыты, в «Закрытые»/«Все» — показаны в фильтре и выгрузке; окно создания/правки ВСП — только активные ССП.** Обоснование: закрытые ВСП архивного ССП иначе видны, но не выгружаются. Цена: если архивных ССП с ВСП в данных нет — лишний пункт списка в режиме закрытых.
|