From 5941207955e6d250a025efe0f013a18f8150f238 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Thu, 7 May 2026 11:19:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(records):=20bulk=20close=20=E2=80=94=20mul?= =?UTF-8?q?ti-select=20toolbar=20+=20per-record=20=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend POST /api/v1/dictionaries/{name}/records/bulk-close принимает businessKeys[] (1..500) + reason. BulkRecordService крутит цикл через inject'ed DictionaryRecordService — каждый close в своей транзакции (SERIALIZABLE), partial success возможен. Result: closed[] / skipped[] (already_closed, not_found) / errors[]. Frontend: checkbox column + sticky toolbar когда selection.size > 0. Header checkbox с indeterminate state для partial select. selectAll включает все ВИДИМЫЕ ключи (не игнорирует фильтры). При изменении фильтра — useEffect выкидывает невидимые ключи из selection (WYSIWYG). Bulk close dialog: count + reason input → confirm → результат панель "Закрыто N, пропущено M, ошибок K" с детальным списком skipped/errors. После успеха selection остаётся для skipped/errors — юзер видит что ещё не сделано. Лимит 500: защита от случайного огромного селекта или бага UI. Дедупликация внутри batch'а silent skip — не error. --- ordinis-admin-ui/src/api/client.ts | 12 + ordinis-admin-ui/src/api/mutations.ts | 22 ++ ordinis-admin-ui/src/i18n.ts | 30 ++ .../src/routes/dictionaries.$name.tsx | 343 ++++++++++++++++-- .../ordinis/restapi/dto/BulkCloseRequest.java | 24 ++ .../restapi/dto/BulkCloseResponse.java | 22 ++ .../restapi/service/BulkRecordService.java | 77 ++++ .../web/DictionaryRecordController.java | 19 + 8 files changed, 526 insertions(+), 23 deletions(-) create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkCloseRequest.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkCloseResponse.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordService.java diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 85629b1..519da70 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -113,6 +113,18 @@ export type CreateRecordRequest = { validTo?: string } +export type BulkCloseRequest = { + businessKeys: string[] + at?: string + reason?: string +} + +export type BulkCloseResponse = { + closed: string[] + skipped: { businessKey: string; reason: string }[] + errors: { businessKey: string; reason: string }[] +} + export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE' export type AuditEntry = { diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 509dfbf..de75e79 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -1,6 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient, + type BulkCloseRequest, + type BulkCloseResponse, type CreateDictionaryRequest, type CreateRecordRequest, type CreateWebhookSubscriptionRequest, @@ -101,6 +103,26 @@ export const useCloseRecord = (dictionaryName: string) => { }) } +/** + * Bulk close: закрыть N records одним запросом. Backend per-record транзакция, + * partial success возможен (already_closed / not_found попадает в skipped). + */ +export const useBulkCloseRecords = (dictionaryName: string) => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (req: BulkCloseRequest): Promise => { + const { data } = await apiClient.post( + `/dictionaries/${dictionaryName}/records/bulk-close`, + req, + ) + return data + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['records', dictionaryName] }) + }, + }) +} + // === Webhooks (v2) === export const useCreateWebhook = () => { diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 24b5fa9..1aeb358 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -145,6 +145,22 @@ i18n 'Запись «{{key}}» будет помечена как закрытая (valid_to = now). Действие обратимо через создание новой версии.', 'dict.confirmClose.reason': 'Причина (опционально)', 'dict.confirmClose.reasonPlaceholder': 'например, «дубликат» или «выведено из эксплуатации»', + 'dict.bulk.selectAll': 'Выбрать все на странице', + 'dict.bulk.selectRow': 'Выбрать запись', + 'dict.bulk.selectedCount_one': 'Выбрано {{count}} из {{total}}', + 'dict.bulk.selectedCount_few': 'Выбрано {{count}} из {{total}}', + 'dict.bulk.selectedCount_many': 'Выбрано {{count}} из {{total}}', + 'dict.bulk.selectedCount_other': 'Выбрано {{count}} из {{total}}', + 'dict.bulk.clearSelection': 'Снять выделение', + 'dict.bulk.closeAction': 'Закрыть выбранные', + 'dict.bulk.confirmTitle': 'Массовое закрытие записей', + 'dict.bulk.confirmBody': + '{{count}} запис(ей) будут помечены как закрытые (valid_to = now). Действие обратимо через создание новой версии. Каждая запись закрывается отдельно — если часть уже закрыта, остальные обработаются.', + 'dict.bulk.confirmAction': 'Закрыть {{count}}', + 'dict.bulk.resultClosed': 'Закрыто: {{count}}', + 'dict.bulk.resultSkipped': 'Пропущено (уже закрыто или не найдено): {{count}}', + 'dict.bulk.resultErrors': 'Ошибок: {{count}}', + 'dict.bulk.dismiss': 'Готово', 'form.identity': 'Идентификация', 'form.idSourceNote': 'Бизнес-ключ создастся автоматически из поля «{{field}}»', 'form.tabs.identity': 'Идентификация', @@ -410,6 +426,20 @@ i18n 'Record "{{key}}" will be marked as closed (valid_to = now). Reversible by creating a new version.', 'dict.confirmClose.reason': 'Reason (optional)', 'dict.confirmClose.reasonPlaceholder': 'e.g., "duplicate" or "decommissioned"', + 'dict.bulk.selectAll': 'Select all on page', + 'dict.bulk.selectRow': 'Select record', + 'dict.bulk.selectedCount_one': '{{count}} of {{total}} selected', + 'dict.bulk.selectedCount_other': '{{count}} of {{total}} selected', + 'dict.bulk.clearSelection': 'Clear selection', + 'dict.bulk.closeAction': 'Close selected', + 'dict.bulk.confirmTitle': 'Bulk close records', + 'dict.bulk.confirmBody': + '{{count}} record(s) will be marked as closed (valid_to = now). Reversible by creating a new version. Each record is closed individually — if some are already closed, others will still be processed.', + 'dict.bulk.confirmAction': 'Close {{count}}', + 'dict.bulk.resultClosed': 'Closed: {{count}}', + 'dict.bulk.resultSkipped': 'Skipped (already closed or not found): {{count}}', + 'dict.bulk.resultErrors': 'Errors: {{count}}', + 'dict.bulk.dismiss': 'Done', 'form.identity': 'Identity', 'form.idSourceNote': 'Business key auto-derived from "{{field}}" field', 'form.tabs.identity': 'Identity', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index cb5031d..56eccb2 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import axios from 'axios' @@ -6,6 +6,7 @@ import { Alert, Badge, Button, + Checkbox, EmptyState, IconButton, LoadingBlock, @@ -21,10 +22,10 @@ import { TableRow, TextInput, } from '@nstart/ui' -import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon } from '@phosphor-icons/react' +import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon } from '@phosphor-icons/react' import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries' -import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations' -import type { CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client' +import { useBulkCloseRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations' +import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client' import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' @@ -97,6 +98,7 @@ function DictionaryDetail() { const createMut = useCreateRecord(name) const updateMut = useUpdateRecord(name) const closeMut = useCloseRecord(name) + const bulkCloseMut = useBulkCloseRecords(name) const [edit, setEdit] = useState({ kind: 'closed' }) const [closeReason, setCloseReason] = useState('') @@ -104,6 +106,15 @@ function DictionaryDetail() { const [historyKey, setHistoryKey] = useState(undefined) const [aoiOpen, setAoiOpen] = useState(false) + // Multi-select state. Set — стабильное id'шник. При изменении + // records data (mutation success / refetch / filter change) — selection + // не очищаем автоматически, но выкидываем те ключи которых больше нет + // в видимом фильтре (избегаем "невидимых" выбранных строк). + const [selection, setSelection] = useState>(new Set()) + const [bulkOpen, setBulkOpen] = useState(false) + const [bulkReason, setBulkReason] = useState('') + const [bulkResult, setBulkResult] = useState(null) + // Search & scope filter — URL-derived. Empty/missing = все scope активны. const search = urlSearch.q ?? '' const scopeFilter = useMemo>( @@ -178,6 +189,85 @@ function DictionaryDetail() { const filtersActive = search.length > 0 || scopeFilter.size > 0 const filteredCount = filteredRecords.length + // Каноничный список видимых ключей — для select-all и для очистки selection + // от ключей которые больше не видны (filter изменился, запись закрылась). + const visibleKeys = useMemo( + () => filteredRecords.map((r) => r.businessKey), + [filteredRecords], + ) + + // Когда видимый набор сократился — выкидываем из selection невидимые ключи. + // Иначе bulk close может пытаться закрыть запись которую юзер уже не видит, + // что нарушает "WYSIWYG" принцип multi-select toolbar'а. + useEffect(() => { + setSelection((prev) => { + if (prev.size === 0) return prev + const visibleSet = new Set(visibleKeys) + const next = new Set() + for (const k of prev) if (visibleSet.has(k)) next.add(k) + return next.size === prev.size ? prev : next + }) + }, [visibleKeys]) + + const allVisibleSelected = + visibleKeys.length > 0 && visibleKeys.every((k) => selection.has(k)) + const someVisibleSelected = + !allVisibleSelected && visibleKeys.some((k) => selection.has(k)) + + const toggleSelect = (key: string) => { + setSelection((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + + const toggleSelectAll = () => { + setSelection((prev) => { + if (visibleKeys.every((k) => prev.has(k))) { + // Все видимые выбраны → снимаем именно их (не трогаем невидимые, + // если такие были; на практике их не должно быть благодаря useEffect) + const next = new Set(prev) + for (const k of visibleKeys) next.delete(k) + return next + } + const next = new Set(prev) + for (const k of visibleKeys) next.add(k) + return next + }) + } + + const clearSelection = () => setSelection(new Set()) + + const openBulkClose = () => { + setBulkResult(null) + setBulkReason('') + setBulkOpen(true) + } + + const handleBulkClose = () => { + if (selection.size === 0) return + bulkCloseMut.mutate( + { + businessKeys: Array.from(selection), + reason: bulkReason || undefined, + }, + { + onSuccess: (res) => { + setBulkResult(res) + // Снимаем selection с успешно закрытых; оставляем skipped/errors + // чтобы юзер видел что ещё не сделано. + setSelection((prev) => { + const next = new Set(prev) + for (const k of res.closed) next.delete(k) + return next + }) + }, + }, + ) + } + // Schema-driven extra columns: enum + reference поля — самые info-dense // не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить // строку (с businessKey + name + scope + extras + дата + actions = 6 @@ -383,23 +473,50 @@ function DictionaryDetail() { {filteredRecords.length === 0 ? ( ) : ( - - - - - {t('dict.col.businessKey')} - name / code - {extraColumns.map((c) => ( - {c.label} - ))} - {t('dict.col.scope')} - {t('dict.col.validFrom')} - {t('dict.col.actions')} - - - + <> + {selection.size > 0 && ( + + )} + +
+ + + + { + if (el) el.indeterminate = someVisibleSelected + }} + onChange={toggleSelectAll} + /> + + {t('dict.col.businessKey')} + name / code + {extraColumns.map((c) => ( + {c.label} + ))} + {t('dict.col.scope')} + {t('dict.col.validFrom')} + {t('dict.col.actions')} + + + {filteredRecords.map((r) => ( - + + + toggleSelect(r.businessKey)} + /> + {r.businessKey} @@ -466,9 +583,10 @@ function DictionaryDetail() { ))} - -
-
+ + + + )} )} @@ -602,6 +720,185 @@ function DictionaryDetail() { onApply={handleAoiApply} initial={aoi?.geojson ?? null} /> + + { + setBulkOpen(false) + setBulkResult(null) + setBulkReason('') + }} + title={t('dict.bulk.confirmTitle')} + maxWidth="max-w-lg" + > + { + setBulkOpen(false) + setBulkResult(null) + setBulkReason('') + }} + isPending={bulkCloseMut.isPending} + serverError={serverErrorMessage(bulkCloseMut.error)} + result={bulkResult} + /> + + + ) +} + +type BulkSelectionToolbarProps = { + count: number + totalVisible: number + onBulkClose: () => void + onClear: () => void +} + +function BulkSelectionToolbar({ + count, + totalVisible, + onBulkClose, + onClear, +}: BulkSelectionToolbarProps) { + const { t } = useTranslation() + return ( +
+
+ + {t('dict.bulk.selectedCount', { count, total: totalVisible })} + + +
+ +
+ ) +} + +type BulkCloseDialogProps = { + count: number + reason: string + onReasonChange: (s: string) => void + onSubmit: () => void + onClose: () => void + isPending: boolean + serverError: string | null + result: BulkCloseResponse | null +} + +function BulkCloseDialog({ + count, + reason, + onReasonChange, + onSubmit, + onClose, + isPending, + serverError, + result, +}: BulkCloseDialogProps) { + const { t } = useTranslation() + + // После успеха — показываем сводку; вход на confirm форму прячем + if (result) { + return ( +
+
+ {result.closed.length > 0 && ( +
+ + {t('dict.bulk.resultClosed', { count: result.closed.length })} +
+ )} + {result.skipped.length > 0 && ( +
+ + {t('dict.bulk.resultSkipped', { count: result.skipped.length })} +
+ )} + {result.errors.length > 0 && ( +
+ + {t('dict.bulk.resultErrors', { count: result.errors.length })} +
+ )} +
+ + {(result.skipped.length > 0 || result.errors.length > 0) && ( +
+
    + {result.skipped.map((s) => ( +
  • + · {s.businessKey} — {s.reason} +
  • + ))} + {result.errors.map((e) => ( +
  • + ! {e.businessKey} — {e.reason} +
  • + ))} +
+
+ )} + +
+ +
+
+ ) + } + + return ( +
+

{t('dict.bulk.confirmBody', { count })}

+ onReasonChange(e.target.value)} + /> + {serverError && {serverError}} +
+ + +
) } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkCloseRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkCloseRequest.java new file mode 100644 index 0000000..f55b7c3 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkCloseRequest.java @@ -0,0 +1,24 @@ +package cloud.nstart.terravault.ordinis.restapi.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * Bulk close: закрыть N записей одним запросом. Партиционирование per-record: + * если 3 из 10 уже закрыты или не существуют — другие 7 закроются. + * + *

