DFiP_Budget_planing/docs/superpowers/plans/2026-11-12-aurora-1883-closed-vsp.md
Andrey Epifancev 99ab7598c9 docs(vsp): OpenSpec change и план AURORA-1883 — просмотр закрытых ВСП (#330)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:27:51 +04:00

196 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
- Подписи: бейджи «Действует» / «Закрыт»; ячейка формы — `<рег. номер> (закрыт)`, ненайденный — `ВСП #<id>`; кнопки статуса «Действующие» / «Закрытые» / «Все»; поля «Закрыт с» / «по».
- Цвет приглушённого текста закрытых — `#99a1af`; цвета бейджей — как «Активен»/«Архив» в `web/src/pages/AdminPanel/SppsPage/SspTable.jsx`.
- `@mui/x-date-pickers` не использовать (не объявлен в package.json).
- Коммиты на русском, с `(AURORA-1883, #330)` и строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Окружение
- 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" <venv>/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, которого нет в загруженном списке (удалённый ВСП), при разрядности «тыс.»/«млн» — должна показывать `ВСП #<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 и лишние/недостающие строки у интеграционных):
`<venv>/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** на свежей БД: `<venv>/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)`.