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:
+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;
|
||||
|
||||
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 как одну экранированную ячейку.
|
||||
*
|
||||
* <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.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.
|
||||
*
|
||||
* <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 словаря недоступен текущему пользователю — намеренно скрываем
|
||||
* существование записи (security through obscurity), как делает read-api.
|
||||
|
||||
+10
-10
@@ -57,7 +57,7 @@ class BulkRecordServiceTest {
|
||||
void allSucceed_whenServiceClosesEachRecord() {
|
||||
List<String> 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<String> 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<String> 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<String> 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<String> 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));
|
||||
|
||||
Reference in New Issue
Block a user