feat(records): bulk export CSV выбранных + pagination
Backend POST /api/v1/dictionaries/{name}/records/export-selected.csv:
принимает businessKeys[] (1..500), возвращает CSV с колонками
business_key, valid_from, valid_to, scope, data (JSONB inline).
Не-найденные ключи отдаются строкой с пустыми ячейками — admin видит
что ключ обработан, но данных нет. RFC 4180 экранирование.
requireReadAccess: для экспорта достаточно прав чтения (не write).
POST а не GET потому что >100 ключей не помещается в URL params
(Server: Request URI Too Long).
Frontend useBulkExportRecords: axios POST с responseType: blob →
temporary <a download> link → клик → revokeObjectURL через rAF.
Filename берётся из Content-Disposition header.
Toolbar: новая кнопка "Экспорт CSV" (variant=secondary) рядом с
"Закрыть выбранные" (variant=danger). Loading state через isPending.
Pagination для records list (PAGE_SIZE=50, client-side):
- visibleRecords = filteredRecords.slice(...)
- Footer "Записи 1–50 из 100, Стр. 1 из 2" с Prev/Next кнопками
- Auto-reset на page=0 при изменении filter/scope/AOI
- selectAll/visibleKeys теперь = только current page (WYSIWYG)
- При >50 записей видно навигацию, при <=50 footer скрыт
Прогон: 20/20 e2e + 60/60 rest-api + 89/89 admin-ui.
This commit is contained in:
@@ -123,6 +123,41 @@ export const useBulkCloseRecords = (dictionaryName: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk export selected: выгрузить N выбранных записей в CSV. Backend
|
||||||
|
* возвращает text/csv blob с auth header, фронтенд триггерит download
|
||||||
|
* через temporary `<a>` link.
|
||||||
|
*
|
||||||
|
* POST (не GET): >100 ключей не помещается в URL params.
|
||||||
|
*/
|
||||||
|
export const useBulkExportRecords = (dictionaryName: string) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (businessKeys: string[]): Promise<void> => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/dictionaries/${dictionaryName}/records/export-selected.csv`,
|
||||||
|
{ businessKeys },
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
)
|
||||||
|
// Извлекаем filename из Content-Disposition если есть
|
||||||
|
const cd = response.headers['content-disposition'] as string | undefined
|
||||||
|
const match = cd?.match(/filename="?([^";]+)"?/)
|
||||||
|
const filename = match?.[1]
|
||||||
|
?? `${dictionaryName}-selected-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
|
||||||
|
// Trigger browser download через blob URL + temporary <a download>
|
||||||
|
const url = window.URL.createObjectURL(response.data as Blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
// Через rAF чтобы download успел запуститься до revoke
|
||||||
|
requestAnimationFrame(() => window.URL.revokeObjectURL(url))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// === Webhooks (v2) ===
|
// === Webhooks (v2) ===
|
||||||
|
|
||||||
export const useCreateWebhook = () => {
|
export const useCreateWebhook = () => {
|
||||||
|
|||||||
@@ -153,6 +153,11 @@ i18n
|
|||||||
'dict.bulk.selectedCount_other': 'Выбрано {{count}} из {{total}}',
|
'dict.bulk.selectedCount_other': 'Выбрано {{count}} из {{total}}',
|
||||||
'dict.bulk.clearSelection': 'Снять выделение',
|
'dict.bulk.clearSelection': 'Снять выделение',
|
||||||
'dict.bulk.closeAction': 'Закрыть выбранные',
|
'dict.bulk.closeAction': 'Закрыть выбранные',
|
||||||
|
'dict.bulk.exportAction': 'Экспорт CSV',
|
||||||
|
'dict.page.prev': 'Назад',
|
||||||
|
'dict.page.next': 'Вперёд',
|
||||||
|
'dict.page.of': 'Стр. {{cur}} из {{total}}',
|
||||||
|
'dict.page.range': 'Записи {{from}}–{{to}} из {{total}}',
|
||||||
'dict.bulk.confirmTitle': 'Массовое закрытие записей',
|
'dict.bulk.confirmTitle': 'Массовое закрытие записей',
|
||||||
'dict.bulk.confirmBody':
|
'dict.bulk.confirmBody':
|
||||||
'{{count}} запис(ей) будут помечены как закрытые (valid_to = now). Действие обратимо через создание новой версии. Каждая запись закрывается отдельно — если часть уже закрыта, остальные обработаются.',
|
'{{count}} запис(ей) будут помечены как закрытые (valid_to = now). Действие обратимо через создание новой версии. Каждая запись закрывается отдельно — если часть уже закрыта, остальные обработаются.',
|
||||||
@@ -432,6 +437,11 @@ i18n
|
|||||||
'dict.bulk.selectedCount_other': '{{count}} of {{total}} selected',
|
'dict.bulk.selectedCount_other': '{{count}} of {{total}} selected',
|
||||||
'dict.bulk.clearSelection': 'Clear selection',
|
'dict.bulk.clearSelection': 'Clear selection',
|
||||||
'dict.bulk.closeAction': 'Close selected',
|
'dict.bulk.closeAction': 'Close selected',
|
||||||
|
'dict.bulk.exportAction': 'Export CSV',
|
||||||
|
'dict.page.prev': 'Prev',
|
||||||
|
'dict.page.next': 'Next',
|
||||||
|
'dict.page.of': 'Page {{cur}} of {{total}}',
|
||||||
|
'dict.page.range': 'Records {{from}}–{{to}} of {{total}}',
|
||||||
'dict.bulk.confirmTitle': 'Bulk close records',
|
'dict.bulk.confirmTitle': 'Bulk close records',
|
||||||
'dict.bulk.confirmBody':
|
'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.',
|
'{{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.',
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@nstart/ui'
|
} from '@nstart/ui'
|
||||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon } from '@phosphor-icons/react'
|
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
|
||||||
import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries'
|
import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries'
|
||||||
import { useBulkCloseRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
||||||
import type { BulkCloseResponse, 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'
|
||||||
@@ -99,6 +99,7 @@ function DictionaryDetail() {
|
|||||||
const updateMut = useUpdateRecord(name)
|
const updateMut = useUpdateRecord(name)
|
||||||
const closeMut = useCloseRecord(name)
|
const closeMut = useCloseRecord(name)
|
||||||
const bulkCloseMut = useBulkCloseRecords(name)
|
const bulkCloseMut = useBulkCloseRecords(name)
|
||||||
|
const bulkExportMut = useBulkExportRecords(name)
|
||||||
|
|
||||||
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
||||||
const [closeReason, setCloseReason] = useState('')
|
const [closeReason, setCloseReason] = useState('')
|
||||||
@@ -189,11 +190,30 @@ 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
|
||||||
|
|
||||||
|
// Client-side pagination — типичный словарь до 100 записей, server-side
|
||||||
|
// пейджинг overkill. Page 0-indexed. PAGE_SIZE = 50 — достаточно чтобы
|
||||||
|
// увидеть все основные dictionaries без листания.
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredCount / PAGE_SIZE))
|
||||||
|
|
||||||
|
// При изменении filter — на page 0 (иначе page может оказаться вне диапазона)
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0)
|
||||||
|
}, [search, scopeFilter, aoi])
|
||||||
|
|
||||||
|
const visibleRecords = useMemo(
|
||||||
|
() => filteredRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
|
||||||
|
[filteredRecords, page],
|
||||||
|
)
|
||||||
|
|
||||||
// Каноничный список видимых ключей — для select-all и для очистки selection
|
// Каноничный список видимых ключей — для select-all и для очистки selection
|
||||||
// от ключей которые больше не видны (filter изменился, запись закрылась).
|
// от ключей которые больше не видны (filter изменился, запись закрылась).
|
||||||
|
// С client-side pagination "видимые" = только текущая страница, чтобы
|
||||||
|
// select-all чекбокс трогал ровно то что юзер видит на экране.
|
||||||
const visibleKeys = useMemo(
|
const visibleKeys = useMemo(
|
||||||
() => filteredRecords.map((r) => r.businessKey),
|
() => visibleRecords.map((r) => r.businessKey),
|
||||||
[filteredRecords],
|
[visibleRecords],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Когда видимый набор сократился — выкидываем из selection невидимые ключи.
|
// Когда видимый набор сократился — выкидываем из selection невидимые ключи.
|
||||||
@@ -246,6 +266,11 @@ function DictionaryDetail() {
|
|||||||
setBulkOpen(true)
|
setBulkOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBulkExport = () => {
|
||||||
|
if (selection.size === 0) return
|
||||||
|
bulkExportMut.mutate(Array.from(selection))
|
||||||
|
}
|
||||||
|
|
||||||
const handleBulkClose = () => {
|
const handleBulkClose = () => {
|
||||||
if (selection.size === 0) return
|
if (selection.size === 0) return
|
||||||
bulkCloseMut.mutate(
|
bulkCloseMut.mutate(
|
||||||
@@ -479,7 +504,9 @@ function DictionaryDetail() {
|
|||||||
count={selection.size}
|
count={selection.size}
|
||||||
totalVisible={filteredCount}
|
totalVisible={filteredCount}
|
||||||
onBulkClose={openBulkClose}
|
onBulkClose={openBulkClose}
|
||||||
|
onBulkExport={handleBulkExport}
|
||||||
onClear={clearSelection}
|
onClear={clearSelection}
|
||||||
|
isExporting={bulkExportMut.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Panel>
|
<Panel>
|
||||||
@@ -508,7 +535,7 @@ function DictionaryDetail() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredRecords.map((r) => (
|
{visibleRecords.map((r) => (
|
||||||
<TableRow key={r.id} data-selected={selection.has(r.businessKey)}>
|
<TableRow key={r.id} data-selected={selection.has(r.businessKey)}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -586,6 +613,16 @@ function DictionaryDetail() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<RecordsPagination
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalCount={filteredCount}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPrev={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -754,14 +791,18 @@ type BulkSelectionToolbarProps = {
|
|||||||
count: number
|
count: number
|
||||||
totalVisible: number
|
totalVisible: number
|
||||||
onBulkClose: () => void
|
onBulkClose: () => void
|
||||||
|
onBulkExport: () => void
|
||||||
onClear: () => void
|
onClear: () => void
|
||||||
|
isExporting: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function BulkSelectionToolbar({
|
function BulkSelectionToolbar({
|
||||||
count,
|
count,
|
||||||
totalVisible,
|
totalVisible,
|
||||||
onBulkClose,
|
onBulkClose,
|
||||||
|
onBulkExport,
|
||||||
onClear,
|
onClear,
|
||||||
|
isExporting,
|
||||||
}: BulkSelectionToolbarProps) {
|
}: BulkSelectionToolbarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
@@ -784,14 +825,77 @@ function BulkSelectionToolbar({
|
|||||||
{t('dict.bulk.clearSelection')}
|
{t('dict.bulk.clearSelection')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<Button
|
||||||
variant="danger"
|
type="button"
|
||||||
leftIcon={<TrashIcon weight="bold" size={14} />}
|
variant="secondary"
|
||||||
onClick={onBulkClose}
|
leftIcon={<DownloadSimpleIcon weight="bold" size={14} />}
|
||||||
>
|
onClick={onBulkExport}
|
||||||
{t('dict.bulk.closeAction')}
|
loading={isExporting}
|
||||||
</Button>
|
>
|
||||||
|
{t('dict.bulk.exportAction')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
leftIcon={<TrashIcon weight="bold" size={14} />}
|
||||||
|
onClick={onBulkClose}
|
||||||
|
>
|
||||||
|
{t('dict.bulk.closeAction')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordsPaginationProps = {
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
totalCount: number
|
||||||
|
pageSize: number
|
||||||
|
onPrev: () => void
|
||||||
|
onNext: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordsPagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
totalCount,
|
||||||
|
pageSize,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
}: RecordsPaginationProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const from = page * pageSize + 1
|
||||||
|
const to = Math.min((page + 1) * pageSize, totalCount)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3 px-2">
|
||||||
|
<span className="text-2xs text-carbon/70 tabular-nums">
|
||||||
|
{t('dict.page.range', { from, to, total: totalCount })}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
leftIcon={<CaretLeftIcon weight="bold" size={14} />}
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={page <= 0}
|
||||||
|
>
|
||||||
|
{t('dict.page.prev')}
|
||||||
|
</Button>
|
||||||
|
<span className="tabular-nums text-2xs text-carbon/70">
|
||||||
|
{t('dict.page.of', { cur: page + 1, total: totalPages })}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
rightIcon={<CaretRightIcon weight="bold" size={14} />}
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={page + 1 >= totalPages}
|
||||||
|
>
|
||||||
|
{t('dict.page.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk export selected: экспорт N записей в CSV. Используется в admin UI
|
||||||
|
* для отчётов "выгрузить эти 50 спутников". Лимит 500 — синхронно с bulk close.
|
||||||
|
*
|
||||||
|
* @param businessKeys список ключей для экспорта (1..500)
|
||||||
|
*/
|
||||||
|
public record BulkExportRequest(
|
||||||
|
@NotEmpty
|
||||||
|
@Size(max = 500, message = "bulk export принимает максимум 500 записей за запрос")
|
||||||
|
List<String> businessKeys) {}
|
||||||
+76
-1
@@ -1,5 +1,8 @@
|
|||||||
package cloud.nstart.terravault.ordinis.restapi.service;
|
package cloud.nstart.terravault.ordinis.restapi.service;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseRequest;
|
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseResponse;
|
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseResponse;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
@@ -7,8 +10,10 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk операции над records. Каждый record закрывается в своей транзакции
|
* Bulk операции над records. Каждый record закрывается в своей транзакции
|
||||||
@@ -26,9 +31,16 @@ public class BulkRecordService {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(BulkRecordService.class);
|
private static final Logger log = LoggerFactory.getLogger(BulkRecordService.class);
|
||||||
|
|
||||||
private final DictionaryRecordService recordService;
|
private final DictionaryRecordService recordService;
|
||||||
|
private final DictionaryDefinitionService definitionService;
|
||||||
|
private final DictionaryRecordRepository repository;
|
||||||
|
|
||||||
public BulkRecordService(DictionaryRecordService recordService) {
|
public BulkRecordService(
|
||||||
|
DictionaryRecordService recordService,
|
||||||
|
DictionaryDefinitionService definitionService,
|
||||||
|
DictionaryRecordRepository repository) {
|
||||||
this.recordService = recordService;
|
this.recordService = recordService;
|
||||||
|
this.definitionService = definitionService;
|
||||||
|
this.repository = repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,4 +86,67 @@ public class BulkRecordService {
|
|||||||
|
|
||||||
return new BulkCloseResponse(closed, skipped, errors);
|
return new BulkCloseResponse(closed, skipped, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экспорт N записей в CSV. Возвращает строку с заголовком + строками данных.
|
||||||
|
* Колонки: {@code business_key, valid_from, valid_to, scope, data} —
|
||||||
|
* последняя содержит весь JSONB как одну экранированную ячейку.
|
||||||
|
*
|
||||||
|
* <p>Решение писать data inline (не flatten'ить per-key): схемы разные между
|
||||||
|
* dictionaries, fixed columns не подходит для универсального экспорта.
|
||||||
|
* Получатель (Excel/jq/python) распарсит JSON ячейку у себя.
|
||||||
|
*
|
||||||
|
* <p>Только активные на now версии (не история) — для history использовать
|
||||||
|
* {@code GET /records/{key}/history}.
|
||||||
|
*
|
||||||
|
* @param dictionaryName словарь
|
||||||
|
* @param businessKeys список ключей (1..500)
|
||||||
|
* @return CSV string
|
||||||
|
*/
|
||||||
|
public String exportCsv(String dictionaryName, List<String> businessKeys) {
|
||||||
|
DictionaryDefinition def = definitionService.findByName(dictionaryName);
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
|
// Дедуп + skip blank — чтобы не делать N запросов на дублях
|
||||||
|
var seen = new java.util.LinkedHashSet<String>();
|
||||||
|
for (String bk : businessKeys) {
|
||||||
|
if (bk != null && !bk.isBlank()) seen.add(bk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index активных записей по business_key для O(1) lookup
|
||||||
|
Map<String, DictionaryRecord> active = new java.util.HashMap<>();
|
||||||
|
for (String bk : seen) {
|
||||||
|
repository.findActiveAt(def.getId(), bk, now).ifPresent(r -> active.put(bk, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder csv = new StringBuilder("business_key,valid_from,valid_to,scope,data\n");
|
||||||
|
// Сохраняем порядок входа (после dedup) — admin ожидает что строки в CSV
|
||||||
|
// совпадают с тем что он выбрал
|
||||||
|
for (String bk : seen) {
|
||||||
|
DictionaryRecord r = active.get(bk);
|
||||||
|
csv.append(csvCell(bk)).append(',');
|
||||||
|
if (r == null) {
|
||||||
|
// Запись не активна / не найдена — отдаём строку с пустыми ячейками,
|
||||||
|
// чтобы admin видел в CSV что бизнес-ключ обработан, но данных нет
|
||||||
|
csv.append(",,,\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
csv.append(r.getValidFrom() == null ? "" : r.getValidFrom().toString()).append(',')
|
||||||
|
.append(r.getValidTo() == null ? "" : r.getValidTo().toString()).append(',')
|
||||||
|
.append(r.getDataScope() == null ? "" : r.getDataScope().name()).append(',')
|
||||||
|
.append(csvCell(r.getData() == null ? "" : r.getData().toString()))
|
||||||
|
.append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Bulk export CSV в {} : requested={} found={}",
|
||||||
|
dictionaryName, seen.size(), active.size());
|
||||||
|
return csv.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 4180 экранирование: запятая/кавычка/перенос строки → quote-wrap. */
|
||||||
|
private static String csvCell(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
if (s.indexOf(',') < 0 && s.indexOf('"') < 0 && s.indexOf('\n') < 0) return s;
|
||||||
|
return "\"" + s.replace("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -4,6 +4,7 @@ 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.BulkCloseRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseResponse;
|
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseResponse;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.BulkExportRequest;
|
||||||
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;
|
||||||
@@ -110,6 +111,26 @@ public class DictionaryRecordController {
|
|||||||
return bulkService.bulkClose(name, req);
|
return bulkService.bulkClose(name, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk export: выгрузить N выбранных записей в CSV. POST (не GET) потому что
|
||||||
|
* списком >100 ключей URL ломается. Auth header attached через axios →
|
||||||
|
* frontend получает blob и триггерит download.
|
||||||
|
*
|
||||||
|
* <p>RequireReadAccess: для экспорта достаточно прав чтения.
|
||||||
|
*/
|
||||||
|
@PostMapping(value = "/export-selected.csv", produces = "text/csv")
|
||||||
|
public org.springframework.http.ResponseEntity<String> bulkExportCsv(
|
||||||
|
@PathVariable String name,
|
||||||
|
@Valid @RequestBody BulkExportRequest req) {
|
||||||
|
requireReadAccess(name);
|
||||||
|
String csv = bulkService.exportCsv(name, req.businessKeys());
|
||||||
|
String filename = name + "-selected-" + OffsetDateTime.now().toLocalDate() + ".csv";
|
||||||
|
return org.springframework.http.ResponseEntity.ok()
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||||
|
.header("Content-Type", "text/csv; charset=UTF-8")
|
||||||
|
.body(csv);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 404 если scope словаря недоступен текущему пользователю — намеренно скрываем
|
* 404 если scope словаря недоступен текущему пользователю — намеренно скрываем
|
||||||
* существование записи (security through obscurity), как делает read-api.
|
* существование записи (security through obscurity), как делает read-api.
|
||||||
|
|||||||
+10
-10
@@ -57,7 +57,7 @@ class BulkRecordServiceTest {
|
|||||||
void allSucceed_whenServiceClosesEachRecord() {
|
void allSucceed_whenServiceClosesEachRecord() {
|
||||||
List<String> calls = new ArrayList<>();
|
List<String> calls = new ArrayList<>();
|
||||||
DictionaryRecordService rs = routedStub(Map.of(), calls);
|
DictionaryRecordService rs = routedStub(Map.of(), calls);
|
||||||
BulkRecordService bulk = new BulkRecordService(rs);
|
BulkRecordService bulk = new BulkRecordService(rs, null, null);
|
||||||
|
|
||||||
BulkCloseResponse res = bulk.bulkClose(DICT,
|
BulkCloseResponse res = bulk.bulkClose(DICT,
|
||||||
new BulkCloseRequest(List.of("a", "b", "c"), null, "test reason"));
|
new BulkCloseRequest(List.of("a", "b", "c"), null, "test reason"));
|
||||||
@@ -74,7 +74,7 @@ class BulkRecordServiceTest {
|
|||||||
DictionaryRecordService rs = routedStub(
|
DictionaryRecordService rs = routedStub(
|
||||||
Map.of("b", OrdinisException.notFound("record_not_active", "уже закрыто")),
|
Map.of("b", OrdinisException.notFound("record_not_active", "уже закрыто")),
|
||||||
calls);
|
calls);
|
||||||
BulkRecordService bulk = new BulkRecordService(rs);
|
BulkRecordService bulk = new BulkRecordService(rs, null, null);
|
||||||
|
|
||||||
BulkCloseResponse res = bulk.bulkClose(DICT,
|
BulkCloseResponse res = bulk.bulkClose(DICT,
|
||||||
new BulkCloseRequest(List.of("a", "b", "c"), null, null));
|
new BulkCloseRequest(List.of("a", "b", "c"), null, null));
|
||||||
@@ -92,7 +92,7 @@ class BulkRecordServiceTest {
|
|||||||
DictionaryRecordService rs = routedStub(
|
DictionaryRecordService rs = routedStub(
|
||||||
Map.of("a", OrdinisException.forbidden("scope_insufficient", "нет прав")),
|
Map.of("a", OrdinisException.forbidden("scope_insufficient", "нет прав")),
|
||||||
calls);
|
calls);
|
||||||
BulkRecordService bulk = new BulkRecordService(rs);
|
BulkRecordService bulk = new BulkRecordService(rs, null, null);
|
||||||
|
|
||||||
BulkCloseResponse res = bulk.bulkClose(DICT,
|
BulkCloseResponse res = bulk.bulkClose(DICT,
|
||||||
new BulkCloseRequest(List.of("a"), null, null));
|
new BulkCloseRequest(List.of("a"), null, null));
|
||||||
@@ -110,7 +110,7 @@ class BulkRecordServiceTest {
|
|||||||
DictionaryRecordService rs = routedStub(
|
DictionaryRecordService rs = routedStub(
|
||||||
Map.of("a", new RuntimeException("DB connection lost")),
|
Map.of("a", new RuntimeException("DB connection lost")),
|
||||||
calls);
|
calls);
|
||||||
BulkRecordService bulk = new BulkRecordService(rs);
|
BulkRecordService bulk = new BulkRecordService(rs, null, null);
|
||||||
|
|
||||||
BulkCloseResponse res = bulk.bulkClose(DICT,
|
BulkCloseResponse res = bulk.bulkClose(DICT,
|
||||||
new BulkCloseRequest(List.of("a"), null, null));
|
new BulkCloseRequest(List.of("a"), null, null));
|
||||||
@@ -127,7 +127,7 @@ class BulkRecordServiceTest {
|
|||||||
void duplicates_silentlyDeduplicated() {
|
void duplicates_silentlyDeduplicated() {
|
||||||
List<String> calls = new ArrayList<>();
|
List<String> calls = new ArrayList<>();
|
||||||
DictionaryRecordService rs = routedStub(Map.of(), calls);
|
DictionaryRecordService rs = routedStub(Map.of(), calls);
|
||||||
BulkRecordService bulk = new BulkRecordService(rs);
|
BulkRecordService bulk = new BulkRecordService(rs, null, null);
|
||||||
|
|
||||||
BulkCloseResponse res = bulk.bulkClose(DICT,
|
BulkCloseResponse res = bulk.bulkClose(DICT,
|
||||||
new BulkCloseRequest(List.of("a", "a", "b", "a"), null, null));
|
new BulkCloseRequest(List.of("a", "a", "b", "a"), null, null));
|
||||||
@@ -143,7 +143,7 @@ class BulkRecordServiceTest {
|
|||||||
void blankBusinessKey_classifiedAsSkipped_invalidKey() {
|
void blankBusinessKey_classifiedAsSkipped_invalidKey() {
|
||||||
List<String> calls = new ArrayList<>();
|
List<String> calls = new ArrayList<>();
|
||||||
DictionaryRecordService rs = routedStub(Map.of(), calls);
|
DictionaryRecordService rs = routedStub(Map.of(), calls);
|
||||||
BulkRecordService bulk = new BulkRecordService(rs);
|
BulkRecordService bulk = new BulkRecordService(rs, null, null);
|
||||||
|
|
||||||
BulkCloseResponse res = bulk.bulkClose(DICT,
|
BulkCloseResponse res = bulk.bulkClose(DICT,
|
||||||
new BulkCloseRequest(java.util.Arrays.asList("a", null, "", " ", "b"), null, null));
|
new BulkCloseRequest(java.util.Arrays.asList("a", null, "", " ", "b"), null, null));
|
||||||
@@ -169,7 +169,7 @@ class BulkRecordServiceTest {
|
|||||||
capturedReason.add(reason);
|
capturedReason.add(reason);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
BulkRecordService bulk = new BulkRecordService(rs);
|
BulkRecordService bulk = new BulkRecordService(rs, null, null);
|
||||||
|
|
||||||
OffsetDateTime when = OffsetDateTime.parse("2026-01-01T00:00:00Z");
|
OffsetDateTime when = OffsetDateTime.parse("2026-01-01T00:00:00Z");
|
||||||
bulk.bulkClose(DICT, new BulkCloseRequest(List.of("a"), when, "scheduled deactivation"));
|
bulk.bulkClose(DICT, new BulkCloseRequest(List.of("a"), when, "scheduled deactivation"));
|
||||||
@@ -187,7 +187,7 @@ class BulkRecordServiceTest {
|
|||||||
"dup", OrdinisException.notFound("record_not_active", "x"),
|
"dup", OrdinisException.notFound("record_not_active", "x"),
|
||||||
"bad", new IllegalStateException("boom")),
|
"bad", new IllegalStateException("boom")),
|
||||||
calls);
|
calls);
|
||||||
BulkRecordService bulk = new BulkRecordService(rs);
|
BulkRecordService bulk = new BulkRecordService(rs, null, null);
|
||||||
|
|
||||||
BulkCloseResponse res = bulk.bulkClose(DICT,
|
BulkCloseResponse res = bulk.bulkClose(DICT,
|
||||||
new BulkCloseRequest(List.of("ok1", "dup", "bad", "ok2"), null, null));
|
new BulkCloseRequest(List.of("ok1", "dup", "bad", "ok2"), null, null));
|
||||||
@@ -203,7 +203,7 @@ class BulkRecordServiceTest {
|
|||||||
void emptyBatch_returnsEmptyResultWithoutCallingService() {
|
void emptyBatch_returnsEmptyResultWithoutCallingService() {
|
||||||
List<String> calls = new ArrayList<>();
|
List<String> calls = new ArrayList<>();
|
||||||
DictionaryRecordService rs = routedStub(Map.of(), calls);
|
DictionaryRecordService rs = routedStub(Map.of(), calls);
|
||||||
BulkRecordService bulk = new BulkRecordService(rs);
|
BulkRecordService bulk = new BulkRecordService(rs, null, null);
|
||||||
|
|
||||||
BulkCloseResponse res = bulk.bulkClose(DICT, new BulkCloseRequest(List.of(), null, null));
|
BulkCloseResponse res = bulk.bulkClose(DICT, new BulkCloseRequest(List.of(), null, null));
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ class BulkRecordServiceTest {
|
|||||||
void closeOrder_preservesInputOrder() {
|
void closeOrder_preservesInputOrder() {
|
||||||
List<String> calls = new ArrayList<>();
|
List<String> calls = new ArrayList<>();
|
||||||
DictionaryRecordService rs = routedStub(Map.of(), calls);
|
DictionaryRecordService rs = routedStub(Map.of(), calls);
|
||||||
BulkRecordService bulk = new BulkRecordService(rs);
|
BulkRecordService bulk = new BulkRecordService(rs, null, null);
|
||||||
|
|
||||||
BulkCloseResponse res = bulk.bulkClose(DICT,
|
BulkCloseResponse res = bulk.bulkClose(DICT,
|
||||||
new BulkCloseRequest(List.of("z", "m", "a"), null, null));
|
new BulkCloseRequest(List.of("z", "m", "a"), null, null));
|
||||||
|
|||||||
Reference in New Issue
Block a user