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:
+24
@@ -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) {}
|
||||
+22
@@ -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) {}
|
||||
}
|
||||
+77
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -2,9 +2,12 @@ package cloud.nstart.terravault.ordinis.restapi.web;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
||||
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.BulkCloseResponse;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse;
|
||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.BulkRecordService;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -28,14 +31,17 @@ import java.util.List;
|
||||
public class DictionaryRecordController {
|
||||
|
||||
private final DictionaryRecordService service;
|
||||
private final BulkRecordService bulkService;
|
||||
private final DictionaryDefinitionService definitionService;
|
||||
private final ScopeContext scopeContext;
|
||||
|
||||
public DictionaryRecordController(
|
||||
DictionaryRecordService service,
|
||||
BulkRecordService bulkService,
|
||||
DictionaryDefinitionService definitionService,
|
||||
ScopeContext scopeContext) {
|
||||
this.service = service;
|
||||
this.bulkService = bulkService;
|
||||
this.definitionService = definitionService;
|
||||
this.scopeContext = scopeContext;
|
||||
}
|
||||
@@ -91,6 +97,19 @@ public class DictionaryRecordController {
|
||||
service.close(name, businessKey, at, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk close: закрыть N записей одним запросом. Per-record транзакция —
|
||||
* partial success возможен (e.g. 7 из 10 закрылись, 3 уже были закрыты).
|
||||
* Используется multi-select toolbar в admin UI.
|
||||
*/
|
||||
@PostMapping("/bulk-close")
|
||||
public BulkCloseResponse bulkClose(
|
||||
@PathVariable String name,
|
||||
@Valid @RequestBody BulkCloseRequest req) {
|
||||
requireWriteAccess(name);
|
||||
return bulkService.bulkClose(name, req);
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 если scope словаря недоступен текущему пользователю — намеренно скрываем
|
||||
* существование записи (security through obscurity), как делает read-api.
|
||||
|
||||
Reference in New Issue
Block a user