19 KiB
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.pyget_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.pyVSPExportRequest—registration_number,address,ssp_ids,open_date_start/end,placement_type,staff_count_min/max; статуса нет.VSPInDBуже отдаётis_activeиclose_date(aliasclosed_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); ~:450vspRegistrationByIdдля поиска/фильтра по колонке ВСП.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. Переключатель — MUIToggleButtonGroup(exclusive) «Действующие» / «Закрытые» / «Все» рядом с ССП. Поля дат —TextField type="date"«Закрыт с» / «по» (как в остальном коде справочника;@mui/x-date-pickersне объявлен в package.json — не использовать), видимы приstatus !== 'active'.DictVspPage.jsx: statestatus('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 нет или которые от него отличаются.
- 2026-11-12 — «давно закрытые» = то, что уже есть в
v3.vsp, без загрузки архива. Обоснование: вопрос к заказчику в Jira; по умолчанию там же предложен этот вариант, человек его подтвердил выбором «делай по Jira». Цена ошибки: если заказчику нужен архив извне — отдельная задача на импорт, текущая работа не теряется. - 2026-11-12 — ветка поверх AURORA-1885 (PR #328), не поверх 1886. Обоснование: 1883 зависит только от немедленного закрытия (1885); 1886 с миграцией 0062 ждёт стека #315–#323, ставить 1883 за ним — задержать без нужды. Цена: если 1886 и 1883 сольются в другом порядке — возможен текстовый конфликт в
vsp_service.py(разные методы, маловероятен). - 2026-11-12 —
status→is_activeчистой функциейvsp_status_to_is_active,VSPStatusв schemas.py (D1). Обоснование: один перевод для списка и выгрузки, покрывается unit-тестом. Цена: нет. - 2026-11-12 — фильтр периода закрытия применяется при любом статусе на API, UI показывает поля только при «Закрытые»/«Все» (D2). Обоснование: действующий ВСП может иметь будущую дату закрытия; запрещать комбинацию на API незачем. Цена: нет.
- 2026-11-12 —
nulls_lastв сортировке закрытых, удаление мёртвогоreturnвget_list(D3). Обоснование: страховка и чистка в том же методе. Цена: нет. - 2026-11-12 —
SelectVspModalне меняется (D7). Обоснование: он делает собственный запрос dropdown безinclude_inactiveи уже получает только действующие; Jira предлагала фильтровать поis_active, что избыточно. Цена: если кто-то переключит модалку на контекст — закрытые попадут в выбор; закрыто тем, что фильтр стоит вVspDropdownEditCell, но не в модалке — отметить комментарием в коде модалки. - 2026-11-12 — в
VspDropdownEditCellтекущий закрытый ВСП показывается подписью, но не выбирается (D7). Обоснование: иначе поле открылось бы пустым и создало впечатление потери значения. Цена: нет. - 2026-11-12 — поля дат —
TextField type="date", переключатель —ToggleButtonGroup(D6). Обоснование:@mui/x-date-pickersне объявлен в package.json (ARCHITECTURE.md, «Открытые вопросы»). Цена: вид поля даты — нативный браузерный. - 2026-11-12 — колонка «Статус» в таблице справочника — после «Дата закрытия ВСП»; подписи бейджей «Действует» / «Закрыт» (как в Jira; цвета — как в
SspTable). Цена: косметика.