Лимит 500 — защита от случайного UI-bug или копи-паст огромного списка. + * + * @param businessKeys список business_key для закрытия (1..500) + * @param at момент close (default now) + * @param reason бизнес-причина (опционально, идёт в audit log + outbox event) + */ +public record BulkCloseRequest( + @NotEmpty + @Size(max = 500, message = "bulk close принимает максимум 500 записей за запрос") + List businessKeys, + OffsetDateTime at, + String reason) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkCloseResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkCloseResponse.java new file mode 100644 index 0000000..e53650a --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkCloseResponse.java @@ -0,0 +1,22 @@ +package cloud.nstart.terravault.ordinis.restapi.dto; + +import java.util.List; + +/** + * Результат bulk close: per-record success/skip/error. Frontend показывает + * сводку "закрыто 7 из 10, 3 пропущено". + * + * @param closed успешно закрытые business_key + * @param skipped business_key которые уже неактивны (already_closed) или + * не существуют (not_found) — не error, ожидаемая ситуация при + * повторном клике или одновременной работе двух админов + * @param errors неожиданные ошибки (DB exception, validation fail) с reason + */ +public record BulkCloseResponse( + List closed, + List skipped, + List errors) { + + public record SkippedItem(String businessKey, String reason) {} + public record ErrorItem(String businessKey, String reason) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordService.java new file mode 100644 index 0000000..1dc9700 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordService.java @@ -0,0 +1,77 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseRequest; +import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseResponse; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +/** + * Bulk операции над records. Каждый record закрывается в своей транзакции + * (через инжект {@link DictionaryRecordService}, чтобы Spring AOP proxy + * подхватил {@code @Transactional} на каждом вызове). Если 3 из 10 уже + * закрыты — остальные 7 закроются. Atomic guarantee только per-record, + * не на весь bulk. + * + *

Не @Transactional само по себе — иначе единственная транзакция накроет + * весь цикл и любая ошибка откатит всё, что мы НЕ хотим. + */ +@Service +public class BulkRecordService { + + private static final Logger log = LoggerFactory.getLogger(BulkRecordService.class); + + private final DictionaryRecordService recordService; + + public BulkRecordService(DictionaryRecordService recordService) { + this.recordService = recordService; + } + + /** + * Закрыть N records по business_key. Дубликаты в списке дедуплицируются. + * Empty/blank business_key пропускается (попадает в skipped с reason=invalid_key). + */ + public BulkCloseResponse bulkClose(String dictionaryName, BulkCloseRequest req) { + List closed = new ArrayList<>(); + List skipped = new ArrayList<>(); + List errors = new ArrayList<>(); + + // Дедуп: если admin кликнул bulk close на одном records'е дважды — + // не пытаться закрыть его повторно во второй итерации. + var seen = new java.util.LinkedHashSet(); + for (String bk : req.businessKeys()) { + if (bk == null || bk.isBlank()) { + skipped.add(new BulkCloseResponse.SkippedItem(String.valueOf(bk), "invalid_key")); + continue; + } + if (!seen.add(bk)) { + // Дубликат внутри batch — silently skip, не error + continue; + } + + try { + recordService.close(dictionaryName, bk, req.at(), req.reason()); + closed.add(bk); + } catch (OrdinisException e) { + // record_not_active = повторный close или race с другим админом + if ("record_not_active".equals(e.getCode())) { + skipped.add(new BulkCloseResponse.SkippedItem(bk, "already_closed")); + } else { + errors.add(new BulkCloseResponse.ErrorItem(bk, e.getCode() + ": " + e.getMessage())); + } + } catch (Exception e) { + log.warn("Bulk close: unexpected error для business_key={} в словаре {}", bk, dictionaryName, e); + errors.add(new BulkCloseResponse.ErrorItem(bk, e.getClass().getSimpleName() + ": " + e.getMessage())); + } + } + + log.info("Bulk close в {} : closed={} skipped={} errors={}", + dictionaryName, closed.size(), skipped.size(), errors.size()); + + return new BulkCloseResponse(closed, skipped, errors); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java index 504b75e..86ebaea 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java @@ -2,9 +2,12 @@ package cloud.nstart.terravault.ordinis.restapi.web; import cloud.nstart.terravault.ordinis.auth.ScopeContext; import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseRequest; +import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseResponse; import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest; import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.restapi.service.BulkRecordService; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService; import jakarta.validation.Valid; @@ -28,14 +31,17 @@ import java.util.List; public class DictionaryRecordController { private final DictionaryRecordService service; + private final BulkRecordService bulkService; private final DictionaryDefinitionService definitionService; private final ScopeContext scopeContext; public DictionaryRecordController( DictionaryRecordService service, + BulkRecordService bulkService, DictionaryDefinitionService definitionService, ScopeContext scopeContext) { this.service = service; + this.bulkService = bulkService; this.definitionService = definitionService; this.scopeContext = scopeContext; } @@ -91,6 +97,19 @@ public class DictionaryRecordController { service.close(name, businessKey, at, reason); } + /** + * Bulk close: закрыть N записей одним запросом. Per-record транзакция — + * partial success возможен (e.g. 7 из 10 закрылись, 3 уже были закрыты). + * Используется multi-select toolbar в admin UI. + */ + @PostMapping("/bulk-close") + public BulkCloseResponse bulkClose( + @PathVariable String name, + @Valid @RequestBody BulkCloseRequest req) { + requireWriteAccess(name); + return bulkService.bulkClose(name, req); + } + /** * 404 если scope словаря недоступен текущему пользователю — намеренно скрываем * существование записи (security through obscurity), как делает read-api.