feat(approval): Approval Workflow v2 Phase 0 — schema + skeleton API

Approval Workflow v2 epic, Phase 0 foundation. Maker-checker pattern для
критичных справочников через record_drafts отдельную таблицу (Approach A
из design doc).

Decisions made (auto, per design doc recommendations):
- D1=A: per-dict policy через колонки на dictionary_definitions
  (approval_required + approval_min_role).
- D2=A: legacy POST на approval-required dict вернёт 409 (Phase 1 wire'ит
  в DictionaryRecordService).
- D3=A: pool model для reviewer'ов — все ordinis:approver видят queue.
- D4=A: rejected drafts kept (status='rejected'); compliance audit trail.

Migration 0019:
- record_drafts table:
  * id, dictionary_id, business_key, parent_record_id, operation
  * data (jsonb), geometry, proposed_valid_from/to
  * status, maker_id, maker_comment, submitted_at
  * reviewer_id, reviewed_at, review_comment, version
- 4 constraints:
  * status_check: pending/approved/rejected/withdrawn
  * operation_check: CREATE/UPDATE/CLOSE
  * no_self_approve: maker_id != reviewer_id
  * one_pending_per_key: EXCLUDE WHERE status=pending
    (concurrent edits → последний wins на submit, prevents merge conflicts)
- 3 indices: pending_by_dict, by_maker, pending_global.
- dictionary_definitions: +approval_required (BOOLEAN, default false),
  +approval_min_role (VARCHAR 64, optional Keycloak role).

Backend:
- Entity RecordDraft + Repository с JPQL queries для:
  * findPendingByKey (single pending lookup)
  * findPending (global queue для approver pool, paginated)
  * findPendingByDictionary (per-dict list)
  * findByMaker (own drafts tracking, paginated)
- DTO SubmitDraftRequest, DraftResponse.
- DraftService skeleton:
  * submit() — creates pending draft. 409 на exclusion violation.
  * findById, listPendingByDictionary, listPendingGlobal — read-only.
  * approve / reject / withdraw / updateDraft — Phase 1 stubs (комментарии).
- DraftController с 4 endpoints:
  * POST /api/v1/dictionaries/{name}/records/drafts — submit
  * GET /api/v1/dictionaries/{name}/records/drafts — list per dict
  * GET /api/v1/drafts/{id} — single draft
  * GET /api/v1/admin/reviews/pending — global queue (paginated)

Tests:
- DraftServiceTest 6 unit tests:
  * submit creates with PENDING status
  * 409 на duplicate pending business_key (exclusion constraint)
  * invalid WKT → 400
  * dict not found → 404
  * findById unknown → 404
  * listPendingByDictionary returns multiple pending
- StubDraftRepo simulates DB exclusion constraint в memory.

Verify:
- mvn -pl ordinis-rest-api -am test: 112/112 PASS (+6 new).
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS — migration 0019
  applies clean, no regression.
- helm/yaml configs не трогали — Phase 0 чистый schema + code.

