Andrey Epifancev b1c61ae769 openspec: архив aurora-1883-closed-vsp и aurora-1896-cell-formulas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 18:49:51 +04:00

22 KiB
Raw Blame History

Design — AURORA-1883: просмотр закрытых ВСП

Context

Исходный дизайн — описание Jira AURORA-1883 (28.10.2026). Он сверен с кодом ветки feat/327-aurora-1885-vsp-get-and-close (744b71f, поверх test ac68699, 12.11.2026). Описание и код совпадают; где фронт с 28.10 изменился, это отмечено ниже (D7, D8).

Ветка 1883 (feat/330-aurora-1883-closed-vsp) построена поверх AURORA-1885 (PR #328): после 1885 прошедшая дата закрытия сразу даёт is_active=false, поэтому «закрытый» = is_active=false без оговорок. AURORA-1886 (PR после #329, миграция 0062) правит VSPService.update и VSPRepository.update — с 1883 пересечений по коду нет (1883 трогает get_list, get_dropdown, export_xlsx, роутер и фронт). Миграции в 1883 нет.

Как сейчас (бэк):

  • api/src/api/v1/vsp.py get_vsp — service.get_list(..., is_active=True, with_count=True, load_org_unit=True), статус зашит. get_vsp_dropdown(ssp_id, form_id) → service.get_dropdown. export_info(body: VSPExportRequest) → service.export_xlsx.
  • api/src/domain/schemas.py VSPExportRequest — registration_number, address, ssp_ids, open_date_start/end, placement_type, staff_count_min/max; статуса нет. VSPInDB уже отдаёт is_active и close_date (alias closed_at).
  • VSPService.get_list — синхронизация закрытия (_sync_closed_records), пересечение ССП с ССП исполнителя, вызов vsp_repo.get_list(... is_active=is_active ...). get_dropdown — ССП формы/ssp_id ∩ ССП исполнителя, vsp_repo.get_list(ssp_ids, is_active=True, with_count). export_xlsx — get_list(..., is_active=True, load_org_unit=True).
  • VSPRepository.get_list — is_deleted = false всегда; фильтры через match; is_active (по умолчанию True); close_date_is_null — никем не передаётся; order_by(Vsp.id); в конце мёртвый return после if/else.

Как сейчас (фронт):

  • web/src/api/dict-vsp.js — get(params), getById, export, getDropdownVsp(params).
  • pages/DictVspPage/DictVspPage.jsx — loadData зовёт DictVspApi.get() без параметров; handleEdit обновляет строку в списке.
  • components/DictVspFilters.jsx — только Autocomplete ССП (ssps, selectedSsp, onSspChange); фильтр по ССП — на клиенте в TableDictVsp.jsx.
  • components/Modals/ModalExport.jsx — DictVspApi.export({ ssp_ids: selectedSsps }).
  • pages/AdminPanel/SppsPage/SspTable.jsx:130-150 — образец бейджа статуса (синий «Активен» / серый «Архив», текст #99a1af).
  • components/RealtimeTable/RealtimeTable.jsx ~:304-318 — DictVspApi.getDropdownVsp({ form_id }) → setVspOptions (контекст RealtimeVspOptionsContext, contexts/RealtimeContext.js); ~:450 vspRegistrationById для поиска/фильтра по колонке ВСП.
  • Cell/Cell/Cell.jsx ~:165-184 — isNum считается до проверки isVspDropdown; если ВСП найден в vspOptions — показывается registration_number, иначе — id, отформатированный как сумма с displayFactor.
  • Cell/EditCell/VspDropdownEditCell.jsx — список выбора из useVspOptions().
  • Modals/SelectVspModal.jsx — свой запрос getDropdownVsp({ form_id }) в собственный state (не контекст).

Goals / Non-Goals

Goals

  • Найти в справочнике закрытый (в том числе давно) ВСП по статусу и периоду закрытия; видеть статус строки.
  • Выгрузка по выбранному статусу и периоду.
  • Подпись закрытого ВСП в листах форм — рег. номер с пометкой, не число.
  • Поведение по умолчанию (без параметров) — прежнее везде.

Non-Goals

  • Снятие даты закрытия (AURORA-1886), удаление ВСП (AURORA-1884).
  • Загрузка архива закрытых ВСП из внешнего источника.
  • Серверная пагинация справочника, перенос фильтра ССП на сервер.
  • Изменение прав: кто видит какие ВСП, кто их редактирует.

Decisions

D1. status — параметр роутера, в сервис идёт is_active

get_vsp: status: Literal["active", "closed", "all"] = "active" (FastAPI сам отвечает 422 на иное значение). Перевод — одна чистая функция vsp_status_to_is_active(status) -> bool | None (active → True, closed → False, all → None) в api/src/services/vsp_service.py (модульный уровень), чтобы её покрыть unit-тестом и переиспользовать в выгрузке. Сервис и репозиторий по-прежнему принимают is_active. VSPStatus = Literal["active", "closed", "all"] объявляется в schemas.py и используется и в роутере, и в VSPExportRequest.

D2. Период закрытия — close_date_start / close_date_end до репозитория

Добавить в get_vsp, VSPService.get_list, VSPService.export_xlsx, VSPRepository.get_list: close_date_start: date | None, close_date_end: date | None → Vsp.closed_at >= start, Vsp.closed_at <= end (в тот же match). NULL >= … в SQL ложно — ВСП без даты закрытия при заданной границе не попадает, отдельного условия не нужно. Фильтр применяется при любом status (у действующего ВСП может быть будущая дата закрытия; UI показывает поля периода только при «Закрытые» и «Все», но API не запрещает).

D3. Сортировка закрытых

VSPRepository.get_list: при is_active is False — order_by(Vsp.closed_at.desc().nulls_last(), Vsp.id), иначе — order_by(Vsp.id). nulls_last — страховка: закрытый ВСП без даты возможен только при ручной правке БД. Мёртвый return в конце метода удалить. Параметр close_date_is_null удалить (никто не передаёт — проверено grep'ом по api/).

D4. Выгрузка

VSPExportRequest: status: VSPStatus = "active", close_date_start: date | None = None, close_date_end: date | None = None. export_info передаёт их в export_xlsx, тот — is_active=vsp_status_to_is_active(status) и даты в get_list вместо зашитого is_active=True. Набор колонок файла не меняется; колонка close_date в выгрузке уже есть (vsp_service.py ~:198, :225).

D5. Dropdown с закрытыми

get_vsp_dropdown: include_inactive: bool = False → service.get_dropdown(..., include_inactive=...) → vsp_repo.get_list(ssp_ids, is_active=None if include_inactive else True, with_count). Удалённые отсекает is_deleted = false в репозитории, как раньше.

D6. Фронт — справочник

  • Чистая утилита web/src/pages/DictVspPage/utils/vspListParams.js: buildVspListParams({ status, closeDateStart, closeDateEnd }) → объект query-параметров: status всегда; даты (YYYY-MM-DD) только при status !== 'active' и если заданы. Её же использует выгрузка. Плюс vspMatchesStatus(vsp, status) — для handleEdit: active → vsp.is_active, closed → !vsp.is_active, all → true. Покрыть node --test (vspListParams.test.js рядом).
  • DictVspFilters.jsx: новые пропсы status, onStatusChange, closeDateStart, closeDateEnd, onCloseDateChange. Переключатель — MUI ToggleButtonGroup (exclusive) «Действующие» / «Закрытые» / «Все» рядом с ССП. Поля дат — TextField type="date" «Закрыт с» / «по» (как в остальном коде справочника; @mui/x-date-pickers не объявлен в package.json — не использовать), видимы при status !== 'active'.
  • DictVspPage.jsx: state status ('active'), closeDateStart, closeDateEnd (''); loadData — DictVspApi.get(buildVspListParams(...)); перезагрузка в useEffect по этим трём значениям. Остальные запросы loadData (ССП и т.п.) не трогать — перезагружать только список ВСП, если loadData грузит несколько вещей, вынести загрузку ВСП в отдельную функцию. handleEdit: после ответа сервера, если !vspMatchesStatus(updated, status) — убрать строку из списка, иначе заменить как сейчас. Переключение статуса сохраняет выбранный ССП.
  • TableDictVsp.jsx: колонка «Статус» (после «Дата закрытия ВСП»), бейдж «Действует» (синий, как «Активен» в SspTable) / «Закрыт» (серый, как «Архив»). Текст ячеек строки закрытого ВСП — #99a1af (через muiTableBodyRowProps/muiTableBodyCellProps по row.original.is_active).
  • ModalExport.jsx: принимает status, closeDateStart, closeDateEnd пропсами от страницы и передаёт { ssp_ids, ...buildVspListParams(...) } в DictVspApi.export.

D7. Фронт — подпись в листах форм

  • Чистая утилита web/src/components/RealtimeTable/utils/vspLabel.js: getVspLabel(value, vspOptions) → null для пустого значения (null/undefined/''); иначе ищет по Number(value); действующий → registration_number; закрытый (is_active === false) → `${registration_number} (закрыт)`; не найден → `ВСП #${value}`. Тест vspLabel.test.js: действующий, закрытый, отсутствующий id, строковый id "123", пустое значение.
  • Cell.jsx: для isVspDropdown ветка идёт до числового форматирования: display = getVspLabel(value, vspOptions) ?? '', isNumeric = false, без displayFactor. (Отличие от Jira: на 28.10 в Cell не было поиска по vspOptions вовсе — сейчас он есть, но срабатывает только для найденных; меняем порядок, чтобы ненайденный не форматировался как сумма.)
  • RealtimeTable.jsx: getDropdownVsp({ form_id: formId, include_inactive: true }) — в контексте полный список (действующие + закрытые). vspRegistrationById строится из того же списка — закрытые находятся поиском по рег. номеру без изменений кода.
  • VspDropdownEditCell.jsx: в список выбора — только vspOptions.filter((v) => v.is_active !== false). Если текущее значение ячейки — закрытый ВСП, оно не теряется: Select показывает подпись из getVspLabel для текущего значения (не пустое поле), но выбрать закрытый снова нельзя.
  • SelectVspModal.jsx — не меняется: делает собственный запрос без include_inactive и уже получает только действующие (отличие от Jira, где предлагалось фильтровать).

D8. Тесты

  • Интеграционные (api/tests/integration/test_vsp.py, фикстуры admin_tokens / isp_tokens; ВСП с прошлой датой закрытия создаётся админом через POST /dict/info, после 1885 он сразу закрыт): status по умолчанию / closed / all; период внутри/вне; исполнитель с status=closed — только свои ССП (если в фикстуре нет второго ССП, доступного админу и недоступного исполнителю, — создать ВСП на ССП вне его привязок); status=bogus → 422; удалённый не возвращается ни при каком статусе; порядок closed — по дате закрытия убыв.; dropdown?form_id=…&include_inactive=true содержит закрытый ВСП ССП формы, без параметра — нет; POST /dict/info/export с status=closed — в xlsx (openpyxl) есть закрытый ВСП, без статуса — нет.
  • Unit (api/tests/unit/): vsp_status_to_is_active (три значения); VSPService.export_xlsx/get_dropdown с моком репозитория — какой is_active уходит в get_list.
  • Фронт: node --test для vspLabel.test.js, vspListParams.test.js; npx biome check по изменённым файлам без новых ошибок; npm run build.

Risks / Trade-offs

  • Справочник грузится целиком без пагинации; «Все» на большом справочнике тяжелее «Действующих». Объём справочника — тысячи строк, приемлемо; пагинация — вне задачи.
  • include_inactive=true увеличивает список ВСП, который получает каждая открытая форма 2 (на закрытые ВСП её ССП). Ограничено ССП формы — несущественно.
  • Исполнитель видит закрытые ВСП своих ССП — так же, как видел их до закрытия; новых данных ему не открывается.

Оставлено исполнителю

  • Точная вёрстка переключателя и полей дат в DictVspFilters (в стиле существующего Autocomplete).
  • Как именно протянуть status/даты в ModalExport (пропсы или контекст страницы) — без изменения UX модалки.
  • Формат даты в фикстурах тестов, имена тестов.

Decisions made without discussion

Человек сказал «делай по Jira» (12.11.2026) — дизайн взят из описания Jira; ниже решения, которых в Jira нет или которые от него отличаются.

  1. 2026-11-12 — «давно закрытые» = то, что уже есть в v3.vsp, без загрузки архива. Обоснование: вопрос к заказчику в Jira; по умолчанию там же предложен этот вариант, человек его подтвердил выбором «делай по Jira». Цена ошибки: если заказчику нужен архив извне — отдельная задача на импорт, текущая работа не теряется.
  2. 2026-11-12 — ветка поверх AURORA-1885 (PR #328), не поверх 1886. Обоснование: 1883 зависит только от немедленного закрытия (1885); 1886 с миграцией 0062 ждёт стека #315–#323, ставить 1883 за ним — задержать без нужды. Цена: если 1886 и 1883 сольются в другом порядке — возможен текстовый конфликт в vsp_service.py (разные методы, маловероятен).
  3. 2026-11-12 — status→is_active чистой функцией vsp_status_to_is_active, VSPStatus в schemas.py (D1). Обоснование: один перевод для списка и выгрузки, покрывается unit-тестом. Цена: нет.
  4. 2026-11-12 — фильтр периода закрытия применяется при любом статусе на API, UI показывает поля только при «Закрытые»/«Все» (D2). Обоснование: действующий ВСП может иметь будущую дату закрытия; запрещать комбинацию на API незачем. Цена: нет.
  5. 2026-11-12 — nulls_last в сортировке закрытых, удаление мёртвого return в get_list (D3). Обоснование: страховка и чистка в том же методе. Цена: нет.
  6. 2026-11-12 — SelectVspModal не меняется (D7). Обоснование: он делает собственный запрос dropdown без include_inactive и уже получает только действующие; Jira предлагала фильтровать по is_active, что избыточно. Цена: если кто-то переключит модалку на контекст — закрытые попадут в выбор; закрыто тем, что фильтр стоит в VspDropdownEditCell, но не в модалке — отметить комментарием в коде модалки.
  7. 2026-11-12 — в VspDropdownEditCell текущий закрытый ВСП показывается подписью, но не выбирается (D7). Обоснование: иначе поле открылось бы пустым и создало впечатление потери значения. Цена: нет.
  8. 2026-11-12 — поля дат — TextField type="date", переключатель — ToggleButtonGroup (D6). Обоснование: @mui/x-date-pickers не объявлен в package.json (ARCHITECTURE.md, «Открытые вопросы»). Цена: вид поля даты — нативный браузерный.
  9. 2026-11-12 — колонка «Статус» в таблице справочника — после «Дата закрытия ВСП»; подписи бейджей «Действует» / «Закрыт» (как в Jira; цвета — как в SspTable). Цена: косметика.

Решения при исполнении (12.11.2026, executing-plans; маркеры R-n — в ledger и PR):

  1. 2026-11-12 — R-1: переключатель статуса виден всем ролям, включая EXECUTOR_RF (у него скрыт только выбор ССП). Обоснование: спека требует переключатель без оговорок по ролям; бэк ограничивает ССП исполнителя. Цена: если РФ не должен видеть закрытые — скрыть одну кнопку.
  2. 2026-11-12 — R-2: загрузка списка ВСП отделена от загрузки ССП (loadVspList, свой индикатор, устаревшие ответы отбрасываются счётчиком). Обоснование: иначе смена статуса перерисовывала страницу спиннером вместе с фильтрами. Цена: нет.
  3. 2026-11-12 — R-3: созданный ВСП, не подходящий под текущий статус, не добавляется в список (как при правке). Цена: нет.
  4. 2026-11-12 — R-4: закрытый текущий ВСП в редакторе ячейки — скрытый disabled пункт меню, чтобы Select показал подпись и MUI не ругался на значение вне списка. Цена: нет (оговорка по клавиатуре — M-5, отложено).
  5. 2026-11-12 — R-5: InputLabelProps → slotProps.inputLabel (MUI 9.1.1 убрал InputLabelProps; найдено ручной проверкой). Цена: нет.
  6. 2026-11-12 — R-6 (финальное ревью I-1): админ грузит все ССП; в «Действующих» архивные ССП скрыты, в «Закрытые»/«Все» — показаны в фильтре и выгрузке; окно создания/правки ВСП — только активные ССП. Обоснование: закрытые ВСП архивного ССП иначе видны, но не выгружаются. Цена: если архивных ССП с ВСП в данных нет — лишний пункт списка в режиме закрытых.