feat(records): bulk close — multi-select toolbar + per-record транзакция

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.
This commit is contained in:
Zimin A.N.
2026-05-07 11:19:57 +03:00
parent 92507ab0a1
commit 5941207955
8 changed files with 526 additions and 23 deletions
+12
View File
@@ -113,6 +113,18 @@ export type CreateRecordRequest = {
validTo?: string 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 AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE'
export type AuditEntry = { export type AuditEntry = {
+22
View File
@@ -1,6 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { import {
apiClient, apiClient,
type BulkCloseRequest,
type BulkCloseResponse,
type CreateDictionaryRequest, type CreateDictionaryRequest,
type CreateRecordRequest, type CreateRecordRequest,
type CreateWebhookSubscriptionRequest, 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<BulkCloseResponse> => {
const { data } = await apiClient.post<BulkCloseResponse>(
`/dictionaries/${dictionaryName}/records/bulk-close`,
req,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
},
})
}
// === Webhooks (v2) === // === Webhooks (v2) ===
export const useCreateWebhook = () => { export const useCreateWebhook = () => {
+30
View File
@@ -145,6 +145,22 @@ i18n
'Запись «{{key}}» будет помечена как закрытая (valid_to = now). Действие обратимо через создание новой версии.', 'Запись «{{key}}» будет помечена как закрытая (valid_to = now). Действие обратимо через создание новой версии.',
'dict.confirmClose.reason': 'Причина (опционально)', 'dict.confirmClose.reason': 'Причина (опционально)',
'dict.confirmClose.reasonPlaceholder': 'например, «дубликат» или «выведено из эксплуатации»', '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.identity': 'Идентификация',
'form.idSourceNote': 'Бизнес-ключ создастся автоматически из поля «{{field}}»', 'form.idSourceNote': 'Бизнес-ключ создастся автоматически из поля «{{field}}»',
'form.tabs.identity': 'Идентификация', 'form.tabs.identity': 'Идентификация',
@@ -410,6 +426,20 @@ i18n
'Record "{{key}}" will be marked as closed (valid_to = now). Reversible by creating a new version.', 'Record "{{key}}" will be marked as closed (valid_to = now). Reversible by creating a new version.',
'dict.confirmClose.reason': 'Reason (optional)', 'dict.confirmClose.reason': 'Reason (optional)',
'dict.confirmClose.reasonPlaceholder': 'e.g., "duplicate" or "decommissioned"', '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.identity': 'Identity',
'form.idSourceNote': 'Business key auto-derived from "{{field}}" field', 'form.idSourceNote': 'Business key auto-derived from "{{field}}" field',
'form.tabs.identity': 'Identity', 'form.tabs.identity': 'Identity',
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import axios from 'axios' import axios from 'axios'
@@ -6,6 +6,7 @@ import {
Alert, Alert,
Badge, Badge,
Button, Button,
Checkbox,
EmptyState, EmptyState,
IconButton, IconButton,
LoadingBlock, LoadingBlock,
@@ -21,10 +22,10 @@ import {
TableRow, TableRow,
TextInput, TextInput,
} from '@nstart/ui' } 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 { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries'
import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations' import { useBulkCloseRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
import type { CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client' import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm' import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer' import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
@@ -97,6 +98,7 @@ function DictionaryDetail() {
const createMut = useCreateRecord(name) const createMut = useCreateRecord(name)
const updateMut = useUpdateRecord(name) const updateMut = useUpdateRecord(name)
const closeMut = useCloseRecord(name) const closeMut = useCloseRecord(name)
const bulkCloseMut = useBulkCloseRecords(name)
const [edit, setEdit] = useState<EditState>({ kind: 'closed' }) const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
const [closeReason, setCloseReason] = useState('') const [closeReason, setCloseReason] = useState('')
@@ -104,6 +106,15 @@ function DictionaryDetail() {
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined) const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false) const [aoiOpen, setAoiOpen] = useState(false)
// Multi-select state. Set<businessKey> — стабильное id'шник. При изменении
// records data (mutation success / refetch / filter change) — selection
// не очищаем автоматически, но выкидываем те ключи которых больше нет
// в видимом фильтре (избегаем "невидимых" выбранных строк).
const [selection, setSelection] = useState<Set<string>>(new Set())
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkReason, setBulkReason] = useState('')
const [bulkResult, setBulkResult] = useState<BulkCloseResponse | null>(null)
// Search & scope filter — URL-derived. Empty/missing = все scope активны. // Search & scope filter — URL-derived. Empty/missing = все scope активны.
const search = urlSearch.q ?? '' const search = urlSearch.q ?? ''
const scopeFilter = useMemo<Set<DataScope>>( const scopeFilter = useMemo<Set<DataScope>>(
@@ -178,6 +189,85 @@ function DictionaryDetail() {
const filtersActive = search.length > 0 || scopeFilter.size > 0 const filtersActive = search.length > 0 || scopeFilter.size > 0
const filteredCount = filteredRecords.length 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<string>()
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 // Schema-driven extra columns: enum + reference поля — самые info-dense
// не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить // не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить
// строку (с businessKey + name + scope + extras + дата + actions = 6 // строку (с businessKey + name + scope + extras + дата + actions = 6
@@ -383,23 +473,50 @@ function DictionaryDetail() {
{filteredRecords.length === 0 ? ( {filteredRecords.length === 0 ? (
<EmptyState title={t('dict.filter.noMatches')} /> <EmptyState title={t('dict.filter.noMatches')} />
) : ( ) : (
<Panel> <>
<Table> {selection.size > 0 && (
<TableHead> <BulkSelectionToolbar
<TableRow> count={selection.size}
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell> totalVisible={filteredCount}
<TableHeaderCell>name / code</TableHeaderCell> onBulkClose={openBulkClose}
{extraColumns.map((c) => ( onClear={clearSelection}
<TableHeaderCell key={c.key}>{c.label}</TableHeaderCell> />
))} )}
<TableHeaderCell>{t('dict.col.scope')}</TableHeaderCell> <Panel>
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell> <Table>
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell> <TableHead>
</TableRow> <TableRow>
</TableHead> <TableHeaderCell>
<TableBody> <Checkbox
aria-label={t('dict.bulk.selectAll')}
checked={allVisibleSelected}
// Native checkbox indeterminate через ref — для partial select
ref={(el) => {
if (el) el.indeterminate = someVisibleSelected
}}
onChange={toggleSelectAll}
/>
</TableHeaderCell>
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
<TableHeaderCell>name / code</TableHeaderCell>
{extraColumns.map((c) => (
<TableHeaderCell key={c.key}>{c.label}</TableHeaderCell>
))}
<TableHeaderCell>{t('dict.col.scope')}</TableHeaderCell>
<TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell>
<TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{filteredRecords.map((r) => ( {filteredRecords.map((r) => (
<TableRow key={r.id}> <TableRow key={r.id} data-selected={selection.has(r.businessKey)}>
<TableCell>
<Checkbox
aria-label={`${t('dict.bulk.selectRow')} ${r.businessKey}`}
checked={selection.has(r.businessKey)}
onChange={() => toggleSelect(r.businessKey)}
/>
</TableCell>
<TableCell> <TableCell>
<span className="font-mono text-2xs">{r.businessKey}</span> <span className="font-mono text-2xs">{r.businessKey}</span>
</TableCell> </TableCell>
@@ -466,9 +583,10 @@ function DictionaryDetail() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</Panel> </Panel>
</>
)} )}
</> </>
)} )}
@@ -602,6 +720,185 @@ function DictionaryDetail() {
onApply={handleAoiApply} onApply={handleAoiApply}
initial={aoi?.geojson ?? null} initial={aoi?.geojson ?? null}
/> />
<Modal
isOpen={bulkOpen}
onClose={() => {
setBulkOpen(false)
setBulkResult(null)
setBulkReason('')
}}
title={t('dict.bulk.confirmTitle')}
maxWidth="max-w-lg"
>
<BulkCloseDialog
count={selection.size}
reason={bulkReason}
onReasonChange={setBulkReason}
onSubmit={handleBulkClose}
onClose={() => {
setBulkOpen(false)
setBulkResult(null)
setBulkReason('')
}}
isPending={bulkCloseMut.isPending}
serverError={serverErrorMessage(bulkCloseMut.error)}
result={bulkResult}
/>
</Modal>
</div>
)
}
type BulkSelectionToolbarProps = {
count: number
totalVisible: number
onBulkClose: () => void
onClear: () => void
}
function BulkSelectionToolbar({
count,
totalVisible,
onBulkClose,
onClear,
}: BulkSelectionToolbarProps) {
const { t } = useTranslation()
return (
<div
className={[
'sticky top-0 z-10 -mx-1 flex items-center justify-between gap-3',
'bg-ultramarain text-white rounded-md shadow-sm px-4 py-2',
].join(' ')}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium tabular-nums">
{t('dict.bulk.selectedCount', { count, total: totalVisible })}
</span>
<button
type="button"
onClick={onClear}
className="inline-flex items-center gap-1 text-2xs underline-offset-2 hover:underline opacity-80 hover:opacity-100"
>
<XIcon size={12} weight="bold" />
{t('dict.bulk.clearSelection')}
</button>
</div>
<Button
type="button"
variant="danger"
leftIcon={<TrashIcon weight="bold" size={14} />}
onClick={onBulkClose}
>
{t('dict.bulk.closeAction')}
</Button>
</div>
)
}
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 (
<div className="space-y-4">
<div className="space-y-2">
{result.closed.length > 0 && (
<div className="flex items-center gap-2 text-sm text-soyuz-green">
<CheckCircleIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultClosed', { count: result.closed.length })}</span>
</div>
)}
{result.skipped.length > 0 && (
<div className="flex items-center gap-2 text-sm text-saturn">
<WarningIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultSkipped', { count: result.skipped.length })}</span>
</div>
)}
{result.errors.length > 0 && (
<div className="flex items-center gap-2 text-sm text-mars">
<XCircleIcon size={18} weight="fill" />
<span>{t('dict.bulk.resultErrors', { count: result.errors.length })}</span>
</div>
)}
</div>
{(result.skipped.length > 0 || result.errors.length > 0) && (
<div className="border border-regolith rounded p-3 max-h-48 overflow-auto bg-regolith/30">
<ul className="text-2xs font-mono space-y-1">
{result.skipped.map((s) => (
<li key={`s-${s.businessKey}`} className="text-saturn">
· {s.businessKey} {s.reason}
</li>
))}
{result.errors.map((e) => (
<li key={`e-${e.businessKey}`} className="text-mars">
! {e.businessKey} {e.reason}
</li>
))}
</ul>
</div>
)}
<div className="flex justify-end pt-2 border-t border-regolith">
<Button type="button" variant="primary" onClick={onClose}>
{t('dict.bulk.dismiss')}
</Button>
</div>
</div>
)
}
return (
<div className="space-y-4">
<p className="text-sm text-carbon">{t('dict.bulk.confirmBody', { count })}</p>
<TextInput
label={t('dict.confirmClose.reason')}
placeholder={t('dict.confirmClose.reasonPlaceholder')}
value={reason}
onChange={(e) => onReasonChange(e.target.value)}
/>
{serverError && <Alert variant="error">{serverError}</Alert>}
<div className="flex justify-end gap-2 pt-2 border-t border-regolith">
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={onClose}
>
{t('form.cancel')}
</Button>
<Button
type="button"
variant="danger"
loading={isPending}
onClick={onSubmit}
leftIcon={<TrashIcon weight="bold" size={14} />}
>
{t('dict.bulk.confirmAction', { count })}
</Button>
</div>
</div> </div>
) )
} }
@@ -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 закроются.
*
* <p>Лимит 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<String> businessKeys,
OffsetDateTime at,
String reason) {}
@@ -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<String> closed,
List<SkippedItem> skipped,
List<ErrorItem> errors) {
public record SkippedItem(String businessKey, String reason) {}
public record ErrorItem(String businessKey, String reason) {}
}
@@ -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.
*
* <p>Не @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<String> closed = new ArrayList<>();
List<BulkCloseResponse.SkippedItem> skipped = new ArrayList<>();
List<BulkCloseResponse.ErrorItem> errors = new ArrayList<>();
// Дедуп: если admin кликнул bulk close на одном records'е дважды
// не пытаться закрыть его повторно во второй итерации.
var seen = new java.util.LinkedHashSet<String>();
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);
}
}
@@ -2,9 +2,12 @@ package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.auth.ScopeContext; import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope; 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.CreateRecordRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse; import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; 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.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -28,14 +31,17 @@ import java.util.List;
public class DictionaryRecordController { public class DictionaryRecordController {
private final DictionaryRecordService service; private final DictionaryRecordService service;
private final BulkRecordService bulkService;
private final DictionaryDefinitionService definitionService; private final DictionaryDefinitionService definitionService;
private final ScopeContext scopeContext; private final ScopeContext scopeContext;
public DictionaryRecordController( public DictionaryRecordController(
DictionaryRecordService service, DictionaryRecordService service,
BulkRecordService bulkService,
DictionaryDefinitionService definitionService, DictionaryDefinitionService definitionService,
ScopeContext scopeContext) { ScopeContext scopeContext) {
this.service = service; this.service = service;
this.bulkService = bulkService;
this.definitionService = definitionService; this.definitionService = definitionService;
this.scopeContext = scopeContext; this.scopeContext = scopeContext;
} }
@@ -91,6 +97,19 @@ public class DictionaryRecordController {
service.close(name, businessKey, at, reason); 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 словаря недоступен текущему пользователю намеренно скрываем * 404 если scope словаря недоступен текущему пользователю намеренно скрываем
* существование записи (security through obscurity), как делает read-api. * существование записи (security through obscurity), как делает read-api.