Phase 1 (next session) — full lifecycle:
- DraftService.approve(): SERIALIZABLE tx с COPY draft → live + outbox
  RecordCreated/Updated/Deleted event + audit. Validate against schema
  ON APPROVE (могла schema измениться с момента submit'а).
- reject() — mark status=rejected, kept (D4).
- withdraw() — maker отзывает свой pending.
- DictionaryRecordService.create/update/close: pre-check
  approval_required → 409 draft_required. JWT subject → maker_id.
- Reviewer queue + diff viewer admin UI.

Source: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
This commit is contained in:
Zimin A.N.
2026-05-08 13:08:21 +03:00
parent 48df5ceeb8
commit e438c4c8c8
12 changed files with 930 additions and 0 deletions
@@ -0,0 +1,52 @@
package cloud.nstart.terravault.ordinis.restapi.dto.draft;
import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation;
import cloud.nstart.terravault.ordinis.domain.draft.DraftStatus;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraft;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import org.locationtech.jts.io.WKTWriter;
import java.time.OffsetDateTime;
import java.util.UUID;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record DraftResponse(
UUID id,
UUID dictionaryId,
String businessKey,
UUID parentRecordId,
DraftOperation operation,
JsonNode data,
String geometryWkt,
OffsetDateTime proposedValidFrom,
OffsetDateTime proposedValidTo,
DraftStatus status,
String makerId,
String makerComment,
OffsetDateTime submittedAt,
String reviewerId,
OffsetDateTime reviewedAt,
String reviewComment) {
public static DraftResponse from(RecordDraft d) {
String wkt = d.getGeometry() == null ? null : new WKTWriter().write(d.getGeometry());
return new DraftResponse(
d.getId(),
d.getDictionaryId(),
d.getBusinessKey(),
d.getParentRecordId(),
d.getOperation(),
d.getData(),
wkt,
d.getProposedValidFrom(),
d.getProposedValidTo(),
d.getStatus(),
d.getMakerId(),
d.getMakerComment(),
d.getSubmittedAt(),
d.getReviewerId(),
d.getReviewedAt(),
d.getReviewComment());
}
}
@@ -0,0 +1,25 @@
package cloud.nstart.terravault.ordinis.restapi.dto.draft;
import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.OffsetDateTime;
/**
* Maker submits a proposed change. Approval workflow v2 (D2=A: legacy POST на
* approval-required dict вернёт 409 с hint'ом use this endpoint).
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SubmitDraftRequest(
@NotBlank String businessKey,
@NotNull DraftOperation operation,
/** CREATE/UPDATE: новый payload. CLOSE: null OK. */
JsonNode data,
String geometryWkt,
OffsetDateTime validFrom,
OffsetDateTime validTo,
/** Optional maker note. */
String comment) {}
@@ -0,0 +1,157 @@
package cloud.nstart.terravault.ordinis.restapi.service.draft;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation;
import cloud.nstart.terravault.ordinis.domain.draft.DraftStatus;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraft;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraftRepository;
import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
/**
* Approval workflow v2 — Phase 0 skeleton.
*
* <p>Текущий scope: submit + list + get (read-only operations + new draft
* insert). Полный lifecycle (approve / reject / withdraw + COPY-on-approve)
* — Phase 1.
*
* <p>Decisions (auto, per design doc):
* <ul>
* <li>D1=A: per-dictionary policy через колонки на dictionary_definitions.</li>
* <li>D2=A: existing endpoints вернут 409 если approval_required=true
* (handled в DictionaryRecordService — TBD Phase 1).</li>
* <li>D3=A: pool model для reviewer'ов (нет explicit assignment).</li>
* <li>D4=A: rejected drafts kept (status=rejected, kept в таблице).</li>
* </ul>
*/
@Service
public class DraftService {
private static final Logger log = LoggerFactory.getLogger(DraftService.class);
private final RecordDraftRepository draftRepo;
private final DictionaryDefinitionService definitionService;
private final ScopeContext scopeContext;
public DraftService(
RecordDraftRepository draftRepo,
DictionaryDefinitionService definitionService,
ScopeContext scopeContext) {
this.draftRepo = draftRepo;
this.definitionService = definitionService;
this.scopeContext = scopeContext;
}
/**
* Submit новой draft. Maker = current authenticated user.
*
* <p>Pre-conditions:
* <ul>
* <li>Dict существует и виден caller'у в scope.</li>
* <li>Нет другого pending draft на этот business_key (DB exclusion
* constraint обеспечивает; конфликт = 409).</li>
* </ul>
*
* <p>Validation схемы — Phase 1 (нужен RecordValidator + ReferenceValidator
* inject + apply on submit). Для skeleton принимаем data как-есть.
*/
@Transactional
public RecordDraft submit(String dictionaryName, SubmitDraftRequest req) {
DictionaryDefinition def = definitionService.findByName(dictionaryName);
String makerId = currentUserId();
var draft = new RecordDraft(
UUID.randomUUID(),
def.getId(),
req.businessKey(),
req.operation(),
makerId);
draft.setData(req.data());
draft.setGeometry(parseWkt(req.geometryWkt()));
draft.setProposedValidFrom(req.validFrom());
draft.setProposedValidTo(req.validTo());
draft.setMakerComment(req.comment());
try {
RecordDraft saved = draftRepo.saveAndFlush(draft);
log.info("Draft submitted: dict={} bk={} op={} maker={}",
dictionaryName, req.businessKey(), req.operation(), makerId);
return saved;
} catch (DataIntegrityViolationException ex) {
// record_drafts_one_pending_per_key — exclusion violation.
throw OrdinisException.conflict(
"draft_already_pending",
"На запись business_key=" + req.businessKey() + " уже есть pending draft.");
}
}
@Transactional(readOnly = true)
public RecordDraft findById(UUID id) {
return draftRepo.findById(id)
.orElseThrow(() -> OrdinisException.notFound(
"draft_not_found", "Draft not found: " + id));
}
@Transactional(readOnly = true)
public List<RecordDraft> listPendingByDictionary(String dictionaryName) {
DictionaryDefinition def = definitionService.findByName(dictionaryName);
return draftRepo.findPendingByDictionary(def.getId());
}
@Transactional(readOnly = true)
public Page<RecordDraft> listPendingGlobal(Pageable pageable) {
return draftRepo.findPending(pageable);
}
// === Phase 1 stubs (will be implemented next) ===
// approve(UUID draftId, String reviewComment) — checker action; COPY → live + mark approved + delete? TBD.
// reject(UUID draftId, String reviewComment) — checker action; mark rejected, kept (D4=A).
// withdraw(UUID draftId) — maker отзывает свой draft; mark withdrawn.
// updateDraft(UUID draftId, ...) — maker editing своего pending draft.
// === Internals ===
/** Resolve current user from JWT subject. Если нет auth — fallback "anonymous". */
private String currentUserId() {
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext()
.getAuthentication();
if (auth == null || !auth.isAuthenticated()) return "anonymous";
return auth.getName();
}
private static Geometry parseWkt(String wkt) {
if (wkt == null || wkt.isBlank()) return null;
try {
Geometry g = new WKTReader().read(wkt);
g.setSRID(4326);
return g;
} catch (ParseException e) {
throw OrdinisException.badRequest(
"invalid_geometry_wkt", "Cannot parse WKT: " + e.getMessage());
}
}
/** ScopeContext kept for future scope-filter Phase 1 (когда draft list
* может ограничиваться по scope). Suppress unused warning сейчас. */
@SuppressWarnings("unused")
private ScopeContext scopeContext() { return scopeContext; }
/** Convenience overload: всё что нужно от datetime. */
static OffsetDateTime nowUtc() { return OffsetDateTime.now(); }
}
@@ -0,0 +1,82 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.restapi.dto.draft.DraftResponse;
import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.service.draft.DraftService;
import jakarta.validation.Valid;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Approval workflow draft endpoints — Phase 0 skeleton.
*
* <p>Submit + list per dict + global queue + get by id.
* Approve / reject / withdraw / update — Phase 1.
*
* <p>Дизайн doc: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
*/
@RestController
public class DraftController {
private final DraftService draftService;
public DraftController(DraftService draftService) {
this.draftService = draftService;
}
/**
* Maker submits новый proposal. Returns 201 Created с full draft response.
* Если pending draft на этот business_key уже есть → 409 draft_already_pending.
*/
@PostMapping("/api/v1/dictionaries/{name}/records/drafts")
@ResponseStatus(HttpStatus.CREATED)
public DraftResponse submit(
@PathVariable String name,
@Valid @RequestBody SubmitDraftRequest req) {
return DraftResponse.from(draftService.submit(name, req));
}
/** Per-dictionary pending list. */
@RequestMapping(method = org.springframework.web.bind.annotation.RequestMethod.GET,
value = "/api/v1/dictionaries/{name}/records/drafts")
public List<DraftResponse> listByDict(@PathVariable String name) {
return draftService.listPendingByDictionary(name).stream()
.map(DraftResponse::from)
.toList();
}
/** Single draft по id (для drilling в reviewer queue). */
@GetMapping("/api/v1/drafts/{id}")
public DraftResponse getById(@PathVariable UUID id) {
return DraftResponse.from(draftService.findById(id));
}
/**
* Global reviewer queue (D3=A pool model). Все pending drafts отсортированы
* by submitted_at ASC (oldest first — fairness).
*/
@GetMapping("/api/v1/admin/reviews/pending")
public Map<String, Object> globalQueue(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
var pg = draftService.listPendingGlobal(PageRequest.of(page, Math.min(size, 200)));
return Map.of(
"items", pg.getContent().stream().map(DraftResponse::from).toList(),
"page", pg.getNumber(),
"size", pg.getSize(),
"totalElements", pg.getTotalElements(),
"totalPages", pg.getTotalPages());
}
}
@@ -0,0 +1,253 @@
package cloud.nstart.terravault.ordinis.restapi.service.draft;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation;
import cloud.nstart.terravault.ordinis.domain.draft.DraftStatus;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraft;
import cloud.nstart.terravault.ordinis.domain.draft.RecordDraftRepository;
import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests {@link DraftService} Phase 0 skeleton.
*
* <p>JDK 25 + Mockito incompat для concrete classes — используем anonymous
* subclass для DictionaryDefinitionService и in-memory stub repository.
*/
class DraftServiceTest {
private static final ObjectMapper OM = new ObjectMapper();
private final UUID dictId = UUID.randomUUID();
private DictionaryDefinitionService defService;
private StubDraftRepo draftRepo;
private ScopeContext scopeContext;
private DraftService service;
@BeforeEach
void setUp() {
DictionaryDefinition def = new DictionaryDefinition(
dictId, "test_dict", DataScope.PUBLIC, OM.createObjectNode());
def.setApprovalRequired(true);
defService = new DictionaryDefinitionService(null, null) {
@Override
public DictionaryDefinition findByName(String name) {
if ("test_dict".equals(name)) return def;
throw OrdinisException.notFound("dictionary_not_found", "no: " + name);
}
};
draftRepo = new StubDraftRepo();
scopeContext = new ScopeContext(null) {
@Override
public Set<DataScope> currentScopes() {
return Set.of(DataScope.PUBLIC);
}
};
service = new DraftService(draftRepo, defService, scopeContext);
}
@Test
void submit_createsDraftWithPendingStatus() throws Exception {
var req = new SubmitDraftRequest(
"BK-001", DraftOperation.CREATE,
OM.readTree("{\"name\":\"test\"}"),
null, null, null, "initial submit");
RecordDraft saved = service.submit("test_dict", req);
assertThat(saved.getStatus()).isEqualTo(DraftStatus.PENDING);
assertThat(saved.getDictionaryId()).isEqualTo(dictId);
assertThat(saved.getBusinessKey()).isEqualTo("BK-001");
assertThat(saved.getOperation()).isEqualTo(DraftOperation.CREATE);
assertThat(saved.getMakerComment()).isEqualTo("initial submit");
// makerId = "anonymous" если auth context пустой (test env).
assertThat(saved.getMakerId()).isEqualTo("anonymous");
assertThat(saved.getSubmittedAt()).isNotNull();
}
@Test
void submit_409WhenPendingExists() throws Exception {
var req = new SubmitDraftRequest(
"BK-001", DraftOperation.CREATE,
OM.readTree("{}"), null, null, null, null);
service.submit("test_dict", req); // first OK
// Stub raises DataIntegrityViolationException на second insert с тем же business_key.
assertThatThrownBy(() -> service.submit("test_dict", req))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("draft_already_pending"));
}
@Test
void submit_invalidGeometryWkt_throwsBadRequest() throws Exception {
var req = new SubmitDraftRequest(
"BK-001", DraftOperation.CREATE,
OM.readTree("{}"),
"NOT-A-WKT-STRING",
null, null, null);
assertThatThrownBy(() -> service.submit("test_dict", req))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("invalid_geometry_wkt"));
}
@Test
void submit_dictNotFound_throwsNotFound() throws Exception {
var req = new SubmitDraftRequest(
"BK-001", DraftOperation.CREATE,
OM.readTree("{}"), null, null, null, null);
assertThatThrownBy(() -> service.submit("missing_dict", req))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("dictionary_not_found"));
}
@Test
void findById_unknownThrowsNotFound() {
UUID id = UUID.randomUUID();
assertThatThrownBy(() -> service.findById(id))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("draft_not_found"));
}
@Test
void listPendingByDictionary_returnsOnlyPendingForThisDict() throws Exception {
service.submit("test_dict", new SubmitDraftRequest(
"BK-001", DraftOperation.CREATE, OM.readTree("{}"),
null, null, null, null));
// Another business_key — should also be pending.
service.submit("test_dict", new SubmitDraftRequest(
"BK-002", DraftOperation.CREATE, OM.readTree("{}"),
null, null, null, null));
var list = service.listPendingByDictionary("test_dict");
assertThat(list).hasSize(2);
assertThat(list).allSatisfy(d ->
assertThat(d.getStatus()).isEqualTo(DraftStatus.PENDING));
}
// === In-memory stub repo ===
/** Mimics DB exclusion constraint: one_pending_per_key per (dict, key). */
private static class StubDraftRepo implements RecordDraftRepository {
private final java.util.Map<UUID, RecordDraft> store = new java.util.LinkedHashMap<>();
@Override
public RecordDraft saveAndFlush(RecordDraft draft) {
// Simulate exclusion constraint.
boolean conflict = store.values().stream().anyMatch(d ->
d.getStatus() == DraftStatus.PENDING
&& d.getDictionaryId().equals(draft.getDictionaryId())
&& d.getBusinessKey().equals(draft.getBusinessKey())
&& !d.getId().equals(draft.getId()));
if (conflict) {
throw new DataIntegrityViolationException(
"record_drafts_one_pending_per_key");
}
store.put(draft.getId(), draft);
return draft;
}
@Override
public <S extends RecordDraft> S save(S entity) {
saveAndFlush(entity);
return entity;
}
@Override
public Optional<RecordDraft> findById(UUID id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Optional<RecordDraft> findPendingByKey(UUID dictionaryId, String businessKey) {
return store.values().stream()
.filter(d -> d.getStatus() == DraftStatus.PENDING
&& d.getDictionaryId().equals(dictionaryId)
&& d.getBusinessKey().equals(businessKey))
.findFirst();
}
@Override
public org.springframework.data.domain.Page<RecordDraft> findPending(
org.springframework.data.domain.Pageable pageable) {
var list = store.values().stream()
.filter(d -> d.getStatus() == DraftStatus.PENDING)
.toList();
return new org.springframework.data.domain.PageImpl<>(list, pageable, list.size());
}
@Override
public java.util.List<RecordDraft> findPendingByDictionary(UUID dictionaryId) {
return store.values().stream()
.filter(d -> d.getStatus() == DraftStatus.PENDING
&& d.getDictionaryId().equals(dictionaryId))
.toList();
}
@Override
public org.springframework.data.domain.Page<RecordDraft> findByMaker(
String makerId, org.springframework.data.domain.Pageable pageable) {
var list = store.values().stream()
.filter(d -> d.getMakerId().equals(makerId))
.toList();
return new org.springframework.data.domain.PageImpl<>(list, pageable, list.size());
}
// Unused JpaRepository methods stubbed.
@Override public java.util.List<RecordDraft> findAll() { return store.values().stream().toList(); }
@Override public java.util.List<RecordDraft> findAll(org.springframework.data.domain.Sort sort) { return findAll(); }
@Override public org.springframework.data.domain.Page<RecordDraft> findAll(org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException(); }
@Override public java.util.List<RecordDraft> findAllById(Iterable<UUID> ids) { throw new UnsupportedOperationException(); }
@Override public long count() { return store.size(); }
@Override public void deleteById(UUID id) { store.remove(id); }
@Override public void delete(RecordDraft entity) { store.remove(entity.getId()); }
@Override public void deleteAllById(Iterable<? extends UUID> ids) { ids.forEach(store::remove); }
@Override public void deleteAll(Iterable<? extends RecordDraft> entities) { entities.forEach(this::delete); }
@Override public void deleteAll() { store.clear(); }
@Override public boolean existsById(UUID id) { return store.containsKey(id); }
@Override public <S extends RecordDraft> java.util.List<S> saveAll(Iterable<S> entities) {
java.util.List<S> out = new java.util.ArrayList<>();
for (S e : entities) out.add(save(e));
return out;
}
@Override public <S extends RecordDraft> java.util.List<S> saveAllAndFlush(Iterable<S> entities) { return saveAll(entities); }
@Override public void flush() {}
@Override public void deleteAllInBatch(Iterable<RecordDraft> entities) { deleteAll(entities); }
@Override public void deleteAllByIdInBatch(Iterable<UUID> ids) { deleteAllById(ids); }
@Override public void deleteAllInBatch() { deleteAll(); }
@Override public RecordDraft getOne(UUID id) { return store.get(id); }
@Override public RecordDraft getById(UUID id) { return store.get(id); }
@Override public RecordDraft getReferenceById(UUID id) { return store.get(id); }
@Override public <S extends RecordDraft> java.util.List<S> findAll(org.springframework.data.domain.Example<S> example) { throw new UnsupportedOperationException(); }
@Override public <S extends RecordDraft> java.util.List<S> findAll(org.springframework.data.domain.Example<S> example, org.springframework.data.domain.Sort sort) { throw new UnsupportedOperationException(); }
@Override public <S extends RecordDraft> org.springframework.data.domain.Page<S> findAll(org.springframework.data.domain.Example<S> example, org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException(); }
@Override public <S extends RecordDraft> long count(org.springframework.data.domain.Example<S> example) { return 0; }
@Override public <S extends RecordDraft> boolean exists(org.springframework.data.domain.Example<S> example) { return false; }
@Override public <S extends RecordDraft> Optional<S> findOne(org.springframework.data.domain.Example<S> example) { return Optional.empty(); }
@Override public <S extends RecordDraft, R> R findBy(org.springframework.data.domain.Example<S> example, java.util.function.Function<org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery<S>, R> queryFunction) { throw new UnsupportedOperationException(); }
}
/** ScopeContext getter — match service's protected accessor through field. */
@SuppressWarnings("unused")
private final AtomicReference<Object> _silenceUnusedScope = new AtomicReference<>();
}