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
@@ -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.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.