From 99ab7598c982ebf54254e914a1907645635228a9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 11:27:51 +0400 Subject: [PATCH 1/8] =?UTF-8?q?docs(vsp):=20OpenSpec=20change=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD=20AURORA-1883=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D1=8B=D1=85=20=D0=92=D0=A1=D0=9F=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-11-12-aurora-1883-closed-vsp.md | 195 ++++++++++++++++++ .../aurora-1883-closed-vsp/.openspec.yaml | 2 + .../changes/aurora-1883-closed-vsp/design.md | 100 +++++++++ .../aurora-1883-closed-vsp/proposal.md | 36 ++++ .../specs/vsp-directory/spec.md | 94 +++++++++ 5 files changed, 427 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-12-aurora-1883-closed-vsp.md create mode 100644 openspec/changes/aurora-1883-closed-vsp/.openspec.yaml create mode 100644 openspec/changes/aurora-1883-closed-vsp/design.md create mode 100644 openspec/changes/aurora-1883-closed-vsp/proposal.md create mode 100644 openspec/changes/aurora-1883-closed-vsp/specs/vsp-directory/spec.md 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` в нём нет -- 2.47.2 From 972dce8994a0cfd9b4db29670f0ba2d4079061f8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 11:46:19 +0400 Subject: [PATCH 2/8] =?UTF-8?q?feat(vsp):=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=D1=83=20=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20(AU?= =?UTF-8?q?RORA-1883,=20#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /dict/info: status=active|closed|all (по умолчанию active), close_date_start/end; закрытые сортируются по дате закрытия по убыванию. Удалён неиспользуемый close_date_is_null и мёртвый return в VSPRepository.get_list. Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/vsp.py | 10 ++- api/src/domain/schemas.py | 4 ++ api/src/repository/vsp_repository.py | 21 +++--- api/src/services/vsp_service.py | 13 +++- api/tests/integration/test_vsp.py | 104 +++++++++++++++++++++++++++ api/tests/unit/test_vsp_status.py | 8 +++ 6 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 api/tests/unit/test_vsp_status.py diff --git a/api/src/api/v1/vsp.py b/api/src/api/v1/vsp.py index 0a2e0b3..937bf72 100644 --- a/api/src/api/v1/vsp.py +++ b/api/src/api/v1/vsp.py @@ -13,9 +13,10 @@ from src.domain.schemas import ( VSPCreate, VSPExportRequest, VSPInDB, + VSPStatus, VSPUpdate, ) -from src.services.vsp_service import VSPService +from src.services.vsp_service import VSPService, vsp_status_to_is_active router = APIRouter(prefix="/dict/info", tags=["info"]) @@ -30,6 +31,9 @@ async def get_vsp( placement_type: str | None = None, staff_count_min: int | None = None, staff_count_max: int | None = None, + status: VSPStatus = "active", + close_date_start: date | None = None, + close_date_end: date | None = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(require_executor), ): @@ -46,7 +50,9 @@ async def get_vsp( staff_count_min=staff_count_min, staff_count_max=staff_count_max, load_org_unit=True, - is_active=True, + is_active=vsp_status_to_is_active(status), + close_date_start=close_date_start, + close_date_end=close_date_end, with_count=True, ) return BaseListResponse( diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 2685b36..d5cd68e 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -745,6 +745,10 @@ class VSPUpdate(VSPEditBase): ) +# AURORA-1883: статус справочника ВСП — действующие / закрытые / все. +VSPStatus = Literal["active", "closed", "all"] + + class VSPInDB(VSPBase): model_config = ConfigDict(from_attributes=True) id: int diff --git a/api/src/repository/vsp_repository.py b/api/src/repository/vsp_repository.py index 1cd5caa..57a07e6 100644 --- a/api/src/repository/vsp_repository.py +++ b/api/src/repository/vsp_repository.py @@ -59,7 +59,8 @@ class VSPRepository: staff_count_min: int | None = None, staff_count_max: int | None = None, is_active: bool | None = True, - close_date_is_null: bool | None = None, + close_date_start: date | None = None, + close_date_end: date | None = None, load_org_unit: bool = False, with_count: bool = False, ) -> list[Vsp] | tuple[int, list[Vsp]]: @@ -78,6 +79,8 @@ class VSPRepository: ("staff_count_min", staff_count_min), ("staff_count_max", staff_count_max), ("is_active", is_active), + ("close_date_start", close_date_start), + ("close_date_end", close_date_end), ] for filter_name, filter_value in filters: # print(filter_name, filter_value) @@ -98,14 +101,13 @@ class VSPRepository: queries.append(Vsp.staff_count <= value) case ("is_active", value) if value is not None: queries.append(Vsp.is_active == value) + case ("close_date_start", value) if value is not None: + queries.append(Vsp.closed_at >= value) + case ("close_date_end", value) if value is not None: + queries.append(Vsp.closed_at <= value) case _: continue - if close_date_is_null is True: - queries.append(Vsp.closed_at.is_(None)) - elif close_date_is_null is False: - queries.append(Vsp.closed_at.is_not(None)) - if ssp_ids is not None: if not ssp_ids: if with_count: @@ -116,7 +118,11 @@ class VSPRepository: query = query.where(*queries) if load_org_unit: query = query.options(joinedload(Vsp.org_unit)) - query = query.order_by(Vsp.id) + if is_active is False: + # AURORA-1883: закрытые — свежезакрытые сверху + query = query.order_by(Vsp.closed_at.desc().nulls_last(), Vsp.id) + else: + query = query.order_by(Vsp.id) if with_count: result = (await self.db.execute(query)).all() @@ -131,7 +137,6 @@ class VSPRepository: return (await self.db.execute(count_query)).scalar(), [] else: return (await self.db.execute(query)).scalars().all() - return (await self.db.execute(query.order_by(Vsp.id))).scalars().all() async def create(self, payload: VSPCreate, created_by: int, load_org_unit: bool = False) -> Vsp: data = payload.model_dump() diff --git a/api/src/services/vsp_service.py b/api/src/services/vsp_service.py index 63e7847..113ccbe 100644 --- a/api/src/services/vsp_service.py +++ b/api/src/services/vsp_service.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.repository.budget_form_repository import BudgetFormRepository from src.core.errors import AccessDeniedException, ValidationException from src.db.models import AppUser, UserRoleEnum, Vsp -from src.domain.schemas import VSPCreate, VSPUpdate +from src.domain.schemas import VSPCreate, VSPStatus, VSPUpdate from src.repository.org_unit_repository import OrgUnitRepository from src.repository.user_repository import UserRepository from src.repository.vsp_repository import VSPRepository @@ -18,6 +18,13 @@ from src.repository.vsp_repository import VSPRepository _last_info_close_sync_run: Optional[date] = None _info_close_sync_lock = asyncio.Lock() +_STATUS_TO_IS_ACTIVE: dict[str, bool | None] = {"active": True, "closed": False, "all": None} + + +def vsp_status_to_is_active(status: VSPStatus) -> bool | None: + """AURORA-1883: статус справочника → фильтр is_active (None — без фильтра).""" + return _STATUS_TO_IS_ACTIVE[status] + class VSPService: EXECUTOR_EDITABLE_FIELDS = { @@ -64,6 +71,8 @@ class VSPService: staff_count_min: int | None = None, staff_count_max: int | None = None, is_active: bool | None = None, + close_date_start: date | None = None, + close_date_end: date | None = None, load_org_unit: bool = False, with_count: bool = False, ) -> list[Vsp] | tuple[int, list[Vsp]]: @@ -94,6 +103,8 @@ class VSPService: staff_count_min=staff_count_min, staff_count_max=staff_count_max, is_active=is_active, + close_date_start=close_date_start, + close_date_end=close_date_end, load_org_unit=load_org_unit, with_count=with_count, ) diff --git a/api/tests/integration/test_vsp.py b/api/tests/integration/test_vsp.py index 4ea505c..d92cc44 100644 --- a/api/tests/integration/test_vsp.py +++ b/api/tests/integration/test_vsp.py @@ -353,3 +353,107 @@ def test_vsp_update_past_close_date_deactivates(client, admin_tokens, auth_heade listing = client.get("/api/v1/dict/info", headers=auth_headers(admin_tokens)) assert listing.status_code == 200 assert vsp_id not in [item["id"] for item in listing.json()["result"]] + + +# --- AURORA-1883: статус и период закрытия в справочнике --- + + +def _create_named_vsp(client, admin_tokens, auth_headers, **extra) -> str: + reg_number = _unique_reg_number() + created = client.post( + "/api/v1/dict/info", + json={ + "registration_number": reg_number, + "address": "AURORA-1883", + "ssp_id": 1, + "open_date": "2020-01-01", + **extra, + }, + headers=auth_headers(admin_tokens), + ) + assert created.status_code == 201, created.text + return reg_number + + +def _list_reg_numbers(client, tokens, auth_headers, query: str = "") -> list[str]: + response = client.get(f"/api/v1/dict/info{query}", headers=auth_headers(tokens)) + assert response.status_code == 200, response.text + return [item["registration_number"] for item in response.json()["result"]] + + +def test_vsp_list_status_default_active(client, admin_tokens, auth_headers): + active = _create_named_vsp(client, admin_tokens, auth_headers) + closed = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15") + + regs = _list_reg_numbers(client, admin_tokens, auth_headers) + assert active in regs + assert closed not in regs + + +def test_vsp_list_status_closed(client, admin_tokens, auth_headers): + active = _create_named_vsp(client, admin_tokens, auth_headers) + closed = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15") + + response = client.get("/api/v1/dict/info?status=closed", headers=auth_headers(admin_tokens)) + assert response.status_code == 200 + by_reg = {item["registration_number"]: item for item in response.json()["result"]} + assert closed in by_reg + assert by_reg[closed]["is_active"] is False + assert active not in by_reg + + +def test_vsp_list_status_all(client, admin_tokens, auth_headers): + active = _create_named_vsp(client, admin_tokens, auth_headers) + closed = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15") + + regs = _list_reg_numbers(client, admin_tokens, auth_headers, "?status=all") + assert active in regs + assert closed in regs + + +def test_vsp_list_status_invalid(client, admin_tokens, auth_headers): + response = client.get("/api/v1/dict/info?status=bogus", headers=auth_headers(admin_tokens)) + assert response.status_code == 422 + + +def test_vsp_list_deleted_never_returned(client, admin_tokens, auth_headers): + reg = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15") + listing = client.get("/api/v1/dict/info?status=all", headers=auth_headers(admin_tokens)) + vsp_id = next(i["id"] for i in listing.json()["result"] if i["registration_number"] == reg) + deleted = client.delete(f"/api/v1/dict/info/{vsp_id}", headers=auth_headers(admin_tokens)) + assert deleted.status_code == 200 + + for status in ("active", "closed", "all"): + assert reg not in _list_reg_numbers(client, admin_tokens, auth_headers, f"?status={status}") + + +def test_vsp_list_close_period(client, admin_tokens, auth_headers): + closed = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15") + + inside = _list_reg_numbers( + client, admin_tokens, auth_headers, + "?status=closed&close_date_start=2021-01-01&close_date_end=2021-12-31", + ) + assert closed in inside + + outside = _list_reg_numbers(client, admin_tokens, auth_headers, "?status=closed&close_date_start=2022-01-01") + assert closed not in outside + + +def test_vsp_list_closed_order(client, admin_tokens, auth_headers): + older = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15") + newer = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2024-06-01") + + regs = _list_reg_numbers(client, admin_tokens, auth_headers, "?status=closed") + assert regs.index(newer) < regs.index(older) + + +def test_vsp_list_closed_executor_scope(client, admin_tokens, isp_tokens, auth_headers): + # isp1 привязан к оргединице 2 (fixture.sql, user_org), оргединица 1 ему чужая + own = _create_named_vsp(client, admin_tokens, auth_headers, ssp_id=2, close_date="2021-03-15") + foreign = _create_named_vsp(client, admin_tokens, auth_headers, ssp_id=1, close_date="2021-03-15") + + for status in ("closed", "all"): + regs = _list_reg_numbers(client, isp_tokens, auth_headers, f"?status={status}") + assert own in regs + assert foreign not in regs diff --git a/api/tests/unit/test_vsp_status.py b/api/tests/unit/test_vsp_status.py new file mode 100644 index 0000000..0dd6cbb --- /dev/null +++ b/api/tests/unit/test_vsp_status.py @@ -0,0 +1,8 @@ +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 -- 2.47.2 From ca79fdedcf5a376d370194defd569e6d7b04b993 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 11:47:52 +0400 Subject: [PATCH 3/8] =?UTF-8?q?feat(vsp):=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D1=83=20=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D0=B4=D1=83,=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=92=D0=A1=D0=9F=20=D0=B2=20dropdown=20=D0=BF=D0=BE?= =?UTF-8?q?=20include=5Finactive=20(AURORA-1883,=20#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/vsp.py | 8 ++++- api/src/domain/schemas.py | 3 ++ api/src/services/vsp_service.py | 11 +++++-- api/tests/integration/test_vsp.py | 39 ++++++++++++++++++++++++ api/tests/unit/test_vsp_status.py | 50 +++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/api/src/api/v1/vsp.py b/api/src/api/v1/vsp.py index 937bf72..44501e7 100644 --- a/api/src/api/v1/vsp.py +++ b/api/src/api/v1/vsp.py @@ -67,12 +67,15 @@ async def get_vsp( async def get_vsp_dropdown( ssp_id: int | None = None, form_id: int | None = None, + include_inactive: bool = False, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(require_executor), ): """Получение записей для выпадающего списка INFO.""" service = VSPService(db) - result = await service.get_dropdown(user=current_user, ssp_id=ssp_id, form_id=form_id, with_count=True) + result = await service.get_dropdown( + user=current_user, ssp_id=ssp_id, form_id=form_id, with_count=True, include_inactive=include_inactive, + ) return BaseListResponse( success=True, message="Список INFO для выпадающего списка", @@ -99,6 +102,9 @@ async def export_info( placement_type=body.placement_type, staff_count_min=body.staff_count_min, staff_count_max=body.staff_count_max, + status=body.status, + close_date_start=body.close_date_start, + close_date_end=body.close_date_end, ) return StreamingResponse( stream, diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index d5cd68e..e088448 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -784,6 +784,9 @@ class VSPExportRequest(BaseModel): placement_type: Optional[str] = None staff_count_min: Optional[int] = None staff_count_max: Optional[int] = None + status: VSPStatus = "active" + close_date_start: Optional[date] = None + close_date_end: Optional[date] = None class ExpenseItemBaseSchema(BaseModel): diff --git a/api/src/services/vsp_service.py b/api/src/services/vsp_service.py index 113ccbe..dfedaa8 100644 --- a/api/src/services/vsp_service.py +++ b/api/src/services/vsp_service.py @@ -115,6 +115,7 @@ class VSPService: ssp_id: int | None = None, form_id: int | None = None, with_count: bool = False, + include_inactive: bool = False, ) -> list[Vsp]: ssp_ids = await self._get_scoped_ssp_ids(user=user) @@ -136,9 +137,10 @@ class VSPService: ssp_ids = list(checking_ssps & set(ssp_ids)) await self._sync_closed_records() + # AURORA-1883: include_inactive — с закрытыми, для подписей ВСП в листах форм return await self.vsp_repo.get_list( ssp_ids=ssp_ids, - is_active=True, + is_active=None if include_inactive else True, with_count=with_count, ) @@ -177,6 +179,9 @@ class VSPService: placement_type: str | None = None, staff_count_min: int | None = None, staff_count_max: int | None = None, + status: VSPStatus = "active", + close_date_start: date | None = None, + close_date_end: date | None = None, ) -> tuple[io.BytesIO, str]: data = await self.get_list( user=user, @@ -188,7 +193,9 @@ class VSPService: placement_type=placement_type, staff_count_min=staff_count_min, staff_count_max=staff_count_max, - is_active=True, + is_active=vsp_status_to_is_active(status), + close_date_start=close_date_start, + close_date_end=close_date_end, load_org_unit=True, ) diff --git a/api/tests/integration/test_vsp.py b/api/tests/integration/test_vsp.py index d92cc44..3889924 100644 --- a/api/tests/integration/test_vsp.py +++ b/api/tests/integration/test_vsp.py @@ -457,3 +457,42 @@ def test_vsp_list_closed_executor_scope(client, admin_tokens, isp_tokens, auth_h regs = _list_reg_numbers(client, isp_tokens, auth_headers, f"?status={status}") assert own in regs assert foreign not in regs + + +def test_vsp_dropdown_include_inactive(client, admin_tokens, auth_headers): + closed = _create_named_vsp(client, admin_tokens, auth_headers, ssp_id=2, close_date="2021-03-15") + + with_closed = client.get( + "/api/v1/dict/info/dropdown?form_id=2&include_inactive=true", headers=auth_headers(admin_tokens) + ) + assert with_closed.status_code == 200 + by_reg = {item["registration_number"]: item for item in with_closed.json()["result"]} + assert closed in by_reg + assert by_reg[closed]["is_active"] is False + + default = client.get("/api/v1/dict/info/dropdown?form_id=2", headers=auth_headers(admin_tokens)) + assert closed not in [item["registration_number"] for item in default.json()["result"]] + + +def _export_values(client, tokens, auth_headers, body: dict) -> set: + import io + + from openpyxl import load_workbook + + response = client.post("/api/v1/dict/info/export", json=body, headers=auth_headers(tokens)) + assert response.status_code == 200, response.text + sheet = load_workbook(io.BytesIO(response.content)).active + return {cell for row in sheet.iter_rows(values_only=True) for cell in row} + + +def test_vsp_export_status_closed(client, admin_tokens, auth_headers): + active = _create_named_vsp(client, admin_tokens, auth_headers) + closed = _create_named_vsp(client, admin_tokens, auth_headers, close_date="2021-03-15") + + closed_values = _export_values(client, admin_tokens, auth_headers, {"ssp_ids": [1], "status": "closed"}) + assert closed in closed_values + assert active not in closed_values + + default_values = _export_values(client, admin_tokens, auth_headers, {"ssp_ids": [1]}) + assert active in default_values + assert closed not in default_values diff --git a/api/tests/unit/test_vsp_status.py b/api/tests/unit/test_vsp_status.py index 0dd6cbb..58f7ab7 100644 --- a/api/tests/unit/test_vsp_status.py +++ b/api/tests/unit/test_vsp_status.py @@ -6,3 +6,53 @@ 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 + + +# --- выгрузка и dropdown (AURORA-1883, задача 2) --- +from datetime import date +from unittest.mock import AsyncMock, MagicMock + +from src.db.models import UserRoleEnum +from src.services.vsp_service import VSPService + + +def _admin(): + user = MagicMock() + user.id = 1 + user.role_id = UserRoleEnum.ADMIN + return user + + +def _dropdown_service() -> VSPService: + service = VSPService.__new__(VSPService) + service.vsp_repo = MagicMock() + service.vsp_repo.get_list = AsyncMock(return_value=(0, [])) + service.form_repo = MagicMock() + service.form_repo.get = AsyncMock(return_value=None) + service._get_scoped_ssp_ids = AsyncMock(return_value=None) + service._sync_closed_records = AsyncMock() + return service + + +async def test_get_dropdown_include_inactive_passes_none(): + service = _dropdown_service() + await service.get_dropdown(user=_admin(), ssp_id=2, include_inactive=True, with_count=True) + assert service.vsp_repo.get_list.await_args.kwargs["is_active"] is None + + +async def test_get_dropdown_default_active_only(): + service = _dropdown_service() + await service.get_dropdown(user=_admin(), ssp_id=2, with_count=True) + assert service.vsp_repo.get_list.await_args.kwargs["is_active"] is True + + +async def test_export_xlsx_status_closed(): + service = VSPService.__new__(VSPService) + service.get_list = AsyncMock(return_value=[]) + + await service.export_xlsx(user=_admin(), status="closed", close_date_start=date(2021, 1, 1)) + + kwargs = service.get_list.await_args.kwargs + assert kwargs["is_active"] is False + assert kwargs["close_date_start"] == date(2021, 1, 1) + assert kwargs["close_date_end"] is None -- 2.47.2 From a561939a6615be1ca64b1fe7d44ef2f80ef30240 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 11:49:36 +0400 Subject: [PATCH 4/8] =?UTF-8?q?feat(forms):=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D1=8C=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=92=D0=A1=D0=9F=20=C2=AB(=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82)=C2=BB=20=D0=B2=20=D0=BB=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=85=20=D1=84=D0=BE=D1=80=D0=BC,=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D1=85=20?= =?UTF-8?q?(AURORA-1883,=20#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../RealtimeTable/Cell/Cell/Cell.jsx | 12 +++--- .../Cell/EditCell/VspDropdownEditCell.jsx | 14 ++++++- .../RealtimeTable/Modals/SelectVspModal.jsx | 1 + .../RealtimeTable/RealtimeTable.jsx | 3 +- .../RealtimeTable/utils/vspLabel.js | 17 ++++++++ .../RealtimeTable/utils/vspLabel.test.js | 40 +++++++++++++++++++ 6 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/vspLabel.js create mode 100644 web/src/components/RealtimeTable/utils/vspLabel.test.js diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index bd07a07..d55ed56 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -4,6 +4,7 @@ import { useCellLock } from '../../contexts/RealtimeContext'; import { resolveCellTextColor } from '../../utils/cellTextColor'; import { formatNumber } from '../../utils/numberFormat'; import { isTextColumn } from '../../utils/moneyUtils'; +import { getVspLabel } from '../../utils/vspLabel'; const BASE_CELL_STYLES = { width: '100%', @@ -164,6 +165,10 @@ const CellComponent = ({ const { rawValue, displayValue, isNumeric, isNegative } = useMemo(() => { const value = cell.getValue(); + // AURORA-1883: id ВСП — не сумма; подпись (рег. номер, «(закрыт)», «ВСП #id») без разрядности. + if (isVspDropdown) { + return { rawValue: value, displayValue: getVspLabel(value, vspOptions) ?? '', isNumeric: false, isNegative: false }; + } const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)) && !isTextColumn(column); let display = String(value || ''); let negative = false; @@ -173,13 +178,6 @@ const CellComponent = ({ negative = formatted.isNegative; } - if (isVspDropdown && value != null) { - const vsp = vspOptions?.find((v) => v.id === Number(value)); - if (vsp) { - return { rawValue: value, displayValue: vsp.registration_number, isNumeric: false, isNegative: false }; - } - } - return { rawValue: value, displayValue: display, isNumeric: isNum, isNegative: negative }; }, [cell, column.id, isVspDropdown, vspOptions, displayFactor]); diff --git a/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx index beafa61..b233dfd 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx @@ -1,6 +1,7 @@ import { FormControl, MenuItem, Select } from '@mui/material'; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useRealtimeActions, useVspOptions } from '../../contexts/RealtimeContext'; +import { getVspLabel, selectableVspOptions } from '../../utils/vspLabel'; const EDITOR_STYLES = { position: 'absolute', @@ -11,6 +12,12 @@ const EDITOR_STYLES = { const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => { const { endEditing: contextEndEditing } = useRealtimeActions(); const vspOptions = useVspOptions(); + // AURORA-1883: выбрать можно только действующий ВСП; закрытый текущий — подписан, но не выбирается. + const selectableOptions = useMemo(() => selectableVspOptions(vspOptions), [vspOptions]); + const currentIsSelectable = useMemo( + () => value == null || value === '' || selectableOptions.some((option) => Number(option.id) === Number(value)), + [selectableOptions, value], + ); const [selectedValue, setSelectedValue] = useState(() => value ?? ''); const [position, setPosition] = useState(); @@ -140,7 +147,12 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table, c Выберите ВСП - {vspOptions.map((option) => ( + {!currentIsSelectable && ( + + {getVspLabel(value, vspOptions)} + + )} + {selectableOptions.map((option) => ( {option.registration_number} diff --git a/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx b/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx index 88cd4da..c3d4043 100644 --- a/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx +++ b/web/src/components/RealtimeTable/Modals/SelectVspModal.jsx @@ -8,6 +8,7 @@ import { Cancel } from '../../common/icons/icons'; const SelectVspModalComponent = ({ isOpen, onClose, onSelect, formId, title = 'Выбор ВСП', isSaving = false }) => { const [selectedValue, setSelectedValue] = useState(''); + // AURORA-1883: модалка сама грузит dropdown без include_inactive — в выборе только действующие ВСП. const [vspOptions, setVspOption] = useState([]); useEffect(() => { diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 93e456d..d4e442d 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -308,7 +308,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { if (!hasVspDropdown(columnsConfig.columns)) return; let isCancelled = false; - DictVspApi.getDropdownVsp({ form_id: formId }).then((data) => { + // AURORA-1883: с закрытыми — для подписей строк на закрытых ВСП; выбор ограничен в VspDropdownEditCell. + DictVspApi.getDropdownVsp({ form_id: formId, include_inactive: true }).then((data) => { if (!isCancelled && data.success) { setVspOptions(data.result); } diff --git a/web/src/components/RealtimeTable/utils/vspLabel.js b/web/src/components/RealtimeTable/utils/vspLabel.js new file mode 100644 index 0000000..4dd7f8d --- /dev/null +++ b/web/src/components/RealtimeTable/utils/vspLabel.js @@ -0,0 +1,17 @@ +// AURORA-1883: подпись ВСП в ячейке листа формы (колонки editType 'vsp_dropdown'). +// vspOptions — ответ /dict/info/dropdown?include_inactive=true: действующие и закрытые ВСП ССП формы. + +const isEmptyValue = (value) => value === null || value === undefined || value === ''; + +/** Рег. номер ВСП; у закрытого — «<рег. номер> (закрыт)»; не найден — «ВСП #»; пусто — null. */ +export function getVspLabel(value, vspOptions) { + if (isEmptyValue(value)) return null; + const vsp = (vspOptions || []).find((option) => Number(option.id) === Number(value)); + if (!vsp) return `ВСП #${value}`; + return vsp.is_active === false ? `${vsp.registration_number} (закрыт)` : vsp.registration_number; +} + +/** ВСП, которые можно выбрать для строки: только действующие. */ +export function selectableVspOptions(vspOptions) { + return (vspOptions || []).filter((option) => option.is_active !== false); +} diff --git a/web/src/components/RealtimeTable/utils/vspLabel.test.js b/web/src/components/RealtimeTable/utils/vspLabel.test.js new file mode 100644 index 0000000..1200386 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/vspLabel.test.js @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getVspLabel, selectableVspOptions } from './vspLabel.js'; + +// AURORA-1883: в листах форм закрытый ВСП подписан рег. номером с пометкой, а не числом. +const opts = [ + { id: 1, registration_number: '1234/567', is_active: true }, + { id: 2, registration_number: '7777/001', is_active: false }, +]; + +test('active VSP is labelled by its registration number', () => { + assert.equal(getVspLabel(1, opts), '1234/567'); +}); + +test('closed VSP is labelled with the "(закрыт)" suffix', () => { + assert.equal(getVspLabel(2, opts), '7777/001 (закрыт)'); +}); + +test('string id is matched like a number', () => { + assert.equal(getVspLabel('1', opts), '1234/567'); +}); + +test('unknown id is labelled "ВСП #", never formatted as a number', () => { + assert.equal(getVspLabel(99, opts), 'ВСП #99'); + assert.equal(getVspLabel(5, undefined), 'ВСП #5'); +}); + +test('empty value has no label', () => { + assert.equal(getVspLabel(null, opts), null); + assert.equal(getVspLabel(undefined, opts), null); + assert.equal(getVspLabel('', opts), null); +}); + +test('only active VSPs are selectable', () => { + assert.deepEqual( + selectableVspOptions(opts).map((v) => v.id), + [1], + ); + assert.deepEqual(selectableVspOptions(undefined), []); +}); -- 2.47.2 From c39bbbfd069639a4b256448fd3884623177593a0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 11:52:18 +0400 Subject: [PATCH 5/8] =?UTF-8?q?feat(vsp):=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=20=D0=92=D0=A1=D0=9F=20=E2=80=94?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=C2=AB=D0=94=D0=B5?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D0=B5/=D0=97?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B5/=D0=92=D1=81=D0=B5?= =?UTF-8?q?=C2=BB,=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F,=20=D0=B1=D0=B5=D0=B9?= =?UTF-8?q?=D0=B4=D0=B6=20=D0=B8=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D1=83=20(AURORA-1883,=20#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/DictVspPage/DictVspPage.jsx | 130 ++++++++++++------ .../DictVspPage/components/DictVspFilters.jsx | 83 +++++++++-- .../DictVspPage/components/DictVspModals.jsx | 4 +- .../components/Modals/ModalExport.jsx | 5 +- .../DictVspPage/components/TableDictVsp.jsx | 37 ++++- .../pages/DictVspPage/utils/vspListParams.js | 23 ++++ .../DictVspPage/utils/vspListParams.test.js | 33 +++++ 7 files changed, 251 insertions(+), 64 deletions(-) create mode 100644 web/src/pages/DictVspPage/utils/vspListParams.js create mode 100644 web/src/pages/DictVspPage/utils/vspListParams.test.js diff --git a/web/src/pages/DictVspPage/DictVspPage.jsx b/web/src/pages/DictVspPage/DictVspPage.jsx index d9b1992..c6f6e0d 100644 --- a/web/src/pages/DictVspPage/DictVspPage.jsx +++ b/web/src/pages/DictVspPage/DictVspPage.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { NameTask, TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style'; import { Box, CircularProgress, Stack } from '@mui/material'; @@ -13,6 +13,7 @@ import { PageContainer } from '../../components/common/PageContainer'; import DictVspFilters from './components/DictVspFilters'; import DictVspModals from './components/DictVspModals'; import TableDictVsp from './components/TableDictVsp'; +import { buildVspListParams, vspMatchesStatus } from './utils/vspListParams'; const DictVspInfo = () => { const portalContent = ( @@ -30,24 +31,46 @@ function DictVspPage() { const [dictVspData, setDictVspData] = useState([]); const [ssps, setSsps] = useState([]); const [selectedSsp, setSelectedSsp] = useState(null); + // AURORA-1883: статус ВСП и период закрытия; список ВСП перезагружается с сервера при их смене. + const [status, setStatus] = useState('active'); + const [closeDates, setCloseDates] = useState({ closeDateStart: '', closeDateEnd: '' }); + const [isListLoading, setIsListLoading] = useState(true); + const listRequestRef = useRef(0); const { user } = useAuth(); const modalsRef = useRef(null); + const listFilter = useMemo(() => ({ status, ...closeDates }), [status, closeDates]); + + const loadVspList = useCallback(async () => { + if (!user) return; + const requestId = ++listRequestRef.current; + setIsListLoading(true); + try { + const vspResponse = await DictVspApi.get(buildVspListParams(listFilter)); + if (requestId !== listRequestRef.current) return; + if (!vspResponse.success) { + toast.error('Ошибка загрузки справочника'); + return; + } + setDictVspData(vspResponse.result || []); + } catch (error) { + if (requestId !== listRequestRef.current) return; + toast.error(error.response?.data?.message || 'Ошибка загрузки справочника'); + } finally { + if (requestId === listRequestRef.current) setIsListLoading(false); + } + }, [user, listFilter]); + + useEffect(() => { + loadVspList(); + }, [loadVspList]); + const loadData = useCallback(async () => { if (!user) return; setIsLoading(true); try { const isAdmin = user.role_id === ROLES_NAME_ID.admin; - const [vspResponse, sspResponse] = await Promise.all([ - DictVspApi.get(), - isAdmin ? SspApi.getAll({ is_active: true }) : Promise.resolve(null), - ]); - - if (!vspResponse.success) { - toast.error('Ошибка загрузки справочника'); - return; - } - setDictVspData(vspResponse.result || []); + const sspResponse = isAdmin ? await SspApi.getAll({ is_active: true }) : null; if (isAdmin) { if (!sspResponse.success) { @@ -70,37 +93,49 @@ function DictVspPage() { loadData(); }, [loadData]); - const handleAdd = useCallback(async (formData) => { - try { - const response = await DictVspApi.create(formData); - if (response.success) { - toast.success('ВСП успешно создан'); - setDictVspData((prev) => [...prev, response.result]); - } else { - toast.error(response.message || 'Ошибка создания ВСП'); + const handleAdd = useCallback( + async (formData) => { + try { + const response = await DictVspApi.create(formData); + if (response.success) { + toast.success('ВСП успешно создан'); + if (vspMatchesStatus(response.result, status)) { + setDictVspData((prev) => [...prev, response.result]); + } + } else { + toast.error(response.message || 'Ошибка создания ВСП'); + } + } catch (error) { + console.error('Error adding VSP:', error); + toast.error(error.response?.data?.message || 'Ошибка создания ВСП'); + throw error; } - } catch (error) { - console.error('Error adding VSP:', error); - toast.error(error.response?.data?.message || 'Ошибка создания ВСП'); - throw error; - } - }, []); + }, + [status], + ); - const handleEdit = useCallback(async (formData) => { - try { - const response = await DictVspApi.update(formData.id, formData); - if (response.success) { - toast.success('ВСП успешно обновлен'); - setDictVspData((prev) => [...prev.filter((v) => v.id !== formData.id), response.result]); - } else { - toast.error(response.message || 'Ошибка обновления ВСП'); + const handleEdit = useCallback( + async (formData) => { + try { + const response = await DictVspApi.update(formData.id, formData); + if (response.success) { + toast.success('ВСП успешно обновлен'); + // AURORA-1883: ВСП, переставший подходить под статус (например, закрыт в «Действующих»), уходит из списка. + setDictVspData((prev) => { + const rest = prev.filter((v) => v.id !== formData.id); + return vspMatchesStatus(response.result, status) ? [...rest, response.result] : rest; + }); + } else { + toast.error(response.message || 'Ошибка обновления ВСП'); + } + } catch (error) { + console.error('Error editing VSP:', error); + toast.error(error.response?.data?.message || 'Ошибка обновления ВСП'); + throw error; } - } catch (error) { - console.error('Error editing VSP:', error); - toast.error(error.response?.data?.message || 'Ошибка обновления ВСП'); - throw error; - } - }, []); + }, + [status], + ); const handleDelete = useCallback(async (vspId) => { try { @@ -140,16 +175,24 @@ function DictVspPage() { - {user.role_id !== ROLES_NAME_ID.executor_rf && ( - - )} + diff --git a/web/src/pages/DictVspPage/components/DictVspFilters.jsx b/web/src/pages/DictVspPage/components/DictVspFilters.jsx index cec8f08..8e07ed7 100644 --- a/web/src/pages/DictVspPage/components/DictVspFilters.jsx +++ b/web/src/pages/DictVspPage/components/DictVspFilters.jsx @@ -1,5 +1,6 @@ -import { Autocomplete, Box, Paper, Stack, TextField } from '@mui/material'; +import { Autocomplete, Box, Paper, Stack, TextField, ToggleButton, ToggleButtonGroup } from '@mui/material'; import { styled } from '@mui/material'; +import { VSP_STATUS_OPTIONS } from '../utils/vspListParams'; const AutocompleteStyled = styled(Autocomplete)` & .MuiOutlinedInput-root { @@ -23,7 +24,22 @@ const AutocompleteStyled = styled(Autocomplete)` } `; -const DictVspFilters = ({ ssps, selectedSsp = null, onSspChange }) => { +const DATE_INPUT_SX = { + width: '11rem', + '& .MuiOutlinedInput-root': { backgroundColor: 'white', height: '2.56rem', borderRadius: '0.63rem' }, +}; + +const DictVspFilters = ({ + ssps, + selectedSsp = null, + onSspChange, + showSsp = true, + status = 'active', + onStatusChange, + closeDateStart = '', + closeDateEnd = '', + onCloseDateChange, +}) => { return ( { gap: '1rem', mb: '1.5rem', }}> - - - option.title || option.name || ''} - value={selectedSsp} - getOptionKey={(option) => option.id || option.name || option.title} - onChange={(event, newValue) => onSspChange(newValue)} - renderInput={(params) => } - /> - + + {showSsp && ( + + option.title || option.name || ''} + value={selectedSsp} + getOptionKey={(option) => option.id || option.name || option.title} + onChange={(event, newValue) => onSspChange(newValue)} + renderInput={(params) => } + /> + + )} + {/* AURORA-1883: статус ВСП и период закрытия */} + value && onStatusChange?.(value)} + sx={{ backgroundColor: 'white', height: '2.56rem' }}> + {VSP_STATUS_OPTIONS.map((option) => ( + + {option.label} + + ))} + + {status !== 'active' && ( + <> + onCloseDateChange?.({ closeDateStart: event.target.value, closeDateEnd })} + InputLabelProps={{ shrink: true }} + sx={DATE_INPUT_SX} + /> + onCloseDateChange?.({ closeDateStart, closeDateEnd: event.target.value })} + InputLabelProps={{ shrink: true }} + sx={DATE_INPUT_SX} + /> + + )} ); diff --git a/web/src/pages/DictVspPage/components/DictVspModals.jsx b/web/src/pages/DictVspPage/components/DictVspModals.jsx index 22b7b01..8c94e14 100644 --- a/web/src/pages/DictVspPage/components/DictVspModals.jsx +++ b/web/src/pages/DictVspPage/components/DictVspModals.jsx @@ -3,7 +3,7 @@ import ModalEditAddVsp from './Modals/ModalEditAddVsp'; import ModalExport from './Modals/ModalExport'; // Состояние модалок живёт здесь, а не в таблице: открытие/закрытие не перерисовывает MRT. -const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps }, ref) => { +const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps, listFilter }, ref) => { const [modalOpen, setModalOpen] = useState(false); const [modalExportOpen, setModalExportOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); @@ -55,7 +55,7 @@ const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps }, ref) => { loading={saveLoading} ssps={ssps} /> - + ); }); diff --git a/web/src/pages/DictVspPage/components/Modals/ModalExport.jsx b/web/src/pages/DictVspPage/components/Modals/ModalExport.jsx index 47c46cb..0887031 100644 --- a/web/src/pages/DictVspPage/components/Modals/ModalExport.jsx +++ b/web/src/pages/DictVspPage/components/Modals/ModalExport.jsx @@ -7,9 +7,10 @@ import Checkbox from '../../../../components/common/Checkbox'; import Modal from '../../../../components/common/Modal/Modal'; import SearchComponent from '../../../../components/common/SearchComponent'; import { downloadFile } from '../../../../utils/downloadFile'; +import { buildVspListParams } from '../../utils/vspListParams'; import { ModalContainer } from './Modal.style'; -const ModalExport = ({ open, onClose, ssps = [], loading = false }) => { +const ModalExport = ({ open, onClose, ssps = [], loading = false, listFilter = { status: 'active' } }) => { const [searchTerm, setSearchTerm] = useState(''); const [selectedSsps, setSelectedSsps] = useState([]); @@ -45,8 +46,10 @@ const ModalExport = ({ open, onClose, ssps = [], loading = false }) => { const handleExport = async () => { if (selectedSsps.length > 0) { try { + // AURORA-1883: выгрузка по текущему статусу и периоду закрытия справочника. const response = await DictVspApi.export({ ssp_ids: selectedSsps, + ...buildVspListParams(listFilter), }); await downloadFile(response, 'Справочник ВСП', 'csv'); } catch (error) { diff --git a/web/src/pages/DictVspPage/components/TableDictVsp.jsx b/web/src/pages/DictVspPage/components/TableDictVsp.jsx index 1c96868..792cded 100644 --- a/web/src/pages/DictVspPage/components/TableDictVsp.jsx +++ b/web/src/pages/DictVspPage/components/TableDictVsp.jsx @@ -125,6 +125,36 @@ const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading = enableColumnActions: false, enableSorting: false, }, + { + // AURORA-1883: статус ВСП — как бейдж «Активен»/«Архив» в SspTable. + accessorKey: 'is_active', + header: 'Статус', + size: 110, + minSize: 100, + maxSize: 130, + Cell: ({ row }) => { + const isActive = row.original.is_active !== false; + return ( + + {isActive ? 'Действует' : 'Закрыт'} + + ); + }, + enableColumnActions: false, + enableSorting: false, + }, { accessorKey: 'placement_type', header: 'Форма расположения ВСП', @@ -412,15 +442,16 @@ const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading = }, }, - muiTableBodyCellProps: { + muiTableBodyCellProps: ({ row }) => ({ sx: { padding: '0.75rem', fontSize: '0.75rem', lineHeight: '150%', - color: '#364153', + // AURORA-1883: текст строк закрытых ВСП приглушён. + color: row.original.is_active === false ? '#99a1af' : '#364153', whiteSpace: 'nowrap', // Предотвращаем перенос текста }, - }, + }), muiTableHeadCellProps: { sx: { diff --git a/web/src/pages/DictVspPage/utils/vspListParams.js b/web/src/pages/DictVspPage/utils/vspListParams.js new file mode 100644 index 0000000..fcbc40c --- /dev/null +++ b/web/src/pages/DictVspPage/utils/vspListParams.js @@ -0,0 +1,23 @@ +// AURORA-1883: статус справочника ВСП — 'active' (по умолчанию) | 'closed' | 'all'. + +export const VSP_STATUS_OPTIONS = [ + { value: 'active', label: 'Действующие' }, + { value: 'closed', label: 'Закрытые' }, + { value: 'all', label: 'Все' }, +]; + +/** Параметры GET /dict/info и тела выгрузки: период закрытия — только для «Закрытые»/«Все». */ +export function buildVspListParams({ status, closeDateStart, closeDateEnd }) { + const params = { status }; + if (status === 'active') return params; + if (closeDateStart) params.close_date_start = closeDateStart; + if (closeDateEnd) params.close_date_end = closeDateEnd; + return params; +} + +/** Подходит ли ВСП под выбранный статус (после правки строка может выпасть из списка). */ +export function vspMatchesStatus(vsp, status) { + if (status === 'active') return vsp.is_active !== false; + if (status === 'closed') return vsp.is_active === false; + return true; +} diff --git a/web/src/pages/DictVspPage/utils/vspListParams.test.js b/web/src/pages/DictVspPage/utils/vspListParams.test.js new file mode 100644 index 0000000..330bba3 --- /dev/null +++ b/web/src/pages/DictVspPage/utils/vspListParams.test.js @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildVspListParams, vspMatchesStatus } from './vspListParams.js'; + +// AURORA-1883: параметры запроса справочника ВСП по статусу и периоду закрытия. +test('active status sends no close period', () => { + assert.deepEqual(buildVspListParams({ status: 'active', closeDateStart: '2021-01-01', closeDateEnd: '' }), { + status: 'active', + }); +}); + +test('closed status sends both close dates', () => { + 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', + }); +}); + +test('all status sends only the filled close date', () => { + assert.deepEqual(buildVspListParams({ status: 'all', closeDateStart: '', closeDateEnd: '2021-12-31' }), { + status: 'all', + close_date_end: '2021-12-31', + }); +}); + +test('vspMatchesStatus follows is_active', () => { + 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); +}); -- 2.47.2 From f1fca5d12ccdeeb6f44ae32ecce742cd5226d671 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 12:23:16 +0400 Subject: [PATCH 6/8] =?UTF-8?q?fix(vsp):=20=D0=BF=D0=BE=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D0=B0=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=E2=80=94=20slotProps.inputLab?= =?UTF-8?q?el=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D1=91=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B2=20MUI=209?= =?UTF-8?q?=20InputLabelProps=20(AURORA-1883,=20#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/pages/DictVspPage/components/DictVspFilters.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/DictVspPage/components/DictVspFilters.jsx b/web/src/pages/DictVspPage/components/DictVspFilters.jsx index 8e07ed7..aeb419f 100644 --- a/web/src/pages/DictVspPage/components/DictVspFilters.jsx +++ b/web/src/pages/DictVspPage/components/DictVspFilters.jsx @@ -89,7 +89,7 @@ const DictVspFilters = ({ label='Закрыт с' value={closeDateStart} onChange={(event) => onCloseDateChange?.({ closeDateStart: event.target.value, closeDateEnd })} - InputLabelProps={{ shrink: true }} + slotProps={{ inputLabel: { shrink: true } }} sx={DATE_INPUT_SX} /> onCloseDateChange?.({ closeDateStart, closeDateEnd: event.target.value })} - InputLabelProps={{ shrink: true }} + slotProps={{ inputLabel: { shrink: true } }} sx={DATE_INPUT_SX} /> -- 2.47.2 From a4a674fe601d72ec26d481980638f0dee11915ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 12:28:04 +0400 Subject: [PATCH 7/8] =?UTF-8?q?fix(vsp):=20=D0=B0=D1=80=D1=85=D0=B8=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=A1=D0=A1=D0=9F=20=D0=B2=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D0=B5=20=D0=B8=20=D0=B2=D1=8B=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B5=20=D0=B4=D0=BB=D1=8F=20=C2=AB?= =?UTF-8?q?=D0=97=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B5=C2=BB/=C2=AB?= =?UTF-8?q?=D0=92=D1=81=D0=B5=C2=BB=20=E2=80=94=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8B=D0=B5=20=D0=92=D0=A1=D0=9F=20=D0=B0=D1=80?= =?UTF-8?q?=D1=85=D0=B8=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=A1=D0=A1=D0=9F?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B6=D0=B0=D1=8E=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20(AURORA-1883,=20#330,=20I-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Окно создания и правки ВСП по-прежнему предлагает только активные ССП. Co-Authored-By: Claude Opus 5.5 --- web/src/pages/DictVspPage/DictVspPage.jsx | 20 ++++++++++++---- .../DictVspPage/components/DictVspModals.jsx | 4 ++-- .../pages/DictVspPage/utils/vspListParams.js | 6 +++++ .../DictVspPage/utils/vspListParams.test.js | 23 ++++++++++++++++++- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/web/src/pages/DictVspPage/DictVspPage.jsx b/web/src/pages/DictVspPage/DictVspPage.jsx index c6f6e0d..b502414 100644 --- a/web/src/pages/DictVspPage/DictVspPage.jsx +++ b/web/src/pages/DictVspPage/DictVspPage.jsx @@ -13,7 +13,7 @@ import { PageContainer } from '../../components/common/PageContainer'; import DictVspFilters from './components/DictVspFilters'; import DictVspModals from './components/DictVspModals'; import TableDictVsp from './components/TableDictVsp'; -import { buildVspListParams, vspMatchesStatus } from './utils/vspListParams'; +import { buildVspListParams, sspsForStatus, vspMatchesStatus } from './utils/vspListParams'; const DictVspInfo = () => { const portalContent = ( @@ -40,6 +40,16 @@ function DictVspPage() { const modalsRef = useRef(null); const listFilter = useMemo(() => ({ status, ...closeDates }), [status, closeDates]); + const visibleSsps = useMemo(() => sspsForStatus(ssps, status), [ssps, status]); + // Создание и правка ВСП — только на активном ССП (бэк отклоняет архивный). + const activeSsps = useMemo(() => sspsForStatus(ssps, 'active'), [ssps]); + + // Выбранный архивный ССП исчезает из фильтра при возврате в «Действующие» — сбрасываем выбор. + useEffect(() => { + if (selectedSsp && !visibleSsps.some((ssp) => ssp.id === selectedSsp.id)) { + setSelectedSsp(null); + } + }, [selectedSsp, visibleSsps]); const loadVspList = useCallback(async () => { if (!user) return; @@ -70,7 +80,8 @@ function DictVspPage() { setIsLoading(true); try { const isAdmin = user.role_id === ROLES_NAME_ID.admin; - const sspResponse = isAdmin ? await SspApi.getAll({ is_active: true }) : null; + // AURORA-1883: все ССП, включая архивные, — для закрытых ВСП; в «Действующих» архивные скрываются (sspsForStatus). + const sspResponse = isAdmin ? await SspApi.getAll() : null; if (isAdmin) { if (!sspResponse.success) { @@ -176,7 +187,7 @@ function DictVspPage() { { +const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps, exportSsps, listFilter }, ref) => { const [modalOpen, setModalOpen] = useState(false); const [modalExportOpen, setModalExportOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); @@ -55,7 +55,7 @@ const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps, listFilter }, loading={saveLoading} ssps={ssps} /> - + ); }); diff --git a/web/src/pages/DictVspPage/utils/vspListParams.js b/web/src/pages/DictVspPage/utils/vspListParams.js index fcbc40c..4f22a21 100644 --- a/web/src/pages/DictVspPage/utils/vspListParams.js +++ b/web/src/pages/DictVspPage/utils/vspListParams.js @@ -21,3 +21,9 @@ export function vspMatchesStatus(vsp, status) { if (status === 'closed') return vsp.is_active === false; return true; } + +/** ССП для фильтра и выгрузки: архивные — только при «Закрытые»/«Все» (закрытые ВСП бывают на архивных ССП). */ +export function sspsForStatus(ssps, status) { + const list = ssps || []; + return status === 'active' ? list.filter((ssp) => ssp.is_active !== false) : list; +} diff --git a/web/src/pages/DictVspPage/utils/vspListParams.test.js b/web/src/pages/DictVspPage/utils/vspListParams.test.js index 330bba3..af3db31 100644 --- a/web/src/pages/DictVspPage/utils/vspListParams.test.js +++ b/web/src/pages/DictVspPage/utils/vspListParams.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildVspListParams, vspMatchesStatus } from './vspListParams.js'; +import { buildVspListParams, sspsForStatus, vspMatchesStatus } from './vspListParams.js'; // AURORA-1883: параметры запроса справочника ВСП по статусу и периоду закрытия. test('active status sends no close period', () => { @@ -31,3 +31,24 @@ test('vspMatchesStatus follows is_active', () => { assert.equal(vspMatchesStatus({ is_active: true }, 'closed'), false); assert.equal(vspMatchesStatus({ is_active: false }, 'all'), true); }); + +// AURORA-1883, финальное ревью I-1: закрытые ВСП архивного ССП должны фильтроваться и выгружаться. +test('sspsForStatus hides archived SSPs only for active VSPs', () => { + const ssps = [ + { id: 1, is_active: true }, + { id: 2, is_active: false }, + ]; + assert.deepEqual( + sspsForStatus(ssps, 'active').map((s) => s.id), + [1], + ); + assert.deepEqual( + sspsForStatus(ssps, 'closed').map((s) => s.id), + [1, 2], + ); + assert.deepEqual( + sspsForStatus(ssps, 'all').map((s) => s.id), + [1, 2], + ); + assert.deepEqual(sspsForStatus(undefined, 'active'), []); +}); -- 2.47.2 From d42cce510c0d19e99befdebc8e9c66f3ff4df679 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 12:28:14 +0400 Subject: [PATCH 8/8] =?UTF-8?q?docs(vsp):=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20AURORA-1883=20=D0=B2=20design.md=20(R-1=E2=80=A6?= =?UTF-8?q?R-6)=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/aurora-1883-closed-vsp/design.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openspec/changes/aurora-1883-closed-vsp/design.md b/openspec/changes/aurora-1883-closed-vsp/design.md index 25eb860..f808e65 100644 --- a/openspec/changes/aurora-1883-closed-vsp/design.md +++ b/openspec/changes/aurora-1883-closed-vsp/design.md @@ -98,3 +98,12 @@ 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): админ грузит все ССП; в «Действующих» архивные ССП скрыты, в «Закрытые»/«Все» — показаны в фильтре и выгрузке; окно создания/правки ВСП — только активные ССП.** Обоснование: закрытые ВСП архивного ССП иначе видны, но не выгружаются. Цена: если архивных ССП с ВСП в данных нет — лишний пункт списка в режиме закрытых. -- 2.47.2