diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index de75e79..a8a13a9 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -123,6 +123,41 @@ export const useBulkCloseRecords = (dictionaryName: string) => { }) } +/** + * Bulk export selected: выгрузить N выбранных записей в CSV. Backend + * возвращает text/csv blob с auth header, фронтенд триггерит download + * через temporary `` link. + * + * POST (не GET): >100 ключей не помещается в URL params. + */ +export const useBulkExportRecords = (dictionaryName: string) => { + return useMutation({ + mutationFn: async (businessKeys: string[]): Promise => { + 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 + 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) === export const useCreateWebhook = () => { diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 1aeb358..746bb37 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -153,6 +153,11 @@ i18n 'dict.bulk.selectedCount_other': 'Выбрано {{count}} из {{total}}', 'dict.bulk.clearSelection': 'Снять выделение', '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.confirmBody': '{{count}} запис(ей) будут помечены как закрытые (valid_to = now). Действие обратимо через создание новой версии. Каждая запись закрывается отдельно — если часть уже закрыта, остальные обработаются.', @@ -432,6 +437,11 @@ i18n 'dict.bulk.selectedCount_other': '{{count}} of {{total}} selected', 'dict.bulk.clearSelection': 'Clear selection', '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.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.', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 56eccb2..a9d9e57 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -22,9 +22,9 @@ import { TableRow, TextInput, } 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 { 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 { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' @@ -99,6 +99,7 @@ function DictionaryDetail() { const updateMut = useUpdateRecord(name) const closeMut = useCloseRecord(name) const bulkCloseMut = useBulkCloseRecords(name) + const bulkExportMut = useBulkExportRecords(name) const [edit, setEdit] = useState({ kind: 'closed' }) const [closeReason, setCloseReason] = useState('') @@ -189,11 +190,30 @@ function DictionaryDetail() { const filtersActive = search.length > 0 || scopeFilter.size > 0 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 // от ключей которые больше не видны (filter изменился, запись закрылась). + // С client-side pagination "видимые" = только текущая страница, чтобы + // select-all чекбокс трогал ровно то что юзер видит на экране. const visibleKeys = useMemo( - () => filteredRecords.map((r) => r.businessKey), - [filteredRecords], + () => visibleRecords.map((r) => r.businessKey), + [visibleRecords], ) // Когда видимый набор сократился — выкидываем из selection невидимые ключи. @@ -246,6 +266,11 @@ function DictionaryDetail() { setBulkOpen(true) } + const handleBulkExport = () => { + if (selection.size === 0) return + bulkExportMut.mutate(Array.from(selection)) + } + const handleBulkClose = () => { if (selection.size === 0) return bulkCloseMut.mutate( @@ -479,7 +504,9 @@ function DictionaryDetail() { count={selection.size} totalVisible={filteredCount} onBulkClose={openBulkClose} + onBulkExport={handleBulkExport} onClear={clearSelection} + isExporting={bulkExportMut.isPending} /> )} @@ -508,7 +535,7 @@ function DictionaryDetail() { - {filteredRecords.map((r) => ( + {visibleRecords.map((r) => ( + {totalPages > 1 && ( + setPage((p) => Math.max(0, p - 1))} + onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))} + /> + )} )} @@ -754,14 +791,18 @@ type BulkSelectionToolbarProps = { count: number totalVisible: number onBulkClose: () => void + onBulkExport: () => void onClear: () => void + isExporting: boolean } function BulkSelectionToolbar({ count, totalVisible, onBulkClose, + onBulkExport, onClear, + isExporting, }: BulkSelectionToolbarProps) { const { t } = useTranslation() return ( @@ -784,14 +825,77 @@ function BulkSelectionToolbar({ {t('dict.bulk.clearSelection')} - +
+ + +
+ + ) +} + +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 ( +
+ + {t('dict.page.range', { from, to, total: totalCount })} + +
+ + + {t('dict.page.of', { cur: page + 1, total: totalPages })} + + +
) } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkExportRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkExportRequest.java new file mode 100644 index 0000000..510deea --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/BulkExportRequest.java @@ -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 businessKeys) {} 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 index 1dc9700..30a9f0e 100644 --- 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 @@ -1,5 +1,8 @@ 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.BulkCloseResponse; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; @@ -7,8 +10,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * Bulk операции над records. Каждый record закрывается в своей транзакции @@ -26,9 +31,16 @@ public class BulkRecordService { private static final Logger log = LoggerFactory.getLogger(BulkRecordService.class); 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.definitionService = definitionService; + this.repository = repository; } /** @@ -74,4 +86,67 @@ public class BulkRecordService { return new BulkCloseResponse(closed, skipped, errors); } + + /** + * Экспорт N записей в CSV. Возвращает строку с заголовком + строками данных. + * Колонки: {@code business_key, valid_from, valid_to, scope, data} — + * последняя содержит весь JSONB как одну экранированную ячейку. + * + *

Решение писать data inline (не flatten'ить per-key): схемы разные между + * dictionaries, fixed columns не подходит для универсального экспорта. + * Получатель (Excel/jq/python) распарсит JSON ячейку у себя. + * + *

Только активные на now версии (не история) — для history использовать + * {@code GET /records/{key}/history}. + * + * @param dictionaryName словарь + * @param businessKeys список ключей (1..500) + * @return CSV string + */ + public String exportCsv(String dictionaryName, List businessKeys) { + DictionaryDefinition def = definitionService.findByName(dictionaryName); + OffsetDateTime now = OffsetDateTime.now(); + + // Дедуп + skip blank — чтобы не делать N запросов на дублях + var seen = new java.util.LinkedHashSet(); + for (String bk : businessKeys) { + if (bk != null && !bk.isBlank()) seen.add(bk); + } + + // Index активных записей по business_key для O(1) lookup + Map 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("\"", "\"\"") + "\""; + } } 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 86ebaea..04dd12b 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 @@ -4,6 +4,7 @@ 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.BulkExportRequest; import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest; import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; @@ -110,6 +111,26 @@ public class DictionaryRecordController { return bulkService.bulkClose(name, req); } + /** + * Bulk export: выгрузить N выбранных записей в CSV. POST (не GET) потому что + * списком >100 ключей URL ломается. Auth header attached через axios → + * frontend получает blob и триггерит download. + * + *

RequireReadAccess: для экспорта достаточно прав чтения. + */ + @PostMapping(value = "/export-selected.csv", produces = "text/csv") + public org.springframework.http.ResponseEntity 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 словаря недоступен текущему пользователю — намеренно скрываем * существование записи (security through obscurity), как делает read-api. diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordServiceTest.java index a7fea0b..d045923 100644 --- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordServiceTest.java +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/BulkRecordServiceTest.java @@ -57,7 +57,7 @@ class BulkRecordServiceTest { void allSucceed_whenServiceClosesEachRecord() { List calls = new ArrayList<>(); 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("a", "b", "c"), null, "test reason")); @@ -74,7 +74,7 @@ class BulkRecordServiceTest { DictionaryRecordService rs = routedStub( Map.of("b", OrdinisException.notFound("record_not_active", "уже закрыто")), calls); - BulkRecordService bulk = new BulkRecordService(rs); + BulkRecordService bulk = new BulkRecordService(rs, null, null); BulkCloseResponse res = bulk.bulkClose(DICT, new BulkCloseRequest(List.of("a", "b", "c"), null, null)); @@ -92,7 +92,7 @@ class BulkRecordServiceTest { DictionaryRecordService rs = routedStub( Map.of("a", OrdinisException.forbidden("scope_insufficient", "нет прав")), calls); - BulkRecordService bulk = new BulkRecordService(rs); + BulkRecordService bulk = new BulkRecordService(rs, null, null); BulkCloseResponse res = bulk.bulkClose(DICT, new BulkCloseRequest(List.of("a"), null, null)); @@ -110,7 +110,7 @@ class BulkRecordServiceTest { DictionaryRecordService rs = routedStub( Map.of("a", new RuntimeException("DB connection lost")), calls); - BulkRecordService bulk = new BulkRecordService(rs); + BulkRecordService bulk = new BulkRecordService(rs, null, null); BulkCloseResponse res = bulk.bulkClose(DICT, new BulkCloseRequest(List.of("a"), null, null)); @@ -127,7 +127,7 @@ class BulkRecordServiceTest { void duplicates_silentlyDeduplicated() { List calls = new ArrayList<>(); 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("a", "a", "b", "a"), null, null)); @@ -143,7 +143,7 @@ class BulkRecordServiceTest { void blankBusinessKey_classifiedAsSkipped_invalidKey() { List calls = new ArrayList<>(); 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(java.util.Arrays.asList("a", null, "", " ", "b"), null, null)); @@ -169,7 +169,7 @@ class BulkRecordServiceTest { capturedReason.add(reason); } }; - BulkRecordService bulk = new BulkRecordService(rs); + BulkRecordService bulk = new BulkRecordService(rs, null, null); OffsetDateTime when = OffsetDateTime.parse("2026-01-01T00:00:00Z"); bulk.bulkClose(DICT, new BulkCloseRequest(List.of("a"), when, "scheduled deactivation")); @@ -187,7 +187,7 @@ class BulkRecordServiceTest { "dup", OrdinisException.notFound("record_not_active", "x"), "bad", new IllegalStateException("boom")), calls); - BulkRecordService bulk = new BulkRecordService(rs); + BulkRecordService bulk = new BulkRecordService(rs, null, null); BulkCloseResponse res = bulk.bulkClose(DICT, new BulkCloseRequest(List.of("ok1", "dup", "bad", "ok2"), null, null)); @@ -203,7 +203,7 @@ class BulkRecordServiceTest { void emptyBatch_returnsEmptyResultWithoutCallingService() { List calls = new ArrayList<>(); 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)); @@ -217,7 +217,7 @@ class BulkRecordServiceTest { void closeOrder_preservesInputOrder() { List calls = new ArrayList<>(); 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("z", "m", "a"), null, null));