diff --git a/docs/superpowers/plans/2026-11-12-aurora-1883-closed-vsp.md b/docs/superpowers/plans/2026-11-12-aurora-1883-closed-vsp.md new file mode 100644 index 0000000..2721f11 --- /dev/null +++ b/docs/superpowers/plans/2026-11-12-aurora-1883-closed-vsp.md @@ -0,0 +1,195 @@ +# AURORA-1883: просмотр закрытых ВСП — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** В справочнике ВСП можно смотреть закрытые ВСП (статус «Действующие / Закрытые / Все», период закрытия, бейдж статуса, выгрузка по фильтру), а в листах формы 2 закрытый ВСП подписан рег. номером с пометкой «(закрыт)», а не числом. + +**Architecture:** Бэк: параметр `status` (и период закрытия) в роутере `/dict/info` и в теле выгрузки переводится чистой функцией в `is_active` и доходит до `VSPRepository.get_list`; dropdown получает `include_inactive`. Фронт: чистые утилиты параметров списка и подписи ВСП, переключатель статуса на странице справочника, полный список ВСП (с закрытыми) в контексте `RealtimeTable` и выбор только действующих в редакторе ячейки. Миграции нет. + +**Tech Stack:** FastAPI 0.111, SQLAlchemy 2 async, pydantic 2; React 18 (JSX), MUI, material-react-table, `node --test`, Biome. + +**Spec:** `openspec/changes/aurora-1883-closed-vsp/` — `proposal.md`, `specs/vsp-directory/spec.md`, `design.md` (решения D1–D8). Исполнитель читает design.md целиком перед своей задачей. + +## Global Constraints + +- База — ветка `feat/330-aurora-1883-closed-vsp` (поверх AURORA-1885, PR #328); PR — `--base test`. +- Значения статуса — ровно `active`, `closed`, `all`; по умолчанию `active`; иное — 422. +- Параметры: `close_date_start`, `close_date_end` (`YYYY-MM-DD`), `include_inactive` (bool, по умолчанию `false`). +- Без новых параметров поведение API и UI прежнее: только действующие ВСП. +- Удалённые ВСП (`is_deleted`) не возвращаются ни из списка, ни из выгрузки, ни из dropdown. +- Подписи: бейджи «Действует» / «Закрыт»; ячейка формы — `<рег. номер> (закрыт)`, ненайденный — `ВСП #`; кнопки статуса «Действующие» / «Закрытые» / «Все»; поля «Закрыт с» / «по». +- Цвет приглушённого текста закрытых — `#99a1af`; цвета бейджей — как «Активен»/«Архив» в `web/src/pages/AdminPanel/SppsPage/SspTable.jsx`. +- `@mui/x-date-pickers` не использовать (не объявлен в package.json). +- Коммиты на русском, с `(AURORA-1883, #330)` и строкой `Co-Authored-By: Claude Opus 5.5 `. + +## Окружение + +- Python: `/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest`; пакеты не ставить. +- Postgres `perf-pg` (`perf:perf@localhost:55432`); на каждый прогон с `--init_db` — свежая БД: `docker exec perf-pg psql -U perf -d perf -c "DROP DATABASE IF EXISTS verify_1883" -c "CREATE DATABASE verify_1883"`, затем `cd api && DATABASE_URL="postgresql+asyncpg://perf:perf@localhost:55432/verify_1883" /bin/pytest --init_db <пути>`. +- Эталон полного backend на ветке 1885: 906 passed / 10 известных падений (form3 add_project, satellite spod ×3, upd_form_cells_subtree ×3, 0037_guard, export_service_query_params ×2). +- Фронт: в worktree `web/node_modules` нет — сделать симлинк `ln -s /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/web/node_modules web/node_modules` (как в worktree aurora-1896; симлинк не коммитить). `npm install` не запускать, реестр npm не подменять. +- Фикстура `api/tests/sqls/fixture.sql`: ВСП 1 (branch 2) уже закрыт (`closed_at 2026-06-16`, `is_active=false`), ВСП 2 и 3 (branch 2) действующие. Тесты создают свои ВСП через `_create_vsp(...)` в `test_vsp.py` (уже есть, от 1885) с уникальным рег. номером и ищут их по рег. номеру — не завязываться на общее число строк. + +## Review Focus + +1. Ячейка ВСП с id, которого нет в загруженном списке (удалённый ВСП), при разрядности «тыс.»/«млн» — должна показывать `ВСП #`, а не `0,1` (Task 3, тест `getVspLabel` + проверка, что Cell не применяет `displayFactor`). +2. Редактор ячейки строки, где уже стоит закрытый ВСП, — поле не пустое (подпись закрытого), но в списке выбора его нет (Task 3). +3. Переключение статуса при выбранном ССП и периоде — ССП сохраняется, период уходит в запрос только для «Закрытые»/«Все» (Task 4, тест `buildVspListParams`). +4. Исполнитель с `status=all` — только ВСП своих ССП (Task 1, интеграционный тест). +5. Выгрузка из режима «Закрытые» — файл содержит закрытые, а не действующие (Task 2, интеграционный тест; Task 4 — передача параметров из модалки). + +--- + +### Task 1: Бэк — статус и период закрытия в списке справочника + +**Files:** +- Modify: `api/src/domain/schemas.py` (рядом с `VSPExportRequest`, ~:741) +- Modify: `api/src/services/vsp_service.py` (модульная функция; `get_list` ~:54-99) +- Modify: `api/src/repository/vsp_repository.py` (`get_list` ~:51-134) +- Modify: `api/src/api/v1/vsp.py` (`get_vsp` ~:23-57) +- Test: `api/tests/integration/test_vsp.py`, `api/tests/unit/test_vsp_status.py` (новый) + +**Interfaces:** +- Produces: `VSPStatus = Literal["active", "closed", "all"]` в `src.domain.schemas`; `vsp_status_to_is_active(status: VSPStatus) -> bool | None` в `src.services.vsp_service`; `VSPService.get_list(..., is_active, close_date_start: date | None = None, close_date_end: date | None = None, ...)`; `VSPRepository.get_list(..., is_active, close_date_start: date | None = None, close_date_end: date | None = None, ...)` — без параметра `close_date_is_null`. + +- [ ] **Step 1: Unit-тест перевода статуса** — `api/tests/unit/test_vsp_status.py`: + +```python +import pytest +from src.services.vsp_service import vsp_status_to_is_active + +@pytest.mark.parametrize("status, expected", [("active", True), ("closed", False), ("all", None)]) +def test_vsp_status_to_is_active(status, expected): + assert vsp_status_to_is_active(status) is expected +``` + +- [ ] **Step 2: Интеграционные тесты** в `test_vsp.py` (каждый создаёт свои ВСП через `_create_vsp`, закрытый — `close_date` в прошлом; ищет по `registration_number` в `result`): + - `test_vsp_list_status_default_active` — действующий A есть, закрытый B (`close_date="2021-03-15"`) нет; + - `test_vsp_list_status_closed` — `?status=closed`: B есть с `is_active is False`, A нет; + - `test_vsp_list_status_all` — A и B есть; + - `test_vsp_list_status_invalid` — `?status=bogus` → 422; + - `test_vsp_list_deleted_never_returned` — ВСП C закрыт и удалён (`DELETE`), нет ни в `active`, ни в `closed`, ни в `all`; + - `test_vsp_list_close_period` — B (2021-03-15): `?status=closed&close_date_start=2021-01-01&close_date_end=2021-12-31` → есть; `?status=closed&close_date_start=2022-01-01` → нет; + - `test_vsp_list_closed_order` — B (2021-03-15) и D (2024-06-01): в `?status=closed` индекс D меньше индекса B; + - `test_vsp_list_closed_executor_scope` — закрытый ВСП на ССП исполнителя (`isp_tokens`) виден ему в `?status=closed` и в `?status=all`, закрытый на чужом ССП — нет. ССП исполнителя и чужой ССП взять из фикстуры (`api/tests/sqls/fixture.sql`, привязки пользователя-исполнителя); если чужого активного ССП нет — тест `pytest.skip` с причиной. + +- [ ] **Step 3: Прогнать — падают** (`ImportError` у unit, 200 вместо 422 и лишние/недостающие строки у интеграционных): +`/bin/pytest --init_db tests/unit/test_vsp_status.py tests/integration/test_vsp.py -q` (свежая БД). + +- [ ] **Step 4: Реализация** + - `schemas.py`: `VSPStatus`. + - `vsp_service.py`: `vsp_status_to_is_active`; `get_list` принимает и передаёт `close_date_start`, `close_date_end`. + - `vsp_repository.py` `get_list`: даты в `match` (`Vsp.closed_at >= start`, `<= end`); сортировка по D3 (`is_active is False` → `Vsp.closed_at.desc().nulls_last(), Vsp.id`, иначе `Vsp.id`); удалить `close_date_is_null` и мёртвый `return` в конце. + - `vsp.py` `get_vsp`: `status: VSPStatus = "active"`, `close_date_start: date | None = None`, `close_date_end: date | None = None`; `is_active=vsp_status_to_is_active(status)`. + +- [ ] **Step 5: Прогнать — зелёные** (та же команда, свежая БД). Ожидается: все тесты `test_vsp.py` и новый unit — passed. + +- [ ] **Step 6: Коммит** — `feat(vsp): фильтр справочника по статусу и периоду закрытия (AURORA-1883, #330)`. + +### Task 2: Бэк — выгрузка и dropdown с закрытыми + +**Files:** +- Modify: `api/src/domain/schemas.py` (`VSPExportRequest`) +- Modify: `api/src/services/vsp_service.py` (`get_dropdown` ~:101-132, `export_xlsx` ~:158-182) +- Modify: `api/src/api/v1/vsp.py` (`get_vsp_dropdown`, `export_info`) +- Test: `api/tests/integration/test_vsp.py`, `api/tests/unit/test_vsp_status.py` + +**Interfaces:** +- Consumes: `VSPStatus`, `vsp_status_to_is_active`, `VSPService.get_list(close_date_start, close_date_end)` из Task 1. +- Produces: `VSPExportRequest.status: VSPStatus = "active"`, `.close_date_start`, `.close_date_end: date | None = None`; `VSPService.get_dropdown(..., include_inactive: bool = False)`; `VSPService.export_xlsx(..., status: VSPStatus = "active", close_date_start=None, close_date_end=None)`; query `GET /dict/info/dropdown?include_inactive=true`. + +- [ ] **Step 1: Unit-тесты** в `test_vsp_status.py` с моком `vsp_repo` (как `_service` в `test_vsp_service_close_date.py`; для `get_dropdown` замокать `_get_scoped_ssp_ids`, `form_repo.get`, `_sync_closed_records`): + - `test_get_dropdown_include_inactive_passes_none` — `include_inactive=True` → `vsp_repo.get_list` вызван с `is_active=None`; + - `test_get_dropdown_default_active_only` — без параметра → `is_active=True`; + - `test_export_xlsx_status_closed` — `export_xlsx(user, status="closed", close_date_start=date(2021,1,1))` → `get_list` вызван с `is_active=False`, `close_date_start=date(2021,1,1)` (замокать `get_list` у сервиса, вернуть `[]`). + +- [ ] **Step 2: Интеграционные тесты** в `test_vsp.py`: + - `test_vsp_dropdown_include_inactive` — закрытый ВСП на ССП 2 (`_create_vsp(..., ssp_id=2, close_date="2021-03-15")`): `dropdown?form_id=2&include_inactive=true` содержит его (`is_active is False`); `dropdown?form_id=2` — нет. Существующий `test_vsp_dropdown_with_filter` (без параметра) должен остаться зелёным; + - `test_vsp_export_status_closed` — `POST /dict/info/export` `{"ssp_ids":[1], "status":"closed"}` → в xlsx (openpyxl, `load_workbook(io.BytesIO(response.content))`) есть рег. номер закрытого ВСП и нет действующего; тот же запрос без `status` — наоборот. + +- [ ] **Step 3: Прогнать — падают.** + +- [ ] **Step 4: Реализация** по D4, D5 design.md. + +- [ ] **Step 5: Прогнать — зелёные**, затем **полный backend** на свежей БД: `/bin/pytest --init_db -q`. Ожидается: те же 10 известных падений, новых нет (passed ≥ 906 + новые). + +- [ ] **Step 6: Коммит** — `feat(vsp): выгрузка по статусу и периоду, закрытые ВСП в dropdown по include_inactive (AURORA-1883, #330)`. + +### Task 3: Фронт — подпись ВСП в листах форм + +**Files:** +- Create: `web/src/components/RealtimeTable/utils/vspLabel.js`, `web/src/components/RealtimeTable/utils/vspLabel.test.js` +- Modify: `web/src/components/RealtimeTable/Cell/Cell/Cell.jsx` (~:165-184) +- Modify: `web/src/components/RealtimeTable/RealtimeTable.jsx` (~:311 `getDropdownVsp`) +- Modify: `web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx` +- Modify (комментарий): `web/src/components/RealtimeTable/Modals/SelectVspModal.jsx` + +**Interfaces:** +- Consumes: `GET /dict/info/dropdown?include_inactive=true` (Task 2), поля ВСП `id`, `registration_number`, `is_active`. +- Produces: `getVspLabel(value, vspOptions) -> string | null`; `selectableVspOptions(vspOptions) -> array` (только `is_active !== false`) — обе в `vspLabel.js`. + +- [ ] **Step 1: Тест** `vspLabel.test.js` (`node:test`, `node:assert/strict`, стиль соседних `*.test.js`): + +```js +const opts = [ + { id: 1, registration_number: '1234/567', is_active: true }, + { id: 2, registration_number: '7777/001', is_active: false }, +]; +// getVspLabel +assert.equal(getVspLabel(1, opts), '1234/567'); +assert.equal(getVspLabel(2, opts), '7777/001 (закрыт)'); +assert.equal(getVspLabel('1', opts), '1234/567'); +assert.equal(getVspLabel(99, opts), 'ВСП #99'); +assert.equal(getVspLabel(null, opts), null); +assert.equal(getVspLabel('', opts), null); +assert.equal(getVspLabel(5, undefined), 'ВСП #5'); +// selectableVspOptions +assert.deepEqual(selectableVspOptions(opts).map((v) => v.id), [1]); +assert.deepEqual(selectableVspOptions(undefined), []); +``` + +- [ ] **Step 2: Прогнать — падает:** `cd web && node --test src/components/RealtimeTable/utils/vspLabel.test.js` → ошибка импорта. + +- [ ] **Step 3: Реализация** `vspLabel.js`; `Cell.jsx` — для `isVspDropdown` вернуть `{ rawValue: value, displayValue: getVspLabel(value, vspOptions) ?? '', isNumeric: false, isNegative: false }` **до** числового форматирования (D7); `RealtimeTable.jsx` — `getDropdownVsp({ form_id: formId, include_inactive: true })`; `VspDropdownEditCell.jsx` — пункты списка из `selectableVspOptions(vspOptions)`, отображение текущего значения через `getVspLabel` (закрытый не выбирается, но подписан); `SelectVspModal.jsx` — только комментарий, что модалка сама грузит dropdown без `include_inactive` и поэтому получает только действующие (AURORA-1883). + +- [ ] **Step 4: Прогнать** `node --test src/components/RealtimeTable/utils/*.test.js` — ожидается: всё зелёное, кроме известного падения `SMETA.test.js`, если он входит в набор (сравнить с прогоном до правок); `npx biome check` по изменённым файлам — без новых ошибок относительно исходных. + +- [ ] **Step 5: Коммит** — `feat(forms): подпись закрытого ВСП «(закрыт)» в листах форм, выбор только действующих (AURORA-1883, #330)`. + +### Task 4: Фронт — справочник ВСП: статус, период, бейдж, выгрузка + +**Files:** +- Create: `web/src/pages/DictVspPage/utils/vspListParams.js`, `web/src/pages/DictVspPage/utils/vspListParams.test.js` +- Modify: `web/src/pages/DictVspPage/DictVspPage.jsx` (`loadData` ~:34-65, `handleEdit` ~:87-101) +- Modify: `web/src/pages/DictVspPage/components/DictVspFilters.jsx` +- Modify: `web/src/pages/DictVspPage/components/TableDictVsp.jsx` (колонки ~:54-236) +- Modify: `web/src/pages/DictVspPage/components/Modals/ModalExport.jsx` (~:45-57) и место, где страница её рендерит (`DictVspModals.jsx`) + +**Interfaces:** +- Consumes: `GET /dict/info?status&close_date_start&close_date_end` (Task 1), `POST /dict/info/export` с теми же полями (Task 2). +- Produces: `buildVspListParams({ status, closeDateStart, closeDateEnd }) -> { status, close_date_start?, close_date_end? }`; `vspMatchesStatus(vsp, status) -> boolean`. + +- [ ] **Step 1: Тест** `vspListParams.test.js` (запуск `node --test src/pages/DictVspPage/utils/*.test.js`, как `filterVspBySsp.test.js` рядом): + +```js +assert.deepEqual(buildVspListParams({ status: 'active', closeDateStart: '2021-01-01', closeDateEnd: '' }), { status: 'active' }); +assert.deepEqual(buildVspListParams({ status: 'closed', closeDateStart: '2021-01-01', closeDateEnd: '2021-12-31' }), + { status: 'closed', close_date_start: '2021-01-01', close_date_end: '2021-12-31' }); +assert.deepEqual(buildVspListParams({ status: 'all', closeDateStart: '', closeDateEnd: '2021-12-31' }), + { status: 'all', close_date_end: '2021-12-31' }); +assert.equal(vspMatchesStatus({ is_active: true }, 'active'), true); +assert.equal(vspMatchesStatus({ is_active: false }, 'active'), false); +assert.equal(vspMatchesStatus({ is_active: false }, 'closed'), true); +assert.equal(vspMatchesStatus({ is_active: true }, 'closed'), false); +assert.equal(vspMatchesStatus({ is_active: false }, 'all'), true); +``` + +- [ ] **Step 2: Прогнать — падает** (ошибка импорта). + +- [ ] **Step 3: Реализация** по D6 design.md: утилиты; `DictVspFilters` — `ToggleButtonGroup` «Действующие»/«Закрытые»/«Все» и два `TextField type="date"` «Закрыт с»/«по» при `status !== 'active'`; `DictVspPage` — state `status='active'`, `closeDateStart=''`, `closeDateEnd=''`, перезагрузка только списка ВСП по их изменению, выбранный ССП сохраняется, `handleEdit` убирает строку при `!vspMatchesStatus(updated, status)`; `TableDictVsp` — колонка «Статус» после «Дата закрытия ВСП» с бейджем, текст строки закрытого `#99a1af`; `ModalExport` — тело `{ ssp_ids, ...buildVspListParams(...) }`. + +- [ ] **Step 4: Прогнать** `node --test src/pages/DictVspPage/utils/*.test.js` (зелёные), `npx biome check` по изменённым файлам (без новых ошибок), `npm run build` (успешно). + +- [ ] **Step 5: Ручная проверка** (если поднимается стенд: API на БД `verify_1883` + `npm run start`): режимы статуса, период 2021, бейдж, закрытие ВСП в режиме «Действующие» убирает строку, выгрузка из «Закрытые»; лист FORM_2 со строкой на закрытом ВСП — подпись «(закрыт)», в выборе его нет. Если стенд не поднимается — отметить в отчёте как не выполненную. + +- [ ] **Step 6: Коммит** — `feat(vsp): справочник ВСП — статус «Действующие/Закрытые/Все», период закрытия, бейдж и выгрузка по фильтру (AURORA-1883, #330)`. diff --git a/openspec/changes/aurora-1883-closed-vsp/.openspec.yaml b/openspec/changes/aurora-1883-closed-vsp/.openspec.yaml new file mode 100644 index 0000000..55ab199 --- /dev/null +++ b/openspec/changes/aurora-1883-closed-vsp/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-12 diff --git a/openspec/changes/aurora-1883-closed-vsp/design.md b/openspec/changes/aurora-1883-closed-vsp/design.md new file mode 100644 index 0000000..25eb860 --- /dev/null +++ b/openspec/changes/aurora-1883-closed-vsp/design.md @@ -0,0 +1,100 @@ +# 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`). Цена: косметика. diff --git a/openspec/changes/aurora-1883-closed-vsp/proposal.md b/openspec/changes/aurora-1883-closed-vsp/proposal.md new file mode 100644 index 0000000..40f0809 --- /dev/null +++ b/openspec/changes/aurora-1883-closed-vsp/proposal.md @@ -0,0 +1,36 @@ +# Proposal + +## Why + +Замечание заказчика №3 «Справочники» (свод замечаний Фомичева от 27.10.2026, Jira AURORA-1883, эпик AURORA-1876, приоритет «Высокий»): «Предусмотреть возможность просмотра закрытых ВСП, в том числе давно закрытых». + +Сейчас справочник ВСП показывает только действующие ВСП: `GET /api/v1/dict/info` всегда передаёт в сервис `is_active=True`, параметра статуса нет, выгрузка тоже отдаёт только действующие. ВСП с прошедшей датой закрытия автоматически получает `is_active=false` (после AURORA-1885 — сразу при сохранении) и пропадает из UI, хотя в `v3.vsp` остаётся. Посмотреть или исправить его нельзя. В листах формы 2 ячейка «ВСП РФ» строки с закрытым ВСП показывает не рег. номер, а id как число: подписи берутся из `/dict/info/dropdown`, где только действующие. + +## What Changes + +- `GET /dict/info`: новый query-параметр `status` (`active` | `closed` | `all`, по умолчанию `active`) и фильтр периода закрытия `close_date_start` / `close_date_end`. Закрытые сортируются по дате закрытия, свежие сверху. +- `POST /dict/info/export`: те же `status`, `close_date_start`, `close_date_end` в теле. +- `GET /dict/info/dropdown`: параметр `include_inactive` (по умолчанию `false`) — для подписей закрытых ВСП в листах форм. Удалённые (`is_deleted`) не возвращаются ни при каком статусе и ни в каком списке. +- Справочник ВСП (фронт): переключатель «Действующие / Закрытые / Все», поля «Закрыт с / по», колонка «Статус» с бейджем «Действует» / «Закрыт», приглушённый текст закрытых строк; выгрузка учитывает текущий статус и период. +- Листы формы 2: ячейка ВСП показывает рег. номер; у закрытого — «<рег. номер> (закрыт)»; если ВСП не найден — «ВСП #», а не число. Выбрать для строки можно только действующий ВСП. + +**Не меняется:** +- поведение по умолчанию: без параметров справочник, выгрузка и выпадающие списки отдают только действующие ВСП; +- права: исполнитель видит ВСП (и действующие, и закрытые) только своих ССП, админ — все; редактирование и удаление ВСП не меняются; +- автоматическое закрытие ВСП по дате (`_sync_closed_records`, AURORA-1885); +- снятие даты закрытия — AURORA-1886; удаление ВСП админом — AURORA-1884; +- загрузка «давно закрытых» ВСП из внешнего архива — вне задачи: показываем то, что есть в `v3.vsp`. + +## Capabilities + +### New Capabilities +- `vsp-directory`: список, фильтры по статусу и периоду закрытия, выгрузка и выпадающие списки справочника объектов ВСП; подпись ВСП в листах форм. + +### Modified Capabilities +Нет. + +## Impact + +- Python: `api/src/api/v1/vsp.py` (`get_vsp`, `get_vsp_dropdown`, `export_info`), `api/src/domain/schemas.py` (`VSPExportRequest`), `api/src/services/vsp_service.py` (`get_list`, `get_dropdown`, `export_xlsx`), `api/src/repository/vsp_repository.py` (`get_list`). Миграции нет. +- Web: `web/src/pages/DictVspPage/` (`DictVspPage.jsx`, `components/DictVspFilters.jsx`, `components/TableDictVsp.jsx`, `components/Modals/ModalExport.jsx`), `web/src/components/RealtimeTable/` (`RealtimeTable.jsx`, `Cell/Cell/Cell.jsx`, `Cell/EditCell/VspDropdownEditCell.jsx`, `Modals/SelectVspModal.jsx`), новые чистые утилиты `utils/vspLabel.js`, `pages/DictVspPage/utils/vspListParams.js`. +- Тесты: `api/tests/integration/test_vsp.py`, `api/tests/unit/` (репозиторий/сервис), `node --test` на новые утилиты. diff --git a/openspec/changes/aurora-1883-closed-vsp/specs/vsp-directory/spec.md b/openspec/changes/aurora-1883-closed-vsp/specs/vsp-directory/spec.md new file mode 100644 index 0000000..7c108f3 --- /dev/null +++ b/openspec/changes/aurora-1883-closed-vsp/specs/vsp-directory/spec.md @@ -0,0 +1,94 @@ +# Spec Delta + +## Purpose + +Справочник объектов ВСП (`/api/v1/dict/info`, страница «Справочник ВСП») и подпись ВСП в листах форм: какие ВСП попадают в список, выгрузку и выпадающие списки, как их фильтровать по статусу и периоду закрытия, как закрытый ВСП выглядит в справочнике и в ячейке формы. + +Термины: **действующий ВСП** — запись `v3.vsp` с `is_deleted = false` и `is_active = true`; **закрытый ВСП** — `is_deleted = false` и `is_active = false` (дата закрытия наступила); **удалённый ВСП** — `is_deleted = true`. + +## ADDED Requirements + +### Requirement: Фильтр справочника по статусу +`GET /api/v1/dict/info` SHALL принимать query-параметр `status` со значениями `active`, `closed`, `all`; по умолчанию — `active`. `active` SHALL возвращать только действующие ВСП, `closed` — только закрытые, `all` — действующие и закрытые. Иное значение SHALL давать 422. Удалённые ВСП SHALL NOT возвращаться ни при каком значении `status`. Остальные фильтры (рег. номер, адрес, ССП, дата открытия, тип размещения, численность) и ограничение исполнителя своими ССП SHALL применяться как прежде, вместе со `status`. + +#### Scenario: По умолчанию — только действующие +- **WHEN** администратор запрашивает `GET /dict/info` без `status`, а в справочнике есть действующий ВСП A и закрытый ВСП B +- **THEN** ответ содержит A и не содержит B + +#### Scenario: Закрытые +- **WHEN** администратор запрашивает `GET /dict/info?status=closed` +- **THEN** ответ содержит B с `is_active = false` и не содержит A + +#### Scenario: Все +- **WHEN** администратор запрашивает `GET /dict/info?status=all` +- **THEN** ответ содержит и A, и B + +#### Scenario: Недопустимый статус +- **WHEN** запрос `GET /dict/info?status=bogus` +- **THEN** ответ — 422 + +#### Scenario: Удалённый не возвращается +- **WHEN** администратор удалил ВСП C (`DELETE /dict/info/{id}`) и запрашивает `status=active`, `closed` и `all` +- **THEN** ни один ответ не содержит C + +#### Scenario: Исполнитель видит закрытые только своих ССП +- **WHEN** исполнитель, привязанный к ССП S1, запрашивает `GET /dict/info?status=closed`, а закрытые ВСП есть в S1 и в чужом S2 +- **THEN** ответ содержит закрытые ВСП S1 и не содержит закрытые ВСП S2 + +### Requirement: Фильтр по периоду закрытия +`GET /api/v1/dict/info` SHALL принимать необязательные `close_date_start` и `close_date_end` (даты `YYYY-MM-DD`). При заданном `close_date_start` SHALL возвращаться только ВСП с датой закрытия не раньше неё, при заданном `close_date_end` — не позже неё (границы включительно). ВСП без даты закрытия SHALL NOT попадать в ответ, если задана хотя бы одна граница. + +#### Scenario: ВСП закрыт в периоде +- **WHEN** ВСП B закрыт 2021-03-15 и запрос `GET /dict/info?status=closed&close_date_start=2021-01-01&close_date_end=2021-12-31` +- **THEN** ответ содержит B + +#### Scenario: ВСП закрыт вне периода +- **WHEN** тот же ВСП B и запрос `?status=closed&close_date_start=2022-01-01` +- **THEN** ответ не содержит B + +### Requirement: Порядок закрытых ВСП +При `status=closed` список SHALL быть отсортирован по дате закрытия по убыванию, при равной дате — по `id` по возрастанию. При `status=active` и `status=all` порядок SHALL оставаться прежним — по `id`. + +#### Scenario: Свежезакрытые сверху +- **WHEN** ВСП B закрыт 2021-03-15, ВСП D закрыт 2024-06-01 и запрос `?status=closed` +- **THEN** D идёт в ответе раньше B + +### Requirement: Выгрузка учитывает статус и период +`POST /api/v1/dict/info/export` SHALL принимать в теле `status` (`active` | `closed` | `all`, по умолчанию `active`), `close_date_start`, `close_date_end` и применять их так же, как `GET /dict/info`. Без этих полей выгрузка SHALL содержать только действующие ВСП, как раньше. + +#### Scenario: Выгрузка закрытых +- **WHEN** администратор выгружает справочник с `status = closed` +- **THEN** файл содержит закрытый ВСП B и не содержит действующий A + +### Requirement: Выпадающий список ВСП с закрытыми для подписей +`GET /api/v1/dict/info/dropdown` SHALL принимать параметр `include_inactive` (по умолчанию `false`). При `false` SHALL возвращаться только действующие ВСП, как раньше; при `true` — действующие и закрытые в тех же границах (ССП формы `form_id` или `ssp_id`, ССП исполнителя). Удалённые ВСП SHALL NOT возвращаться. + +#### Scenario: Закрытый ВСП ССП формы +- **WHEN** у ССП формы F есть закрытый ВСП B и запрос `GET /dict/info/dropdown?form_id=F&include_inactive=true` +- **THEN** ответ содержит B с `is_active = false` + +#### Scenario: Без параметра +- **WHEN** запрос `GET /dict/info/dropdown?form_id=F` +- **THEN** ответ не содержит B + +### Requirement: Справочник ВСП показывает статус и позволяет найти закрытые +Страница «Справочник ВСП» SHALL иметь переключатель статуса «Действующие» (по умолчанию) / «Закрытые» / «Все» и, при «Закрытые» и «Все», поля периода закрытия «Закрыт с» / «по». Смена статуса или периода SHALL перезагружать список с сервера с этими параметрами. Таблица SHALL иметь колонку «Статус» с бейджем «Действует» или «Закрыт»; текст строк закрытых ВСП SHALL быть приглушённым. Выгрузка со страницы SHALL передавать текущие статус и период. Если после правки ВСП перестал подходить под выбранный статус (например, его закрыли в режиме «Действующие»), строка SHALL исчезать из списка. + +#### Scenario: Найти давно закрытый ВСП +- **WHEN** администратор выбирает «Закрытые» и период «Закрыт с 01.01.2021 по 31.12.2021» +- **THEN** таблица показывает ВСП, закрытые в 2021 году, с бейджем «Закрыт» + +#### Scenario: Закрыли в режиме «Действующие» +- **WHEN** в режиме «Действующие» администратор ставит ВСП дату закрытия «вчера» и сохраняет +- **THEN** строка этого ВСП пропадает из таблицы + +### Requirement: Подпись ВСП в листах форм +Ячейка колонки с `editType = 'vsp_dropdown'` в листах форм SHALL показывать рег. номер ВСП, а не число: для действующего ВСП — рег. номер; для закрытого — «<рег. номер> (закрыт)»; если ВСП с таким id нет среди загруженных для формы (например, удалён) — «ВСП #». Значение такой ячейки SHALL NOT форматироваться как сумма и SHALL NOT масштабироваться селектором разрядности. Для выбора ВСП в ячейке и при добавлении строки SHALL предлагаться только действующие ВСП. Поиск и фильтр по колонке ВСП SHALL находить и закрытые ВСП по рег. номеру. + +#### Scenario: Строка с закрытым ВСП +- **WHEN** в листе FORM_2/AHR строка ссылается на закрытый ВСП с рег. номером `1234/567`, разрядность «тыс. руб.» +- **THEN** ячейка «ВСП РФ» показывает `1234/567 (закрыт)` + +#### Scenario: Выбор ВСП +- **WHEN** пользователь открывает выбор ВСП в ячейке этой строки +- **THEN** в списке только действующие ВСП, закрытого `1234/567` в нём нет